Compare commits
No commits in common. "28d097cfbf09ed043b0fde5cedaaae6308627a68" and "1eecfc3ae8102304bc31e5da0bad1e37fefe8835" have entirely different histories.
28d097cfbf
...
1eecfc3ae8
10 changed files with 8 additions and 338 deletions
|
|
@ -1,42 +0,0 @@
|
|||
"""add notes column to entry and month_entry
|
||||
|
||||
Revision ID: ec804bdf366d
|
||||
Revises: 03ebe3c07262
|
||||
Create Date: 2026-04-17 12:47:41.533978
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'ec804bdf366d'
|
||||
down_revision: Union[str, Sequence[str], None] = '03ebe3c07262'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table('entry', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('notes', sa.String(length=1024), nullable=True))
|
||||
|
||||
with op.batch_alter_table('month_entry', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('notes', sa.String(length=1024), nullable=True))
|
||||
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table('month_entry', schema=None) as batch_op:
|
||||
batch_op.drop_column('notes')
|
||||
|
||||
with op.batch_alter_table('entry', schema=None) as batch_op:
|
||||
batch_op.drop_column('notes')
|
||||
|
||||
# ### end Alembic commands ###
|
||||
|
|
@ -51,7 +51,6 @@ class Entry(Base):
|
|||
)
|
||||
name: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
amount: Mapped[Decimal] = mapped_column(Numeric(10, 2), nullable=False)
|
||||
notes: Mapped[str | None] = mapped_column(String(1024), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
|
|
@ -118,7 +117,6 @@ class MonthEntry(Base):
|
|||
Numeric(10, 2), nullable=False, default=Decimal("0.00"),
|
||||
server_default="0.00",
|
||||
)
|
||||
notes: Mapped[str | None] = mapped_column(String(1024), nullable=True)
|
||||
origin_name: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
origin_planned: Mapped[Decimal | None] = mapped_column(
|
||||
Numeric(10, 2), nullable=True
|
||||
|
|
|
|||
|
|
@ -112,7 +112,6 @@ def create_month(db: Session, year_month: str) -> Month:
|
|||
name=e.name,
|
||||
planned=e.amount,
|
||||
applied=Decimal("0.00"),
|
||||
notes=e.notes,
|
||||
origin_name=e.name,
|
||||
origin_planned=e.amount,
|
||||
source_entry_id=e.id,
|
||||
|
|
@ -204,20 +203,12 @@ def section_view(month: Month, section: Section, label: str) -> MonthSectionView
|
|||
)
|
||||
|
||||
|
||||
def _clean_notes(raw: str | None) -> str | None:
|
||||
if raw is None:
|
||||
return None
|
||||
stripped = raw.strip()
|
||||
return stripped if stripped else None
|
||||
|
||||
|
||||
def add_month_entry(
|
||||
db: Session,
|
||||
month: Month,
|
||||
section: Section,
|
||||
name: str,
|
||||
planned: Decimal,
|
||||
notes: str | None = None,
|
||||
) -> MonthEntry:
|
||||
entry = MonthEntry(
|
||||
month_id=month.id,
|
||||
|
|
@ -225,7 +216,6 @@ def add_month_entry(
|
|||
name=name.strip(),
|
||||
planned=planned,
|
||||
applied=Decimal("0.00"),
|
||||
notes=_clean_notes(notes),
|
||||
origin_name=None,
|
||||
origin_planned=None,
|
||||
source_entry_id=None,
|
||||
|
|
@ -253,9 +243,6 @@ def delete_month_entry(db: Session, month: Month, entry_id: int) -> Section | No
|
|||
return section
|
||||
|
||||
|
||||
_NOTES_SENTINEL = object()
|
||||
|
||||
|
||||
def update_month_entry(
|
||||
db: Session,
|
||||
month: Month,
|
||||
|
|
@ -264,7 +251,6 @@ def update_month_entry(
|
|||
name: str | None = None,
|
||||
planned: Decimal | None = None,
|
||||
applied: Decimal | None = None,
|
||||
notes: str | None | object = _NOTES_SENTINEL,
|
||||
) -> MonthEntry | None:
|
||||
entry = get_month_entry(db, month, entry_id)
|
||||
if entry is None:
|
||||
|
|
@ -275,8 +261,6 @@ def update_month_entry(
|
|||
entry.planned = planned
|
||||
if applied is not None:
|
||||
entry.applied = applied
|
||||
if notes is not _NOTES_SENTINEL:
|
||||
entry.notes = _clean_notes(notes) # type: ignore[arg-type]
|
||||
db.commit()
|
||||
db.refresh(entry)
|
||||
return entry
|
||||
|
|
|
|||
|
|
@ -107,14 +107,13 @@ def create_entry(
|
|||
request: Request,
|
||||
name: str = Form(...),
|
||||
amount: str = Form(...),
|
||||
notes: str | None = Form(None),
|
||||
db: Session = Depends(get_session),
|
||||
) -> HTMLResponse:
|
||||
clean_name = name.strip()
|
||||
if not clean_name:
|
||||
raise HTTPException(status_code=400, detail="name is required")
|
||||
parsed = _parse_amount(amount)
|
||||
service.add_entry(db, section, clean_name, parsed, notes=notes)
|
||||
service.add_entry(db, section, clean_name, parsed)
|
||||
response = _render_section(request, db, section)
|
||||
extras: list[HTMLResponse] = [
|
||||
_render_zero(request, db),
|
||||
|
|
@ -144,19 +143,6 @@ def remove_entry(
|
|||
return _append_oob(response, *extras)
|
||||
|
||||
|
||||
@router.post("/entries/{entry_id}/notes", response_class=HTMLResponse)
|
||||
def update_entry_notes(
|
||||
entry_id: int,
|
||||
request: Request,
|
||||
notes: str | None = Form(None),
|
||||
db: Session = Depends(get_session),
|
||||
) -> HTMLResponse:
|
||||
updated = service.set_entry_notes(db, entry_id, notes)
|
||||
if updated is None:
|
||||
raise HTTPException(status_code=404, detail="entry not found")
|
||||
return _render_section(request, db, updated.section)
|
||||
|
||||
|
||||
@router.post("/debt-target", response_class=HTMLResponse)
|
||||
def update_debt_target(
|
||||
request: Request,
|
||||
|
|
|
|||
|
|
@ -164,7 +164,6 @@ def add_month_entry(
|
|||
request: Request,
|
||||
name: str = Form(...),
|
||||
planned: str = Form(...),
|
||||
notes: str | None = Form(None),
|
||||
db: Session = Depends(get_session),
|
||||
) -> HTMLResponse:
|
||||
month = _require_month(db, year_month)
|
||||
|
|
@ -172,7 +171,7 @@ def add_month_entry(
|
|||
if not clean_name:
|
||||
raise HTTPException(status_code=400, detail="name is required")
|
||||
month_service.add_month_entry(
|
||||
db, month, section, clean_name, _parse_amount(planned), notes=notes
|
||||
db, month, section, clean_name, _parse_amount(planned)
|
||||
)
|
||||
db.refresh(month)
|
||||
return _append_oob(
|
||||
|
|
@ -211,7 +210,6 @@ def update_month_entry(
|
|||
name: str | None = Form(None),
|
||||
planned: str | None = Form(None),
|
||||
applied: str | None = Form(None),
|
||||
notes: str | None = Form(None),
|
||||
db: Session = Depends(get_session),
|
||||
) -> HTMLResponse:
|
||||
month = _require_month(db, year_month)
|
||||
|
|
@ -222,14 +220,14 @@ def update_month_entry(
|
|||
raise HTTPException(status_code=400, detail="name must not be empty")
|
||||
parsed_planned = _parse_amount(planned) if planned is not None else None
|
||||
parsed_applied = _parse_amount(applied) if applied is not None else None
|
||||
kwargs: dict = dict(
|
||||
updated = month_service.update_month_entry(
|
||||
db,
|
||||
month,
|
||||
entry_id,
|
||||
name=clean_name,
|
||||
planned=parsed_planned,
|
||||
applied=parsed_applied,
|
||||
)
|
||||
if notes is not None:
|
||||
kwargs["notes"] = notes
|
||||
updated = month_service.update_month_entry(db, month, entry_id, **kwargs)
|
||||
if updated is None:
|
||||
raise HTTPException(status_code=404, detail="entry not found")
|
||||
db.refresh(month)
|
||||
|
|
|
|||
|
|
@ -87,44 +87,14 @@ def budget_zero(db: Session) -> Decimal:
|
|||
return (total_income - total_non_income).quantize(Decimal("0.01"))
|
||||
|
||||
|
||||
def add_entry(
|
||||
db: Session,
|
||||
section: Section,
|
||||
name: str,
|
||||
amount: Decimal,
|
||||
notes: str | None = None,
|
||||
) -> Entry:
|
||||
entry = Entry(
|
||||
section=section,
|
||||
name=name.strip(),
|
||||
amount=amount,
|
||||
notes=_clean_notes(notes),
|
||||
)
|
||||
def add_entry(db: Session, section: Section, name: str, amount: Decimal) -> Entry:
|
||||
entry = Entry(section=section, name=name.strip(), amount=amount)
|
||||
db.add(entry)
|
||||
db.commit()
|
||||
db.refresh(entry)
|
||||
return entry
|
||||
|
||||
|
||||
def _clean_notes(raw: str | None) -> str | None:
|
||||
if raw is None:
|
||||
return None
|
||||
stripped = raw.strip()
|
||||
return stripped if stripped else None
|
||||
|
||||
|
||||
def set_entry_notes(
|
||||
db: Session, entry_id: int, notes: str | None
|
||||
) -> Entry | None:
|
||||
entry = db.get(Entry, entry_id)
|
||||
if entry is None:
|
||||
return None
|
||||
entry.notes = _clean_notes(notes)
|
||||
db.commit()
|
||||
db.refresh(entry)
|
||||
return entry
|
||||
|
||||
|
||||
def delete_entry(db: Session, entry_id: int) -> Entry | None:
|
||||
entry = db.get(Entry, entry_id)
|
||||
if entry is None:
|
||||
|
|
|
|||
|
|
@ -111,37 +111,6 @@ tr.add-row td {
|
|||
|
||||
.muted { color: var(--muted); font-style: italic; }
|
||||
|
||||
tr.entry-notes-row td {
|
||||
padding: 0 0.5rem 0.35rem;
|
||||
border-bottom: 1px solid var(--rule);
|
||||
}
|
||||
|
||||
.notes-input {
|
||||
width: 100%;
|
||||
padding: 0.2rem 0.4rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--muted);
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 1px dashed transparent;
|
||||
}
|
||||
|
||||
.notes-input:hover,
|
||||
.notes-input:focus {
|
||||
border-bottom-color: var(--rule);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.notes-input:not(:placeholder-shown) {
|
||||
color: var(--ink);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.add-form .add-notes {
|
||||
grid-column: 1 / -1;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
button.delete {
|
||||
background: transparent;
|
||||
border: none;
|
||||
|
|
|
|||
|
|
@ -68,22 +68,6 @@
|
|||
>×</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="entry-notes-row">
|
||||
<td colspan="4">
|
||||
<input
|
||||
class="notes-input"
|
||||
type="text"
|
||||
name="notes"
|
||||
value="{{ row.entry.notes or '' }}"
|
||||
placeholder="notes..."
|
||||
hx-post="/month/{{ month.year_month }}/entries/{{ row.entry.id }}"
|
||||
hx-trigger="change"
|
||||
hx-target="#section-{{ section.section.value }}"
|
||||
hx-swap="outerHTML"
|
||||
aria-label="Notes for {{ row.entry.name }}"
|
||||
>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr class="empty"><td colspan="4">No entries.</td></tr>
|
||||
{% endfor %}
|
||||
|
|
@ -99,7 +83,6 @@
|
|||
<input type="text" name="name" placeholder="Name" required>
|
||||
<input type="number" name="planned" step="0.01" min="0" placeholder="Planned" required>
|
||||
<button type="submit">Add</button>
|
||||
<input class="notes-input add-notes" type="text" name="notes" placeholder="notes (optional)">
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
|
|
|
|||
|
|
@ -22,22 +22,6 @@
|
|||
>×</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="entry-notes-row">
|
||||
<td colspan="3">
|
||||
<input
|
||||
class="notes-input"
|
||||
type="text"
|
||||
name="notes"
|
||||
value="{{ entry.notes or '' }}"
|
||||
placeholder="notes..."
|
||||
hx-post="/entries/{{ entry.id }}/notes"
|
||||
hx-trigger="change"
|
||||
hx-target="#section-{{ section.section.value }}"
|
||||
hx-swap="outerHTML"
|
||||
aria-label="Notes for {{ entry.name }}"
|
||||
>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr class="empty"><td colspan="3">No entries yet.</td></tr>
|
||||
{% endfor %}
|
||||
|
|
@ -53,7 +37,6 @@
|
|||
<input type="text" name="name" placeholder="Name" required>
|
||||
<input type="number" name="amount" step="0.01" min="0" placeholder="0.00" required>
|
||||
<button type="submit">Add</button>
|
||||
<input class="notes-input add-notes" type="text" name="notes" placeholder="notes (optional)">
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
|
|
|
|||
|
|
@ -1,159 +0,0 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
from quartermaster import month_service, service
|
||||
from quartermaster.models import Section
|
||||
|
||||
|
||||
def test_add_entry_stores_notes(db):
|
||||
entry = service.add_entry(
|
||||
db,
|
||||
Section.sinking_fund,
|
||||
"Emergency",
|
||||
Decimal("500.00"),
|
||||
notes="Target: 3 months of expenses",
|
||||
)
|
||||
assert entry.notes == "Target: 3 months of expenses"
|
||||
|
||||
|
||||
def test_add_entry_strips_and_nulls_empty_notes(db):
|
||||
entry = service.add_entry(
|
||||
db, Section.other, "Gift", Decimal("25.00"), notes=" "
|
||||
)
|
||||
assert entry.notes is None
|
||||
|
||||
|
||||
def test_set_entry_notes_updates(db):
|
||||
entry = service.add_entry(db, Section.food, "Groceries", Decimal("400.00"))
|
||||
updated = service.set_entry_notes(db, entry.id, "weekly Costco run")
|
||||
assert updated is not None
|
||||
assert updated.notes == "weekly Costco run"
|
||||
|
||||
|
||||
def test_set_entry_notes_missing_returns_none(db):
|
||||
assert service.set_entry_notes(db, 9999, "oops") is None
|
||||
|
||||
|
||||
def test_snapshot_copies_notes(db):
|
||||
service.add_entry(
|
||||
db,
|
||||
Section.sinking_fund,
|
||||
"Emergency",
|
||||
Decimal("500.00"),
|
||||
notes="3 months expenses",
|
||||
)
|
||||
month = month_service.create_month(db, "2026-04")
|
||||
emergency = next(e for e in month.entries if e.origin_name == "Emergency")
|
||||
assert emergency.notes == "3 months expenses"
|
||||
|
||||
|
||||
def test_month_notes_edit_does_not_change_deviation_state(db):
|
||||
service.add_entry(
|
||||
db, Section.fixed_bill, "Rent", Decimal("1200.00"), notes="auto-pay"
|
||||
)
|
||||
month = month_service.create_month(db, "2026-04")
|
||||
rent = next(e for e in month.entries if e.origin_name == "Rent")
|
||||
# unchanged initially
|
||||
assert (
|
||||
month_service.deviation_state(rent) == month_service.DeviationState.unchanged
|
||||
)
|
||||
# update notes only
|
||||
month_service.update_month_entry(
|
||||
db, month, rent.id, notes="auto-pay; renew July 2027"
|
||||
)
|
||||
db.refresh(rent)
|
||||
assert rent.notes == "auto-pay; renew July 2027"
|
||||
assert (
|
||||
month_service.deviation_state(rent) == month_service.DeviationState.unchanged
|
||||
)
|
||||
|
||||
|
||||
def test_update_month_entry_notes_to_empty_nulls(db):
|
||||
service.add_entry(db, Section.other, "Parking", Decimal("25.00"), notes="work")
|
||||
month = month_service.create_month(db, "2026-04")
|
||||
entry = next(e for e in month.entries if e.origin_name == "Parking")
|
||||
assert entry.notes == "work"
|
||||
month_service.update_month_entry(db, month, entry.id, notes="")
|
||||
db.refresh(entry)
|
||||
assert entry.notes is None
|
||||
|
||||
|
||||
# --- route-level -----------------------------------------------------------
|
||||
|
||||
|
||||
def test_create_entry_route_accepts_notes(client):
|
||||
response = client.post(
|
||||
"/sections/sinking_fund/entries",
|
||||
data={"name": "Emergency", "amount": "500.00", "notes": "3 mo cushion"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert "3 mo cushion" in response.text
|
||||
|
||||
|
||||
def test_update_entry_notes_route(client):
|
||||
client.post(
|
||||
"/sections/food/entries",
|
||||
data={"name": "Groceries", "amount": "400.00"},
|
||||
)
|
||||
response = client.post("/entries/1/notes", data={"notes": "weekly"})
|
||||
assert response.status_code == 200
|
||||
assert "weekly" in response.text
|
||||
|
||||
|
||||
def test_update_entry_notes_empty_clears(client):
|
||||
client.post(
|
||||
"/sections/food/entries",
|
||||
data={"name": "Groceries", "amount": "400.00", "notes": "weekly"},
|
||||
)
|
||||
response = client.post("/entries/1/notes", data={"notes": ""})
|
||||
assert response.status_code == 200
|
||||
# the input's value="" still renders but the placeholder kicks in;
|
||||
# specifically, no literal "weekly" anymore
|
||||
assert "value=\"weekly\"" not in response.text
|
||||
|
||||
|
||||
def test_create_month_entry_route_accepts_notes(client):
|
||||
client.post("/month/2026-04/create")
|
||||
response = client.post(
|
||||
"/month/2026-04/sections/other/entries",
|
||||
data={"name": "Gift", "planned": "25.00", "notes": "birthday"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert "birthday" in response.text
|
||||
|
||||
|
||||
def test_update_month_entry_route_accepts_notes(client):
|
||||
client.post(
|
||||
"/sections/fixed_bill/entries",
|
||||
data={"name": "Rent", "amount": "1200.00"},
|
||||
)
|
||||
client.post("/month/2026-04/create")
|
||||
response = client.post(
|
||||
"/month/2026-04/entries/1", data={"notes": "auto-pay"}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert "auto-pay" in response.text
|
||||
|
||||
|
||||
def test_budget_page_renders_notes_inputs(client):
|
||||
client.post(
|
||||
"/sections/fixed_bill/entries",
|
||||
data={"name": "Rent", "amount": "1200.00", "notes": "due 1st"},
|
||||
)
|
||||
response = client.get("/")
|
||||
assert response.status_code == 200
|
||||
assert "entry-notes-row" in response.text
|
||||
assert "due 1st" in response.text
|
||||
|
||||
|
||||
def test_month_page_renders_notes_inputs(client):
|
||||
client.post(
|
||||
"/sections/fixed_bill/entries",
|
||||
data={"name": "Rent", "amount": "1200.00", "notes": "auto-pay"},
|
||||
)
|
||||
client.post("/month/2026-04/create")
|
||||
response = client.get("/month/2026-04")
|
||||
assert response.status_code == 200
|
||||
assert "entry-notes-row" in response.text
|
||||
assert "auto-pay" in response.text
|
||||
Loading…
Reference in a new issue