diff --git a/CHANGELOG.md b/CHANGELOG.md index f3d2fcf..a3684f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## 2026-05-07 22:37 CEST — feat(py): M5.3 — Python /api/* shadow endpoints + +- `app.py`: four new JSON routes (`/api/version`, `/api/adults`, `/api/juniors`, `/api/payments`) mirroring the Go `/api/*` handlers; `_unwrap_view_model_for_api()` helper expands pre-serialised JSON strings and renames `month_labels_json` → `month_labels`, `raw_payments_json` → `raw_payments` to match Go wire contract. +- `tests/test_app.py`: four new smoke tests asserting top-level key sets and that unwrapped fields are objects (not strings). + ## 2026-05-07 20:13 CEST — feat(go): M5.2 — HTTP handlers for /api/adults, /api/juniors, /api/payments, /api/version - `web/api/handler.go`: `Handler` struct + `ServeAdults`, `ServeJuniors`, `ServePayments`, `ServeVersion` using `membership.Sources`. diff --git a/app.py b/app.py index 85cc440..4f33b81 100644 --- a/app.py +++ b/app.py @@ -7,7 +7,7 @@ import os import io import qrcode import logging -from flask import Flask, render_template, g, send_file, request +from flask import Flask, render_template, g, send_file, request, jsonify # Configure logging, allowing override via LOG_LEVEL environment variable log_level = os.environ.get("LOG_LEVEL", "INFO").upper() @@ -68,6 +68,16 @@ BUILD_META = _json.loads(_meta_path.read_text()) if _meta_path.exists() else { "tag": "dev", "commit": "local", "build_date": "" } + +def _unwrap_view_model_for_api(vm: dict) -> dict: + """Expand pre-stringified JSON fields and rename to match Go API contract.""" + out = dict(vm) + out["member_data"] = _json.loads(out.pop("member_data")) + out["month_labels"] = _json.loads(out.pop("month_labels_json")) + out["raw_payments"] = _json.loads(out.pop("raw_payments_json")) + return out + + warmup_cache() @app.before_request @@ -144,6 +154,75 @@ def sync_bank(): def version(): return BUILD_META +@app.route("/api/version") +def api_version(): + return jsonify(BUILD_META) + +@app.route("/api/adults") +def api_adults(): + 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" + members_data = get_cached_data("attendance_regular", ATTENDANCE_SHEET_ID, get_members_with_fees) + if not members_data: + return jsonify({"error": "no data"}), 503 + members, sorted_months = members_data + transactions = get_cached_data("payments_transactions", PAYMENTS_SHEET_ID, fetch_sheet_data, PAYMENTS_SHEET_ID, CREDENTIALS_PATH) + 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}, + ) + result = reconcile(members, sorted_months, transactions, exceptions) + 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, + ) + return jsonify(_unwrap_view_model_for_api(vm)) + +@app.route("/api/juniors") +def api_juniors(): + 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" + junior_members_data = get_cached_data("attendance_juniors", ATTENDANCE_SHEET_ID, get_junior_members_with_fees) + if not junior_members_data: + return jsonify({"error": "no data"}), 503 + junior_members, sorted_months = junior_members_data + transactions = get_cached_data("payments_transactions", PAYMENTS_SHEET_ID, fetch_sheet_data, PAYMENTS_SHEET_ID, CREDENTIALS_PATH) + 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}, + ) + adapted_members = adapt_junior_members(junior_members) + result = reconcile(adapted_members, sorted_months, transactions, exceptions) + 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, + ) + return jsonify(_unwrap_view_model_for_api(vm)) + +@app.route("/api/payments") +def api_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" + transactions = get_cached_data("payments_transactions", PAYMENTS_SHEET_ID, fetch_sheet_data, PAYMENTS_SHEET_ID, CREDENTIALS_PATH) + adults_data = get_cached_data("attendance_regular", ATTENDANCE_SHEET_ID, get_members_with_fees) + juniors_data = get_cached_data("attendance_juniors", ATTENDANCE_SHEET_ID, get_junior_members_with_fees) + member_names = [] + if adults_data: + member_names.extend(name for name, _, _ in adults_data[0]) + if juniors_data: + member_names.extend(name for name, _, _ in juniors_data[0]) + vm = build_payments_view_model( + transactions, member_names, + attendance_url=attendance_url, payments_url=payments_url, + ) + return jsonify(vm) + @app.route("/adults") def adults_view(): attendance_url = f"https://docs.google.com/spreadsheets/d/{ATTENDANCE_SHEET_ID}/edit" diff --git a/docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md b/docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md index 883b487..b97f69f 100644 --- a/docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md +++ b/docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md @@ -99,7 +99,7 @@ Goal: byte-equal JSON between Python and Go for every route. This is the parity - [x] **M5.1** Hand-author Go structs for `/api/adults`, `/api/juniors`, `/api/payments`, `/api/version` with explicit `json:` tags matching Python keys; emit JSON Schemas via `github.com/invopop/jsonschema` to `tests/fixtures/api-schema/` — `f253e3f` - [x] **M5.2** Implement Go handlers for `/api/*` routes composing `services/*` results into the JSON structs — `7d48e8f` -- [ ] **M5.3** Add Python `/api/X` shadow endpoints in [app.py](app.py): `jsonify(view_model_dict)` — no transformation +- [x] **M5.3** Add Python `/api/X` shadow endpoints in [app.py](app.py): `jsonify(view_model_dict)` — no transformation — `40e4a9e` - [ ] **M5.4** Build `cmd/parity/main.go`: hits both backends' `/api/X`, normalizes allowlist (`render_time.total`, `build_meta`), prints `cmp.Diff`. Add `make parity` target **Gate:** For each route, `make parity` reports zero non-allowlisted diffs across the M3 fixture corpus. diff --git a/docs/plans/2026-05-07-2114-go-rewrite-m5-3-python-api-shadow.md b/docs/plans/2026-05-07-2114-go-rewrite-m5-3-python-api-shadow.md new file mode 100644 index 0000000..b7d75a7 --- /dev/null +++ b/docs/plans/2026-05-07-2114-go-rewrite-m5-3-python-api-shadow.md @@ -0,0 +1,113 @@ +# M5.3 — Python `/api/X` shadow endpoints + +Companion to: +- [2026-05-03-2349-go-backend-rewrite.md](2026-05-03-2349-go-backend-rewrite.md) (master design) +- [2026-05-03-2349-go-backend-rewrite-progress.md](2026-05-03-2349-go-backend-rewrite-progress.md) (M5.3 row) +- [2026-05-07-1431-m5-json-api-parity.md](2026-05-07-1431-m5-json-api-parity.md) (Python view-model extraction prep) +- [2026-05-07-1650-go-rewrite-m5-1-api-structs-schemas.md](2026-05-07-1650-go-rewrite-m5-1-api-structs-schemas.md) (Go wire types) + +## Context + +M5.1 (Go wire types + JSON Schemas) and M5.2 (Go HTTP handlers for `/api/adults` `/api/juniors` `/api/payments` `/api/version`) have merged. M5.3 mirrors the same four endpoints on the Python Flask side so M5.4's `cmd/parity` tool can hit both backends and diff the JSON. After M5.3, every byte the Go side emits has a Python counterpart to compare against. + +The Python view-model builders ([scripts/views.py](scripts/views.py)) already produce dicts very close to the wire shape — except three template-only fields (`member_data`, `month_labels_json`, `raw_payments_json`) are pre-`json.dumps`'d for inline `