155 lines
11 KiB
Markdown
155 lines
11 KiB
Markdown
# Superior Finance — Notes
|
|
|
|
Running notes on how the Flask/SQLAlchemy models in `app.py` fit together.
|
|
|
|
## Database connection
|
|
|
|
```python
|
|
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///info.db'
|
|
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
|
|
```
|
|
|
|
## Model relationships
|
|
|
|
### `Goal` ↔ `BabyStep`
|
|
|
|
```python
|
|
babysteps = db.relationship('BabyStep', backref='goal', lazy=True, cascade='all, delete-orphan')
|
|
```
|
|
|
|
Lives on the `Goal` model and defines the one-to-many link between a `Goal` and its `BabyStep` records (which have a `goal_id` foreign key at `app.py:74`).
|
|
|
|
- **`babysteps = db.relationship('BabyStep', ...)`** — creates an attribute `goal.babysteps` that gives you a list of all `BabyStep` rows pointing at this goal. It's not a database column itself; SQLAlchemy figures out the join using the `goal_id` foreign key on `BabyStep`.
|
|
- **`'BabyStep'` (string, not the class)** — the target model, passed as a string because `BabyStep` is defined after `Goal` in the file, so the class doesn't exist yet at this point. SQLAlchemy resolves the string lazily once both models are registered.
|
|
- **`backref='goal'`** — automatically adds the reverse attribute to `BabyStep`, so you also get `babystep.goal` to walk from a step back to its parent goal, without defining it explicitly on the `BabyStep` class.
|
|
- **`lazy=True`** — controls when the related rows are fetched. `True` (a.k.a. `'select'`) means accessing `goal.babysteps` triggers a separate `SELECT` query at that moment, rather than eagerly joining it in with the original query for `Goal`.
|
|
- **`cascade='all, delete-orphan'`** — controls what happens to child rows when the parent changes:
|
|
- `all` cascades all operations (save, update, merge, delete, etc.) from `Goal` to its babysteps.
|
|
- `delete-orphan` means if a `BabyStep` is removed from `goal.babysteps` (or the parent `Goal` is deleted), that `BabyStep` row is deleted too, rather than left orphaned with a dangling `goal_id`.
|
|
|
|
In short: each `Goal` owns a list of `BabySteps`; deleting a goal (or unlinking a step from it) deletes those steps too. It gives `goal.babysteps` and `babystep.goal` for navigating both directions, and is used at `app.py:58` to sum up `saved_amount` across all baby steps for a goal's progress calculation.
|
|
|
|
### `BabyStep` ↔ `Payment`
|
|
|
|
```python
|
|
payments = db.relationship('Payment', backref='babystep', lazy=True, cascade='all, delete-orphan')
|
|
```
|
|
|
|
The same pattern one rung down. Lives on `BabyStep` and links each baby step to the `Payment` rows that fund it, using the `babystep_id` foreign key at `app.py:94`.
|
|
|
|
- **`payments = db.relationship('Payment', ...)`** — adds `babystep.payments`, a list of every `Payment` whose `babystep_id` points at this step. `'Payment'` is a string for the same reason `'BabyStep'` was above: `Payment` is defined further down the file, so SQLAlchemy resolves it lazily.
|
|
- **`backref='babystep'`** — adds the reverse pointer, `payment.babystep`, without writing it explicitly on the `Payment` class.
|
|
- **`lazy=True`** — accessing `babystep.payments` fires a separate `SELECT` at that moment, rather than joining it in eagerly.
|
|
- **`cascade='all, delete-orphan'`** — deleting a `BabyStep` (or removing a `Payment` from its list) deletes the orphaned `Payment` rows too, instead of leaving a dangling `babystep_id`.
|
|
|
|
Used at `app.py:80` the same way `babysteps` is used at `app.py:58` — summing a child field into a running total. `BabyStep.saved_amount` sums `payment.amount`, and that feeds `Goal.saved_amount`, which sums `step.saved_amount` across baby steps. Two identical aggregation rungs, stacked.
|
|
|
|
Both relationships are structurally identical, just one level apart: `Goal ⇄ BabyStep` at `app.py:50` and `BabyStep ⇄ Payment` at `app.py:76`. Once one makes sense, the other is a rename.
|
|
|
|
Diagram of the full cascade: https://claude.ai/code/artifact/57f98b9d-efa0-468e-a81a-614b61134d8a
|
|
|
|
## `@property` basics
|
|
|
|
`@property` is a Python-level feature, not Flask/SQLAlchemy-specific.
|
|
|
|
**What it does:** it lets a method be accessed like a plain attribute — no parentheses. Without it, you'd write `goal.saved_amount()`; with it, you write `goal.saved_amount`.
|
|
|
|
**Why use it here instead of just a regular method?**
|
|
|
|
- **It's computed, not stored.** `saved_amount`, `progress_percent`, and `image_list` (the `@property` items in this codebase) aren't real database columns — there's no `saved_amount = db.Column(...)` anywhere. They're derived from other data every time you ask for them (sum of payments, parsed JSON, etc.). `@property` is Python's way of saying "this looks like a piece of data on the object, but it's actually calculated on demand."
|
|
- **Consistency with real columns.** In a template like `templates/goal.html`, you write `{{ goal.saved_amount }}` right next to `{{ goal.title }}` or `{{ goal.goal_amount }}`. Without `@property`, the calculated ones would need parentheses (`{{ goal.saved_amount() }}`) while the real columns wouldn't — an inconsistent, easy-to-forget distinction. `@property` hides that difference so callers don't need to know or care which fields are stored and which are derived.
|
|
- **It can't accidentally go stale.** Since it recalculates from `self.payments`/`self.babysteps` every access, you never have a cached `saved_amount` number that drifts out of sync after a new payment is added — there's nothing to remember to update. The tradeoff is it recomputes (re-sums, re-queries) every single time it's accessed, which is fine at this scale but wouldn't be free on a huge dataset.
|
|
- **The alternative it's avoiding:** a plain method `get_saved_amount(self)` called explicitly everywhere. `@property` is purely ergonomic — it doesn't add capability, it just lets computed values read exactly like stored attributes, which is idiomatic Python for "this is a value the object has," even when that value is calculated rather than stored.
|
|
|
|
## Computed properties
|
|
|
|
### `BabyStep.saved_amount`
|
|
|
|
```python
|
|
@property
|
|
def saved_amount(self):
|
|
return sum(payment.amount for payment in self.payments)
|
|
```
|
|
|
|
A companion to `Goal.saved_amount` (below), one level down.
|
|
|
|
- **`@property`** — turns the method into something accessed like an attribute rather than called like a function: `babystep.saved_amount`, not `babystep.saved_amount()`. Computed fresh every access, not stored as a column.
|
|
- **`def saved_amount(self):`** — takes only `self`, which is required for `@property` — a property can only take `self`.
|
|
- **`self.payments`** — comes from the `payments = db.relationship(...)` line at `app.py:76`. It's the list of all `Payment` rows linked to this baby step.
|
|
- **`payment.amount for payment in self.payments`** — a generator expression pulling `amount` out of every payment tied to this baby step.
|
|
- **`sum(...)`** — adds all those amounts together.
|
|
|
|
So `babystep.saved_amount` returns the total dollars paid toward that baby step, by summing all its associated `Payment` records. This is exactly what `Goal.saved_amount` (`app.py:57-58`) then sums again one level up — `sum(step.saved_amount for step in self.babysteps)` — to get the total saved toward the whole goal. It's a two-level rollup: payments → baby step total → goal total.
|
|
|
|
### `BabyStep.progress_percent`
|
|
|
|
```python
|
|
@property
|
|
def progress_percent(self):
|
|
if not self.monthly_amount:
|
|
return 0
|
|
return min(100, round(self.saved_amount / self.monthly_amount * 100))
|
|
```
|
|
|
|
The `BabyStep` counterpart to `Goal.progress_percent` (`app.py:60-64`), living right after `saved_amount` (`app.py:82-86`).
|
|
|
|
- **`@property`** — same as above, lets you call it as `babystep.progress_percent` instead of `babystep.progress_percent()`.
|
|
- **`if not self.monthly_amount: return 0`** — a guard against division by zero. `monthly_amount` is the target amount for this baby step (`app.py:71`). If it's `0` (or `None`), the function bails out and returns `0%` instead of raising a `ZeroDivisionError`.
|
|
- **`self.saved_amount`** — calls the property defined right above it (`app.py:78-80`), which sums up all `Payment` amounts tied to this baby step.
|
|
- **`self.saved_amount / self.monthly_amount * 100`** — turns the raw saved amount into a percentage of the target. E.g. $150 saved toward a $500 monthly target is `150/500*100 = 30`.
|
|
- **`round(...)`** — rounds that percentage to the nearest whole number.
|
|
- **`min(100, ...)`** — caps the result at 100, so overpaying the baby step doesn't push the displayed progress past 100%.
|
|
|
|
Mirrors `Goal.progress_percent` exactly, just one level down — per baby step instead of per whole goal.
|
|
|
|
### `Goal.image_list`
|
|
|
|
```python
|
|
@property
|
|
def image_list(self):
|
|
return json.loads(self.images) if self.images else []
|
|
```
|
|
|
|
- **`self.images`** — the raw `db.Column(db.Text, nullable=True)` at `app.py:45`, a single text column storing a JSON-encoded array of image filenames/paths as a string (e.g. `'["a.jpg", "b.jpg"]'`), since this schema has no native array/list column type.
|
|
- **`if self.images else []`** — guards against `None`/empty string. If no images have been set, skip parsing and return an empty list.
|
|
- **`json.loads(self.images)`** — otherwise parses that JSON string back into an actual Python list, so templates/code can do `for img in goal.image_list` instead of dealing with raw JSON text.
|
|
|
|
Unrelated to the saved-amount math — this one is purely for handling image uploads (ties to `allowed_image`/`UPLOAD_FOLDER` near the top of the file), stored as a JSON blob in one column instead of a separate images table.
|
|
|
|
### `Goal.saved_amount`
|
|
|
|
```python
|
|
@property
|
|
def saved_amount(self):
|
|
return sum(step.saved_amount for step in self.babysteps)
|
|
```
|
|
|
|
- **`self.babysteps`** — the relationship list from `app.py:50`, every `BabyStep` belonging to this `Goal`.
|
|
- For each step, it accesses **`step.saved_amount`** — the exact `BabyStep.saved_amount` property from `app.py:78-80`, which itself sums that step's `Payment.amount` values.
|
|
|
|
A rollup of a rollup: `Payment.amount` → summed into `BabyStep.saved_amount` → summed again into `Goal.saved_amount`. Three levels: payments fund baby steps, baby steps roll up into the goal.
|
|
|
|
### `Goal.progress_percent`
|
|
|
|
```python
|
|
@property
|
|
def progress_percent(self):
|
|
if not self.goal_amount:
|
|
return 0
|
|
return min(100, round(self.saved_amount / self.goal_amount * 100))
|
|
```
|
|
|
|
Structurally identical to `BabyStep.progress_percent` (`app.py:82-86`): guard against a zero `goal_amount` (the target for the whole goal, `app.py:46`), divide `saved_amount` by the target, `*100`, round, and cap at 100 with `min`.
|
|
|
|
The key difference is scale: `BabyStep.progress_percent` compares its own `saved_amount` (sum of its payments) against its own `monthly_amount`. `Goal.progress_percent` compares the goal's `saved_amount` (sum across all baby steps) against the goal's `goal_amount`.
|
|
|
|
## How it all ties together
|
|
|
|
```
|
|
Payment.amount
|
|
└─ summed by BabyStep.saved_amount
|
|
└─ compared to BabyStep.monthly_amount → BabyStep.progress_percent
|
|
└─ also summed again (via all babysteps) by Goal.saved_amount
|
|
└─ compared to Goal.goal_amount → Goal.progress_percent
|
|
```
|