Compare commits
21 Commits
0.15
...
ced238385e
| Author | SHA1 | Date | |
|---|---|---|---|
| ced238385e | |||
| 77743019b0 | |||
| f712198319 | |||
| 1ac5df7be5 | |||
| 109ef983f0 | |||
| 083a51023c | |||
| 54762cd421 | |||
| b2aaca5df9 | |||
| 883bc4489e | |||
| 3ad4a21f5b | |||
| 3c1604c7af | |||
| 8b3223f865 | |||
| 276e18a9c8 | |||
| 61f2126c1b | |||
| 3377092a3f | |||
| dca0c6c933 | |||
| 9b99f6d33b | |||
| e83d6af1f5 | |||
| 7d51f9ca77 | |||
| 033349cafa | |||
| 0d0c2af778 |
@@ -31,5 +31,9 @@ jobs:
|
|||||||
TAG=${{ inputs.tag }}
|
TAG=${{ inputs.tag }}
|
||||||
fi
|
fi
|
||||||
IMAGE=gitea.home.hrajfrisbee.cz/${{ github.repository }}:$TAG
|
IMAGE=gitea.home.hrajfrisbee.cz/${{ github.repository }}:$TAG
|
||||||
docker build -f build/Dockerfile -t $IMAGE .
|
docker build -f build/Dockerfile \
|
||||||
|
--build-arg GIT_TAG=$TAG \
|
||||||
|
--build-arg GIT_COMMIT=${{ github.sha }} \
|
||||||
|
--build-arg BUILD_DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ) \
|
||||||
|
-t $IMAGE .
|
||||||
docker push $IMAGE
|
docker push $IMAGE
|
||||||
|
|||||||
16
CLAUDE.md
16
CLAUDE.md
@@ -4,22 +4,12 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||||||
|
|
||||||
## Project Status
|
## Project Status
|
||||||
|
|
||||||
This is a greenfield project in early discovery/design phase. No source code exists yet. The project aims to automate financial and operational management for a small sports club.
|
Flask-based financial management system for FUJ (Frisbee Ultimate Jablonec). Handles attendance-based fee calculation, Fio bank transaction sync, payment reconciliation, and a web dashboard.
|
||||||
|
|
||||||
See `docs/project-notes.md` for the current brainstorming state, domain model, and open questions that need answering before implementation begins.
|
|
||||||
|
|
||||||
## Key Constraints
|
## Key Constraints
|
||||||
|
|
||||||
- **PII separation**: Member data (names, emails, payment info) must never be committed to git. Enforce config/data separation from day one.
|
- **PII separation**: Member data (names, emails, payment info) must never be committed to git. Enforce config/data separation from day one.
|
||||||
- **Incremental approach**: Start with highest-ROI automation (likely fee billing & payment tracking), not a full platform.
|
- **Configuration**: External service IDs, credentials, and tunable parameters are centralized in `scripts/config.py`. Domain-specific constants (fees, merged months) stay in their respective modules.
|
||||||
|
|
||||||
## Development Workflow
|
|
||||||
|
|
||||||
This project uses a hybrid workflow:
|
|
||||||
- Claude.ai chat for brainstorming and design exploration
|
|
||||||
- Claude Code for implementation
|
|
||||||
|
|
||||||
## When Code Exists
|
|
||||||
|
|
||||||
## Development Setup
|
## Development Setup
|
||||||
|
|
||||||
@@ -40,7 +30,7 @@ Alternatively, use the Makefile:
|
|||||||
- `make web` - Start dashboard
|
- `make web` - Start dashboard
|
||||||
- `make image` - Build Docker image
|
- `make image` - Build Docker image
|
||||||
|
|
||||||
Requires `credentials.json` in the root for Google Sheets API access.
|
Requires `.secret/fuj-management-bot-credentials.json` for Google Sheets API access (configurable via `CREDENTIALS_PATH` env var).
|
||||||
|
|
||||||
## Git Commits
|
## Git Commits
|
||||||
|
|
||||||
|
|||||||
6
Makefile
6
Makefile
@@ -45,7 +45,11 @@ web-debug: $(PYTHON)
|
|||||||
FLASK_DEBUG=1 $(PYTHON) app.py
|
FLASK_DEBUG=1 $(PYTHON) app.py
|
||||||
|
|
||||||
image:
|
image:
|
||||||
docker build -t fuj-management:latest -f build/Dockerfile .
|
docker build -t fuj-management:latest \
|
||||||
|
--build-arg GIT_TAG=$$(git describe --tags --always 2>/dev/null || echo "untagged") \
|
||||||
|
--build-arg GIT_COMMIT=$$(git rev-parse --short HEAD) \
|
||||||
|
--build-arg BUILD_DATE=$$(date -u +%Y-%m-%dT%H:%M:%SZ) \
|
||||||
|
-f build/Dockerfile .
|
||||||
|
|
||||||
run:
|
run:
|
||||||
docker run -it --rm -p 5001:5001 -v $(CURDIR)/.secret:/app/.secret:ro fuj-management:latest
|
docker run -it --rm -p 5001:5001 -v $(CURDIR)/.secret:/app/.secret:ro fuj-management:latest
|
||||||
|
|||||||
497
app.py
497
app.py
@@ -17,9 +17,15 @@ logging.basicConfig(level=getattr(logging, log_level, logging.INFO), format='%(a
|
|||||||
scripts_dir = Path(__file__).parent / "scripts"
|
scripts_dir = Path(__file__).parent / "scripts"
|
||||||
sys.path.append(str(scripts_dir))
|
sys.path.append(str(scripts_dir))
|
||||||
|
|
||||||
from attendance import get_members_with_fees, get_junior_members_with_fees, SHEET_ID as ATTENDANCE_SHEET_ID, JUNIOR_SHEET_GID, ADULT_MERGED_MONTHS, JUNIOR_MERGED_MONTHS
|
from config import (
|
||||||
from match_payments import reconcile, fetch_sheet_data, fetch_exceptions, normalize, DEFAULT_SPREADSHEET_ID as PAYMENTS_SHEET_ID
|
ATTENDANCE_SHEET_ID, PAYMENTS_SHEET_ID, JUNIOR_SHEET_GID,
|
||||||
from cache_utils import get_sheet_modified_time, read_cache, write_cache, _LAST_CHECKED
|
BANK_ACCOUNT, CREDENTIALS_PATH,
|
||||||
|
)
|
||||||
|
from attendance import get_members_with_fees, get_junior_members_with_fees, ADULT_MERGED_MONTHS, JUNIOR_MERGED_MONTHS
|
||||||
|
from match_payments import reconcile, fetch_sheet_data, fetch_exceptions, normalize
|
||||||
|
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):
|
def get_cached_data(cache_key, sheet_id, fetch_func, *args, serialize=None, deserialize=None, **kwargs):
|
||||||
mod_time = get_sheet_modified_time(cache_key)
|
mod_time = get_sheet_modified_time(cache_key)
|
||||||
@@ -51,10 +57,31 @@ def get_month_labels(sorted_months, merged_months):
|
|||||||
labels[m] = dt.strftime("%b %Y")
|
labels[m] = dt.strftime("%b %Y")
|
||||||
return labels
|
return labels
|
||||||
|
|
||||||
|
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__)
|
app = Flask(__name__)
|
||||||
|
|
||||||
# Bank account for QR code payments (can be overridden by ENV)
|
import json as _json
|
||||||
BANK_ACCOUNT = os.environ.get("BANK_ACCOUNT", "CZ8520100000002800359168")
|
_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": ""
|
||||||
|
}
|
||||||
|
|
||||||
|
warmup_cache()
|
||||||
|
|
||||||
@app.before_request
|
@app.before_request
|
||||||
def start_timer():
|
def start_timer():
|
||||||
@@ -83,165 +110,58 @@ def inject_render_time():
|
|||||||
"total": f"{total:.3f}",
|
"total": f"{total:.3f}",
|
||||||
"breakdown": " | ".join(breakdown)
|
"breakdown": " | ".join(breakdown)
|
||||||
}
|
}
|
||||||
return dict(get_render_time=get_render_time)
|
return dict(get_render_time=get_render_time, build_meta=BUILD_META)
|
||||||
|
|
||||||
@app.route("/")
|
@app.route("/")
|
||||||
def index():
|
def index():
|
||||||
# Redirect root to /fees for convenience while there are no other apps
|
# Redirect root to /adults for convenience while there are no other apps
|
||||||
return '<meta http-equiv="refresh" content="0; url=/fees" />'
|
return '<meta http-equiv="refresh" content="0; url=/adults" />'
|
||||||
|
|
||||||
@app.route("/fees")
|
@app.route("/flush-cache", methods=["GET", "POST"])
|
||||||
def fees():
|
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("/adults")
|
||||||
|
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"
|
||||||
payments_url = f"https://docs.google.com/spreadsheets/d/{PAYMENTS_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
|
|
||||||
|
|
||||||
# Filter to adults only for display
|
|
||||||
results = [(name, fees) for name, tier, fees in members if tier == "A"]
|
|
||||||
|
|
||||||
# Format month labels
|
|
||||||
month_labels = get_month_labels(sorted_months, ADULT_MERGED_MONTHS)
|
|
||||||
|
|
||||||
monthly_totals = {m: 0 for m in sorted_months}
|
|
||||||
|
|
||||||
# Get exceptions for formatting
|
|
||||||
credentials_path = ".secret/fuj-management-bot-credentials.json"
|
|
||||||
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")
|
|
||||||
|
|
||||||
formatted_results = []
|
|
||||||
for name, month_fees in results:
|
|
||||||
row = {"name": name, "months": []}
|
|
||||||
norm_name = normalize(name)
|
|
||||||
for m in sorted_months:
|
|
||||||
fee, count = month_fees.get(m, (0, 0))
|
|
||||||
|
|
||||||
# Check for exception
|
|
||||||
norm_period = normalize(m)
|
|
||||||
ex_data = exceptions.get((norm_name, norm_period))
|
|
||||||
override_amount = ex_data["amount"] if ex_data else None
|
|
||||||
|
|
||||||
if override_amount is not None and override_amount != fee:
|
|
||||||
cell = f"{override_amount} ({fee}) CZK ({count})" if count > 0 else f"{override_amount} ({fee}) CZK"
|
|
||||||
is_overridden = True
|
|
||||||
else:
|
|
||||||
if isinstance(fee, int):
|
|
||||||
monthly_totals[m] += fee
|
|
||||||
cell = f"{fee} CZK ({count})" if count > 0 else "-"
|
|
||||||
is_overridden = False
|
|
||||||
row["months"].append({"cell": cell, "overridden": is_overridden})
|
|
||||||
formatted_results.append(row)
|
|
||||||
|
|
||||||
record_step("process_data")
|
|
||||||
|
|
||||||
return render_template(
|
|
||||||
"fees.html",
|
|
||||||
months=[month_labels[m] for m in sorted_months],
|
|
||||||
results=formatted_results,
|
|
||||||
totals=[f"{monthly_totals[m]} CZK" for m in sorted_months],
|
|
||||||
attendance_url=attendance_url,
|
|
||||||
payments_url=payments_url
|
|
||||||
)
|
|
||||||
|
|
||||||
@app.route("/fees-juniors")
|
|
||||||
def fees_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"
|
|
||||||
|
|
||||||
members_data = get_cached_data("attendance_juniors", ATTENDANCE_SHEET_ID, get_junior_members_with_fees)
|
|
||||||
record_step("fetch_junior_members")
|
|
||||||
if not members_data:
|
|
||||||
return "No data."
|
|
||||||
members, sorted_months = members_data
|
|
||||||
|
|
||||||
# Sort members by name
|
|
||||||
results = sorted([(name, fees) for name, tier, fees in members], key=lambda x: x[0])
|
|
||||||
|
|
||||||
# Format month labels
|
|
||||||
month_labels = get_month_labels(sorted_months, JUNIOR_MERGED_MONTHS)
|
|
||||||
|
|
||||||
monthly_totals = {m: 0 for m in sorted_months}
|
|
||||||
|
|
||||||
# Get exceptions for formatting (reusing payments sheet)
|
|
||||||
credentials_path = ".secret/fuj-management-bot-credentials.json"
|
|
||||||
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")
|
|
||||||
|
|
||||||
formatted_results = []
|
|
||||||
for name, month_fees in results:
|
|
||||||
row = {"name": name, "months": []}
|
|
||||||
norm_name = normalize(name)
|
|
||||||
for m in sorted_months:
|
|
||||||
fee_data = month_fees.get(m, (0, 0, 0, 0))
|
|
||||||
if len(fee_data) == 4:
|
|
||||||
fee, total_count, adult_count, junior_count = fee_data
|
|
||||||
else:
|
|
||||||
fee, total_count = fee_data
|
|
||||||
adult_count, junior_count = 0, 0
|
|
||||||
|
|
||||||
# Check for exception
|
|
||||||
norm_period = normalize(m)
|
|
||||||
ex_data = exceptions.get((norm_name, norm_period))
|
|
||||||
override_amount = ex_data["amount"] if ex_data else None
|
|
||||||
|
|
||||||
if ex_data is None and isinstance(fee, int):
|
|
||||||
monthly_totals[m] += fee
|
|
||||||
|
|
||||||
# Formulate the count string display
|
|
||||||
if adult_count > 0 and junior_count > 0:
|
|
||||||
count_str = f"{total_count} ({adult_count}A+{junior_count}J)"
|
|
||||||
elif adult_count > 0:
|
|
||||||
count_str = f"{total_count} (A)"
|
|
||||||
elif junior_count > 0:
|
|
||||||
count_str = f"{total_count} (J)"
|
|
||||||
else:
|
|
||||||
count_str = f"{total_count}"
|
|
||||||
|
|
||||||
if override_amount is not None and override_amount != fee:
|
|
||||||
cell = f"{override_amount} ({fee}) CZK / {count_str}" if total_count > 0 else f"{override_amount} ({fee}) CZK"
|
|
||||||
is_overridden = True
|
|
||||||
else:
|
|
||||||
if fee == "?":
|
|
||||||
cell = f"? / {count_str}" if total_count > 0 else "-"
|
|
||||||
else:
|
|
||||||
cell = f"{fee} CZK / {count_str}" if total_count > 0 else "-"
|
|
||||||
is_overridden = False
|
|
||||||
row["months"].append({"cell": cell, "overridden": is_overridden})
|
|
||||||
formatted_results.append(row)
|
|
||||||
|
|
||||||
record_step("process_data")
|
|
||||||
|
|
||||||
return render_template(
|
|
||||||
"fees-juniors.html",
|
|
||||||
months=[month_labels[m] for m in sorted_months],
|
|
||||||
results=formatted_results,
|
|
||||||
totals=[f"{t} CZK" if isinstance(t, int) else t for t in monthly_totals.values()],
|
|
||||||
attendance_url=attendance_url,
|
|
||||||
payments_url=payments_url
|
|
||||||
)
|
|
||||||
|
|
||||||
@app.route("/reconcile")
|
|
||||||
def reconcile_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"
|
|
||||||
|
|
||||||
# Use hardcoded credentials path for now, consistent with other scripts
|
|
||||||
credentials_path = ".secret/fuj-management-bot-credentials.json"
|
|
||||||
|
|
||||||
members_data = get_cached_data("attendance_regular", ATTENDANCE_SHEET_ID, get_members_with_fees)
|
members_data = get_cached_data("attendance_regular", ATTENDANCE_SHEET_ID, get_members_with_fees)
|
||||||
record_step("fetch_members")
|
record_step("fetch_members")
|
||||||
@@ -261,69 +181,134 @@ def reconcile_view():
|
|||||||
result = reconcile(members, sorted_months, transactions, exceptions)
|
result = reconcile(members, sorted_months, transactions, exceptions)
|
||||||
record_step("reconcile")
|
record_step("reconcile")
|
||||||
|
|
||||||
# Format month labels
|
|
||||||
month_labels = get_month_labels(sorted_months, ADULT_MERGED_MONTHS)
|
month_labels = get_month_labels(sorted_months, ADULT_MERGED_MONTHS)
|
||||||
|
|
||||||
# Filter to adults for the main table
|
|
||||||
adult_names = sorted([name for name, tier, _ in members if tier == "A"])
|
adult_names = sorted([name for name, tier, _ in members if tier == "A"])
|
||||||
|
current_month = datetime.now().strftime("%Y-%m")
|
||||||
|
|
||||||
|
monthly_totals = {m: {"expected": 0, "paid": 0} for m in sorted_months}
|
||||||
formatted_results = []
|
formatted_results = []
|
||||||
for name in adult_names:
|
for name in adult_names:
|
||||||
data = result["members"][name]
|
data = result["members"][name]
|
||||||
row = {"name": name, "months": [], "balance": data["total_balance"], "unpaid_periods": ""}
|
row = {"name": name, "months": [], "balance": data["total_balance"], "unpaid_periods": "", "raw_unpaid_periods": ""}
|
||||||
unpaid_months = []
|
unpaid_months = []
|
||||||
|
raw_unpaid_months = []
|
||||||
|
payable_amount = 0
|
||||||
for m in sorted_months:
|
for m in sorted_months:
|
||||||
mdata = data["months"].get(m, {"expected": 0, "original_expected": 0, "paid": 0})
|
mdata = data["months"].get(m, {"expected": 0, "original_expected": 0, "attendance_count": 0, "paid": 0, "exception": None})
|
||||||
expected = mdata["expected"]
|
expected = mdata.get("expected", 0)
|
||||||
paid = int(mdata["paid"])
|
original_expected = mdata.get("original_expected", 0)
|
||||||
|
count = mdata.get("attendance_count", 0)
|
||||||
|
paid = int(mdata.get("paid", 0))
|
||||||
|
exception_info = mdata.get("exception", None)
|
||||||
|
|
||||||
|
monthly_totals[m]["expected"] += expected
|
||||||
|
monthly_totals[m]["paid"] += paid
|
||||||
|
|
||||||
|
override_amount = exception_info["amount"] if exception_info else None
|
||||||
|
|
||||||
|
if override_amount is not None and override_amount != original_expected:
|
||||||
|
is_overridden = True
|
||||||
|
fee_display = f"{override_amount} ({original_expected}) CZK ({count})" if count > 0 else f"{override_amount} ({original_expected}) CZK"
|
||||||
|
else:
|
||||||
|
is_overridden = False
|
||||||
|
fee_display = f"{expected} CZK ({count})" if count > 0 else f"{expected} CZK"
|
||||||
|
|
||||||
status = "empty"
|
status = "empty"
|
||||||
cell_text = "-"
|
cell_text = "-"
|
||||||
amount_to_pay = 0
|
amount_to_pay = 0
|
||||||
|
|
||||||
if expected > 0:
|
if expected > 0:
|
||||||
|
amount_to_pay = max(0, expected - paid)
|
||||||
if paid >= expected:
|
if paid >= expected:
|
||||||
status = "ok"
|
status = "ok"
|
||||||
cell_text = "OK"
|
cell_text = f"{paid}/{fee_display}"
|
||||||
elif paid > 0:
|
elif paid > 0:
|
||||||
status = "partial"
|
status = "partial"
|
||||||
cell_text = f"{paid}/{expected}"
|
cell_text = f"{paid}/{fee_display}"
|
||||||
amount_to_pay = expected - paid
|
if m < current_month:
|
||||||
unpaid_months.append(month_labels[m])
|
unpaid_months.append(month_labels[m])
|
||||||
|
raw_unpaid_months.append(datetime.strptime(m, "%Y-%m").strftime("%m/%Y"))
|
||||||
|
payable_amount += amount_to_pay
|
||||||
else:
|
else:
|
||||||
status = "unpaid"
|
status = "unpaid"
|
||||||
cell_text = f"UNPAID {expected}"
|
cell_text = f"0/{fee_display}"
|
||||||
amount_to_pay = expected
|
if m < current_month:
|
||||||
unpaid_months.append(month_labels[m])
|
unpaid_months.append(month_labels[m])
|
||||||
|
raw_unpaid_months.append(datetime.strptime(m, "%Y-%m").strftime("%m/%Y"))
|
||||||
|
payable_amount += amount_to_pay
|
||||||
elif paid > 0:
|
elif paid > 0:
|
||||||
status = "surplus"
|
status = "surplus"
|
||||||
cell_text = f"PAID {paid}"
|
cell_text = f"PAID {paid}"
|
||||||
|
else:
|
||||||
|
cell_text = "-"
|
||||||
|
amount_to_pay = 0
|
||||||
|
|
||||||
|
if expected > 0 or paid > 0:
|
||||||
|
tooltip = f"Received: {paid}, Expected: {expected}"
|
||||||
|
else:
|
||||||
|
tooltip = ""
|
||||||
|
|
||||||
row["months"].append({
|
row["months"].append({
|
||||||
"text": cell_text,
|
"text": cell_text,
|
||||||
|
"overridden": is_overridden,
|
||||||
"status": status,
|
"status": status,
|
||||||
"amount": amount_to_pay,
|
"amount": amount_to_pay,
|
||||||
"month": month_labels[m]
|
"month": month_labels[m],
|
||||||
|
"raw_month": m,
|
||||||
|
"tooltip": tooltip
|
||||||
})
|
})
|
||||||
|
|
||||||
row["unpaid_periods"] = ", ".join(unpaid_months) if unpaid_months else ("Older debt" if data["total_balance"] < 0 else "")
|
# Compute balance excluding current/future months
|
||||||
row["balance"] = data["total_balance"] # Updated to use total_balance
|
current_month_debt = 0
|
||||||
|
for m in sorted_months:
|
||||||
|
if m >= current_month:
|
||||||
|
mdata = data["months"].get(m, {"expected": 0, "paid": 0})
|
||||||
|
exp = mdata.get("expected", 0)
|
||||||
|
pd = int(mdata.get("paid", 0))
|
||||||
|
current_month_debt += max(0, exp - pd)
|
||||||
|
settled_balance = data["total_balance"] + current_month_debt
|
||||||
|
|
||||||
|
row["unpaid_periods"] = ", ".join(unpaid_months) if unpaid_months else ("Older debt" if settled_balance < 0 and payable_amount == 0 else "")
|
||||||
|
row["raw_unpaid_periods"] = "+".join(raw_unpaid_months)
|
||||||
|
row["balance"] = settled_balance
|
||||||
|
row["payable_amount"] = payable_amount
|
||||||
formatted_results.append(row)
|
formatted_results.append(row)
|
||||||
|
|
||||||
# Format credits and debts
|
formatted_totals = []
|
||||||
credits = sorted([{"name": n, "amount": a["total_balance"]} for n, a in result["members"].items() if a["total_balance"] > 0 and n in adult_names], key=lambda x: x["name"])
|
for m in sorted_months:
|
||||||
debts = sorted([{"name": n, "amount": abs(a["total_balance"])} for n, a in result["members"].items() if a["total_balance"] < 0 and n in adult_names], key=lambda x: x["name"])
|
t = monthly_totals[m]
|
||||||
# Format unmatched
|
status = "empty"
|
||||||
|
if t["expected"] > 0 or t["paid"] > 0:
|
||||||
|
if t["paid"] == t["expected"]:
|
||||||
|
status = "ok"
|
||||||
|
elif t["paid"] < t["expected"]:
|
||||||
|
status = "unpaid"
|
||||||
|
else:
|
||||||
|
status = "surplus"
|
||||||
|
|
||||||
|
formatted_totals.append({
|
||||||
|
"text": f"{t['paid']} / {t['expected']} CZK",
|
||||||
|
"status": status
|
||||||
|
})
|
||||||
|
|
||||||
|
def settled_balance(name):
|
||||||
|
data = result["members"][name]
|
||||||
|
debt = sum(max(0, data["months"].get(m, {"expected": 0, "paid": 0}).get("expected", 0) - int(data["months"].get(m, {"expected": 0, "paid": 0}).get("paid", 0))) for m in sorted_months if m >= current_month)
|
||||||
|
return data["total_balance"] + debt
|
||||||
|
|
||||||
|
credits = sorted([{"name": n, "amount": settled_balance(n)} for n in adult_names if settled_balance(n) > 0], key=lambda x: x["name"])
|
||||||
|
debts = sorted([{"name": n, "amount": abs(settled_balance(n))} for n in adult_names if settled_balance(n) < 0], key=lambda x: x["name"])
|
||||||
unmatched = result["unmatched"]
|
unmatched = result["unmatched"]
|
||||||
import json
|
import json
|
||||||
|
|
||||||
record_step("process_data")
|
record_step("process_data")
|
||||||
|
|
||||||
return render_template(
|
return render_template(
|
||||||
"reconcile.html",
|
"adults.html",
|
||||||
months=[month_labels[m] for m in sorted_months],
|
months=[month_labels[m] for m in sorted_months],
|
||||||
raw_months=sorted_months,
|
raw_months=sorted_months,
|
||||||
results=formatted_results,
|
results=formatted_results,
|
||||||
|
totals=formatted_totals,
|
||||||
member_data=json.dumps(result["members"]),
|
member_data=json.dumps(result["members"]),
|
||||||
month_labels_json=json.dumps(month_labels),
|
month_labels_json=json.dumps(month_labels),
|
||||||
credits=credits,
|
credits=credits,
|
||||||
@@ -331,15 +316,16 @@ def reconcile_view():
|
|||||||
unmatched=unmatched,
|
unmatched=unmatched,
|
||||||
attendance_url=attendance_url,
|
attendance_url=attendance_url,
|
||||||
payments_url=payments_url,
|
payments_url=payments_url,
|
||||||
bank_account=BANK_ACCOUNT
|
bank_account=BANK_ACCOUNT,
|
||||||
|
current_month=current_month
|
||||||
)
|
)
|
||||||
|
|
||||||
@app.route("/reconcile-juniors")
|
@app.route("/juniors")
|
||||||
def reconcile_juniors_view():
|
def juniors_view():
|
||||||
attendance_url = f"https://docs.google.com/spreadsheets/d/{ATTENDANCE_SHEET_ID}/edit#gid={JUNIOR_SHEET_GID}"
|
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"
|
payments_url = f"https://docs.google.com/spreadsheets/d/{PAYMENTS_SHEET_ID}/edit"
|
||||||
|
|
||||||
credentials_path = ".secret/fuj-management-bot-credentials.json"
|
credentials_path = CREDENTIALS_PATH
|
||||||
|
|
||||||
junior_members_data = get_cached_data("attendance_juniors", ATTENDANCE_SHEET_ID, get_junior_members_with_fees)
|
junior_members_data = get_cached_data("attendance_juniors", ATTENDANCE_SHEET_ID, get_junior_members_with_fees)
|
||||||
record_step("fetch_junior_members")
|
record_step("fetch_junior_members")
|
||||||
@@ -376,19 +362,54 @@ def reconcile_juniors_view():
|
|||||||
|
|
||||||
# Format month labels
|
# Format month labels
|
||||||
month_labels = get_month_labels(sorted_months, JUNIOR_MERGED_MONTHS)
|
month_labels = get_month_labels(sorted_months, JUNIOR_MERGED_MONTHS)
|
||||||
|
|
||||||
# Filter to juniors for the main table
|
|
||||||
junior_names = sorted([name for name, tier, _ in adapted_members])
|
junior_names = sorted([name for name, tier, _ in adapted_members])
|
||||||
|
junior_members_dict = {name: fees_dict for name, _, fees_dict in junior_members}
|
||||||
|
current_month = datetime.now().strftime("%Y-%m")
|
||||||
|
|
||||||
|
monthly_totals = {m: {"expected": 0, "paid": 0} for m in sorted_months}
|
||||||
formatted_results = []
|
formatted_results = []
|
||||||
for name in junior_names:
|
for name in junior_names:
|
||||||
data = result["members"][name]
|
data = result["members"][name]
|
||||||
row = {"name": name, "months": [], "balance": data["total_balance"], "unpaid_periods": ""}
|
row = {"name": name, "months": [], "balance": data["total_balance"], "unpaid_periods": "", "raw_unpaid_periods": ""}
|
||||||
unpaid_months = []
|
unpaid_months = []
|
||||||
|
raw_unpaid_months = []
|
||||||
|
payable_amount = 0
|
||||||
for m in sorted_months:
|
for m in sorted_months:
|
||||||
mdata = data["months"].get(m, {"expected": 0, "original_expected": 0, "paid": 0})
|
mdata = data["months"].get(m, {"expected": 0, "original_expected": 0, "attendance_count": 0, "paid": 0, "exception": None})
|
||||||
expected = mdata["expected"]
|
expected = mdata.get("expected", 0)
|
||||||
paid = int(mdata["paid"])
|
original_expected = mdata.get("original_expected", 0)
|
||||||
|
count = mdata.get("attendance_count", 0)
|
||||||
|
paid = int(mdata.get("paid", 0))
|
||||||
|
exception_info = mdata.get("exception", None)
|
||||||
|
|
||||||
|
if expected != "?" and isinstance(expected, int):
|
||||||
|
monthly_totals[m]["expected"] += expected
|
||||||
|
monthly_totals[m]["paid"] += paid
|
||||||
|
|
||||||
|
orig_fee_data = junior_members_dict.get(name, {}).get(m)
|
||||||
|
adult_count = 0
|
||||||
|
junior_count = 0
|
||||||
|
if orig_fee_data and len(orig_fee_data) == 4:
|
||||||
|
_, _, adult_count, junior_count = orig_fee_data
|
||||||
|
|
||||||
|
breakdown = ""
|
||||||
|
if adult_count > 0 and junior_count > 0:
|
||||||
|
breakdown = f":{junior_count}J,{adult_count}A"
|
||||||
|
elif junior_count > 0:
|
||||||
|
breakdown = f":{junior_count}J"
|
||||||
|
elif adult_count > 0:
|
||||||
|
breakdown = f":{adult_count}A"
|
||||||
|
|
||||||
|
count_str = f" ({count}{breakdown})" if count > 0 else ""
|
||||||
|
|
||||||
|
override_amount = exception_info["amount"] if exception_info else None
|
||||||
|
|
||||||
|
if override_amount is not None and override_amount != original_expected:
|
||||||
|
is_overridden = True
|
||||||
|
fee_display = f"{override_amount} ({original_expected}) CZK{count_str}"
|
||||||
|
else:
|
||||||
|
is_overridden = False
|
||||||
|
fee_display = f"{expected} CZK{count_str}"
|
||||||
|
|
||||||
status = "empty"
|
status = "empty"
|
||||||
cell_text = "-"
|
cell_text = "-"
|
||||||
@@ -397,48 +418,105 @@ def reconcile_juniors_view():
|
|||||||
if expected == "?" or (isinstance(expected, int) and expected > 0):
|
if expected == "?" or (isinstance(expected, int) and expected > 0):
|
||||||
if expected == "?":
|
if expected == "?":
|
||||||
status = "empty"
|
status = "empty"
|
||||||
cell_text = "?"
|
cell_text = f"?{count_str}"
|
||||||
elif paid >= expected:
|
elif paid >= expected:
|
||||||
status = "ok"
|
status = "ok"
|
||||||
cell_text = "OK"
|
cell_text = f"{paid}/{fee_display}"
|
||||||
elif paid > 0:
|
elif paid > 0:
|
||||||
status = "partial"
|
status = "partial"
|
||||||
cell_text = f"{paid}/{expected}"
|
cell_text = f"{paid}/{fee_display}"
|
||||||
amount_to_pay = expected - paid
|
amount_to_pay = expected - paid
|
||||||
unpaid_months.append(month_labels[m])
|
if m < current_month:
|
||||||
|
unpaid_months.append(month_labels[m])
|
||||||
|
raw_unpaid_months.append(datetime.strptime(m, "%Y-%m").strftime("%m/%Y"))
|
||||||
|
payable_amount += amount_to_pay
|
||||||
else:
|
else:
|
||||||
status = "unpaid"
|
status = "unpaid"
|
||||||
cell_text = f"UNPAID {expected}"
|
cell_text = f"0/{fee_display}"
|
||||||
amount_to_pay = expected
|
amount_to_pay = expected
|
||||||
unpaid_months.append(month_labels[m])
|
if m < current_month:
|
||||||
|
unpaid_months.append(month_labels[m])
|
||||||
|
raw_unpaid_months.append(datetime.strptime(m, "%Y-%m").strftime("%m/%Y"))
|
||||||
|
payable_amount += amount_to_pay
|
||||||
elif paid > 0:
|
elif paid > 0:
|
||||||
status = "surplus"
|
status = "surplus"
|
||||||
cell_text = f"PAID {paid}"
|
cell_text = f"PAID {paid}"
|
||||||
|
|
||||||
|
if (isinstance(expected, int) and expected > 0) or paid > 0:
|
||||||
|
tooltip = f"Received: {paid}, Expected: {expected}"
|
||||||
|
else:
|
||||||
|
tooltip = ""
|
||||||
|
|
||||||
row["months"].append({
|
row["months"].append({
|
||||||
"text": cell_text,
|
"text": cell_text,
|
||||||
|
"overridden": is_overridden,
|
||||||
"status": status,
|
"status": status,
|
||||||
"amount": amount_to_pay,
|
"amount": amount_to_pay,
|
||||||
"month": month_labels[m]
|
"month": month_labels[m],
|
||||||
|
"raw_month": m,
|
||||||
|
"tooltip": tooltip
|
||||||
})
|
})
|
||||||
|
|
||||||
row["unpaid_periods"] = ", ".join(unpaid_months) if unpaid_months else ("Older debt" if data["total_balance"] < 0 else "")
|
# Compute balance excluding current/future months
|
||||||
row["balance"] = data["total_balance"]
|
current_month_debt = 0
|
||||||
|
for m in sorted_months:
|
||||||
|
if m >= current_month:
|
||||||
|
mdata = data["months"].get(m, {"expected": 0, "paid": 0})
|
||||||
|
exp = mdata.get("expected", 0)
|
||||||
|
if isinstance(exp, int):
|
||||||
|
pd = int(mdata.get("paid", 0))
|
||||||
|
current_month_debt += max(0, exp - pd)
|
||||||
|
settled_balance = data["total_balance"] + current_month_debt
|
||||||
|
|
||||||
|
row["unpaid_periods"] = ", ".join(unpaid_months) if unpaid_months else ("Older debt" if settled_balance < 0 and payable_amount == 0 else "")
|
||||||
|
row["raw_unpaid_periods"] = "+".join(raw_unpaid_months)
|
||||||
|
row["balance"] = settled_balance
|
||||||
|
row["payable_amount"] = payable_amount
|
||||||
formatted_results.append(row)
|
formatted_results.append(row)
|
||||||
|
|
||||||
|
formatted_totals = []
|
||||||
|
for m in sorted_months:
|
||||||
|
t = monthly_totals[m]
|
||||||
|
status = "empty"
|
||||||
|
if t["expected"] > 0 or t["paid"] > 0:
|
||||||
|
if t["paid"] == t["expected"]:
|
||||||
|
status = "ok"
|
||||||
|
elif t["paid"] < t["expected"]:
|
||||||
|
status = "unpaid"
|
||||||
|
else:
|
||||||
|
status = "surplus"
|
||||||
|
|
||||||
|
formatted_totals.append({
|
||||||
|
"text": f"{t['paid']} / {t['expected']} CZK",
|
||||||
|
"status": status
|
||||||
|
})
|
||||||
|
|
||||||
# Format credits and debts
|
# Format credits and debts
|
||||||
credits = sorted([{"name": n, "amount": a["total_balance"]} for n, a in result["members"].items() if a["total_balance"] > 0], key=lambda x: x["name"])
|
def junior_settled_balance(name):
|
||||||
debts = sorted([{"name": n, "amount": abs(a["total_balance"])} for n, a in result["members"].items() if a["total_balance"] < 0], key=lambda x: x["name"])
|
data = result["members"][name]
|
||||||
|
debt = 0
|
||||||
|
for m in sorted_months:
|
||||||
|
if m >= current_month:
|
||||||
|
mdata = data["months"].get(m, {"expected": 0, "paid": 0})
|
||||||
|
exp = mdata.get("expected", 0)
|
||||||
|
if isinstance(exp, int):
|
||||||
|
debt += max(0, exp - int(mdata.get("paid", 0)))
|
||||||
|
return data["total_balance"] + debt
|
||||||
|
|
||||||
|
junior_all_names = [name for name, _, _ in adapted_members]
|
||||||
|
credits = sorted([{"name": n, "amount": junior_settled_balance(n)} for n in junior_all_names if junior_settled_balance(n) > 0], key=lambda x: x["name"])
|
||||||
|
debts = sorted([{"name": n, "amount": abs(junior_settled_balance(n))} for n in junior_all_names if junior_settled_balance(n) < 0], key=lambda x: x["name"])
|
||||||
unmatched = result["unmatched"]
|
unmatched = result["unmatched"]
|
||||||
import json
|
import json
|
||||||
|
|
||||||
record_step("process_data")
|
record_step("process_data")
|
||||||
|
|
||||||
return render_template(
|
return render_template(
|
||||||
"reconcile-juniors.html",
|
"juniors.html",
|
||||||
months=[month_labels[m] for m in sorted_months],
|
months=[month_labels[m] for m in sorted_months],
|
||||||
raw_months=sorted_months,
|
raw_months=sorted_months,
|
||||||
results=formatted_results,
|
results=formatted_results,
|
||||||
|
totals=formatted_totals,
|
||||||
member_data=json.dumps(result["members"]),
|
member_data=json.dumps(result["members"]),
|
||||||
month_labels_json=json.dumps(month_labels),
|
month_labels_json=json.dumps(month_labels),
|
||||||
credits=credits,
|
credits=credits,
|
||||||
@@ -446,14 +524,15 @@ def reconcile_juniors_view():
|
|||||||
unmatched=unmatched,
|
unmatched=unmatched,
|
||||||
attendance_url=attendance_url,
|
attendance_url=attendance_url,
|
||||||
payments_url=payments_url,
|
payments_url=payments_url,
|
||||||
bank_account=BANK_ACCOUNT
|
bank_account=BANK_ACCOUNT,
|
||||||
|
current_month=current_month
|
||||||
)
|
)
|
||||||
|
|
||||||
@app.route("/payments")
|
@app.route("/payments")
|
||||||
def payments():
|
def payments():
|
||||||
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"
|
||||||
payments_url = f"https://docs.google.com/spreadsheets/d/{PAYMENTS_SHEET_ID}/edit"
|
payments_url = f"https://docs.google.com/spreadsheets/d/{PAYMENTS_SHEET_ID}/edit"
|
||||||
credentials_path = ".secret/fuj-management-bot-credentials.json"
|
credentials_path = CREDENTIALS_PATH
|
||||||
|
|
||||||
transactions = get_cached_data("payments_transactions", PAYMENTS_SHEET_ID, fetch_sheet_data, PAYMENTS_SHEET_ID, credentials_path)
|
transactions = get_cached_data("payments_transactions", PAYMENTS_SHEET_ID, fetch_sheet_data, PAYMENTS_SHEET_ID, credentials_path)
|
||||||
record_step("fetch_payments")
|
record_step("fetch_payments")
|
||||||
@@ -495,6 +574,10 @@ def qr_code():
|
|||||||
amount = request.args.get("amount", "0")
|
amount = request.args.get("amount", "0")
|
||||||
message = request.args.get("message", "")
|
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
|
# QR Platba standard: SPD*1.0*ACC:accountNumber*BC:bankCode*AM:amount*CC:CZK*MSG:message
|
||||||
acc_parts = account.split('/')
|
acc_parts = account.split('/')
|
||||||
if len(acc_parts) == 2:
|
if len(acc_parts) == 2:
|
||||||
@@ -504,12 +587,14 @@ def qr_code():
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
amt_val = float(amount)
|
amt_val = float(amount)
|
||||||
|
if amt_val < 0 or amt_val > 10_000_000:
|
||||||
|
amt_val = 0
|
||||||
amt_str = f"{amt_val:.2f}"
|
amt_str = f"{amt_val:.2f}"
|
||||||
except ValueError:
|
except ValueError:
|
||||||
amt_str = "0.00"
|
amt_str = "0.00"
|
||||||
|
|
||||||
# Message max 60 characters
|
# Message max 60 characters, strip SPD delimiters to prevent injection
|
||||||
msg_str = message[:60]
|
msg_str = message[:60].replace("*", "")
|
||||||
|
|
||||||
qr_data = f"SPD*1.0*ACC:{acc_str}*AM:{amt_str}*CC:CZK*MSG:{msg_str}"
|
qr_data = f"SPD*1.0*ACC:{acc_str}*AM:{amt_str}*CC:CZK*MSG:{msg_str}"
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ RUN pip install --no-cache-dir \
|
|||||||
google-auth-httplib2 \
|
google-auth-httplib2 \
|
||||||
google-auth-oauthlib \
|
google-auth-oauthlib \
|
||||||
qrcode \
|
qrcode \
|
||||||
pillow
|
pillow \
|
||||||
|
gunicorn
|
||||||
|
|
||||||
COPY app.py Makefile ./
|
COPY app.py Makefile ./
|
||||||
COPY scripts/ ./scripts/
|
COPY scripts/ ./scripts/
|
||||||
@@ -23,6 +24,17 @@ COPY templates/ ./templates/
|
|||||||
COPY build/entrypoint.sh /entrypoint.sh
|
COPY build/entrypoint.sh /entrypoint.sh
|
||||||
RUN chmod +x /entrypoint.sh
|
RUN chmod +x /entrypoint.sh
|
||||||
|
|
||||||
|
ARG GIT_TAG=unknown
|
||||||
|
ARG GIT_COMMIT=unknown
|
||||||
|
ARG BUILD_DATE=unknown
|
||||||
|
|
||||||
|
LABEL org.opencontainers.image.version="${GIT_TAG}" \
|
||||||
|
org.opencontainers.image.revision="${GIT_COMMIT}" \
|
||||||
|
org.opencontainers.image.created="${BUILD_DATE}" \
|
||||||
|
org.opencontainers.image.title="fuj-management"
|
||||||
|
|
||||||
|
RUN echo "{\"tag\": \"${GIT_TAG}\", \"commit\": \"${GIT_COMMIT}\", \"build_date\": \"${BUILD_DATE}\"}" > /app/build_meta.json
|
||||||
|
|
||||||
EXPOSE 5001
|
EXPOSE 5001
|
||||||
|
|
||||||
HEALTHCHECK --interval=60s --timeout=5s --start-period=5s \
|
HEALTHCHECK --interval=60s --timeout=5s --start-period=5s \
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
echo "[entrypoint] Starting Flask app on port 5001..."
|
echo "[entrypoint] Starting gunicorn on port 5001..."
|
||||||
|
|
||||||
# Running the app directly via python
|
exec gunicorn \
|
||||||
# For a production setup, we would ideally use gunicorn/waitress, but sticking to what's in app.py for now.
|
--bind 0.0.0.0:5001 \
|
||||||
exec python3 /app/app.py
|
--workers "${GUNICORN_WORKERS:-2}" \
|
||||||
|
--timeout "${GUNICORN_TIMEOUT:-120}" \
|
||||||
|
--access-logfile - \
|
||||||
|
app:app
|
||||||
|
|||||||
15
docs/README.md
Normal file
15
docs/README.md
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# FUJ Management Documentation
|
||||||
|
|
||||||
|
Welcome to the documentation for the FUJ Management application.
|
||||||
|
|
||||||
|
This project automates financial and operational management for the FUJ (Frisbee Ultimate Jablonec) club.
|
||||||
|
|
||||||
|
## Navigation
|
||||||
|
|
||||||
|
Use the sidebar to explore the documentation:
|
||||||
|
|
||||||
|
* **[Project Notes](project-notes.md)**: Main brainstorming and domain model.
|
||||||
|
* **[Scripts](scripts.md)**: Details about available CLI tools.
|
||||||
|
* **[Fee Specification](fee-calculation-spec.md)**: Rules for fee calculation.
|
||||||
|
|
||||||
|
For more technical details, check out the guides by Claude and Gemini in the sidebar.
|
||||||
25
docs/_sidebar.md
Normal file
25
docs/_sidebar.md
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
* [Home](README.md)
|
||||||
|
* [Project Notes](project-notes.md)
|
||||||
|
* [Scripts](scripts.md)
|
||||||
|
* [Fee Spec](fee-calculation-spec.md)
|
||||||
|
|
||||||
|
* **By Claude Opus**
|
||||||
|
* [README](by-claude-opus/README.md)
|
||||||
|
* [User Guide](by-claude-opus/user-guide.md)
|
||||||
|
* [Web App](by-claude-opus/web-app.md)
|
||||||
|
* [Deployment](by-claude-opus/deployment.md)
|
||||||
|
* [Architecture](by-claude-opus/architecture.md)
|
||||||
|
* [Data Model](by-claude-opus/data-model.md)
|
||||||
|
* [Development](by-claude-opus/development.md)
|
||||||
|
* [Scripts](by-claude-opus/scripts.md)
|
||||||
|
* [Testing](by-claude-opus/testing.md)
|
||||||
|
|
||||||
|
* **By Gemini**
|
||||||
|
* [README](by-gemini/README.md)
|
||||||
|
* [User Guide](by-gemini/user-guide.md)
|
||||||
|
* [Architecture](by-gemini/architecture.md)
|
||||||
|
* [Deployment](by-gemini/deployment.md)
|
||||||
|
* [Scripts](by-gemini/scripts.md)
|
||||||
|
|
||||||
|
* **Specs**
|
||||||
|
* [Fio Sync](spec/fio_to_sheets_sync.md)
|
||||||
214
docs/by-claude-opus/README.md
Normal file
214
docs/by-claude-opus/README.md
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
# FUJ Management — Comprehensive Documentation
|
||||||
|
|
||||||
|
> **FUJ = Frisbee Ultimate Jablonec** — a small sports club in the Czech Republic.
|
||||||
|
|
||||||
|
## What Is This Project?
|
||||||
|
|
||||||
|
FUJ Management is a purpose-built financial management system for a small ultimate frisbee club. It automates the tedious process of tracking **who attended practice**, **how much they owe**, **who has paid**, and **who still owes money** — a workflow that would otherwise require manual cross-referencing between attendance spreadsheets and bank statements.
|
||||||
|
|
||||||
|
The system is built around two Google Sheets (one for attendance, one for payments) and a Fio bank transparent account. A set of Python scripts sync and process the data, while a Flask-based web dashboard provides real-time visibility into fees, payments, and reconciliation status.
|
||||||
|
|
||||||
|
### The Problem It Solves
|
||||||
|
|
||||||
|
Before this system, the club treasurer had to:
|
||||||
|
|
||||||
|
1. **Manually count** attendance marks for each member each month
|
||||||
|
2. **Calculate** whether each person owes 0, 200, or 750 CZK based on how many times they showed up
|
||||||
|
3. **Cross-reference** bank statements to figure out who paid and for which month
|
||||||
|
4. **Chase** members who hadn't paid, often losing track of partial payments and advance payments
|
||||||
|
5. **Handle edge cases** like members paying for multiple months at once, using nicknames in payment messages, or paying via a family member's account
|
||||||
|
|
||||||
|
This system automates steps 1–4 entirely, and provides tooling for step 5.
|
||||||
|
|
||||||
|
## System Overview
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────┐ ┌──────────────────────────┐
|
||||||
|
│ Attendance Sheet │ │ Fio Bank Account │
|
||||||
|
│ (Google Sheets) │ │ (transparent account) │
|
||||||
|
│ │ │ │
|
||||||
|
│ Members × Dates × ✓/✗ │ │ Incoming payments with │
|
||||||
|
│ Tier (A/J/X) │ │ sender, amount, message │
|
||||||
|
└──────────┬───────────────┘ └──────────┬───────────────┘
|
||||||
|
│ │
|
||||||
|
│ CSV export │ API / HTML scraping
|
||||||
|
│ │
|
||||||
|
▼ ▼
|
||||||
|
┌─────────────────┐ ┌───────────────────────┐
|
||||||
|
│ attendance.py │ │ sync_fio_to_sheets.py │
|
||||||
|
│ │ │ │
|
||||||
|
│ Fetches sheet, │ │ Syncs bank txns to │
|
||||||
|
│ computes fees │ │ Payments Google Sheet │
|
||||||
|
└────────┬────────┘ └───────────┬────────────┘
|
||||||
|
│ │
|
||||||
|
│ ▼
|
||||||
|
│ ┌───────────────────────┐
|
||||||
|
│ │ Payments Sheet │
|
||||||
|
│ │ (Google Sheets) │
|
||||||
|
│ │ │
|
||||||
|
│ │ Date|Amount|Person| │
|
||||||
|
│ │ Purpose|Sender|etc. │
|
||||||
|
│ └───────────┬────────────┘
|
||||||
|
│ │
|
||||||
|
│ ┌─────────────────────────┤
|
||||||
|
│ │ │
|
||||||
|
│ ▼ ▼
|
||||||
|
│ ┌──────────────┐ ┌──────────────────┐
|
||||||
|
│ │infer_payments│ │ match_payments.py │
|
||||||
|
│ │ .py │ │ │
|
||||||
|
│ │ │ │ Reconciliation │
|
||||||
|
│ │ Auto-fills │ │ engine: matches │
|
||||||
|
│ │ Person, │ │ payments against │
|
||||||
|
│ │ Purpose, │ │ expected fees │
|
||||||
|
│ │ Amount │ └────────┬──────────┘
|
||||||
|
│ └──────────────┘ │
|
||||||
|
│ │
|
||||||
|
└────────────────┬───────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌──────────────────────┐
|
||||||
|
│ Flask Web App │
|
||||||
|
│ (app.py) │
|
||||||
|
│ │
|
||||||
|
│ /fees – fee │
|
||||||
|
│ table │
|
||||||
|
│ /reconcile – balance │
|
||||||
|
│ matrix │
|
||||||
|
│ /payments – ledger │
|
||||||
|
│ /qr – QR code │
|
||||||
|
└───────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- **Python 3.13+**
|
||||||
|
- **[uv](https://docs.astral.sh/uv/)** — fast Python package manager
|
||||||
|
- **Google Sheets API credentials** — a service account JSON file placed at `.secret/fuj-management-bot-credentials.json`
|
||||||
|
- *Optional*: `FIO_API_TOKEN` environment variable for Fio REST API access (falls back to transparent page scraping)
|
||||||
|
|
||||||
|
### Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Clone and install dependencies
|
||||||
|
git clone <repo-url>
|
||||||
|
cd fuj-management
|
||||||
|
uv sync # Installs all dependencies from pyproject.toml
|
||||||
|
|
||||||
|
# Place your Google API credentials
|
||||||
|
mkdir -p .secret
|
||||||
|
cp /path/to/your/credentials.json .secret/fuj-management-bot-credentials.json
|
||||||
|
```
|
||||||
|
|
||||||
|
### Common Operations
|
||||||
|
|
||||||
|
| Command | Purpose |
|
||||||
|
|---------|---------|
|
||||||
|
| `make web` | Start the web dashboard at `http://localhost:5001` |
|
||||||
|
| `make sync` | Pull new bank transactions into the Google Sheet |
|
||||||
|
| `make infer` | Auto-fill Person/Purpose/Amount for new transactions |
|
||||||
|
| `make reconcile` | Print a CLI balance report |
|
||||||
|
| `make fees` | Print fee calculation table from attendance |
|
||||||
|
| `make test` | Run the test suite |
|
||||||
|
| `make image` | Build the Docker container image |
|
||||||
|
|
||||||
|
### Typical Workflow
|
||||||
|
|
||||||
|
```
|
||||||
|
make sync → make infer → (manual review in Google Sheets) → make web
|
||||||
|
↓ ↓ ↓ ↓
|
||||||
|
Pull new bank Auto-match Fix any [?] View live
|
||||||
|
transactions payments to flagged rows dashboard
|
||||||
|
into sheet members/months in the sheet
|
||||||
|
```
|
||||||
|
|
||||||
|
## Documentation Index
|
||||||
|
|
||||||
|
| Document | Contents |
|
||||||
|
|----------|----------|
|
||||||
|
| [Architecture](architecture.md) | System design, data flow diagrams, module dependency graph |
|
||||||
|
| [Web Application](web-app.md) | Flask app architecture, routes, templates, interactive features |
|
||||||
|
| [User Guide](user-guide.md) | End-user guide for the web dashboard — what each page shows |
|
||||||
|
| [Scripts Reference](scripts.md) | Detailed reference for all CLI scripts and shared modules |
|
||||||
|
| [Data Model](data-model.md) | Google Sheets schemas, fee calculation rules, bank integration |
|
||||||
|
| [Deployment](deployment.md) | Docker containerization, Gitea CI/CD, Kubernetes deployment |
|
||||||
|
| [Testing](testing.md) | Test infrastructure, coverage, how to write new tests |
|
||||||
|
| [Development Guide](development.md) | Local setup, coding conventions, tooling, project history |
|
||||||
|
|
||||||
|
## Technology Stack
|
||||||
|
|
||||||
|
| Layer | Technology |
|
||||||
|
|-------|-----------|
|
||||||
|
| Language | Python 3.13+ |
|
||||||
|
| Web framework | Flask 3.1 |
|
||||||
|
| Package management | uv + pyproject.toml |
|
||||||
|
| Data sources | Google Sheets API, Fio Bank API / HTML scraping |
|
||||||
|
| QR codes | `qrcode` library (PIL backend) |
|
||||||
|
| Containerization | Docker (Alpine-based) |
|
||||||
|
| CI/CD | Gitea Actions |
|
||||||
|
| Deployment target | Self-hosted Kubernetes |
|
||||||
|
| Frontend | Server-rendered HTML/CSS/JS (terminal-aesthetic theme) |
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
fuj-management/
|
||||||
|
├── app.py # Flask web application (4 routes)
|
||||||
|
├── Makefile # Build automation (13 targets)
|
||||||
|
├── pyproject.toml # Python dependencies and metadata
|
||||||
|
│
|
||||||
|
├── scripts/
|
||||||
|
│ ├── attendance.py # Shared: attendance data + fee calculation
|
||||||
|
│ ├── calculate_fees.py # CLI: print fee table
|
||||||
|
│ ├── match_payments.py # Core: reconciliation engine + CLI report
|
||||||
|
│ ├── infer_payments.py # Auto-fill Person/Purpose in Google Sheet
|
||||||
|
│ ├── sync_fio_to_sheets.py # Sync Fio bank → Google Sheet
|
||||||
|
│ ├── fio_utils.py # Shared: Fio bank data fetching
|
||||||
|
│ └── czech_utils.py # Shared: diacritics normalization + Czech month parsing
|
||||||
|
│
|
||||||
|
├── templates/
|
||||||
|
│ ├── fees.html # Attendance/fees dashboard
|
||||||
|
│ ├── reconcile.html # Payment reconciliation with modals + QR
|
||||||
|
│ └── payments.html # Payments ledger grouped by member
|
||||||
|
│
|
||||||
|
├── tests/
|
||||||
|
│ ├── test_app.py # Flask route tests (mocked data)
|
||||||
|
│ └── test_reconcile_exceptions.py # Reconciliation with fee exceptions
|
||||||
|
│
|
||||||
|
├── build/
|
||||||
|
│ ├── Dockerfile # Alpine-based container image
|
||||||
|
│ └── entrypoint.sh # Container entry point
|
||||||
|
│
|
||||||
|
├── .gitea/workflows/
|
||||||
|
│ ├── build.yaml # CI: build + push Docker image
|
||||||
|
│ └── kubernetes-deploy.yaml # CD: deploy to K8s cluster
|
||||||
|
│
|
||||||
|
├── .secret/ # (gitignored) API credentials
|
||||||
|
├── docs/ # Project documentation
|
||||||
|
│ ├── project-notes.md # Original brainstorming and design notes
|
||||||
|
│ ├── fee-calculation-spec.md # Fee rules and payment matching spec
|
||||||
|
│ ├── scripts.md # Legacy scripts documentation
|
||||||
|
│ └── spec/
|
||||||
|
│ └── fio_to_sheets_sync.md # Fio-to-Sheets sync specification
|
||||||
|
│
|
||||||
|
└── CLAUDE.md # AI assistant context file
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key Design Decisions
|
||||||
|
|
||||||
|
1. **No database** — Google Sheets serves as both the data store and the manual editing interface. This keeps the system simple and accessible to non-technical club members who can review and edit data directly in the spreadsheet.
|
||||||
|
|
||||||
|
2. **PII separation** — No member names or personal data are stored in the git repository. All data is fetched at runtime from Google Sheets and the bank account.
|
||||||
|
|
||||||
|
3. **Idempotent sync** — The Fio-to-Sheets sync uses SHA-256 hashes as deduplication keys, making re-runs safe and append-only.
|
||||||
|
|
||||||
|
4. **Graceful fallbacks** — Bank data can be fetched via the REST API (if a token is available) or by scraping the public transparent account page. The system doesn't break if the API token is missing.
|
||||||
|
|
||||||
|
5. **Czech language support** — Payment messages are in Czech and use diacritics. The system normalizes text (strips diacritics) and understands Czech month names in all grammatical declensions.
|
||||||
|
|
||||||
|
6. **Terminal aesthetic** — The web dashboard uses a monospace, dark-themed, terminal-inspired design that matches the project's pragmatic, CLI-first philosophy.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*This documentation was generated on 2026-03-03 by Claude Opus, based on a comprehensive analysis of the complete codebase.*
|
||||||
268
docs/by-claude-opus/architecture.md
Normal file
268
docs/by-claude-opus/architecture.md
Normal file
@@ -0,0 +1,268 @@
|
|||||||
|
# System Architecture
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
FUJ Management follows a **pipeline architecture** where data flows from external sources (Google Sheets, Fio Bank) through processing scripts into a web dashboard. There is no central database — Google Sheets serves as the persistent data store, and the Flask app renders views by fetching and processing data on every request.
|
||||||
|
|
||||||
|
## Component Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────┐
|
||||||
|
│ EXTERNAL DATA SOURCES │
|
||||||
|
│ │
|
||||||
|
│ ┌──────────────────┐ ┌──────────────────────┐ │
|
||||||
|
│ │ Attendance Sheet │ │ Fio Bank Account │ │
|
||||||
|
│ │ (Google Sheets) │ │ │ │
|
||||||
|
│ │ │ │ ┌────────────────┐ │ │
|
||||||
|
│ │ ID: 1E2e_gT... │ │ │ REST API │ │ │
|
||||||
|
│ │ │ │ │ (JSON, w/token)│ │ │
|
||||||
|
│ │ CSV export (pub) │ │ ├────────────────┤ │ │
|
||||||
|
│ │ │ │ │ Transparent │ │ │
|
||||||
|
│ └────────┬─────────┘ │ │ page (HTML) │ │ │
|
||||||
|
│ │ │ └───────┬────────┘ │ │
|
||||||
|
│ │ └──────────┼──────────┘ │
|
||||||
|
└───────────┼───────────────────────┼────────────┘
|
||||||
|
│ │
|
||||||
|
─ ─ ─ ─ ─ ─ ┼ ─ ─ DATA INGESTION ─ ┼ ─ ─ ─ ─ ─
|
||||||
|
│ │
|
||||||
|
┌───────────▼──────┐ ┌───────────▼──────────┐
|
||||||
|
│ attendance.py │ │ fio_utils.py │
|
||||||
|
│ │ │ │
|
||||||
|
│ fetch_csv() │ │ fetch_transactions() │
|
||||||
|
│ parse_dates() │ │ FioTableParser │
|
||||||
|
│ group_by_month() │ │ parse_czech_amount() │
|
||||||
|
│ calculate_fee() │ │ parse_czech_date() │
|
||||||
|
│ get_members() │ │ │
|
||||||
|
│ get_members_ │ │ API + HTML fallback │
|
||||||
|
│ with_fees() │ │ │
|
||||||
|
└───────────┬──────┘ └───────────┬──────────┘
|
||||||
|
│ │
|
||||||
|
─ ─ ─ ─ ─ ─ ┼ ─ ─ PROCESSING ─ ─ ─ ┼ ─ ─ ─ ─ ─
|
||||||
|
│ │
|
||||||
|
│ ┌─────────────▼──────────┐
|
||||||
|
│ │ sync_fio_to_sheets.py │ ──▶ Payments Sheet
|
||||||
|
│ │ │ (Google Sheets)
|
||||||
|
│ │ generate_sync_id() │
|
||||||
|
│ │ sort_sheet_by_date() │
|
||||||
|
│ │ get_sheets_service() │
|
||||||
|
│ └────────────────────────┘
|
||||||
|
│ │
|
||||||
|
│ ┌─────────────▼──────────┐
|
||||||
|
│ │ infer_payments.py │ ──▶ Writes back to
|
||||||
|
│ │ │ Payments Sheet
|
||||||
|
│ │ infer Person/Purpose/ │
|
||||||
|
│ │ Amount for empty rows │
|
||||||
|
│ └────────────────────────┘
|
||||||
|
│ │
|
||||||
|
│ ┌──────────────────▼──────────┐
|
||||||
|
│ │ czech_utils.py │
|
||||||
|
│ │ │
|
||||||
|
│ │ normalize() — strip │
|
||||||
|
│ │ diacritics │
|
||||||
|
│ │ parse_month_references() │
|
||||||
|
│ │ CZECH_MONTHS dict │
|
||||||
|
│ └─────────────────────────────┘
|
||||||
|
│ │
|
||||||
|
─ ─ ─ ─ ─ ─ ┼ ─ RECONCILIATION ─ ─┼ ─ ─ ─ ─ ─
|
||||||
|
│ │
|
||||||
|
┌─────────▼───────────────────────▼───────────┐
|
||||||
|
│ match_payments.py │
|
||||||
|
│ │
|
||||||
|
│ _build_name_variants() — name matching │
|
||||||
|
│ match_members() — fuzzy match │
|
||||||
|
│ infer_transaction_details() │
|
||||||
|
│ fetch_sheet_data() — read payments │
|
||||||
|
│ fetch_exceptions() — fee overrides │
|
||||||
|
│ reconcile() — CORE ENGINE │
|
||||||
|
│ print_report() — CLI output │
|
||||||
|
└──────────────────────┬──────────────────────┘
|
||||||
|
│
|
||||||
|
─ ─ ─ ─ ─ ─ ─ PRESENTATION ┼ ─ ─ ─ ─ ─ ─ ─ ─ ─
|
||||||
|
│
|
||||||
|
┌──────────────────────▼──────────────────────┐
|
||||||
|
│ app.py (Flask) │
|
||||||
|
│ │
|
||||||
|
│ GET / → redirect to /fees │
|
||||||
|
│ GET /fees → fees.html │
|
||||||
|
│ GET /reconcile → reconcile.html │
|
||||||
|
│ GET /payments → payments.html │
|
||||||
|
│ GET /qr → PNG QR code (SPD format) │
|
||||||
|
└─────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Module Dependency Graph
|
||||||
|
|
||||||
|
```
|
||||||
|
app.py
|
||||||
|
├── attendance.py
|
||||||
|
│ └── (stdlib: csv, urllib, datetime)
|
||||||
|
└── match_payments.py
|
||||||
|
├── attendance.py
|
||||||
|
├── czech_utils.py
|
||||||
|
│ └── (stdlib: re, unicodedata)
|
||||||
|
└── sync_fio_to_sheets.py (for get_sheets_service, DEFAULT_SPREADSHEET_ID)
|
||||||
|
└── fio_utils.py
|
||||||
|
└── (stdlib: json, urllib, html.parser, datetime)
|
||||||
|
|
||||||
|
infer_payments.py
|
||||||
|
├── sync_fio_to_sheets.py
|
||||||
|
├── match_payments.py
|
||||||
|
└── attendance.py
|
||||||
|
|
||||||
|
calculate_fees.py
|
||||||
|
└── attendance.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### Import Relationships
|
||||||
|
|
||||||
|
| Module | Imports from |
|
||||||
|
|--------|-------------|
|
||||||
|
| `app.py` | `attendance` (`get_members_with_fees`, `SHEET_ID`), `match_payments` (`reconcile`, `fetch_sheet_data`, `fetch_exceptions`, `normalize`, `DEFAULT_SPREADSHEET_ID`) |
|
||||||
|
| `match_payments.py` | `attendance` (`get_members_with_fees`), `czech_utils` (`normalize`, `parse_month_references`), `sync_fio_to_sheets` (`get_sheets_service`, `DEFAULT_SPREADSHEET_ID`) |
|
||||||
|
| `infer_payments.py` | `sync_fio_to_sheets` (`get_sheets_service`, `DEFAULT_SPREADSHEET_ID`), `match_payments` (`infer_transaction_details`), `attendance` (`get_members_with_fees`) |
|
||||||
|
| `sync_fio_to_sheets.py` | `fio_utils` (`fetch_transactions`) |
|
||||||
|
| `calculate_fees.py` | `attendance` (`get_members_with_fees`) |
|
||||||
|
|
||||||
|
## Data Flow Patterns
|
||||||
|
|
||||||
|
### Pattern 1: Sync & Enrich (Batch Pipeline)
|
||||||
|
|
||||||
|
This is the primary workflow for keeping the payments ledger up to date:
|
||||||
|
|
||||||
|
```
|
||||||
|
1. make sync 2. make infer
|
||||||
|
┌──────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
|
||||||
|
│ Fio │───▶│ Payments │ │ Payments │───▶│ Payments │
|
||||||
|
│ Bank │ │ Sheet │ │ Sheet │ │ Sheet │
|
||||||
|
└──────┘ │ (append) │ │ (read) │ │ (update) │
|
||||||
|
└──────────┘ └──────────┘ └──────────┘
|
||||||
|
|
||||||
|
- Fetches last 30 days - Reads empty Person/Purpose rows
|
||||||
|
- SHA-256 dedup prevents - Uses name matching + Czech month
|
||||||
|
duplicate entries parsing to auto-fill
|
||||||
|
- Marks uncertain matches with [?]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pattern 2: Real-Time Rendering (Web Dashboard)
|
||||||
|
|
||||||
|
Every web request triggers a fresh data fetch — no caching layer exists:
|
||||||
|
|
||||||
|
```
|
||||||
|
Browser Request → Flask Route → Fetch (Google Sheets API/CSV) → Process → Render HTML
|
||||||
|
│ │
|
||||||
|
│ attendance.py │ reconcile()
|
||||||
|
│ fetch_sheet_data() │ or direct
|
||||||
|
│ fetch_exceptions() │ formatting
|
||||||
|
▼ ▼
|
||||||
|
~1-3 seconds Template with
|
||||||
|
(network I/O) inline CSS + JS
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pattern 3: QR Code Generation (On-Demand)
|
||||||
|
|
||||||
|
```
|
||||||
|
Browser clicks "Pay" → GET /qr?account=...&amount=...&message=... → SPD QR PNG
|
||||||
|
│
|
||||||
|
qrcode lib
|
||||||
|
generates
|
||||||
|
in-memory PNG
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key Design Patterns
|
||||||
|
|
||||||
|
### 1. Google Sheets as Database
|
||||||
|
|
||||||
|
Instead of a traditional database, the system uses two Google Sheets:
|
||||||
|
|
||||||
|
| Sheet | Purpose | Access Method |
|
||||||
|
|-------|---------|---------------|
|
||||||
|
| Attendance Sheet (`1E2e_gT...`) | Member names, tiers, practice dates, attendance marks | Public CSV export (no auth needed) |
|
||||||
|
| Payments Sheet (`1Om0YPo...`) | Bank transactions with Person/Purpose annotations | Google Sheets API (service account auth) |
|
||||||
|
|
||||||
|
**Trade-offs**:
|
||||||
|
- ✅ Non-technical users can view and edit data directly
|
||||||
|
- ✅ No database setup or maintenance
|
||||||
|
- ✅ Built-in audit trail (Google Sheets version history)
|
||||||
|
- ❌ Every page load incurs 1-3s of API latency
|
||||||
|
- ❌ No complex queries or indexing
|
||||||
|
- ❌ Rate limits on Google Sheets API
|
||||||
|
|
||||||
|
### 2. Dual-Mode Bank Access
|
||||||
|
|
||||||
|
`fio_utils.py` implements a transparent fallback pattern:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def fetch_transactions(date_from, date_to):
|
||||||
|
token = os.environ.get("FIO_API_TOKEN", "").strip()
|
||||||
|
if token:
|
||||||
|
return fetch_transactions_api(token, date_from, date_to) # Structured JSON
|
||||||
|
return fetch_transactions_transparent(...) # HTML scraping
|
||||||
|
```
|
||||||
|
|
||||||
|
The API provides richer data (sender account numbers, stable bank IDs) but requires a token. The transparent page is always available but lacks some fields.
|
||||||
|
|
||||||
|
### 3. Name Matching with Confidence Levels
|
||||||
|
|
||||||
|
The reconciliation engine uses a multi-tier matching strategy:
|
||||||
|
|
||||||
|
| Priority | Method | Confidence | Example |
|
||||||
|
|----------|--------|-----------|---------|
|
||||||
|
| 1 | Full name match | `auto` | "František Vrbík" in message |
|
||||||
|
| 2 | Both first + last name (any order) | `auto` | "Vrbík František" |
|
||||||
|
| 3 | Nickname match | `auto` | "(Štrúdl)" from member list |
|
||||||
|
| 4 | Last name only (≥4 chars, not common) | `review` | "Vrbík" alone |
|
||||||
|
| 5 | First name only (≥3 chars) | `review` | "František" alone |
|
||||||
|
|
||||||
|
When both `auto` and `review` matches exist, `review` matches are discarded. This prevents false positives from generic first names.
|
||||||
|
|
||||||
|
### 4. Exception System
|
||||||
|
|
||||||
|
Fee overrides are managed through an `exceptions` sheet tab in the Payments Google Sheet:
|
||||||
|
|
||||||
|
| Column | Content |
|
||||||
|
|--------|---------|
|
||||||
|
| Name | Member name |
|
||||||
|
| Period | Month (YYYY-MM) |
|
||||||
|
| Amount | Overridden fee in CZK |
|
||||||
|
| Note | Reason for the exception |
|
||||||
|
|
||||||
|
Exceptions are applied during reconciliation, replacing the attendance-calculated fee with the manually specified amount.
|
||||||
|
|
||||||
|
### 5. Render-Time Performance Tracking
|
||||||
|
|
||||||
|
Every page includes a performance breakdown:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@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()))
|
||||||
|
```
|
||||||
|
|
||||||
|
The footer displays total render time and, on click, reveals a detailed breakdown (e.g., `fetch_members:0.892s | fetch_payments:1.205s | reconcile:0.003s | render:0.015s`).
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
| Concern | Mitigation |
|
||||||
|
|---------|-----------|
|
||||||
|
| PII in git | `.secret/` is gitignored; all data fetched at runtime |
|
||||||
|
| Google API credentials | Service account JSON stored in `.secret/`, mounted as Docker secret |
|
||||||
|
| Bank API token | Passed via `FIO_API_TOKEN` environment variable, never committed |
|
||||||
|
| Web app authentication | **None currently** — the app has no auth layer |
|
||||||
|
| CSRF protection | **None currently** — Flask default (no POST routes exist) |
|
||||||
|
|
||||||
|
## Scalability Notes
|
||||||
|
|
||||||
|
This system is purpose-built for a small club (~20-40 members). It makes deliberate trade-offs favoring simplicity over scale:
|
||||||
|
|
||||||
|
- **No caching**: Every page load fetches live data from Google Sheets (1-3s latency). For a single-user admin dashboard, this is acceptable.
|
||||||
|
- **No background workers**: Sync and inference are manual `make` commands, not scheduled jobs.
|
||||||
|
- **No database**: Google Sheets handles 10s of members and 100s of transactions with ease.
|
||||||
|
- **Single-process Flask**: The built-in development server runs directly in production (via Docker). For this use case, this is intentional — it's a personal tool, not a public service.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Architecture documentation generated from comprehensive code analysis on 2026-03-03.*
|
||||||
201
docs/by-claude-opus/data-model.md
Normal file
201
docs/by-claude-opus/data-model.md
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
# Data Model
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
FUJ Management operates on two Google Sheets and an external bank account. There is no local database — all persistent data lives in Google Sheets, and all member data is fetched at runtime (never committed to git).
|
||||||
|
|
||||||
|
## External Data Sources
|
||||||
|
|
||||||
|
### 1. Attendance Google Sheet
|
||||||
|
|
||||||
|
| Property | Value |
|
||||||
|
|----------|-------|
|
||||||
|
| **Sheet ID** | `1E2e_gT_K5AwSRCDLDTa2UetZTkHmBOcz0kFbBUNUNBA` |
|
||||||
|
| **Access** | Public CSV export (no authentication required) |
|
||||||
|
| **Purpose** | Member roster, weekly practice attendance marks |
|
||||||
|
| **Scope** | Tuesday practices (20:30–22:00) |
|
||||||
|
|
||||||
|
#### Schema
|
||||||
|
|
||||||
|
```
|
||||||
|
Row 1: [Title] [blank] [blank] [10/1/2025] [10/8/2025] [10/15/2025] ...
|
||||||
|
Row 2: Venue per date (ignored by the system)
|
||||||
|
Row 3: Subtotals per date (ignored by the system)
|
||||||
|
Row 4+: [Name] [Tier] [Total] [TRUE/FALSE] [TRUE/FALSE] ...
|
||||||
|
...
|
||||||
|
Row N: # last line (sentinel — stops parsing)
|
||||||
|
```
|
||||||
|
|
||||||
|
| Column | Index | Content | Example |
|
||||||
|
|--------|-------|---------|---------|
|
||||||
|
| A | 0 | Member name | `Jan Novák` |
|
||||||
|
| B | 1 | Tier code | `A`, `J`, or `X` |
|
||||||
|
| C | 2 | Total attendance (auto-calculated, ignored by the system) | `12` |
|
||||||
|
| D+ | 3+ | Attendance per date | `TRUE` or `FALSE` |
|
||||||
|
|
||||||
|
#### Tier Codes
|
||||||
|
|
||||||
|
| Code | Meaning | Pays fees? |
|
||||||
|
|------|---------|-----------|
|
||||||
|
| `A` | Adult | Yes — calculated from this sheet |
|
||||||
|
| `J` | Junior | No — managed via a separate sheet |
|
||||||
|
| `X` | Exempt | No |
|
||||||
|
|
||||||
|
#### Sentinel Row
|
||||||
|
|
||||||
|
The system stops parsing member rows when it encounters a row whose first column contains `# last line` (case-insensitive). Rows starting with `#` are also skipped as comments.
|
||||||
|
|
||||||
|
### 2. Payments Google Sheet
|
||||||
|
|
||||||
|
| Property | Value |
|
||||||
|
|----------|-------|
|
||||||
|
| **Sheet ID** | `1Om0YPoDVCH5cV8BrNz5LG5eR5MMU05ypQC7UMN1xn_Y` |
|
||||||
|
| **Access** | Google Sheets API (service account authentication) |
|
||||||
|
| **Purpose** | Intermediary ledger for bank transactions + manual annotations |
|
||||||
|
| **Managed by** | `sync_fio_to_sheets.py` (append), `infer_payments.py` (update) |
|
||||||
|
|
||||||
|
#### Main Sheet Schema (Columns A–K)
|
||||||
|
|
||||||
|
| Column | Label | Populated by | Description |
|
||||||
|
|--------|-------|-------------|-------------|
|
||||||
|
| A | Date | `sync` | Transaction date (`YYYY-MM-DD`) |
|
||||||
|
| B | Amount | `sync` | Bank transaction amount in CZK |
|
||||||
|
| C | manual fix | Human | If non-empty, `infer` will skip this row |
|
||||||
|
| D | Person | `infer` or human | Member name(s), comma-separated for multi-person payments |
|
||||||
|
| E | Purpose | `infer` or human | Month(s) covered, e.g. `2026-01` or `2026-01, 2026-02` |
|
||||||
|
| F | Inferred Amount | `infer` or human | Amount to use for reconciliation (may differ from bank amount) |
|
||||||
|
| G | Sender | `sync` | Bank sender name/account |
|
||||||
|
| H | VS | `sync` | Variable symbol |
|
||||||
|
| I | Message | `sync` | Payment message for recipient |
|
||||||
|
| J | Bank ID | `sync` | Fio transaction ID (API only) |
|
||||||
|
| K | Sync ID | `sync` | SHA-256 deduplication hash |
|
||||||
|
|
||||||
|
#### Exceptions Sheet Tab
|
||||||
|
|
||||||
|
A separate tab named `exceptions` in the same spreadsheet, used for manual fee overrides:
|
||||||
|
|
||||||
|
| Column | Label | Content |
|
||||||
|
|--------|-------|---------|
|
||||||
|
| A | Name | Member name (plain text) |
|
||||||
|
| B | Period | Month (`YYYY-MM`) |
|
||||||
|
| C | Amount | Overridden fee in CZK |
|
||||||
|
| D | Note | Reason for override (optional) |
|
||||||
|
|
||||||
|
The first row is assumed to be a header and is skipped. Name and period values are normalized (diacritics stripped, lowercased) for matching.
|
||||||
|
|
||||||
|
### 3. Fio Bank Account
|
||||||
|
|
||||||
|
| Property | Value |
|
||||||
|
|----------|-------|
|
||||||
|
| **Account number** | `2800359168/2010` |
|
||||||
|
| **IBAN** | `CZ8520100000002800359168` |
|
||||||
|
| **Type** | Transparent account |
|
||||||
|
| **Owner** | Nathan Heilmann |
|
||||||
|
| **Public URL** | `https://ib.fio.cz/ib/transparent?a=2800359168` |
|
||||||
|
|
||||||
|
#### Access Methods
|
||||||
|
|
||||||
|
| Method | Trigger | Data richness |
|
||||||
|
|--------|---------|--------------|
|
||||||
|
| REST API | `FIO_API_TOKEN` env var set | Full data: sender account, bank ID, user identification, currency |
|
||||||
|
| HTML scraping | `FIO_API_TOKEN` not set | Partial: date, amount, sender name, message, VS/KS/SS |
|
||||||
|
|
||||||
|
#### API Rate Limit
|
||||||
|
|
||||||
|
The Fio REST API allows 1 request per 30 seconds per token.
|
||||||
|
|
||||||
|
## Fee Calculation Rules
|
||||||
|
|
||||||
|
Fees apply only to **tier A (Adult)** members. They are calculated per calendar month based on Tuesday practice attendance:
|
||||||
|
|
||||||
|
| Practices attended | Monthly fee |
|
||||||
|
|-------------------|-------------|
|
||||||
|
| 0 | 0 CZK |
|
||||||
|
| 1 | 200 CZK |
|
||||||
|
| 2+ | 750 CZK |
|
||||||
|
|
||||||
|
### Exception Overrides
|
||||||
|
|
||||||
|
The fee can be manually overridden per member per month via the `exceptions` tab. When an exception exists:
|
||||||
|
- The `expected` amount in reconciliation uses the exception amount
|
||||||
|
- The `original_expected` amount preserves the attendance-based calculation
|
||||||
|
- The override is displayed in amber/orange in the web UI
|
||||||
|
|
||||||
|
### Advance Payments
|
||||||
|
|
||||||
|
If a payment references a month not yet covered by attendance data:
|
||||||
|
- It is tracked as **credit** on the member's account
|
||||||
|
- Credits are added to the total balance
|
||||||
|
- When attendance data becomes available for that month, the credit effectively offsets the expected fee
|
||||||
|
|
||||||
|
## Reconciliation Data Model
|
||||||
|
|
||||||
|
The `reconcile()` function returns this structure:
|
||||||
|
|
||||||
|
```python
|
||||||
|
{
|
||||||
|
"members": {
|
||||||
|
"Jan Novák": {
|
||||||
|
"tier": "A",
|
||||||
|
"months": {
|
||||||
|
"2026-01": {
|
||||||
|
"expected": 750, # Fee after exception application
|
||||||
|
"original_expected": 750, # Attendance-based fee
|
||||||
|
"attendance_count": 4, # How many times they came
|
||||||
|
"exception": None, # or {"amount": 400, "note": "..."}
|
||||||
|
"paid": 750.0, # Total matched payments
|
||||||
|
"transactions": [ # Individual payment records
|
||||||
|
{
|
||||||
|
"amount": 750.0,
|
||||||
|
"date": "2026-01-15",
|
||||||
|
"sender": "Jan Novák",
|
||||||
|
"message": "leden",
|
||||||
|
"confidence": "auto"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"total_balance": 0 # sum(paid - expected) across all months + off-window credits
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"unmatched": [ # Transactions that couldn't be assigned
|
||||||
|
{
|
||||||
|
"date": "2026-01-20",
|
||||||
|
"amount": 500,
|
||||||
|
"sender": "Unknown",
|
||||||
|
"message": "dar"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"credits": { # Alias for positive total_balance entries
|
||||||
|
"Jan Novák": 200
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Sync ID Generation
|
||||||
|
|
||||||
|
The deduplication key for bank transactions is a SHA-256 hash of:
|
||||||
|
|
||||||
|
```
|
||||||
|
sha256("date|amount|currency|sender|vs|message|bank_id")
|
||||||
|
```
|
||||||
|
|
||||||
|
All values are lowercased before hashing. This ensures:
|
||||||
|
- Same transaction fetched twice produces the same ID
|
||||||
|
- Two payments on the same day with different amounts/senders produce different IDs
|
||||||
|
- The hash is stable across API and HTML scraping modes (shared fields)
|
||||||
|
|
||||||
|
## Date Handling
|
||||||
|
|
||||||
|
| Source | Format | Normalization |
|
||||||
|
|--------|--------|--------------|
|
||||||
|
| Attendance Sheet header | `M/D/YYYY` (US format) | `datetime.strptime(raw, "%m/%d/%Y")` |
|
||||||
|
| Fio API | `YYYY-MM-DD+HHMM` | Take first 10 characters |
|
||||||
|
| Fio transparent page | `DD.MM.YYYY` | `datetime.strptime(raw, "%d.%m.%Y")` |
|
||||||
|
| Google Sheets (unformatted) | Serial number (days since 1899-12-30) | `datetime(1899, 12, 30) + timedelta(days=val)` |
|
||||||
|
|
||||||
|
All internal date representation uses `YYYY-MM-DD` format. Month keys use `YYYY-MM`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Data model documentation generated from comprehensive code analysis on 2026-03-03.*
|
||||||
198
docs/by-claude-opus/deployment.md
Normal file
198
docs/by-claude-opus/deployment.md
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
# Deployment Guide
|
||||||
|
|
||||||
|
## Local Development
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- **Python 3.13+** (required by `pyproject.toml`)
|
||||||
|
- **[uv](https://docs.astral.sh/uv/)** — Fast Python package manager
|
||||||
|
- Google Sheets API credentials (service account JSON)
|
||||||
|
|
||||||
|
### Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone <repo-url>
|
||||||
|
cd fuj-management
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
uv sync
|
||||||
|
|
||||||
|
# Configure credentials
|
||||||
|
mkdir -p .secret
|
||||||
|
cp /path/to/credentials.json .secret/fuj-management-bot-credentials.json
|
||||||
|
|
||||||
|
# Optional: Set Fio API token for richer bank data
|
||||||
|
export FIO_API_TOKEN=your_token_here
|
||||||
|
|
||||||
|
# Start the web dashboard
|
||||||
|
make web
|
||||||
|
# → Flask server at http://localhost:5001
|
||||||
|
```
|
||||||
|
|
||||||
|
### Makefile Targets
|
||||||
|
|
||||||
|
| Target | Command | Description |
|
||||||
|
|--------|---------|-------------|
|
||||||
|
| `help` | `make help` | List all available targets |
|
||||||
|
| `venv` | `make venv` | Sync virtual environment with pyproject.toml |
|
||||||
|
| `fees` | `make fees` | Print fee calculation table |
|
||||||
|
| `match` | `make match` | (Legacy) Direct bank matching |
|
||||||
|
| `web` | `make web` | Start Flask dashboard on port 5001 |
|
||||||
|
| `sync` | `make sync` | Sync last 30 days of bank transactions |
|
||||||
|
| `sync-2026` | `make sync-2026` | Sync full year 2026 transactions |
|
||||||
|
| `infer` | `make infer` | Auto-fill Person/Purpose in the sheet |
|
||||||
|
| `reconcile` | `make reconcile` | Print CLI balance report |
|
||||||
|
| `test` | `make test` | Run test suite |
|
||||||
|
| `test-v` | `make test-v` | Run tests with verbose output |
|
||||||
|
| `image` | `make image` | Build Docker image |
|
||||||
|
| `run` | `make run` | Run Docker container locally |
|
||||||
|
|
||||||
|
The Makefile includes **automatic venv management**: targets that need Python depend on `.venv/.last_sync`, which triggers `uv sync` when `pyproject.toml` changes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Docker Container
|
||||||
|
|
||||||
|
### Building
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make image
|
||||||
|
# → docker build -t fuj-management:latest -f build/Dockerfile .
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dockerfile Details
|
||||||
|
|
||||||
|
**Base image**: `python:3.13-alpine`
|
||||||
|
|
||||||
|
**Build stages**:
|
||||||
|
1. Install system packages (`bash`, `tzdata`)
|
||||||
|
2. Set timezone to `Europe/Prague`
|
||||||
|
3. Install Python dependencies via pip
|
||||||
|
4. Copy application files (`app.py`, `scripts/`, `templates/`, `Makefile`)
|
||||||
|
5. Copy entrypoint script
|
||||||
|
|
||||||
|
**Exposed port**: 5001
|
||||||
|
|
||||||
|
**Health check**: `wget -q -O /dev/null http://localhost:5001/` every 60s
|
||||||
|
|
||||||
|
### Running Locally via Docker
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make run
|
||||||
|
# → docker run -it --rm -p 5001:5001 fuj-management:latest
|
||||||
|
|
||||||
|
# With credentials and environment:
|
||||||
|
docker run -it --rm \
|
||||||
|
-p 5001:5001 \
|
||||||
|
-v $(pwd)/.secret:/app/.secret:ro \
|
||||||
|
-e FIO_API_TOKEN=your_token \
|
||||||
|
-e BANK_ACCOUNT=CZ8520100000002800359168 \
|
||||||
|
fuj-management:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
### Entrypoint
|
||||||
|
|
||||||
|
The `build/entrypoint.sh` script simply runs:
|
||||||
|
```bash
|
||||||
|
exec python3 /app/app.py
|
||||||
|
```
|
||||||
|
|
||||||
|
This uses Flask's built-in server directly. For a production deployment, consider adding gunicorn or waitress (noted as a TODO in the entrypoint).
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `BANK_ACCOUNT` | `CZ8520100000002800359168` | IBAN for QR code generation |
|
||||||
|
| `FIO_API_TOKEN` | *(none)* | Fio REST API token |
|
||||||
|
| `PYTHONUNBUFFERED` | `1` (set in Dockerfile) | Ensures real-time log output |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CI/CD Pipeline
|
||||||
|
|
||||||
|
### Gitea Actions
|
||||||
|
|
||||||
|
The project uses two Gitea Actions workflows:
|
||||||
|
|
||||||
|
#### 1. Build and Push (`build.yaml`)
|
||||||
|
|
||||||
|
**Triggers**:
|
||||||
|
- Push of any tag
|
||||||
|
- Manual dispatch (with custom tag input)
|
||||||
|
|
||||||
|
**Steps**:
|
||||||
|
1. Checkout code
|
||||||
|
2. Login to Gitea container registry (`gitea.home.hrajfrisbee.cz`)
|
||||||
|
3. Build Docker image using `build/Dockerfile`
|
||||||
|
4. Push to `gitea.home.hrajfrisbee.cz/<owner>/<repo>:<tag>`
|
||||||
|
|
||||||
|
**Tag resolution**: Uses the git tag name. For manual dispatch, uses the provided input.
|
||||||
|
|
||||||
|
#### 2. Deploy to Kubernetes (`kubernetes-deploy.yaml`)
|
||||||
|
|
||||||
|
**Triggers**:
|
||||||
|
- Push to any branch
|
||||||
|
- Manual dispatch
|
||||||
|
|
||||||
|
**Steps**:
|
||||||
|
1. Checkout code
|
||||||
|
2. Install kubectl
|
||||||
|
3. Retrieve Kanidm token from HashiCorp Vault:
|
||||||
|
- Authenticate to Vault via AppRole (`VAULT_ROLE_ID` / `VAULT_SECRET_ID`)
|
||||||
|
- Fetch API token from `secret/data/gitea/gitea-ci`
|
||||||
|
4. Exchange API token for K8s OIDC token via Kanidm:
|
||||||
|
- POST to `https://idm.home.hrajfrisbee.cz/oauth2/token`
|
||||||
|
- Token exchange using `urn:ietf:params:oauth:grant-type:token-exchange`
|
||||||
|
5. Configure kubectl with the OIDC token
|
||||||
|
6. Run `kubectl auth whoami` and `kubectl get ns` (deploy commands are commented out — WIP)
|
||||||
|
|
||||||
|
**Required secrets**:
|
||||||
|
|
||||||
|
| Secret | Purpose |
|
||||||
|
|--------|---------|
|
||||||
|
| `REGISTRY_TOKEN` | Docker registry authentication |
|
||||||
|
| `VAULT_ROLE_ID` | HashiCorp Vault AppRole role ID |
|
||||||
|
| `VAULT_SECRET_ID` | HashiCorp Vault AppRole secret ID |
|
||||||
|
| `K8S_CA_CERT` | Kubernetes cluster CA certificate |
|
||||||
|
|
||||||
|
### Infrastructure Topology
|
||||||
|
|
||||||
|
```
|
||||||
|
Gitea (git push / tag)
|
||||||
|
│
|
||||||
|
├── build.yaml → Docker Build → Gitea Container Registry
|
||||||
|
│ (gitea.home.hrajfrisbee.cz)
|
||||||
|
│
|
||||||
|
└── kubernetes-deploy.yaml → Vault → Kanidm → K8s Cluster
|
||||||
|
(192.168.0.31:6443)
|
||||||
|
```
|
||||||
|
|
||||||
|
This is a self-hosted infrastructure stack:
|
||||||
|
- **Gitea** for git hosting and CI/CD
|
||||||
|
- **HashiCorp Vault** for secret management
|
||||||
|
- **Kanidm** for identity/OIDC
|
||||||
|
- **Kubernetes** for container orchestration
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Credentials Management
|
||||||
|
|
||||||
|
### Google Sheets API
|
||||||
|
|
||||||
|
The system uses a **Google Cloud service account** for accessing the Payments Google Sheet. The credentials file must be:
|
||||||
|
- Stored at `.secret/fuj-management-bot-credentials.json`
|
||||||
|
- In Google Cloud service account JSON format
|
||||||
|
- The service account must be shared (as editor) on the target Google Sheet
|
||||||
|
|
||||||
|
For local development with OAuth2 (personal Google account), the system also supports the OAuth2 installed app flow — it will generate a `token.pickle` file on first use.
|
||||||
|
|
||||||
|
### Fio Bank API
|
||||||
|
|
||||||
|
Optional. Set the `FIO_API_TOKEN` environment variable. The token is generated in Fio internetbanking under Settings → API.
|
||||||
|
|
||||||
|
**Rate limit**: 1 request per 30 seconds per token.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Deployment documentation generated from comprehensive code analysis on 2026-03-03.*
|
||||||
228
docs/by-claude-opus/development.md
Normal file
228
docs/by-claude-opus/development.md
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
# Development Guide
|
||||||
|
|
||||||
|
## Development Environment
|
||||||
|
|
||||||
|
### Required Tools
|
||||||
|
|
||||||
|
| Tool | Version | Purpose |
|
||||||
|
|------|---------|---------|
|
||||||
|
| Python | 3.13+ | Runtime |
|
||||||
|
| uv | Latest | Dependency management |
|
||||||
|
| Docker | Latest | Container builds |
|
||||||
|
| Git | Any | Version control |
|
||||||
|
| Make | Any | Build automation |
|
||||||
|
|
||||||
|
### Initial Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Clone the repository
|
||||||
|
git clone <repo-url>
|
||||||
|
cd fuj-management
|
||||||
|
|
||||||
|
# 2. Install dependencies (creates .venv automatically)
|
||||||
|
uv sync
|
||||||
|
|
||||||
|
# 3. Activate the virtual environment
|
||||||
|
source .venv/bin/activate
|
||||||
|
|
||||||
|
# 4. Set up credentials
|
||||||
|
mkdir -p .secret
|
||||||
|
# Copy your Google service account JSON here:
|
||||||
|
cp ~/Downloads/fuj-management-bot-credentials.json .secret/
|
||||||
|
|
||||||
|
# 5. (Optional) Set Fio API token
|
||||||
|
export FIO_API_TOKEN=your_token_here
|
||||||
|
```
|
||||||
|
|
||||||
|
### IDE Configuration
|
||||||
|
|
||||||
|
The `.vscode/` directory contains workspace settings. If using VS Code, the Python interpreter should automatically detect the `.venv` directory.
|
||||||
|
|
||||||
|
**PYTHONPATH note**: When running scripts from the project root, the Makefile sets `PYTHONPATH=scripts:$PYTHONPATH`. If your IDE doesn't do this, you may see import errors in `match_payments.py` and other scripts that import sibling modules.
|
||||||
|
|
||||||
|
## Project Dependencies
|
||||||
|
|
||||||
|
Defined in `pyproject.toml`:
|
||||||
|
|
||||||
|
| Dependency | Version | Purpose |
|
||||||
|
|------------|---------|---------|
|
||||||
|
| `flask` | ≥3.1.3 | Web framework |
|
||||||
|
| `google-api-python-client` | ≥2.162.0 | Google Sheets API |
|
||||||
|
| `google-auth-httplib2` | ≥0.2.0 | Google auth transport |
|
||||||
|
| `google-auth-oauthlib` | ≥1.2.1 | OAuth2 support |
|
||||||
|
| `qrcode[pil]` | ≥8.0 | QR code generation (with PIL/Pillow backend) |
|
||||||
|
|
||||||
|
The project uses `uv` with `package = false` in `[tool.uv]`, meaning it's not an installable package — dependencies are synced directly to the virtual environment.
|
||||||
|
|
||||||
|
## Coding Conventions
|
||||||
|
|
||||||
|
### Python Style
|
||||||
|
|
||||||
|
- No linter or formatter is configured — the codebase uses a pragmatic, readable style
|
||||||
|
- Type hints are used for function signatures but not exhaustively
|
||||||
|
- Docstrings follow Google-style format on key functions
|
||||||
|
- Scripts use `if __name__ == "__main__": main()` pattern
|
||||||
|
|
||||||
|
### Import Pattern
|
||||||
|
|
||||||
|
Scripts in the `scripts/` directory import from each other as top-level modules:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# In match_payments.py:
|
||||||
|
from attendance import get_members_with_fees
|
||||||
|
from czech_utils import normalize, parse_month_references
|
||||||
|
from sync_fio_to_sheets import get_sheets_service, DEFAULT_SPREADSHEET_ID
|
||||||
|
```
|
||||||
|
|
||||||
|
This works because `scripts/` is added to `sys.path` at runtime (by `app.py` on startup, by Makefile via `PYTHONPATH`, or by scripts adding their own directory to `sys.path`).
|
||||||
|
|
||||||
|
### Template Style
|
||||||
|
|
||||||
|
- All CSS is inline (no external stylesheets)
|
||||||
|
- No CSS preprocessors or frameworks
|
||||||
|
- No JavaScript frameworks — plain DOM manipulation
|
||||||
|
- Terminal-inspired aesthetic: monospace fonts, green-on-black, dashed borders
|
||||||
|
|
||||||
|
### Commit Conventions
|
||||||
|
|
||||||
|
The project uses [Conventional Commits](https://www.conventionalcommits.org/):
|
||||||
|
```
|
||||||
|
feat: add keyboard navigation to member popup
|
||||||
|
fix: correct diacritic-insensitive search filter
|
||||||
|
chore: update dependencies
|
||||||
|
```
|
||||||
|
|
||||||
|
AI commits include a co-author trailer:
|
||||||
|
```
|
||||||
|
Co-authored-by: Antigravity <antigravity@google.com>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture Decisions
|
||||||
|
|
||||||
|
### Why No Database?
|
||||||
|
|
||||||
|
Google Sheets serves as the database because:
|
||||||
|
1. Club members can view and correct data without special tools
|
||||||
|
2. No database server to manage or back up
|
||||||
|
3. Built-in version history and collaborative editing
|
||||||
|
4. Good enough for ~40 members and ~hundreds of transactions
|
||||||
|
|
||||||
|
### Why No Template Inheritance?
|
||||||
|
|
||||||
|
Each HTML template is self-contained. While this means CSS duplication, it keeps each page fully independent and easy to understand. For a 3-page app, the duplication cost is minimal.
|
||||||
|
|
||||||
|
### Why Flask Development Server in Production?
|
||||||
|
|
||||||
|
The Docker container runs Flask's built-in server (`python3 app.py`) rather than gunicorn or waitress. This is intentional — the dashboard is an internal tool accessed by one person at a time. The simplicity outweighs the performance cost.
|
||||||
|
|
||||||
|
### Why Scrape HTML When There's an API?
|
||||||
|
|
||||||
|
The Fio transparent page scraping exists as a **zero-configuration fallback**. Not everyone has an API token, and the transparent page is always publicly accessible. The API is preferred when available (richer data, stable IDs).
|
||||||
|
|
||||||
|
## Common Development Tasks
|
||||||
|
|
||||||
|
### Adding a New Web Route
|
||||||
|
|
||||||
|
1. Add the route function in `app.py`:
|
||||||
|
```python
|
||||||
|
@app.route("/new-page")
|
||||||
|
def new_page():
|
||||||
|
# Fetch data
|
||||||
|
record_step("fetch_data")
|
||||||
|
# Process
|
||||||
|
record_step("process_data")
|
||||||
|
return render_template("new_page.html", ...)
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Create `templates/new_page.html` (copy structure from `fees.html`)
|
||||||
|
|
||||||
|
3. Add a link in the nav bar across all templates:
|
||||||
|
```html
|
||||||
|
<a href="/new-page">[New Page]</a>
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Add a test in `tests/test_app.py`
|
||||||
|
|
||||||
|
### Adding a New Script
|
||||||
|
|
||||||
|
1. Create `scripts/new_script.py`
|
||||||
|
2. Add a Makefile target:
|
||||||
|
```makefile
|
||||||
|
new-target: $(PYTHON)
|
||||||
|
$(PYTHON) scripts/new_script.py
|
||||||
|
```
|
||||||
|
3. Update `make help` output
|
||||||
|
4. Add the `.PHONY` declaration
|
||||||
|
|
||||||
|
### Modifying Fee Rules
|
||||||
|
|
||||||
|
Fee rules are defined as constants in `scripts/attendance.py`:
|
||||||
|
```python
|
||||||
|
FEE_FULL = 750 # 2+ practices
|
||||||
|
FEE_SINGLE = 200 # 1 practice
|
||||||
|
```
|
||||||
|
|
||||||
|
The calculation logic is in `calculate_fee()`:
|
||||||
|
```python
|
||||||
|
def calculate_fee(attendance_count: int) -> int:
|
||||||
|
if attendance_count == 0: return 0
|
||||||
|
if attendance_count == 1: return FEE_SINGLE
|
||||||
|
return FEE_FULL
|
||||||
|
```
|
||||||
|
|
||||||
|
### Adding a New Czech Month Form
|
||||||
|
|
||||||
|
If you encounter a Czech month declension not yet supported, add it to `CZECH_MONTHS` in `scripts/czech_utils.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
CZECH_MONTHS = {
|
||||||
|
"leden": 1, "ledna": 1, "lednu": 1,
|
||||||
|
"lednem": 1, # New instrumental case
|
||||||
|
...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Project History
|
||||||
|
|
||||||
|
The project evolved through distinct phases:
|
||||||
|
|
||||||
|
1. **Design phase** — Initial brainstorming captured in `docs/project-notes.md`
|
||||||
|
2. **CLI tools** — `calculate_fees.py` and `match_payments.py` for command-line workflows
|
||||||
|
3. **Bank integration** — `fio_utils.py` for transparent page scraping, later API support
|
||||||
|
4. **Google Sheets sync** — `sync_fio_to_sheets.py` + `infer_payments.py` for the ledger pipeline
|
||||||
|
5. **Web dashboard** — `app.py` with the `/fees`, `/reconcile`, and `/payments` pages
|
||||||
|
6. **Interactive features** — Modal popups, QR payments, keyboard navigation, search filter
|
||||||
|
7. **Fee exceptions** — Manual override system via the `exceptions` sheet tab
|
||||||
|
8. **CI/CD** — Gitea Actions for Docker builds and Kubernetes deployment
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### "No data." on the web dashboard
|
||||||
|
|
||||||
|
The attendance Google Sheet couldn't be fetched, or it returned empty data. Check:
|
||||||
|
- Internet connectivity
|
||||||
|
- The sheet ID in `attendance.py` is still valid
|
||||||
|
- The sheet's public sharing settings haven't changed
|
||||||
|
|
||||||
|
### Slow page loads
|
||||||
|
|
||||||
|
Each page fetches data from Google Sheets on every request (no caching). Typical load times are 1-3 seconds. If significantly slower:
|
||||||
|
- Check the performance breakdown (click the render time in the footer)
|
||||||
|
- Google Sheets API rate limiting may be the cause
|
||||||
|
|
||||||
|
### Import errors in scripts
|
||||||
|
|
||||||
|
Ensure `PYTHONPATH` includes the `scripts/` directory:
|
||||||
|
```bash
|
||||||
|
export PYTHONPATH=scripts:$PYTHONPATH
|
||||||
|
```
|
||||||
|
|
||||||
|
Or use the Makefile, which sets this automatically.
|
||||||
|
|
||||||
|
### "Could not fetch exceptions" warning
|
||||||
|
|
||||||
|
The `exceptions` tab doesn't exist in the Payments Google Sheet. This is non-fatal — reconciliation proceeds without fee overrides.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Development guide generated from comprehensive code analysis on 2026-03-03.*
|
||||||
325
docs/by-claude-opus/scripts.md
Normal file
325
docs/by-claude-opus/scripts.md
Normal file
@@ -0,0 +1,325 @@
|
|||||||
|
# Scripts Reference
|
||||||
|
|
||||||
|
All scripts live in the `scripts/` directory and are invoked via `make` targets or directly with Python.
|
||||||
|
|
||||||
|
## Pipeline Scripts
|
||||||
|
|
||||||
|
These scripts form the core data processing pipeline. They are typically run in sequence:
|
||||||
|
|
||||||
|
### `sync_fio_to_sheets.py` — Bank → Google Sheet
|
||||||
|
|
||||||
|
Syncs incoming Fio bank transactions to the Payments Google Sheet. Implements an append-only, deduplicated sync — re-running is always safe.
|
||||||
|
|
||||||
|
**Usage**:
|
||||||
|
```bash
|
||||||
|
make sync # Last 30 days
|
||||||
|
make sync-2026 # Full year 2026 (Jan 1 – Dec 31, sorted)
|
||||||
|
|
||||||
|
# Direct invocation with options:
|
||||||
|
python scripts/sync_fio_to_sheets.py \
|
||||||
|
--credentials .secret/fuj-management-bot-credentials.json \
|
||||||
|
--from 2026-01-01 --to 2026-03-01 \
|
||||||
|
--sort-by-date
|
||||||
|
```
|
||||||
|
|
||||||
|
**Arguments**:
|
||||||
|
|
||||||
|
| Argument | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `--days` | `30` | Days to look back (ignored if `--from`/`--to` set) |
|
||||||
|
| `--sheet-id` | Built-in ID | Target Google Sheet |
|
||||||
|
| `--credentials` | `credentials.json` | Path to Google API credentials |
|
||||||
|
| `--from` | *(auto)* | Start date (YYYY-MM-DD) |
|
||||||
|
| `--to` | *(auto)* | End date (YYYY-MM-DD) |
|
||||||
|
| `--sort-by-date` | `false` | Sort the entire sheet by date after sync |
|
||||||
|
|
||||||
|
**How it works**:
|
||||||
|
|
||||||
|
1. Reads existing Sync IDs (column K) from the Google Sheet
|
||||||
|
2. Fetches transactions from Fio bank (API or transparent page scraping)
|
||||||
|
3. For each transaction, generates a SHA-256 hash: `sha256(date|amount|currency|sender|vs|message|bank_id)`
|
||||||
|
4. Appends only transactions whose hash doesn't exist in the sheet
|
||||||
|
5. Optionally sorts the sheet by date
|
||||||
|
|
||||||
|
**Key functions**:
|
||||||
|
|
||||||
|
| Function | Signature | Description |
|
||||||
|
|----------|-----------|-------------|
|
||||||
|
| `get_sheets_service` | `(credentials_path: str) → Resource` | Authenticates with Google Sheets API. Supports both service accounts and OAuth2 flows. |
|
||||||
|
| `generate_sync_id` | `(tx: dict) → str` | Creates the SHA-256 deduplication hash for a transaction. |
|
||||||
|
| `sort_sheet_by_date` | `(service, spreadsheet_id)` | Sorts all rows (excluding header) by the Date column. |
|
||||||
|
| `sync_to_sheets` | `(spreadsheet_id, credentials_path, ...)` | Main sync logic — read existing, fetch new, deduplicate, append. |
|
||||||
|
|
||||||
|
**Output example**:
|
||||||
|
```
|
||||||
|
Connecting to Google Sheets using .secret/fuj-management-bot-credentials.json...
|
||||||
|
Reading existing sync IDs from sheet...
|
||||||
|
Fetching Fio transactions from 2026-02-01 to 2026-03-03...
|
||||||
|
Found 15 transactions.
|
||||||
|
Appending 3 new transactions to the sheet...
|
||||||
|
Sync completed successfully.
|
||||||
|
Sheet sorted by date.
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `infer_payments.py` — Auto-Fill Person/Purpose
|
||||||
|
|
||||||
|
Scans the Payments Google Sheet for rows with empty Person/Purpose columns and uses name matching and Czech month parsing to fill them automatically.
|
||||||
|
|
||||||
|
**Usage**:
|
||||||
|
```bash
|
||||||
|
make infer
|
||||||
|
|
||||||
|
# Dry run (preview without writing):
|
||||||
|
python scripts/infer_payments.py \
|
||||||
|
--credentials .secret/fuj-management-bot-credentials.json \
|
||||||
|
--dry-run
|
||||||
|
```
|
||||||
|
|
||||||
|
**Arguments**:
|
||||||
|
|
||||||
|
| Argument | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `--sheet-id` | Built-in ID | Target Google Sheet |
|
||||||
|
| `--credentials` | `credentials.json` | Path to Google API credentials |
|
||||||
|
| `--dry-run` | `false` | Print inferences without writing to the sheet |
|
||||||
|
|
||||||
|
**How it works**:
|
||||||
|
|
||||||
|
1. Reads all rows from the Payments Google Sheet
|
||||||
|
2. Fetches the member list from the Attendance Sheet
|
||||||
|
3. For each row where Person AND Purpose are empty AND there's no "manual fix":
|
||||||
|
- Combines sender name + message text
|
||||||
|
- Attempts to match against member names (using name variants and diacritics normalization)
|
||||||
|
- Parses Czech month references from the message
|
||||||
|
- Writes inferred Person, Purpose, and Amount back to the sheet
|
||||||
|
4. Low-confidence matches are prefixed with `[?]` for manual review
|
||||||
|
|
||||||
|
**Skipping rules**:
|
||||||
|
- If `manual fix` column has any value → skip
|
||||||
|
- If `Person` column already has a value → skip
|
||||||
|
- If `Purpose` column already has a value → skip
|
||||||
|
|
||||||
|
**Output example**:
|
||||||
|
```
|
||||||
|
Connecting to Google Sheets...
|
||||||
|
Reading sheet data...
|
||||||
|
Fetching member list for matching...
|
||||||
|
Inffering details for empty rows...
|
||||||
|
Row 45: Inferred Jan Novák for 2026-02 (750 CZK)
|
||||||
|
Row 46: Inferred [?] František Vrbík for 2026-01, 2026-02 (1500 CZK)
|
||||||
|
Applying 2 updates to the sheet...
|
||||||
|
Update completed successfully.
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `match_payments.py` — Reconciliation Engine + CLI Report
|
||||||
|
|
||||||
|
The core reconciliation engine. Matches payment transactions against expected fees and generates a detailed report. Also used as a library by `app.py` and `infer_payments.py`.
|
||||||
|
|
||||||
|
**Usage**:
|
||||||
|
```bash
|
||||||
|
make reconcile
|
||||||
|
|
||||||
|
# Direct invocation:
|
||||||
|
python scripts/match_payments.py \
|
||||||
|
--credentials .secret/fuj-management-bot-credentials.json \
|
||||||
|
--sheet-id YOUR_SHEET_ID
|
||||||
|
```
|
||||||
|
|
||||||
|
**Arguments**:
|
||||||
|
|
||||||
|
| Argument | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `--sheet-id` | Built-in ID | Payments Google Sheet |
|
||||||
|
| `--credentials` | `.secret/fuj-management-bot-credentials.json` | Google API credentials |
|
||||||
|
| `--bank` | `false` | Fetch directly from Fio bank instead of the Google Sheet |
|
||||||
|
|
||||||
|
**Key functions**:
|
||||||
|
|
||||||
|
| Function | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| `_build_name_variants(name)` | Generates searchable name variants from a member name. E.g., "František Vrbík (Štrúdl)" → `["frantisek vrbik", "strudl", "vrbik", "frantisek"]` |
|
||||||
|
| `match_members(text, member_names)` | Finds members mentioned in text. Returns `(name, confidence)` tuples where confidence is `auto` or `review`. |
|
||||||
|
| `infer_transaction_details(tx, member_names)` | Infers member(s) and month(s) for a single transaction. |
|
||||||
|
| `format_date(val)` | Normalizes dates from Google Sheets (handles serial numbers and strings). |
|
||||||
|
| `fetch_sheet_data(spreadsheet_id, credentials_path)` | Reads all rows from the Payments sheet as a list of dicts. |
|
||||||
|
| `fetch_exceptions(spreadsheet_id, credentials_path)` | Reads fee overrides from the `exceptions` sheet tab. |
|
||||||
|
| `reconcile(members, sorted_months, transactions, exceptions)` | **Core engine**: matches transactions to members/months, calculates balances. |
|
||||||
|
| `print_report(result, sorted_months)` | Prints the CLI reconciliation report. |
|
||||||
|
|
||||||
|
**Name matching strategy**:
|
||||||
|
|
||||||
|
The matching algorithm uses multiple tiers, in order of confidence:
|
||||||
|
|
||||||
|
| Priority | What it checks | Confidence |
|
||||||
|
|----------|---------------|-----------|
|
||||||
|
| 1 | Full name (normalized) found in text | `auto` |
|
||||||
|
| 2 | Both first and last name present (any order) | `auto` |
|
||||||
|
| 3 | Nickname from parentheses matches | `auto` |
|
||||||
|
| 4 | Last name only (≥4 chars, not in common surname list) | `review` |
|
||||||
|
| 5 | First name only (≥3 chars) | `review` |
|
||||||
|
|
||||||
|
**Common surnames excluded from last-name-only matching**: `novak`, `novakova`, `prach`
|
||||||
|
|
||||||
|
If any `auto`-confidence match exists, all `review` matches are discarded.
|
||||||
|
|
||||||
|
**Payment allocation**:
|
||||||
|
|
||||||
|
When a transaction matches multiple members and/or multiple months, the amount is split **evenly** across all allocations:
|
||||||
|
```
|
||||||
|
per_allocation = amount / (num_members × num_months)
|
||||||
|
```
|
||||||
|
|
||||||
|
**CLI report sections**:
|
||||||
|
|
||||||
|
1. **Summary table** — Per-member, per-month grid: `OK`, `UNPAID {amount}`, `{paid}/{expected}`, balance
|
||||||
|
2. **Credits** — Members with positive total balance
|
||||||
|
3. **Debts** — Members with negative total balance
|
||||||
|
4. **Unmatched transactions** — Payments that couldn't be assigned
|
||||||
|
5. **Matched transaction details** — Full breakdown with `[REVIEW]` flags
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `calculate_fees.py` — Fee Calculation
|
||||||
|
|
||||||
|
Calculates and prints monthly fees in a simple table format.
|
||||||
|
|
||||||
|
**Usage**:
|
||||||
|
```bash
|
||||||
|
make fees
|
||||||
|
```
|
||||||
|
|
||||||
|
**Output example**:
|
||||||
|
```
|
||||||
|
Member | Jan 2026 | Feb 2026
|
||||||
|
-------------------------------------------------------
|
||||||
|
Jan Novák | 750 CZK (4) | 200 CZK (1)
|
||||||
|
Alice Testová | - | 750 CZK (3)
|
||||||
|
-------------------------------------------------------
|
||||||
|
TOTAL | 750 CZK | 950 CZK
|
||||||
|
```
|
||||||
|
|
||||||
|
This is a simpler CLI version of the `/fees` web page. It only shows adults (tier A).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Shared Modules
|
||||||
|
|
||||||
|
### `attendance.py` — Attendance Data & Fee Logic
|
||||||
|
|
||||||
|
Shared module that fetches attendance data from the Google Sheet and computes fees.
|
||||||
|
|
||||||
|
**Constants**:
|
||||||
|
|
||||||
|
| Constant | Value | Description |
|
||||||
|
|----------|-------|-------------|
|
||||||
|
| `SHEET_ID` | `1E2e_gT_K5AwSRCDLDTa2UetZTkHmBOcz0kFbBUNUNBA` | Attendance Google Sheet ID |
|
||||||
|
| `FEE_FULL` | `750` | Monthly fee for 2+ practices |
|
||||||
|
| `FEE_SINGLE` | `200` | Monthly fee for exactly 1 practice |
|
||||||
|
| `COL_NAME` | `0` | Column index for member name |
|
||||||
|
| `COL_TIER` | `1` | Column index for member tier |
|
||||||
|
| `FIRST_DATE_COL` | `3` | First column with date headers |
|
||||||
|
|
||||||
|
**Functions**:
|
||||||
|
|
||||||
|
| Function | Signature | Description |
|
||||||
|
|----------|-----------|-------------|
|
||||||
|
| `fetch_csv` | `() → list[list[str]]` | Downloads the attendance sheet as CSV via its public export URL. No authentication needed. |
|
||||||
|
| `parse_dates` | `(header_row) → list[tuple[int, datetime]]` | Parses `M/D/YYYY` dates from the header row and returns `(column_index, date)` pairs. |
|
||||||
|
| `group_by_month` | `(dates) → dict[str, list[int]]` | Groups column indices by `YYYY-MM` month key. |
|
||||||
|
| `calculate_fee` | `(count: int) → int` | Applies fee rules: 0→0, 1→200, 2+→750 CZK. |
|
||||||
|
| `get_members` | `(rows) → list[tuple[str, str, list[str]]]` | Parses member rows. Stops at `# last line` sentinel. Skips comment rows (starting with `#`). |
|
||||||
|
| `get_members_with_fees` | `() → tuple[list, list[str]]` | Full pipeline: fetch → parse → compute. Returns `(members, sorted_months)` where each member is `(name, tier, {month: (fee, count)})`. |
|
||||||
|
|
||||||
|
**Member tier codes**:
|
||||||
|
|
||||||
|
| Tier | Meaning | Fees? |
|
||||||
|
|------|---------|-------|
|
||||||
|
| `A` | Adult | Yes (200 or 750 CZK) |
|
||||||
|
| `J` | Junior | No (separate sheet) |
|
||||||
|
| `X` | Exempt | No |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `fio_utils.py` — Fio Bank Integration
|
||||||
|
|
||||||
|
Handles fetching transactions from Fio bank, supporting both API and HTML scraping modes.
|
||||||
|
|
||||||
|
**Functions**:
|
||||||
|
|
||||||
|
| Function | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| `fetch_transactions(date_from, date_to)` | Main entry point. Uses API if `FIO_API_TOKEN` is set, falls back to transparent page scraping. |
|
||||||
|
| `fetch_transactions_api(token, date_from, date_to)` | Fetches via Fio REST API (JSON). Returns richer data including sender account and stable bank IDs. |
|
||||||
|
| `fetch_transactions_transparent(date_from, date_to, account_id)` | Scrapes the public Fio transparent account HTML page. |
|
||||||
|
| `parse_czech_amount(s)` | Parses Czech currency strings like `"1 500,00 CZK"` to float. |
|
||||||
|
| `parse_czech_date(s)` | Parses `DD.MM.YYYY` or `DD/MM/YYYY` to `YYYY-MM-DD`. |
|
||||||
|
|
||||||
|
**FioTableParser** — A custom `HTMLParser` subclass that extracts transaction rows from the second `<table class="table">` on the Fio transparent page. Column mapping:
|
||||||
|
|
||||||
|
| Index | Column |
|
||||||
|
|-------|--------|
|
||||||
|
| 0 | Date (Datum) |
|
||||||
|
| 1 | Amount (Částka) |
|
||||||
|
| 2 | Type (Typ) |
|
||||||
|
| 3 | Sender name (Název protiúčtu) |
|
||||||
|
| 4 | Message (Zpráva pro příjemce) |
|
||||||
|
| 5 | KS (constant symbol) |
|
||||||
|
| 6 | VS (variable symbol) |
|
||||||
|
| 7 | SS (specific symbol) |
|
||||||
|
| 8 | Note (Poznámka) |
|
||||||
|
|
||||||
|
**Transaction dict format** (returned by all fetch functions):
|
||||||
|
|
||||||
|
```python
|
||||||
|
{
|
||||||
|
"date": "2026-01-15", # YYYY-MM-DD
|
||||||
|
"amount": 750.0, # Float, always positive (outgoing filtered)
|
||||||
|
"sender": "Jan Novák", # Sender name
|
||||||
|
"message": "příspěvek", # Message for recipient
|
||||||
|
"vs": "12345", # Variable symbol
|
||||||
|
"ks": "", # Constant symbol
|
||||||
|
"ss": "", # Specific symbol
|
||||||
|
"bank_id": "abc123", # Bank operation ID (API only)
|
||||||
|
"user_id": "...", # User identification (API only)
|
||||||
|
"sender_account": "...", # Sender account number (API only)
|
||||||
|
"currency": "CZK" # Currency (API only)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `czech_utils.py` — Czech Language Utilities
|
||||||
|
|
||||||
|
Text processing utilities for Czech language content, critical for matching payment messages.
|
||||||
|
|
||||||
|
**`normalize(text: str) → str`**
|
||||||
|
|
||||||
|
Strips diacritics and lowercases text using Unicode NFKD normalization:
|
||||||
|
- `"Štrúdl"` → `"strudl"`
|
||||||
|
- `"František Vrbík"` → `"frantisek vrbik"`
|
||||||
|
- `"LEDEN 2026"` → `"leden 2026"`
|
||||||
|
|
||||||
|
**`parse_month_references(text: str, default_year=2026) → list[str]`**
|
||||||
|
|
||||||
|
Extracts YYYY-MM month references from Czech free text. Handles a remarkable variety of formats:
|
||||||
|
|
||||||
|
| Input | Output | Pattern |
|
||||||
|
|-------|--------|---------|
|
||||||
|
| `"leden"` | `["2026-01"]` | Czech month name |
|
||||||
|
| `"ledna"` | `["2026-01"]` | Czech month declension |
|
||||||
|
| `"01/26"` | `["2026-01"]` | Numeric short year |
|
||||||
|
| `"1/2026"` | `["2026-01"]` | Numeric full year |
|
||||||
|
| `"11+12/2025"` | `["2025-11", "2025-12"]` | Multiple slash-separated |
|
||||||
|
| `"12.2025"` | `["2025-12"]` | Dot notation |
|
||||||
|
| `"listopad-leden"` | `["2025-11", "2025-12", "2026-01"]` | Range with year wrap |
|
||||||
|
| `"říjen"` | `["2025-10"]` | Months ≥ October assumed previous year |
|
||||||
|
|
||||||
|
**`CZECH_MONTHS`** — Dictionary mapping all Czech month name forms (nominative, genitive, locative) to month numbers. 35 entries covering all 12 months in multiple declensions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Scripts reference generated from comprehensive code analysis on 2026-03-03.*
|
||||||
145
docs/by-claude-opus/testing.md
Normal file
145
docs/by-claude-opus/testing.md
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
# Testing Guide
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The project uses Python's built-in `unittest` framework with `unittest.mock` for mocking external dependencies (Google Sheets API, attendance data). Tests live in the `tests/` directory.
|
||||||
|
|
||||||
|
## Running Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make test # Run all tests
|
||||||
|
make test-v # Run with verbose output
|
||||||
|
```
|
||||||
|
|
||||||
|
Under the hood:
|
||||||
|
```bash
|
||||||
|
PYTHONPATH=scripts:. python3 -m unittest discover tests
|
||||||
|
```
|
||||||
|
|
||||||
|
The `PYTHONPATH` includes both `scripts/` and the project root so that test files can import from both `app.py` and `scripts/*.py`.
|
||||||
|
|
||||||
|
## Test Files
|
||||||
|
|
||||||
|
### `test_app.py` — Flask Route Tests
|
||||||
|
|
||||||
|
Tests the Flask web application routes using Flask's built-in test client. All external data fetching is mocked.
|
||||||
|
|
||||||
|
| Test | What it verifies |
|
||||||
|
|------|-----------------|
|
||||||
|
| `test_index_page` | `GET /` returns 200 and contains a redirect to `/fees` |
|
||||||
|
| `test_fees_route` | `GET /fees` renders the fees dashboard with correct member names |
|
||||||
|
| `test_reconcile_route` | `GET /reconcile` renders the reconciliation page with payment matching |
|
||||||
|
| `test_payments_route` | `GET /payments` renders the ledger with grouped transactions |
|
||||||
|
|
||||||
|
**Mocking strategy**:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@patch('app.get_members_with_fees')
|
||||||
|
def test_fees_route(self, mock_get_members):
|
||||||
|
mock_get_members.return_value = (
|
||||||
|
[('Test Member', 'A', {'2026-01': (750, 4)})],
|
||||||
|
['2026-01']
|
||||||
|
)
|
||||||
|
response = self.client.get('/fees')
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertIn(b'Test Member', response.data)
|
||||||
|
```
|
||||||
|
|
||||||
|
Each test patches the data-fetching functions (`get_members_with_fees`, `fetch_sheet_data`) to return controlled test data, avoiding any network calls.
|
||||||
|
|
||||||
|
**Notable**: The reconcile route test also mocks `fetch_sheet_data` and verifies that the reconciliation engine correctly matches a payment against an expected fee (checking for "OK" in the response).
|
||||||
|
|
||||||
|
### `test_reconcile_exceptions.py` — Reconciliation Logic Tests
|
||||||
|
|
||||||
|
Tests the `reconcile()` function directly (unit tests for the core business logic):
|
||||||
|
|
||||||
|
| Test | What it verifies |
|
||||||
|
|------|-----------------|
|
||||||
|
| `test_reconcile_applies_exceptions` | When a fee exception exists (400 CZK instead of 750), the expected amount is overridden and balance is calculated correctly |
|
||||||
|
| `test_reconcile_fallback_to_attendance` | When no exception exists, the attendance-based fee is used |
|
||||||
|
|
||||||
|
**Why these tests matter**: The exception system is critical for correctness — an incorrect override could cause members to be shown incorrect amounts owed. These tests verify that:
|
||||||
|
- Exceptions properly override the attendance-based fee
|
||||||
|
- The absence of an exception correctly falls back to the standard calculation
|
||||||
|
- Balances are computed correctly against overridden amounts
|
||||||
|
|
||||||
|
## Test Data Patterns
|
||||||
|
|
||||||
|
The tests use minimal but representative data:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# A member with attendance-based fee
|
||||||
|
members = [('Alice', 'A', {'2026-01': (750, 4)})]
|
||||||
|
|
||||||
|
# An exception reducing the fee
|
||||||
|
exceptions = {('alice', '2026-01'): {'amount': 400, 'note': 'Test exception'}}
|
||||||
|
|
||||||
|
# A matching payment
|
||||||
|
transactions = [{
|
||||||
|
'date': '2026-01-05',
|
||||||
|
'amount': 400,
|
||||||
|
'person': 'Alice',
|
||||||
|
'purpose': '2026-01',
|
||||||
|
'inferred_amount': 400,
|
||||||
|
'sender': 'Alice Sender',
|
||||||
|
'message': 'fee'
|
||||||
|
}]
|
||||||
|
```
|
||||||
|
|
||||||
|
## What's Not Tested
|
||||||
|
|
||||||
|
| Area | Status | Notes |
|
||||||
|
|------|--------|-------|
|
||||||
|
| Name matching logic | ❌ Not tested | `match_members()`, `_build_name_variants()` |
|
||||||
|
| Czech month parsing | ❌ Not tested | `parse_month_references()` |
|
||||||
|
| Fio bank data fetching | ❌ Not tested | Both API and HTML scraping |
|
||||||
|
| Sync deduplication | ❌ Not tested | `generate_sync_id()` |
|
||||||
|
| QR code generation | ❌ Not tested | `/qr` route |
|
||||||
|
| Payment inference | ❌ Not tested | `infer_payments.py` logic |
|
||||||
|
| Multi-person payment splitting | ❌ Not tested | Even split across members/months |
|
||||||
|
| Edge cases | ❌ Not tested | Empty sheets, malformed dates, etc. |
|
||||||
|
|
||||||
|
## Writing New Tests
|
||||||
|
|
||||||
|
### Adding a Flask route test
|
||||||
|
|
||||||
|
```python
|
||||||
|
# In tests/test_app.py
|
||||||
|
|
||||||
|
@patch('app.some_function')
|
||||||
|
def test_new_route(self, mock_fn):
|
||||||
|
mock_fn.return_value = expected_data
|
||||||
|
response = self.client.get('/new-route')
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertIn(b'expected content', response.data)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Adding a reconciliation logic test
|
||||||
|
|
||||||
|
```python
|
||||||
|
# In tests/test_reconcile_exceptions.py (or a new test file)
|
||||||
|
|
||||||
|
def test_multi_month_payment(self):
|
||||||
|
members = [('Bob', 'A', {
|
||||||
|
'2026-01': (750, 3),
|
||||||
|
'2026-02': (750, 4)
|
||||||
|
})]
|
||||||
|
transactions = [{
|
||||||
|
'date': '2026-02-01',
|
||||||
|
'amount': 1500,
|
||||||
|
'person': 'Bob',
|
||||||
|
'purpose': '2026-01, 2026-02',
|
||||||
|
'inferred_amount': 1500,
|
||||||
|
'sender': 'Bob',
|
||||||
|
'message': 'leden+unor'
|
||||||
|
}]
|
||||||
|
result = reconcile(members, ['2026-01', '2026-02'], transactions)
|
||||||
|
bob = result['members']['Bob']
|
||||||
|
self.assertEqual(bob['months']['2026-01']['paid'], 750)
|
||||||
|
self.assertEqual(bob['months']['2026-02']['paid'], 750)
|
||||||
|
self.assertEqual(bob['total_balance'], 0)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Testing documentation generated from comprehensive code analysis on 2026-03-03.*
|
||||||
166
docs/by-claude-opus/user-guide.md
Normal file
166
docs/by-claude-opus/user-guide.md
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
# User Guide — FUJ Web Dashboard
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
Start the dashboard with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make web
|
||||||
|
```
|
||||||
|
|
||||||
|
The dashboard is available at **http://localhost:5001** and provides three pages accessible via the green navigation bar at the top.
|
||||||
|
|
||||||
|
## Page 1: Attendance & Fees (`/fees`)
|
||||||
|
|
||||||
|
This page answers the question: **"How much does each member owe this month?"**
|
||||||
|
|
||||||
|
### What You See
|
||||||
|
|
||||||
|
A table with one row per adult member and one column per month. Each cell shows:
|
||||||
|
|
||||||
|
| Cell | Meaning |
|
||||||
|
|------|---------|
|
||||||
|
| `750 CZK (4)` | Member owes 750 CZK (attended 4 practices that month) |
|
||||||
|
| `200 CZK (1)` | Member owes 200 CZK (attended 1 practice) |
|
||||||
|
| `-` | Member didn't attend — no fee |
|
||||||
|
| `400 (750) CZK (3)` | Fee **overridden** from 750 to 400 CZK (shown in orange) |
|
||||||
|
|
||||||
|
The bottom row shows **monthly totals** — the total amount expected from all adult members.
|
||||||
|
|
||||||
|
### Fee Rules
|
||||||
|
|
||||||
|
| Practices in a month | Monthly fee |
|
||||||
|
|----------------------|-------------|
|
||||||
|
| 0 | 0 CZK (no charge) |
|
||||||
|
| 1 | 200 CZK |
|
||||||
|
| 2 or more | 750 CZK |
|
||||||
|
|
||||||
|
### Source Links
|
||||||
|
|
||||||
|
At the top, you'll find direct links to:
|
||||||
|
- **Attendance Sheet** — the Google Sheet with raw attendance data
|
||||||
|
- **Payments Ledger** — the Google Sheet with bank transactions
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Page 2: Payment Reconciliation (`/reconcile`)
|
||||||
|
|
||||||
|
This page answers: **"Who has paid, who hasn't, and who owes extra?"**
|
||||||
|
|
||||||
|
### Main Table
|
||||||
|
|
||||||
|
Each cell in the matrix shows the payment status for a member × month combination:
|
||||||
|
|
||||||
|
| Cell | Color | Meaning |
|
||||||
|
|------|-------|---------|
|
||||||
|
| `OK` | 🟢 Green | Fully paid |
|
||||||
|
| `UNPAID 750` | 🔴 Red | Haven't paid at all |
|
||||||
|
| `300/750` | 🔴 Red | Partially paid (300 out of 750) |
|
||||||
|
| `-` | Gray | No fee expected |
|
||||||
|
| `PAID 200` | — | Payment received but no fee expected |
|
||||||
|
|
||||||
|
The rightmost column shows each member's **total balance**:
|
||||||
|
- **Positive** (green): Member has overpaid / has credit
|
||||||
|
- **Negative** (red): Member still owes money
|
||||||
|
- **Zero**: Fully settled
|
||||||
|
|
||||||
|
### Search Filter
|
||||||
|
|
||||||
|
Type in the search box at the top to filter members by name. The search is **diacritic-insensitive** — typing "novak" will match "Novák".
|
||||||
|
|
||||||
|
### Member Details
|
||||||
|
|
||||||
|
Click the **`[i]`** icon next to any member's name to open a detailed popup:
|
||||||
|
|
||||||
|
1. **Status Summary** — Month-by-month breakdown with attendance count, expected fee, paid amount, and status. Overridden fees are marked with an amber asterisk.
|
||||||
|
|
||||||
|
2. **Fee Exceptions** — If any months have manual fee overrides, they're listed here with the override amount and reason.
|
||||||
|
|
||||||
|
3. **Payment History** — Every bank transaction matched to this member, showing the date, amount, sender, and payment message.
|
||||||
|
|
||||||
|
**Keyboard shortcuts** (when the popup is open):
|
||||||
|
- `↑` / `↓` — Navigate to the previous/next member
|
||||||
|
- `Escape` — Close the popup
|
||||||
|
|
||||||
|
### QR Code Payments
|
||||||
|
|
||||||
|
When you hover over an unpaid or partially paid cell, a red **"Pay"** button appears. Clicking it opens a QR code that can be scanned with any Czech banking app. The QR code is pre-filled with:
|
||||||
|
|
||||||
|
- The club's bank account number
|
||||||
|
- The exact amount owed
|
||||||
|
- A payment message identifying the member and month
|
||||||
|
|
||||||
|
This makes it trivial to send a payment link to a member who owes money.
|
||||||
|
|
||||||
|
### Summary Sections
|
||||||
|
|
||||||
|
Below the main table, three additional sections may appear:
|
||||||
|
|
||||||
|
| Section | Shows |
|
||||||
|
|---------|-------|
|
||||||
|
| **Credits** | Members with positive balances (advance payments or overpayments) |
|
||||||
|
| **Debts** | Members with negative balances (outstanding fees) |
|
||||||
|
| **Unmatched Transactions** | Bank transactions that couldn't be automatically matched to any member |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Page 3: Payments Ledger (`/payments`)
|
||||||
|
|
||||||
|
This page answers: **"What payments has each member made?"**
|
||||||
|
|
||||||
|
### What You See
|
||||||
|
|
||||||
|
Transactions grouped by member name, each showing:
|
||||||
|
- **Date** — When the payment was received
|
||||||
|
- **Amount** — How much was paid (in CZK)
|
||||||
|
- **Purpose** — Which month(s) the payment covers
|
||||||
|
- **Bank Message** — The original message from the bank transfer
|
||||||
|
|
||||||
|
Transactions are sorted newest-first within each member's section.
|
||||||
|
|
||||||
|
### Unmatched Payments
|
||||||
|
|
||||||
|
Transactions that couldn't be assigned to a member appear under **"Unmatched / Unknown"** — these typically need manual review in the Google Sheet.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Performance Footer
|
||||||
|
|
||||||
|
Every page shows a **render time** in the bottom-right corner (very small, gray text). This tells you how long the page took to generate.
|
||||||
|
|
||||||
|
Click on it to reveal a detailed breakdown showing how much time was spent on each step (fetching members, fetching payments, reconciliation, template rendering, etc.). This is mostly useful for debugging slow page loads.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Common Workflows
|
||||||
|
|
||||||
|
### "A member asks how much they owe"
|
||||||
|
|
||||||
|
1. Open `/reconcile`
|
||||||
|
2. Search for the member's name
|
||||||
|
3. Their row shows the exact status per month
|
||||||
|
4. Click `[i]` for detailed payment history
|
||||||
|
|
||||||
|
### "A member wants to pay"
|
||||||
|
|
||||||
|
1. Open `/reconcile`
|
||||||
|
2. Find the unpaid cell
|
||||||
|
3. Hover and click the red **Pay** button
|
||||||
|
4. Share the QR code with the member (screenshot or show on screen)
|
||||||
|
|
||||||
|
### "I want to see all payments from one person"
|
||||||
|
|
||||||
|
1. Open `/payments`
|
||||||
|
2. Scroll to the member's section (alphabetically sorted)
|
||||||
|
|
||||||
|
### "A transaction wasn't matched correctly"
|
||||||
|
|
||||||
|
1. Open the **Payments Ledger** Google Sheet (link at the top of any page)
|
||||||
|
2. Find the row
|
||||||
|
3. Manually correct the **Person** and/or **Purpose** columns
|
||||||
|
4. Put any marker in the **manual fix** column to prevent the inference script from overwriting your edit
|
||||||
|
5. Refresh the web dashboard
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*User guide generated from comprehensive code analysis on 2026-03-03.*
|
||||||
256
docs/by-claude-opus/web-app.md
Normal file
256
docs/by-claude-opus/web-app.md
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
# Web Application Documentation
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The FUJ Management web application is a Flask-based dashboard that provides real-time visibility into club finances. It renders server-side HTML with embedded CSS and JavaScript — no build tools, no npm, no framework. The UI follows a distinctive **terminal-inspired aesthetic** with monospace fonts, green-on-black colors, and dashed borders.
|
||||||
|
|
||||||
|
## Routes
|
||||||
|
|
||||||
|
### `GET /` — Index (Redirect)
|
||||||
|
|
||||||
|
Redirects to `/fees` via an HTML meta refresh tag. This exists so the root URL always leads somewhere useful.
|
||||||
|
|
||||||
|
### `GET /fees` — Attendance & Fees Dashboard
|
||||||
|
|
||||||
|
**Template**: `templates/fees.html`
|
||||||
|
|
||||||
|
Displays a table of all adult members with their calculated monthly fees based on attendance. Each cell shows the fee amount (in CZK), the number of practices attended, or a dash for months with zero attendance.
|
||||||
|
|
||||||
|
**Data pipeline**:
|
||||||
|
```
|
||||||
|
attendance.py::get_members_with_fees() → Filter to tier "A" (adults)
|
||||||
|
match_payments.py::fetch_exceptions() → Check for fee overrides
|
||||||
|
→ Format cells with override indicators
|
||||||
|
→ Render fees.html with totals row
|
||||||
|
```
|
||||||
|
|
||||||
|
**Visual features**:
|
||||||
|
- Fee overrides shown in **orange** with the original amount in parentheses
|
||||||
|
- Empty months shown in muted gray
|
||||||
|
- Monthly totals row at the bottom
|
||||||
|
- Performance timing in the footer (click to expand breakdown)
|
||||||
|
|
||||||
|
**Template variables**:
|
||||||
|
|
||||||
|
| Variable | Type | Content |
|
||||||
|
|----------|------|---------|
|
||||||
|
| `months` | `list[str]` | Month labels like "Jan 2026" |
|
||||||
|
| `results` | `list[dict]` | `{name, months: [{cell, overridden}]}` |
|
||||||
|
| `totals` | `list[str]` | Monthly total strings like "3750 CZK" |
|
||||||
|
| `attendance_url` | `str` | Link to the attendance Google Sheet |
|
||||||
|
| `payments_url` | `str` | Link to the payments Google Sheet |
|
||||||
|
|
||||||
|
### `GET /reconcile` — Payment Reconciliation
|
||||||
|
|
||||||
|
**Template**: `templates/reconcile.html` (802 lines — the most complex template)
|
||||||
|
|
||||||
|
The centerpiece of the application. Shows a matrix of members × months with payment status, plus summary sections for credits, debts, and unmatched transactions.
|
||||||
|
|
||||||
|
**Data pipeline**:
|
||||||
|
```
|
||||||
|
attendance.py::get_members_with_fees() → All members + fees
|
||||||
|
match_payments.py::fetch_sheet_data() → All payment transactions
|
||||||
|
match_payments.py::fetch_exceptions() → Fee overrides
|
||||||
|
match_payments.py::reconcile() → Match payments ↔ fees
|
||||||
|
→ Render reconcile.html
|
||||||
|
```
|
||||||
|
|
||||||
|
**Cell statuses**:
|
||||||
|
|
||||||
|
| Status | CSS Class | Display | Meaning |
|
||||||
|
|--------|-----------|---------|---------|
|
||||||
|
| `empty` | `cell-empty` | `-` | No fee expected, no payment |
|
||||||
|
| `ok` | `cell-ok` | `OK` | Paid in full (green) |
|
||||||
|
| `partial` | `cell-unpaid` | `300/750` | Partially paid (red) |
|
||||||
|
| `unpaid` | `cell-unpaid` | `UNPAID 750` | Nothing paid (red) |
|
||||||
|
| `surplus` | — | `PAID 200` | Payment received but no fee expected |
|
||||||
|
|
||||||
|
**Interactive features**:
|
||||||
|
|
||||||
|
1. **Member detail modal** — Click the `[i]` icon next to any member name to see:
|
||||||
|
- Status summary table (month, attendance count, expected, paid, status)
|
||||||
|
- Fee exceptions (if any, shown in amber)
|
||||||
|
- Full payment history with dates, amounts, senders, and messages
|
||||||
|
|
||||||
|
2. **Keyboard navigation** — When a member modal is open:
|
||||||
|
- `↑` / `↓` arrows navigate between members (respecting search filter)
|
||||||
|
- `Escape` closes the modal
|
||||||
|
|
||||||
|
3. **Name search filter** — Type in the search box to filter members. Uses diacritic-insensitive matching (e.g., typing "novak" matches "Novák").
|
||||||
|
|
||||||
|
4. **QR Payment** — Hover over an unpaid/partial cell to reveal a "Pay" button. Clicking it opens a QR code modal with:
|
||||||
|
- A Czech SPD-format QR code (scannable by Czech banking apps)
|
||||||
|
- Pre-filled account number, amount, and payment message
|
||||||
|
- The QR image is generated server-side via `GET /qr`
|
||||||
|
|
||||||
|
**Client-side data**:
|
||||||
|
|
||||||
|
The template receives a full JSON dump of member data (`member_data`) embedded in a `<script>` tag. This powers the modal without additional API calls:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const memberData = {{ member_data | safe }};
|
||||||
|
const sortedMonths = {{ raw_months | tojson }};
|
||||||
|
```
|
||||||
|
|
||||||
|
**Summary sections** (rendered below the main table):
|
||||||
|
|
||||||
|
| Section | Shown when | Content |
|
||||||
|
|---------|-----------|---------|
|
||||||
|
| Credits | Any member has positive balance | Names with surplus amounts |
|
||||||
|
| Debts | Any member has negative balance | Names with outstanding amounts (red) |
|
||||||
|
| Unmatched Transactions | Any transaction couldn't be matched | Date, amount, sender, message |
|
||||||
|
|
||||||
|
### `GET /payments` — Payments Ledger
|
||||||
|
|
||||||
|
**Template**: `templates/payments.html`
|
||||||
|
|
||||||
|
Displays all bank transactions grouped by member name. Each member section shows their transactions in reverse chronological order.
|
||||||
|
|
||||||
|
**Data pipeline**:
|
||||||
|
```
|
||||||
|
match_payments.py::fetch_sheet_data() → All transactions
|
||||||
|
→ Group by Person column
|
||||||
|
→ Strip [?] markers
|
||||||
|
→ Handle comma-separated people
|
||||||
|
→ Sort by date descending
|
||||||
|
→ Render payments.html
|
||||||
|
```
|
||||||
|
|
||||||
|
**Multi-person handling**: If a transaction's "Person" field contains comma-separated names (e.g., "Alice, Bob"), the transaction appears under both Alice's and Bob's sections.
|
||||||
|
|
||||||
|
### `GET /qr` — QR Code Generator
|
||||||
|
|
||||||
|
Returns a PNG image containing a Czech SPD (Short Payment Descriptor) QR code.
|
||||||
|
|
||||||
|
**Query parameters**:
|
||||||
|
|
||||||
|
| Parameter | Default | Description |
|
||||||
|
|-----------|---------|-------------|
|
||||||
|
| `account` | `BANK_ACCOUNT` env var | IBAN or Czech account number |
|
||||||
|
| `amount` | `0` | Payment amount |
|
||||||
|
| `message` | *(empty)* | Payment message (max 60 chars) |
|
||||||
|
|
||||||
|
**SPD format**: `SPD*1.0*ACC:{account}*AM:{amount}*CC:CZK*MSG:{message}`
|
||||||
|
|
||||||
|
This format is recognized by all Czech banking apps and generates a pre-filled payment order when scanned.
|
||||||
|
|
||||||
|
## UI Design System
|
||||||
|
|
||||||
|
### Color Palette
|
||||||
|
|
||||||
|
| Element | Color | Hex |
|
||||||
|
|---------|-------|-----|
|
||||||
|
| Background | Near-black | `#0c0c0c` |
|
||||||
|
| Base text | Medium gray | `#cccccc` |
|
||||||
|
| Headings, accents, "OK" | Terminal green | `#00ff00` |
|
||||||
|
| Unpaid, debts | Alert red | `#ff3333` |
|
||||||
|
| Fee overrides | Amber/orange | `#ffa500` / `#ffaa00` |
|
||||||
|
| Empty/muted | Dark gray | `#444444` |
|
||||||
|
| Borders | Subtle gray | `#333` (dashed), `#555` (solid) |
|
||||||
|
|
||||||
|
### Typography
|
||||||
|
|
||||||
|
All text uses the system monospace font stack:
|
||||||
|
```css
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
|
||||||
|
"Liberation Mono", "Courier New", monospace;
|
||||||
|
```
|
||||||
|
|
||||||
|
Base font size is 11px with 1.2 line-height — intentionally dense for a data-heavy dashboard.
|
||||||
|
|
||||||
|
### Navigation
|
||||||
|
|
||||||
|
A persistent nav bar appears at the top of every page:
|
||||||
|
```
|
||||||
|
[Attendance/Fees] [Payment Reconciliation] [Payments Ledger]
|
||||||
|
```
|
||||||
|
The active page's link is highlighted with inverted colors (black text on green background).
|
||||||
|
|
||||||
|
### Shared Footer
|
||||||
|
|
||||||
|
Every page includes a click-to-expand performance timer showing total render time and a per-step breakdown.
|
||||||
|
|
||||||
|
## Flask Application Architecture
|
||||||
|
|
||||||
|
### Request Lifecycle
|
||||||
|
|
||||||
|
```
|
||||||
|
Request → @app.before_request (start timer) → Route handler → Template → Response
|
||||||
|
│ │
|
||||||
|
▼ ▼
|
||||||
|
g.start_time record_step("fetch_members")
|
||||||
|
g.steps = [] record_step("fetch_payments")
|
||||||
|
record_step("process_data")
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
@app.context_processor
|
||||||
|
inject_render_time()
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
{{ get_render_time() }}
|
||||||
|
in template footer
|
||||||
|
```
|
||||||
|
|
||||||
|
### Module Loading
|
||||||
|
|
||||||
|
The Flask app adds the `scripts/` directory to `sys.path` at startup, allowing direct imports from scripts:
|
||||||
|
|
||||||
|
```python
|
||||||
|
scripts_dir = Path(__file__).parent / "scripts"
|
||||||
|
sys.path.append(str(scripts_dir))
|
||||||
|
|
||||||
|
from attendance import get_members_with_fees, SHEET_ID
|
||||||
|
from match_payments import reconcile, fetch_sheet_data, fetch_exceptions, normalize
|
||||||
|
```
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
| Variable | Default | Purpose |
|
||||||
|
|----------|---------|---------|
|
||||||
|
| `BANK_ACCOUNT` | `CZ8520100000002800359168` | Bank account for QR code generation |
|
||||||
|
| `FIO_API_TOKEN` | *(none)* | Fio API token (used by `fio_utils.py`) |
|
||||||
|
|
||||||
|
### Error Handling
|
||||||
|
|
||||||
|
The application has minimal error handling:
|
||||||
|
- If Google Sheets returns no data, routes return a simple "No data." text response
|
||||||
|
- No custom error pages for 404/500
|
||||||
|
- Exceptions propagate to Flask's default error handler (debug mode in development, 500 in production)
|
||||||
|
|
||||||
|
## Template Architecture
|
||||||
|
|
||||||
|
All three page templates share a common structure:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>FUJ [Page Name]</title>
|
||||||
|
<style>
|
||||||
|
/* ALL CSS is inline — no external stylesheets */
|
||||||
|
/* ~150-400 lines of CSS per template */
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="nav"><!-- 3-link navigation --></div>
|
||||||
|
<h1>Page Title</h1>
|
||||||
|
<div class="description"><!-- Source links --></div>
|
||||||
|
|
||||||
|
<!-- Page-specific content -->
|
||||||
|
|
||||||
|
<div class="footer"><!-- Render time --></div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
/* Page-specific JavaScript (only in reconcile.html) */
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
```
|
||||||
|
|
||||||
|
There is no shared base template (no Jinja2 template inheritance). CSS is duplicated across templates with small variations.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Web application documentation generated from comprehensive code analysis on 2026-03-03.*
|
||||||
36
docs/by-gemini/README.md
Normal file
36
docs/by-gemini/README.md
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
# FUJ Management System
|
||||||
|
|
||||||
|
Welcome to the **FUJ Management System**, a streamlined solution for managing Ultimate Frisbee club finances, attendance, and member payments. This system automates the tedious parts of club management, keeping your ledger clean and your reconciliation painless.
|
||||||
|
|
||||||
|
## 🚀 Mission
|
||||||
|
|
||||||
|
The project's goal is to minimize manual entry and potential human error in club management by:
|
||||||
|
1. **Automating Bank Synchronization**: Periodically fetching transactions from Fio bank.
|
||||||
|
2. **Smart Inference**: Using heuristics to match bank transactions to members and payment periods.
|
||||||
|
3. **Visual Reconciliation**: Providing a clear, real-time web dashboard for managers to track who has paid and who is in debt.
|
||||||
|
|
||||||
|
## ✨ Key Features
|
||||||
|
|
||||||
|
- **Seamless Bank Integration**: Synchronize transactions directly from the Fio bank API into a Google Spreadsheet.
|
||||||
|
- **Intelligent Matching**: Automatic detection of member names and payment periods from transaction messages using diacritic-insensitive Czech text processing.
|
||||||
|
- **Dynamic Dashboard**: A Flask-powered web interface displaying monthly fees, payment status (OK, Partial, Unpaid), and total balances.
|
||||||
|
- **Manual Overrides**: Support for fee exceptions and manual payment matching when automation needs a human touch.
|
||||||
|
- **QR Payment Generation**: Integrated QR code generation to make paying outstanding fees trivial for members.
|
||||||
|
|
||||||
|
## 🛠 Tech Stack
|
||||||
|
|
||||||
|
- **Backend**: Python 3.12+ (managed with `uv`)
|
||||||
|
- **Web Framework**: Flask with Jinja2 templates
|
||||||
|
- **Data Storage**: Google Sheets (used as a collaborative database)
|
||||||
|
- **APIs**: Fio Bank API, Google Sheets API v4
|
||||||
|
- **Containerization**: Docker / OCI Images
|
||||||
|
- **Automation**: `Makefile` based workflow
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📂 Documentation Guide
|
||||||
|
|
||||||
|
- [Architecture](architecture.md): High-level system design and data flow.
|
||||||
|
- [User Guide](user-guide.md): How to operate the system as a club manager.
|
||||||
|
- [Support Scripts](scripts.md): Detailed reference for CLI tools.
|
||||||
|
- [Deployment](deployment.md): Technical setup and infrastructure instructions.
|
||||||
48
docs/by-gemini/architecture.md
Normal file
48
docs/by-gemini/architecture.md
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
# System Architecture
|
||||||
|
|
||||||
|
The FUJ Management system is designed around a **"Sheet-as-a-Database"** architecture. This allows for easy manual editing and transparency while enabling powerful automation.
|
||||||
|
|
||||||
|
## 🔄 High-Level Data Flow
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TD
|
||||||
|
Fio[Fio Bank API] -->|Sync Script| GS(Google Spreadsheet)
|
||||||
|
Att[Attendance Sheet] -->|CSV Export| App(Flask Web App)
|
||||||
|
GS -->|API Fetch| App
|
||||||
|
App -->|Display| UI[Manager Dashboard]
|
||||||
|
GS -.->|Manual Edits| GS
|
||||||
|
App -->|Generate| QR[QR Codes for Members]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1. Data Ingestion (Bank to Sheet)
|
||||||
|
The synchronization pipeline moves raw bank data into a structured format:
|
||||||
|
- `sync_fio_to_sheets.py` fetches transactions and appends them to the "Transactions" sheet.
|
||||||
|
- Each transaction is assigned a unique `Sync ID` to prevent duplicates.
|
||||||
|
- `infer_payments.py` processes new rows, attempting to fill the `Person`, `Purpose`, and `Inferred Amount` columns based on the message and sender.
|
||||||
|
|
||||||
|
### 2. Logic & Reconciliation
|
||||||
|
The core logic resides in shared Python scripts:
|
||||||
|
- **Attendance**: `attendance.py` pulls the latest practice data from a separate attendance sheet and calculates expected fees (e.g., 0/200/750 CZK rules).
|
||||||
|
- **Matching**: `match_payments.py` performs the "heavy lifting" by correlating members, months, and payments. It handles partial payments, overpayments (credits), and manual exceptions.
|
||||||
|
|
||||||
|
### 3. Presentation Layer
|
||||||
|
The Flask application (`app.py`) serves as the primary interface:
|
||||||
|
- **Fees View**: Shows attendance-based charges.
|
||||||
|
- **Reconciliation View**: The main "truth" dashboard showing balance per member.
|
||||||
|
- **Payments View**: Historical list of transactions grouped by member.
|
||||||
|
|
||||||
|
## 🛡 Security & Authentication
|
||||||
|
|
||||||
|
- **Fio Bank**: Authorized via a private API token (kept in `.secret/`).
|
||||||
|
- **Google Sheets**: Authenticated via a **Service Account** or **OAuth2** (using `.secret/fuj-management-bot-credentials.json`).
|
||||||
|
- **Environment**: Secrets are never committed; the `.secret/` directory is git-ignored.
|
||||||
|
|
||||||
|
## 🧩 Key Components
|
||||||
|
|
||||||
|
| Component | Responsibility |
|
||||||
|
| :--- | :--- |
|
||||||
|
| **Google Spreadsheet** | Unified source of truth for transactions and manual overrides. |
|
||||||
|
| **scripts/** | A suite of CLI utilities for batch processing and data maintenance. |
|
||||||
|
| **Flask App** | Read-only views for state visualization and QR code generation. |
|
||||||
|
| **czech_utils.py** | Diacritic-normalization and NLP for Czech month/name parsing. |
|
||||||
|
```
|
||||||
72
docs/by-gemini/deployment.md
Normal file
72
docs/by-gemini/deployment.md
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
# Deployment & Technical Setup
|
||||||
|
|
||||||
|
This document provides instructions for developers and devops engineers to set up and deploy the FUJ Management system.
|
||||||
|
|
||||||
|
## 🛠 Prerequisites
|
||||||
|
|
||||||
|
- **Python 3.12+**: The project uses modern type hinting and syntax features.
|
||||||
|
- **uv**: High-performance Python package installer and resolver.
|
||||||
|
- Install via brew: `brew install uv`
|
||||||
|
- **Docker** (Optional): For containerized deployments.
|
||||||
|
|
||||||
|
## ⚙️ Initial Setup
|
||||||
|
|
||||||
|
1. **Clone the repository**:
|
||||||
|
```bash
|
||||||
|
git clone <repo-url>
|
||||||
|
cd fuj-management
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Install dependencies**:
|
||||||
|
Using `uv`, everything is handled automatically:
|
||||||
|
```bash
|
||||||
|
make venv
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Secrets Management**:
|
||||||
|
Create a `.secret/` directory. You will need two main credentials:
|
||||||
|
- `fuj-management-bot-credentials.json`: A Google Cloud Service Account key with access to the Sheets API.
|
||||||
|
- `fio-token.txt`: (Implicitly used by `fio_utils.py`) Your Fio bank API token.
|
||||||
|
|
||||||
|
Ensure these are never committed! They are ignored by `.gitignore`.
|
||||||
|
|
||||||
|
## 🐳 Containerization
|
||||||
|
|
||||||
|
The project can be built and run as an OCI image.
|
||||||
|
|
||||||
|
1. **Build the image**:
|
||||||
|
```bash
|
||||||
|
make image
|
||||||
|
```
|
||||||
|
This uses the `build/Dockerfile`, which is optimized for small size and security.
|
||||||
|
|
||||||
|
2. **Run the container**:
|
||||||
|
```bash
|
||||||
|
make run
|
||||||
|
```
|
||||||
|
The app exposes port `5001`.
|
||||||
|
|
||||||
|
## 🧪 Testing & Validation
|
||||||
|
|
||||||
|
The project includes a suite of infrastructure and logic tests.
|
||||||
|
|
||||||
|
- **Run all tests**:
|
||||||
|
```bash
|
||||||
|
make test
|
||||||
|
```
|
||||||
|
- **Verbose output**:
|
||||||
|
```bash
|
||||||
|
make test-v
|
||||||
|
```
|
||||||
|
|
||||||
|
Tests are located in the `tests/` directory and use the standard Python `unittest` framework. They cover:
|
||||||
|
- CSV parsing logic.
|
||||||
|
- Fee calculation rules.
|
||||||
|
- Name matching and normalization.
|
||||||
|
|
||||||
|
## 🚀 Future Roadmap
|
||||||
|
|
||||||
|
- **Automated Backups**: Regular snapshots of the Google Sheet.
|
||||||
|
- **Authentication Layer**: Login for the web dashboard (currently assumes internal VPN or trusted environment).
|
||||||
|
- **Gitea Actions**: Continuous Integration for building and testing images.
|
||||||
|
```
|
||||||
66
docs/by-gemini/scripts.md
Normal file
66
docs/by-gemini/scripts.md
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
# Support Scripts Reference
|
||||||
|
|
||||||
|
The project includes several CLI utilities located in the `scripts/` directory. Most are accessible via `make` targets.
|
||||||
|
|
||||||
|
## 🚀 Primary Scripts
|
||||||
|
|
||||||
|
### `sync_fio_to_sheets.py`
|
||||||
|
**Target**: `make sync` | `make sync-2026`
|
||||||
|
- **Purpose**: Downloads transactions from Fio bank via API and appends new ones to the Google Sheet.
|
||||||
|
- **Key Logic**: Uses a `Sync ID` (SHA-256 hash of transaction details) to ensure that even if the sync is run multiple times, no duplicate rows are created.
|
||||||
|
- **Arguments**:
|
||||||
|
- `--days`: How many days back to look (default 30).
|
||||||
|
- `--from/--to`: Specific date range.
|
||||||
|
- `--sort-by-date`: Re-sorts the spreadsheet after appending.
|
||||||
|
|
||||||
|
### `infer_payments.py`
|
||||||
|
**Target**: `make infer`
|
||||||
|
- **Purpose**: Processes the "Transactions" sheet to fill in `Person`, `Purpose`, and `Inferred Amount`.
|
||||||
|
- **Logic**:
|
||||||
|
- Analyzes the `Sender` and `Message` fields.
|
||||||
|
- Uses `match_payments.py` heuristics to find members.
|
||||||
|
- If confidence is low, prefixes the name with `[?]` to flag it for manual review.
|
||||||
|
- Won't overwrite cells that already have data (respecting your manual fixes).
|
||||||
|
|
||||||
|
### `match_payments.py`
|
||||||
|
**Target**: `make match` | `make reconcile`
|
||||||
|
- **Purpose**: The core "Reconciliation Engine".
|
||||||
|
- **Logic**:
|
||||||
|
- Fetches attendance fees (from `attendance.py`).
|
||||||
|
- Fetches transaction data.
|
||||||
|
- Correlates them based on inferred `Person` and `Purpose`.
|
||||||
|
- Handles "rollover" balances—extra money from one month is tracked as a credit for the next.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛠 Utility Modules
|
||||||
|
|
||||||
|
### `attendance.py`
|
||||||
|
- Handles the connection to the Google Attendance sheet (exported as CSV).
|
||||||
|
- Implements the club's fee rules:
|
||||||
|
- 0 practices = 0 CZK
|
||||||
|
- 1 practice = 200 CZK
|
||||||
|
- 2+ practices = 750 CZK
|
||||||
|
- *Note*: Fee calculation only applies to members in Tier "A" (Adults).
|
||||||
|
|
||||||
|
### `czech_utils.py`
|
||||||
|
- **Normalization**: Strips diacritics and lowercases text (e.g., `František` -> `frantisek`).
|
||||||
|
- **Month Parsing**: Advanced regex to detect month references in Czech (e.g., "leden-brezen", "11+12/25", "na únor").
|
||||||
|
|
||||||
|
### `fio_utils.py`
|
||||||
|
- Low-level wrapper for the Fio Bank API.
|
||||||
|
- Handles HTTP requests and JSON parsing for transaction lists.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚙️ Makefile Summary
|
||||||
|
|
||||||
|
| Command | Action |
|
||||||
|
| :--- | :--- |
|
||||||
|
| `make fees` | Preview calculated fees based on attendance. |
|
||||||
|
| `make sync` | Sync last 30 days of bank data. |
|
||||||
|
| `make infer` | Run smart tagging on the sheet. |
|
||||||
|
| `make reconcile` | Run a text-based reconciliation report in terminal. |
|
||||||
|
| `make web` | Start the Flask dashboard. |
|
||||||
|
| `make test` | Run the test suite. |
|
||||||
|
```
|
||||||
61
docs/by-gemini/user-guide.md
Normal file
61
docs/by-gemini/user-guide.md
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
# User Guide
|
||||||
|
|
||||||
|
This guide is intended for club managers who use the FUJ Management system for day-to-day operations.
|
||||||
|
|
||||||
|
## 🛠 Operational Workflow
|
||||||
|
|
||||||
|
To keep the club finances up-to-date, follow these steps periodically (e.g., once a week):
|
||||||
|
|
||||||
|
1. **Sync Bank Transactions**:
|
||||||
|
Run the sync script to pull the latest payments from Fio.
|
||||||
|
```bash
|
||||||
|
make sync
|
||||||
|
```
|
||||||
|
2. **Infer Payments**:
|
||||||
|
Let the system automatically tag who paid for what.
|
||||||
|
```bash
|
||||||
|
make infer
|
||||||
|
```
|
||||||
|
3. **Manual Review (Google Sheets)**:
|
||||||
|
Open the Google Spreadsheet. Check rows with the `[?]` prefix in the `Person` column—these require human confirmation.
|
||||||
|
- If correct: Remove the `[?]` prefix.
|
||||||
|
- If incorrect: Manually fix the `Person` and `Purpose`.
|
||||||
|
- If a payment covers a special case: Use the **exceptions** sheet to override expected fees.
|
||||||
|
4. **Check Reconciliation Dashboard**:
|
||||||
|
Start the web app to see the final balance report.
|
||||||
|
```bash
|
||||||
|
make web
|
||||||
|
```
|
||||||
|
Navigate to `http://localhost:5001/reconcile`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 Understanding the Dashboard
|
||||||
|
|
||||||
|
### Reconciliation Page
|
||||||
|
- **Green (OK)**: Member has paid exactly what was expected (or more).
|
||||||
|
- **Orange (Partial)**: Some payment was received, but there's still a debt.
|
||||||
|
- **Red (UNPAID)**: No payment recorded for this month.
|
||||||
|
- **Blue (SURPLUS)**: Payment received for a month where no fee was expected.
|
||||||
|
|
||||||
|
### Handling Debts
|
||||||
|
If a member is in debt, you can click on the unpaid/partial cell to get a **QR Platba** link. You can send this link or screenshot to the member to facilitate quick payment.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ❓ FAQ & Troubleshooting
|
||||||
|
|
||||||
|
### Why is a payment "Unmatched"?
|
||||||
|
A payment stays unmatched if neither the sender name nor the message contains recognizable member names or nicknames.
|
||||||
|
- **Fix**: Manually enter the member's name in the `Person` column in the Google Sheet.
|
||||||
|
|
||||||
|
### How do I handle a "Family Discount" or "Prepaid Year"?
|
||||||
|
Use the `exceptions` sheet in the Google Spreadsheet.
|
||||||
|
1. Add the member's name (exactly as it appears in attendance).
|
||||||
|
2. Enter the month (e.g., `2026-03`).
|
||||||
|
3. Enter the new `Amount` (use `0` for prepaid).
|
||||||
|
4. Add a `Note` for clarity.
|
||||||
|
|
||||||
|
### The web app is slow to load.
|
||||||
|
The app fetches data from Google Sheets API on every request. This ensures real-time data but can take a few seconds. The "Performance Breakdown" footer shows exactly where the time was spent.
|
||||||
|
```
|
||||||
43
docs/index.html
Normal file
43
docs/index.html
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>FUJ Management - Documentation</title>
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1" />
|
||||||
|
<meta name="description" content="Documentation for FUJ Management Application">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0">
|
||||||
|
<link rel="stylesheet" href="//cdn.jsdelivr.net/npm/docsify@4/lib/themes/vue.css">
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--theme-color: #42b983;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div id="app">Loading documentation...</div>
|
||||||
|
<script>
|
||||||
|
window.$docsify = {
|
||||||
|
name: 'FUJ Management',
|
||||||
|
repo: '',
|
||||||
|
loadSidebar: true,
|
||||||
|
subMaxLevel: 2,
|
||||||
|
search: 'auto',
|
||||||
|
auto2top: true,
|
||||||
|
alias: {
|
||||||
|
'/.*/_sidebar.md': '/_sidebar.md'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<!-- Docsify v4 -->
|
||||||
|
<script src="//cdn.jsdelivr.net/npm/docsify@4"></script>
|
||||||
|
<script src="//cdn.jsdelivr.net/npm/docsify/lib/plugins/search.min.js"></script>
|
||||||
|
<script src="//cdn.jsdelivr.net/npm/docsify-copy-code"></script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
52
docs/operation-manual.md
Normal file
52
docs/operation-manual.md
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
# Operation Manual
|
||||||
|
|
||||||
|
## Adding a Monthly Fee Override
|
||||||
|
|
||||||
|
Use this when the club decides to charge a different flat fee for a specific month — for example, a reduced fee during a short or holiday month.
|
||||||
|
|
||||||
|
There are two independent dictionaries in [scripts/attendance.py](../scripts/attendance.py), one for adults and one for juniors. Edit whichever tiers need an override.
|
||||||
|
|
||||||
|
### Adults
|
||||||
|
|
||||||
|
Add an entry to `ADULT_FEE_MONTHLY_RATE` (line ~15):
|
||||||
|
|
||||||
|
```python
|
||||||
|
ADULT_FEE_MONTHLY_RATE = {
|
||||||
|
"2026-03": 350 # reduced fee for March 2026
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The key is `YYYY-MM`, the value is the fee in CZK. This replaces `ADULT_FEE_DEFAULT` (750 CZK) for members who attended 2+ practices that month. The single-practice fee (`ADULT_FEE_SINGLE`, 200 CZK) is unaffected.
|
||||||
|
|
||||||
|
### Juniors
|
||||||
|
|
||||||
|
Add an entry to `JUNIOR_MONTHLY_RATE` (line ~20):
|
||||||
|
|
||||||
|
```python
|
||||||
|
JUNIOR_MONTHLY_RATE = {
|
||||||
|
"2025-09": 250, # reduced fee for September 2025
|
||||||
|
"2026-03": 250 # reduced fee for March 2026
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The key is `YYYY-MM`, the value is the fee in CZK. This replaces `JUNIOR_FEE_DEFAULT` (500 CZK) for members who attended 2+ practices that month.
|
||||||
|
|
||||||
|
### Example: March 2026
|
||||||
|
|
||||||
|
Both tiers reduced to 350 CZK (adults) and 250 CZK (juniors):
|
||||||
|
|
||||||
|
```python
|
||||||
|
ADULT_FEE_MONTHLY_RATE = {
|
||||||
|
"2026-03": 350
|
||||||
|
}
|
||||||
|
|
||||||
|
JUNIOR_MONTHLY_RATE = {
|
||||||
|
"2026-03": 250
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Notes
|
||||||
|
|
||||||
|
- Overrides apply to all members of the given tier — use the **exceptions sheet** in Google Sheets for per-member overrides instead.
|
||||||
|
- After changing these values, restart the web dashboard (`make web`) for the change to take effect.
|
||||||
|
- The override only affects the calculated/expected fee. It does not modify any already-recorded payments in the bank sheet.
|
||||||
1
prompts/2026-03-09-add-pay-all.md
Normal file
1
prompts/2026-03-09-add-pay-all.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
Now on both reconiciliation pages in the balance column i want to have a button "Pay All" which will create a new row in the transactions table with amount equal to the balance and with a note same as for payment for single period but stating all periods debt consist of
|
||||||
7
prompts/2026-03-10-cache-data-from-google-sheets.md
Normal file
7
prompts/2026-03-10-cache-data-from-google-sheets.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
i would like to implement caching of data that we load from the google documents. For all of them. I do not need persistence across application restarts, so file of whatever format in tmp directory would be good enough. I think it would be good idea to read metadata about documents we access - last modified time? and reload these files only when document is newer than cached data.
|
||||||
|
|
||||||
|
Suggest solution, suggest file format for caching.
|
||||||
|
|
||||||
|
------------
|
||||||
|
|
||||||
|
i do not need caching for scripts, caching is relevant for web app only
|
||||||
@@ -8,8 +8,15 @@ dependencies = [
|
|||||||
"google-auth-httplib2>=0.2.0",
|
"google-auth-httplib2>=0.2.0",
|
||||||
"google-auth-oauthlib>=1.2.1",
|
"google-auth-oauthlib>=1.2.1",
|
||||||
"qrcode[pil]>=8.0",
|
"qrcode[pil]>=8.0",
|
||||||
|
"gunicorn>=23.0",
|
||||||
]
|
]
|
||||||
requires-python = ">=3.13"
|
requires-python = ">=3.13"
|
||||||
|
|
||||||
|
[dependency-groups]
|
||||||
|
dev = [
|
||||||
|
"pytest>=8.0",
|
||||||
|
"pytest-cov>=6.0",
|
||||||
|
]
|
||||||
|
|
||||||
[tool.uv]
|
[tool.uv]
|
||||||
package = false
|
package = false
|
||||||
|
|||||||
@@ -5,17 +5,21 @@ import io
|
|||||||
import urllib.request
|
import urllib.request
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
SHEET_ID = "1E2e_gT_K5AwSRCDLDTa2UetZTkHmBOcz0kFbBUNUNBA"
|
from config import ATTENDANCE_SHEET_ID as SHEET_ID, JUNIOR_SHEET_GID
|
||||||
JUNIOR_SHEET_GID = "1213318614"
|
|
||||||
EXPORT_URL = f"https://docs.google.com/spreadsheets/d/{SHEET_ID}/export?format=csv&gid=0"
|
EXPORT_URL = f"https://docs.google.com/spreadsheets/d/{SHEET_ID}/export?format=csv&gid=0"
|
||||||
JUNIOR_EXPORT_URL = f"https://docs.google.com/spreadsheets/d/{SHEET_ID}/export?format=csv&gid={JUNIOR_SHEET_GID}"
|
JUNIOR_EXPORT_URL = f"https://docs.google.com/spreadsheets/d/{SHEET_ID}/export?format=csv&gid={JUNIOR_SHEET_GID}"
|
||||||
|
|
||||||
FEE_FULL = 750 # CZK, for 2+ practices in a month
|
ADULT_FEE_DEFAULT = 750 # CZK, for 2+ practices in a month
|
||||||
FEE_SINGLE = 200 # CZK, for exactly 1 practice in a month
|
ADULT_FEE_SINGLE = 200 # CZK, for exactly 1 practice in a month
|
||||||
|
ADULT_FEE_MONTHLY_RATE = {
|
||||||
|
"2026-03": 350
|
||||||
|
}
|
||||||
|
|
||||||
JUNIOR_FEE_DEFAULT = 500 # CZK for 2+ practices
|
JUNIOR_FEE_DEFAULT = 500 # CZK for 2+ practices
|
||||||
JUNIOR_MONTHLY_RATE = {
|
JUNIOR_MONTHLY_RATE = {
|
||||||
"2025-09": 250
|
"2025-09": 250,
|
||||||
|
"2026-03": 250 # reduced fee for March 2026
|
||||||
}
|
}
|
||||||
ADULT_MERGED_MONTHS = {
|
ADULT_MERGED_MONTHS = {
|
||||||
#"2025-12": "2026-01", # keys are merged into values
|
#"2025-12": "2026-01", # keys are merged into values
|
||||||
@@ -34,13 +38,8 @@ FIRST_DATE_COL = 3
|
|||||||
|
|
||||||
def fetch_csv(url: str = EXPORT_URL) -> list[list[str]]:
|
def fetch_csv(url: str = EXPORT_URL) -> list[list[str]]:
|
||||||
"""Fetch the attendance Google Sheet as parsed CSV rows."""
|
"""Fetch the attendance Google Sheet as parsed CSV rows."""
|
||||||
import ssl
|
|
||||||
ctx = ssl.create_default_context()
|
|
||||||
ctx.check_hostname = False
|
|
||||||
ctx.verify_mode = ssl.CERT_NONE
|
|
||||||
|
|
||||||
req = urllib.request.Request(url)
|
req = urllib.request.Request(url)
|
||||||
with urllib.request.urlopen(req, context=ctx) as resp:
|
with urllib.request.urlopen(req) as resp:
|
||||||
text = resp.read().decode("utf-8")
|
text = resp.read().decode("utf-8")
|
||||||
reader = csv.reader(io.StringIO(text))
|
reader = csv.reader(io.StringIO(text))
|
||||||
return list(reader)
|
return list(reader)
|
||||||
@@ -81,13 +80,13 @@ def group_by_month(dates: list[tuple[int, datetime]], merged_months: dict[str, s
|
|||||||
return months
|
return months
|
||||||
|
|
||||||
|
|
||||||
def calculate_fee(attendance_count: int) -> int:
|
def calculate_fee(attendance_count: int, month_key: str) -> int:
|
||||||
"""Apply fee rules: 0 → 0, 1 → 200, 2+ → 750."""
|
"""Apply fee rules: 0 → 0, 1 → 200, 2+ → configured rate (default 750)."""
|
||||||
if attendance_count == 0:
|
if attendance_count == 0:
|
||||||
return 0
|
return 0
|
||||||
if attendance_count == 1:
|
if attendance_count == 1:
|
||||||
return FEE_SINGLE
|
return ADULT_FEE_SINGLE
|
||||||
return FEE_FULL
|
return ADULT_FEE_MONTHLY_RATE.get(month_key, ADULT_FEE_DEFAULT)
|
||||||
|
|
||||||
|
|
||||||
def calculate_junior_fee(attendance_count: int, month_key: str) -> str | int:
|
def calculate_junior_fee(attendance_count: int, month_key: str) -> str | int:
|
||||||
@@ -191,7 +190,7 @@ def get_members_with_fees() -> tuple[list[tuple[str, str, dict[str, int]]], list
|
|||||||
for c in cols
|
for c in cols
|
||||||
if c < len(row) and row[c].strip().upper() == "TRUE"
|
if c < len(row) and row[c].strip().upper() == "TRUE"
|
||||||
)
|
)
|
||||||
fee = calculate_fee(count) if tier == "A" else 0
|
fee = calculate_fee(count, month_key) if tier == "A" else 0
|
||||||
month_fees[month_key] = (fee, count)
|
month_fees[month_key] = (fee, count)
|
||||||
members.append((name, tier, month_fees))
|
members.append((name, tier, month_fees))
|
||||||
|
|
||||||
|
|||||||
@@ -1,29 +1,17 @@
|
|||||||
import json
|
import json
|
||||||
import os
|
|
||||||
import socket
|
import socket
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
|
||||||
from google.oauth2 import service_account
|
from google.oauth2 import service_account
|
||||||
from googleapiclient.discovery import build
|
from googleapiclient.discovery import build
|
||||||
|
|
||||||
|
from config import (
|
||||||
|
CACHE_DIR, CREDENTIALS_PATH as CREDS_PATH, DRIVE_TIMEOUT,
|
||||||
|
CACHE_TTL_SECONDS, CACHE_API_CHECK_TTL_SECONDS, CACHE_SHEET_MAP,
|
||||||
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Constants
|
|
||||||
CACHE_DIR = Path(__file__).parent.parent / "tmp"
|
|
||||||
CREDS_PATH = Path(__file__).parent.parent / ".secret" / "fuj-management-bot-credentials.json"
|
|
||||||
DRIVE_TIMEOUT = 10 # seconds
|
|
||||||
CACHE_TTL_SECONDS = int(os.environ.get("CACHE_TTL_SECONDS", 300)) # 30 min default for max cache age
|
|
||||||
CACHE_API_CHECK_TTL_SECONDS = int(os.environ.get("CACHE_API_CHECK_TTL_SECONDS", 300)) # 5 min default
|
|
||||||
|
|
||||||
# Known mappings mapping "cache name" to Google Sheet ID
|
|
||||||
CACHE_SHEET_MAP = {
|
|
||||||
"attendance_regular": "1E2e_gT_K5AwSRCDLDTa2UetZTkHmBOcz0kFbBUNUNBA",
|
|
||||||
"attendance_juniors": "1E2e_gT_K5AwSRCDLDTa2UetZTkHmBOcz0kFbBUNUNBA",
|
|
||||||
"exceptions_dict": "1Om0YPoDVCH5cV8BrNz5LG5eR5MMU05ypQC7UMN1xn_Y",
|
|
||||||
"payments_transactions": "1Om0YPoDVCH5cV8BrNz5LG5eR5MMU05ypQC7UMN1xn_Y"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Global state to track last Drive API check time per sheet
|
# Global state to track last Drive API check time per sheet
|
||||||
_LAST_CHECKED = {}
|
_LAST_CHECKED = {}
|
||||||
_DRIVE_SERVICE = None
|
_DRIVE_SERVICE = None
|
||||||
@@ -87,7 +75,7 @@ def get_sheet_modified_time(cache_key: str) -> str | None:
|
|||||||
# 2. Check if the cache file is simply too new (legacy check)
|
# 2. Check if the cache file is simply too new (legacy check)
|
||||||
if CACHE_TTL_SECONDS > 0 and cache_file.exists():
|
if CACHE_TTL_SECONDS > 0 and cache_file.exists():
|
||||||
try:
|
try:
|
||||||
file_mtime = os.path.getmtime(cache_file)
|
file_mtime = cache_file.stat().st_mtime
|
||||||
if time.time() - file_mtime < CACHE_TTL_SECONDS:
|
if time.time() - file_mtime < CACHE_TTL_SECONDS:
|
||||||
with open(cache_file, "r", encoding="utf-8") as f:
|
with open(cache_file, "r", encoding="utf-8") as f:
|
||||||
cache_data = json.load(f)
|
cache_data = json.load(f)
|
||||||
@@ -167,3 +155,19 @@ def write_cache(sheet_id: str, modified_time: str, data: list | dict) -> None:
|
|||||||
logger.info(f"Wrote cache for {sheet_id}")
|
logger.info(f"Wrote cache for {sheet_id}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to write cache {sheet_id}: {e}")
|
logger.error(f"Failed to write cache {sheet_id}: {e}")
|
||||||
|
|
||||||
|
def flush_cache():
|
||||||
|
"""Delete all cache files and reset in-memory state. Returns count of deleted files."""
|
||||||
|
global _DRIVE_SERVICE
|
||||||
|
_LAST_CHECKED.clear()
|
||||||
|
_DRIVE_SERVICE = None
|
||||||
|
|
||||||
|
deleted = 0
|
||||||
|
if CACHE_DIR.exists():
|
||||||
|
for f in CACHE_DIR.glob("*_cache.json"):
|
||||||
|
f.unlink()
|
||||||
|
deleted += 1
|
||||||
|
logger.info(f"Deleted cache file: {f.name}")
|
||||||
|
|
||||||
|
logger.info(f"Cache flushed: {deleted} files deleted, timers reset")
|
||||||
|
return deleted
|
||||||
|
|||||||
39
scripts/config.py
Normal file
39
scripts/config.py
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
"""Centralized configuration for FUJ management scripts.
|
||||||
|
|
||||||
|
External service IDs, credentials, and tunable parameters.
|
||||||
|
Domain-specific constants (fees, column indices) stay in their respective modules.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Paths
|
||||||
|
PROJECT_ROOT = Path(__file__).parent.parent
|
||||||
|
CREDENTIALS_PATH = Path(os.environ.get(
|
||||||
|
"CREDENTIALS_PATH",
|
||||||
|
str(PROJECT_ROOT / ".secret" / "fuj-management-bot-credentials.json"),
|
||||||
|
))
|
||||||
|
|
||||||
|
# Google Sheets IDs
|
||||||
|
ATTENDANCE_SHEET_ID = "1E2e_gT_K5AwSRCDLDTa2UetZTkHmBOcz0kFbBUNUNBA"
|
||||||
|
PAYMENTS_SHEET_ID = "1Om0YPoDVCH5cV8BrNz5LG5eR5MMU05ypQC7UMN1xn_Y"
|
||||||
|
|
||||||
|
# Attendance sheet tab GIDs
|
||||||
|
JUNIOR_SHEET_GID = "1213318614"
|
||||||
|
|
||||||
|
# Bank
|
||||||
|
BANK_ACCOUNT = os.environ.get("BANK_ACCOUNT", "CZ8520100000002800359168")
|
||||||
|
|
||||||
|
# Cache settings
|
||||||
|
CACHE_DIR = PROJECT_ROOT / "tmp"
|
||||||
|
DRIVE_TIMEOUT = 10 # seconds
|
||||||
|
CACHE_TTL_SECONDS = int(os.environ.get("CACHE_TTL_SECONDS", 300)) # 5 min default
|
||||||
|
CACHE_API_CHECK_TTL_SECONDS = int(os.environ.get("CACHE_API_CHECK_TTL_SECONDS", 300)) # 5 min default
|
||||||
|
|
||||||
|
# Maps cache keys to their source sheet IDs (used by cache_utils)
|
||||||
|
CACHE_SHEET_MAP = {
|
||||||
|
"attendance_regular": ATTENDANCE_SHEET_ID,
|
||||||
|
"attendance_juniors": ATTENDANCE_SHEET_ID,
|
||||||
|
"exceptions_dict": PAYMENTS_SHEET_ID,
|
||||||
|
"payments_transactions": PAYMENTS_SHEET_ID,
|
||||||
|
}
|
||||||
@@ -102,7 +102,7 @@ def infer_payments(spreadsheet_id: str, credentials_path: str, dry_run: bool = F
|
|||||||
member_names = [m[0] for m in members_data]
|
member_names = [m[0] for m in members_data]
|
||||||
|
|
||||||
# 3. Process rows
|
# 3. Process rows
|
||||||
print("Inffering details for empty rows...")
|
print("Inferring details for empty rows...")
|
||||||
updates = []
|
updates = []
|
||||||
|
|
||||||
for i, row in enumerate(rows[1:], start=2):
|
for i, row in enumerate(rows[1:], start=2):
|
||||||
|
|||||||
@@ -3,12 +3,15 @@
|
|||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import urllib.request
|
import urllib.request
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from html.parser import HTMLParser
|
from html.parser import HTMLParser
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
from attendance import get_members_with_fees
|
from attendance import get_members_with_fees
|
||||||
from czech_utils import normalize, parse_month_references
|
from czech_utils import normalize, parse_month_references
|
||||||
from sync_fio_to_sheets import get_sheets_service, DEFAULT_SPREADSHEET_ID
|
from sync_fio_to_sheets import get_sheets_service, DEFAULT_SPREADSHEET_ID
|
||||||
@@ -212,6 +215,11 @@ def fetch_sheet_data(spreadsheet_id: str, credentials_path: str) -> list[dict]:
|
|||||||
idx_message = get_col_index("Message")
|
idx_message = get_col_index("Message")
|
||||||
idx_bank_id = get_col_index("Bank ID")
|
idx_bank_id = get_col_index("Bank ID")
|
||||||
|
|
||||||
|
required = {"Date": idx_date, "Amount": idx_amount, "Person": idx_person, "Purpose": idx_purpose}
|
||||||
|
missing = [name for name, idx in required.items() if idx == -1]
|
||||||
|
if missing:
|
||||||
|
raise ValueError(f"Required columns missing from payments sheet: {', '.join(missing)}. Found headers: {header}")
|
||||||
|
|
||||||
transactions = []
|
transactions = []
|
||||||
for row in rows[1:]:
|
for row in rows[1:]:
|
||||||
def get_val(idx):
|
def get_val(idx):
|
||||||
@@ -381,12 +389,13 @@ def reconcile(
|
|||||||
per_allocation = amount / num_allocations if num_allocations > 0 else 0
|
per_allocation = amount / num_allocations if num_allocations > 0 else 0
|
||||||
|
|
||||||
for member_name, confidence in matched_members:
|
for member_name, confidence in matched_members:
|
||||||
# If we matched via sheet 'Person' column, name might be partial or have markers
|
|
||||||
# but usually it's the exact member name from get_members_with_fees.
|
|
||||||
# Let's ensure it exists in our ledger.
|
|
||||||
if member_name not in ledger:
|
if member_name not in ledger:
|
||||||
# Try matching by base name if it was Jan Novak (Kačerr) etc.
|
logger.warning(
|
||||||
pass
|
"Payment matched to unknown member %r (tx: %s, %s) — adding to unmatched",
|
||||||
|
member_name, tx.get("date", "?"), tx.get("message", "?"),
|
||||||
|
)
|
||||||
|
unmatched.append(tx)
|
||||||
|
continue
|
||||||
|
|
||||||
for month_key in matched_months:
|
for month_key in matched_months:
|
||||||
entry = {
|
entry = {
|
||||||
@@ -396,7 +405,7 @@ def reconcile(
|
|||||||
"message": tx["message"],
|
"message": tx["message"],
|
||||||
"confidence": confidence,
|
"confidence": confidence,
|
||||||
}
|
}
|
||||||
if month_key in ledger.get(member_name, {}):
|
if month_key in ledger[member_name]:
|
||||||
ledger[member_name][month_key]["paid"] += per_allocation
|
ledger[member_name][month_key]["paid"] += per_allocation
|
||||||
ledger[member_name][month_key]["transactions"].append(entry)
|
ledger[member_name][month_key]["transactions"].append(entry)
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -14,8 +14,7 @@ from googleapiclient.discovery import build
|
|||||||
|
|
||||||
from fio_utils import fetch_transactions
|
from fio_utils import fetch_transactions
|
||||||
|
|
||||||
# Configuration
|
from config import PAYMENTS_SHEET_ID as DEFAULT_SPREADSHEET_ID
|
||||||
DEFAULT_SPREADSHEET_ID = "1Om0YPoDVCH5cV8BrNz5LG5eR5MMU05ypQC7UMN1xn_Y"
|
|
||||||
SCOPES = ["https://www.googleapis.com/auth/spreadsheets"]
|
SCOPES = ["https://www.googleapis.com/auth/spreadsheets"]
|
||||||
TOKEN_FILE = "token.pickle"
|
TOKEN_FILE = "token.pickle"
|
||||||
COLUMN_LABELS = ["Date", "Amount", "manual fix", "Person", "Purpose", "Inferred Amount", "Sender", "VS", "Message", "Bank ID", "Sync ID"]
|
COLUMN_LABELS = ["Date", "Amount", "manual fix", "Person", "Purpose", "Inferred Amount", "Sender", "VS", "Message", "Bank ID", "Sync ID"]
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>FUJ Payment Reconciliation</title>
|
<title>FUJ Adults Dashboard</title>
|
||||||
<style>
|
<style>
|
||||||
body {
|
body {
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||||
@@ -45,8 +45,16 @@
|
|||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #555;
|
color: #555;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav > div {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 15px;
|
gap: 15px;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav a {
|
.nav a {
|
||||||
@@ -67,6 +75,23 @@
|
|||||||
border-color: #555;
|
border-color: #555;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nav-archived a {
|
||||||
|
font-size: 10px;
|
||||||
|
color: #666;
|
||||||
|
border-color: #222;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-archived a.active {
|
||||||
|
color: #ccc;
|
||||||
|
background-color: #333;
|
||||||
|
border-color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-archived a:hover {
|
||||||
|
color: #999;
|
||||||
|
border-color: #444;
|
||||||
|
}
|
||||||
|
|
||||||
.description {
|
.description {
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@@ -142,6 +167,16 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.cell-unpaid-current {
|
||||||
|
color: #994444;
|
||||||
|
background-color: rgba(153, 68, 68, 0.05);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell-overridden {
|
||||||
|
color: #ffa500 !important;
|
||||||
|
}
|
||||||
|
|
||||||
.pay-btn {
|
.pay-btn {
|
||||||
display: none;
|
display: none;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -230,6 +265,24 @@
|
|||||||
border-color: #00ff00;
|
border-color: #00ff00;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.filter-select {
|
||||||
|
background-color: #1a1a1a;
|
||||||
|
border: 1px solid #333;
|
||||||
|
color: #00ff00;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-select:focus {
|
||||||
|
border-color: #00ff00;
|
||||||
|
}
|
||||||
|
|
||||||
|
.month-hidden {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
.filter-label {
|
.filter-label {
|
||||||
color: #888;
|
color: #888;
|
||||||
text-transform: lowercase;
|
text-transform: lowercase;
|
||||||
@@ -423,14 +476,22 @@
|
|||||||
|
|
||||||
<body>
|
<body>
|
||||||
<div class="nav">
|
<div class="nav">
|
||||||
<a href="/fees">[Adult - Attendance/Fees]</a>
|
<div>
|
||||||
<a href="/fees-juniors">[Junior Attendance/Fees]</a>
|
<a href="/adults" class="active">[Adults]</a>
|
||||||
<a href="/reconcile" class="active">[Adult Payment Reconciliation]</a>
|
<a href="/juniors">[Juniors]</a>
|
||||||
<a href="/reconcile-juniors">[Junior Payment Reconciliation]</a>
|
</div>
|
||||||
<a href="/payments">[Payments Ledger]</a>
|
<div class="nav-archived">
|
||||||
|
<span style="color: #666; margin-right: 5px;">Archived:</span>
|
||||||
|
<a href="/payments">[Payments Ledger]</a>
|
||||||
|
</div>
|
||||||
|
<div class="nav-archived">
|
||||||
|
<span style="color: #666; margin-right: 5px;">Tools:</span>
|
||||||
|
<a href="/sync-bank">[Sync Bank Data]</a>
|
||||||
|
<a href="/flush-cache">[Flush Cache]</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h1>Payment Reconciliation</h1>
|
<h1>Adults Dashboard</h1>
|
||||||
|
|
||||||
<div class="description">
|
<div class="description">
|
||||||
Balances calculated by matching Google Sheet payments against attendance fees.<br>
|
Balances calculated by matching Google Sheet payments against attendance fees.<br>
|
||||||
@@ -441,6 +502,20 @@
|
|||||||
<div class="filter-container">
|
<div class="filter-container">
|
||||||
<span class="filter-label">search member:</span>
|
<span class="filter-label">search member:</span>
|
||||||
<input type="text" id="nameFilter" class="filter-input" placeholder="..." autocomplete="off">
|
<input type="text" id="nameFilter" class="filter-input" placeholder="..." autocomplete="off">
|
||||||
|
<span class="filter-label" style="margin-left: 16px;">from:</span>
|
||||||
|
<select id="fromMonth" class="filter-select">
|
||||||
|
{% for m in months %}
|
||||||
|
<option value="{{ loop.index0 }}">{{ m }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<span class="filter-label" style="margin-left: 8px;">to:</span>
|
||||||
|
<select id="toMonth" class="filter-select">
|
||||||
|
{% for m in months %}
|
||||||
|
<option value="{{ loop.index0 }}">{{ m }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<button type="button" onclick="applyMonthFilter()" class="filter-select" style="cursor: pointer;">Apply</button>
|
||||||
|
<button type="button" onclick="resetMonthFilter()" class="filter-select" style="cursor: pointer;">All</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="table-container">
|
<div class="table-container">
|
||||||
@@ -449,7 +524,7 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<th>Member</th>
|
<th>Member</th>
|
||||||
{% for m in months %}
|
{% for m in months %}
|
||||||
<th>{{ m }}</th>
|
<th data-month-idx="{{ loop.index0 }}">{{ m }}</th>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
<th>Balance</th>
|
<th>Balance</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -462,24 +537,36 @@
|
|||||||
<span class="info-icon" onclick="showMemberDetails('{{ row.name|e }}')">[i]</span>
|
<span class="info-icon" onclick="showMemberDetails('{{ row.name|e }}')">[i]</span>
|
||||||
</td>
|
</td>
|
||||||
{% for cell in row.months %}
|
{% for cell in row.months %}
|
||||||
<td
|
<td data-month-idx="{{ loop.index0 }}" title="{{ cell.tooltip }}"
|
||||||
class="{% if cell.status == 'empty' %}cell-empty{% elif cell.status == 'unpaid' or cell.status == 'partial' %}cell-unpaid{% elif cell.status == 'ok' %}cell-ok{% endif %}">
|
class="{% if cell.status == 'empty' %}cell-empty{% elif (cell.status == 'unpaid' or cell.status == 'partial') and cell.raw_month >= current_month %}cell-unpaid-current{% elif cell.status == 'unpaid' or cell.status == 'partial' %}cell-unpaid{% elif cell.status == 'ok' %}cell-ok{% endif %}{% if cell.overridden %} cell-overridden{% endif %}">
|
||||||
{{ cell.text }}
|
{{ cell.text }}
|
||||||
{% if cell.status == 'unpaid' or cell.status == 'partial' %}
|
{% if (cell.status == 'unpaid' or cell.status == 'partial') and cell.raw_month < current_month %}
|
||||||
<button class="pay-btn"
|
<button class="pay-btn"
|
||||||
onclick="showPayQR('{{ row.name|e }}', {{ cell.amount }}, '{{ cell.month|e }}')">Pay</button>
|
onclick="showPayQR('{{ row.name|e }}', {{ cell.amount }}, '{{ cell.month|e }}', '{{ cell.raw_month }}')">Pay</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
<td class="{% if row.balance > 0 %}balance-pos{% elif row.balance < 0 %}balance-neg{% endif %}" style="position: relative;">
|
<td class="{% if row.balance > 0 %}balance-pos{% elif row.balance < 0 %}balance-neg{% endif %}" style="position: relative;">
|
||||||
{{ "%+d"|format(row.balance) if row.balance != 0 else "0" }}
|
{{ "%+d"|format(row.balance) if row.balance != 0 else "0" }}
|
||||||
{% if row.balance < 0 %}
|
{% if row.payable_amount > 0 %}
|
||||||
<button class="pay-btn"
|
<button class="pay-btn"
|
||||||
onclick="showPayQR('{{ row.name|e }}', {{ -row.balance }}, '{{ row.unpaid_periods|e }}')">Pay All</button>
|
onclick="showPayQR('{{ row.name|e }}', {{ row.payable_amount }}, '{{ row.unpaid_periods|e }}', '{{ row.raw_unpaid_periods|e }}')">Pay All</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
<tr class="totals-row" style="font-weight: bold; background-color: #111; border-top: 2px solid #333;">
|
||||||
|
<td style="text-align: left; padding: 6px 8px;">
|
||||||
|
TOTAL
|
||||||
|
</td>
|
||||||
|
{% for t in totals %}
|
||||||
|
<td data-month-idx="{{ loop.index0 }}" class="{% if t.status == 'ok' %}cell-ok{% elif t.status == 'unpaid' %}cell-unpaid{% elif t.status == 'surplus' %}cell-overridden{% endif %}" style="padding-top: 4px; padding-bottom: 4px;">
|
||||||
|
<span style="font-size: 0.6em; font-weight: normal; color: #666; text-transform: lowercase; display: block; margin-bottom: 2px;">received / expected</span>
|
||||||
|
{{ t.text }}
|
||||||
|
</td>
|
||||||
|
{% endfor %}
|
||||||
|
<td></td>
|
||||||
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -599,7 +686,7 @@
|
|||||||
{% set rt = get_render_time() %}
|
{% set rt = get_render_time() %}
|
||||||
<div class="footer"
|
<div class="footer"
|
||||||
onclick="document.getElementById('perf-details').style.display = (document.getElementById('perf-details').style.display === 'block' ? 'none' : 'block')">
|
onclick="document.getElementById('perf-details').style.display = (document.getElementById('perf-details').style.display === 'block' ? 'none' : 'block')">
|
||||||
render time: {{ rt.total }}s
|
{{ build_meta.tag }}@{{ build_meta.commit }} | render time: {{ rt.total }}s
|
||||||
<div id="perf-details" class="perf-breakdown">
|
<div id="perf-details" class="perf-breakdown">
|
||||||
{{ rt.breakdown }}
|
{{ rt.breakdown }}
|
||||||
</div>
|
</div>
|
||||||
@@ -638,9 +725,9 @@
|
|||||||
let status = '-';
|
let status = '-';
|
||||||
let statusClass = '';
|
let statusClass = '';
|
||||||
if (expected > 0 || paid > 0) {
|
if (expected > 0 || paid > 0) {
|
||||||
if (paid >= expected && expected > 0) { status = 'OK'; statusClass = 'cell-ok'; }
|
if (paid >= expected && expected > 0) { status = paid + '/' + expected; statusClass = 'cell-ok'; }
|
||||||
else if (paid > 0) { status = paid + '/' + expected; }
|
else if (paid > 0) { status = paid + '/' + expected; }
|
||||||
else { status = 'UNPAID ' + expected; statusClass = 'cell-unpaid'; }
|
else { status = '0/' + expected; statusClass = 'cell-unpaid'; }
|
||||||
}
|
}
|
||||||
|
|
||||||
const expectedCell = mdata.exception
|
const expectedCell = mdata.exception
|
||||||
@@ -809,9 +896,13 @@
|
|||||||
showMemberDetails(nextName);
|
showMemberDetails(nextName);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function showPayQR(name, amount, month) {
|
function showPayQR(name, amount, month, rawMonth) {
|
||||||
const account = "{{ bank_account }}";
|
const account = "{{ bank_account }}";
|
||||||
const message = `${name} / ${month}`;
|
// Convert YYYY-MM to MM/YYYY for infer_payments.py compatibility
|
||||||
|
const numericMonth = rawMonth.includes('+')
|
||||||
|
? rawMonth.split('+').map(p => p.replace(/(\d{4})-(\d{2})/, '$2/$1')).join('+')
|
||||||
|
: rawMonth.replace(/(\d{4})-(\d{2})/, '$2/$1');
|
||||||
|
const message = `${name}: ${numericMonth}`;
|
||||||
const qrTitle = document.getElementById('qrTitle');
|
const qrTitle = document.getElementById('qrTitle');
|
||||||
const qrImg = document.getElementById('qrImg');
|
const qrImg = document.getElementById('qrImg');
|
||||||
const qrAccount = document.getElementById('qrAccount');
|
const qrAccount = document.getElementById('qrAccount');
|
||||||
@@ -836,6 +927,64 @@
|
|||||||
event.target.style.display = 'none';
|
event.target.style.display = 'none';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Month range filter
|
||||||
|
var maxMonthIdx;
|
||||||
|
|
||||||
|
function applyMonthFilter() {
|
||||||
|
var fromIdx = parseInt(document.getElementById('fromMonth').value);
|
||||||
|
var toIdx = parseInt(document.getElementById('toMonth').value);
|
||||||
|
document.querySelectorAll('[data-month-idx]').forEach(function(el) {
|
||||||
|
var idx = parseInt(el.getAttribute('data-month-idx'));
|
||||||
|
if (idx >= fromIdx && idx <= toIdx) {
|
||||||
|
el.classList.remove('month-hidden');
|
||||||
|
} else {
|
||||||
|
el.classList.add('month-hidden');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetMonthFilter() {
|
||||||
|
var fromSelect = document.getElementById('fromMonth');
|
||||||
|
var toSelect = document.getElementById('toMonth');
|
||||||
|
fromSelect.value = 0;
|
||||||
|
toSelect.value = maxMonthIdx;
|
||||||
|
applyMonthFilter();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove future months from selects, set defaults, apply on load
|
||||||
|
(function() {
|
||||||
|
var now = new Date();
|
||||||
|
var currentMonth = now.getFullYear() + '-' + String(now.getMonth() + 1).padStart(2, '0');
|
||||||
|
maxMonthIdx = sortedMonths.length - 1;
|
||||||
|
for (var i = 0; i < sortedMonths.length; i++) {
|
||||||
|
if (sortedMonths[i] > currentMonth) {
|
||||||
|
maxMonthIdx = i - 1;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var fromSelect = document.getElementById('fromMonth');
|
||||||
|
var toSelect = document.getElementById('toMonth');
|
||||||
|
|
||||||
|
// Remove future month options
|
||||||
|
for (var i = fromSelect.options.length - 1; i > maxMonthIdx; i--) {
|
||||||
|
fromSelect.remove(i);
|
||||||
|
toSelect.remove(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide future month columns permanently
|
||||||
|
document.querySelectorAll('[data-month-idx]').forEach(function(el) {
|
||||||
|
if (parseInt(el.getAttribute('data-month-idx')) > maxMonthIdx) {
|
||||||
|
el.classList.add('month-hidden');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
var defaultFrom = Math.max(0, maxMonthIdx - 4);
|
||||||
|
fromSelect.value = defaultFrom;
|
||||||
|
toSelect.value = maxMonthIdx;
|
||||||
|
applyMonthFilter();
|
||||||
|
})();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
@@ -1,216 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>FUJ Junior Fees Dashboard</title>
|
|
||||||
<style>
|
|
||||||
body {
|
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
|
||||||
background-color: #0c0c0c;
|
|
||||||
color: #cccccc;
|
|
||||||
padding: 10px;
|
|
||||||
margin: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
font-size: 11px;
|
|
||||||
line-height: 1.2;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
color: #00ff00;
|
|
||||||
font-family: inherit;
|
|
||||||
margin-top: 10px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 1px;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-container {
|
|
||||||
background-color: transparent;
|
|
||||||
border: 1px solid #333;
|
|
||||||
box-shadow: none;
|
|
||||||
overflow-x: auto;
|
|
||||||
width: 100%;
|
|
||||||
max-width: 1200px;
|
|
||||||
}
|
|
||||||
|
|
||||||
table {
|
|
||||||
border-collapse: collapse;
|
|
||||||
width: 100%;
|
|
||||||
table-layout: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
th,
|
|
||||||
td {
|
|
||||||
padding: 2px 8px;
|
|
||||||
text-align: right;
|
|
||||||
border-bottom: 1px dashed #222;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
th:first-child,
|
|
||||||
td:first-child {
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
th {
|
|
||||||
background-color: transparent;
|
|
||||||
color: #888888;
|
|
||||||
font-weight: normal;
|
|
||||||
border-bottom: 1px solid #555;
|
|
||||||
text-transform: lowercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
tr:hover {
|
|
||||||
background-color: #1a1a1a;
|
|
||||||
}
|
|
||||||
|
|
||||||
.total {
|
|
||||||
font-weight: bold;
|
|
||||||
background-color: transparent;
|
|
||||||
color: #00ff00;
|
|
||||||
border-top: 1px solid #555;
|
|
||||||
}
|
|
||||||
|
|
||||||
.total:hover {
|
|
||||||
background-color: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cell-empty {
|
|
||||||
color: #444444;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cell-paid {
|
|
||||||
color: #aaaaaa;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cell-overridden {
|
|
||||||
color: #ffa500 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav {
|
|
||||||
margin-bottom: 20px;
|
|
||||||
font-size: 12px;
|
|
||||||
color: #555;
|
|
||||||
display: flex;
|
|
||||||
gap: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav a {
|
|
||||||
color: #00ff00;
|
|
||||||
text-decoration: none;
|
|
||||||
padding: 2px 8px;
|
|
||||||
border: 1px solid #333;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav a.active {
|
|
||||||
color: #000;
|
|
||||||
background-color: #00ff00;
|
|
||||||
border-color: #00ff00;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav a:hover {
|
|
||||||
color: #fff;
|
|
||||||
border-color: #555;
|
|
||||||
}
|
|
||||||
|
|
||||||
.description {
|
|
||||||
margin-bottom: 20px;
|
|
||||||
text-align: center;
|
|
||||||
color: #888;
|
|
||||||
max-width: 800px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.description a {
|
|
||||||
color: #00ff00;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.description a:hover {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer {
|
|
||||||
margin-top: 50px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
color: #333;
|
|
||||||
font-size: 9px;
|
|
||||||
text-align: center;
|
|
||||||
width: 100%;
|
|
||||||
cursor: pointer;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.perf-breakdown {
|
|
||||||
display: none;
|
|
||||||
margin-top: 5px;
|
|
||||||
color: #222;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body>
|
|
||||||
<div class="nav">
|
|
||||||
<a href="/fees">[Adult - Attendance/Fees]</a>
|
|
||||||
<a href="/fees-juniors" class="active">[Junior Attendance/Fees]</a>
|
|
||||||
<a href="/reconcile">[Adult Payment Reconciliation]</a>
|
|
||||||
<a href="/reconcile-juniors">[Junior Payment Reconciliation]</a>
|
|
||||||
<a href="/payments">[Payments Ledger]</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h1>FUJ Junior Fees Dashboard</h1>
|
|
||||||
|
|
||||||
<div class="description">
|
|
||||||
Calculated monthly fees based on attendance markers.<br>
|
|
||||||
Source: <a href="{{ attendance_url }}" target="_blank">Junior Attendance Sheet</a> |
|
|
||||||
<a href="{{ payments_url }}" target="_blank">Payments Ledger</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="table-container">
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Member</th>
|
|
||||||
{% for m in months %}
|
|
||||||
<th>{{ m }}</th>
|
|
||||||
{% endfor %}
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{% for row in results %}
|
|
||||||
<tr>
|
|
||||||
<td>{{ row.name }}</td>
|
|
||||||
{% for mdata in row.months %}
|
|
||||||
<td
|
|
||||||
class="{% if mdata.cell == '-' %}cell-empty{% elif mdata.overridden %}cell-overridden{% else %}cell-paid{% endif %}">
|
|
||||||
{{ mdata.cell }}
|
|
||||||
</td>
|
|
||||||
{% endfor %}
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
<tfoot>
|
|
||||||
<tr class="total">
|
|
||||||
<td>TOTAL</td>
|
|
||||||
{% for t in totals %}
|
|
||||||
<td>{{ t }}</td>
|
|
||||||
{% endfor %}
|
|
||||||
</tr>
|
|
||||||
</tfoot>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
{% set rt = get_render_time() %}
|
|
||||||
<div class="footer"
|
|
||||||
onclick="document.getElementById('perf-details').style.display = (document.getElementById('perf-details').style.display === 'block' ? 'none' : 'block')">
|
|
||||||
render time: {{ rt.total }}s
|
|
||||||
<div id="perf-details" class="perf-breakdown">
|
|
||||||
{{ rt.breakdown }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
|
|
||||||
</html>
|
|
||||||
@@ -1,231 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>FUJ Fees Dashboard</title>
|
|
||||||
<style>
|
|
||||||
body {
|
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
|
||||||
background-color: #0c0c0c;
|
|
||||||
/* Deeper black */
|
|
||||||
color: #cccccc;
|
|
||||||
/* Base gray terminal text */
|
|
||||||
padding: 10px;
|
|
||||||
margin: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
font-size: 11px;
|
|
||||||
/* Even smaller font */
|
|
||||||
line-height: 1.2;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
color: #00ff00;
|
|
||||||
/* Terminal green */
|
|
||||||
font-family: inherit;
|
|
||||||
/* Use monospace for header too */
|
|
||||||
margin-top: 10px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 1px;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-container {
|
|
||||||
background-color: transparent;
|
|
||||||
/* Remove the card background */
|
|
||||||
border: 1px solid #333;
|
|
||||||
/* Just a thin outline if needed, or none */
|
|
||||||
box-shadow: none;
|
|
||||||
overflow-x: auto;
|
|
||||||
width: 100%;
|
|
||||||
max-width: 1200px;
|
|
||||||
}
|
|
||||||
|
|
||||||
table {
|
|
||||||
border-collapse: collapse;
|
|
||||||
width: 100%;
|
|
||||||
table-layout: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
th,
|
|
||||||
td {
|
|
||||||
padding: 2px 8px;
|
|
||||||
/* Extremely tight padding */
|
|
||||||
text-align: right;
|
|
||||||
border-bottom: 1px dashed #222;
|
|
||||||
/* Dashed lines for terminal feel */
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
th:first-child,
|
|
||||||
td:first-child {
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
th {
|
|
||||||
background-color: transparent;
|
|
||||||
color: #888888;
|
|
||||||
font-weight: normal;
|
|
||||||
border-bottom: 1px solid #555;
|
|
||||||
/* Stronger border for header */
|
|
||||||
text-transform: lowercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
tr:hover {
|
|
||||||
background-color: #1a1a1a;
|
|
||||||
/* Very subtle hover */
|
|
||||||
}
|
|
||||||
|
|
||||||
.total {
|
|
||||||
font-weight: bold;
|
|
||||||
background-color: transparent;
|
|
||||||
color: #00ff00;
|
|
||||||
/* Highlight total row */
|
|
||||||
border-top: 1px solid #555;
|
|
||||||
}
|
|
||||||
|
|
||||||
.total:hover {
|
|
||||||
background-color: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cell-empty {
|
|
||||||
color: #444444;
|
|
||||||
/* Darker gray for empty cells */
|
|
||||||
}
|
|
||||||
|
|
||||||
.cell-paid {
|
|
||||||
color: #aaaaaa;
|
|
||||||
/* Light gray for normal cells */
|
|
||||||
}
|
|
||||||
|
|
||||||
.cell-overridden {
|
|
||||||
color: #ffa500 !important;
|
|
||||||
/* Orange for overrides */
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav {
|
|
||||||
margin-bottom: 20px;
|
|
||||||
font-size: 12px;
|
|
||||||
color: #555;
|
|
||||||
display: flex;
|
|
||||||
gap: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav a {
|
|
||||||
color: #00ff00;
|
|
||||||
text-decoration: none;
|
|
||||||
padding: 2px 8px;
|
|
||||||
border: 1px solid #333;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav a.active {
|
|
||||||
color: #000;
|
|
||||||
background-color: #00ff00;
|
|
||||||
border-color: #00ff00;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav a:hover {
|
|
||||||
color: #fff;
|
|
||||||
border-color: #555;
|
|
||||||
}
|
|
||||||
|
|
||||||
.description {
|
|
||||||
margin-bottom: 20px;
|
|
||||||
text-align: center;
|
|
||||||
color: #888;
|
|
||||||
max-width: 800px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.description a {
|
|
||||||
color: #00ff00;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.description a:hover {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer {
|
|
||||||
margin-top: 50px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
color: #333;
|
|
||||||
font-size: 9px;
|
|
||||||
text-align: center;
|
|
||||||
width: 100%;
|
|
||||||
cursor: pointer;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.perf-breakdown {
|
|
||||||
display: none;
|
|
||||||
margin-top: 5px;
|
|
||||||
color: #222;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body>
|
|
||||||
<div class="nav">
|
|
||||||
<a href="/fees" class="active">[Adult - Attendance/Fees]</a>
|
|
||||||
<a href="/fees-juniors">[Junior Attendance/Fees]</a>
|
|
||||||
<a href="/reconcile">[Adult Payment Reconciliation]</a>
|
|
||||||
<a href="/reconcile-juniors">[Junior Payment Reconciliation]</a>
|
|
||||||
<a href="/payments">[Payments Ledger]</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h1>FUJ Fees Dashboard</h1>
|
|
||||||
|
|
||||||
<div class="description">
|
|
||||||
Calculated monthly fees based on attendance markers.<br>
|
|
||||||
Source: <a href="{{ attendance_url }}" target="_blank">Attendance Sheet</a> |
|
|
||||||
<a href="{{ payments_url }}" target="_blank">Payments Ledger</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="table-container">
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Member</th>
|
|
||||||
{% for m in months %}
|
|
||||||
<th>{{ m }}</th>
|
|
||||||
{% endfor %}
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{% for row in results %}
|
|
||||||
<tr>
|
|
||||||
<td>{{ row.name }}</td>
|
|
||||||
{% for mdata in row.months %}
|
|
||||||
<td
|
|
||||||
class="{% if mdata.cell == '-' %}cell-empty{% elif mdata.overridden %}cell-overridden{% else %}cell-paid{% endif %}">
|
|
||||||
{{ mdata.cell }}
|
|
||||||
</td>
|
|
||||||
{% endfor %}
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
<tfoot>
|
|
||||||
<tr class="total">
|
|
||||||
<td>TOTAL</td>
|
|
||||||
{% for t in totals %}
|
|
||||||
<td>{{ t }}</td>
|
|
||||||
{% endfor %}
|
|
||||||
</tr>
|
|
||||||
</tfoot>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
{% set rt = get_render_time() %}
|
|
||||||
<div class="footer"
|
|
||||||
onclick="document.getElementById('perf-details').style.display = (document.getElementById('perf-details').style.display === 'block' ? 'none' : 'block')">
|
|
||||||
render time: {{ rt.total }}s
|
|
||||||
<div id="perf-details" class="perf-breakdown">
|
|
||||||
{{ rt.breakdown }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
|
|
||||||
</html>
|
|
||||||
163
templates/flush-cache.html
Normal file
163
templates/flush-cache.html
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>FUJ - Flush Cache</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||||
|
background-color: #0c0c0c;
|
||||||
|
color: #cccccc;
|
||||||
|
padding: 10px;
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
color: #00ff00;
|
||||||
|
font-family: inherit;
|
||||||
|
margin-top: 10px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #555;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav > div {
|
||||||
|
display: flex;
|
||||||
|
gap: 15px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav a {
|
||||||
|
color: #00ff00;
|
||||||
|
text-decoration: none;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border: 1px solid #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav a.active {
|
||||||
|
color: #000;
|
||||||
|
background-color: #00ff00;
|
||||||
|
border-color: #00ff00;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav a:hover {
|
||||||
|
color: #fff;
|
||||||
|
border-color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-archived a {
|
||||||
|
font-size: 10px;
|
||||||
|
color: #666;
|
||||||
|
border-color: #222;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-archived a.active {
|
||||||
|
color: #ccc;
|
||||||
|
background-color: #333;
|
||||||
|
border-color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-archived a:hover {
|
||||||
|
color: #999;
|
||||||
|
border-color: #444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flush-container {
|
||||||
|
background-color: #111;
|
||||||
|
border: 1px solid #333;
|
||||||
|
padding: 30px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 600px;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flush-btn {
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #00ff00;
|
||||||
|
background-color: #1a1a1a;
|
||||||
|
border: 1px solid #00ff00;
|
||||||
|
padding: 8px 24px;
|
||||||
|
cursor: pointer;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flush-btn:hover {
|
||||||
|
background-color: #00ff00;
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
margin-bottom: 15px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-ok { color: #00ff00; }
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
text-align: center;
|
||||||
|
color: #333;
|
||||||
|
margin-top: 20px;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div class="nav">
|
||||||
|
<div>
|
||||||
|
<a href="/adults">[Adults]</a>
|
||||||
|
<a href="/juniors">[Juniors]</a>
|
||||||
|
</div>
|
||||||
|
<div class="nav-archived">
|
||||||
|
<span style="color: #666; margin-right: 5px;">Archived:</span>
|
||||||
|
<a href="/payments">[Payments Ledger]</a>
|
||||||
|
</div>
|
||||||
|
<div class="nav-archived">
|
||||||
|
<span style="color: #666; margin-right: 5px;">Tools:</span>
|
||||||
|
<a href="/sync-bank">[Sync Bank Data]</a>
|
||||||
|
<a href="/flush-cache" class="active">[Flush Cache]</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1>Flush Cache</h1>
|
||||||
|
|
||||||
|
{% if flushed %}
|
||||||
|
<div class="status">
|
||||||
|
<span class="status-ok">Cache flushed successfully. {{ deleted }} file(s) deleted.</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="flush-container">
|
||||||
|
<p style="margin-bottom: 20px; color: #888;">Clears all cached Google Sheets data and resets refresh timers.</p>
|
||||||
|
<form method="POST" action="/flush-cache">
|
||||||
|
<button type="submit" class="flush-btn">[Flush Cache]</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
{{ build_meta.tag }} | {{ build_meta.commit }} | {{ build_meta.build_date }}
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>FUJ Junior Payment Reconciliation</title>
|
<title>FUJ Juniors Dashboard</title>
|
||||||
<style>
|
<style>
|
||||||
body {
|
body {
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||||
@@ -45,8 +45,16 @@
|
|||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #555;
|
color: #555;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav > div {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 15px;
|
gap: 15px;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav a {
|
.nav a {
|
||||||
@@ -67,6 +75,23 @@
|
|||||||
border-color: #555;
|
border-color: #555;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nav-archived a {
|
||||||
|
font-size: 10px;
|
||||||
|
color: #666;
|
||||||
|
border-color: #222;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-archived a.active {
|
||||||
|
color: #ccc;
|
||||||
|
background-color: #333;
|
||||||
|
border-color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-archived a:hover {
|
||||||
|
color: #999;
|
||||||
|
border-color: #444;
|
||||||
|
}
|
||||||
|
|
||||||
.description {
|
.description {
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@@ -142,6 +167,16 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.cell-unpaid-current {
|
||||||
|
color: #994444;
|
||||||
|
background-color: rgba(153, 68, 68, 0.05);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell-overridden {
|
||||||
|
color: #ffa500 !important;
|
||||||
|
}
|
||||||
|
|
||||||
.pay-btn {
|
.pay-btn {
|
||||||
display: none;
|
display: none;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -230,6 +265,24 @@
|
|||||||
border-color: #00ff00;
|
border-color: #00ff00;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.filter-select {
|
||||||
|
background-color: #1a1a1a;
|
||||||
|
border: 1px solid #333;
|
||||||
|
color: #00ff00;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-select:focus {
|
||||||
|
border-color: #00ff00;
|
||||||
|
}
|
||||||
|
|
||||||
|
.month-hidden {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
.filter-label {
|
.filter-label {
|
||||||
color: #888;
|
color: #888;
|
||||||
text-transform: lowercase;
|
text-transform: lowercase;
|
||||||
@@ -423,17 +476,25 @@
|
|||||||
|
|
||||||
<body>
|
<body>
|
||||||
<div class="nav">
|
<div class="nav">
|
||||||
<a href="/fees">[Adult - Attendance/Fees]</a>
|
<div>
|
||||||
<a href="/fees-juniors">[Junior Attendance/Fees]</a>
|
<a href="/adults">[Adults]</a>
|
||||||
<a href="/reconcile">[Adult Payment Reconciliation]</a>
|
<a href="/juniors" class="active">[Juniors]</a>
|
||||||
<a href="/reconcile-juniors" class="active">[Junior Payment Reconciliation]</a>
|
</div>
|
||||||
<a href="/payments">[Payments Ledger]</a>
|
<div class="nav-archived">
|
||||||
|
<span style="color: #666; margin-right: 5px;">Archived:</span>
|
||||||
|
<a href="/payments">[Payments Ledger]</a>
|
||||||
|
</div>
|
||||||
|
<div class="nav-archived">
|
||||||
|
<span style="color: #666; margin-right: 5px;">Tools:</span>
|
||||||
|
<a href="/sync-bank">[Sync Bank Data]</a>
|
||||||
|
<a href="/flush-cache">[Flush Cache]</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h1>Junior Payment Reconciliation</h1>
|
<h1>Juniors Dashboard</h1>
|
||||||
|
|
||||||
<div class="description">
|
<div class="description">
|
||||||
Balances calculated by matching Google Sheet payments against junior attendance fees.<br>
|
Balances calculated by matching Google Sheet payments against attendance fees.<br>
|
||||||
Source: <a href="{{ attendance_url }}" target="_blank">Attendance Sheet</a> |
|
Source: <a href="{{ attendance_url }}" target="_blank">Attendance Sheet</a> |
|
||||||
<a href="{{ payments_url }}" target="_blank">Payments Ledger</a>
|
<a href="{{ payments_url }}" target="_blank">Payments Ledger</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -441,6 +502,20 @@
|
|||||||
<div class="filter-container">
|
<div class="filter-container">
|
||||||
<span class="filter-label">search member:</span>
|
<span class="filter-label">search member:</span>
|
||||||
<input type="text" id="nameFilter" class="filter-input" placeholder="..." autocomplete="off">
|
<input type="text" id="nameFilter" class="filter-input" placeholder="..." autocomplete="off">
|
||||||
|
<span class="filter-label" style="margin-left: 16px;">from:</span>
|
||||||
|
<select id="fromMonth" class="filter-select">
|
||||||
|
{% for m in months %}
|
||||||
|
<option value="{{ loop.index0 }}">{{ m }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<span class="filter-label" style="margin-left: 8px;">to:</span>
|
||||||
|
<select id="toMonth" class="filter-select">
|
||||||
|
{% for m in months %}
|
||||||
|
<option value="{{ loop.index0 }}">{{ m }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<button type="button" onclick="applyMonthFilter()" class="filter-select" style="cursor: pointer;">Apply</button>
|
||||||
|
<button type="button" onclick="resetMonthFilter()" class="filter-select" style="cursor: pointer;">All</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="table-container">
|
<div class="table-container">
|
||||||
@@ -449,7 +524,7 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<th>Member</th>
|
<th>Member</th>
|
||||||
{% for m in months %}
|
{% for m in months %}
|
||||||
<th>{{ m }}</th>
|
<th data-month-idx="{{ loop.index0 }}">{{ m }}</th>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
<th>Balance</th>
|
<th>Balance</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -462,24 +537,36 @@
|
|||||||
<span class="info-icon" onclick="showMemberDetails('{{ row.name|e }}')">[i]</span>
|
<span class="info-icon" onclick="showMemberDetails('{{ row.name|e }}')">[i]</span>
|
||||||
</td>
|
</td>
|
||||||
{% for cell in row.months %}
|
{% for cell in row.months %}
|
||||||
<td
|
<td data-month-idx="{{ loop.index0 }}" title="{{ cell.tooltip }}"
|
||||||
class="{% if cell.status == 'empty' %}cell-empty{% elif cell.status == 'unpaid' or cell.status == 'partial' %}cell-unpaid{% elif cell.status == 'ok' %}cell-ok{% endif %}">
|
class="{% if cell.status == 'empty' %}cell-empty{% elif (cell.status == 'unpaid' or cell.status == 'partial') and cell.raw_month >= current_month %}cell-unpaid-current{% elif cell.status == 'unpaid' or cell.status == 'partial' %}cell-unpaid{% elif cell.status == 'ok' %}cell-ok{% endif %}{% if cell.overridden %} cell-overridden{% endif %}">
|
||||||
{{ cell.text }}
|
{{ cell.text }}
|
||||||
{% if cell.status == 'unpaid' or cell.status == 'partial' %}
|
{% if (cell.status == 'unpaid' or cell.status == 'partial') and cell.raw_month < current_month %}
|
||||||
<button class="pay-btn"
|
<button class="pay-btn"
|
||||||
onclick="showPayQR('{{ row.name|e }}', {{ cell.amount }}, '{{ cell.month|e }}')">Pay</button>
|
onclick="showPayQR('{{ row.name|e }}', {{ cell.amount }}, '{{ cell.month|e }}', '{{ cell.raw_month }}')">Pay</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
<td class="{% if row.balance > 0 %}balance-pos{% elif row.balance < 0 %}balance-neg{% endif %}" style="position: relative;">
|
<td class="{% if row.balance > 0 %}balance-pos{% elif row.balance < 0 %}balance-neg{% endif %}" style="position: relative;">
|
||||||
{{ "%+d"|format(row.balance) if row.balance != 0 else "0" }}
|
{{ "%+d"|format(row.balance) if row.balance != 0 else "0" }}
|
||||||
{% if row.balance < 0 %}
|
{% if row.payable_amount > 0 %}
|
||||||
<button class="pay-btn"
|
<button class="pay-btn"
|
||||||
onclick="showPayQR('{{ row.name|e }}', {{ -row.balance }}, '{{ row.unpaid_periods|e }}')">Pay All</button>
|
onclick="showPayQR('{{ row.name|e }}', {{ row.payable_amount }}, '{{ row.unpaid_periods|e }}', '{{ row.raw_unpaid_periods|e }}')">Pay All</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
<tr class="totals-row" style="font-weight: bold; background-color: #111; border-top: 2px solid #333;">
|
||||||
|
<td style="text-align: left; padding: 6px 8px;">
|
||||||
|
TOTAL
|
||||||
|
</td>
|
||||||
|
{% for t in totals %}
|
||||||
|
<td data-month-idx="{{ loop.index0 }}" class="{% if t.status == 'ok' %}cell-ok{% elif t.status == 'unpaid' %}cell-unpaid{% elif t.status == 'surplus' %}cell-overridden{% endif %}" style="padding-top: 4px; padding-bottom: 4px;">
|
||||||
|
<span style="font-size: 0.6em; font-weight: normal; color: #666; text-transform: lowercase; display: block; margin-bottom: 2px;">received / expected</span>
|
||||||
|
{{ t.text }}
|
||||||
|
</td>
|
||||||
|
{% endfor %}
|
||||||
|
<td></td>
|
||||||
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -508,25 +595,6 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if unmatched %}
|
|
||||||
<h2>Unmatched Transactions</h2>
|
|
||||||
<div class="list-container">
|
|
||||||
<div class="unmatched-row unmatched-header">
|
|
||||||
<span>Date</span>
|
|
||||||
<span>Amount</span>
|
|
||||||
<span>Sender</span>
|
|
||||||
<span>Message</span>
|
|
||||||
</div>
|
|
||||||
{% for tx in unmatched %}
|
|
||||||
<div class="unmatched-row">
|
|
||||||
<span>{{ tx.date }}</span>
|
|
||||||
<span>{{ tx.amount }}</span>
|
|
||||||
<span>{{ tx.sender }}</span>
|
|
||||||
<span>{{ tx.message }}</span>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<!-- QR Code Modal -->
|
<!-- QR Code Modal -->
|
||||||
<div id="qrModal" class="modal"
|
<div id="qrModal" class="modal"
|
||||||
@@ -599,7 +667,7 @@
|
|||||||
{% set rt = get_render_time() %}
|
{% set rt = get_render_time() %}
|
||||||
<div class="footer"
|
<div class="footer"
|
||||||
onclick="document.getElementById('perf-details').style.display = (document.getElementById('perf-details').style.display === 'block' ? 'none' : 'block')">
|
onclick="document.getElementById('perf-details').style.display = (document.getElementById('perf-details').style.display === 'block' ? 'none' : 'block')">
|
||||||
render time: {{ rt.total }}s
|
{{ build_meta.tag }}@{{ build_meta.commit }} | render time: {{ rt.total }}s
|
||||||
<div id="perf-details" class="perf-breakdown">
|
<div id="perf-details" class="perf-breakdown">
|
||||||
{{ rt.breakdown }}
|
{{ rt.breakdown }}
|
||||||
</div>
|
</div>
|
||||||
@@ -638,9 +706,9 @@
|
|||||||
let status = '-';
|
let status = '-';
|
||||||
let statusClass = '';
|
let statusClass = '';
|
||||||
if (expected > 0 || paid > 0) {
|
if (expected > 0 || paid > 0) {
|
||||||
if (paid >= expected && expected > 0) { status = 'OK'; statusClass = 'cell-ok'; }
|
if (paid >= expected && expected > 0) { status = paid + '/' + expected; statusClass = 'cell-ok'; }
|
||||||
else if (paid > 0) { status = paid + '/' + expected; }
|
else if (paid > 0) { status = paid + '/' + expected; }
|
||||||
else { status = 'UNPAID ' + expected; statusClass = 'cell-unpaid'; }
|
else { status = '0/' + expected; statusClass = 'cell-unpaid'; }
|
||||||
}
|
}
|
||||||
|
|
||||||
const expectedCell = mdata.exception
|
const expectedCell = mdata.exception
|
||||||
@@ -809,9 +877,13 @@
|
|||||||
showMemberDetails(nextName);
|
showMemberDetails(nextName);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function showPayQR(name, amount, month) {
|
function showPayQR(name, amount, month, rawMonth) {
|
||||||
const account = "{{ bank_account }}";
|
const account = "{{ bank_account }}";
|
||||||
const message = `${name} / ${month}`;
|
// Convert YYYY-MM to MM/YYYY for infer_payments.py compatibility
|
||||||
|
const numericMonth = rawMonth.includes('+')
|
||||||
|
? rawMonth.split('+').map(p => p.replace(/(\d{4})-(\d{2})/, '$2/$1')).join('+')
|
||||||
|
: rawMonth.replace(/(\d{4})-(\d{2})/, '$2/$1');
|
||||||
|
const message = `${name}: ${numericMonth}`;
|
||||||
const qrTitle = document.getElementById('qrTitle');
|
const qrTitle = document.getElementById('qrTitle');
|
||||||
const qrImg = document.getElementById('qrImg');
|
const qrImg = document.getElementById('qrImg');
|
||||||
const qrAccount = document.getElementById('qrAccount');
|
const qrAccount = document.getElementById('qrAccount');
|
||||||
@@ -836,6 +908,64 @@
|
|||||||
event.target.style.display = 'none';
|
event.target.style.display = 'none';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Month range filter
|
||||||
|
var maxMonthIdx;
|
||||||
|
|
||||||
|
function applyMonthFilter() {
|
||||||
|
var fromIdx = parseInt(document.getElementById('fromMonth').value);
|
||||||
|
var toIdx = parseInt(document.getElementById('toMonth').value);
|
||||||
|
document.querySelectorAll('[data-month-idx]').forEach(function(el) {
|
||||||
|
var idx = parseInt(el.getAttribute('data-month-idx'));
|
||||||
|
if (idx >= fromIdx && idx <= toIdx) {
|
||||||
|
el.classList.remove('month-hidden');
|
||||||
|
} else {
|
||||||
|
el.classList.add('month-hidden');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetMonthFilter() {
|
||||||
|
var fromSelect = document.getElementById('fromMonth');
|
||||||
|
var toSelect = document.getElementById('toMonth');
|
||||||
|
fromSelect.value = 0;
|
||||||
|
toSelect.value = maxMonthIdx;
|
||||||
|
applyMonthFilter();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove future months from selects, set defaults, apply on load
|
||||||
|
(function() {
|
||||||
|
var now = new Date();
|
||||||
|
var currentMonth = now.getFullYear() + '-' + String(now.getMonth() + 1).padStart(2, '0');
|
||||||
|
maxMonthIdx = sortedMonths.length - 1;
|
||||||
|
for (var i = 0; i < sortedMonths.length; i++) {
|
||||||
|
if (sortedMonths[i] > currentMonth) {
|
||||||
|
maxMonthIdx = i - 1;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var fromSelect = document.getElementById('fromMonth');
|
||||||
|
var toSelect = document.getElementById('toMonth');
|
||||||
|
|
||||||
|
// Remove future month options
|
||||||
|
for (var i = fromSelect.options.length - 1; i > maxMonthIdx; i--) {
|
||||||
|
fromSelect.remove(i);
|
||||||
|
toSelect.remove(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide future month columns permanently
|
||||||
|
document.querySelectorAll('[data-month-idx]').forEach(function(el) {
|
||||||
|
if (parseInt(el.getAttribute('data-month-idx')) > maxMonthIdx) {
|
||||||
|
el.classList.add('month-hidden');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
var defaultFrom = Math.max(0, maxMonthIdx - 4);
|
||||||
|
fromSelect.value = defaultFrom;
|
||||||
|
toSelect.value = maxMonthIdx;
|
||||||
|
applyMonthFilter();
|
||||||
|
})();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
@@ -45,8 +45,16 @@
|
|||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #555;
|
color: #555;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav > div {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 15px;
|
gap: 15px;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav a {
|
.nav a {
|
||||||
@@ -67,6 +75,23 @@
|
|||||||
border-color: #555;
|
border-color: #555;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nav-archived a {
|
||||||
|
font-size: 10px;
|
||||||
|
color: #666;
|
||||||
|
border-color: #222;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-archived a.active {
|
||||||
|
color: #ccc;
|
||||||
|
background-color: #333;
|
||||||
|
border-color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-archived a:hover {
|
||||||
|
color: #999;
|
||||||
|
border-color: #444;
|
||||||
|
}
|
||||||
|
|
||||||
.description {
|
.description {
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@@ -159,11 +184,19 @@
|
|||||||
|
|
||||||
<body>
|
<body>
|
||||||
<div class="nav">
|
<div class="nav">
|
||||||
<a href="/fees">[Adult - Attendance/Fees]</a>
|
<div>
|
||||||
<a href="/fees-juniors">[Junior Attendance/Fees]</a>
|
<a href="/adults">[Adults]</a>
|
||||||
<a href="/reconcile">[Adult Payment Reconciliation]</a>
|
<a href="/juniors">[Juniors]</a>
|
||||||
<a href="/reconcile-juniors">[Junior Payment Reconciliation]</a>
|
</div>
|
||||||
<a href="/payments" class="active">[Payments Ledger]</a>
|
<div class="nav-archived">
|
||||||
|
<span style="color: #666; margin-right: 5px;">Archived:</span>
|
||||||
|
<a href="/payments" class="active">[Payments Ledger]</a>
|
||||||
|
</div>
|
||||||
|
<div class="nav-archived">
|
||||||
|
<span style="color: #666; margin-right: 5px;">Tools:</span>
|
||||||
|
<a href="/sync-bank">[Sync Bank Data]</a>
|
||||||
|
<a href="/flush-cache">[Flush Cache]</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h1>Payments Ledger</h1>
|
<h1>Payments Ledger</h1>
|
||||||
@@ -205,7 +238,7 @@
|
|||||||
{% set rt = get_render_time() %}
|
{% set rt = get_render_time() %}
|
||||||
<div class="footer"
|
<div class="footer"
|
||||||
onclick="document.getElementById('perf-details').style.display = (document.getElementById('perf-details').style.display === 'block' ? 'none' : 'block')">
|
onclick="document.getElementById('perf-details').style.display = (document.getElementById('perf-details').style.display === 'block' ? 'none' : 'block')">
|
||||||
render time: {{ rt.total }}s
|
{{ build_meta.tag }}@{{ build_meta.commit }} | render time: {{ rt.total }}s
|
||||||
<div id="perf-details" class="perf-breakdown">
|
<div id="perf-details" class="perf-breakdown">
|
||||||
{{ rt.breakdown }}
|
{{ rt.breakdown }}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
153
templates/sync.html
Normal file
153
templates/sync.html
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>FUJ - Sync Bank Data</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||||
|
background-color: #0c0c0c;
|
||||||
|
color: #cccccc;
|
||||||
|
padding: 10px;
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
color: #00ff00;
|
||||||
|
font-family: inherit;
|
||||||
|
margin-top: 10px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #555;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav > div {
|
||||||
|
display: flex;
|
||||||
|
gap: 15px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav a {
|
||||||
|
color: #00ff00;
|
||||||
|
text-decoration: none;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border: 1px solid #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav a.active {
|
||||||
|
color: #000;
|
||||||
|
background-color: #00ff00;
|
||||||
|
border-color: #00ff00;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav a:hover {
|
||||||
|
color: #fff;
|
||||||
|
border-color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-archived a {
|
||||||
|
font-size: 10px;
|
||||||
|
color: #666;
|
||||||
|
border-color: #222;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-archived a.active {
|
||||||
|
color: #ccc;
|
||||||
|
background-color: #333;
|
||||||
|
border-color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-archived a:hover {
|
||||||
|
color: #999;
|
||||||
|
border-color: #444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.output-container {
|
||||||
|
background-color: #111;
|
||||||
|
border: 1px solid #333;
|
||||||
|
padding: 15px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1200px;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.output-container pre {
|
||||||
|
margin: 0;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-wrap: break-word;
|
||||||
|
color: {% if success %}#cccccc{% else %}#ff6666{% endif %};
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
margin-bottom: 15px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-ok { color: #00ff00; }
|
||||||
|
.status-error { color: #ff6666; }
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
text-align: center;
|
||||||
|
color: #333;
|
||||||
|
margin-top: 20px;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div class="nav">
|
||||||
|
<div>
|
||||||
|
<a href="/adults">[Adults]</a>
|
||||||
|
<a href="/juniors">[Juniors]</a>
|
||||||
|
</div>
|
||||||
|
<div class="nav-archived">
|
||||||
|
<span style="color: #666; margin-right: 5px;">Archived:</span>
|
||||||
|
<a href="/payments">[Payments Ledger]</a>
|
||||||
|
</div>
|
||||||
|
<div class="nav-archived">
|
||||||
|
<span style="color: #666; margin-right: 5px;">Tools:</span>
|
||||||
|
<a href="/sync-bank" class="active">[Sync Bank Data]</a>
|
||||||
|
<a href="/flush-cache">[Flush Cache]</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1>Sync Bank Data</h1>
|
||||||
|
|
||||||
|
<div class="status">
|
||||||
|
{% if success %}
|
||||||
|
<span class="status-ok">Sync completed successfully.</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="status-error">Sync failed - see output below.</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="output-container">
|
||||||
|
<pre>{{ output }}</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
{{ build_meta.tag }} | {{ build_meta.commit }} | {{ build_meta.build_date }}
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
@@ -1,24 +1,29 @@
|
|||||||
import unittest
|
import unittest
|
||||||
from unittest.mock import patch, MagicMock
|
from unittest.mock import patch
|
||||||
from app import app
|
from app import app
|
||||||
|
|
||||||
|
|
||||||
|
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."""
|
||||||
|
return fetch_func(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
class TestWebApp(unittest.TestCase):
|
class TestWebApp(unittest.TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
# Configure app for testing
|
|
||||||
app.config['TESTING'] = True
|
app.config['TESTING'] = True
|
||||||
self.client = app.test_client()
|
self.client = app.test_client()
|
||||||
|
|
||||||
@patch('app.get_members_with_fees')
|
def test_index_page(self):
|
||||||
def test_index_page(self, mock_get_members):
|
|
||||||
"""Test that / returns the refresh meta tag"""
|
"""Test that / returns the refresh meta tag"""
|
||||||
response = self.client.get('/')
|
response = self.client.get('/')
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertIn(b'url=/fees', response.data)
|
self.assertIn(b'url=/adults', response.data)
|
||||||
|
|
||||||
|
@patch('app.get_cached_data', side_effect=_bypass_cache)
|
||||||
@patch('app.get_members_with_fees')
|
@patch('app.get_members_with_fees')
|
||||||
def test_fees_route(self, mock_get_members):
|
@patch('app.fetch_exceptions', return_value={})
|
||||||
|
def test_fees_route(self, mock_exceptions, mock_get_members, mock_cache):
|
||||||
"""Test that /fees returns 200 and renders the dashboard"""
|
"""Test that /fees returns 200 and renders the dashboard"""
|
||||||
# Mock attendance data
|
|
||||||
mock_get_members.return_value = (
|
mock_get_members.return_value = (
|
||||||
[('Test Member', 'A', {'2026-01': (750, 4)})],
|
[('Test Member', 'A', {'2026-01': (750, 4)})],
|
||||||
['2026-01']
|
['2026-01']
|
||||||
@@ -29,10 +34,11 @@ class TestWebApp(unittest.TestCase):
|
|||||||
self.assertIn(b'FUJ Fees Dashboard', response.data)
|
self.assertIn(b'FUJ Fees Dashboard', response.data)
|
||||||
self.assertIn(b'Test Member', response.data)
|
self.assertIn(b'Test Member', response.data)
|
||||||
|
|
||||||
|
@patch('app.get_cached_data', side_effect=_bypass_cache)
|
||||||
@patch('app.get_junior_members_with_fees')
|
@patch('app.get_junior_members_with_fees')
|
||||||
def test_fees_juniors_route(self, mock_get_junior_members):
|
@patch('app.fetch_exceptions', return_value={})
|
||||||
|
def test_fees_juniors_route(self, mock_exceptions, mock_get_junior_members, mock_cache):
|
||||||
"""Test that /fees-juniors returns 200 and renders the junior dashboard"""
|
"""Test that /fees-juniors returns 200 and renders the junior dashboard"""
|
||||||
# Mock attendance data: one with string symbol '?', one with integer
|
|
||||||
mock_get_junior_members.return_value = (
|
mock_get_junior_members.return_value = (
|
||||||
[
|
[
|
||||||
('Test Junior 1', 'J', {'2026-01': ('?', 1, 0, 1)}),
|
('Test Junior 1', 'J', {'2026-01': ('?', 1, 0, 1)}),
|
||||||
@@ -48,16 +54,16 @@ class TestWebApp(unittest.TestCase):
|
|||||||
self.assertIn(b'? / 1 (J)', response.data)
|
self.assertIn(b'? / 1 (J)', response.data)
|
||||||
self.assertIn(b'500 CZK / 4 (1A+3J)', response.data)
|
self.assertIn(b'500 CZK / 4 (1A+3J)', response.data)
|
||||||
|
|
||||||
|
@patch('app.get_cached_data', side_effect=_bypass_cache)
|
||||||
@patch('app.fetch_sheet_data')
|
@patch('app.fetch_sheet_data')
|
||||||
|
@patch('app.fetch_exceptions', return_value={})
|
||||||
@patch('app.get_members_with_fees')
|
@patch('app.get_members_with_fees')
|
||||||
def test_reconcile_route(self, mock_get_members, mock_fetch_sheet):
|
def test_reconcile_route(self, mock_get_members, mock_exceptions, mock_fetch_sheet, mock_cache):
|
||||||
"""Test that /reconcile returns 200 and shows matches"""
|
"""Test that /reconcile returns 200 and shows matches"""
|
||||||
# Mock attendance data
|
|
||||||
mock_get_members.return_value = (
|
mock_get_members.return_value = (
|
||||||
[('Test Member', 'A', {'2026-01': (750, 4)})],
|
[('Test Member', 'A', {'2026-01': (750, 4)})],
|
||||||
['2026-01']
|
['2026-01']
|
||||||
)
|
)
|
||||||
# Mock sheet data - include all keys required by reconcile
|
|
||||||
mock_fetch_sheet.return_value = [{
|
mock_fetch_sheet.return_value = [{
|
||||||
'date': '2026-01-01',
|
'date': '2026-01-01',
|
||||||
'amount': 750,
|
'amount': 750,
|
||||||
@@ -74,10 +80,10 @@ class TestWebApp(unittest.TestCase):
|
|||||||
self.assertIn(b'Test Member', response.data)
|
self.assertIn(b'Test Member', response.data)
|
||||||
self.assertIn(b'OK', response.data)
|
self.assertIn(b'OK', response.data)
|
||||||
|
|
||||||
|
@patch('app.get_cached_data', side_effect=_bypass_cache)
|
||||||
@patch('app.fetch_sheet_data')
|
@patch('app.fetch_sheet_data')
|
||||||
def test_payments_route(self, mock_fetch_sheet):
|
def test_payments_route(self, mock_fetch_sheet, mock_cache):
|
||||||
"""Test that /payments returns 200 and groups transactions"""
|
"""Test that /payments returns 200 and groups transactions"""
|
||||||
# Mock sheet data
|
|
||||||
mock_fetch_sheet.return_value = [{
|
mock_fetch_sheet.return_value = [{
|
||||||
'date': '2026-01-01',
|
'date': '2026-01-01',
|
||||||
'amount': 750,
|
'amount': 750,
|
||||||
@@ -92,10 +98,11 @@ class TestWebApp(unittest.TestCase):
|
|||||||
self.assertIn(b'Test Member', response.data)
|
self.assertIn(b'Test Member', response.data)
|
||||||
self.assertIn(b'Direct Member Payment', response.data)
|
self.assertIn(b'Direct Member Payment', response.data)
|
||||||
|
|
||||||
|
@patch('app.get_cached_data', side_effect=_bypass_cache)
|
||||||
@patch('app.fetch_sheet_data')
|
@patch('app.fetch_sheet_data')
|
||||||
@patch('app.fetch_exceptions')
|
@patch('app.fetch_exceptions')
|
||||||
@patch('app.get_junior_members_with_fees')
|
@patch('app.get_junior_members_with_fees')
|
||||||
def test_reconcile_juniors_route(self, mock_get_junior, mock_exceptions, mock_transactions):
|
def test_reconcile_juniors_route(self, mock_get_junior, mock_exceptions, mock_transactions, mock_cache):
|
||||||
"""Test that /reconcile-juniors correctly computes balances for juniors."""
|
"""Test that /reconcile-juniors correctly computes balances for juniors."""
|
||||||
mock_get_junior.return_value = (
|
mock_get_junior.return_value = (
|
||||||
[
|
[
|
||||||
@@ -123,5 +130,65 @@ class TestWebApp(unittest.TestCase):
|
|||||||
self.assertIn(b'OK', response.data)
|
self.assertIn(b'OK', response.data)
|
||||||
self.assertIn(b'?', response.data)
|
self.assertIn(b'?', response.data)
|
||||||
|
|
||||||
|
@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_adults_route(self, mock_get_members, mock_exceptions, mock_fetch_sheet, mock_cache):
|
||||||
|
"""Test that /adults returns 200 and shows combined matches"""
|
||||||
|
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('/adults')
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertIn(b'Adults Dashboard', response.data)
|
||||||
|
self.assertIn(b'Test Member', response.data)
|
||||||
|
self.assertNotIn(b'OK', response.data)
|
||||||
|
self.assertIn(b'750/750 CZK (4)', response.data)
|
||||||
|
|
||||||
|
@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_juniors_route(self, mock_get_junior_members, mock_exceptions, mock_fetch_sheet, mock_cache):
|
||||||
|
"""Test that /juniors returns 200, uses single line format, and displays '?' properly"""
|
||||||
|
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_exceptions.return_value = {}
|
||||||
|
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('/juniors')
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertIn(b'Juniors Dashboard', response.data)
|
||||||
|
self.assertIn(b'Junior One', response.data)
|
||||||
|
self.assertIn(b'Junior Two', response.data)
|
||||||
|
self.assertNotIn(b'OK', response.data)
|
||||||
|
self.assertIn(b'500/500 CZK', response.data)
|
||||||
|
self.assertIn(b'?', response.data)
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
161
uv.lock
generated
161
uv.lock
generated
@@ -127,6 +127,75 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "coverage"
|
||||||
|
version = "7.13.4"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/24/56/95b7e30fa389756cb56630faa728da46a27b8c6eb46f9d557c68fff12b65/coverage-7.13.4.tar.gz", hash = "sha256:e5c8f6ed1e61a8b2dcdf31eb0b9bbf0130750ca79c1c49eb898e2ad86f5ccc91", size = 827239, upload-time = "2026-02-09T12:59:03.86Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/db/23/aad45061a31677d68e47499197a131eea55da4875d16c1f42021ab963503/coverage-7.13.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b66a2da594b6068b48b2692f043f35d4d3693fb639d5ea8b39533c2ad9ac3ab9", size = 219474, upload-time = "2026-02-09T12:57:19.332Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a5/70/9b8b67a0945f3dfec1fd896c5cefb7c19d5a3a6d74630b99a895170999ae/coverage-7.13.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3599eb3992d814d23b35c536c28df1a882caa950f8f507cef23d1cbf334995ac", size = 219844, upload-time = "2026-02-09T12:57:20.66Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/97/fd/7e859f8fab324cef6c4ad7cff156ca7c489fef9179d5749b0c8d321281c2/coverage-7.13.4-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:93550784d9281e374fb5a12bf1324cc8a963fd63b2d2f223503ef0fd4aa339ea", size = 250832, upload-time = "2026-02-09T12:57:22.007Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e4/dc/b2442d10020c2f52617828862d8b6ee337859cd8f3a1f13d607dddda9cf7/coverage-7.13.4-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b720ce6a88a2755f7c697c23268ddc47a571b88052e6b155224347389fdf6a3b", size = 253434, upload-time = "2026-02-09T12:57:23.339Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5a/88/6728a7ad17428b18d836540630487231f5470fb82454871149502f5e5aa2/coverage-7.13.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7b322db1284a2ed3aa28ffd8ebe3db91c929b7a333c0820abec3d838ef5b3525", size = 254676, upload-time = "2026-02-09T12:57:24.774Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7c/bc/21244b1b8cedf0dff0a2b53b208015fe798d5f2a8d5348dbfece04224fff/coverage-7.13.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f4594c67d8a7c89cf922d9df0438c7c7bb022ad506eddb0fdb2863359ff78242", size = 256807, upload-time = "2026-02-09T12:57:26.125Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/97/a0/ddba7ed3251cff51006737a727d84e05b61517d1784a9988a846ba508877/coverage-7.13.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:53d133df809c743eb8bce33b24bcababb371f4441340578cd406e084d94a6148", size = 251058, upload-time = "2026-02-09T12:57:27.614Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9b/55/e289addf7ff54d3a540526f33751951bf0878f3809b47f6dfb3def69c6f7/coverage-7.13.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76451d1978b95ba6507a039090ba076105c87cc76fc3efd5d35d72093964d49a", size = 252805, upload-time = "2026-02-09T12:57:29.066Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/13/4e/cc276b1fa4a59be56d96f1dabddbdc30f4ba22e3b1cd42504c37b3313255/coverage-7.13.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7f57b33491e281e962021de110b451ab8a24182589be17e12a22c79047935e23", size = 250766, upload-time = "2026-02-09T12:57:30.522Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/94/44/1093b8f93018f8b41a8cf29636c9292502f05e4a113d4d107d14a3acd044/coverage-7.13.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1731dc33dc276dafc410a885cbf5992f1ff171393e48a21453b78727d090de80", size = 254923, upload-time = "2026-02-09T12:57:31.946Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8b/55/ea2796da2d42257f37dbea1aab239ba9263b31bd91d5527cdd6db5efe174/coverage-7.13.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:bd60d4fe2f6fa7dff9223ca1bbc9f05d2b6697bc5961072e5d3b952d46e1b1ea", size = 250591, upload-time = "2026-02-09T12:57:33.842Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d4/fa/7c4bb72aacf8af5020675aa633e59c1fbe296d22aed191b6a5b711eb2bc7/coverage-7.13.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9181a3ccead280b828fae232df12b16652702b49d41e99d657f46cc7b1f6ec7a", size = 252364, upload-time = "2026-02-09T12:57:35.743Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5c/38/a8d2ec0146479c20bbaa7181b5b455a0c41101eed57f10dd19a78ab44c80/coverage-7.13.4-cp313-cp313-win32.whl", hash = "sha256:f53d492307962561ac7de4cd1de3e363589b000ab69617c6156a16ba7237998d", size = 222010, upload-time = "2026-02-09T12:57:37.25Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e2/0c/dbfafbe90a185943dcfbc766fe0e1909f658811492d79b741523a414a6cc/coverage-7.13.4-cp313-cp313-win_amd64.whl", hash = "sha256:e6f70dec1cc557e52df5306d051ef56003f74d56e9c4dd7ddb07e07ef32a84dd", size = 222818, upload-time = "2026-02-09T12:57:38.734Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/04/d1/934918a138c932c90d78301f45f677fb05c39a3112b96fd2c8e60503cdc7/coverage-7.13.4-cp313-cp313-win_arm64.whl", hash = "sha256:fb07dc5da7e849e2ad31a5d74e9bece81f30ecf5a42909d0a695f8bd1874d6af", size = 221438, upload-time = "2026-02-09T12:57:40.223Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/52/57/ee93ced533bcb3e6df961c0c6e42da2fc6addae53fb95b94a89b1e33ebd7/coverage-7.13.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:40d74da8e6c4b9ac18b15331c4b5ebc35a17069410cad462ad4f40dcd2d50c0d", size = 220165, upload-time = "2026-02-09T12:57:41.639Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c5/e0/969fc285a6fbdda49d91af278488d904dcd7651b2693872f0ff94e40e84a/coverage-7.13.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4223b4230a376138939a9173f1bdd6521994f2aff8047fae100d6d94d50c5a12", size = 220516, upload-time = "2026-02-09T12:57:44.215Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b1/b8/9531944e16267e2735a30a9641ff49671f07e8138ecf1ca13db9fd2560c7/coverage-7.13.4-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1d4be36a5114c499f9f1f9195e95ebf979460dbe2d88e6816ea202010ba1c34b", size = 261804, upload-time = "2026-02-09T12:57:45.989Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8a/f3/e63df6d500314a2a60390d1989240d5f27318a7a68fa30ad3806e2a9323e/coverage-7.13.4-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:200dea7d1e8095cc6e98cdabe3fd1d21ab17d3cee6dab00cadbb2fe35d9c15b9", size = 263885, upload-time = "2026-02-09T12:57:47.42Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f3/67/7654810de580e14b37670b60a09c599fa348e48312db5b216d730857ffe6/coverage-7.13.4-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8eb931ee8e6d8243e253e5ed7336deea6904369d2fd8ae6e43f68abbf167092", size = 266308, upload-time = "2026-02-09T12:57:49.345Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/37/6f/39d41eca0eab3cc82115953ad41c4e77935286c930e8fad15eaed1389d83/coverage-7.13.4-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:75eab1ebe4f2f64d9509b984f9314d4aa788540368218b858dad56dc8f3e5eb9", size = 267452, upload-time = "2026-02-09T12:57:50.811Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/50/6d/39c0fbb8fc5cd4d2090811e553c2108cf5112e882f82505ee7495349a6bf/coverage-7.13.4-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c35eb28c1d085eb7d8c9b3296567a1bebe03ce72962e932431b9a61f28facf26", size = 261057, upload-time = "2026-02-09T12:57:52.447Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a4/a2/60010c669df5fa603bb5a97fb75407e191a846510da70ac657eb696b7fce/coverage-7.13.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:eb88b316ec33760714a4720feb2816a3a59180fd58c1985012054fa7aebee4c2", size = 263875, upload-time = "2026-02-09T12:57:53.938Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3e/d9/63b22a6bdbd17f1f96e9ed58604c2a6b0e72a9133e37d663bef185877cf6/coverage-7.13.4-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7d41eead3cc673cbd38a4417deb7fd0b4ca26954ff7dc6078e33f6ff97bed940", size = 261500, upload-time = "2026-02-09T12:57:56.012Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/70/bf/69f86ba1ad85bc3ad240e4c0e57a2e620fbc0e1645a47b5c62f0e941ad7f/coverage-7.13.4-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:fb26a934946a6afe0e326aebe0730cdff393a8bc0bbb65a2f41e30feddca399c", size = 265212, upload-time = "2026-02-09T12:57:57.5Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ae/f2/5f65a278a8c2148731831574c73e42f57204243d33bedaaf18fa79c5958f/coverage-7.13.4-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:dae88bc0fc77edaa65c14be099bd57ee140cf507e6bfdeea7938457ab387efb0", size = 260398, upload-time = "2026-02-09T12:57:59.027Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ef/80/6e8280a350ee9fea92f14b8357448a242dcaa243cb2c72ab0ca591f66c8c/coverage-7.13.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:845f352911777a8e722bfce168958214951e07e47e5d5d9744109fa5fe77f79b", size = 262584, upload-time = "2026-02-09T12:58:01.129Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/22/63/01ff182fc95f260b539590fb12c11ad3e21332c15f9799cb5e2386f71d9f/coverage-7.13.4-cp313-cp313t-win32.whl", hash = "sha256:2fa8d5f8de70688a28240de9e139fa16b153cc3cbb01c5f16d88d6505ebdadf9", size = 222688, upload-time = "2026-02-09T12:58:02.736Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a9/43/89de4ef5d3cd53b886afa114065f7e9d3707bdb3e5efae13535b46ae483d/coverage-7.13.4-cp313-cp313t-win_amd64.whl", hash = "sha256:9351229c8c8407645840edcc277f4a2d44814d1bc34a2128c11c2a031d45a5dd", size = 223746, upload-time = "2026-02-09T12:58:05.362Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/35/39/7cf0aa9a10d470a5309b38b289b9bb07ddeac5d61af9b664fe9775a4cb3e/coverage-7.13.4-cp313-cp313t-win_arm64.whl", hash = "sha256:30b8d0512f2dc8c8747557e8fb459d6176a2c9e5731e2b74d311c03b78451997", size = 222003, upload-time = "2026-02-09T12:58:06.952Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/92/11/a9cf762bb83386467737d32187756a42094927150c3e107df4cb078e8590/coverage-7.13.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:300deaee342f90696ed186e3a00c71b5b3d27bffe9e827677954f4ee56969601", size = 219522, upload-time = "2026-02-09T12:58:08.623Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d3/28/56e6d892b7b052236d67c95f1936b6a7cf7c3e2634bf27610b8cbd7f9c60/coverage-7.13.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:29e3220258d682b6226a9b0925bc563ed9a1ebcff3cad30f043eceea7eaf2689", size = 219855, upload-time = "2026-02-09T12:58:10.176Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e5/69/233459ee9eb0c0d10fcc2fe425a029b3fa5ce0f040c966ebce851d030c70/coverage-7.13.4-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:391ee8f19bef69210978363ca930f7328081c6a0152f1166c91f0b5fdd2a773c", size = 250887, upload-time = "2026-02-09T12:58:12.503Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/06/90/2cdab0974b9b5bbc1623f7876b73603aecac11b8d95b85b5b86b32de5eab/coverage-7.13.4-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0dd7ab8278f0d58a0128ba2fca25824321f05d059c1441800e934ff2efa52129", size = 253396, upload-time = "2026-02-09T12:58:14.615Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ac/15/ea4da0f85bf7d7b27635039e649e99deb8173fe551096ea15017f7053537/coverage-7.13.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78cdf0d578b15148b009ccf18c686aa4f719d887e76e6b40c38ffb61d264a552", size = 254745, upload-time = "2026-02-09T12:58:16.162Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/99/11/bb356e86920c655ca4d61daee4e2bbc7258f0a37de0be32d233b561134ff/coverage-7.13.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:48685fee12c2eb3b27c62f2658e7ea21e9c3239cba5a8a242801a0a3f6a8c62a", size = 257055, upload-time = "2026-02-09T12:58:17.892Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c9/0f/9ae1f8cb17029e09da06ca4e28c9e1d5c1c0a511c7074592e37e0836c915/coverage-7.13.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4e83efc079eb39480e6346a15a1bcb3e9b04759c5202d157e1dd4303cd619356", size = 250911, upload-time = "2026-02-09T12:58:19.495Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/89/3a/adfb68558fa815cbc29747b553bc833d2150228f251b127f1ce97e48547c/coverage-7.13.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ecae9737b72408d6a950f7e525f30aca12d4bd8dd95e37342e5beb3a2a8c4f71", size = 252754, upload-time = "2026-02-09T12:58:21.064Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/32/b1/540d0c27c4e748bd3cd0bd001076ee416eda993c2bae47a73b7cc9357931/coverage-7.13.4-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ae4578f8528569d3cf303fef2ea569c7f4c4059a38c8667ccef15c6e1f118aa5", size = 250720, upload-time = "2026-02-09T12:58:22.622Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c7/95/383609462b3ffb1fe133014a7c84fc0dd01ed55ac6140fa1093b5af7ebb1/coverage-7.13.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:6fdef321fdfbb30a197efa02d48fcd9981f0d8ad2ae8903ac318adc653f5df98", size = 254994, upload-time = "2026-02-09T12:58:24.548Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f7/ba/1761138e86c81680bfc3c49579d66312865457f9fe405b033184e5793cb3/coverage-7.13.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b0f6ccf3dbe577170bebfce1318707d0e8c3650003cb4b3a9dd744575daa8b5", size = 250531, upload-time = "2026-02-09T12:58:26.271Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f8/8e/05900df797a9c11837ab59c4d6fe94094e029582aab75c3309a93e6fb4e3/coverage-7.13.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75fcd519f2a5765db3f0e391eb3b7d150cce1a771bf4c9f861aeab86c767a3c0", size = 252189, upload-time = "2026-02-09T12:58:27.807Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/00/bd/29c9f2db9ea4ed2738b8a9508c35626eb205d51af4ab7bf56a21a2e49926/coverage-7.13.4-cp314-cp314-win32.whl", hash = "sha256:8e798c266c378da2bd819b0677df41ab46d78065fb2a399558f3f6cae78b2fbb", size = 222258, upload-time = "2026-02-09T12:58:29.441Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a7/4d/1f8e723f6829977410efeb88f73673d794075091c8c7c18848d273dc9d73/coverage-7.13.4-cp314-cp314-win_amd64.whl", hash = "sha256:245e37f664d89861cf2329c9afa2c1fe9e6d4e1a09d872c947e70718aeeac505", size = 223073, upload-time = "2026-02-09T12:58:31.026Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/51/5b/84100025be913b44e082ea32abcf1afbf4e872f5120b7a1cab1d331b1e13/coverage-7.13.4-cp314-cp314-win_arm64.whl", hash = "sha256:ad27098a189e5838900ce4c2a99f2fe42a0bf0c2093c17c69b45a71579e8d4a2", size = 221638, upload-time = "2026-02-09T12:58:32.599Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a7/e4/c884a405d6ead1370433dad1e3720216b4f9fd8ef5b64bfd984a2a60a11a/coverage-7.13.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:85480adfb35ffc32d40918aad81b89c69c9cc5661a9b8a81476d3e645321a056", size = 220246, upload-time = "2026-02-09T12:58:34.181Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/81/5c/4d7ed8b23b233b0fffbc9dfec53c232be2e695468523242ea9fd30f97ad2/coverage-7.13.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:79be69cf7f3bf9b0deeeb062eab7ac7f36cd4cc4c4dd694bd28921ba4d8596cc", size = 220514, upload-time = "2026-02-09T12:58:35.704Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2f/6f/3284d4203fd2f28edd73034968398cd2d4cb04ab192abc8cff007ea35679/coverage-7.13.4-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:caa421e2684e382c5d8973ac55e4f36bed6821a9bad5c953494de960c74595c9", size = 261877, upload-time = "2026-02-09T12:58:37.864Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/09/aa/b672a647bbe1556a85337dc95bfd40d146e9965ead9cc2fe81bde1e5cbce/coverage-7.13.4-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:14375934243ee05f56c45393fe2ce81fe5cc503c07cee2bdf1725fb8bef3ffaf", size = 264004, upload-time = "2026-02-09T12:58:39.492Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/79/a1/aa384dbe9181f98bba87dd23dda436f0c6cf2e148aecbb4e50fc51c1a656/coverage-7.13.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25a41c3104d08edb094d9db0d905ca54d0cd41c928bb6be3c4c799a54753af55", size = 266408, upload-time = "2026-02-09T12:58:41.852Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/53/5e/5150bf17b4019bc600799f376bb9606941e55bd5a775dc1e096b6ffea952/coverage-7.13.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f01afcff62bf9a08fb32b2c1d6e924236c0383c02c790732b6537269e466a72", size = 267544, upload-time = "2026-02-09T12:58:44.093Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e0/ed/f1de5c675987a4a7a672250d2c5c9d73d289dbf13410f00ed7181d8017dd/coverage-7.13.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eb9078108fbf0bcdde37c3f4779303673c2fa1fe8f7956e68d447d0dd426d38a", size = 260980, upload-time = "2026-02-09T12:58:45.721Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b3/e3/fe758d01850aa172419a6743fe76ba8b92c29d181d4f676ffe2dae2ba631/coverage-7.13.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e086334e8537ddd17e5f16a344777c1ab8194986ec533711cbe6c41cde841b6", size = 263871, upload-time = "2026-02-09T12:58:47.334Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b6/76/b829869d464115e22499541def9796b25312b8cf235d3bb00b39f1675395/coverage-7.13.4-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:725d985c5ab621268b2edb8e50dfe57633dc69bda071abc470fed55a14935fd3", size = 261472, upload-time = "2026-02-09T12:58:48.995Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/14/9e/caedb1679e73e2f6ad240173f55218488bfe043e38da577c4ec977489915/coverage-7.13.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:3c06f0f1337c667b971ca2f975523347e63ec5e500b9aa5882d91931cd3ef750", size = 265210, upload-time = "2026-02-09T12:58:51.178Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3a/10/0dd02cb009b16ede425b49ec344aba13a6ae1dc39600840ea6abcb085ac4/coverage-7.13.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:590c0ed4bf8e85f745e6b805b2e1c457b2e33d5255dd9729743165253bc9ad39", size = 260319, upload-time = "2026-02-09T12:58:53.081Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/92/8e/234d2c927af27c6d7a5ffad5bd2cf31634c46a477b4c7adfbfa66baf7ebb/coverage-7.13.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:eb30bf180de3f632cd043322dad5751390e5385108b2807368997d1a92a509d0", size = 262638, upload-time = "2026-02-09T12:58:55.258Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2f/64/e5547c8ff6964e5965c35a480855911b61509cce544f4d442caa759a0702/coverage-7.13.4-cp314-cp314t-win32.whl", hash = "sha256:c4240e7eded42d131a2d2c4dec70374b781b043ddc79a9de4d55ca71f8e98aea", size = 223040, upload-time = "2026-02-09T12:58:56.936Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c7/96/38086d58a181aac86d503dfa9c47eb20715a79c3e3acbdf786e92e5c09a8/coverage-7.13.4-cp314-cp314t-win_amd64.whl", hash = "sha256:4c7d3cc01e7350f2f0f6f7036caaf5673fb56b6998889ccfe9e1c1fe75a9c932", size = 224148, upload-time = "2026-02-09T12:58:58.645Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ce/72/8d10abd3740a0beb98c305e0c3faf454366221c0f37a8bcf8f60020bb65a/coverage-7.13.4-cp314-cp314t-win_arm64.whl", hash = "sha256:23e3f687cf945070d1c90f85db66d11e3025665d8dafa831301a0e0038f3db9b", size = 222172, upload-time = "2026-02-09T12:59:00.396Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0d/4a/331fe2caf6799d591109bb9c08083080f6de90a823695d412a935622abb2/coverage-7.13.4-py3-none-any.whl", hash = "sha256:1af1641e57cf7ba1bd67d677c9abdbcd6cc2ab7da3bca7fa1e2b7e50e65f2ad0", size = 211242, upload-time = "2026-02-09T12:59:02.032Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cryptography"
|
name = "cryptography"
|
||||||
version = "46.0.5"
|
version = "46.0.5"
|
||||||
@@ -206,18 +275,32 @@ dependencies = [
|
|||||||
{ name = "google-api-python-client" },
|
{ name = "google-api-python-client" },
|
||||||
{ name = "google-auth-httplib2" },
|
{ name = "google-auth-httplib2" },
|
||||||
{ name = "google-auth-oauthlib" },
|
{ name = "google-auth-oauthlib" },
|
||||||
|
{ name = "gunicorn" },
|
||||||
{ name = "qrcode", extra = ["pil"] },
|
{ name = "qrcode", extra = ["pil"] },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[package.dev-dependencies]
|
||||||
|
dev = [
|
||||||
|
{ name = "pytest" },
|
||||||
|
{ name = "pytest-cov" },
|
||||||
|
]
|
||||||
|
|
||||||
[package.metadata]
|
[package.metadata]
|
||||||
requires-dist = [
|
requires-dist = [
|
||||||
{ name = "flask", specifier = ">=3.1.3" },
|
{ name = "flask", specifier = ">=3.1.3" },
|
||||||
{ name = "google-api-python-client", specifier = ">=2.162.0" },
|
{ name = "google-api-python-client", specifier = ">=2.162.0" },
|
||||||
{ name = "google-auth-httplib2", specifier = ">=0.2.0" },
|
{ name = "google-auth-httplib2", specifier = ">=0.2.0" },
|
||||||
{ name = "google-auth-oauthlib", specifier = ">=1.2.1" },
|
{ name = "google-auth-oauthlib", specifier = ">=1.2.1" },
|
||||||
|
{ name = "gunicorn", specifier = ">=23.0" },
|
||||||
{ name = "qrcode", extras = ["pil"], specifier = ">=8.0" },
|
{ name = "qrcode", extras = ["pil"], specifier = ">=8.0" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[package.metadata.requires-dev]
|
||||||
|
dev = [
|
||||||
|
{ name = "pytest", specifier = ">=8.0" },
|
||||||
|
{ name = "pytest-cov", specifier = ">=6.0" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "google-api-core"
|
name = "google-api-core"
|
||||||
version = "2.30.0"
|
version = "2.30.0"
|
||||||
@@ -302,6 +385,18 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/c4/ab/09169d5a4612a5f92490806649ac8d41e3ec9129c636754575b3553f4ea4/googleapis_common_protos-1.72.0-py3-none-any.whl", hash = "sha256:4299c5a82d5ae1a9702ada957347726b167f9f8d1fc352477702a1e851ff4038", size = 297515, upload-time = "2025-11-06T18:29:13.14Z" },
|
{ url = "https://files.pythonhosted.org/packages/c4/ab/09169d5a4612a5f92490806649ac8d41e3ec9129c636754575b3553f4ea4/googleapis_common_protos-1.72.0-py3-none-any.whl", hash = "sha256:4299c5a82d5ae1a9702ada957347726b167f9f8d1fc352477702a1e851ff4038", size = 297515, upload-time = "2025-11-06T18:29:13.14Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "gunicorn"
|
||||||
|
version = "25.1.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "packaging" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/66/13/ef67f59f6a7896fdc2c1d62b5665c5219d6b0a9a1784938eb9a28e55e128/gunicorn-25.1.0.tar.gz", hash = "sha256:1426611d959fa77e7de89f8c0f32eed6aa03ee735f98c01efba3e281b1c47616", size = 594377, upload-time = "2026-02-13T11:09:58.989Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/da/73/4ad5b1f6a2e21cf1e85afdaad2b7b1a933985e2f5d679147a1953aaa192c/gunicorn-25.1.0-py3-none-any.whl", hash = "sha256:d0b1236ccf27f72cfe14bce7caadf467186f19e865094ca84221424e839b8b8b", size = 197067, upload-time = "2026-02-13T11:09:57.146Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "httplib2"
|
name = "httplib2"
|
||||||
version = "0.31.2"
|
version = "0.31.2"
|
||||||
@@ -323,6 +418,15 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
|
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "iniconfig"
|
||||||
|
version = "2.3.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "itsdangerous"
|
name = "itsdangerous"
|
||||||
version = "2.2.0"
|
version = "2.2.0"
|
||||||
@@ -405,6 +509,15 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/be/9c/92789c596b8df838baa98fa71844d84283302f7604ed565dafe5a6b5041a/oauthlib-3.3.1-py3-none-any.whl", hash = "sha256:88119c938d2b8fb88561af5f6ee0eec8cc8d552b7bb1f712743136eb7523b7a1", size = 160065, upload-time = "2025-06-19T22:48:06.508Z" },
|
{ url = "https://files.pythonhosted.org/packages/be/9c/92789c596b8df838baa98fa71844d84283302f7604ed565dafe5a6b5041a/oauthlib-3.3.1-py3-none-any.whl", hash = "sha256:88119c938d2b8fb88561af5f6ee0eec8cc8d552b7bb1f712743136eb7523b7a1", size = 160065, upload-time = "2025-06-19T22:48:06.508Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "packaging"
|
||||||
|
version = "26.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pillow"
|
name = "pillow"
|
||||||
version = "12.1.1"
|
version = "12.1.1"
|
||||||
@@ -463,6 +576,15 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/ec/d2/de599c95ba0a973b94410477f8bf0b6f0b5e67360eb89bcb1ad365258beb/pillow-12.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:7b03048319bfc6170e93bd60728a1af51d3dd7704935feb228c4d4faab35d334", size = 2546446, upload-time = "2026-02-11T04:22:50.342Z" },
|
{ url = "https://files.pythonhosted.org/packages/ec/d2/de599c95ba0a973b94410477f8bf0b6f0b5e67360eb89bcb1ad365258beb/pillow-12.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:7b03048319bfc6170e93bd60728a1af51d3dd7704935feb228c4d4faab35d334", size = 2546446, upload-time = "2026-02-11T04:22:50.342Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pluggy"
|
||||||
|
version = "1.6.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "proto-plus"
|
name = "proto-plus"
|
||||||
version = "1.27.1"
|
version = "1.27.1"
|
||||||
@@ -520,6 +642,15 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
|
{ url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pygments"
|
||||||
|
version = "2.19.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pyparsing"
|
name = "pyparsing"
|
||||||
version = "3.3.2"
|
version = "3.3.2"
|
||||||
@@ -529,6 +660,36 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" },
|
{ url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pytest"
|
||||||
|
version = "9.0.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||||
|
{ name = "iniconfig" },
|
||||||
|
{ name = "packaging" },
|
||||||
|
{ name = "pluggy" },
|
||||||
|
{ name = "pygments" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pytest-cov"
|
||||||
|
version = "7.0.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "coverage" },
|
||||||
|
{ name = "pluggy" },
|
||||||
|
{ name = "pytest" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "qrcode"
|
name = "qrcode"
|
||||||
version = "8.2"
|
version = "8.2"
|
||||||
|
|||||||
Reference in New Issue
Block a user