From b562ce320150be13f58c8176612a2147d7cff632 Mon Sep 17 00:00:00 2001 From: Jan Novak Date: Thu, 7 May 2026 15:22:12 +0200 Subject: [PATCH 1/2] refactor(app): extract view-model builders into scripts/views.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app.py | 425 ++--------------- .../2026-05-07-1431-m5-json-api-parity.md | 185 ++++++++ scripts/views.py | 445 ++++++++++++++++++ 3 files changed, 669 insertions(+), 386 deletions(-) create mode 100644 docs/plans/2026-05-07-1431-m5-json-api-parity.md create mode 100644 scripts/views.py diff --git a/app.py b/app.py index b614121..9e89fc9 100644 --- a/app.py +++ b/app.py @@ -1,7 +1,6 @@ import sys from pathlib import Path from datetime import datetime -import re import time import os import io @@ -21,8 +20,14 @@ from config import ( ATTENDANCE_SHEET_ID, PAYMENTS_SHEET_ID, JUNIOR_SHEET_GID, BANK_ACCOUNT, CREDENTIALS_PATH, ) -from attendance import get_members_with_fees, get_junior_members_with_fees, ADULT_MERGED_MONTHS, JUNIOR_MERGED_MONTHS -from match_payments import reconcile, fetch_sheet_data, fetch_exceptions, normalize, canonical_member_key +from attendance import get_members_with_fees, get_junior_members_with_fees +from match_payments import reconcile, fetch_sheet_data, fetch_exceptions +from views import ( + build_adults_view_model, + build_juniors_view_model, + build_payments_view_model, + adapt_junior_members, +) from cache_utils import get_sheet_modified_time, read_cache, write_cache, _LAST_CHECKED, flush_cache from sync_fio_to_sheets import sync_to_sheets from infer_payments import infer_payments @@ -38,44 +43,6 @@ def get_cached_data(cache_key, sheet_id, fetch_func, *args, serialize=None, dese write_cache(cache_key, mod_time, serialize(data) if serialize else data) return data -def get_month_labels(sorted_months, merged_months): - labels = {} - for m in sorted_months: - dt = datetime.strptime(m, "%Y-%m") - # Find which months were merged into m (e.g. 2026-01 is merged into 2026-02) - merged_in = sorted([k for k, v in merged_months.items() if v == m]) - if merged_in: - all_dts = [datetime.strptime(x, "%Y-%m") for x in sorted(merged_in + [m])] - years = {d.year for d in all_dts} - if len(years) > 1: - parts = [d.strftime("%b %Y") for d in all_dts] - labels[m] = "+".join(parts) - else: - parts = [d.strftime("%b") for d in all_dts] - labels[m] = f"{'+'.join(parts)} {dt.strftime('%Y')}" - else: - labels[m] = dt.strftime("%b %Y") - return labels - -def group_payments_by_person(transactions, member_names=None): - canonical_by_key = ( - {canonical_member_key(n): n for n in member_names} if member_names else {} - ) - grouped = {} - for tx in transactions: - person = str(tx.get("person", "")).strip() - if not person: - continue - for p in person.split(","): - p = re.sub(r"\[\?\]\s*", "", p).strip() - if not p: - continue - key = canonical_by_key.get(canonical_member_key(p), p) - grouped.setdefault(key, []).append(tx) - for rows in grouped.values(): - rows.sort(key=lambda t: str(t.get("date", "")), reverse=True) - return grouped - def warmup_cache(): """Pre-fetch all cached data so first request is fast.""" logger = logging.getLogger(__name__) @@ -181,381 +148,77 @@ def adults_view(): attendance_url = f"https://docs.google.com/spreadsheets/d/{ATTENDANCE_SHEET_ID}/edit" payments_url = f"https://docs.google.com/spreadsheets/d/{PAYMENTS_SHEET_ID}/edit" credentials_path = CREDENTIALS_PATH - + members_data = get_cached_data("attendance_regular", ATTENDANCE_SHEET_ID, get_members_with_fees) record_step("fetch_members") if not members_data: return "No data." members, sorted_months = members_data - + transactions = get_cached_data("payments_transactions", PAYMENTS_SHEET_ID, fetch_sheet_data, PAYMENTS_SHEET_ID, credentials_path) record_step("fetch_payments") exceptions = get_cached_data( - "exceptions_dict", PAYMENTS_SHEET_ID, fetch_exceptions, - PAYMENTS_SHEET_ID, credentials_path, - serialize=lambda d: [[list(k), v] for k, v in d.items()], - deserialize=lambda c: {tuple(k): v for k, v in c}, - ) + "exceptions_dict", PAYMENTS_SHEET_ID, fetch_exceptions, + PAYMENTS_SHEET_ID, credentials_path, + serialize=lambda d: [[list(k), v] for k, v in d.items()], + deserialize=lambda c: {tuple(k): v for k, v in c}, + ) record_step("fetch_exceptions") result = reconcile(members, sorted_months, transactions, exceptions) record_step("reconcile") - - month_labels = get_month_labels(sorted_months, ADULT_MERGED_MONTHS) - adult_names = sorted([name for name, tier, _ in members if tier == "A"]) - current_month = datetime.now().strftime("%Y-%m") - monthly_totals = {m: {"expected": 0, "paid": 0} for m in sorted_months} - formatted_results = [] - for name in adult_names: - data = result["members"][name] - row = {"name": name, "months": [], "balance": data["total_balance"], "unpaid_periods": "", "raw_unpaid_periods": ""} - unpaid_months = [] - raw_unpaid_months = [] - for m in sorted_months: - mdata = data["months"].get(m, {"expected": 0, "original_expected": 0, "attendance_count": 0, "paid": 0, "exception": None}) - expected = mdata.get("expected", 0) - original_expected = mdata.get("original_expected", 0) - count = mdata.get("attendance_count", 0) - paid = int(mdata.get("paid", 0)) - exception_info = mdata.get("exception", None) - - monthly_totals[m]["expected"] += expected - monthly_totals[m]["paid"] += paid - - override_amount = exception_info["amount"] if exception_info else None - - if override_amount is not None and override_amount != original_expected: - is_overridden = True - fee_display = f"{override_amount} ({original_expected}) CZK ({count})" if count > 0 else f"{override_amount} ({original_expected}) CZK" - else: - is_overridden = False - fee_display = f"{expected} CZK ({count})" if count > 0 else f"{expected} CZK" - - status = "empty" - cell_text = "-" - amount_to_pay = 0 - - if expected > 0: - amount_to_pay = max(0, expected - paid) - if paid >= expected: - status = "ok" - cell_text = f"{paid}/{fee_display}" - elif paid > 0: - status = "partial" - cell_text = f"{paid}/{fee_display}" - if m < current_month: - unpaid_months.append(month_labels[m]) - raw_unpaid_months.append(datetime.strptime(m, "%Y-%m").strftime("%m/%Y")) - else: - status = "unpaid" - cell_text = f"0/{fee_display}" - if m < current_month: - unpaid_months.append(month_labels[m]) - raw_unpaid_months.append(datetime.strptime(m, "%Y-%m").strftime("%m/%Y")) - elif paid > 0: - status = "surplus" - cell_text = f"PAID {paid}" - else: - cell_text = "-" - amount_to_pay = 0 - - if expected > 0 or paid > 0: - tooltip = f"Received: {paid}, Expected: {expected}" - else: - tooltip = "" - - row["months"].append({ - "text": cell_text, - "overridden": is_overridden, - "status": status, - "amount": amount_to_pay, - "month": month_labels[m], - "raw_month": m, - "tooltip": tooltip - }) - - # Balance = sum of (paid - expected) for past months only; current/future months ignored. - settled_balance = 0 - for m, mdata in data["months"].items(): - if m >= current_month: - continue - exp = mdata.get("expected", 0) - if isinstance(exp, int): - settled_balance += int(mdata.get("paid", 0)) - exp - - payable_amount = max(0, -settled_balance) - row["unpaid_periods"] = ", ".join(unpaid_months) - row["raw_unpaid_periods"] = "+".join(raw_unpaid_months) - row["balance"] = settled_balance - row["payable_amount"] = payable_amount - formatted_results.append(row) - - formatted_totals = [] - for m in sorted_months: - t = monthly_totals[m] - status = "empty" - if t["expected"] > 0 or t["paid"] > 0: - if t["paid"] == t["expected"]: - status = "ok" - elif t["paid"] < t["expected"]: - status = "unpaid" - else: - status = "surplus" - - formatted_totals.append({ - "text": f"{t['paid']} / {t['expected']} CZK", - "status": status - }) - - def settled_balance(name): - data = result["members"][name] - total = 0 - for m, mdata in data["months"].items(): - if m >= current_month: - continue - exp = mdata.get("expected", 0) - if isinstance(exp, int): - total += int(mdata.get("paid", 0)) - exp - return total - - credits = sorted([{"name": n, "amount": settled_balance(n)} for n in adult_names if settled_balance(n) > 0], key=lambda x: x["name"]) - debts = sorted([{"name": n, "amount": abs(settled_balance(n))} for n in adult_names if settled_balance(n) < 0], key=lambda x: x["name"]) - unmatched = result["unmatched"] - import json - - raw_payments_by_person = group_payments_by_person(transactions, [name for name, _, _ in members]) - record_step("process_data") - - return render_template( - "adults.html", - months=[month_labels[m] for m in sorted_months], - raw_months=sorted_months, - results=formatted_results, - totals=formatted_totals, - member_data=json.dumps(result["members"]), - month_labels_json=json.dumps(month_labels), - raw_payments_json=json.dumps(raw_payments_by_person), - credits=credits, - debts=debts, - unmatched=unmatched, + vm = build_adults_view_model( + members, sorted_months, result, transactions, + datetime.now().strftime("%Y-%m"), attendance_url=attendance_url, payments_url=payments_url, bank_account=BANK_ACCOUNT, - current_month=current_month ) + record_step("process_data") + return render_template("adults.html", **vm) @app.route("/juniors") def juniors_view(): attendance_url = f"https://docs.google.com/spreadsheets/d/{ATTENDANCE_SHEET_ID}/edit#gid={JUNIOR_SHEET_GID}" payments_url = f"https://docs.google.com/spreadsheets/d/{PAYMENTS_SHEET_ID}/edit" - credentials_path = CREDENTIALS_PATH - + junior_members_data = get_cached_data("attendance_juniors", ATTENDANCE_SHEET_ID, get_junior_members_with_fees) record_step("fetch_junior_members") if not junior_members_data: return "No data." junior_members, sorted_months = junior_members_data - + transactions = get_cached_data("payments_transactions", PAYMENTS_SHEET_ID, fetch_sheet_data, PAYMENTS_SHEET_ID, credentials_path) record_step("fetch_payments") exceptions = get_cached_data( - "exceptions_dict", PAYMENTS_SHEET_ID, fetch_exceptions, - PAYMENTS_SHEET_ID, credentials_path, - serialize=lambda d: [[list(k), v] for k, v in d.items()], - deserialize=lambda c: {tuple(k): v for k, v in c}, - ) + "exceptions_dict", PAYMENTS_SHEET_ID, fetch_exceptions, + PAYMENTS_SHEET_ID, credentials_path, + serialize=lambda d: [[list(k), v] for k, v in d.items()], + deserialize=lambda c: {tuple(k): v for k, v in c}, + ) record_step("fetch_exceptions") - # Adapt junior tuple format (name, tier, {month: (fee, total_count, adult_count, junior_count)}) - # to what match_payments expects: (name, tier, {month: (expected_fee, attendance_count)}) - adapted_members = [] - for name, tier, fees_dict in junior_members: - adapted_fees = {} - for m, fee_data in fees_dict.items(): - if len(fee_data) == 4: - fee, total_count, _, _ = fee_data - adapted_fees[m] = (fee, total_count) - else: - fee, count = fee_data - adapted_fees[m] = (fee, count) - adapted_members.append((name, tier, adapted_fees)) - + adapted_members = adapt_junior_members(junior_members) result = reconcile(adapted_members, sorted_months, transactions, exceptions) record_step("reconcile") - - # Format month labels - month_labels = get_month_labels(sorted_months, JUNIOR_MERGED_MONTHS) - junior_names = sorted([name for name, tier, _ in adapted_members]) - junior_members_dict = {name: fees_dict for name, _, fees_dict in junior_members} - current_month = datetime.now().strftime("%Y-%m") - monthly_totals = {m: {"expected": 0, "paid": 0} for m in sorted_months} - formatted_results = [] - for name in junior_names: - data = result["members"][name] - row = {"name": name, "months": [], "balance": data["total_balance"], "unpaid_periods": "", "raw_unpaid_periods": ""} - unpaid_months = [] - raw_unpaid_months = [] - for m in sorted_months: - mdata = data["months"].get(m, {"expected": 0, "original_expected": 0, "attendance_count": 0, "paid": 0, "exception": None}) - expected = mdata.get("expected", 0) - original_expected = mdata.get("original_expected", 0) - count = mdata.get("attendance_count", 0) - paid = int(mdata.get("paid", 0)) - exception_info = mdata.get("exception", None) - - if expected != "?" and isinstance(expected, int): - monthly_totals[m]["expected"] += expected - monthly_totals[m]["paid"] += paid - - orig_fee_data = junior_members_dict.get(name, {}).get(m) - adult_count = 0 - junior_count = 0 - if orig_fee_data and len(orig_fee_data) == 4: - _, _, adult_count, junior_count = orig_fee_data - - breakdown = "" - if adult_count > 0 and junior_count > 0: - breakdown = f":{junior_count}J,{adult_count}A" - elif junior_count > 0: - breakdown = f":{junior_count}J" - elif adult_count > 0: - breakdown = f":{adult_count}A" - - count_str = f" ({count}{breakdown})" if count > 0 else "" - - override_amount = exception_info["amount"] if exception_info else None - - if override_amount is not None and override_amount != original_expected: - is_overridden = True - fee_display = f"{override_amount} ({original_expected}) CZK{count_str}" - else: - is_overridden = False - fee_display = f"{expected} CZK{count_str}" - - status = "empty" - cell_text = "-" - amount_to_pay = 0 - - if expected == "?" or (isinstance(expected, int) and expected > 0): - if expected == "?": - status = "empty" - cell_text = f"?{count_str}" - elif paid >= expected: - status = "ok" - cell_text = f"{paid}/{fee_display}" - elif paid > 0: - status = "partial" - cell_text = f"{paid}/{fee_display}" - amount_to_pay = expected - paid - if m < current_month: - unpaid_months.append(month_labels[m]) - raw_unpaid_months.append(datetime.strptime(m, "%Y-%m").strftime("%m/%Y")) - else: - status = "unpaid" - cell_text = f"0/{fee_display}" - amount_to_pay = expected - if m < current_month: - unpaid_months.append(month_labels[m]) - raw_unpaid_months.append(datetime.strptime(m, "%Y-%m").strftime("%m/%Y")) - elif paid > 0: - status = "surplus" - cell_text = f"PAID {paid}" - - if (isinstance(expected, int) and expected > 0) or paid > 0: - tooltip = f"Received: {paid}, Expected: {expected}" - else: - tooltip = "" - - row["months"].append({ - "text": cell_text, - "overridden": is_overridden, - "status": status, - "amount": amount_to_pay, - "month": month_labels[m], - "raw_month": m, - "tooltip": tooltip - }) - - # Balance = sum of (paid - expected) for past months only; current/future months ignored. - settled_balance = 0 - for m, mdata in data["months"].items(): - if m >= current_month: - continue - exp = mdata.get("expected", 0) - if isinstance(exp, int): - settled_balance += int(mdata.get("paid", 0)) - exp - - payable_amount = max(0, -settled_balance) - row["unpaid_periods"] = ", ".join(unpaid_months) - row["raw_unpaid_periods"] = "+".join(raw_unpaid_months) - row["balance"] = settled_balance - row["payable_amount"] = payable_amount - formatted_results.append(row) - - formatted_totals = [] - for m in sorted_months: - t = monthly_totals[m] - status = "empty" - if t["expected"] > 0 or t["paid"] > 0: - if t["paid"] == t["expected"]: - status = "ok" - elif t["paid"] < t["expected"]: - status = "unpaid" - else: - status = "surplus" - - formatted_totals.append({ - "text": f"{t['paid']} / {t['expected']} CZK", - "status": status - }) - - # Format credits and debts - def junior_settled_balance(name): - data = result["members"][name] - total = 0 - for m, mdata in data["months"].items(): - if m >= current_month: - continue - exp = mdata.get("expected", 0) - if isinstance(exp, int): - total += int(mdata.get("paid", 0)) - exp - return total - - junior_all_names = [name for name, _, _ in adapted_members] - credits = sorted([{"name": n, "amount": junior_settled_balance(n)} for n in junior_all_names if junior_settled_balance(n) > 0], key=lambda x: x["name"]) - debts = sorted([{"name": n, "amount": abs(junior_settled_balance(n))} for n in junior_all_names if junior_settled_balance(n) < 0], key=lambda x: x["name"]) - unmatched = result["unmatched"] - raw_payments_by_person = group_payments_by_person(transactions, [name for name, _, _ in adapted_members]) - import json - - record_step("process_data") - - return render_template( - "juniors.html", - months=[month_labels[m] for m in sorted_months], - raw_months=sorted_months, - results=formatted_results, - totals=formatted_totals, - member_data=json.dumps(result["members"]), - month_labels_json=json.dumps(month_labels), - raw_payments_json=json.dumps(raw_payments_by_person), - credits=credits, - debts=debts, - unmatched=unmatched, + vm = build_juniors_view_model( + junior_members, adapted_members, sorted_months, result, transactions, + datetime.now().strftime("%Y-%m"), attendance_url=attendance_url, payments_url=payments_url, bank_account=BANK_ACCOUNT, - current_month=current_month ) + record_step("process_data") + return render_template("juniors.html", **vm) @app.route("/payments") def payments(): attendance_url = f"https://docs.google.com/spreadsheets/d/{ATTENDANCE_SHEET_ID}/edit" payments_url = f"https://docs.google.com/spreadsheets/d/{PAYMENTS_SHEET_ID}/edit" credentials_path = CREDENTIALS_PATH - + transactions = get_cached_data("payments_transactions", PAYMENTS_SHEET_ID, fetch_sheet_data, PAYMENTS_SHEET_ID, credentials_path) record_step("fetch_payments") @@ -567,23 +230,13 @@ def payments(): if juniors_data: member_names.extend(name for name, _, _ in juniors_data[0]) - grouped = group_payments_by_person(transactions, member_names) - # payments page also groups unmatched rows under a fallback key - for tx in transactions: - if not str(tx.get("person", "")).strip(): - grouped.setdefault("Unmatched / Unknown", []).append(tx) - for rows in grouped.values(): - rows.sort(key=lambda t: str(t.get("date", "")), reverse=True) - sorted_people = sorted(grouped.keys()) - - record_step("process_data") - return render_template( - "payments.html", - grouped_payments=grouped, - sorted_people=sorted_people, + vm = build_payments_view_model( + transactions, member_names, attendance_url=attendance_url, - payments_url=payments_url + payments_url=payments_url, ) + record_step("process_data") + return render_template("payments.html", **vm) @app.route("/qr") def qr_code(): diff --git a/docs/plans/2026-05-07-1431-m5-json-api-parity.md b/docs/plans/2026-05-07-1431-m5-json-api-parity.md new file mode 100644 index 0000000..db2a06b --- /dev/null +++ b/docs/plans/2026-05-07-1431-m5-json-api-parity.md @@ -0,0 +1,185 @@ +# Python view-model cleanup (M5 prep — Python side only) + +Scoped-down precursor to M5 of the Go rewrite. See: +- [2026-05-03-2349-go-backend-rewrite.md](2026-05-03-2349-go-backend-rewrite.md) +- [2026-05-03-2349-go-backend-rewrite-progress.md](2026-05-03-2349-go-backend-rewrite-progress.md) + +## 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](app.py#L179) — 167 LOC, computes per-row + status/cell_text/balance/credits/debts inline. +- [juniors_view](app.py#L347) — 205 LOC, same plus `"?"` sentinel + branching and `:NJ,MA` breakdown. +- [payments](app.py#L553) — 35 LOC, lighter but still mixes IO and + grouping. + +Pulling that into pure builder functions: +- shrinks `app.py` and 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/X` shadow endpoint with one line of `jsonify(...)`; +- has zero behavioural change (existing tests in + [test_app.py](tests/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`. + +```python +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. + +```python +@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 + +1. **Pure builders, no Flask state.** No `record_step`, no `g.*`, no + `get_cached_data` inside builders. They take plain args, return + plain dicts. This is what makes them trivially unit-testable and, + later, trivially reusable from `/api/X`. +2. **Preserve byte-equal behaviour.** The dicts returned must match + today's `render_template(...)` kwargs key-for-key, value-for-value + — including the existing `json.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/X` lands later, that route + will produce a sibling dict with the wrappers stripped, but that's + future work.) +3. **`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 so `record_step("reconcile")` + timing isn't lost. Builder takes `result` as an argument. +4. **Shared helpers stay in `app.py`** for now — + [get_month_labels](app.py#L41) and + [group_payments_by_person](app.py#L60) are already module-level + pure functions, used by routes and now by builders. Either leave + them in `app.py` and import into `scripts/views.py`, or move them + into `scripts/views.py`. **Choose: move into `scripts/views.py`** + — they're view-model concerns, not Flask concerns, and `app.py` + should keep shrinking. +5. **No new test file needed.** Existing + [tests/test_app.py](tests/test_app.py) tests + `test_adults_route`, `test_juniors_route`, `test_payments_route` + exercise 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_labels` and `group_payments_by_person` from + [app.py:41–77](app.py#L41-L77) into `scripts/views.py`. Update the + import in `app.py`. +- Implement `build_adults_view_model` by extracting + [app.py:200–344](app.py#L200-L344) (everything between `result = + reconcile(...)` and `return render_template(...)`). Take `result` + as a parameter; emit the same dict that's currently passed as + `**kwargs` to `render_template`. +- Implement `build_juniors_view_model` by extracting + [app.py:370–550](app.py#L370-L550). Same shape — including the + `adapted_members` adapter loop, the junior `?`-sentinel branches, + and the `:NJ,MA` breakdown. +- Implement `build_payments_view_model` by extracting + [app.py:570–586](app.py#L570-L586) (the `group_payments_by_person` + call + `Unmatched / Unknown` bucket + sort). + +### 2. Slim down the route handlers in `app.py` + +Each handler keeps: +- `attendance_url`/`payments_url` URL building +- `get_cached_data` calls (with `record_step` between) +- `reconcile(...)` call (adults/juniors) with `record_step("reconcile")` +- `record_step("process_data")` after the builder call +- `return 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](app.py) — routes shrink dramatically; `get_month_labels` + and `group_payments_by_person` get moved out. +- New: `scripts/views.py` — three builders + the two helpers. +- [tests/test_app.py](tests/test_app.py) — unchanged; serves as the + regression guard. + +## Verification + +1. `make test` — all four `TestWebApp` tests pass unchanged. +2. `make web-py` and visit `/adults`, `/juniors`, `/payments` — each + renders identically to before (same table contents, same totals, + same credits/debts, same `?` rendering on juniors). +3. `git diff app.py` shows substantial deletions (route bodies + shrink) and only thin glue calling the new builders. +4. Optional sanity check: temporarily add `print(repr(view_model))` + in the route before `render_template` on `main` and on the branch, + diff for one fixture run — should be byte-identical dicts. + +## Out of scope (future iterations, not this plan) + +- `/api/` shadow endpoints in Flask +- Go `internal/web/api/` DTOs and JSON Schemas +- Go `internal/services/membership/views.go` aggregators +- Go HTTP handlers for `/api/X` +- `cmd/parity/main.go` and `make parity` target +- 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. diff --git a/scripts/views.py b/scripts/views.py new file mode 100644 index 0000000..05e5055 --- /dev/null +++ b/scripts/views.py @@ -0,0 +1,445 @@ +import json +import re +from datetime import datetime + +from attendance import ADULT_MERGED_MONTHS, JUNIOR_MERGED_MONTHS +from match_payments import canonical_member_key + + +def get_month_labels(sorted_months, merged_months): + labels = {} + for m in sorted_months: + dt = datetime.strptime(m, "%Y-%m") + merged_in = sorted([k for k, v in merged_months.items() if v == m]) + if merged_in: + all_dts = [datetime.strptime(x, "%Y-%m") for x in sorted(merged_in + [m])] + years = {d.year for d in all_dts} + if len(years) > 1: + parts = [d.strftime("%b %Y") for d in all_dts] + labels[m] = "+".join(parts) + else: + parts = [d.strftime("%b") for d in all_dts] + labels[m] = f"{'+'.join(parts)} {dt.strftime('%Y')}" + else: + labels[m] = dt.strftime("%b %Y") + return labels + + +def group_payments_by_person(transactions, member_names=None): + canonical_by_key = ( + {canonical_member_key(n): n for n in member_names} if member_names else {} + ) + grouped = {} + for tx in transactions: + person = str(tx.get("person", "")).strip() + if not person: + continue + for p in person.split(","): + p = re.sub(r"\[\?\]\s*", "", p).strip() + if not p: + continue + key = canonical_by_key.get(canonical_member_key(p), p) + grouped.setdefault(key, []).append(tx) + for rows in grouped.values(): + rows.sort(key=lambda t: str(t.get("date", "")), reverse=True) + return grouped + + +def adapt_junior_members(junior_members): + """Convert 4-tuple junior fee data to (fee, total_count) for reconcile.""" + adapted = [] + for name, tier, fees_dict in junior_members: + adapted_fees = {} + for m, fee_data in fees_dict.items(): + if len(fee_data) == 4: + fee, total_count, _, _ = fee_data + adapted_fees[m] = (fee, total_count) + else: + fee, count = fee_data + adapted_fees[m] = (fee, count) + adapted.append((name, tier, adapted_fees)) + return adapted + + +def build_adults_view_model( + members, + sorted_months, + result, + transactions, + current_month, + *, + attendance_url, + payments_url, + bank_account, +): + month_labels = get_month_labels(sorted_months, ADULT_MERGED_MONTHS) + adult_names = sorted([name for name, tier, _ in members if tier == "A"]) + + monthly_totals = {m: {"expected": 0, "paid": 0} for m in sorted_months} + formatted_results = [] + for name in adult_names: + data = result["members"][name] + row = { + "name": name, + "months": [], + "balance": data["total_balance"], + "unpaid_periods": "", + "raw_unpaid_periods": "", + } + unpaid_months = [] + raw_unpaid_months = [] + for m in sorted_months: + mdata = data["months"].get(m, { + "expected": 0, "original_expected": 0, + "attendance_count": 0, "paid": 0, "exception": None, + }) + expected = mdata.get("expected", 0) + original_expected = mdata.get("original_expected", 0) + count = mdata.get("attendance_count", 0) + paid = int(mdata.get("paid", 0)) + exception_info = mdata.get("exception", None) + + monthly_totals[m]["expected"] += expected + monthly_totals[m]["paid"] += paid + + override_amount = exception_info["amount"] if exception_info else None + + if override_amount is not None and override_amount != original_expected: + is_overridden = True + fee_display = ( + f"{override_amount} ({original_expected}) CZK ({count})" + if count > 0 + else f"{override_amount} ({original_expected}) CZK" + ) + else: + is_overridden = False + fee_display = ( + f"{expected} CZK ({count})" if count > 0 else f"{expected} CZK" + ) + + status = "empty" + cell_text = "-" + amount_to_pay = 0 + + if expected > 0: + amount_to_pay = max(0, expected - paid) + if paid >= expected: + status = "ok" + cell_text = f"{paid}/{fee_display}" + elif paid > 0: + status = "partial" + cell_text = f"{paid}/{fee_display}" + if m < current_month: + unpaid_months.append(month_labels[m]) + raw_unpaid_months.append( + datetime.strptime(m, "%Y-%m").strftime("%m/%Y") + ) + else: + status = "unpaid" + cell_text = f"0/{fee_display}" + if m < current_month: + unpaid_months.append(month_labels[m]) + raw_unpaid_months.append( + datetime.strptime(m, "%Y-%m").strftime("%m/%Y") + ) + elif paid > 0: + status = "surplus" + cell_text = f"PAID {paid}" + else: + cell_text = "-" + amount_to_pay = 0 + + if expected > 0 or paid > 0: + tooltip = f"Received: {paid}, Expected: {expected}" + else: + tooltip = "" + + row["months"].append({ + "text": cell_text, + "overridden": is_overridden, + "status": status, + "amount": amount_to_pay, + "month": month_labels[m], + "raw_month": m, + "tooltip": tooltip, + }) + + settled_balance = 0 + for m, mdata in data["months"].items(): + if m >= current_month: + continue + exp = mdata.get("expected", 0) + if isinstance(exp, int): + settled_balance += int(mdata.get("paid", 0)) - exp + + payable_amount = max(0, -settled_balance) + row["unpaid_periods"] = ", ".join(unpaid_months) + row["raw_unpaid_periods"] = "+".join(raw_unpaid_months) + row["balance"] = settled_balance + row["payable_amount"] = payable_amount + formatted_results.append(row) + + formatted_totals = [] + for m in sorted_months: + t = monthly_totals[m] + status = "empty" + if t["expected"] > 0 or t["paid"] > 0: + if t["paid"] == t["expected"]: + status = "ok" + elif t["paid"] < t["expected"]: + status = "unpaid" + else: + status = "surplus" + formatted_totals.append({ + "text": f"{t['paid']} / {t['expected']} CZK", + "status": status, + }) + + def _settled_balance(name): + data = result["members"][name] + total = 0 + for m, mdata in data["months"].items(): + if m >= current_month: + continue + exp = mdata.get("expected", 0) + if isinstance(exp, int): + total += int(mdata.get("paid", 0)) - exp + return total + + credits = sorted( + [{"name": n, "amount": _settled_balance(n)} for n in adult_names if _settled_balance(n) > 0], + key=lambda x: x["name"], + ) + debts = sorted( + [{"name": n, "amount": abs(_settled_balance(n))} for n in adult_names if _settled_balance(n) < 0], + key=lambda x: x["name"], + ) + + raw_payments_by_person = group_payments_by_person( + transactions, [name for name, _, _ in members] + ) + + return dict( + months=[month_labels[m] for m in sorted_months], + raw_months=sorted_months, + results=formatted_results, + totals=formatted_totals, + member_data=json.dumps(result["members"]), + month_labels_json=json.dumps(month_labels), + raw_payments_json=json.dumps(raw_payments_by_person), + credits=credits, + debts=debts, + unmatched=result["unmatched"], + attendance_url=attendance_url, + payments_url=payments_url, + bank_account=bank_account, + current_month=current_month, + ) + + +def build_juniors_view_model( + junior_members, + adapted_members, + sorted_months, + result, + transactions, + current_month, + *, + attendance_url, + payments_url, + bank_account, +): + month_labels = get_month_labels(sorted_months, JUNIOR_MERGED_MONTHS) + junior_names = sorted([name for name, tier, _ in adapted_members]) + junior_members_dict = {name: fees_dict for name, _, fees_dict in junior_members} + + monthly_totals = {m: {"expected": 0, "paid": 0} for m in sorted_months} + formatted_results = [] + for name in junior_names: + data = result["members"][name] + row = { + "name": name, + "months": [], + "balance": data["total_balance"], + "unpaid_periods": "", + "raw_unpaid_periods": "", + } + unpaid_months = [] + raw_unpaid_months = [] + for m in sorted_months: + mdata = data["months"].get(m, { + "expected": 0, "original_expected": 0, + "attendance_count": 0, "paid": 0, "exception": None, + }) + expected = mdata.get("expected", 0) + original_expected = mdata.get("original_expected", 0) + count = mdata.get("attendance_count", 0) + paid = int(mdata.get("paid", 0)) + exception_info = mdata.get("exception", None) + + if expected != "?" and isinstance(expected, int): + monthly_totals[m]["expected"] += expected + monthly_totals[m]["paid"] += paid + + orig_fee_data = junior_members_dict.get(name, {}).get(m) + adult_count = 0 + junior_count = 0 + if orig_fee_data and len(orig_fee_data) == 4: + _, _, adult_count, junior_count = orig_fee_data + + breakdown = "" + if adult_count > 0 and junior_count > 0: + breakdown = f":{junior_count}J,{adult_count}A" + elif junior_count > 0: + breakdown = f":{junior_count}J" + elif adult_count > 0: + breakdown = f":{adult_count}A" + + count_str = f" ({count}{breakdown})" if count > 0 else "" + + override_amount = exception_info["amount"] if exception_info else None + + if override_amount is not None and override_amount != original_expected: + is_overridden = True + fee_display = f"{override_amount} ({original_expected}) CZK{count_str}" + else: + is_overridden = False + fee_display = f"{expected} CZK{count_str}" + + status = "empty" + cell_text = "-" + amount_to_pay = 0 + + if expected == "?" or (isinstance(expected, int) and expected > 0): + if expected == "?": + status = "empty" + cell_text = f"?{count_str}" + elif paid >= expected: + status = "ok" + cell_text = f"{paid}/{fee_display}" + elif paid > 0: + status = "partial" + cell_text = f"{paid}/{fee_display}" + amount_to_pay = expected - paid + if m < current_month: + unpaid_months.append(month_labels[m]) + raw_unpaid_months.append( + datetime.strptime(m, "%Y-%m").strftime("%m/%Y") + ) + else: + status = "unpaid" + cell_text = f"0/{fee_display}" + amount_to_pay = expected + if m < current_month: + unpaid_months.append(month_labels[m]) + raw_unpaid_months.append( + datetime.strptime(m, "%Y-%m").strftime("%m/%Y") + ) + elif paid > 0: + status = "surplus" + cell_text = f"PAID {paid}" + + if (isinstance(expected, int) and expected > 0) or paid > 0: + tooltip = f"Received: {paid}, Expected: {expected}" + else: + tooltip = "" + + row["months"].append({ + "text": cell_text, + "overridden": is_overridden, + "status": status, + "amount": amount_to_pay, + "month": month_labels[m], + "raw_month": m, + "tooltip": tooltip, + }) + + settled_balance = 0 + for m, mdata in data["months"].items(): + if m >= current_month: + continue + exp = mdata.get("expected", 0) + if isinstance(exp, int): + settled_balance += int(mdata.get("paid", 0)) - exp + + payable_amount = max(0, -settled_balance) + row["unpaid_periods"] = ", ".join(unpaid_months) + row["raw_unpaid_periods"] = "+".join(raw_unpaid_months) + row["balance"] = settled_balance + row["payable_amount"] = payable_amount + formatted_results.append(row) + + formatted_totals = [] + for m in sorted_months: + t = monthly_totals[m] + status = "empty" + if t["expected"] > 0 or t["paid"] > 0: + if t["paid"] == t["expected"]: + status = "ok" + elif t["paid"] < t["expected"]: + status = "unpaid" + else: + status = "surplus" + formatted_totals.append({ + "text": f"{t['paid']} / {t['expected']} CZK", + "status": status, + }) + + junior_all_names = [name for name, _, _ in adapted_members] + + def _junior_settled_balance(name): + data = result["members"][name] + total = 0 + for m, mdata in data["months"].items(): + if m >= current_month: + continue + exp = mdata.get("expected", 0) + if isinstance(exp, int): + total += int(mdata.get("paid", 0)) - exp + return total + + credits = sorted( + [{"name": n, "amount": _junior_settled_balance(n)} for n in junior_all_names if _junior_settled_balance(n) > 0], + key=lambda x: x["name"], + ) + debts = sorted( + [{"name": n, "amount": abs(_junior_settled_balance(n))} for n in junior_all_names if _junior_settled_balance(n) < 0], + key=lambda x: x["name"], + ) + + raw_payments_by_person = group_payments_by_person( + transactions, [name for name, _, _ in adapted_members] + ) + + return dict( + months=[month_labels[m] for m in sorted_months], + raw_months=sorted_months, + results=formatted_results, + totals=formatted_totals, + member_data=json.dumps(result["members"]), + month_labels_json=json.dumps(month_labels), + raw_payments_json=json.dumps(raw_payments_by_person), + credits=credits, + debts=debts, + unmatched=result["unmatched"], + attendance_url=attendance_url, + payments_url=payments_url, + bank_account=bank_account, + current_month=current_month, + ) + + +def build_payments_view_model(transactions, member_names, *, attendance_url, payments_url): + grouped = group_payments_by_person(transactions, member_names) + for tx in transactions: + if not str(tx.get("person", "")).strip(): + grouped.setdefault("Unmatched / Unknown", []).append(tx) + for rows in grouped.values(): + rows.sort(key=lambda t: str(t.get("date", "")), reverse=True) + sorted_people = sorted(grouped.keys()) + return dict( + grouped_payments=grouped, + sorted_people=sorted_people, + attendance_url=attendance_url, + payments_url=payments_url, + ) From 2eec51bb34409a413a2dca1b8c8fd4da00bb6e37 Mon Sep 17 00:00:00 2001 From: Jan Novak Date: Thu, 7 May 2026 15:24:40 +0200 Subject: [PATCH 2/2] fix(app): restore missing import re needed by qr_code route Accidentally removed when moving group_payments_by_person to views.py; re.match in qr_code caused a 500 on every QR request. Co-Authored-By: Claude Opus 4.7 --- app.py | 1 + 1 file changed, 1 insertion(+) diff --git a/app.py b/app.py index 9e89fc9..85cc440 100644 --- a/app.py +++ b/app.py @@ -1,6 +1,7 @@ import sys from pathlib import Path from datetime import datetime +import re import time import os import io