Compare commits
15 Commits
0.07
...
claude-sug
| Author | SHA1 | Date | |
|---|---|---|---|
| 033349cafa | |||
| 0d0c2af778 | |||
| 7170cd4d27 | |||
| 251d7ba6b5 | |||
| 76cdcba424 | |||
| 8662cb4592 | |||
| c8c145486f | |||
|
|
27ad66ff79 | ||
|
|
1257f0d644 | ||
|
|
75a36eb49b | ||
|
|
f40015a2ef | ||
|
|
5bdc7a4566 | ||
|
|
9ee2dd782d | ||
|
|
4bb8c7420c | ||
|
|
b0276f68b3 |
@@ -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
.gitignore
vendored
3
.gitignore
vendored
@@ -1,3 +1,6 @@
|
|||||||
# python cache
|
# python cache
|
||||||
**/*.pyc
|
**/*.pyc
|
||||||
.secret
|
.secret
|
||||||
|
|
||||||
|
# local tmp folder
|
||||||
|
tmp/
|
||||||
|
|||||||
33
.vscode/launch.json
vendored
Normal file
33
.vscode/launch.json
vendored
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"version": "0.2.0",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "Python Debugger: Flask",
|
||||||
|
"type": "debugpy",
|
||||||
|
"request": "launch",
|
||||||
|
"module": "flask",
|
||||||
|
"python": "${workspaceFolder}/.venv/bin/python",
|
||||||
|
"env": {
|
||||||
|
"FLASK_APP": "app.py",
|
||||||
|
"FLASK_DEBUG": "1"
|
||||||
|
},
|
||||||
|
"args": [
|
||||||
|
"run",
|
||||||
|
"--no-debugger",
|
||||||
|
"--no-reload",
|
||||||
|
"--host", "0.0.0.0",
|
||||||
|
"--port", "5001"
|
||||||
|
],
|
||||||
|
"jinja": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Python Debugger: Attach",
|
||||||
|
"type": "debugpy",
|
||||||
|
"request": "attach",
|
||||||
|
"connect": {
|
||||||
|
"host": "localhost",
|
||||||
|
"port": 5678
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
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
|
||||||
|
|
||||||
|
|||||||
18
Makefile
18
Makefile
@@ -1,4 +1,4 @@
|
|||||||
.PHONY: help fees match web image run sync sync-2026 test test-v
|
.PHONY: help fees match web web-debug image run sync sync-2026 test test-v docs
|
||||||
|
|
||||||
export PYTHONPATH := scripts:$(PYTHONPATH)
|
export PYTHONPATH := scripts:$(PYTHONPATH)
|
||||||
VENV := .venv
|
VENV := .venv
|
||||||
@@ -16,15 +16,18 @@ help:
|
|||||||
@echo " make fees - Calculate monthly fees from the attendance sheet"
|
@echo " make fees - Calculate monthly fees from the attendance sheet"
|
||||||
@echo " make match - Match Fio bank payments against expected attendance fees"
|
@echo " make match - Match Fio bank payments against expected attendance fees"
|
||||||
@echo " make web - Start a dynamic web dashboard locally"
|
@echo " make web - Start a dynamic web dashboard locally"
|
||||||
|
@echo " make web-debug - Start a dynamic web dashboard locally in debug mode"
|
||||||
@echo " make image - Build an OCI container image"
|
@echo " make image - Build an OCI container image"
|
||||||
@echo " make run - Run the built Docker image locally"
|
@echo " make run - Run the built Docker image locally"
|
||||||
@echo " make sync - Sync Fio transactions to Google Sheets"
|
@echo " make sync - Sync Fio transactions to Google Sheets"
|
||||||
|
@echo " make sync-2025 - Sync Fio transactions for Q4 2025 (Oct-Dec)"
|
||||||
@echo " make sync-2026 - Sync Fio transactions for the whole year of 2026"
|
@echo " make sync-2026 - Sync Fio transactions for the whole year of 2026"
|
||||||
@echo " make infer - Infer payment details (Person, Purpose, Amount) in the sheet"
|
@echo " make infer - Infer payment details (Person, Purpose, Amount) in the sheet"
|
||||||
@echo " make reconcile - Show balance report using Google Sheets data"
|
@echo " make reconcile - Show balance report using Google Sheets data"
|
||||||
@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
|
||||||
@@ -38,15 +41,21 @@ match: $(PYTHON)
|
|||||||
web: $(PYTHON)
|
web: $(PYTHON)
|
||||||
$(PYTHON) app.py
|
$(PYTHON) app.py
|
||||||
|
|
||||||
|
web-debug: $(PYTHON)
|
||||||
|
FLASK_DEBUG=1 $(PYTHON) app.py
|
||||||
|
|
||||||
image:
|
image:
|
||||||
docker build -t fuj-management:latest -f build/Dockerfile .
|
docker build -t fuj-management:latest -f build/Dockerfile .
|
||||||
|
|
||||||
run:
|
run:
|
||||||
docker run -it --rm -p 5001:5001 fuj-management:latest
|
docker run -it --rm -p 5001:5001 -v $(CURDIR)/.secret:/app/.secret:ro fuj-management:latest
|
||||||
|
|
||||||
sync: $(PYTHON)
|
sync: $(PYTHON)
|
||||||
$(PYTHON) scripts/sync_fio_to_sheets.py --credentials .secret/fuj-management-bot-credentials.json
|
$(PYTHON) scripts/sync_fio_to_sheets.py --credentials .secret/fuj-management-bot-credentials.json
|
||||||
|
|
||||||
|
sync-2025: $(PYTHON)
|
||||||
|
$(PYTHON) scripts/sync_fio_to_sheets.py --credentials .secret/fuj-management-bot-credentials.json --from 2025-10-01 --to 2025-12-31 --sort-by-date
|
||||||
|
|
||||||
sync-2026: $(PYTHON)
|
sync-2026: $(PYTHON)
|
||||||
$(PYTHON) scripts/sync_fio_to_sheets.py --credentials .secret/fuj-management-bot-credentials.json --from 2026-01-01 --to 2026-12-31 --sort-by-date
|
$(PYTHON) scripts/sync_fio_to_sheets.py --credentials .secret/fuj-management-bot-credentials.json --from 2026-01-01 --to 2026-12-31 --sort-by-date
|
||||||
|
|
||||||
@@ -61,3 +70,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
|
||||||
|
|||||||
415
app.py
415
app.py
@@ -2,17 +2,90 @@ 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
|
||||||
|
import logging
|
||||||
|
from flask import Flask, render_template, g, send_file, request
|
||||||
|
|
||||||
|
# Configure logging, allowing override via LOG_LEVEL environment variable
|
||||||
|
log_level = os.environ.get("LOG_LEVEL", "INFO").upper()
|
||||||
|
logging.basicConfig(level=getattr(logging, log_level, logging.INFO), format='%(asctime)s - %(name)s:%(filename)s:%(lineno)d [%(funcName)s] - %(levelname)s - %(message)s')
|
||||||
|
|
||||||
# Add scripts directory to path to allow importing from it
|
# 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 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,
|
||||||
|
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
|
||||||
|
|
||||||
|
def get_cached_data(cache_key, sheet_id, fetch_func, *args, serialize=None, deserialize=None, **kwargs):
|
||||||
|
mod_time = get_sheet_modified_time(cache_key)
|
||||||
|
if mod_time:
|
||||||
|
cached = read_cache(cache_key, mod_time)
|
||||||
|
if cached is not None:
|
||||||
|
return deserialize(cached) if deserialize else cached
|
||||||
|
data = fetch_func(*args, **kwargs)
|
||||||
|
if mod_time:
|
||||||
|
write_cache(cache_key, mod_time, serialize(data) if serialize else data)
|
||||||
|
return data
|
||||||
|
|
||||||
|
def get_month_labels(sorted_months, merged_months):
|
||||||
|
labels = {}
|
||||||
|
for m in sorted_months:
|
||||||
|
dt = datetime.strptime(m, "%Y-%m")
|
||||||
|
# Find which months were merged into m (e.g. 2026-01 is merged into 2026-02)
|
||||||
|
merged_in = sorted([k for k, v in merged_months.items() if v == m])
|
||||||
|
if merged_in:
|
||||||
|
all_dts = [datetime.strptime(x, "%Y-%m") for x in sorted(merged_in + [m])]
|
||||||
|
years = {d.year for d in all_dts}
|
||||||
|
if len(years) > 1:
|
||||||
|
parts = [d.strftime("%b %Y") for d in all_dts]
|
||||||
|
labels[m] = "+".join(parts)
|
||||||
|
else:
|
||||||
|
parts = [d.strftime("%b") for d in all_dts]
|
||||||
|
labels[m] = f"{'+'.join(parts)} {dt.strftime('%Y')}"
|
||||||
|
else:
|
||||||
|
labels[m] = dt.strftime("%b %Y")
|
||||||
|
return labels
|
||||||
|
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
|
|
||||||
|
@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
|
||||||
@@ -23,23 +96,29 @@ def fees():
|
|||||||
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"
|
||||||
|
|
||||||
members, sorted_months = get_members_with_fees()
|
members_data = get_cached_data("attendance_regular", ATTENDANCE_SHEET_ID, get_members_with_fees)
|
||||||
if not members:
|
record_step("fetch_members")
|
||||||
|
if not members_data:
|
||||||
return "No data."
|
return "No data."
|
||||||
|
members, sorted_months = members_data
|
||||||
|
|
||||||
# Filter to adults only for display
|
# Filter to adults only for display
|
||||||
results = [(name, fees) for name, tier, fees in members if tier == "A"]
|
results = [(name, fees) for name, tier, fees in members if tier == "A"]
|
||||||
|
|
||||||
# Format month labels
|
# Format month labels
|
||||||
month_labels = {
|
month_labels = get_month_labels(sorted_months, ADULT_MERGED_MONTHS)
|
||||||
m: datetime.strptime(m, "%Y-%m").strftime("%b %Y") for m in sorted_months
|
|
||||||
}
|
|
||||||
|
|
||||||
monthly_totals = {m: 0 for m in sorted_months}
|
monthly_totals = {m: 0 for m in sorted_months}
|
||||||
|
|
||||||
# Get exceptions for formatting
|
# Get exceptions for formatting
|
||||||
credentials_path = ".secret/fuj-management-bot-credentials.json"
|
credentials_path = CREDENTIALS_PATH
|
||||||
exceptions = fetch_exceptions(PAYMENTS_SHEET_ID, credentials_path)
|
exceptions = get_cached_data(
|
||||||
|
"exceptions_dict", PAYMENTS_SHEET_ID, fetch_exceptions,
|
||||||
|
PAYMENTS_SHEET_ID, credentials_path,
|
||||||
|
serialize=lambda d: [[list(k), v] for k, v in d.items()],
|
||||||
|
deserialize=lambda c: {tuple(k): v for k, v in c},
|
||||||
|
)
|
||||||
|
record_step("fetch_exceptions")
|
||||||
|
|
||||||
formatted_results = []
|
formatted_results = []
|
||||||
for name, month_fees in results:
|
for name, month_fees in results:
|
||||||
@@ -47,7 +126,6 @@ def fees():
|
|||||||
norm_name = normalize(name)
|
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
|
|
||||||
|
|
||||||
# Check for exception
|
# Check for exception
|
||||||
norm_period = normalize(m)
|
norm_period = normalize(m)
|
||||||
@@ -58,11 +136,15 @@ def fees():
|
|||||||
cell = f"{override_amount} ({fee}) CZK ({count})" if count > 0 else f"{override_amount} ({fee}) CZK"
|
cell = f"{override_amount} ({fee}) CZK ({count})" if count > 0 else f"{override_amount} ({fee}) CZK"
|
||||||
is_overridden = True
|
is_overridden = True
|
||||||
else:
|
else:
|
||||||
|
if isinstance(fee, int):
|
||||||
|
monthly_totals[m] += fee
|
||||||
cell = f"{fee} CZK ({count})" if count > 0 else "-"
|
cell = f"{fee} CZK ({count})" if count > 0 else "-"
|
||||||
is_overridden = False
|
is_overridden = False
|
||||||
row["months"].append({"cell": cell, "overridden": is_overridden})
|
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],
|
||||||
@@ -72,26 +154,116 @@ def fees():
|
|||||||
payments_url=payments_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 = CREDENTIALS_PATH
|
||||||
|
exceptions = get_cached_data(
|
||||||
|
"exceptions_dict", PAYMENTS_SHEET_ID, fetch_exceptions,
|
||||||
|
PAYMENTS_SHEET_ID, credentials_path,
|
||||||
|
serialize=lambda d: [[list(k), v] for k, v in d.items()],
|
||||||
|
deserialize=lambda c: {tuple(k): v for k, v in c},
|
||||||
|
)
|
||||||
|
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")
|
@app.route("/reconcile")
|
||||||
def reconcile_view():
|
def reconcile_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"
|
||||||
|
|
||||||
# Use hardcoded credentials path for now, consistent with other scripts
|
# Use hardcoded credentials path for now, consistent with other scripts
|
||||||
credentials_path = ".secret/fuj-management-bot-credentials.json"
|
credentials_path = CREDENTIALS_PATH
|
||||||
|
|
||||||
members, sorted_months = get_members_with_fees()
|
members_data = get_cached_data("attendance_regular", ATTENDANCE_SHEET_ID, get_members_with_fees)
|
||||||
if not members:
|
record_step("fetch_members")
|
||||||
|
if not members_data:
|
||||||
return "No data."
|
return "No data."
|
||||||
|
members, sorted_months = members_data
|
||||||
|
|
||||||
transactions = 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)
|
||||||
exceptions = fetch_exceptions(PAYMENTS_SHEET_ID, credentials_path)
|
record_step("fetch_payments")
|
||||||
|
exceptions = get_cached_data(
|
||||||
|
"exceptions_dict", PAYMENTS_SHEET_ID, fetch_exceptions,
|
||||||
|
PAYMENTS_SHEET_ID, credentials_path,
|
||||||
|
serialize=lambda d: [[list(k), v] for k, v in d.items()],
|
||||||
|
deserialize=lambda c: {tuple(k): v for k, v in c},
|
||||||
|
)
|
||||||
|
record_step("fetch_exceptions")
|
||||||
result = reconcile(members, sorted_months, transactions, exceptions)
|
result = reconcile(members, sorted_months, transactions, exceptions)
|
||||||
|
record_step("reconcile")
|
||||||
|
|
||||||
# Format month labels
|
# Format month labels
|
||||||
month_labels = {
|
month_labels = get_month_labels(sorted_months, ADULT_MERGED_MONTHS)
|
||||||
m: datetime.strptime(m, "%Y-%m").strftime("%b %Y") for m in sorted_months
|
|
||||||
}
|
|
||||||
|
|
||||||
# Filter to adults for the main table
|
# 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"])
|
||||||
@@ -99,55 +271,192 @@ def reconcile_view():
|
|||||||
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"]}
|
row = {"name": name, "months": [], "balance": data["total_balance"], "unpaid_periods": ""}
|
||||||
|
unpaid_months = []
|
||||||
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, "paid": 0})
|
||||||
expected = mdata["expected"]
|
expected = mdata["expected"]
|
||||||
original = mdata["original_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"
|
if expected > 0:
|
||||||
|
if paid >= expected:
|
||||||
|
status = "ok"
|
||||||
|
cell_text = "OK"
|
||||||
elif paid > 0:
|
elif paid > 0:
|
||||||
cell = f"{paid}/{expected}"
|
status = "partial"
|
||||||
|
cell_text = f"{paid}/{expected}"
|
||||||
|
amount_to_pay = expected - paid
|
||||||
|
unpaid_months.append(month_labels[m])
|
||||||
else:
|
else:
|
||||||
cell = f"UNPAID {expected}"
|
status = "unpaid"
|
||||||
|
cell_text = f"UNPAID {expected}"
|
||||||
|
amount_to_pay = expected
|
||||||
|
unpaid_months.append(month_labels[m])
|
||||||
|
elif paid > 0:
|
||||||
|
status = "surplus"
|
||||||
|
cell_text = f"PAID {paid}"
|
||||||
|
|
||||||
row["months"].append(cell)
|
row["months"].append({
|
||||||
|
"text": cell_text,
|
||||||
|
"status": status,
|
||||||
|
"amount": amount_to_pay,
|
||||||
|
"month": month_labels[m]
|
||||||
|
})
|
||||||
|
|
||||||
|
row["unpaid_periods"] = ", ".join(unpaid_months) if unpaid_months else ("Older debt" if data["total_balance"] < 0 else "")
|
||||||
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)
|
||||||
|
|
||||||
# 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 and n in adult_names], 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 and n in adult_names], key=lambda x: x["name"])
|
||||||
# Format unmatched
|
# Format unmatched
|
||||||
unmatched = result["unmatched"]
|
unmatched = result["unmatched"]
|
||||||
import json
|
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,
|
raw_months=sorted_months,
|
||||||
results=formatted_results,
|
results=formatted_results,
|
||||||
member_data=json.dumps(result["members"]),
|
member_data=json.dumps(result["members"]),
|
||||||
|
month_labels_json=json.dumps(month_labels),
|
||||||
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("/reconcile-juniors")
|
||||||
|
def reconcile_juniors_view():
|
||||||
|
attendance_url = f"https://docs.google.com/spreadsheets/d/{ATTENDANCE_SHEET_ID}/edit#gid={JUNIOR_SHEET_GID}"
|
||||||
|
payments_url = f"https://docs.google.com/spreadsheets/d/{PAYMENTS_SHEET_ID}/edit"
|
||||||
|
|
||||||
|
credentials_path = CREDENTIALS_PATH
|
||||||
|
|
||||||
|
junior_members_data = get_cached_data("attendance_juniors", ATTENDANCE_SHEET_ID, get_junior_members_with_fees)
|
||||||
|
record_step("fetch_junior_members")
|
||||||
|
if not junior_members_data:
|
||||||
|
return "No data."
|
||||||
|
junior_members, sorted_months = junior_members_data
|
||||||
|
|
||||||
|
transactions = get_cached_data("payments_transactions", PAYMENTS_SHEET_ID, fetch_sheet_data, PAYMENTS_SHEET_ID, credentials_path)
|
||||||
|
record_step("fetch_payments")
|
||||||
|
exceptions = get_cached_data(
|
||||||
|
"exceptions_dict", PAYMENTS_SHEET_ID, fetch_exceptions,
|
||||||
|
PAYMENTS_SHEET_ID, credentials_path,
|
||||||
|
serialize=lambda d: [[list(k), v] for k, v in d.items()],
|
||||||
|
deserialize=lambda c: {tuple(k): v for k, v in c},
|
||||||
|
)
|
||||||
|
record_step("fetch_exceptions")
|
||||||
|
|
||||||
|
# Adapt junior tuple format (name, tier, {month: (fee, total_count, adult_count, junior_count)})
|
||||||
|
# to what match_payments expects: (name, tier, {month: (expected_fee, attendance_count)})
|
||||||
|
adapted_members = []
|
||||||
|
for name, tier, fees_dict in junior_members:
|
||||||
|
adapted_fees = {}
|
||||||
|
for m, fee_data in fees_dict.items():
|
||||||
|
if len(fee_data) == 4:
|
||||||
|
fee, total_count, _, _ = fee_data
|
||||||
|
adapted_fees[m] = (fee, total_count)
|
||||||
|
else:
|
||||||
|
fee, count = fee_data
|
||||||
|
adapted_fees[m] = (fee, count)
|
||||||
|
adapted_members.append((name, tier, adapted_fees))
|
||||||
|
|
||||||
|
result = reconcile(adapted_members, sorted_months, transactions, exceptions)
|
||||||
|
record_step("reconcile")
|
||||||
|
|
||||||
|
# Format month labels
|
||||||
|
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])
|
||||||
|
|
||||||
|
formatted_results = []
|
||||||
|
for name in junior_names:
|
||||||
|
data = result["members"][name]
|
||||||
|
row = {"name": name, "months": [], "balance": data["total_balance"], "unpaid_periods": ""}
|
||||||
|
unpaid_months = []
|
||||||
|
for m in sorted_months:
|
||||||
|
mdata = data["months"].get(m, {"expected": 0, "original_expected": 0, "paid": 0})
|
||||||
|
expected = mdata["expected"]
|
||||||
|
paid = int(mdata["paid"])
|
||||||
|
|
||||||
|
status = "empty"
|
||||||
|
cell_text = "-"
|
||||||
|
amount_to_pay = 0
|
||||||
|
|
||||||
|
if expected == "?" or (isinstance(expected, int) and expected > 0):
|
||||||
|
if expected == "?":
|
||||||
|
status = "empty"
|
||||||
|
cell_text = "?"
|
||||||
|
elif paid >= expected:
|
||||||
|
status = "ok"
|
||||||
|
cell_text = "OK"
|
||||||
|
elif paid > 0:
|
||||||
|
status = "partial"
|
||||||
|
cell_text = f"{paid}/{expected}"
|
||||||
|
amount_to_pay = expected - paid
|
||||||
|
unpaid_months.append(month_labels[m])
|
||||||
|
else:
|
||||||
|
status = "unpaid"
|
||||||
|
cell_text = f"UNPAID {expected}"
|
||||||
|
amount_to_pay = expected
|
||||||
|
unpaid_months.append(month_labels[m])
|
||||||
|
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["unpaid_periods"] = ", ".join(unpaid_months) if unpaid_months else ("Older debt" if data["total_balance"] < 0 else "")
|
||||||
|
row["balance"] = data["total_balance"]
|
||||||
|
formatted_results.append(row)
|
||||||
|
|
||||||
|
# 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"])
|
||||||
|
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"])
|
||||||
|
import json
|
||||||
|
|
||||||
|
record_step("process_data")
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
"reconcile-juniors.html",
|
||||||
|
months=[month_labels[m] for m in sorted_months],
|
||||||
|
raw_months=sorted_months,
|
||||||
|
results=formatted_results,
|
||||||
|
member_data=json.dumps(result["members"]),
|
||||||
|
month_labels_json=json.dumps(month_labels),
|
||||||
|
credits=credits,
|
||||||
|
debts=debts,
|
||||||
|
unmatched=[],
|
||||||
|
attendance_url=attendance_url,
|
||||||
|
payments_url=payments_url,
|
||||||
|
bank_account=BANK_ACCOUNT
|
||||||
)
|
)
|
||||||
|
|
||||||
@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 = 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")
|
||||||
|
|
||||||
# Group transactions by person
|
# Group transactions by person
|
||||||
grouped = {}
|
grouped = {}
|
||||||
@@ -171,6 +480,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,
|
||||||
@@ -179,5 +489,42 @@ 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", "")
|
||||||
|
|
||||||
|
# Validate account: allow IBAN (letters+digits) or Czech format (digits/digits)
|
||||||
|
if not re.match(r'^[A-Z]{2}\d{2,34}$|^\d{1,16}/\d{4}$', account):
|
||||||
|
account = BANK_ACCOUNT
|
||||||
|
|
||||||
|
# QR Platba standard: SPD*1.0*ACC:accountNumber*BC:bankCode*AM:amount*CC:CZK*MSG:message
|
||||||
|
acc_parts = account.split('/')
|
||||||
|
if len(acc_parts) == 2:
|
||||||
|
acc_str = f"{acc_parts[0]}*BC:{acc_parts[1]}"
|
||||||
|
else:
|
||||||
|
acc_str = account
|
||||||
|
|
||||||
|
try:
|
||||||
|
amt_val = float(amount)
|
||||||
|
if amt_val < 0 or amt_val > 10_000_000:
|
||||||
|
amt_val = 0
|
||||||
|
amt_str = f"{amt_val:.2f}"
|
||||||
|
except ValueError:
|
||||||
|
amt_str = "0.00"
|
||||||
|
|
||||||
|
# Message max 60 characters, strip SPD delimiters to prevent injection
|
||||||
|
msg_str = message[:60].replace("*", "")
|
||||||
|
|
||||||
|
qr_data = f"SPD*1.0*ACC:{acc_str}*AM:{amt_str}*CC:CZK*MSG:{msg_str}"
|
||||||
|
|
||||||
|
img = qrcode.make(qr_data)
|
||||||
|
buf = io.BytesIO()
|
||||||
|
img.save(buf, format='PNG')
|
||||||
|
buf.seek(0)
|
||||||
|
|
||||||
|
return send_file(buf, mimetype='image/png')
|
||||||
|
|
||||||
if __name__ == "__main__":
|
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)
|
||||||
|
|||||||
@@ -12,7 +12,10 @@ 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 \
|
||||||
|
gunicorn
|
||||||
|
|
||||||
COPY app.py Makefile ./
|
COPY app.py Makefile ./
|
||||||
COPY scripts/ ./scripts/
|
COPY scripts/ ./scripts/
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
21
prompts/2026-03-09-junior-fees.md
Normal file
21
prompts/2026-03-09-junior-fees.md
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
---
|
||||||
|
i have new attendance sheet specifically for juniors over here: https://docs.google.com/spreadsheets/d/1E2e_gT_K5AwSRCDLDTa2UetZTkHmBOcz0kFbBUNUNBA/edit?gid=1213318614#gid=1213318614
|
||||||
|
|
||||||
|
I would like you to treat as junior anyone in that sheet
|
||||||
|
- who does not have tier: X
|
||||||
|
- is above line that says in column A: # Treneri
|
||||||
|
|
||||||
|
i want to create similar page as we have in /fees, but for juniors - let's give it path /fees-juniors
|
||||||
|
|
||||||
|
i want you to merge monthly attendance from both sheets in the document, but from the first sheet collect only attendance for members in tier: J
|
||||||
|
|
||||||
|
Rules for monthly payments will be:
|
||||||
|
- attended only once - put ? mark as output
|
||||||
|
- 2 and more: 500 czk per month
|
||||||
|
|
||||||
|
Also i want to have an option to merge multiple subsequent months to act as one for the payment, for now give me an option to specify it in some datastructure in the code, later we might read it from google sheet. Immediatelly prepare merge of january and february 2026
|
||||||
|
|
||||||
|
Also even though now the monthly payment is one value, i would like to have it configurable per month, for now prepare exception for september 2025 with 250
|
||||||
|
|
||||||
|
---
|
||||||
|
cool, now i need an improvement: if the member name in both sheets is exactly the same i want to treat it as one person. Also i want you to keep attendances from "adult practice" (first sheet) and juniors practices (other one) in a datastructure separately, so that you can display primarily sum of those, but also each of them (in brackets after sum) so that we have better visibility
|
||||||
34
prompts/outcomes/2026-03-09-junior-fees.md
Normal file
34
prompts/outcomes/2026-03-09-junior-fees.md
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# Junior Fees Implementation Summary
|
||||||
|
|
||||||
|
Based on the recent updates, we have introduced a dedicated system for tracking, displaying, and reconciling junior team attendances and payments.
|
||||||
|
|
||||||
|
## 1. Implemented Features
|
||||||
|
- **Dual-Sheet Architecture:** The system now pulls attendance from two separate Google Sheet tabs—one for adult practices and another for junior practices.
|
||||||
|
- **New Views:**
|
||||||
|
- `/fees-juniors`: A dedicated dashboard showing junior attendances and calculated fees.
|
||||||
|
- `/reconcile-juniors`: A dedicated page matching Fio bank transactions against expected junior fees.
|
||||||
|
- **Granular Attendance Display:** The UI clearly separates and tallies adult (`A`) and junior (`J`) practice counts for each member (e.g., `4 (2A+2J)` or `2 (J)`).
|
||||||
|
|
||||||
|
## 2. Membership Rules
|
||||||
|
- **Identification:** A member is processed as a junior if they appear in the *Junior Sheet*, UNLESS:
|
||||||
|
- They are listed below the separator line `# Treneri` (or `# Trenéři`).
|
||||||
|
- Their tier is explicitly marked as `X`.
|
||||||
|
- **Adult Sheet Fallback:** Members from the Adult Sheet whose tier is marked as `J` are also tracked as juniors.
|
||||||
|
- **Merging Identities:** If a member has the identical name in both the Adult Sheet and the Junior Sheet, their attendance records are merged together into a single profile.
|
||||||
|
|
||||||
|
## 3. Fee Calculation Rules
|
||||||
|
The base fee calculation for juniors relies on the total combined attendance across both adult and junior practices for a given month:
|
||||||
|
- **0 attendances:** 0 CZK
|
||||||
|
- **Exactly 1 attendance:** `?` (Flags the month for manual review/decision)
|
||||||
|
- **2 or more attendances:** 500 CZK (Default base rate)
|
||||||
|
|
||||||
|
## 4. Exceptions & Overrides
|
||||||
|
We have hardcoded specific timeline and pricing exceptions directly into the logic:
|
||||||
|
|
||||||
|
- **Modified Monthly Rates:**
|
||||||
|
- **September 2025** (`2025-09`) is explicitly configured to have a fee of **250 CZK** for 2+ attendances instead of the default 500 CZK.
|
||||||
|
|
||||||
|
- **Merged Billing Months:**
|
||||||
|
To handle holidays and off-seasons, certain subsequent months are merged and billed as a single period. Their attendances are summed up before the fee rule is applied. The current active merges are:
|
||||||
|
- **December 2025** is merged into **January 2026**
|
||||||
|
- **September 2025** is merged into **October 2025**
|
||||||
29
prompts/outcomes/2026-03-10-cache-data-from-google-sheets.md
Normal file
29
prompts/outcomes/2026-03-10-cache-data-from-google-sheets.md
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
# Google Sheets Data Caching Implementation
|
||||||
|
|
||||||
|
**Date:** 2026-03-11
|
||||||
|
**Objective:** Optimize Flask application performance by heavily caching expensive Google Sheets data processing, avoiding redundant HTTP roundtrips to Google APIs, and ensuring rate limits are not exhausted during simple web app reloads.
|
||||||
|
|
||||||
|
## Implemented Features
|
||||||
|
|
||||||
|
### 1. File-Based JSON Caching (`cache_utils.py`)
|
||||||
|
- **Mechanism:** Implemented a new generic caching system that saves API responses and heavily calculated datasets as `.json` files directly to the local `/tmp/` directory.
|
||||||
|
- **Drive Metadata Checks:** The cache is validated by asking the Google Drive API (`drive.files().get`) for the remote `modifiedTime` of the target Sheet.
|
||||||
|
- **Cache Hit logic:** If the cached version on disk matches the remote `modifiedTime`, the application skips downloading the full CSV payload and computing tuples—instead serving the instant static cache via `json.load`.
|
||||||
|
|
||||||
|
### 2. Global API Auth Object Reuse
|
||||||
|
- **The Problem:** The `_get_drive_service()` and `get_sheets_service()` implementations were completely rebuilding `googleapiclient.discovery` objects for *every single file check*—re-seeking and exchanging Google Service Account tokens constantly.
|
||||||
|
- **The Fix:** Service objects (`_DRIVE_SERVICE`, `_SHEETS_SERVICE`) are now globally cached in application memory. The server authenticates exactly *once* when it wakes up, dramatically saving milliseconds and network resources across every web request. The underlying `httplib2` and `google-auth` intelligently handle silent token refreshes natively.
|
||||||
|
|
||||||
|
### 3. Graceful Configurable Rate Limiting
|
||||||
|
- **In-Memory Debouncing:** Implemented an internal memory state (`_LAST_CHECKED`) inside `cache_utils` that forcefully prevents checking the Drive API `modifiedTime` for a specific file if we already explicitly checked it within the last 5 minutes. This prevents flooding the Google Drive API while clicking wildly around the app GUI.
|
||||||
|
- **Semantic Mappings:** Created a `CACHE_SHEET_MAP` that maps friendly internal cache keys (e.g. `attendance_regular`) back to their raw 44-character Google Sheet IDs.
|
||||||
|
|
||||||
|
### 4. HTTP / Socket Timeout Safety Fix
|
||||||
|
- **The Bug:** Originally, `socket.setdefaulttimeout(10)` was used to prevent Google Drive metadata checks from locking up the worker pool. However, this brutally mutated the underlying Werkzeug/Flask default sockets globally. If fetching thousands of lines from Google *Sheets* (the payload logic) took longer than 10 seconds, Flask would just kill the request with a random `TimeoutError('timed out')`.
|
||||||
|
- **The Fix:** Removed the global mutation. Instantiated a targeted, isolated `httplib2.Http(timeout=10)` injected *specifically* into only the Google Drive API build. The rest of the app can now download massive files without randomly timing out.
|
||||||
|
|
||||||
|
### 5. Developer Experience (DX) Enhancements
|
||||||
|
- **Logging Line Origins:** Enriched the console logging format strings (`logging.basicConfig`) to output `[%(funcName)s]` and `%(filename)s:%(lineno)d` to easily trace exactly which exact file and function is executing on complex stack traces.
|
||||||
|
- **Improved VS Code Local Debugging:**
|
||||||
|
- Integrated `debugpy` launch profiles in `.vscode/launch.json` for "Python Debugger: Flask" (Launching) and "Python Debugger: Attach" (Connecting).
|
||||||
|
- Implemented a standard `make web-attach` target inside the Makefile via `uv run python -m debugpy --listen ...` to allow the background web app to automatically halt and wait for external debuggers before bootstrapping caching layers.
|
||||||
@@ -1,14 +1,22 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "fuj-management"
|
name = "fuj-management"
|
||||||
version = "0.06"
|
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",
|
||||||
|
"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,20 +5,36 @@ 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
|
||||||
EXPORT_URL = f"https://docs.google.com/spreadsheets/d/{SHEET_ID}/export?format=csv"
|
|
||||||
|
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}"
|
||||||
|
|
||||||
FEE_FULL = 750 # CZK, for 2+ practices in a month
|
FEE_FULL = 750 # CZK, for 2+ practices in a month
|
||||||
FEE_SINGLE = 200 # CZK, for exactly 1 practice in a month
|
FEE_SINGLE = 200 # CZK, for exactly 1 practice in a month
|
||||||
|
|
||||||
|
JUNIOR_FEE_DEFAULT = 500 # CZK for 2+ practices
|
||||||
|
JUNIOR_MONTHLY_RATE = {
|
||||||
|
"2025-09": 250
|
||||||
|
}
|
||||||
|
ADULT_MERGED_MONTHS = {
|
||||||
|
#"2025-12": "2026-01", # keys are merged into values
|
||||||
|
#"2025-09": "2025-10"
|
||||||
|
}
|
||||||
|
|
||||||
|
JUNIOR_MERGED_MONTHS = {
|
||||||
|
"2025-12": "2026-01", # keys are merged into values
|
||||||
|
"2025-09": "2025-10"
|
||||||
|
}
|
||||||
|
|
||||||
COL_NAME = 0
|
COL_NAME = 0
|
||||||
COL_TIER = 1
|
COL_TIER = 1
|
||||||
FIRST_DATE_COL = 3
|
FIRST_DATE_COL = 3
|
||||||
|
|
||||||
|
|
||||||
def fetch_csv() -> 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."""
|
||||||
req = urllib.request.Request(EXPORT_URL)
|
req = urllib.request.Request(url)
|
||||||
with urllib.request.urlopen(req) 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))
|
||||||
@@ -28,23 +44,35 @@ def fetch_csv() -> list[list[str]]:
|
|||||||
def parse_dates(header_row: list[str]) -> list[tuple[int, datetime]]:
|
def parse_dates(header_row: list[str]) -> list[tuple[int, datetime]]:
|
||||||
"""Return (column_index, date) pairs for all date columns."""
|
"""Return (column_index, date) pairs for all date columns."""
|
||||||
dates = []
|
dates = []
|
||||||
|
|
||||||
for i in range(FIRST_DATE_COL, len(header_row)):
|
for i in range(FIRST_DATE_COL, len(header_row)):
|
||||||
raw = header_row[i].strip()
|
raw = header_row[i].strip()
|
||||||
if not raw:
|
if not raw:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
try:
|
try:
|
||||||
dates.append((i, datetime.strptime(raw, "%m/%d/%Y")))
|
# Try DD.MM.YYYY
|
||||||
|
dt = datetime.strptime(raw, "%d.%m.%Y")
|
||||||
|
dates.append((i, dt))
|
||||||
except ValueError:
|
except ValueError:
|
||||||
continue
|
try:
|
||||||
|
# Fallback to MM/DD/YYYY
|
||||||
|
dt = datetime.strptime(raw, "%m/%d/%Y")
|
||||||
|
dates.append((i, dt))
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
return dates
|
return dates
|
||||||
|
|
||||||
|
|
||||||
def group_by_month(dates: list[tuple[int, datetime]]) -> dict[str, list[int]]:
|
def group_by_month(dates: list[tuple[int, datetime]], merged_months: dict[str, str]) -> dict[str, list[int]]:
|
||||||
"""Group column indices by YYYY-MM."""
|
"""Group column indices by YYYY-MM, handling merged months."""
|
||||||
months: dict[str, list[int]] = {}
|
months: dict[str, list[int]] = {}
|
||||||
for col, dt in dates:
|
for col, dt in dates:
|
||||||
key = dt.strftime("%Y-%m")
|
key = dt.strftime("%Y-%m")
|
||||||
months.setdefault(key, []).append(col)
|
# Apply merged month mapping if configured
|
||||||
|
target_key = merged_months.get(key, key)
|
||||||
|
months.setdefault(target_key, []).append(col)
|
||||||
return months
|
return months
|
||||||
|
|
||||||
|
|
||||||
@@ -57,6 +85,15 @@ def calculate_fee(attendance_count: int) -> int:
|
|||||||
return FEE_FULL
|
return FEE_FULL
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_junior_fee(attendance_count: int, month_key: str) -> str | int:
|
||||||
|
"""Apply junior fee rules: 0 → 0, 1 → '?', 2+ → Configured Rate (default 500)."""
|
||||||
|
if attendance_count == 0:
|
||||||
|
return 0
|
||||||
|
if attendance_count == 1:
|
||||||
|
return "?"
|
||||||
|
return JUNIOR_MONTHLY_RATE.get(month_key, JUNIOR_FEE_DEFAULT)
|
||||||
|
|
||||||
|
|
||||||
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).
|
||||||
|
|
||||||
@@ -86,6 +123,38 @@ def get_members(rows: list[list[str]]) -> list[tuple[str, str, list[str]]]:
|
|||||||
return members
|
return members
|
||||||
|
|
||||||
|
|
||||||
|
def get_junior_members_from_sheet(rows: list[list[str]]) -> list[tuple[str, str, list[str]]]:
|
||||||
|
"""Parse junior member rows from the junior sheet.
|
||||||
|
|
||||||
|
Stopped at row where first column contains '# Treneri'.
|
||||||
|
Returns list of (name, tier, row) for members where tier is not 'X'.
|
||||||
|
"""
|
||||||
|
members = []
|
||||||
|
for row in rows[1:]:
|
||||||
|
if not row or len(row) <= COL_NAME:
|
||||||
|
continue
|
||||||
|
|
||||||
|
first_col = row[COL_NAME].strip()
|
||||||
|
|
||||||
|
# Terminator for rows to process in junior sheet
|
||||||
|
if "# treneri" in first_col.lower() or "# trenéři" 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 ""
|
||||||
|
if tier == "X":
|
||||||
|
continue
|
||||||
|
|
||||||
|
members.append((first_col, tier, row))
|
||||||
|
return members
|
||||||
|
|
||||||
|
|
||||||
def get_members_with_fees() -> tuple[list[tuple[str, str, dict[str, int]]], list[str]]:
|
def get_members_with_fees() -> tuple[list[tuple[str, str, dict[str, int]]], list[str]]:
|
||||||
"""Fetch attendance data and compute fees.
|
"""Fetch attendance data and compute fees.
|
||||||
|
|
||||||
@@ -103,7 +172,7 @@ def get_members_with_fees() -> tuple[list[tuple[str, str, dict[str, int]]], list
|
|||||||
if not dates:
|
if not dates:
|
||||||
return [], []
|
return [], []
|
||||||
|
|
||||||
months = group_by_month(dates)
|
months = group_by_month(dates, ADULT_MERGED_MONTHS)
|
||||||
sorted_months = sorted(months.keys())
|
sorted_months = sorted(months.keys())
|
||||||
members_raw = get_members(rows)
|
members_raw = get_members(rows)
|
||||||
|
|
||||||
@@ -122,3 +191,87 @@ def get_members_with_fees() -> tuple[list[tuple[str, str, dict[str, int]]], list
|
|||||||
members.append((name, tier, month_fees))
|
members.append((name, tier, month_fees))
|
||||||
|
|
||||||
return members, sorted_months
|
return members, sorted_months
|
||||||
|
|
||||||
|
|
||||||
|
def get_junior_members_with_fees() -> tuple[list[tuple[str, str, dict[str, tuple[str | int, int, int, int]]]], list[str]]:
|
||||||
|
"""Fetch attendance data from both sheets and compute junior fees.
|
||||||
|
|
||||||
|
Merges members by exact name match.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(members, sorted_months) where members is a list of
|
||||||
|
(name, tier, {month_key: (fee, total_count, adult_count, junior_count)}).
|
||||||
|
"""
|
||||||
|
main_rows = fetch_csv(EXPORT_URL)
|
||||||
|
junior_rows = fetch_csv(JUNIOR_EXPORT_URL)
|
||||||
|
|
||||||
|
if len(main_rows) < 2 or len(junior_rows) < 2:
|
||||||
|
return [], []
|
||||||
|
|
||||||
|
main_dates = parse_dates(main_rows[0])
|
||||||
|
junior_dates = parse_dates(junior_rows[0])
|
||||||
|
|
||||||
|
main_months = group_by_month(main_dates, JUNIOR_MERGED_MONTHS)
|
||||||
|
junior_months = group_by_month(junior_dates, JUNIOR_MERGED_MONTHS)
|
||||||
|
|
||||||
|
# Collect all unique sorted months
|
||||||
|
all_months = set(main_months.keys()).union(set(junior_months.keys()))
|
||||||
|
sorted_months = sorted(list(all_months))
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
merged_members: dict[str, Any] = {}
|
||||||
|
|
||||||
|
# Process Junior Tier from Main Sheet (Adult Practices)
|
||||||
|
main_members_raw = get_members(main_rows)
|
||||||
|
for name, tier, row in main_members_raw:
|
||||||
|
if tier != "J":
|
||||||
|
continue
|
||||||
|
|
||||||
|
if name not in merged_members:
|
||||||
|
merged_members[name] = {"tier": tier, "months": {}}
|
||||||
|
|
||||||
|
for month_key in sorted_months:
|
||||||
|
if month_key not in merged_members[name]["months"]:
|
||||||
|
merged_members[name]["months"][month_key] = {"adult": 0, "junior": 0}
|
||||||
|
|
||||||
|
cols = main_months.get(month_key, [])
|
||||||
|
adult_count = sum(
|
||||||
|
1
|
||||||
|
for c in cols
|
||||||
|
if c < len(row) and row[c].strip().upper() == "TRUE"
|
||||||
|
)
|
||||||
|
merged_members[name]["months"][month_key]["adult"] += adult_count
|
||||||
|
|
||||||
|
# Process Junior Sheet (Junior Practices)
|
||||||
|
junior_members_raw = get_junior_members_from_sheet(junior_rows)
|
||||||
|
for name, tier, row in junior_members_raw:
|
||||||
|
if name not in merged_members:
|
||||||
|
merged_members[name] = {"tier": tier, "months": {}}
|
||||||
|
|
||||||
|
for month_key in sorted_months:
|
||||||
|
if month_key not in merged_members[name]["months"]:
|
||||||
|
merged_members[name]["months"][month_key] = {"adult": 0, "junior": 0}
|
||||||
|
|
||||||
|
cols = junior_months.get(month_key, [])
|
||||||
|
junior_count = sum(
|
||||||
|
1
|
||||||
|
for c in cols
|
||||||
|
if c < len(row) and row[c].strip().upper() == "TRUE"
|
||||||
|
)
|
||||||
|
merged_members[name]["months"][month_key]["junior"] += junior_count
|
||||||
|
|
||||||
|
# Compile the final result format
|
||||||
|
members = []
|
||||||
|
for name, data in merged_members.items():
|
||||||
|
month_fees = {}
|
||||||
|
for month_key in sorted_months:
|
||||||
|
adult_count = data["months"].get(month_key, {}).get("adult", 0)
|
||||||
|
junior_count = data["months"].get(month_key, {}).get("junior", 0)
|
||||||
|
total_count = adult_count + junior_count
|
||||||
|
|
||||||
|
fee = calculate_junior_fee(total_count, month_key)
|
||||||
|
month_fees[month_key] = (fee, total_count, adult_count, junior_count)
|
||||||
|
|
||||||
|
members.append((name, data["tier"], month_fees))
|
||||||
|
|
||||||
|
return members, sorted_months
|
||||||
|
|||||||
157
scripts/cache_utils.py
Normal file
157
scripts/cache_utils.py
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
import json
|
||||||
|
import socket
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
from google.oauth2 import service_account
|
||||||
|
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__)
|
||||||
|
|
||||||
|
# Global state to track last Drive API check time per sheet
|
||||||
|
_LAST_CHECKED = {}
|
||||||
|
_DRIVE_SERVICE = None
|
||||||
|
|
||||||
|
def _get_drive_service():
|
||||||
|
global _DRIVE_SERVICE
|
||||||
|
if _DRIVE_SERVICE is not None:
|
||||||
|
return _DRIVE_SERVICE
|
||||||
|
|
||||||
|
if not CREDS_PATH.exists():
|
||||||
|
logger.warning(f"Credentials not found at {CREDS_PATH}. Cannot check Google Drive API.")
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
creds = service_account.Credentials.from_service_account_file(
|
||||||
|
str(CREDS_PATH),
|
||||||
|
scopes=["https://www.googleapis.com/auth/drive.readonly"]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Apply timeout safely to the httplib2 connection without mutating global socket
|
||||||
|
import httplib2
|
||||||
|
import google_auth_httplib2
|
||||||
|
http = httplib2.Http(timeout=DRIVE_TIMEOUT)
|
||||||
|
http = google_auth_httplib2.AuthorizedHttp(creds, http=http)
|
||||||
|
|
||||||
|
_DRIVE_SERVICE = build("drive", "v3", http=http, cache_discovery=False)
|
||||||
|
return _DRIVE_SERVICE
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to build Drive API service: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
import time
|
||||||
|
|
||||||
|
def get_sheet_modified_time(cache_key: str) -> str | None:
|
||||||
|
"""Gets the modifiedTime from Google Drive API for a given cache_key.
|
||||||
|
Returns the ISO timestamp string if successful.
|
||||||
|
If the Drive API fails (e.g., lack of permissions for public sheets),
|
||||||
|
it generates a virtual time bucket string to provide a 5-minute TTL cache.
|
||||||
|
"""
|
||||||
|
sheet_id = CACHE_SHEET_MAP.get(cache_key, cache_key)
|
||||||
|
|
||||||
|
cache_file = CACHE_DIR / f"{cache_key}_cache.json"
|
||||||
|
|
||||||
|
# 1. Check if we should skip the Drive API check entirely (global memory TTL)
|
||||||
|
now = time.time()
|
||||||
|
last_check = _LAST_CHECKED.get(sheet_id, 0)
|
||||||
|
|
||||||
|
if CACHE_API_CHECK_TTL_SECONDS > 0 and (now - last_check) < CACHE_API_CHECK_TTL_SECONDS:
|
||||||
|
# We checked recently. Return cached modifiedTime if cache file exists.
|
||||||
|
if cache_file.exists():
|
||||||
|
try:
|
||||||
|
with open(cache_file, "r", encoding="utf-8") as f:
|
||||||
|
cache_data = json.load(f)
|
||||||
|
cached_time = cache_data.get("modifiedTime")
|
||||||
|
if cached_time:
|
||||||
|
logger.info(f"Skipping Drive API check for {sheet_id} due to {CACHE_API_CHECK_TTL_SECONDS}s API check TTL")
|
||||||
|
return cached_time
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Error reading existing cache during API skip for {sheet_id}: {e}")
|
||||||
|
|
||||||
|
# 2. Check if the cache file is simply too new (legacy check)
|
||||||
|
if CACHE_TTL_SECONDS > 0 and cache_file.exists():
|
||||||
|
try:
|
||||||
|
file_mtime = cache_file.stat().st_mtime
|
||||||
|
if time.time() - file_mtime < CACHE_TTL_SECONDS:
|
||||||
|
with open(cache_file, "r", encoding="utf-8") as f:
|
||||||
|
cache_data = json.load(f)
|
||||||
|
cached_time = cache_data.get("modifiedTime")
|
||||||
|
if cached_time:
|
||||||
|
logger.info(f"Skipping Drive API check for {sheet_id} due to {CACHE_TTL_SECONDS}s max CACHE_TTL")
|
||||||
|
# We consider this a valid check, update the global state
|
||||||
|
_LAST_CHECKED[sheet_id] = now
|
||||||
|
return cached_time
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Error checking cache TTL for {sheet_id}: {e}")
|
||||||
|
|
||||||
|
def _fallback_ttl():
|
||||||
|
bucket = int(time.time() // 300)
|
||||||
|
return f"ttl-5m-{bucket}"
|
||||||
|
|
||||||
|
logger.info(f"Checking Drive API for {sheet_id}")
|
||||||
|
drive_service = _get_drive_service()
|
||||||
|
if not drive_service:
|
||||||
|
return _fallback_ttl()
|
||||||
|
|
||||||
|
try:
|
||||||
|
file_meta = drive_service.files().get(fileId=sheet_id, fields="modifiedTime", supportsAllDrives=True).execute()
|
||||||
|
# Successfully checked API, update the global state
|
||||||
|
_LAST_CHECKED[sheet_id] = time.time()
|
||||||
|
return file_meta.get("modifiedTime")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Could not get modifiedTime for sheet {sheet_id}: {e}. Falling back to 5-minute TTL.")
|
||||||
|
return _fallback_ttl()
|
||||||
|
|
||||||
|
def read_cache(sheet_id: str, current_modified_time: str) -> list | dict | None:
|
||||||
|
"""Reads the JSON cache for the given sheet_id.
|
||||||
|
Returns the cached data if it exists AND the cached modifiedTime matches
|
||||||
|
current_modified_time.
|
||||||
|
Otherwise, returns None.
|
||||||
|
"""
|
||||||
|
if not current_modified_time:
|
||||||
|
return None
|
||||||
|
|
||||||
|
cache_file = CACHE_DIR / f"{sheet_id}_cache.json"
|
||||||
|
if not cache_file.exists():
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(cache_file, "r", encoding="utf-8") as f:
|
||||||
|
cache_data = json.load(f)
|
||||||
|
|
||||||
|
cached_time = cache_data.get("modifiedTime")
|
||||||
|
if cached_time == current_modified_time:
|
||||||
|
logger.info(f"Cache hit for {sheet_id} ({current_modified_time})")
|
||||||
|
return cache_data.get("data")
|
||||||
|
else:
|
||||||
|
logger.info(f"Cache miss for {sheet_id}. Cached: {cached_time}, Current: {current_modified_time}")
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to read cache {cache_file}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def write_cache(sheet_id: str, modified_time: str, data: list | dict) -> None:
|
||||||
|
"""Writes the data to a JSON cache file with the given modified_time."""
|
||||||
|
if not modified_time:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
cache_file = CACHE_DIR / f"{sheet_id}_cache.json"
|
||||||
|
|
||||||
|
cache_data = {
|
||||||
|
"modifiedTime": modified_time,
|
||||||
|
"data": data,
|
||||||
|
"cachedAt": datetime.now().isoformat()
|
||||||
|
}
|
||||||
|
|
||||||
|
with open(cache_file, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(cache_data, f, ensure_ascii=False)
|
||||||
|
|
||||||
|
logger.info(f"Wrote cache for {sheet_id}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to write cache {sheet_id}: {e}")
|
||||||
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):
|
||||||
@@ -286,20 +294,22 @@ 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]] = {}
|
||||||
|
other_ledger: dict[str, list] = {}
|
||||||
exceptions = exceptions or {}
|
exceptions = exceptions or {}
|
||||||
for name in member_names:
|
for name in member_names:
|
||||||
ledger[name] = {}
|
ledger[name] = {}
|
||||||
|
other_ledger[name] = []
|
||||||
for m in sorted_months:
|
for m in sorted_months:
|
||||||
# Robust normalization for lookup
|
# Robust normalization for lookup
|
||||||
norm_name = normalize(name)
|
norm_name = normalize(name)
|
||||||
norm_period = normalize(m)
|
norm_period = normalize(m)
|
||||||
fee_data = member_fees[name].get(m, (0, 0))
|
fee_data = member_fees[name].get(m, (0, 0))
|
||||||
original_expected = fee_data[0] if isinstance(fee_data, tuple) else fee_data
|
original_expected = fee_data[0] if isinstance(fee_data, (tuple, list)) else fee_data
|
||||||
attendance_count = fee_data[1] if isinstance(fee_data, tuple) else 0
|
attendance_count = fee_data[1] if isinstance(fee_data, (tuple, list)) else 0
|
||||||
|
|
||||||
ex_data = exceptions.get((norm_name, norm_period))
|
ex_data = exceptions.get((norm_name, norm_period))
|
||||||
if ex_data is not None:
|
if ex_data is not None:
|
||||||
@@ -328,12 +338,13 @@ def reconcile(
|
|||||||
|
|
||||||
# Strip markers like [?]
|
# Strip markers like [?]
|
||||||
person_str = re.sub(r"\[\?\]\s*", "", person_str)
|
person_str = re.sub(r"\[\?\]\s*", "", person_str)
|
||||||
|
is_other = purpose_str.lower().startswith("other:")
|
||||||
|
|
||||||
if person_str and purpose_str:
|
if person_str and purpose_str:
|
||||||
# We have pre-matched data (either from script or manual)
|
# We have pre-matched data (either from script or manual)
|
||||||
# Support multiple people/months in the comma-separated string
|
# Support multiple people/months in the comma-separated string
|
||||||
matched_members = [(p.strip(), "auto") for p in person_str.split(",") if p.strip()]
|
matched_members = [(p.strip(), "auto") for p in person_str.split(",") if p.strip()]
|
||||||
matched_months = [m.strip() for m in purpose_str.split(",") if m.strip()]
|
matched_months = [purpose_str] if is_other else [m.strip() for m in purpose_str.split(",") if m.strip()]
|
||||||
|
|
||||||
# Use Inferred Amount if available, otherwise bank Amount
|
# Use Inferred Amount if available, otherwise bank Amount
|
||||||
amount = tx.get("inferred_amount")
|
amount = tx.get("inferred_amount")
|
||||||
@@ -359,16 +370,32 @@ def reconcile(
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
# Allocate payment across matched members and months
|
# Allocate payment across matched members and months
|
||||||
|
if is_other:
|
||||||
|
num_allocations = len(matched_members)
|
||||||
|
per_allocation = amount / num_allocations if num_allocations > 0 else 0
|
||||||
|
for member_name, confidence in matched_members:
|
||||||
|
if member_name in other_ledger:
|
||||||
|
other_ledger[member_name].append({
|
||||||
|
"amount": per_allocation,
|
||||||
|
"date": tx["date"],
|
||||||
|
"sender": tx["sender"],
|
||||||
|
"message": tx["message"],
|
||||||
|
"purpose": purpose_str,
|
||||||
|
"confidence": confidence,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
|
||||||
num_allocations = len(matched_members) * len(matched_months)
|
num_allocations = len(matched_members) * len(matched_months)
|
||||||
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 = {
|
||||||
@@ -378,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:
|
||||||
@@ -389,7 +416,7 @@ def reconcile(
|
|||||||
final_balances: dict[str, int] = {}
|
final_balances: dict[str, int] = {}
|
||||||
for name in member_names:
|
for name in member_names:
|
||||||
window_balance = sum(
|
window_balance = sum(
|
||||||
int(mdata["paid"]) - mdata["expected"]
|
int(mdata["paid"]) - (mdata["expected"] if isinstance(mdata["expected"], int) else 0)
|
||||||
for mdata in ledger[name].values()
|
for mdata in ledger[name].values()
|
||||||
)
|
)
|
||||||
final_balances[name] = window_balance + credits.get(name, 0)
|
final_balances[name] = window_balance + credits.get(name, 0)
|
||||||
@@ -399,6 +426,7 @@ def reconcile(
|
|||||||
name: {
|
name: {
|
||||||
"tier": member_tiers[name],
|
"tier": member_tiers[name],
|
||||||
"months": ledger[name],
|
"months": ledger[name],
|
||||||
|
"other_transactions": other_ledger[name],
|
||||||
"total_balance": final_balances[name]
|
"total_balance": final_balances[name]
|
||||||
}
|
}
|
||||||
for name in member_names
|
for name in member_names
|
||||||
|
|||||||
@@ -14,13 +14,18 @@ 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"]
|
||||||
|
_SHEETS_SERVICE = None
|
||||||
|
|
||||||
def get_sheets_service(credentials_path: str):
|
def get_sheets_service(credentials_path: str):
|
||||||
"""Authenticate and return the Google Sheets API service."""
|
"""Authenticate and return the Google Sheets API service."""
|
||||||
|
global _SHEETS_SERVICE
|
||||||
|
if _SHEETS_SERVICE is not None:
|
||||||
|
return _SHEETS_SERVICE
|
||||||
|
|
||||||
if not os.path.exists(credentials_path):
|
if not os.path.exists(credentials_path):
|
||||||
raise FileNotFoundError(f"Credentials file not found: {credentials_path}")
|
raise FileNotFoundError(f"Credentials file not found: {credentials_path}")
|
||||||
|
|
||||||
@@ -50,7 +55,8 @@ def get_sheets_service(credentials_path: str):
|
|||||||
with open(TOKEN_FILE, "wb") as token:
|
with open(TOKEN_FILE, "wb") as token:
|
||||||
pickle.dump(creds, token)
|
pickle.dump(creds, token)
|
||||||
|
|
||||||
return build("sheets", "v4", credentials=creds)
|
_SHEETS_SERVICE = build("sheets", "v4", credentials=creds)
|
||||||
|
return _SHEETS_SERVICE
|
||||||
|
|
||||||
|
|
||||||
def generate_sync_id(tx: dict) -> str:
|
def generate_sync_id(tx: dict) -> str:
|
||||||
|
|||||||
216
templates/fees-juniors.html
Normal file
216
templates/fees-juniors.html
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
<!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>
|
||||||
@@ -148,13 +148,32 @@
|
|||||||
.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>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<div class="nav">
|
<div class="nav">
|
||||||
<a href="/fees" class="active">[Attendance/Fees]</a>
|
<a href="/fees" class="active">[Adult - Attendance/Fees]</a>
|
||||||
<a href="/reconcile">[Payment Reconciliation]</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>
|
<a href="/payments">[Payments Ledger]</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -199,6 +218,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>
|
||||||
@@ -137,13 +137,32 @@
|
|||||||
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>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<div class="nav">
|
<div class="nav">
|
||||||
<a href="/fees">[Attendance/Fees]</a>
|
<a href="/fees">[Adult - Attendance/Fees]</a>
|
||||||
<a href="/reconcile">[Payment Reconciliation]</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" class="active">[Payments Ledger]</a>
|
<a href="/payments" class="active">[Payments Ledger]</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -183,6 +202,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>
|
||||||
843
templates/reconcile-juniors.html
Normal file
843
templates/reconcile-juniors.html
Normal file
@@ -0,0 +1,843 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>FUJ Junior Payment Reconciliation</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;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
color: #00ff00;
|
||||||
|
font-size: 12px;
|
||||||
|
margin-top: 30px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1200px;
|
||||||
|
border-bottom: 1px solid #333;
|
||||||
|
padding-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-container {
|
||||||
|
background-color: transparent;
|
||||||
|
border: 1px solid #333;
|
||||||
|
box-shadow: none;
|
||||||
|
overflow-x: auto;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1200px;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.balance-pos {
|
||||||
|
color: #00ff00;
|
||||||
|
}
|
||||||
|
|
||||||
|
.balance-neg {
|
||||||
|
color: #ff3333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell-ok {
|
||||||
|
color: #00ff00;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell-unpaid {
|
||||||
|
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 {
|
||||||
|
color: #444444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-container {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1200px;
|
||||||
|
color: #888;
|
||||||
|
margin-bottom: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-start;
|
||||||
|
gap: 20px;
|
||||||
|
padding: 1px 0;
|
||||||
|
border-bottom: 1px dashed #222;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-item-name {
|
||||||
|
color: #ccc;
|
||||||
|
min-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-item-val {
|
||||||
|
color: #00ff00;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unmatched-row {
|
||||||
|
font-family: inherit;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 100px 100px 200px 1fr;
|
||||||
|
gap: 15px;
|
||||||
|
color: #888;
|
||||||
|
padding: 2px 0;
|
||||||
|
border-bottom: 1px dashed #222;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unmatched-header {
|
||||||
|
color: #555;
|
||||||
|
border-bottom: 1px solid #333;
|
||||||
|
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>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div class="nav">
|
||||||
|
<a href="/fees">[Adult - Attendance/Fees]</a>
|
||||||
|
<a href="/fees-juniors">[Junior Attendance/Fees]</a>
|
||||||
|
<a href="/reconcile">[Adult Payment Reconciliation]</a>
|
||||||
|
<a href="/reconcile-juniors" class="active">[Junior Payment Reconciliation]</a>
|
||||||
|
<a href="/payments">[Payments Ledger]</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1>Junior Payment Reconciliation</h1>
|
||||||
|
|
||||||
|
<div class="description">
|
||||||
|
Balances calculated by matching Google Sheet payments against junior attendance fees.<br>
|
||||||
|
Source: <a href="{{ attendance_url }}" target="_blank">Attendance Sheet</a> |
|
||||||
|
<a href="{{ payments_url }}" target="_blank">Payments Ledger</a>
|
||||||
|
</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">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Member</th>
|
||||||
|
{% for m in months %}
|
||||||
|
<th>{{ m }}</th>
|
||||||
|
{% endfor %}
|
||||||
|
<th>Balance</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="reconcileBody">
|
||||||
|
{% for row in results %}
|
||||||
|
<tr class="member-row">
|
||||||
|
<td class="member-name">
|
||||||
|
{{ row.name }}
|
||||||
|
<span class="info-icon" onclick="showMemberDetails('{{ row.name|e }}')">[i]</span>
|
||||||
|
</td>
|
||||||
|
{% for cell in row.months %}
|
||||||
|
<td
|
||||||
|
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.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>
|
||||||
|
{% endfor %}
|
||||||
|
<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" }}
|
||||||
|
{% if row.balance < 0 %}
|
||||||
|
<button class="pay-btn"
|
||||||
|
onclick="showPayQR('{{ row.name|e }}', {{ -row.balance }}, '{{ row.unpaid_periods|e }}')">Pay All</button>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if credits %}
|
||||||
|
<h2>Credits (Advance Payments / Surplus)</h2>
|
||||||
|
<div class="list-container">
|
||||||
|
{% for item in credits %}
|
||||||
|
<div class="list-item">
|
||||||
|
<span class="list-item-name">{{ item.name }}</span>
|
||||||
|
<span class="list-item-val">{{ item.amount }} CZK</span>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if debts %}
|
||||||
|
<h2>Debts (Missing Payments)</h2>
|
||||||
|
<div class="list-container">
|
||||||
|
{% for item in debts %}
|
||||||
|
<div class="list-item">
|
||||||
|
<span class="list-item-name">{{ item.name }}</span>
|
||||||
|
<span class="list-item-val" style="color: #ff3333;">{{ item.amount }} CZK</span>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% 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 -->
|
||||||
|
<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" id="modalOtherSection" style="display: none;">
|
||||||
|
<div class="modal-section-title">Other Transactions</div>
|
||||||
|
<div id="modalOtherList" 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 }};
|
||||||
|
const monthLabels = {{ month_labels_json| safe }};
|
||||||
|
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 displayMonth = monthLabels[m] || m;
|
||||||
|
const row = document.createElement('tr');
|
||||||
|
row.innerHTML = `
|
||||||
|
<td style="color: #888;">${displayMonth}</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 displayMonth = monthLabels[ex.month] || ex.month;
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.className = 'tx-item'; // Reuse style
|
||||||
|
item.innerHTML = `
|
||||||
|
<div class="tx-meta">${displayMonth}</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 otherList = document.getElementById('modalOtherList');
|
||||||
|
const otherSection = document.getElementById('modalOtherSection');
|
||||||
|
otherList.innerHTML = '';
|
||||||
|
|
||||||
|
if (data.other_transactions && data.other_transactions.length > 0) {
|
||||||
|
otherSection.style.display = 'block';
|
||||||
|
data.other_transactions.forEach(tx => {
|
||||||
|
const displayPurpose = tx.purpose || 'Other';
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.className = 'tx-item';
|
||||||
|
item.innerHTML = `
|
||||||
|
<div class="tx-meta">${tx.date} | ${displayPurpose}</div>
|
||||||
|
<div class="tx-main">
|
||||||
|
<span class="tx-amount" style="color: #66ccff;">${tx.amount} CZK</span>
|
||||||
|
<span class="tx-sender">${tx.sender}</span>
|
||||||
|
</div>
|
||||||
|
<div class="tx-msg">${tx.message || ''}</div>
|
||||||
|
`;
|
||||||
|
otherList.appendChild(item);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
otherSection.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 displayMonth = monthLabels[tx.month] || tx.month;
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.className = 'tx-item';
|
||||||
|
item.innerHTML = `
|
||||||
|
<div class="tx-meta">${tx.date} | matched to ${displayMonth}</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>
|
||||||
|
|
||||||
|
</html>
|
||||||
|
```
|
||||||
@@ -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 {
|
||||||
@@ -343,13 +365,68 @@
|
|||||||
color: #888;
|
color: #888;
|
||||||
font-style: italic;
|
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>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<div class="nav">
|
<div class="nav">
|
||||||
<a href="/fees">[Attendance/Fees]</a>
|
<a href="/fees">[Adult - Attendance/Fees]</a>
|
||||||
<a href="/reconcile" class="active">[Payment Reconciliation]</a>
|
<a href="/fees-juniors">[Junior Attendance/Fees]</a>
|
||||||
|
<a href="/reconcile" class="active">[Adult Payment Reconciliation]</a>
|
||||||
|
<a href="/reconcile-juniors">[Junior Payment Reconciliation]</a>
|
||||||
<a href="/payments">[Payments Ledger]</a>
|
<a href="/payments">[Payments Ledger]</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -386,12 +463,20 @@
|
|||||||
</td>
|
</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 %}" 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 %}
|
||||||
|
<button class="pay-btn"
|
||||||
|
onclick="showPayQR('{{ row.name|e }}', {{ -row.balance }}, '{{ row.unpaid_periods|e }}')">Pay All</button>
|
||||||
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@@ -443,6 +528,25 @@
|
|||||||
</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 id="memberModal">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
@@ -476,6 +580,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-section" id="modalOtherSection" style="display: none;">
|
||||||
|
<div class="modal-section-title">Other Transactions</div>
|
||||||
|
<div id="modalOtherList" class="tx-list">
|
||||||
|
<!-- Filled by JS -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="modal-section">
|
<div class="modal-section">
|
||||||
<div class="modal-section-title">Payment History</div>
|
<div class="modal-section-title">Payment History</div>
|
||||||
<div id="modalTxList" class="tx-list">
|
<div id="modalTxList" class="tx-list">
|
||||||
@@ -485,11 +596,23 @@
|
|||||||
</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>
|
<script>
|
||||||
const memberData = {{ member_data| safe }};
|
const memberData = {{ member_data| safe }};
|
||||||
const sortedMonths = {{ raw_months| tojson }};
|
const sortedMonths = {{ raw_months| tojson }};
|
||||||
|
const monthLabels = {{ month_labels_json| safe }};
|
||||||
|
let currentMemberName = null;
|
||||||
|
|
||||||
function showMemberDetails(name) {
|
function showMemberDetails(name) {
|
||||||
|
currentMemberName = name;
|
||||||
const data = memberData[name];
|
const data = memberData[name];
|
||||||
if (!data) return;
|
if (!data) return;
|
||||||
|
|
||||||
@@ -524,9 +647,10 @@
|
|||||||
? `<span style="color: #ffaa00;" title="Overridden from ${originalExpected}">${expected}*</span>`
|
? `<span style="color: #ffaa00;" title="Overridden from ${originalExpected}">${expected}*</span>`
|
||||||
: expected;
|
: expected;
|
||||||
|
|
||||||
|
const displayMonth = monthLabels[m] || m;
|
||||||
const row = document.createElement('tr');
|
const row = document.createElement('tr');
|
||||||
row.innerHTML = `
|
row.innerHTML = `
|
||||||
<td style="color: #888;">${m}</td>
|
<td style="color: #888;">${displayMonth}</td>
|
||||||
<td style="text-align: center; color: #ccc;">${attendance}</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;">${expectedCell}</td>
|
||||||
<td style="text-align: center; color: #ccc;">${paid}</td>
|
<td style="text-align: center; color: #ccc;">${paid}</td>
|
||||||
@@ -555,10 +679,11 @@
|
|||||||
if (exceptions.length > 0) {
|
if (exceptions.length > 0) {
|
||||||
exSection.style.display = 'block';
|
exSection.style.display = 'block';
|
||||||
exceptions.forEach(ex => {
|
exceptions.forEach(ex => {
|
||||||
|
const displayMonth = monthLabels[ex.month] || ex.month;
|
||||||
const item = document.createElement('div');
|
const item = document.createElement('div');
|
||||||
item.className = 'tx-item'; // Reuse style
|
item.className = 'tx-item'; // Reuse style
|
||||||
item.innerHTML = `
|
item.innerHTML = `
|
||||||
<div class="tx-meta">${ex.month}</div>
|
<div class="tx-meta">${displayMonth}</div>
|
||||||
<div class="tx-main">
|
<div class="tx-main">
|
||||||
<span class="tx-amount" style="color: #ffaa00;">${ex.amount} CZK</span>
|
<span class="tx-amount" style="color: #ffaa00;">${ex.amount} CZK</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -570,6 +695,30 @@
|
|||||||
exSection.style.display = 'none';
|
exSection.style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const otherList = document.getElementById('modalOtherList');
|
||||||
|
const otherSection = document.getElementById('modalOtherSection');
|
||||||
|
otherList.innerHTML = '';
|
||||||
|
|
||||||
|
if (data.other_transactions && data.other_transactions.length > 0) {
|
||||||
|
otherSection.style.display = 'block';
|
||||||
|
data.other_transactions.forEach(tx => {
|
||||||
|
const displayPurpose = tx.purpose || 'Other';
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.className = 'tx-item';
|
||||||
|
item.innerHTML = `
|
||||||
|
<div class="tx-meta">${tx.date} | ${displayPurpose}</div>
|
||||||
|
<div class="tx-main">
|
||||||
|
<span class="tx-amount" style="color: #66ccff;">${tx.amount} CZK</span>
|
||||||
|
<span class="tx-sender">${tx.sender}</span>
|
||||||
|
</div>
|
||||||
|
<div class="tx-msg">${tx.message || ''}</div>
|
||||||
|
`;
|
||||||
|
otherList.appendChild(item);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
otherSection.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
const txList = document.getElementById('modalTxList');
|
const txList = document.getElementById('modalTxList');
|
||||||
txList.innerHTML = '';
|
txList.innerHTML = '';
|
||||||
|
|
||||||
@@ -577,10 +726,11 @@
|
|||||||
txList.innerHTML = '<div style="color: #444; font-style: italic; padding: 10px 0;">No transactions matched to this member.</div>';
|
txList.innerHTML = '<div style="color: #444; font-style: italic; padding: 10px 0;">No transactions matched to this member.</div>';
|
||||||
} else {
|
} else {
|
||||||
allTransactions.sort((a, b) => b.date.localeCompare(a.date)).forEach(tx => {
|
allTransactions.sort((a, b) => b.date.localeCompare(a.date)).forEach(tx => {
|
||||||
|
const displayMonth = monthLabels[tx.month] || tx.month;
|
||||||
const item = document.createElement('div');
|
const item = document.createElement('div');
|
||||||
item.className = 'tx-item';
|
item.className = 'tx-item';
|
||||||
item.innerHTML = `
|
item.innerHTML = `
|
||||||
<div class="tx-meta">${tx.date} | matched to ${tx.month}</div>
|
<div class="tx-meta">${tx.date} | matched to ${displayMonth}</div>
|
||||||
<div class="tx-main">
|
<div class="tx-main">
|
||||||
<span class="tx-amount">${tx.amount} CZK</span>
|
<span class="tx-amount">${tx.amount} CZK</span>
|
||||||
<span class="tx-sender">${tx.sender}</span>
|
<span class="tx-sender">${tx.sender}</span>
|
||||||
@@ -594,9 +744,16 @@
|
|||||||
document.getElementById('memberModal').classList.add('active');
|
document.getElementById('memberModal').classList.add('active');
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeModal() {
|
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');
|
document.getElementById('memberModal').classList.remove('active');
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Existing filter script
|
// Existing filter script
|
||||||
document.getElementById('nameFilter').addEventListener('input', function (e) {
|
document.getElementById('nameFilter').addEventListener('input', function (e) {
|
||||||
@@ -614,10 +771,71 @@
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Close on Esc
|
// Close on Esc and Navigate with Arrows
|
||||||
document.addEventListener('keydown', function (e) {
|
document.addEventListener('keydown', function (e) {
|
||||||
if (e.key === 'Escape') closeModal();
|
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>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
|
|||||||
@@ -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=/fees', 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,16 +34,36 @@ 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.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"""
|
||||||
|
mock_get_junior_members.return_value = (
|
||||||
|
[
|
||||||
|
('Test Junior 1', 'J', {'2026-01': ('?', 1, 0, 1)}),
|
||||||
|
('Test Junior 2', 'J', {'2026-01': (500, 4, 1, 3)})
|
||||||
|
],
|
||||||
|
['2026-01']
|
||||||
|
)
|
||||||
|
|
||||||
|
response = self.client.get('/fees-juniors')
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertIn(b'FUJ Junior Fees Dashboard', response.data)
|
||||||
|
self.assertIn(b'Test Junior 1', response.data)
|
||||||
|
self.assertIn(b'? / 1 (J)', 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,
|
||||||
@@ -55,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,
|
||||||
@@ -67,12 +92,43 @@ class TestWebApp(unittest.TestCase):
|
|||||||
'message': 'Direct Member Payment',
|
'message': 'Direct Member Payment',
|
||||||
'sender': 'External Bank User'
|
'sender': 'External Bank User'
|
||||||
}]
|
}]
|
||||||
|
|
||||||
response = self.client.get('/payments')
|
response = self.client.get('/payments')
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertIn(b'Payments Ledger', response.data)
|
self.assertIn(b'Payments Ledger', response.data)
|
||||||
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_exceptions')
|
||||||
|
@patch('app.get_junior_members_with_fees')
|
||||||
|
def test_reconcile_juniors_route(self, mock_get_junior, mock_exceptions, mock_transactions, mock_cache):
|
||||||
|
"""Test that /reconcile-juniors correctly computes balances for juniors."""
|
||||||
|
mock_get_junior.return_value = (
|
||||||
|
[
|
||||||
|
('Junior One', 'J', {'2026-01': (500, 4, 2, 2)}),
|
||||||
|
('Junior Two', 'X', {'2026-01': ('?', 1, 0, 1)})
|
||||||
|
],
|
||||||
|
['2026-01']
|
||||||
|
)
|
||||||
|
mock_exceptions.return_value = {}
|
||||||
|
mock_transactions.return_value = [{
|
||||||
|
'date': '2026-01-15',
|
||||||
|
'amount': 500,
|
||||||
|
'person': 'Junior One',
|
||||||
|
'purpose': '2026-01',
|
||||||
|
'message': '',
|
||||||
|
'sender': 'Parent',
|
||||||
|
'inferred_amount': 500
|
||||||
|
}]
|
||||||
|
|
||||||
|
response = self.client.get('/reconcile-juniors')
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertIn(b'Junior Payment Reconciliation', response.data)
|
||||||
|
self.assertIn(b'Junior One', response.data)
|
||||||
|
self.assertIn(b'Junior Two', response.data)
|
||||||
|
self.assertIn(b'OK', response.data)
|
||||||
|
self.assertIn(b'?', response.data)
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
240
uv.lock
generated
240
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"
|
||||||
@@ -199,13 +268,21 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fuj-management"
|
name = "fuj-management"
|
||||||
version = "0.6"
|
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 = "gunicorn" },
|
||||||
|
{ name = "qrcode", extra = ["pil"] },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.dev-dependencies]
|
||||||
|
dev = [
|
||||||
|
{ name = "pytest" },
|
||||||
|
{ name = "pytest-cov" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.metadata]
|
[package.metadata]
|
||||||
@@ -214,6 +291,14 @@ 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 = "gunicorn", specifier = ">=23.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]]
|
||||||
@@ -300,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"
|
||||||
@@ -321,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"
|
||||||
@@ -403,6 +509,82 @@ 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]]
|
||||||
|
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]]
|
||||||
|
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"
|
||||||
@@ -460,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"
|
||||||
@@ -469,6 +660,53 @@ 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]]
|
||||||
|
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"
|
||||||
|
|||||||
Reference in New Issue
Block a user