8 Commits

Author SHA1 Message Date
Jan Novak
f40015a2ef fix: mark docs target as .PHONY in Makefile
Some checks failed
Deploy to K8s / deploy (push) Failing after 9s
2026-03-03 14:24:28 +01:00
Jan Novak
5bdc7a4566 feat: add keyboard navigation to member details and fix attendance count
Some checks failed
Deploy to K8s / deploy (push) Failing after 7s
Build and Push / build (push) Successful in 9s
- Users can now navigate between members in the details popup using Up/Down arrows.
- Fixed 0 attendance count in member popup by preserving count in reconciliation.
- Updated uv.lock following dependency changes.

Co-authored-by: Antigravity <antigravity@google.com>
2026-03-03 11:04:50 +01:00
Jan Novak
9ee2dd782d fix: add missing qrcode and pillow dependencies to Dockerfile and pyproject.toml
All checks were successful
Deploy to K8s / deploy (push) Successful in 10s
Build and Push / build (push) Successful in 30s
This fixes the 'ModuleNotFoundError: No module named qrcode' error in the container.
Updated pyproject.toml version to 0.10.

Co-authored-by: Antigravity <antigravity@google.com>
2026-03-02 22:57:15 +01:00
Jan Novak
4bb8c7420c feat: implement local payment QR codes and update AI co-authoring rules
All checks were successful
Deploy to K8s / deploy (push) Successful in 11s
Build and Push / build (push) Successful in 8s
QR codes are now generated locally using the 'qrcode' library for better privacy and reliability.
Updated .agent/rules.md with co-author details and Conventional Commits preference.

Co-authored-by: Antigravity <antigravity@google.com>
2026-03-02 22:54:48 +01:00
Jan Novak
b0276f68b3 feat: add detailed performance profiling with interactive toggle
All checks were successful
Deploy to K8s / deploy (push) Successful in 11s
Build and Push / build (push) Successful in 9s
2026-03-02 22:34:06 +01:00
Jan Novak
7d05e3812c fix: correctly extract exception amount on fees page
All checks were successful
Deploy to K8s / deploy (push) Successful in 7s
Build and Push / build (push) Successful in 9s
2026-03-02 22:23:13 +01:00
Jan Novak
815b962dd7 feat: add member details popup with attendance and fee exceptions
All checks were successful
Deploy to K8s / deploy (push) Successful in 12s
Build and Push / build (push) Successful in 8s
2026-03-02 21:41:36 +01:00
Jan Novak
99b23199b1 feat: improve attendance parsing logic and fix payment date formatting
All checks were successful
Build and Push / build (push) Successful in 8s
Deploy to K8s / deploy (push) Successful in 12s
2026-03-02 15:06:28 +01:00
13 changed files with 985 additions and 41 deletions

View File

@@ -1,5 +1,7 @@
# Antigravity Agent Configuration # Antigravity Agent Configuration
# This file provides global rules for the Antigravity agent when working on this repository. # This file provides global rules for the Antigravity agent when working on this repository.
- **Git Commits**: When making git commits, always append the following co-author trailer to the end of the commit message to indicate AI assistance: - **Identity**: Antigravity AI (Assistant)
`Co-authored-by: Antigravity <antigravity@deepmind.com>` - **Git Commits**: Always follow [Conventional Commits](https://www.conventionalcommits.org/) and append the co-author trailer:
`Co-authored-by: Antigravity <antigravity@google.com>`
- **Workflow**: Prefer updating `task.md` and `walkthrough.md` in the `.gemini/antigravity/brain/` directory to track progress and document changes.

3
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"makefile.configureOnOpen": false
}

View File

@@ -1,4 +1,4 @@
.PHONY: help fees match web image run sync sync-2026 test test-v .PHONY: help fees match web image run sync sync-2026 test test-v docs
export PYTHONPATH := scripts:$(PYTHONPATH) export PYTHONPATH := scripts:$(PYTHONPATH)
VENV := .venv VENV := .venv
@@ -25,6 +25,7 @@ help:
@echo " make venv - Sync virtual environment with pyproject.toml" @echo " make venv - Sync virtual environment with pyproject.toml"
@echo " make test - Run web application infrastructure tests" @echo " make test - Run web application infrastructure tests"
@echo " make test-v - Run tests with verbose output" @echo " make test-v - Run tests with verbose output"
@echo " make docs - Serve documentation in a browser"
venv: venv:
uv sync uv sync
@@ -61,3 +62,8 @@ test: $(PYTHON) ## Run web application tests
test-v: $(PYTHON) ## Run tests with verbose output test-v: $(PYTHON) ## Run tests with verbose output
export PYTHONPATH=$(PYTHONPATH):$(CURDIR)/scripts:$(CURDIR) && $(PYTHON) -m unittest discover -v tests export PYTHONPATH=$(PYTHONPATH):$(CURDIR)/scripts:$(CURDIR) && $(PYTHON) -m unittest discover -v tests
docs: ## Serve documentation locally
@echo "Starting documentation server at http://localhost:8000"
@echo "Press Ctrl+C to stop."
$(PYTHON) -m http.server 8000 --directory docs

150
app.py
View File

