feat(py): M5.3 — add Python /api/* shadow endpoints
Four new JSON routes mirror the Go /api/* handlers so the M5.4 parity tool can diff them: /api/version, /api/adults, /api/juniors, /api/payments. A small _unwrap_view_model_for_api() helper in app.py expands the three pre-serialised JSON strings in the view-model dicts and renames month_labels_json → month_labels and raw_payments_json → raw_payments to match the Go wire contract. Tests in test_app.py assert top-level key sets match the Go API schema and that member_data, month_labels, raw_payments are objects not strings. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
81
app.py
81
app.py
@@ -7,7 +7,7 @@ import os
|
|||||||
import io
|
import io
|
||||||
import qrcode
|
import qrcode
|
||||||
import logging
|
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
|
# Configure logging, allowing override via LOG_LEVEL environment variable
|
||||||
log_level = os.environ.get("LOG_LEVEL", "INFO").upper()
|
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": ""
|
"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()
|
warmup_cache()
|
||||||
|
|
||||||
@app.before_request
|
@app.before_request
|
||||||
@@ -144,6 +154,75 @@ def sync_bank():
|
|||||||
def version():
|
def version():
|
||||||
return BUILD_META
|
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")
|
@app.route("/adults")
|
||||||
def adults_view():
|
def adults_view():
|
||||||
attendance_url = f"https://docs.google.com/spreadsheets/d/{ATTENDANCE_SHEET_ID}/edit"
|
attendance_url = f"https://docs.google.com/spreadsheets/d/{ATTENDANCE_SHEET_ID}/edit"
|
||||||
|
|||||||
@@ -1,7 +1,17 @@
|
|||||||
import unittest
|
import unittest
|
||||||
|
import json
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
from app import app
|
from app import app
|
||||||
|
|
||||||
|
EXPECTED_ADULTS_KEYS = {
|
||||||
|
"months", "raw_months", "results", "totals", "member_data", "month_labels",
|
||||||
|
"raw_payments", "credits", "debts", "unmatched", "attendance_url",
|
||||||
|
"payments_url", "bank_account", "current_month",
|
||||||
|
}
|
||||||
|
EXPECTED_JUNIORS_KEYS = EXPECTED_ADULTS_KEYS
|
||||||
|
EXPECTED_PAYMENTS_KEYS = {"grouped_payments", "sorted_people", "attendance_url", "payments_url"}
|
||||||
|
EXPECTED_VERSION_KEYS = {"tag", "commit", "build_date"}
|
||||||
|
|
||||||
|
|
||||||
def _bypass_cache(cache_key, sheet_id, fetch_func, *args, serialize=None, deserialize=None, **kwargs):
|
def _bypass_cache(cache_key, sheet_id, fetch_func, *args, serialize=None, deserialize=None, **kwargs):
|
||||||
"""Test helper: call fetch_func directly, bypassing the cache layer."""
|
"""Test helper: call fetch_func directly, bypassing the cache layer."""
|
||||||
@@ -97,5 +107,80 @@ class TestWebApp(unittest.TestCase):
|
|||||||
self.assertIn(b'500/500 CZK', response.data)
|
self.assertIn(b'500/500 CZK', response.data)
|
||||||
self.assertIn(b'?', response.data)
|
self.assertIn(b'?', response.data)
|
||||||
|
|
||||||
|
def test_api_version(self):
|
||||||
|
"""Test /api/version returns BUILD_META keys as JSON."""
|
||||||
|
response = self.client.get('/api/version')
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertTrue(response.is_json)
|
||||||
|
data = json.loads(response.data)
|
||||||
|
self.assertEqual(set(data.keys()), EXPECTED_VERSION_KEYS)
|
||||||
|
|
||||||
|
@patch('app.get_cached_data', side_effect=_bypass_cache)
|
||||||
|
@patch('app.fetch_sheet_data')
|
||||||
|
@patch('app.fetch_exceptions', return_value={})
|
||||||
|
@patch('app.get_members_with_fees')
|
||||||
|
def test_api_adults(self, mock_get_members, mock_exceptions, mock_fetch_sheet, mock_cache):
|
||||||
|
"""Test /api/adults returns JSON with correct top-level keys and unwrapped fields."""
|
||||||
|
mock_get_members.return_value = (
|
||||||
|
[('Test Member', 'A', {'2026-01': (750, 4)})],
|
||||||
|
['2026-01']
|
||||||
|
)
|
||||||
|
mock_fetch_sheet.return_value = [{
|
||||||
|
'date': '2026-01-01', 'amount': 750, 'person': 'Test Member',
|
||||||
|
'purpose': '2026-01', 'message': 'test payment',
|
||||||
|
'sender': 'External Bank User', 'inferred_amount': 750,
|
||||||
|
}]
|
||||||
|
response = self.client.get('/api/adults')
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertTrue(response.is_json)
|
||||||
|
data = json.loads(response.data)
|
||||||
|
self.assertEqual(set(data.keys()), EXPECTED_ADULTS_KEYS)
|
||||||
|
self.assertIsInstance(data['member_data'], dict)
|
||||||
|
self.assertIsInstance(data['month_labels'], dict)
|
||||||
|
self.assertIsInstance(data['raw_payments'], dict)
|
||||||
|
|
||||||
|
@patch('app.get_cached_data', side_effect=_bypass_cache)
|
||||||
|
@patch('app.fetch_sheet_data')
|
||||||
|
@patch('app.fetch_exceptions', return_value={})
|
||||||
|
@patch('app.get_junior_members_with_fees')
|
||||||
|
def test_api_juniors(self, mock_get_junior_members, mock_exceptions, mock_fetch_sheet, mock_cache):
|
||||||
|
"""Test /api/juniors returns JSON with correct top-level keys and unwrapped fields."""
|
||||||
|
mock_get_junior_members.return_value = (
|
||||||
|
[
|
||||||
|
('Junior One', 'J', {'2026-01': (500, 3, 0, 3)}),
|
||||||
|
('Junior Two', 'X', {'2026-01': ('?', 1, 0, 1)}),
|
||||||
|
],
|
||||||
|
['2026-01']
|
||||||
|
)
|
||||||
|
mock_fetch_sheet.return_value = [{
|
||||||
|
'date': '2026-01-15', 'amount': 500, 'person': 'Junior One',
|
||||||
|
'purpose': '2026-01', 'message': '', 'sender': 'Parent', 'inferred_amount': 500,
|
||||||
|
}]
|
||||||
|
response = self.client.get('/api/juniors')
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertTrue(response.is_json)
|
||||||
|
data = json.loads(response.data)
|
||||||
|
self.assertEqual(set(data.keys()), EXPECTED_JUNIORS_KEYS)
|
||||||
|
self.assertIsInstance(data['member_data'], dict)
|
||||||
|
self.assertIsInstance(data['month_labels'], dict)
|
||||||
|
self.assertIsInstance(data['raw_payments'], dict)
|
||||||
|
|
||||||
|
@patch('app.get_cached_data', side_effect=_bypass_cache)
|
||||||
|
@patch('app.fetch_sheet_data')
|
||||||
|
def test_api_payments(self, mock_fetch_sheet, mock_cache):
|
||||||
|
"""Test /api/payments returns JSON with correct top-level keys."""
|
||||||
|
mock_fetch_sheet.return_value = [{
|
||||||
|
'date': '2026-01-01', 'amount': 750, 'person': 'Test Member',
|
||||||
|
'purpose': '2026-01', 'message': 'test', 'sender': 'Someone',
|
||||||
|
}]
|
||||||
|
response = self.client.get('/api/payments')
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertTrue(response.is_json)
|
||||||
|
data = json.loads(response.data)
|
||||||
|
self.assertEqual(set(data.keys()), EXPECTED_PAYMENTS_KEYS)
|
||||||
|
self.assertIsInstance(data['grouped_payments'], dict)
|
||||||
|
self.assertIsInstance(data['sorted_people'], list)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Reference in New Issue
Block a user