import sys from pathlib import Path from datetime import datetime import re import time import os import io import qrcode import logging 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() logging.basicConfig(level=getattr(logging, log_level, logging.INFO), format='%(asctime)s - %(name)s:%(filename)s:%(lineno)d [%(funcName)s] - %(levelname)s - %(message)s') # Add scripts directory to path to allow importing from it scripts_dir = Path(__file__).parent / "scripts" sys.path.append(str(scripts_dir)) from config import ( ATTENDANCE_SHEET_ID, PAYMENTS_SHEET_ID, JUNIOR_SHEET_GID, BANK_ACCOUNT, CREDENTIALS_PATH, MONTHS_TO_SHOW, ) 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 def get_cached_data(cache_key, sheet_id, fetch_func, *args, serialize=None, deserialize=None, **kwargs): mod_time = get_sheet_modified_time(cache_key) if mod_time: cached = read_cache(cache_key, mod_time) if cached is not None: return deserialize(cached) if deserialize else cached data = fetch_func(*args, **kwargs) if mod_time: write_cache(cache_key, mod_time, serialize(data) if serialize else data) return data def warmup_cache(): """Pre-fetch all cached data so first request is fast.""" logger = logging.getLogger(__name__) logger.info("Warming up cache...") credentials_path = CREDENTIALS_PATH get_cached_data("attendance_regular", ATTENDANCE_SHEET_ID, get_members_with_fees) get_cached_data("attendance_juniors", ATTENDANCE_SHEET_ID, get_junior_members_with_fees) get_cached_data("payments_transactions", PAYMENTS_SHEET_ID, fetch_sheet_data, PAYMENTS_SHEET_ID, credentials_path) 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}, ) logger.info("Cache warmup complete.") app = Flask(__name__) import json as _json _meta_path = Path(__file__).parent / "build_meta.json" 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 def start_timer(): g.start_time = time.perf_counter() g.steps = [] def record_step(name): g.steps.append((name, time.perf_counter())) @app.context_processor def inject_render_time(): def get_render_time(): total = time.perf_counter() - g.start_time breakdown = [] last_time = g.start_time for name, timestamp in g.steps: duration = timestamp - last_time breakdown.append(f"{name}:{duration:.3f}s") last_time = timestamp # Add remaining time as 'render' render_duration = time.perf_counter() - last_time breakdown.append(f"render:{render_duration:.3f}s") return { "total": f"{total:.3f}", "breakdown": " | ".join(breakdown) } return dict(get_render_time=get_render_time, build_meta=BUILD_META) @app.route("/") def index(): # Redirect root to /adults for convenience while there are no other apps return '' @app.route("/flush-cache", methods=["GET", "POST"]) def flush_cache_endpoint(): if request.method == "GET": return render_template("flush-cache.html") deleted = flush_cache() return render_template("flush-cache.html", flushed=True, deleted=deleted) @app.route("/sync-bank") def sync_bank(): import contextlib output = io.StringIO() success = True try: with contextlib.redirect_stdout(output), contextlib.redirect_stderr(output): # sync_to_sheets: equivalent of make sync-2026 output.write("=== Syncing Fio transactions (2026) ===\n") sync_to_sheets( spreadsheet_id=PAYMENTS_SHEET_ID, credentials_path=CREDENTIALS_PATH, date_from_str="2026-01-01", date_to_str="2026-12-31", sort_by_date=True, ) output.write("\n=== Inferring payment details ===\n") infer_payments(PAYMENTS_SHEET_ID, CREDENTIALS_PATH) output.write("\n=== Flushing cache ===\n") deleted = flush_cache() output.write(f"Deleted {deleted} cache files.\n") output.write("\n=== Done ===\n") except Exception as e: import traceback output.write(f"\n!!! Error: {e}\n") output.write(traceback.format_exc()) success = False return render_template("sync.html", output=output.getvalue(), success=success) @app.route("/version") 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" 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}, ) record_step("fetch_exceptions") result = reconcile(members, sorted_months, transactions, exceptions) record_step("reconcile") 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, ) record_step("process_data") return render_template("adults.html", months_to_show=MONTHS_TO_SHOW, **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}, ) record_step("fetch_exceptions") adapted_members = adapt_junior_members(junior_members) result = reconcile(adapted_members, sorted_months, transactions, exceptions) record_step("reconcile") 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, ) record_step("process_data") return render_template("juniors.html", months_to_show=MONTHS_TO_SHOW, **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") 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, ) record_step("process_data") return render_template("payments.html", **vm) @app.route("/qr") def qr_code(): account = request.args.get("account", BANK_ACCOUNT) amount = request.args.get("amount", "0") message = request.args.get("message", "") # Validate account: allow IBAN (letters+digits) or Czech format (digits/digits) if not re.match(r'^[A-Z]{2}\d{2,34}$|^\d{1,16}/\d{4}$', account): account = BANK_ACCOUNT # QR Platba standard: SPD*1.0*ACC:accountNumber*BC:bankCode*AM:amount*CC:CZK*MSG:message acc_parts = account.split('/') if len(acc_parts) == 2: acc_str = f"{acc_parts[0]}*BC:{acc_parts[1]}" else: acc_str = account try: amt_val = float(amount) if amt_val < 0 or amt_val > 10_000_000: amt_val = 0 amt_str = f"{amt_val:.2f}" except ValueError: amt_str = "0.00" # Message max 60 characters, strip SPD delimiters to prevent injection msg_str = message[:60].replace("*", "") qr_data = f"SPD*1.0*ACC:{acc_str}*AM:{amt_str}*CC:CZK*MSG:{msg_str}" img = qrcode.make(qr_data) buf = io.BytesIO() img.save(buf, format='PNG') buf.seek(0) return send_file(buf, mimetype='image/png') if __name__ == "__main__": app.run(debug=True, host='0.0.0.0', port=5001)