@@ -2,17 +2,53 @@ import sys
from pathlib import Path from pathlib import Path
from datetime import datetime from datetime import datetime
import re import re
from flask import Flask, render_template import time
import os
import io
import qrcode
from flask import Flask, render_template, g, send_file, request
# Add scripts directory to path to allow importing from it # Add scripts directory to path to allow importing from it
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, SHEET_ID as ATTENDANCE_SHEET_ID from attendance import get_members_with_fees, SHEET_ID as ATTENDANCE_SHEET_ID
from match_payments import reconcile, fetch_sheet_data, DEFAULT_SPREADSHEET_ID as PAYMENTS_SHEET_ID from match_payments import reconcile, fetch_sheet_data, fetch_exceptions, normalize, DEFAULT_SPREADSHEET_ID as PAYMENTS_SHEET_ID
app = Flask(__name__) app = Flask(__name__)
# Bank account for QR code payments (can be overridden by ENV)
BANK_ACCOUNT = os.environ.get("BANK_ACCOUNT", "CZ8520100000002800359168")
@app.before_request
def start_timer():
g.start_time = time.perf_counter()
g.steps = []
def record_step(name):
g.steps.append((name, time.perf_counter()))
@app.context_processor
def inject_render_time():
def get_render_time():
total = time.perf_counter() - g.start_time
breakdown = []
last_time = g.start_time
for name, timestamp in g.steps:
duration = timestamp - last_time
breakdown.append(f"{name}:{duration:.3f}s")
last_time = timestamp
# Add remaining time as 'render'
render_duration = time.perf_counter() - last_time
breakdown.append(f"render:{render_duration:.3f}s")
return {
"total": f"{total:.3f}",
"breakdown": " | ".join(breakdown)
}
return dict(get_render_time=get_render_time)
@app.route("/") @app.route("/")
def index(): def index():
# Redirect root to /fees for convenience while there are no other apps # Redirect root to /fees for convenience while there are no other apps
@@ -24,6 +60,7 @@ def fees():
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"
members, sorted_months = get_members_with_fees() members, sorted_months = get_members_with_fees()
record_step("fetch_members")
if not members: if not members:
return "No data." return "No data."
@@ -37,16 +74,35 @@ def fees():
monthly_totals = {m: 0 for m in sorted_months} monthly_totals = {m: 0 for m in sorted_months}
# Get exceptions for formatting
credentials_path = ".secret/fuj-management-bot-credentials.json"
exceptions = fetch_exceptions(PAYMENTS_SHEET_ID, credentials_path)
record_step("fetch_exceptions")
formatted_results = [] formatted_results = []
for name, month_fees in results: for name, month_fees in results:
row = {"name": name, "months": []} row = {"name": name, "months": []}
norm_name = normalize(name)
for m in sorted_months: for m in sorted_months:
fee, count = month_fees.get(m, (0, 0)) fee, count = month_fees.get(m, (0, 0))
monthly_totals[m] += fee monthly_totals[m] += fee
cell = f"{fee} CZK ({count})" if count > 0 else "-"
row["months"].append(cell) # 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:
cell = f"{fee} CZK ({count})" if count > 0 else "-"
is_overridden = False
row["months"].append({"cell": cell, "overridden": is_overridden})
formatted_results.append(row) formatted_results.append(row)
record_step("process_data")
return render_template( return render_template(
"fees.html", "fees.html",
months=[month_labels[m] for m in sorted_months], months=[month_labels[m] for m in sorted_months],
@@ -65,11 +121,16 @@ def reconcile_view():
credentials_path = ".secret/fuj-management-bot-credentials.json" credentials_path = ".secret/fuj-management-bot-credentials.json"
members, sorted_months = get_members_with_fees() members, sorted_months = get_members_with_fees()
record_step("fetch_members")
if not members: if not members:
return "No data." return "No data."
transactions = fetch_sheet_data(PAYMENTS_SHEET_ID, credentials_path) transactions = fetch_sheet_data(PAYMENTS_SHEET_ID, credentials_path)
result = reconcile(members, sorted_months, transactions) record_step("fetch_payments")
exceptions = fetch_exceptions(PAYMENTS_SHEET_ID, credentials_path)
record_step("fetch_exceptions")
result = reconcile(members, sorted_months, transactions, exceptions)
record_step("reconcile")
# Format month labels # Format month labels
month_labels = { month_labels = {
@@ -84,21 +145,36 @@ def reconcile_view():
data = result["members"][name] data = result["members"][name]
row = {"name": name, "months": [], "balance": data["total_balance"]} row = {"name": name, "months": [], "balance": data["total_balance"]}
for m in sorted_months: for m in sorted_months:
mdata = data["months"].get(m, {"expected": 0, "paid": 0}) mdata = data["months"].get(m, {"expected": 0, "original_expected": 0, "paid": 0})
expected = mdata["expected"] expected = mdata["expected"]
paid = int(mdata["paid"]) paid = int(mdata["paid"])
cell_status = "" status = "empty"
if expected == 0 and paid == 0: cell_text = "-"
cell = "-" amount_to_pay = 0
elif paid >= expected and expected > 0:
cell = "OK"
elif paid > 0:
cell = f"{paid}/{expected}"
else:
cell = f"UNPAID {expected}"
row["months"].append(cell) if expected > 0:
if paid >= expected:
status = "ok"
cell_text = "OK"
elif paid > 0:
status = "partial"
cell_text = f"{paid}/{expected}"
amount_to_pay = expected - paid
else:
status = "unpaid"
cell_text = f"UNPAID {expected}"
amount_to_pay = expected
elif paid > 0:
status = "surplus"
cell_text = f"PAID {paid}"
row["months"].append({
"text": cell_text,
"status": status,
"amount": amount_to_pay,
"month": month_labels[m]
})
row["balance"] = data["total_balance"] # Updated to use total_balance row["balance"] = data["total_balance"] # Updated to use total_balance
formatted_results.append(row) formatted_results.append(row)
@@ -106,19 +182,24 @@ def reconcile_view():
# 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"]) 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"])
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"]) 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"])
# Format unmatched # Format unmatched
unmatched = result["unmatched"] unmatched = result["unmatched"]
import json
record_step("process_data")
return render_template( return render_template(
"reconcile.html", "reconcile.html",
months=[month_labels[m] for m in sorted_months], months=[month_labels[m] for m in sorted_months],
raw_months=sorted_months,
results=formatted_results, results=formatted_results,
member_data=json.dumps(result["members"]),
credits=credits, credits=credits,
debts=debts, debts=debts,
unmatched=unmatched, unmatched=unmatched,
attendance_url=attendance_url, attendance_url=attendance_url,
payments_url=payments_url payments_url=payments_url,
bank_account=BANK_ACCOUNT
) )
@app.route("/payments") @app.route("/payments")
@@ -128,6 +209,7 @@ def payments():
credentials_path = ".secret/fuj-management-bot-credentials.json" credentials_path = ".secret/fuj-management-bot-credentials.json"
transactions = fetch_sheet_data(PAYMENTS_SHEET_ID, credentials_path) transactions = fetch_sheet_data(PAYMENTS_SHEET_ID, credentials_path)
record_step("fetch_payments")
# Group transactions by person # Group transactions by person
grouped = {} grouped = {}
@@ -151,6 +233,7 @@ def payments():
# Sort by date descending # Sort by date descending
grouped[p].sort(key=lambda x: str(x.get("date", "")), reverse=True) grouped[p].sort(key=lambda x: str(x.get("date", "")), reverse=True)
record_step("process_data")
return render_template( return render_template(
"payments.html", "payments.html",
grouped_payments=grouped, grouped_payments=grouped,
@@ -159,5 +242,36 @@ def payments():
payments_url=payments_url payments_url=payments_url
) )
@app.route("/qr")
def qr_code():
account = request.args.get("account", BANK_ACCOUNT)
amount = request.args.get("amount", "0")
message = request.args.get("message", "")
# QR Platba standard: SPD*1.0*ACC:accountNumber*BC:bankCode*AM:amount*CC:CZK*MSG:message
acc_parts = account.split('/')
if len(acc_parts) == 2:
acc_str = f"{acc_parts[0]}*BC:{acc_parts[1]}"
else:
acc_str = account
try:
amt_val = float(amount)
amt_str = f"{amt_val:.2f}"
except ValueError:
amt_str = "0.00"
# Message max 60 characters
msg_str = message[:60]
qr_data = f"SPD*1.0*ACC:{acc_str}*AM:{amt_str}*CC:CZK*MSG:{msg_str}"
img = qrcode.make(qr_data)
buf = io.BytesIO()
img.save(buf, format='PNG')
buf.seek(0)
return send_file(buf, mimetype='image/png')
if __name__ == "__main__": if __name__ == "__main__":
app.run(debug=True, host='0.0.0.0', port=5001) app.run(debug=True, host='0.0.0.0', port=5001)

View File

@@ -12,7 +12,9 @@ RUN pip install --no-cache-dir \
flask \ flask \
google-api-python-client \ google-api-python-client \
google-auth-httplib2 \ google-auth-httplib2 \
google-auth-oauthlib google-auth-oauthlib \
qrcode \
pillow
COPY app.py Makefile ./ COPY app.py Makefile ./
COPY scripts/ ./scripts/ COPY scripts/ ./scripts/

View File

@@ -1,12 +1,13 @@
[project] [project]
name = "fuj-management" name = "fuj-management"
version = "0.05" version = "0.10"
description = "Management tools for FUJ (Frisbee Ultimate Jablonec)" description = "Management tools for FUJ (Frisbee Ultimate Jablonec)"
dependencies = [ dependencies = [
"flask>=3.1.3", "flask>=3.1.3",
"google-api-python-client>=2.162.0", "google-api-python-client>=2.162.0",
"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",
] ]
requires-python = ">=3.13" requires-python = ">=3.13"

View File

@@ -58,14 +58,31 @@ def calculate_fee(attendance_count: int) -> int:
def get_members(rows: list[list[str]]) -> list[tuple[str, str, list[str]]]: def get_members(rows: list[list[str]]) -> list[tuple[str, str, list[str]]]:
"""Parse member rows. Returns list of (name, tier, row).""" """Parse member rows. Returns list of (name, tier, row).
Stopped at row where first column contains '# last line'.
Skips rows starting with '#'.
"""
members = [] members = []
for row in rows[1:]: for row in rows[1:]:
name = row[COL_NAME].strip() if len(row) > COL_NAME else "" if not row or len(row) <= COL_NAME:
if not name or name.lower() in ("jméno", "name", "jmeno"):
continue continue
first_col = row[COL_NAME].strip()
# Terminator for rows to process
if "# last line" in first_col.lower():
break
# Ignore comments
if first_col.startswith("#"):
continue
if not first_col or first_col.lower() in ("jméno", "name", "jmeno"):
continue
tier = row[COL_TIER].strip().upper() if len(row) > COL_TIER else "" tier = row[COL_TIER].strip().upper() if len(row) > COL_TIER else ""
members.append((name, tier, row)) members.append((first_col, tier, row))
return members return members

View File

@@ -159,6 +159,27 @@ def infer_transaction_details(tx: dict, member_names: list[str]) -> dict:
} }
def format_date(val) -> str:
"""Normalize date from Google Sheet (handles serial numbers and strings)."""
if val is None or val == "":
return ""
# Handle Google Sheets serial dates (number of days since 1899-12-30)
if isinstance(val, (int, float)):
base_date = datetime(1899, 12, 30)
dt = base_date + timedelta(days=val)
return dt.strftime("%Y-%m-%d")
val_str = str(val).strip()
if not val_str:
return ""
# If already YYYY-MM-DD, return as is
if len(val_str) == 10 and val_str[4] == "-" and val_str[7] == "-":
return val_str
return val_str
def fetch_sheet_data(spreadsheet_id: str, credentials_path: str) -> list[dict]: def fetch_sheet_data(spreadsheet_id: str, credentials_path: str) -> list[dict]:
"""Fetch all rows from the Google Sheet and convert to a list of dicts.""" """Fetch all rows from the Google Sheet and convert to a list of dicts."""
service = get_sheets_service(credentials_path) service = get_sheets_service(credentials_path)
@@ -197,7 +218,7 @@ def fetch_sheet_data(spreadsheet_id: str, credentials_path: str) -> list[dict]:
return row[idx] if idx != -1 and idx < len(row) else "" return row[idx] if idx != -1 and idx < len(row) else ""
tx = { tx = {
"date": get_val(idx_date), "date": format_date(get_val(idx_date)),
"amount": get_val(idx_amount), "amount": get_val(idx_amount),
"manual_fix": get_val(idx_manual), "manual_fix": get_val(idx_manual),
"person": get_val(idx_person), "person": get_val(idx_person),
@@ -212,10 +233,49 @@ def fetch_sheet_data(spreadsheet_id: str, credentials_path: str) -> list[dict]:
return transactions return transactions
def fetch_exceptions(spreadsheet_id: str, credentials_path: str) -> dict[tuple[str, str], dict]:
"""Fetch manual fee overrides from the 'exceptions' sheet.
Returns a dict mapping (member_name, period_YYYYMM) to {'amount': int, 'note': str}.
"""
service = get_sheets_service(credentials_path)
try:
result = service.spreadsheets().values().get(
spreadsheetId=spreadsheet_id,
range="'exceptions'!A2:D",
valueRenderOption="UNFORMATTED_VALUE"
).execute()
rows = result.get("values", [])
except Exception as e:
print(f"Warning: Could not fetch exceptions: {e}")
return {}
exceptions = {}
for row in rows:
if len(row) < 3 or str(row[0]).lower().startswith("name"):
continue
name = str(row[0]).strip()
period = str(row[1]).strip()
# Robust normalization using czech_utils.normalize
norm_name = normalize(name)
norm_period = normalize(period)
try:
amount = int(row[2])
note = str(row[3]).strip() if len(row) > 3 else ""
exceptions[(norm_name, norm_period)] = {"amount": amount, "note": note}
except (ValueError, TypeError):
continue
return exceptions
def reconcile( def reconcile(
members: list[tuple[str, str, dict[str, int]]], members: list[tuple[str, str, dict[str, int]]],
sorted_months: list[str], sorted_months: list[str],
transactions: list[dict], transactions: list[dict],
exceptions: dict[tuple[str, str], dict] = None,
) -> dict: ) -> dict:
"""Match transactions to members and months. """Match transactions to members and months.
@@ -226,15 +286,34 @@ def reconcile(
""" """
member_names = [name for name, _, _ in members] member_names = [name for name, _, _ in members]
member_tiers = {name: tier for name, tier, _ in members} member_tiers = {name: tier for name, tier, _ in members}
member_fees = {name: {m: fee for m, (fee, _) in fees.items()} for name, _, fees in members} member_fees = {name: fees for name, _, fees in members}
# Initialize ledger # Initialize ledger
ledger: dict[str, dict[str, dict]] = {} ledger: dict[str, dict[str, dict]] = {}
exceptions = exceptions or {}
for name in member_names: for name in member_names:
ledger[name] = {} ledger[name] = {}
for m in sorted_months: for m in sorted_months:
# Robust normalization for lookup
norm_name = normalize(name)
norm_period = normalize(m)
fee_data = member_fees[name].get(m, (0, 0))
original_expected = fee_data[0] if isinstance(fee_data, tuple) else fee_data
attendance_count = fee_data[1] if isinstance(fee_data, tuple) else 0
ex_data = exceptions.get((norm_name, norm_period))
if ex_data is not None:
expected = ex_data["amount"]
exception_info = ex_data
else:
expected = original_expected
exception_info = None
ledger[name][m] = { ledger[name][m] = {
"expected": member_fees[name].get(m, 0), "expected": expected,
"original_expected": original_expected,
"attendance_count": attendance_count,
"exception": exception_info,
"paid": 0, "paid": 0,
"transactions": [], "transactions": [],
} }
@@ -371,10 +450,12 @@ def print_report(result: dict, sorted_months: list[str]):
for m in sorted_months: for m in sorted_months:
mdata = data["months"].get(m, {"expected": 0, "paid": 0}) mdata = data["months"].get(m, {"expected": 0, "paid": 0})
expected = mdata["expected"] expected = mdata["expected"]
original = mdata["original_expected"]
paid = int(mdata["paid"]) paid = int(mdata["paid"])
total_expected += expected total_expected += expected
total_paid += paid total_paid += paid
cell_status = ""
if expected == 0 and paid == 0: if expected == 0 and paid == 0:
cell = "-" cell = "-"
elif paid >= expected and expected > 0: elif paid >= expected and expected > 0:
@@ -383,6 +464,7 @@ def print_report(result: dict, sorted_months: list[str]):
cell = f"{paid}/{expected}" cell = f"{paid}/{expected}"
else: else:
cell = f"UNPAID {expected}" cell = f"UNPAID {expected}"
member_balance += paid - expected member_balance += paid - expected
line += f" | {cell:>10}" line += f" | {cell:>10}"
balance_str = f"{member_balance:+d}" if member_balance != 0 else "0" balance_str = f"{member_balance:+d}" if member_balance != 0 else "0"
@@ -488,7 +570,11 @@ def main():
print(f"Processing {len(transactions)} transactions.\n") print(f"Processing {len(transactions)} transactions.\n")
result = reconcile(members, sorted_months, transactions) exceptions = fetch_exceptions(args.sheet_id, args.credentials)
if exceptions:
print(f"Loaded {len(exceptions)} fee exceptions.")
result = reconcile(members, sorted_months, transactions, exceptions)
print_report(result, sorted_months) print_report(result, sorted_months)

View File

@@ -102,6 +102,11 @@
/* Light gray for normal cells */ /* Light gray for normal cells */
} }
.cell-overridden {
color: #ffa500 !important;
/* Orange for overrides */
}
.nav { .nav {
margin-bottom: 20px; margin-bottom: 20px;
font-size: 12px; font-size: 12px;
@@ -143,6 +148,23 @@
.description a:hover { .description a:hover {
text-decoration: underline; 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> </style>
</head> </head>
@@ -175,8 +197,11 @@
{% for row in results %} {% for row in results %}
<tr> <tr>
<td>{{ row.name }}</td> <td>{{ row.name }}</td>
{% for cell in row.months %} {% for mdata in row.months %}
<td class="{% if cell == '-' %}cell-empty{% else %}cell-paid{% endif %}">{{ cell }}</td> <td
class="{% if mdata.cell == '-' %}cell-empty{% elif mdata.overridden %}cell-overridden{% else %}cell-paid{% endif %}">
{{ mdata.cell }}
</td>
{% endfor %} {% endfor %}
</tr> </tr>
{% endfor %} {% endfor %}
@@ -191,6 +216,14 @@
</tfoot> </tfoot>
</table> </table>
</div> </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> </body>
</html> </html>

View File

@@ -137,6 +137,23 @@
tr:hover { tr:hover {
background-color: #1a1a1a; background-color: #1a1a1a;
} }
.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> </style>
</head> </head>
@@ -183,6 +200,14 @@
{% endfor %} {% endfor %}
</div> </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> </body>
</html> </html>

View File

@@ -138,6 +138,28 @@
.cell-unpaid { .cell-unpaid {
color: #ff3333; color: #ff3333;
background-color: rgba(255, 51, 51, 0.05);
position: relative;
}
.pay-btn {
display: none;
position: absolute;
right: 5px;
top: 50%;
transform: translateY(-50%);
background: #ff3333;
color: white;
border: none;
border-radius: 3px;
padding: 2px 6px;
font-size: 10px;
cursor: pointer;
font-weight: bold;
}
.member-row:hover .pay-btn {
display: inline-block;
} }
.cell-empty { .cell-empty {
@@ -183,6 +205,219 @@
border-bottom: 1px solid #333; border-bottom: 1px solid #333;
margin-bottom: 5px; margin-bottom: 5px;
} }
.filter-container {
width: 100%;
max-width: 1200px;
margin-bottom: 15px;
display: flex;
align-items: center;
gap: 10px;
}
.filter-input {
background-color: #1a1a1a;
border: 1px solid #333;
color: #00ff00;
font-family: inherit;
font-size: 11px;
padding: 4px 8px;
width: 250px;
outline: none;
}
.filter-input:focus {
border-color: #00ff00;
}
.filter-label {
color: #888;
text-transform: lowercase;
}
.info-icon {
color: #00ff00;
cursor: pointer;
margin-left: 5px;
font-size: 10px;
opacity: 0.5;
}
.info-icon:hover {
opacity: 1;
}
/* Modal Styles */
#memberModal {
display: none !important;
/* Force hide by default */
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background-color: rgba(0, 0, 0, 0.9);
z-index: 9999;
justify-content: center;
align-items: center;
}
#memberModal.active {
display: flex !important;
}
.modal-content {
background-color: #0c0c0c;
border: 1px solid #00ff00;
width: 90%;
max-width: 800px;
max-height: 85vh;
overflow-y: auto;
padding: 20px;
box-shadow: 0 0 20px rgba(0, 255, 0, 0.2);
position: relative;
}
.modal-header {
border-bottom: 1px solid #333;
margin-bottom: 20px;
padding-bottom: 10px;
display: flex;
justify-content: space-between;
align-items: center;
}
.modal-title {
color: #00ff00;
font-size: 14px;
text-transform: uppercase;
}
.close-btn {
color: #ff3333;
cursor: pointer;
font-size: 14px;
text-transform: lowercase;
}
.modal-section {
margin-bottom: 25px;
}
.modal-section-title {
color: #555;
text-transform: uppercase;
font-size: 10px;
margin-bottom: 8px;
border-bottom: 1px dashed #222;
}
.modal-table {
width: 100%;
border-collapse: collapse;
}
.modal-table th,
.modal-table td {
text-align: left;
padding: 4px 0;
border-bottom: 1px dashed #1a1a1a;
}
.modal-table th {
color: #666;
font-weight: normal;
font-size: 10px;
}
.tx-list {
list-style: none;
padding: 0;
margin: 0;
}
.tx-item {
padding: 8px 0;
border-bottom: 1px dashed #222;
}
.tx-meta {
color: #555;
font-size: 10px;
margin-bottom: 4px;
}
.tx-main {
display: flex;
justify-content: space-between;
gap: 20px;
}
.tx-amount {
color: #00ff00;
}
.tx-sender {
color: #ccc;
}
.tx-msg {
color: #888;
font-style: italic;
}
.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;
}
/* QR Modal styles */
#qrModal .modal-content {
max-width: 400px;
text-align: center;
}
.qr-image {
background: white;
padding: 10px;
border-radius: 5px;
margin: 20px 0;
display: inline-block;
}
.qr-image img {
display: block;
width: 250px;
height: 250px;
}
.qr-details {
text-align: left;
margin-top: 15px;
font-size: 14px;
color: #ccc;
}
.qr-details div {
margin-bottom: 5px;
}
.qr-details span {
color: #00ff00;
font-family: monospace;
}
</style> </style>
</head> </head>
@@ -201,6 +436,11 @@
<a href="{{ payments_url }}" target="_blank">Payments Ledger</a> <a href="{{ payments_url }}" target="_blank">Payments Ledger</a>
</div> </div>
<div class="filter-container">
<span class="filter-label">search member:</span>
<input type="text" id="nameFilter" class="filter-input" placeholder="..." autocomplete="off">
</div>
<div class="table-container"> <div class="table-container">
<table> <table>
<thead> <thead>
@@ -212,14 +452,21 @@
<th>Balance</th> <th>Balance</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody id="reconcileBody">
{% for row in results %} {% for row in results %}
<tr> <tr class="member-row">
<td>{{ row.name }}</td> <td class="member-name">
{{ row.name }}
<span class="info-icon" onclick="showMemberDetails('{{ row.name|e }}')">[i]</span>
</td>
{% for cell in row.months %} {% for cell in row.months %}
<td <td
class="{% if cell == '-' %}cell-empty{% elif 'UNPAID' in cell %}cell-unpaid{% elif cell == 'OK' %}cell-ok{% endif %}"> class="{% if cell.status == 'empty' %}cell-empty{% elif cell.status == 'unpaid' or cell.status == 'partial' %}cell-unpaid{% elif cell.status == 'ok' %}cell-ok{% endif %}">
{{ cell }} {{ cell.text }}
{% if cell.status == 'unpaid' or cell.status == 'partial' %}
<button class="pay-btn"
onclick="showPayQR('{{ row.name|e }}', {{ cell.amount }}, '{{ cell.month|e }}')">Pay</button>
{% endif %}
</td> </td>
{% endfor %} {% endfor %}
<td class="{% if row.balance > 0 %}balance-pos{% elif row.balance < 0 %}balance-neg{% endif %}"> <td class="{% if row.balance > 0 %}balance-pos{% elif row.balance < 0 %}balance-neg{% endif %}">
@@ -275,6 +522,281 @@
</div> </div>
{% endif %} {% endif %}
<!-- QR Code Modal -->
<div id="qrModal" class="modal"
style="display:none; position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; background-color: rgba(0, 0, 0, 0.9); z-index: 9999; justify-content: center; align-items: center;">
<div class="modal-content">
<div class="modal-header">
<div class="modal-title" id="qrTitle">Payment for ...</div>
<div class="close-btn" onclick="closeModal('qrModal')">[close]</div>
</div>
<div class="qr-image">
<img id="qrImg" src="" alt="Payment QR Code">
</div>
<div class="qr-details">
<div>Account: <span id="qrAccount"></span></div>
<div>Amount: <span id="qrAmount"></span> CZK</div>
<div>Message: <span id="qrMessage"></span></div>
</div>
</div>
</div>
<div id="memberModal">
<div class="modal-content">
<div class="modal-header">
<div class="modal-title" id="modalMemberName">Member Name</div>
<div class="close-btn" onclick="closeModal()">[close]</div>
</div>
<div class="modal-section">
<div class="modal-section-title">Status Summary</div>
<div id="modalTier" style="margin-bottom: 10px; color: #888;">Tier: -</div>
<table class="modal-table">
<thead>
<tr>
<th>Month</th>
<th style="text-align: center;">Att.</th>
<th style="text-align: center;">Expected</th>
<th style="text-align: center;">Paid</th>
<th style="text-align: right;">Status</th>
</tr>
</thead>
<tbody id="modalStatusBody">
<!-- Filled by JS -->
</tbody>
</table>
</div>
<div class="modal-section" id="modalExceptionSection" style="display: none;">
<div class="modal-section-title">Fee Exceptions</div>
<div id="modalExceptionList" class="tx-list">
<!-- Filled by JS -->
</div>
</div>
<div class="modal-section">
<div class="modal-section-title">Payment History</div>
<div id="modalTxList" class="tx-list">
<!-- Filled by JS -->
</div>
</div>
</div>
</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>
<script>
const memberData = {{ member_data| safe }};
const sortedMonths = {{ raw_months| tojson }};
let currentMemberName = null;
function showMemberDetails(name) {
currentMemberName = name;
const data = memberData[name];
if (!data) return;
document.getElementById('modalMemberName').textContent = name;
document.getElementById('modalTier').textContent = 'Tier: ' + data.tier;
const statusBody = document.getElementById('modalStatusBody');
statusBody.innerHTML = '';
// Collect all transactions for listing
const allTransactions = [];
// We need to iterate over months in reverse to show newest first
const monthKeys = Object.keys(data.months).sort().reverse();
monthKeys.forEach(m => {
const mdata = data.months[m];
const expected = mdata.expected || 0;
const paid = mdata.paid || 0;
const attendance = mdata.attendance_count || 0;
const originalExpected = mdata.original_expected;
let status = '-';
let statusClass = '';
if (expected > 0 || paid > 0) {
if (paid >= expected && expected > 0) { status = 'OK'; statusClass = 'cell-ok'; }
else if (paid > 0) { status = paid + '/' + expected; }
else { status = 'UNPAID ' + expected; statusClass = 'cell-unpaid'; }
}
const expectedCell = mdata.exception
? `<span style="color: #ffaa00;" title="Overridden from ${originalExpected}">${expected}*</span>`
: expected;
const row = document.createElement('tr');
row.innerHTML = `
<td style="color: #888;">${m}</td>
<td style="text-align: center; color: #ccc;">${attendance}</td>
<td style="text-align: center; color: #ccc;">${expectedCell}</td>
<td style="text-align: center; color: #ccc;">${paid}</td>
<td style="text-align: right;" class="${statusClass}">${status}</td>
`;
statusBody.appendChild(row);
if (mdata.transactions) {
mdata.transactions.forEach(tx => {
allTransactions.push({ month: m, ...tx });
});
}
});
const exList = document.getElementById('modalExceptionList');
const exSection = document.getElementById('modalExceptionSection');
exList.innerHTML = '';
const exceptions = [];
monthKeys.forEach(m => {
if (data.months[m].exception) {
exceptions.push({ month: m, ...data.months[m].exception });
}
});
if (exceptions.length > 0) {
exSection.style.display = 'block';
exceptions.forEach(ex => {
const item = document.createElement('div');
item.className = 'tx-item'; // Reuse style
item.innerHTML = `
<div class="tx-meta">${ex.month}</div>
<div class="tx-main">
<span class="tx-amount" style="color: #ffaa00;">${ex.amount} CZK</span>
</div>
<div class="tx-msg">${ex.note || 'No details provided.'}</div>
`;
exList.appendChild(item);
});
} else {
exSection.style.display = 'none';
}
const txList = document.getElementById('modalTxList');
txList.innerHTML = '';
if (allTransactions.length === 0) {
txList.innerHTML = '<div style="color: #444; font-style: italic; padding: 10px 0;">No transactions matched to this member.</div>';
} else {
allTransactions.sort((a, b) => b.date.localeCompare(a.date)).forEach(tx => {
const item = document.createElement('div');
item.className = 'tx-item';
item.innerHTML = `
<div class="tx-meta">${tx.date} | matched to ${tx.month}</div>
<div class="tx-main">
<span class="tx-amount">${tx.amount} CZK</span>
<span class="tx-sender">${tx.sender}</span>
</div>
<div class="tx-msg">${tx.message || ''}</div>
`;
txList.appendChild(item);
});
}
document.getElementById('memberModal').classList.add('active');
}
function closeModal(id) {
if (id) {
document.getElementById(id).style.display = 'none';
if (id === 'qrModal') {
document.getElementById(id).style.display = 'none';
}
} else {
document.getElementById('memberModal').classList.remove('active');
}
}
// Existing filter script
document.getElementById('nameFilter').addEventListener('input', function (e) {
const filterValue = e.target.value.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "");
const rows = document.querySelectorAll('.member-row');
rows.forEach(row => {
const nameNode = row.querySelector('.member-name');
const name = nameNode.childNodes[0].textContent.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "");
if (name.includes(filterValue)) {
row.style.display = '';
} else {
row.style.display = 'none';
}
});
});
// Close on Esc and Navigate with Arrows
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') {
closeModal();
closeModal('qrModal');
}
const modal = document.getElementById('memberModal');
if (modal.classList.contains('active')) {
if (e.key === 'ArrowUp') {
e.preventDefault();
navigateMember(-1);
} else if (e.key === 'ArrowDown') {
e.preventDefault();
navigateMember(1);
}
}
});
function navigateMember(direction) {
const rows = Array.from(document.querySelectorAll('.member-row'));
const visibleRows = rows.filter(row => row.style.display !== 'none');
let currentIndex = visibleRows.findIndex(row => {
const nameNode = row.querySelector('.member-name');
const name = nameNode.childNodes[0].textContent.trim();
return name === currentMemberName;
});
if (currentIndex === -1) return;
let nextIndex = currentIndex + direction;
if (nextIndex >= 0 && nextIndex < visibleRows.length) {
const nextRow = visibleRows[nextIndex];
const nextName = nextRow.querySelector('.member-name').childNodes[0].textContent.trim();
showMemberDetails(nextName);
}
}
function showPayQR(name, amount, month) {
const account = "{{ bank_account }}";
const message = `${name} / ${month}`;
const qrTitle = document.getElementById('qrTitle');
const qrImg = document.getElementById('qrImg');
const qrAccount = document.getElementById('qrAccount');
const qrAmount = document.getElementById('qrAmount');
const qrMessage = document.getElementById('qrMessage');
qrTitle.innerText = `Payment for ${month}`;
qrAccount.innerText = account;
qrAmount.innerText = amount;
qrMessage.innerText = message;
const encodedMessage = encodeURIComponent(message);
const qrUrl = `/qr?account=${encodeURIComponent(account)}&amount=${amount}&message=${encodedMessage}`;
qrImg.src = qrUrl;
document.getElementById('qrModal').style.display = 'block';
}
// Close modal when clicking outside
window.onclick = function (event) {
if (event.target.className === 'modal') {
event.target.style.display = 'none';
}
}
</script>
</body> </body>
</html> </html>
```

View File

@@ -0,0 +1,56 @@
import unittest
from scripts.match_payments import reconcile
class TestReconcileWithExceptions(unittest.TestCase):
def test_reconcile_applies_exceptions(self):
# 1. Setup mock data
# Member: Alice, Tier A, expected 750 (attendance-based)
members = [
('Alice', 'A', {'2026-01': (750, 4)})
]
sorted_months = ['2026-01']
# Exception: Alice should only pay 400 in 2026-01 (normalized keys, no accents)
exceptions = {
('alice', '2026-01'): {'amount': 400, 'note': 'Test exception'}
}
# Transaction: Alice paid 400
transactions = [{
'date': '2026-01-05',
'amount': 400,
'person': 'Alice',
'purpose': '2026-01',
'inferred_amount': 400,
'sender': 'Alice Sender',
'message': 'fee'
}]
# 2. Reconcile
result = reconcile(members, sorted_months, transactions, exceptions)
# 3. Assertions
alice_data = result['members']['Alice']
jan_data = alice_data['months']['2026-01']
self.assertEqual(jan_data['expected'], 400, "Expected amount should be overridden by exception")
self.assertEqual(jan_data['paid'], 400, "Paid amount should be 400")
self.assertEqual(alice_data['total_balance'], 0, "Balance should be 0 because 400/400")
def test_reconcile_fallback_to_attendance(self):
# Alice has attendance-based fee 750, NO exception
members = [
('Alice', 'A', {'2026-01': (750, 4)})
]
sorted_months = ['2026-01']
exceptions = {} # No exceptions
transactions = []
result = reconcile(members, sorted_months, transactions, exceptions)
alice_data = result['members']['Alice']
self.assertEqual(alice_data['months']['2026-01']['expected'], 750, "Should fallback to attendance fee")
if __name__ == '__main__':
unittest.main()

79
uv.lock generated
View File

@@ -199,13 +199,14 @@ wheels = [
[[package]] [[package]]
name = "fuj-management" name = "fuj-management"
version = "0.2" version = "0.10"
source = { virtual = "." } source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "flask" }, { name = "flask" },
{ 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 = "qrcode", extra = ["pil"] },
] ]
[package.metadata] [package.metadata]
@@ -214,6 +215,7 @@ requires-dist = [
{ 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 = "qrcode", extras = ["pil"], specifier = ">=8.0" },
] ]
[[package]] [[package]]
@@ -403,6 +405,64 @@ 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 = "pillow"
version = "12.1.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1f/42/5c74462b4fd957fcd7b13b04fb3205ff8349236ea74c7c375766d6c82288/pillow-12.1.1.tar.gz", hash = "sha256:9ad8fa5937ab05218e2b6a4cff30295ad35afd2f83ac592e68c0d871bb0fdbc4", size = 46980264, upload-time = "2026-02-11T04:23:07.146Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d5/11/6db24d4bd7685583caeae54b7009584e38da3c3d4488ed4cd25b439de486/pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d242e8ac078781f1de88bf823d70c1a9b3c7950a44cdf4b7c012e22ccbcd8e4e", size = 4062689, upload-time = "2026-02-11T04:21:06.804Z" },
{ url = "https://files.pythonhosted.org/packages/33/c0/ce6d3b1fe190f0021203e0d9b5b99e57843e345f15f9ef22fcd43842fd21/pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:02f84dfad02693676692746df05b89cf25597560db2857363a208e393429f5e9", size = 4138535, upload-time = "2026-02-11T04:21:08.452Z" },
{ url = "https://files.pythonhosted.org/packages/a0/c6/d5eb6a4fb32a3f9c21a8c7613ec706534ea1cf9f4b3663e99f0d83f6fca8/pillow-12.1.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:e65498daf4b583091ccbb2556c7000abf0f3349fcd57ef7adc9a84a394ed29f6", size = 3601364, upload-time = "2026-02-11T04:21:10.194Z" },
{ url = "https://files.pythonhosted.org/packages/14/a1/16c4b823838ba4c9c52c0e6bbda903a3fe5a1bdbf1b8eb4fff7156f3e318/pillow-12.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c6db3b84c87d48d0088943bf33440e0c42370b99b1c2a7989216f7b42eede60", size = 5262561, upload-time = "2026-02-11T04:21:11.742Z" },
{ url = "https://files.pythonhosted.org/packages/bb/ad/ad9dc98ff24f485008aa5cdedaf1a219876f6f6c42a4626c08bc4e80b120/pillow-12.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8b7e5304e34942bf62e15184219a7b5ad4ff7f3bb5cca4d984f37df1a0e1aee2", size = 4657460, upload-time = "2026-02-11T04:21:13.786Z" },
{ url = "https://files.pythonhosted.org/packages/9e/1b/f1a4ea9a895b5732152789326202a82464d5254759fbacae4deea3069334/pillow-12.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:18e5bddd742a44b7e6b1e773ab5db102bd7a94c32555ba656e76d319d19c3850", size = 6232698, upload-time = "2026-02-11T04:21:15.949Z" },
{ url = "https://files.pythonhosted.org/packages/95/f4/86f51b8745070daf21fd2e5b1fe0eb35d4db9ca26e6d58366562fb56a743/pillow-12.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc44ef1f3de4f45b50ccf9136999d71abb99dca7706bc75d222ed350b9fd2289", size = 8041706, upload-time = "2026-02-11T04:21:17.723Z" },
{ url = "https://files.pythonhosted.org/packages/29/9b/d6ecd956bb1266dd1045e995cce9b8d77759e740953a1c9aad9502a0461e/pillow-12.1.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a8eb7ed8d4198bccbd07058416eeec51686b498e784eda166395a23eb99138e", size = 6346621, upload-time = "2026-02-11T04:21:19.547Z" },
{ url = "https://files.pythonhosted.org/packages/71/24/538bff45bde96535d7d998c6fed1a751c75ac7c53c37c90dc2601b243893/pillow-12.1.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47b94983da0c642de92ced1702c5b6c292a84bd3a8e1d1702ff923f183594717", size = 7038069, upload-time = "2026-02-11T04:21:21.378Z" },
{ url = "https://files.pythonhosted.org/packages/94/0e/58cb1a6bc48f746bc4cb3adb8cabff73e2742c92b3bf7a220b7cf69b9177/pillow-12.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:518a48c2aab7ce596d3bf79d0e275661b846e86e4d0e7dec34712c30fe07f02a", size = 6460040, upload-time = "2026-02-11T04:21:23.148Z" },
{ url = "https://files.pythonhosted.org/packages/6c/57/9045cb3ff11eeb6c1adce3b2d60d7d299d7b273a2e6c8381a524abfdc474/pillow-12.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a550ae29b95c6dc13cf69e2c9dc5747f814c54eeb2e32d683e5e93af56caa029", size = 7164523, upload-time = "2026-02-11T04:21:25.01Z" },
{ url = "https://files.pythonhosted.org/packages/73/f2/9be9cb99f2175f0d4dbadd6616ce1bf068ee54a28277ea1bf1fbf729c250/pillow-12.1.1-cp313-cp313-win32.whl", hash = "sha256:a003d7422449f6d1e3a34e3dd4110c22148336918ddbfc6a32581cd54b2e0b2b", size = 6332552, upload-time = "2026-02-11T04:21:27.238Z" },
{ url = "https://files.pythonhosted.org/packages/3f/eb/b0834ad8b583d7d9d42b80becff092082a1c3c156bb582590fcc973f1c7c/pillow-12.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:344cf1e3dab3be4b1fa08e449323d98a2a3f819ad20f4b22e77a0ede31f0faa1", size = 7040108, upload-time = "2026-02-11T04:21:29.462Z" },
{ url = "https://files.pythonhosted.org/packages/d5/7d/fc09634e2aabdd0feabaff4a32f4a7d97789223e7c2042fd805ea4b4d2c2/pillow-12.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:5c0dd1636633e7e6a0afe7bf6a51a14992b7f8e60de5789018ebbdfae55b040a", size = 2453712, upload-time = "2026-02-11T04:21:31.072Z" },
{ url = "https://files.pythonhosted.org/packages/19/2a/b9d62794fc8a0dd14c1943df68347badbd5511103e0d04c035ffe5cf2255/pillow-12.1.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0330d233c1a0ead844fc097a7d16c0abff4c12e856c0b325f231820fee1f39da", size = 5264880, upload-time = "2026-02-11T04:21:32.865Z" },
{ url = "https://files.pythonhosted.org/packages/26/9d/e03d857d1347fa5ed9247e123fcd2a97b6220e15e9cb73ca0a8d91702c6e/pillow-12.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5dae5f21afb91322f2ff791895ddd8889e5e947ff59f71b46041c8ce6db790bc", size = 4660616, upload-time = "2026-02-11T04:21:34.97Z" },
{ url = "https://files.pythonhosted.org/packages/f7/ec/8a6d22afd02570d30954e043f09c32772bfe143ba9285e2fdb11284952cd/pillow-12.1.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2e0c664be47252947d870ac0d327fea7e63985a08794758aa8af5b6cb6ec0c9c", size = 6269008, upload-time = "2026-02-11T04:21:36.623Z" },
{ url = "https://files.pythonhosted.org/packages/3d/1d/6d875422c9f28a4a361f495a5f68d9de4a66941dc2c619103ca335fa6446/pillow-12.1.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:691ab2ac363b8217f7d31b3497108fb1f50faab2f75dfb03284ec2f217e87bf8", size = 8073226, upload-time = "2026-02-11T04:21:38.585Z" },
{ url = "https://files.pythonhosted.org/packages/a1/cd/134b0b6ee5eda6dc09e25e24b40fdafe11a520bc725c1d0bbaa5e00bf95b/pillow-12.1.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9e8064fb1cc019296958595f6db671fba95209e3ceb0c4734c9baf97de04b20", size = 6380136, upload-time = "2026-02-11T04:21:40.562Z" },
{ url = "https://files.pythonhosted.org/packages/7a/a9/7628f013f18f001c1b98d8fffe3452f306a70dc6aba7d931019e0492f45e/pillow-12.1.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:472a8d7ded663e6162dafdf20015c486a7009483ca671cece7a9279b512fcb13", size = 7067129, upload-time = "2026-02-11T04:21:42.521Z" },
{ url = "https://files.pythonhosted.org/packages/1e/f8/66ab30a2193b277785601e82ee2d49f68ea575d9637e5e234faaa98efa4c/pillow-12.1.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:89b54027a766529136a06cfebeecb3a04900397a3590fd252160b888479517bf", size = 6491807, upload-time = "2026-02-11T04:21:44.22Z" },
{ url = "https://files.pythonhosted.org/packages/da/0b/a877a6627dc8318fdb84e357c5e1a758c0941ab1ddffdafd231983788579/pillow-12.1.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:86172b0831b82ce4f7877f280055892b31179e1576aa00d0df3bb1bbf8c3e524", size = 7190954, upload-time = "2026-02-11T04:21:46.114Z" },
{ url = "https://files.pythonhosted.org/packages/83/43/6f732ff85743cf746b1361b91665d9f5155e1483817f693f8d57ea93147f/pillow-12.1.1-cp313-cp313t-win32.whl", hash = "sha256:44ce27545b6efcf0fdbdceb31c9a5bdea9333e664cda58a7e674bb74608b3986", size = 6336441, upload-time = "2026-02-11T04:21:48.22Z" },
{ url = "https://files.pythonhosted.org/packages/3b/44/e865ef3986611bb75bfabdf94a590016ea327833f434558801122979cd0e/pillow-12.1.1-cp313-cp313t-win_amd64.whl", hash = "sha256:a285e3eb7a5a45a2ff504e31f4a8d1b12ef62e84e5411c6804a42197c1cf586c", size = 7045383, upload-time = "2026-02-11T04:21:50.015Z" },
{ url = "https://files.pythonhosted.org/packages/a8/c6/f4fb24268d0c6908b9f04143697ea18b0379490cb74ba9e8d41b898bd005/pillow-12.1.1-cp313-cp313t-win_arm64.whl", hash = "sha256:cc7d296b5ea4d29e6570dabeaed58d31c3fea35a633a69679fb03d7664f43fb3", size = 2456104, upload-time = "2026-02-11T04:21:51.633Z" },
{ url = "https://files.pythonhosted.org/packages/03/d0/bebb3ffbf31c5a8e97241476c4cf8b9828954693ce6744b4a2326af3e16b/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:417423db963cb4be8bac3fc1204fe61610f6abeed1580a7a2cbb2fbda20f12af", size = 4062652, upload-time = "2026-02-11T04:21:53.19Z" },
{ url = "https://files.pythonhosted.org/packages/2d/c0/0e16fb0addda4851445c28f8350d8c512f09de27bbb0d6d0bbf8b6709605/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:b957b71c6b2387610f556a7eb0828afbe40b4a98036fc0d2acfa5a44a0c2036f", size = 4138823, upload-time = "2026-02-11T04:22:03.088Z" },
{ url = "https://files.pythonhosted.org/packages/6b/fb/6170ec655d6f6bb6630a013dd7cf7bc218423d7b5fa9071bf63dc32175ae/pillow-12.1.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:097690ba1f2efdeb165a20469d59d8bb03c55fb6621eb2041a060ae8ea3e9642", size = 3601143, upload-time = "2026-02-11T04:22:04.909Z" },
{ url = "https://files.pythonhosted.org/packages/59/04/dc5c3f297510ba9a6837cbb318b87dd2b8f73eb41a43cc63767f65cb599c/pillow-12.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2815a87ab27848db0321fb78c7f0b2c8649dee134b7f2b80c6a45c6831d75ccd", size = 5266254, upload-time = "2026-02-11T04:22:07.656Z" },
{ url = "https://files.pythonhosted.org/packages/05/30/5db1236b0d6313f03ebf97f5e17cda9ca060f524b2fcc875149a8360b21c/pillow-12.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f7ed2c6543bad5a7d5530eb9e78c53132f93dfa44a28492db88b41cdab885202", size = 4657499, upload-time = "2026-02-11T04:22:09.613Z" },
{ url = "https://files.pythonhosted.org/packages/6f/18/008d2ca0eb612e81968e8be0bbae5051efba24d52debf930126d7eaacbba/pillow-12.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:652a2c9ccfb556235b2b501a3a7cf3742148cd22e04b5625c5fe057ea3e3191f", size = 6232137, upload-time = "2026-02-11T04:22:11.434Z" },
{ url = "https://files.pythonhosted.org/packages/70/f1/f14d5b8eeb4b2cd62b9f9f847eb6605f103df89ef619ac68f92f748614ea/pillow-12.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d6e4571eedf43af33d0fc233a382a76e849badbccdf1ac438841308652a08e1f", size = 8042721, upload-time = "2026-02-11T04:22:13.321Z" },
{ url = "https://files.pythonhosted.org/packages/5a/d6/17824509146e4babbdabf04d8171491fa9d776f7061ff6e727522df9bd03/pillow-12.1.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b574c51cf7d5d62e9be37ba446224b59a2da26dc4c1bb2ecbe936a4fb1a7cb7f", size = 6347798, upload-time = "2026-02-11T04:22:15.449Z" },
{ url = "https://files.pythonhosted.org/packages/d1/ee/c85a38a9ab92037a75615aba572c85ea51e605265036e00c5b67dfafbfe2/pillow-12.1.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a37691702ed687799de29a518d63d4682d9016932db66d4e90c345831b02fb4e", size = 7039315, upload-time = "2026-02-11T04:22:17.24Z" },
{ url = "https://files.pythonhosted.org/packages/ec/f3/bc8ccc6e08a148290d7523bde4d9a0d6c981db34631390dc6e6ec34cacf6/pillow-12.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f95c00d5d6700b2b890479664a06e754974848afaae5e21beb4d83c106923fd0", size = 6462360, upload-time = "2026-02-11T04:22:19.111Z" },
{ url = "https://files.pythonhosted.org/packages/f6/ab/69a42656adb1d0665ab051eec58a41f169ad295cf81ad45406963105408f/pillow-12.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:559b38da23606e68681337ad74622c4dbba02254fc9cb4488a305dd5975c7eeb", size = 7165438, upload-time = "2026-02-11T04:22:21.041Z" },
{ url = "https://files.pythonhosted.org/packages/02/46/81f7aa8941873f0f01d4b55cc543b0a3d03ec2ee30d617a0448bf6bd6dec/pillow-12.1.1-cp314-cp314-win32.whl", hash = "sha256:03edcc34d688572014ff223c125a3f77fb08091e4607e7745002fc214070b35f", size = 6431503, upload-time = "2026-02-11T04:22:22.833Z" },
{ url = "https://files.pythonhosted.org/packages/40/72/4c245f7d1044b67affc7f134a09ea619d4895333d35322b775b928180044/pillow-12.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:50480dcd74fa63b8e78235957d302d98d98d82ccbfac4c7e12108ba9ecbdba15", size = 7176748, upload-time = "2026-02-11T04:22:24.64Z" },
{ url = "https://files.pythonhosted.org/packages/e4/ad/8a87bdbe038c5c698736e3348af5c2194ffb872ea52f11894c95f9305435/pillow-12.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:5cb1785d97b0c3d1d1a16bc1d710c4a0049daefc4935f3a8f31f827f4d3d2e7f", size = 2544314, upload-time = "2026-02-11T04:22:26.685Z" },
{ url = "https://files.pythonhosted.org/packages/6c/9d/efd18493f9de13b87ede7c47e69184b9e859e4427225ea962e32e56a49bc/pillow-12.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1f90cff8aa76835cba5769f0b3121a22bd4eb9e6884cfe338216e557a9a548b8", size = 5268612, upload-time = "2026-02-11T04:22:29.884Z" },
{ url = "https://files.pythonhosted.org/packages/f8/f1/4f42eb2b388eb2ffc660dcb7f7b556c1015c53ebd5f7f754965ef997585b/pillow-12.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1f1be78ce9466a7ee64bfda57bdba0f7cc499d9794d518b854816c41bf0aa4e9", size = 4660567, upload-time = "2026-02-11T04:22:31.799Z" },
{ url = "https://files.pythonhosted.org/packages/01/54/df6ef130fa43e4b82e32624a7b821a2be1c5653a5fdad8469687a7db4e00/pillow-12.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:42fc1f4677106188ad9a55562bbade416f8b55456f522430fadab3cef7cd4e60", size = 6269951, upload-time = "2026-02-11T04:22:33.921Z" },
{ url = "https://files.pythonhosted.org/packages/a9/48/618752d06cc44bb4aae8ce0cd4e6426871929ed7b46215638088270d9b34/pillow-12.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:98edb152429ab62a1818039744d8fbb3ccab98a7c29fc3d5fcef158f3f1f68b7", size = 8074769, upload-time = "2026-02-11T04:22:35.877Z" },
{ url = "https://files.pythonhosted.org/packages/c3/bd/f1d71eb39a72fa088d938655afba3e00b38018d052752f435838961127d8/pillow-12.1.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d470ab1178551dd17fdba0fef463359c41aaa613cdcd7ff8373f54be629f9f8f", size = 6381358, upload-time = "2026-02-11T04:22:37.698Z" },
{ url = "https://files.pythonhosted.org/packages/64/ef/c784e20b96674ed36a5af839305f55616f8b4f8aa8eeccf8531a6e312243/pillow-12.1.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6408a7b064595afcab0a49393a413732a35788f2a5092fdc6266952ed67de586", size = 7068558, upload-time = "2026-02-11T04:22:39.597Z" },
{ url = "https://files.pythonhosted.org/packages/73/cb/8059688b74422ae61278202c4e1ad992e8a2e7375227be0a21c6b87ca8d5/pillow-12.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5d8c41325b382c07799a3682c1c258469ea2ff97103c53717b7893862d0c98ce", size = 6493028, upload-time = "2026-02-11T04:22:42.73Z" },
{ url = "https://files.pythonhosted.org/packages/c6/da/e3c008ed7d2dd1f905b15949325934510b9d1931e5df999bb15972756818/pillow-12.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c7697918b5be27424e9ce568193efd13d925c4481dd364e43f5dff72d33e10f8", size = 7191940, upload-time = "2026-02-11T04:22:44.543Z" },
{ url = "https://files.pythonhosted.org/packages/01/4a/9202e8d11714c1fc5951f2e1ef362f2d7fbc595e1f6717971d5dd750e969/pillow-12.1.1-cp314-cp314t-win32.whl", hash = "sha256:d2912fd8114fc5545aa3a4b5576512f64c55a03f3ebcca4c10194d593d43ea36", size = 6438736, upload-time = "2026-02-11T04:22:46.347Z" },
{ url = "https://files.pythonhosted.org/packages/f3/ca/cbce2327eb9885476b3957b2e82eb12c866a8b16ad77392864ad601022ce/pillow-12.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:4ceb838d4bd9dab43e06c363cab2eebf63846d6a4aeaea283bbdfd8f1a8ed58b", size = 7182894, upload-time = "2026-02-11T04:22:48.114Z" },
{ 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]] [[package]]
name = "proto-plus" name = "proto-plus"
version = "1.27.1" version = "1.27.1"
@@ -469,6 +529,23 @@ 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 = "qrcode"
version = "8.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/8f/b2/7fc2931bfae0af02d5f53b174e9cf701adbb35f39d69c2af63d4a39f81a9/qrcode-8.2.tar.gz", hash = "sha256:35c3f2a4172b33136ab9f6b3ef1c00260dd2f66f858f24d88418a015f446506c", size = 43317, upload-time = "2025-05-01T15:44:24.726Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/dd/b8/d2d6d731733f51684bbf76bf34dab3b70a9148e8f2cef2bb544fccec681a/qrcode-8.2-py3-none-any.whl", hash = "sha256:16e64e0716c14960108e85d853062c9e8bba5ca8252c0b4d0231b9df4060ff4f", size = 45986, upload-time = "2025-05-01T15:44:22.781Z" },
]
[package.optional-dependencies]
pil = [
{ name = "pillow" },
]
[[package]] [[package]]
name = "requests" name = "requests"
version = "2.32.5" version = "2.32.5"