Pull 350+ lines of inline per-row computation out of adults_view, juniors_view, and payments into three pure builder functions with no Flask globals or IO dependencies. Route handlers now contain only cache/IO calls and a single render_template. No behaviour change — all 27 tests pass. Also moves get_month_labels, group_payments_by_person, and adapt_junior_members out of app.py. Prep for /api/* shadow endpoints (M5 Go parity). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
7.6 KiB
Python view-model cleanup (M5 prep — Python side only)
Scoped-down precursor to M5 of the Go rewrite. See:
Context
The Python app is still production. Before adding /api/X shadow
routes, JSON DTOs, parity tooling, and Go handlers, get the Python
side into a clean shape. Today the /adults and /juniors routes
each carry 150–200 lines of inline view-model construction inside
the Flask handler:
- adults_view — 167 LOC, computes per-row status/cell_text/balance/credits/debts inline.
- juniors_view — 205 LOC, same plus
"?"sentinel branching and:NJ,MAbreakdown. - payments — 35 LOC, lighter but still mixes IO and grouping.
Pulling that into pure builder functions:
- shrinks
app.pyand makes each route handler do only what a route handler should (IO + cache + step timing + render); - gives us already-tested pure functions that future M5 work can
call from a
/api/Xshadow endpoint with one line ofjsonify(...); - has zero behavioural change (existing tests in test_app.py act as the regression guard).
This is a Python-only change. No Go work, no JSON contract, no shadow routes — those come after.
Approach
Three pure builder functions in a new scripts/views.py module. Each
takes already-loaded, deserialized inputs (no Flask globals, no IO,
no cache calls) and returns the exact dict that today's route passes
to render_template.
def build_adults_view_model(
members, sorted_months, transactions, exceptions, current_month,
*, attendance_url, payments_url, bank_account,
) -> dict: ...
def build_juniors_view_model(
junior_members, sorted_months, transactions, exceptions, current_month,
*, attendance_url, payments_url, bank_account,
) -> dict: ...
def build_payments_view_model(
transactions, member_names,
*, attendance_url, payments_url,
) -> dict: ...
The route handlers shrink to: load data (with get_cached_data and
record_step calls staying in app.py), call the builder, render.
@app.route("/adults")
def adults_view():
members, sorted_months = ... # cached loads + record_step calls
transactions = ...
exceptions = ...
result_meta = ...
record_step("process_data")
vm = build_adults_view_model(
members, sorted_months, transactions, exceptions,
datetime.now().strftime("%Y-%m"),
attendance_url=..., payments_url=..., bank_account=BANK_ACCOUNT,
)
return render_template("adults.html", **vm)
Decisions
- Pure builders, no Flask state. No
record_step, nog.*, noget_cached_datainside builders. They take plain args, return plain dicts. This is what makes them trivially unit-testable and, later, trivially reusable from/api/X. - Preserve byte-equal behaviour. The dicts returned must match
today's
render_template(...)kwargs key-for-key, value-for-value — including the existingjson.dumps(member_data)/json.dumps(month_labels_json)/json.dumps(raw_payments_json)wrappers. Those wrappers exist for inline JS in templates; the refactor doesn't touch them. (When/api/Xlands later, that route will produce a sibling dict with the wrappers stripped, but that's future work.) reconcile()stays inside the route, not the builder. It crosses the IO/cache boundary in spirit (its inputs come from cache); but it's also pure-domain and called by both adults and juniors. Keep the call site in the route sorecord_step("reconcile")timing isn't lost. Builder takesresultas an argument.- Shared helpers stay in
app.pyfor now — get_month_labels and group_payments_by_person are already module-level pure functions, used by routes and now by builders. Either leave them inapp.pyand import intoscripts/views.py, or move them intoscripts/views.py. Choose: move intoscripts/views.py— they're view-model concerns, not Flask concerns, andapp.pyshould keep shrinking. - No new test file needed. Existing
tests/test_app.py tests
test_adults_route,test_juniors_route,test_payments_routeexercise the rendered HTML end-to-end. If they pass after the refactor, behaviour is preserved. Adding builder-level unit tests is a nice-to-have but not required for this iteration.
Tasks
1. Create scripts/views.py with the three builders + shared helpers
- Move
get_month_labelsandgroup_payments_by_personfrom app.py:41–77 intoscripts/views.py. Update the import inapp.py. - Implement
build_adults_view_modelby extracting app.py:200–344 (everything betweenresult = reconcile(...)andreturn render_template(...)). Takeresultas a parameter; emit the same dict that's currently passed as**kwargstorender_template. - Implement
build_juniors_view_modelby extracting app.py:370–550. Same shape — including theadapted_membersadapter loop, the junior?-sentinel branches, and the:NJ,MAbreakdown. - Implement
build_payments_view_modelby extracting app.py:570–586 (thegroup_payments_by_personcall +Unmatched / Unknownbucket + sort).
2. Slim down the route handlers in app.py
Each handler keeps:
attendance_url/payments_urlURL buildingget_cached_datacalls (withrecord_stepbetween)reconcile(...)call (adults/juniors) withrecord_step("reconcile")record_step("process_data")after the builder callreturn render_template("X.html", **view_model)
Each handler drops all the per-row computation, totals
formatting, credits/debts sorting, raw_payments_by_person building,
and import json lines.
Target post-refactor LOC for each route handler: ~25–30 lines (currently 167 / 205 / 35).
3. Run the existing test suite + manual smoke
make test # all tests green
make web-py # browse /adults /juniors /payments visually
Critical files
- app.py — routes shrink dramatically;
get_month_labelsandgroup_payments_by_personget moved out. - New:
scripts/views.py— three builders + the two helpers. - tests/test_app.py — unchanged; serves as the regression guard.
Verification
make test— all fourTestWebApptests pass unchanged.make web-pyand visit/adults,/juniors,/payments— each renders identically to before (same table contents, same totals, same credits/debts, same?rendering on juniors).git diff app.pyshows substantial deletions (route bodies shrink) and only thin glue calling the new builders.- Optional sanity check: temporarily add
print(repr(view_model))in the route beforerender_templateonmainand on the branch, diff for one fixture run — should be byte-identical dicts.
Out of scope (future iterations, not this plan)
/api/<route>shadow endpoints in Flask- Go
internal/web/api/DTOs and JSON Schemas - Go
internal/services/membership/views.goaggregators - Go HTTP handlers for
/api/X cmd/parity/main.goandmake paritytarget- Junior breakdown sidecar work in
go/internal/services/membership/sources.go
These are the original M5 tasks (M5.1–M5.4 in the progress tracker).
They become much easier once today's refactor lands, because the
shadow /api/X routes will be one-liners over the new builders.