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/tests/test_app.py b/tests/test_app.py index f3a1dae..dc9f324 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -1,7 +1,17 @@ import unittest +import json from unittest.mock import patch 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): """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'?', 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__': unittest.main()