Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
8.7 KiB
M5.3 — Python /api/X shadow endpoints
Companion to:
- 2026-05-03-2349-go-backend-rewrite.md (master design)
- 2026-05-03-2349-go-backend-rewrite-progress.md (M5.3 row)
- 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 (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) 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 <script> blocks. M5.1's plan called this out explicitly: M5.3's /api/X is jsonify(unwrap_json_strings(view_model_dict)) — a 4-line shim, not real transformation logic.
Approach
Add four shadow routes to app.py and one private unwrap helper. Builders and templates are untouched.
Decisions
- Unwrap shim lives in
app.py, notscripts/views.py. It's 4 lines, only the API routes use it, and it's parity-only scaffolding that M8 will delete. Keepingviews.pyfree of HTTP-layer concerns means cleaner deletion later. - No data-loading helper extraction. Each shadow route duplicates ~8 lines of cache loads from its sibling HTML route. A helper would have to thread
attendance_url/payments_url/bank_accountand the adults-vs-juniors-vs-payments branching back out — net negative for code that M8 will erase wholesale. - Drop
record_stepcalls in API routes.record_steponly feedsinject_render_time(a Jinjacontext_processor); JSON responses don't go through templates, so timing breakdown has no consumer. /api/versionis a one-liner.BUILD_METAalready has the keys (tag,commit,build_date) Go emits. Justjsonify(BUILD_META). Existing/versionroute stays as-is — the new endpoint sits alongside.- Tests assert key sets and unwrap, not values. Hard-code
EXPECTED_ADULTS_KEYSetc. as module constants in tests/test_app.py. Catches drift in unit tests rather than waiting for M5.4 parity diffs. Type-check the unwrapped fields (isinstance(member_data, dict)etc.) to prove the shim ran.
The shim
def _unwrap_view_model_for_api(vm: dict) -> dict:
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
Note the rename: month_labels_json → month_labels, raw_payments_json → raw_payments (matches Go contract per adults.go JSON tags).
Route skeleton (/api/adults)
@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))
/api/juniors mirrors with adapt_junior_members + JUNIOR_SHEET_GID + build_juniors_view_model. /api/payments skips the unwrap (its builder has no JSON-string fields): return jsonify(vm). /api/version: return jsonify(BUILD_META).
Files to modify
- app.py
- L10: add
jsonifytofrom flask import .... - Add
_unwrap_view_model_for_apinear BUILD_META (it already importsjson as _json). - Add four routes:
/api/version,/api/adults,/api/juniors,/api/payments. Place them after/versionfor grouping.
- L10: add
- tests/test_app.py
- Add
EXPECTED_ADULTS_KEYS,EXPECTED_JUNIORS_KEYS,EXPECTED_PAYMENTS_KEYS,EXPECTED_VERSION_KEYSmodule constants (sourced from adults.go / juniors.go / payments.go / version.go JSON tags). - Four new test functions:
test_api_adults,test_api_juniors,test_api_payments,test_api_version. Reuse the existing_bypass_cachepatcher and thefetch_sheet_data/fetch_exceptions/get_members_with_fees/get_junior_members_with_feesmocks already in the file. - Each adults/juniors test asserts:
200,response.is_json,set(json.keys()) == EXPECTED_*_KEYS,isinstance(json["member_data"], dict),isinstance(json["month_labels"], dict),isinstance(json["raw_payments"], dict).
- Add
- CHANGELOG.md: post-merge entry per CLAUDE.md format.
- docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md:102: tick M5.3 with merge SHA.
Reusable existing code
build_adults_view_model,build_juniors_view_model,build_payments_view_model— call as-is, no changes.reconcileandadapt_junior_members— same.get_cached_data,fetch_sheet_data,fetch_exceptions,get_members_with_fees,get_junior_members_with_fees— same call sites as the HTML routes.BUILD_META— already shaped to match Go'sVersionResponse.
Verification
make test— all existing tests still pass; four newtest_api_*tests pass.python -c "import app"— catches the missingjsonifyimport.make web-py, then:curl -s localhost:5001/api/version | jq .→{tag, commit, build_date}.curl -s localhost:5001/api/adults | jq 'keys'→ 14 keys, no_jsonsuffix anywhere.curl -s localhost:5001/api/adults | jq '.member_data | type, .month_labels | type, .raw_payments | type'→ all"object"(proves unwrap).- Same checks on
/api/juniorsand/api/payments.
- Visit
/adults,/juniors,/paymentsin browser — HTML still renders identically (regression check on builders). - Optional pre-M5.4 peek:
make web-goon :8080 +make web-pyon :5001, thendiff <(curl -s :5001/api/adults | jq -S .) <(curl -s :8080/api/adults | jq -S .). Expect non-zero diff (raw transaction key shape, see below) — that is fine; M5.4 surfaces and resolves these.
Out of scope (M5.4 will surface and resolve)
These are known parity friction points; don't fix in M5.3 — the whole point of M5.4 is to enumerate them:
raw_payments[name][i]row shape: Python emits raw Google Sheets row dicts with column-header keys (e.g."VS","Sync ID"); Go'sRawTransactionuses snake_case (vs,sync_id). Keys and types will diverge.unmatched[]: same divergence (same raw row dicts).nullvs missing keys: Python omits keys never set; Go zero-value structs may emitnull/""depending onomitempty.Decimal/ float precision ingrouped_paymentsamounts.- Field insertion order:
jsonifypreserves insertion order (Python 3.7+); Go marshals struct fields in declaration order. Likely fine, parity tool will tell.
Branch + MR
Per CLAUDE.md: branch feat/go-m5-3-python-api-shadow, push with -u, open MR via tea pr create --title ... --description ... --base main --head feat/go-m5-3-python-api-shadow. Do not merge from CLI.