All checks were successful
Deploy to K8s / deploy (push) Successful in 17s
Show only the last MONTHS_TO_SHOW months (default 5) in the fee table columns so the page fits on screen without horizontal scrolling. Reconciliation still runs over the full month history so balances, credits, and debts are unaffected. Set MONTHS_TO_SHOW=0 to show all months. Implemented in both Python and Go. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
366 lines
14 KiB
Python
366 lines
14 KiB
Python
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 _last_n_months(months):
|
|
"""Return the last MONTHS_TO_SHOW months; 0 means show all."""
|
|
return months[-MONTHS_TO_SHOW:] if MONTHS_TO_SHOW > 0 else months
|
|
|
|
|
|
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 '<meta http-equiv="refresh" content="0; url=/adults" />'
|
|
|
|
@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, _last_n_months(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, _last_n_months(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, _last_n_months(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", **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, _last_n_months(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", **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)
|