Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1257f0d644 | ||
|
|
75a36eb49b | ||
|
|
f40015a2ef | ||
|
|
5bdc7a4566 | ||
|
|
9ee2dd782d | ||
|
|
4bb8c7420c | ||
|
|
b0276f68b3 | ||
|
|
7d05e3812c | ||
|
|
815b962dd7 | ||
|
|
99b23199b1 | ||
|
|
70d6794a3c | ||
|
|
ed5c9bf173 | ||
|
|
786cddba4d | ||
|
|
cbaab5fb92 | ||
|
|
535e1bb772 | ||
|
|
d719383c9c | ||
|
|
65e40d116b | ||
|
|
8842371f80 | ||
|
|
9769769c2c | ||
|
|
4ba6682000 | ||
|
|
ed8abc9b56 | ||
|
|
bed8e93b5d | ||
|
|
695b08819a | ||
|
|
4d0b89943d | ||
|
|
4a8a64f161 | ||
|
|
01e8bb4406 | ||
|
|
cfaa2db88b |
@@ -1,5 +1,7 @@
|
||||
# Antigravity Agent Configuration
|
||||
# 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:
|
||||
`Co-authored-by: Antigravity <antigravity@deepmind.com>`
|
||||
- **Identity**: Antigravity AI (Assistant)
|
||||
- **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.
|
||||
|
||||
111
.gitea/workflows/kubernetes-deploy.yaml
Normal file
111
.gitea/workflows/kubernetes-deploy.yaml
Normal file
@@ -0,0 +1,111 @@
|
||||
name: Deploy to K8s
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- '**'
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Debug - print Gitea Actions environment
|
||||
run: |
|
||||
echo "=== All environment variables ==="
|
||||
env | sort
|
||||
echo ""
|
||||
echo "=== GITHUB_* / GITEA_* / ACTIONS_* vars ==="
|
||||
env | grep -E '^(GITHUB|GITEA|ACTIONS)_' | sort
|
||||
|
||||
- name: Install kubectl
|
||||
run: |
|
||||
curl -sfLO "https://dl.k8s.io/release/$(curl -sfL https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl"
|
||||
install kubectl /usr/local/bin/
|
||||
|
||||
- name: Get Kanidm token from Vault
|
||||
id: vault
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
|
||||
VAULT_AUTH_RESPONSE=$(curl -f --request POST \
|
||||
--data '{"role_id":"${{ secrets.VAULT_ROLE_ID }}","secret_id":"${{ secrets.VAULT_SECRET_ID }}"}' \
|
||||
https://vault.hrajfrisbee.cz/v1/auth/approle/login)
|
||||
|
||||
echo "Vault auth response: $VAULT_AUTH_RESPONSE" >&2
|
||||
VAULT_TOKEN=$(echo "$VAULT_AUTH_RESPONSE" | jq -r '.auth.client_token')
|
||||
|
||||
# Read the kanidm API token
|
||||
SECRET_RESPONSE=$(curl -f \
|
||||
-H "X-Vault-Token: ${VAULT_TOKEN}" \
|
||||
https://vault.hrajfrisbee.cz/v1/secret/data/gitea/gitea-ci)
|
||||
|
||||
echo "Secret response: $SECRET_RESPONSE" >&2
|
||||
API_TOKEN=$(echo "$SECRET_RESPONSE" | jq -r '.data.data.token')
|
||||
|
||||
echo "::add-mask::${API_TOKEN}"
|
||||
echo "api_token=${API_TOKEN}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Exchange for K8s OIDC token via Kanidm
|
||||
id: k8s
|
||||
run: |
|
||||
API_TOKEN="${{ steps.vault.outputs.api_token }}"
|
||||
echo "api_token length: ${#API_TOKEN}" >&2
|
||||
echo "api_token prefix (first 8 chars): ${API_TOKEN:0:8}..." >&2
|
||||
|
||||
HTTP_BODY=$(mktemp)
|
||||
HTTP_STATUS=$(curl -sS -X POST "https://idm.home.hrajfrisbee.cz/oauth2/token" \
|
||||
-d "grant_type=urn:ietf:params:oauth:grant-type:token-exchange" \
|
||||
-d "client_id=k8s" \
|
||||
-d "subject_token=${API_TOKEN}" \
|
||||
-d "subject_token_type=urn:ietf:params:oauth:token-type:access_token" \
|
||||
-d "audience=k8s" \
|
||||
-d "scope=openid groups" \
|
||||
-o "$HTTP_BODY" -w "%{http_code}")
|
||||
|
||||
echo "HTTP status: $HTTP_STATUS" >&2
|
||||
echo "Response body:" >&2
|
||||
cat "$HTTP_BODY" >&2
|
||||
|
||||
RESPONSE=$(cat "$HTTP_BODY")
|
||||
ID_TOKEN=$(echo "$RESPONSE" | jq -r '.id_token // empty')
|
||||
|
||||
if [ -z "$ID_TOKEN" ]; then
|
||||
echo "::error::Kanidm token exchange failed (HTTP $HTTP_STATUS)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "::add-mask::${ID_TOKEN}"
|
||||
echo "id_token=${ID_TOKEN}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Sanity check
|
||||
# Warning - this is the part that is failing
|
||||
# echo "$ID_TOKEN" | cut -d. -f2 | base64 -d 2>/dev/null | jq '{sub, groups, exp}'
|
||||
|
||||
|
||||
- name: Debug - print environment before kubectl
|
||||
run: env | sort
|
||||
|
||||
- name: Configure kubectl & deploy
|
||||
run: |
|
||||
echo "${{ secrets.K8S_CA_CERT }}" > /tmp/ca.crt
|
||||
|
||||
kubectl config set-cluster mycluster \
|
||||
--server=https://192.168.0.31:6443 \
|
||||
--insecure-skip-tls-verify=true
|
||||
# --certificate-authority=/tmp/ca.crt \
|
||||
|
||||
kubectl config set-credentials gitea-ci \
|
||||
--token="${{ steps.k8s.outputs.id_token }}"
|
||||
|
||||
kubectl config set-context gitea-ci \
|
||||
--cluster=mycluster --user=gitea-ci
|
||||
|
||||
kubectl config use-context gitea-ci
|
||||
|
||||
kubectl auth whoami
|
||||
kubectl get ns
|
||||
|
||||
# your deploy here
|
||||
# kubectl apply -f k8s/
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,2 +1,3 @@
|
||||
# python cache
|
||||
**/*.pyc
|
||||
.secret
|
||||
|
||||
3
.vscode/settings.json
vendored
Normal file
3
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"makefile.configureOnOpen": false
|
||||
}
|
||||
24
CLAUDE.md
24
CLAUDE.md
@@ -21,10 +21,26 @@ This project uses a hybrid workflow:
|
||||
|
||||
## When Code Exists
|
||||
|
||||
Once a tech stack is chosen and implementation begins, update this file with:
|
||||
- Build, test, and lint commands
|
||||
- Architecture overview
|
||||
- Development setup instructions
|
||||
## Development Setup
|
||||
|
||||
This project uses `uv` for dependency management.
|
||||
|
||||
```bash
|
||||
uv venv # Create virtual environment
|
||||
uv sync # Install dependencies from pyproject.toml
|
||||
source .venv/bin/activate
|
||||
```
|
||||
|
||||
Alternatively, use the Makefile:
|
||||
- `make sync` - Sync bank transactions to Google Sheets
|
||||
- `make infer` - Automatically infer Person/Purpose/Amount in the sheet
|
||||
- `make reconcile` - Generate balance report from Google Sheets data
|
||||
- `make fees` - Calculate expected fees from attendance
|
||||
- `make match` - (Legacy) Match bank data directly
|
||||
- `make web` - Start dashboard
|
||||
- `make image` - Build Docker image
|
||||
|
||||
Requires `credentials.json` in the root for Google Sheets API access.
|
||||
|
||||
## Git Commits
|
||||
|
||||
|
||||
51
Makefile
51
Makefile
@@ -1,12 +1,15 @@
|
||||
.PHONY: help fees match web image run
|
||||
.PHONY: help fees match web image run sync sync-2026 test test-v docs
|
||||
|
||||
export PYTHONPATH := scripts:$(PYTHONPATH)
|
||||
VENV := .venv
|
||||
PYTHON := $(VENV)/bin/python3
|
||||
CREDENTIALS := .secret/fuj-management-bot-credentials.json
|
||||
|
||||
$(PYTHON):
|
||||
python3 -m venv $(VENV)
|
||||
$(PYTHON) -m pip install -q flask
|
||||
$(PYTHON): .venv/.last_sync
|
||||
|
||||
.venv/.last_sync: pyproject.toml
|
||||
uv sync
|
||||
touch .venv/.last_sync
|
||||
|
||||
help:
|
||||
@echo "Available targets:"
|
||||
@@ -15,6 +18,18 @@ help:
|
||||
@echo " make web - Start a dynamic web dashboard locally"
|
||||
@echo " make image - Build an OCI container image"
|
||||
@echo " make run - Run the built Docker image locally"
|
||||
@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 infer - Infer payment details (Person, Purpose, Amount) in the sheet"
|
||||
@echo " make reconcile - Show balance report using Google Sheets data"
|
||||
@echo " make venv - Sync virtual environment with pyproject.toml"
|
||||
@echo " make test - Run web application infrastructure tests"
|
||||
@echo " make test-v - Run tests with verbose output"
|
||||
@echo " make docs - Serve documentation in a browser"
|
||||
|
||||
venv:
|
||||
uv sync
|
||||
|
||||
fees: $(PYTHON)
|
||||
$(PYTHON) scripts/calculate_fees.py
|
||||
@@ -29,4 +44,30 @@ image:
|
||||
docker build -t fuj-management:latest -f build/Dockerfile .
|
||||
|
||||
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)
|
||||
$(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)
|
||||
$(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
|
||||
|
||||
infer: $(PYTHON)
|
||||
$(PYTHON) scripts/infer_payments.py --credentials $(CREDENTIALS)
|
||||
|
||||
reconcile: ## Match payments against attendance
|
||||
export PYTHONPATH=$(PYTHONPATH):$(CURDIR)/scripts && $(PYTHON) scripts/match_payments.py --credentials $(CREDENTIALS)
|
||||
|
||||
test: $(PYTHON) ## Run web application tests
|
||||
export PYTHONPATH=$(PYTHONPATH):$(CURDIR)/scripts:$(CURDIR) && $(PYTHON) -m unittest discover tests
|
||||
|
||||
test-v: $(PYTHON) ## Run tests with verbose output
|
||||
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
|
||||
|
||||
448
app.py
448
app.py
@@ -1,16 +1,73 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from flask import Flask, render_template
|
||||
import re
|
||||
import time
|
||||
import os
|
||||
import io
|
||||
import qrcode
|
||||
from flask import Flask, render_template, g, send_file, request
|
||||
|
||||
# Add scripts directory to path to allow importing from it
|
||||
scripts_dir = Path(__file__).parent / "scripts"
|
||||
sys.path.append(str(scripts_dir))
|
||||
|
||||
from attendance import get_members_with_fees
|
||||
from attendance import get_members_with_fees, get_junior_members_with_fees, SHEET_ID as ATTENDANCE_SHEET_ID, JUNIOR_SHEET_GID, ADULT_MERGED_MONTHS, JUNIOR_MERGED_MONTHS
|
||||
from match_payments import reconcile, fetch_sheet_data, fetch_exceptions, normalize, DEFAULT_SPREADSHEET_ID as PAYMENTS_SHEET_ID
|
||||
|
||||
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__)
|
||||
|
||||
# Bank account for QR code payments (can be overridden by ENV)
|
||||
BANK_ACCOUNT = os.environ.get("BANK_ACCOUNT", "CZ8520100000002800359168")
|
||||
|
||||
@app.before_request
|
||||
def start_timer():
|
||||
g.start_time = time.perf_counter()
|
||||
g.steps = []
|
||||
|
||||
def record_step(name):
|
||||
g.steps.append((name, time.perf_counter()))
|
||||
|
||||
@app.context_processor
|
||||
def inject_render_time():
|
||||
def get_render_time():
|
||||
total = time.perf_counter() - g.start_time
|
||||
breakdown = []
|
||||
last_time = g.start_time
|
||||
for name, timestamp in g.steps:
|
||||
duration = timestamp - last_time
|
||||
breakdown.append(f"{name}:{duration:.3f}s")
|
||||
last_time = timestamp
|
||||
|
||||
# Add remaining time as 'render'
|
||||
render_duration = time.perf_counter() - last_time
|
||||
breakdown.append(f"render:{render_duration:.3f}s")
|
||||
|
||||
return {
|
||||
"total": f"{total:.3f}",
|
||||
"breakdown": " | ".join(breakdown)
|
||||
}
|
||||
return dict(get_render_time=get_render_time)
|
||||
|
||||
@app.route("/")
|
||||
def index():
|
||||
# Redirect root to /fees for convenience while there are no other apps
|
||||
@@ -18,7 +75,11 @@ def index():
|
||||
|
||||
@app.route("/fees")
|
||||
def fees():
|
||||
attendance_url = f"https://docs.google.com/spreadsheets/d/{ATTENDANCE_SHEET_ID}/edit"
|
||||
payments_url = f"https://docs.google.com/spreadsheets/d/{PAYMENTS_SHEET_ID}/edit"
|
||||
|
||||
members, sorted_months = get_members_with_fees()
|
||||
record_step("fetch_members")
|
||||
if not members:
|
||||
return "No data."
|
||||
|
||||
@@ -26,28 +87,397 @@ def fees():
|
||||
results = [(name, fees) for name, tier, fees in members if tier == "A"]
|
||||
|
||||
# Format month labels
|
||||
month_labels = {
|
||||
m: datetime.strptime(m, "%Y-%m").strftime("%b %Y") for m in sorted_months
|
||||
}
|
||||
month_labels = get_month_labels(sorted_months, ADULT_MERGED_MONTHS)
|
||||
|
||||
monthly_totals = {m: 0 for m in sorted_months}
|
||||
|
||||
# Get exceptions for formatting
|
||||
credentials_path = ".secret/fuj-management-bot-credentials.json"
|
||||
exceptions = fetch_exceptions(PAYMENTS_SHEET_ID, credentials_path)
|
||||
record_step("fetch_exceptions")
|
||||
|
||||
formatted_results = []
|
||||
for name, month_fees in results:
|
||||
row = {"name": name, "months": []}
|
||||
norm_name = normalize(name)
|
||||
for m in sorted_months:
|
||||
fee, count = month_fees.get(m, (0, 0))
|
||||
monthly_totals[m] += fee
|
||||
cell = f"{fee} CZK ({count})" if count > 0 else "-"
|
||||
row["months"].append(cell)
|
||||
|
||||
# Check for exception
|
||||
norm_period = normalize(m)
|
||||
ex_data = exceptions.get((norm_name, norm_period))
|
||||
override_amount = ex_data["amount"] if ex_data else None
|
||||
|
||||
if override_amount is not None and override_amount != fee:
|
||||
cell = f"{override_amount} ({fee}) CZK ({count})" if count > 0 else f"{override_amount} ({fee}) CZK"
|
||||
is_overridden = True
|
||||
else:
|
||||
if isinstance(fee, int):
|
||||
monthly_totals[m] += fee
|
||||
cell = f"{fee} CZK ({count})" if count > 0 else "-"
|
||||
is_overridden = False
|
||||
row["months"].append({"cell": cell, "overridden": is_overridden})
|
||||
formatted_results.append(row)
|
||||
|
||||
record_step("process_data")
|
||||
|
||||
return render_template(
|
||||
"fees.html",
|
||||
months=[month_labels[m] for m in sorted_months],
|
||||
results=formatted_results,
|
||||
totals=[f"{monthly_totals[m]} CZK" for m in sorted_months]
|
||||
totals=[f"{monthly_totals[m]} CZK" for m in sorted_months],
|
||||
attendance_url=attendance_url,
|
||||
payments_url=payments_url
|
||||
)
|
||||
|
||||
@app.route("/fees-juniors")
|
||||
def fees_juniors():
|
||||
attendance_url = f"https://docs.google.com/spreadsheets/d/{ATTENDANCE_SHEET_ID}/edit#gid={JUNIOR_SHEET_GID}"
|
||||
payments_url = f"https://docs.google.com/spreadsheets/d/{PAYMENTS_SHEET_ID}/edit"
|
||||
|
||||
members, sorted_months = get_junior_members_with_fees()
|
||||
record_step("fetch_junior_members")
|
||||
if not members:
|
||||
return "No data."
|
||||
|
||||
# Sort members by name
|
||||
results = sorted([(name, fees) for name, tier, fees in members], key=lambda x: x[0])
|
||||
|
||||
# Format month labels
|
||||
month_labels = get_month_labels(sorted_months, JUNIOR_MERGED_MONTHS)
|
||||
|
||||
monthly_totals = {m: 0 for m in sorted_months}
|
||||
|
||||
# Get exceptions for formatting (reusing payments sheet)
|
||||
credentials_path = ".secret/fuj-management-bot-credentials.json"
|
||||
exceptions = fetch_exceptions(PAYMENTS_SHEET_ID, credentials_path)
|
||||
record_step("fetch_exceptions")
|
||||
|
||||
formatted_results = []
|
||||
for name, month_fees in results:
|
||||
row = {"name": name, "months": []}
|
||||
norm_name = normalize(name)
|
||||
for m in sorted_months:
|
||||
fee_data = month_fees.get(m, (0, 0, 0, 0))
|
||||
if len(fee_data) == 4:
|
||||
fee, total_count, adult_count, junior_count = fee_data
|
||||
else:
|
||||
fee, total_count = fee_data
|
||||
adult_count, junior_count = 0, 0
|
||||
|
||||
# Check for exception
|
||||
norm_period = normalize(m)
|
||||
ex_data = exceptions.get((norm_name, norm_period))
|
||||
override_amount = ex_data["amount"] if ex_data else None
|
||||
|
||||
if ex_data is None and isinstance(fee, int):
|
||||
monthly_totals[m] += fee
|
||||
|
||||
# Formulate the count string display
|
||||
if adult_count > 0 and junior_count > 0:
|
||||
count_str = f"{total_count} ({adult_count}A+{junior_count}J)"
|
||||
elif adult_count > 0:
|
||||
count_str = f"{total_count} (A)"
|
||||
elif junior_count > 0:
|
||||
count_str = f"{total_count} (J)"
|
||||
else:
|
||||
count_str = f"{total_count}"
|
||||
|
||||
if override_amount is not None and override_amount != fee:
|
||||
cell = f"{override_amount} ({fee}) CZK / {count_str}" if total_count > 0 else f"{override_amount} ({fee}) CZK"
|
||||
is_overridden = True
|
||||
else:
|
||||
if fee == "?":
|
||||
cell = f"? / {count_str}" if total_count > 0 else "-"
|
||||
else:
|
||||
cell = f"{fee} CZK / {count_str}" if total_count > 0 else "-"
|
||||
is_overridden = False
|
||||
row["months"].append({"cell": cell, "overridden": is_overridden})
|
||||
formatted_results.append(row)
|
||||
|
||||
record_step("process_data")
|
||||
|
||||
return render_template(
|
||||
"fees-juniors.html",
|
||||
months=[month_labels[m] for m in sorted_months],
|
||||
results=formatted_results,
|
||||
totals=[f"{t} CZK" if isinstance(t, int) else t for t in monthly_totals.values()],
|
||||
attendance_url=attendance_url,
|
||||
payments_url=payments_url
|
||||
)
|
||||
|
||||
@app.route("/reconcile")
|
||||
def reconcile_view():
|
||||
attendance_url = f"https://docs.google.com/spreadsheets/d/{ATTENDANCE_SHEET_ID}/edit"
|
||||
payments_url = f"https://docs.google.com/spreadsheets/d/{PAYMENTS_SHEET_ID}/edit"
|
||||
|
||||
# Use hardcoded credentials path for now, consistent with other scripts
|
||||
credentials_path = ".secret/fuj-management-bot-credentials.json"
|
||||
|
||||
members, sorted_months = get_members_with_fees()
|
||||
record_step("fetch_members")
|
||||
if not members:
|
||||
return "No data."
|
||||
|
||||
transactions = fetch_sheet_data(PAYMENTS_SHEET_ID, credentials_path)
|
||||
record_step("fetch_payments")
|
||||
exceptions = fetch_exceptions(PAYMENTS_SHEET_ID, credentials_path)
|
||||
record_step("fetch_exceptions")
|
||||
result = reconcile(members, sorted_months, transactions, exceptions)
|
||||
record_step("reconcile")
|
||||
|
||||
# Format month labels
|
||||
month_labels = get_month_labels(sorted_months, ADULT_MERGED_MONTHS)
|
||||
|
||||
# Filter to adults for the main table
|
||||
adult_names = sorted([name for name, tier, _ in members if tier == "A"])
|
||||
|
||||
formatted_results = []
|
||||
for name in adult_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 > 0:
|
||||
if 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"] # Updated to use 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 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 and n in adult_names], key=lambda x: x["name"])
|
||||
# Format unmatched
|
||||
unmatched = result["unmatched"]
|
||||
import json
|
||||
|
||||
record_step("process_data")
|
||||
|
||||
return render_template(
|
||||
"reconcile.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=unmatched,
|
||||
attendance_url=attendance_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 = ".secret/fuj-management-bot-credentials.json"
|
||||
|
||||
junior_members, sorted_months = get_junior_members_with_fees()
|
||||
record_step("fetch_junior_members")
|
||||
if not junior_members:
|
||||
return "No data."
|
||||
|
||||
transactions = fetch_sheet_data(PAYMENTS_SHEET_ID, credentials_path)
|
||||
record_step("fetch_payments")
|
||||
exceptions = fetch_exceptions(PAYMENTS_SHEET_ID, credentials_path)
|
||||
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"])
|
||||
unmatched = result["unmatched"]
|
||||
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=unmatched,
|
||||
attendance_url=attendance_url,
|
||||
payments_url=payments_url,
|
||||
bank_account=BANK_ACCOUNT
|
||||
)
|
||||
|
||||
@app.route("/payments")
|
||||
def payments():
|
||||
attendance_url = f"https://docs.google.com/spreadsheets/d/{ATTENDANCE_SHEET_ID}/edit"
|
||||
payments_url = f"https://docs.google.com/spreadsheets/d/{PAYMENTS_SHEET_ID}/edit"
|
||||
credentials_path = ".secret/fuj-management-bot-credentials.json"
|
||||
|
||||
transactions = fetch_sheet_data(PAYMENTS_SHEET_ID, credentials_path)
|
||||
record_step("fetch_payments")
|
||||
|
||||
# Group transactions by person
|
||||
grouped = {}
|
||||
for tx in transactions:
|
||||
person = str(tx.get("person", "")).strip()
|
||||
if not person:
|
||||
person = "Unmatched / Unknown"
|
||||
|
||||
# Handle multiple people (comma separated)
|
||||
people = [p.strip() for p in person.split(",") if p.strip()]
|
||||
for p in people:
|
||||
# Strip markers
|
||||
clean_p = re.sub(r"\[\?\]\s*", "", p)
|
||||
if clean_p not in grouped:
|
||||
grouped[clean_p] = []
|
||||
grouped[clean_p].append(tx)
|
||||
|
||||
# Sort people and their transactions
|
||||
sorted_people = sorted(grouped.keys())
|
||||
for p in sorted_people:
|
||||
# Sort by date descending
|
||||
grouped[p].sort(key=lambda x: str(x.get("date", "")), reverse=True)
|
||||
|
||||
record_step("process_data")
|
||||
return render_template(
|
||||
"payments.html",
|
||||
grouped_payments=grouped,
|
||||
sorted_people=sorted_people,
|
||||
attendance_url=attendance_url,
|
||||
payments_url=payments_url
|
||||
)
|
||||
|
||||
@app.route("/qr")
|
||||
def qr_code():
|
||||
account = request.args.get("account", BANK_ACCOUNT)
|
||||
amount = request.args.get("amount", "0")
|
||||
message = request.args.get("message", "")
|
||||
|
||||
# QR Platba standard: SPD*1.0*ACC:accountNumber*BC:bankCode*AM:amount*CC:CZK*MSG:message
|
||||
acc_parts = account.split('/')
|
||||
if len(acc_parts) == 2:
|
||||
acc_str = f"{acc_parts[0]}*BC:{acc_parts[1]}"
|
||||
else:
|
||||
acc_str = account
|
||||
|
||||
try:
|
||||
amt_val = float(amount)
|
||||
amt_str = f"{amt_val:.2f}"
|
||||
except ValueError:
|
||||
amt_str = "0.00"
|
||||
|
||||
# Message max 60 characters
|
||||
msg_str = message[:60]
|
||||
|
||||
qr_data = f"SPD*1.0*ACC:{acc_str}*AM:{amt_str}*CC:CZK*MSG:{msg_str}"
|
||||
|
||||
img = qrcode.make(qr_data)
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format='PNG')
|
||||
buf.seek(0)
|
||||
|
||||
return send_file(buf, mimetype='image/png')
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run(debug=True, host='0.0.0.0', port=5001)
|
||||
|
||||
@@ -8,7 +8,13 @@ ENV PYTHONUNBUFFERED=1
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN pip install --no-cache-dir flask
|
||||
RUN pip install --no-cache-dir \
|
||||
flask \
|
||||
google-api-python-client \
|
||||
google-auth-httplib2 \
|
||||
google-auth-oauthlib \
|
||||
qrcode \
|
||||
pillow
|
||||
|
||||
COPY app.py Makefile ./
|
||||
COPY scripts/ ./scripts/
|
||||
|
||||
69
docs/spec/fio_to_sheets_sync.md
Normal file
69
docs/spec/fio_to_sheets_sync.md
Normal file
@@ -0,0 +1,69 @@
|
||||
# Specification: Fio to Google Sheets Sync
|
||||
|
||||
## Goal
|
||||
Automatically sync incoming transactions from a Fio bank account to a Google Sheet to serve as an intermediary ledger. This ledger allows for manual tagging, note-taking, and further processing without risking data loss on subsequent syncs.
|
||||
|
||||
## Data Source (Fio)
|
||||
|
||||
### Scraping Mechanism
|
||||
The script will fetch data from the Fio bank in two possible ways:
|
||||
1. **Transparent Account HTML (Default)**:
|
||||
- Fetches the HTML from `https://ib.fio.cz/ib/transparent?a=2800359168&f={date_from}&t={date_to}`.
|
||||
- Uses a custom `HTMLParser` to locate the second `<table>` with class `table`.
|
||||
- Extracts columns: Date (0), Amount (1), Typ (2), Sender (3), Message (4), KS (5), VS (6), SS (7), and Note (8).
|
||||
- Normalizes dates from `DD.MM.YYYY` and amounts from Czech formatting (e.g., `1 500,00 CZK`).
|
||||
2. **Official API (If `FIO_API_TOKEN` is set)**:
|
||||
- Fetches JSON from `https://fioapi.fio.cz/v1/rest/periods/{token}/{date_from}/{date_to}/transactions.json`.
|
||||
- Provides more structured and reliable data, including exact sender account numbers.
|
||||
|
||||
## Unique Key Generation
|
||||
To prevent duplicate entries in the Google Sheet, each transaction is assigned a **Transaction ID**.
|
||||
- **Algorithm**: SHA-256 hash of a concatenated string.
|
||||
- **Input String**: `date|amount|currency|sender|vs|message|bank_id`
|
||||
- **Why?**: Even if two payments have the same amount on the same day, they likely differ in the variable symbol (VS), sender account, or message. Including all these fields makes the ID stable. The `bank_id` (if available from the API) is the strongest unique identifier.
|
||||
|
||||
## Google Sheet Schema
|
||||
The ledger uses the following column structure (Columns A-K):
|
||||
|
||||
| Col | Label | Description |
|
||||
| :--- | :--- | :--- |
|
||||
| **A** | **Date** | Transaction date (YYYY-MM-DD). |
|
||||
| **B** | **Amount** | Bank transaction amount in CZK. |
|
||||
| **C** | **manual fix** | If not empty, `make infer` will skip this row (Manual Override). |
|
||||
| **D** | **Person** | Inferred or manually entered member name. |
|
||||
| **E** | **Purpose** | Inferred or manually entered month(s) (e.g., `2026-01`). |
|
||||
| **F** | **Inferred Amount** | The amount to be used for reconciliation. |
|
||||
| **G** | **Sender** | Sender account name/number. |
|
||||
| **H** | **VS** | Variable Symbol. |
|
||||
| **I** | **Message** | Message for recipient. |
|
||||
| **J** | **Bank ID** | Official Fio Transaction ID. |
|
||||
| **K** | **Sync ID** | Unique SHA-256 hash for deduplication. |
|
||||
|
||||
## Usage Workflow
|
||||
|
||||
To maintain the ledger and generate reports, follow these steps:
|
||||
|
||||
1. **Sync**: `make sync`
|
||||
- Pulls new transactions from Fio and appends them to the sheet.
|
||||
2. **Infer**: `make infer`
|
||||
- Automatically fills **Person**, **Purpose**, and **Inferred Amount** for new rows.
|
||||
- Skips any rows where **manual fix** or Person/Purpose are already filled.
|
||||
3. **Manual Review** (Optional):
|
||||
- Open the Google Sheet and check any rows marked with `[?]` (low confidence).
|
||||
- Correct the **Person**, **Purpose**, or **Inferred Amount** as needed.
|
||||
- Type anything into the **manual fix** column to prevent the script from changing your edits.
|
||||
4. **Reconcile**: `make reconcile`
|
||||
- Reads the processed data from the sheet and prints a balance report showing who owes how much.
|
||||
|
||||
> [!NOTE]
|
||||
> The reconciliation report uses the Google Sheet as the source of truth, so any manual corrections you make are immediately reflected in the final balances.
|
||||
|
||||
## Synchronization Logic
|
||||
The script performs a "Pull and Append" workflow:
|
||||
1. **Read Existing**: Connects to the target Google Sheet and reads all values in Column K (Sync ID).
|
||||
2. **Fetch New**: Scrapes Fio for the specified range (default: last 30 days).
|
||||
3. **Deduplicate**:
|
||||
- For each scraped transaction, generate its `Sync ID`.
|
||||
- If the `Sync ID` is *not* in the list of existing IDs, it is marked for upload.
|
||||
4. **Append**: Uploads only the new transactions to the next available rows in the sheet.
|
||||
5. **Preserve Manual Edits**: Because the script only *appends* and never *updates* or *deletes* existing rows, any manual notes or extra columns added in the Google Sheet remain untouched.
|
||||
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**
|
||||
15
pyproject.toml
Normal file
15
pyproject.toml
Normal file
@@ -0,0 +1,15 @@
|
||||
[project]
|
||||
name = "fuj-management"
|
||||
version = "0.10"
|
||||
description = "Management tools for FUJ (Frisbee Ultimate Jablonec)"
|
||||
dependencies = [
|
||||
"flask>=3.1.3",
|
||||
"google-api-python-client>=2.162.0",
|
||||
"google-auth-httplib2>=0.2.0",
|
||||
"google-auth-oauthlib>=1.2.1",
|
||||
"qrcode[pil]>=8.0",
|
||||
]
|
||||
requires-python = ">=3.13"
|
||||
|
||||
[tool.uv]
|
||||
package = false
|
||||
@@ -6,20 +6,41 @@ import urllib.request
|
||||
from datetime import datetime
|
||||
|
||||
SHEET_ID = "1E2e_gT_K5AwSRCDLDTa2UetZTkHmBOcz0kFbBUNUNBA"
|
||||
EXPORT_URL = f"https://docs.google.com/spreadsheets/d/{SHEET_ID}/export?format=csv"
|
||||
JUNIOR_SHEET_GID = "1213318614"
|
||||
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_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_TIER = 1
|
||||
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."""
|
||||
req = urllib.request.Request(EXPORT_URL)
|
||||
with urllib.request.urlopen(req) as resp:
|
||||
import ssl
|
||||
ctx = ssl.create_default_context()
|
||||
ctx.check_hostname = False
|
||||
ctx.verify_mode = ssl.CERT_NONE
|
||||
|
||||
req = urllib.request.Request(url)
|
||||
with urllib.request.urlopen(req, context=ctx) as resp:
|
||||
text = resp.read().decode("utf-8")
|
||||
reader = csv.reader(io.StringIO(text))
|
||||
return list(reader)
|
||||
@@ -28,23 +49,35 @@ def fetch_csv() -> list[list[str]]:
|
||||
def parse_dates(header_row: list[str]) -> list[tuple[int, datetime]]:
|
||||
"""Return (column_index, date) pairs for all date columns."""
|
||||
dates = []
|
||||
|
||||
for i in range(FIRST_DATE_COL, len(header_row)):
|
||||
raw = header_row[i].strip()
|
||||
if not raw:
|
||||
continue
|
||||
|
||||
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:
|
||||
continue
|
||||
try:
|
||||
# Fallback to MM/DD/YYYY
|
||||
dt = datetime.strptime(raw, "%m/%d/%Y")
|
||||
dates.append((i, dt))
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
return dates
|
||||
|
||||
|
||||
def group_by_month(dates: list[tuple[int, datetime]]) -> dict[str, list[int]]:
|
||||
"""Group column indices by YYYY-MM."""
|
||||
def group_by_month(dates: list[tuple[int, datetime]], merged_months: dict[str, str]) -> dict[str, list[int]]:
|
||||
"""Group column indices by YYYY-MM, handling merged months."""
|
||||
months: dict[str, list[int]] = {}
|
||||
for col, dt in dates:
|
||||
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
|
||||
|
||||
|
||||
@@ -57,15 +90,73 @@ def calculate_fee(attendance_count: int) -> int:
|
||||
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]]]:
|
||||
"""Parse member rows. Returns list of (name, tier, row)."""
|
||||
"""Parse member rows. Returns list of (name, tier, row).
|
||||
|
||||
Stopped at row where first column contains '# last line'.
|
||||
Skips rows starting with '#'.
|
||||
"""
|
||||
members = []
|
||||
for row in rows[1:]:
|
||||
name = row[COL_NAME].strip() if len(row) > COL_NAME else ""
|
||||
if not name or name.lower() in ("jméno", "name", "jmeno"):
|
||||
if not row or len(row) <= COL_NAME:
|
||||
continue
|
||||
|
||||
first_col = row[COL_NAME].strip()
|
||||
|
||||
# Terminator for rows to process
|
||||
if "# last line" in first_col.lower():
|
||||
break
|
||||
|
||||
# Ignore comments
|
||||
if first_col.startswith("#"):
|
||||
continue
|
||||
|
||||
if not first_col or first_col.lower() in ("jméno", "name", "jmeno"):
|
||||
continue
|
||||
|
||||
tier = row[COL_TIER].strip().upper() if len(row) > COL_TIER else ""
|
||||
members.append((name, tier, row))
|
||||
members.append((first_col, tier, row))
|
||||
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
|
||||
|
||||
|
||||
@@ -86,7 +177,7 @@ def get_members_with_fees() -> tuple[list[tuple[str, str, dict[str, int]]], list
|
||||
if not dates:
|
||||
return [], []
|
||||
|
||||
months = group_by_month(dates)
|
||||
months = group_by_month(dates, ADULT_MERGED_MONTHS)
|
||||
sorted_months = sorted(months.keys())
|
||||
members_raw = get_members(rows)
|
||||
|
||||
@@ -105,3 +196,87 @@ def get_members_with_fees() -> tuple[list[tuple[str, str, dict[str, int]]], list
|
||||
members.append((name, tier, month_fees))
|
||||
|
||||
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
|
||||
|
||||
215
scripts/fio_utils.py
Normal file
215
scripts/fio_utils.py
Normal file
@@ -0,0 +1,215 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Shared Fio bank fetching utilities."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import urllib.request
|
||||
from datetime import datetime
|
||||
from html.parser import HTMLParser
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Transaction fetching
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class FioTableParser(HTMLParser):
|
||||
"""Parse the second <table class="table"> on the Fio transparent page.
|
||||
|
||||
Columns: Datum | Částka | Typ | Název protiúčtu | Zpráva pro příjemce | KS | VS | SS | Poznámka
|
||||
Indices: 0 1 2 3 4 5 6 7 8
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._table_count = 0
|
||||
self._in_target_table = False
|
||||
self._in_thead = False
|
||||
self._in_row = False
|
||||
self._in_cell = False
|
||||
self._current_row: list[str] = []
|
||||
self._rows: list[list[str]] = []
|
||||
self._cell_text = ""
|
||||
|
||||
def handle_starttag(self, tag, attrs):
|
||||
cls = dict(attrs).get("class", "")
|
||||
if tag == "table" and "table" in cls.split():
|
||||
self._table_count += 1
|
||||
if self._table_count == 2:
|
||||
self._in_target_table = True
|
||||
if self._in_target_table:
|
||||
if tag == "thead":
|
||||
self._in_thead = True
|
||||
if tag == "tr" and not self._in_thead:
|
||||
self._in_row = True
|
||||
self._current_row = []
|
||||
if self._in_row and tag in ("td", "th"):
|
||||
self._in_cell = True
|
||||
self._cell_text = ""
|
||||
|
||||
def handle_endtag(self, tag):
|
||||
if self._in_cell and tag in ("td", "th"):
|
||||
self._in_cell = False
|
||||
self._current_row.append(self._cell_text.strip())
|
||||
if tag == "thead":
|
||||
self._in_thead = False
|
||||
if self._in_row and tag == "tr":
|
||||
self._in_row = False
|
||||
if self._current_row:
|
||||
self._rows.append(self._current_row)
|
||||
if tag == "table" and self._in_target_table:
|
||||
self._in_target_table = False
|
||||
|
||||
def handle_data(self, data):
|
||||
if self._in_cell:
|
||||
self._cell_text += data
|
||||
|
||||
def get_rows(self) -> list[list[str]]:
|
||||
return self._rows
|
||||
|
||||
|
||||
# Fio transparent table column indices
|
||||
_COL_DATE = 0
|
||||
_COL_AMOUNT = 1
|
||||
_COL_SENDER = 3
|
||||
_COL_MESSAGE = 4
|
||||
_COL_KS = 5
|
||||
_COL_VS = 6
|
||||
_COL_SS = 7
|
||||
_COL_NOTE = 8
|
||||
|
||||
|
||||
def parse_czech_amount(s: str) -> float | None:
|
||||
"""Parse '1 500,00 CZK' to float."""
|
||||
s = s.replace("\xa0", "").replace(" ", "").replace(",", ".")
|
||||
s = re.sub(r"[A-Za-z]+", "", s).strip()
|
||||
try:
|
||||
return float(s)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def parse_czech_date(s: str) -> str | None:
|
||||
"""Parse 'DD.MM.YYYY' to 'YYYY-MM-DD'."""
|
||||
s = s.strip()
|
||||
for fmt in ("%d.%m.%Y", "%d/%m/%Y"):
|
||||
try:
|
||||
return datetime.strptime(s, fmt).strftime("%Y-%m-%d")
|
||||
except ValueError:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def fetch_transactions_transparent(
|
||||
date_from: str, date_to: str, account_id: str = "2800359168"
|
||||
) -> list[dict]:
|
||||
"""Fetch transactions from Fio transparent account HTML page.
|
||||
|
||||
Args:
|
||||
date_from: D.M.YYYY format
|
||||
date_to: D.M.YYYY format
|
||||
"""
|
||||
url = (
|
||||
f"https://ib.fio.cz/ib/transparent?a={account_id}"
|
||||
f"&f={date_from}&t={date_to}"
|
||||
)
|
||||
req = urllib.request.Request(url)
|
||||
with urllib.request.urlopen(req) as resp:
|
||||
html = resp.read().decode("utf-8")
|
||||
|
||||
parser = FioTableParser()
|
||||
parser.feed(html)
|
||||
rows = parser.get_rows()
|
||||
|
||||
transactions = []
|
||||
for row in rows:
|
||||
if len(row) < 5:
|
||||
continue
|
||||
|
||||
def col(i):
|
||||
return row[i].strip() if i < len(row) else ""
|
||||
|
||||
date_str = parse_czech_date(col(_COL_DATE))
|
||||
amount = parse_czech_amount(col(_COL_AMOUNT))
|
||||
|
||||
if date_str is None or amount is None or amount <= 0:
|
||||
continue
|
||||
|
||||
transactions.append({
|
||||
"date": date_str,
|
||||
"amount": amount,
|
||||
"sender": col(_COL_SENDER),
|
||||
"message": col(_COL_MESSAGE),
|
||||
"ks": col(_COL_KS),
|
||||
"vs": col(_COL_VS),
|
||||
"ss": col(_COL_SS),
|
||||
"note": col(_COL_NOTE),
|
||||
"bank_id": "", # HTML scraping doesn't give stable ID
|
||||
})
|
||||
|
||||
return transactions
|
||||
|
||||
|
||||
def fetch_transactions_api(
|
||||
token: str, date_from: str, date_to: str
|
||||
) -> list[dict]:
|
||||
"""Fetch transactions via Fio REST API (JSON).
|
||||
|
||||
Args:
|
||||
token: Fio API token
|
||||
date_from: YYYY-MM-DD format
|
||||
date_to: YYYY-MM-DD format
|
||||
"""
|
||||
url = (
|
||||
f"https://fioapi.fio.cz/v1/rest/periods/{token}"
|
||||
f"/{date_from}/{date_to}/transactions.json"
|
||||
)
|
||||
req = urllib.request.Request(url)
|
||||
with urllib.request.urlopen(req) as resp:
|
||||
data = json.loads(resp.read().decode("utf-8"))
|
||||
|
||||
transactions = []
|
||||
tx_list = data.get("accountStatement", {}).get("transactionList", {})
|
||||
for tx in (tx_list.get("transaction") or []):
|
||||
# Each field is {"value": ..., "name": ..., "id": ...} or null
|
||||
def val(col_id):
|
||||
col = tx.get(f"column{col_id}")
|
||||
return col["value"] if col else ""
|
||||
|
||||
amount = float(val(1) or 0)
|
||||
if amount <= 0:
|
||||
continue # Skip outgoing
|
||||
|
||||
date_raw = val(0) or ""
|
||||
# API returns date as "YYYY-MM-DD+HHMM" or ISO format
|
||||
date_str = date_raw[:10] if date_raw else ""
|
||||
|
||||
transactions.append({
|
||||
"date": date_str,
|
||||
"amount": amount,
|
||||
"sender": str(val(10) or ""), # column10 = sender name
|
||||
"message": str(val(16) or ""), # column16 = message for recipient
|
||||
"vs": str(val(5) or ""), # column5 = VS
|
||||
"ks": str(val(4) or ""), # column4 = KS
|
||||
"ss": str(val(6) or ""), # column6 = SS
|
||||
"user_id": str(val(7) or ""), # column7 = user identification
|
||||
"sender_account": str(val(2) or ""), # column2 = sender account
|
||||
"bank_id": str(val(22) or ""), # column22 = ID operace
|
||||
"currency": str(val(14) or "CZK"), # column14 = Currency
|
||||
})
|
||||
|
||||
return transactions
|
||||
|
||||
|
||||
def fetch_transactions(date_from: str, date_to: str) -> list[dict]:
|
||||
"""Fetch transactions, using API if token available, else transparent page."""
|
||||
token = os.environ.get("FIO_API_TOKEN", "").strip()
|
||||
if token:
|
||||
return fetch_transactions_api(token, date_from, date_to)
|
||||
|
||||
# Convert YYYY-MM-DD to DD.MM.YYYY for the transparent page URL
|
||||
from_dt = datetime.strptime(date_from, "%Y-%m-%d")
|
||||
to_dt = datetime.strptime(date_to, "%Y-%m-%d")
|
||||
return fetch_transactions_transparent(
|
||||
from_dt.strftime("%d.%m.%Y"),
|
||||
to_dt.strftime("%d.%m.%Y"),
|
||||
)
|
||||
191
scripts/infer_payments.py
Normal file
191
scripts/infer_payments.py
Normal file
@@ -0,0 +1,191 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Infer 'Person', 'Purpose', and 'Amount' for transactions in Google Sheets."""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime
|
||||
|
||||
# Add the current directory to sys.path to import local modules
|
||||
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from googleapiclient.discovery import build
|
||||
from sync_fio_to_sheets import get_sheets_service, DEFAULT_SPREADSHEET_ID
|
||||
from match_payments import infer_transaction_details
|
||||
from attendance import get_members_with_fees
|
||||
|
||||
def parse_czk_amount(val) -> float:
|
||||
"""Parse Czech currency string or handle raw numeric value."""
|
||||
if val is None or val == "":
|
||||
return 0.0
|
||||
if isinstance(val, (int, float)):
|
||||
return float(val)
|
||||
|
||||
val = str(val)
|
||||
# Strip currency symbol and spaces
|
||||
val = val.replace("Kč", "").replace("CZK", "").strip()
|
||||
# Remove thousand separators (often space or dot)
|
||||
# Heuristic: if there's a comma, it's the decimal separator.
|
||||
# If there's a dot, it might be a thousand separator OR decimal separator.
|
||||
if "," in val:
|
||||
# 1.500,00 -> 1500.00
|
||||
val = val.replace(".", "").replace(" ", "").replace(",", ".")
|
||||
else:
|
||||
# 1 500.00 -> 1500.00 or 1.500.00 -> ???
|
||||
# If there are multiple dots, it's thousand separator.
|
||||
if val.count(".") > 1:
|
||||
val = val.replace(".", "").replace(" ", "")
|
||||
# If there's one dot, it might be decimal separator.
|
||||
else:
|
||||
val = val.replace(" ", "")
|
||||
|
||||
try:
|
||||
return float(val)
|
||||
except ValueError:
|
||||
return 0.0
|
||||
|
||||
# Column names as requested by the user
|
||||
COL_MANUAL = "manual fix"
|
||||
COL_PERSON = "Person"
|
||||
COL_PURPOSE = "Purpose"
|
||||
COL_AMOUNT = "Inferred Amount"
|
||||
|
||||
def infer_payments(spreadsheet_id: str, credentials_path: str, dry_run: bool = False):
|
||||
print(f"Connecting to Google Sheets...")
|
||||
service = get_sheets_service(credentials_path)
|
||||
sheet = service.spreadsheets()
|
||||
|
||||
# 1. Fetch all data from the sheet
|
||||
print("Reading sheet data...")
|
||||
result = sheet.values().get(
|
||||
spreadsheetId=spreadsheet_id,
|
||||
range="A1:Z", # Read a broad range to find existing columns
|
||||
valueRenderOption="UNFORMATTED_VALUE"
|
||||
).execute()
|
||||
rows = result.get("values", [])
|
||||
if not rows:
|
||||
print("Sheet is empty.")
|
||||
return
|
||||
|
||||
header = rows[0]
|
||||
|
||||
# Identify indices of existing columns
|
||||
def get_col_index(label):
|
||||
normalized_label = label.lower().strip()
|
||||
for i, h in enumerate(header):
|
||||
if h.lower().strip() == normalized_label:
|
||||
return i
|
||||
return -1
|
||||
|
||||
idx_date = get_col_index("Date")
|
||||
idx_amount_raw = get_col_index("Amount") # Bank Amount
|
||||
idx_sender = get_col_index("Sender")
|
||||
idx_message = get_col_index("Message")
|
||||
idx_vs = get_col_index("VS")
|
||||
|
||||
target_labels = [COL_MANUAL, COL_PERSON, COL_PURPOSE, COL_AMOUNT]
|
||||
|
||||
# Refresh indices
|
||||
idx_manual = get_col_index(COL_MANUAL)
|
||||
idx_inferred_person = get_col_index(COL_PERSON)
|
||||
idx_inferred_purpose = get_col_index(COL_PURPOSE)
|
||||
idx_inferred_amount = get_col_index(COL_AMOUNT)
|
||||
|
||||
if idx_inferred_person == -1 or idx_inferred_purpose == -1 or idx_inferred_amount == -1:
|
||||
print(f"Error: Required columns {target_labels[1:]} not found in sheet.")
|
||||
print(f"Current header: {header}")
|
||||
return
|
||||
|
||||
# 2. Fetch members for matching
|
||||
print("Fetching member list for matching...")
|
||||
members_data, _ = get_members_with_fees()
|
||||
member_names = [m[0] for m in members_data]
|
||||
|
||||
# 3. Process rows
|
||||
print("Inffering details for empty rows...")
|
||||
updates = []
|
||||
|
||||
for i, row in enumerate(rows[1:], start=2):
|
||||
# Extend row if it's shorter than existing header
|
||||
while len(row) < len(header):
|
||||
row.append("")
|
||||
|
||||
# Check if already filled (manual override)
|
||||
val_manual = str(row[idx_manual]) if idx_manual != -1 and idx_manual < len(row) else ""
|
||||
val_person = str(row[idx_inferred_person]) if idx_inferred_person < len(row) else ""
|
||||
val_purpose = str(row[idx_inferred_purpose]) if idx_inferred_purpose < len(row) else ""
|
||||
|
||||
if val_manual.strip() or val_person.strip() or val_purpose.strip():
|
||||
continue
|
||||
|
||||
# Prepare transaction dict for matching logic
|
||||
tx = {
|
||||
"date": row[idx_date] if idx_date != -1 and idx_date < len(row) else "",
|
||||
"amount": parse_czk_amount(row[idx_amount_raw]) if idx_amount_raw != -1 and idx_amount_raw < len(row) and row[idx_amount_raw] else 0,
|
||||
"sender": row[idx_sender] if idx_sender != -1 and idx_sender < len(row) else "",
|
||||
"message": row[idx_message] if idx_message != -1 and idx_message < len(row) else "",
|
||||
"vs": row[idx_vs] if idx_vs != -1 and idx_vs < len(row) else "",
|
||||
}
|
||||
|
||||
inference = infer_transaction_details(tx, member_names)
|
||||
|
||||
# Sort members by confidence and add markers
|
||||
peeps = []
|
||||
for name, conf in inference["members"]:
|
||||
prefix = "[?] " if conf == "review" else ""
|
||||
peeps.append(f"{prefix}{name}")
|
||||
|
||||
matched_months = inference["months"]
|
||||
|
||||
if peeps or matched_months:
|
||||
person_val = ", ".join(peeps)
|
||||
purpose_val = ", ".join(matched_months)
|
||||
amount_val = str(tx["amount"]) # For now, use total amount
|
||||
|
||||
print(f"Row {i}: Inferred {person_val} for {purpose_val} ({amount_val} CZK)")
|
||||
|
||||
# Update the row in memory (for terminal output/dry run)
|
||||
row[idx_inferred_person] = person_val
|
||||
row[idx_inferred_purpose] = purpose_val
|
||||
row[idx_inferred_amount] = amount_val
|
||||
|
||||
# Prepare batch update
|
||||
updates.append({
|
||||
"range": f"R{i}C{idx_inferred_person+1}:R{i}C{idx_inferred_amount+1}",
|
||||
"values": [[person_val, purpose_val, amount_val]]
|
||||
})
|
||||
|
||||
if not updates:
|
||||
print("No new inferences to make.")
|
||||
return
|
||||
|
||||
if dry_run:
|
||||
print(f"Dry run: would update {len(updates)} rows.")
|
||||
else:
|
||||
print(f"Applying {len(updates)} updates to the sheet...")
|
||||
body = {
|
||||
"valueInputOption": "USER_ENTERED",
|
||||
"data": updates
|
||||
}
|
||||
sheet.values().batchUpdate(
|
||||
spreadsheetId=spreadsheet_id,
|
||||
body=body
|
||||
).execute()
|
||||
print("Update completed successfully.")
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Infer payment details in Google Sheets.")
|
||||
parser.add_argument("--sheet-id", default=DEFAULT_SPREADSHEET_ID, help="Google Sheet ID")
|
||||
parser.add_argument("--credentials", default="credentials.json", help="Path to Google API credentials JSON")
|
||||
parser.add_argument("--dry-run", action="store_true", help="Print updates without applying them")
|
||||
args = parser.parse_args()
|
||||
|
||||
try:
|
||||
infer_payments(args.sheet_id, args.credentials, args.dry_run)
|
||||
except Exception as e:
|
||||
print(f"Inference failed: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -11,205 +11,7 @@ from html.parser import HTMLParser
|
||||
|
||||
from attendance import get_members_with_fees
|
||||
from czech_utils import normalize, parse_month_references
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Transaction fetching
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class _FioTableParser(HTMLParser):
|
||||
"""Parse the second <table class="table"> on the Fio transparent page.
|
||||
|
||||
Columns: Datum | Částka | Typ | Název protiúčtu | Zpráva pro příjemce | KS | VS | SS | Poznámka
|
||||
Indices: 0 1 2 3 4 5 6 7 8
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._table_count = 0
|
||||
self._in_target_table = False
|
||||
self._in_thead = False
|
||||
self._in_row = False
|
||||
self._in_cell = False
|
||||
self._current_row: list[str] = []
|
||||
self._rows: list[list[str]] = []
|
||||
self._cell_text = ""
|
||||
|
||||
def handle_starttag(self, tag, attrs):
|
||||
cls = dict(attrs).get("class", "")
|
||||
if tag == "table" and "table" in cls.split():
|
||||
self._table_count += 1
|
||||
if self._table_count == 2:
|
||||
self._in_target_table = True
|
||||
if self._in_target_table:
|
||||
if tag == "thead":
|
||||
self._in_thead = True
|
||||
if tag == "tr" and not self._in_thead:
|
||||
self._in_row = True
|
||||
self._current_row = []
|
||||
if self._in_row and tag in ("td", "th"):
|
||||
self._in_cell = True
|
||||
self._cell_text = ""
|
||||
|
||||
def handle_endtag(self, tag):
|
||||
if self._in_cell and tag in ("td", "th"):
|
||||
self._in_cell = False
|
||||
self._current_row.append(self._cell_text.strip())
|
||||
if tag == "thead":
|
||||
self._in_thead = False
|
||||
if self._in_row and tag == "tr":
|
||||
self._in_row = False
|
||||
if self._current_row:
|
||||
self._rows.append(self._current_row)
|
||||
if tag == "table" and self._in_target_table:
|
||||
self._in_target_table = False
|
||||
|
||||
def handle_data(self, data):
|
||||
if self._in_cell:
|
||||
self._cell_text += data
|
||||
|
||||
def get_rows(self) -> list[list[str]]:
|
||||
return self._rows
|
||||
|
||||
|
||||
# Fio transparent table column indices
|
||||
_COL_DATE = 0
|
||||
_COL_AMOUNT = 1
|
||||
_COL_SENDER = 3
|
||||
_COL_MESSAGE = 4
|
||||
_COL_KS = 5
|
||||
_COL_VS = 6
|
||||
_COL_SS = 7
|
||||
_COL_NOTE = 8
|
||||
|
||||
|
||||
def _parse_czech_amount(s: str) -> float | None:
|
||||
"""Parse '1 500,00 CZK' to float."""
|
||||
s = s.replace("\xa0", "").replace(" ", "").replace(",", ".")
|
||||
s = re.sub(r"[A-Za-z]+", "", s).strip()
|
||||
try:
|
||||
return float(s)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _parse_czech_date(s: str) -> str | None:
|
||||
"""Parse 'DD.MM.YYYY' to 'YYYY-MM-DD'."""
|
||||
s = s.strip()
|
||||
for fmt in ("%d.%m.%Y", "%d/%m/%Y"):
|
||||
try:
|
||||
return datetime.strptime(s, fmt).strftime("%Y-%m-%d")
|
||||
except ValueError:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def fetch_transactions_transparent(
|
||||
date_from: str, date_to: str
|
||||
) -> list[dict]:
|
||||
"""Fetch transactions from Fio transparent account HTML page.
|
||||
|
||||
Args:
|
||||
date_from: D.M.YYYY format
|
||||
date_to: D.M.YYYY format
|
||||
"""
|
||||
url = (
|
||||
f"https://ib.fio.cz/ib/transparent?a=2800359168"
|
||||
f"&f={date_from}&t={date_to}"
|
||||
)
|
||||
req = urllib.request.Request(url)
|
||||
with urllib.request.urlopen(req) as resp:
|
||||
html = resp.read().decode("utf-8")
|
||||
|
||||
parser = _FioTableParser()
|
||||
parser.feed(html)
|
||||
rows = parser.get_rows()
|
||||
|
||||
transactions = []
|
||||
for row in rows:
|
||||
if len(row) < 5:
|
||||
continue
|
||||
|
||||
def col(i):
|
||||
return row[i].strip() if i < len(row) else ""
|
||||
|
||||
date_str = _parse_czech_date(col(_COL_DATE))
|
||||
amount = _parse_czech_amount(col(_COL_AMOUNT))
|
||||
|
||||
if date_str is None or amount is None or amount <= 0:
|
||||
continue
|
||||
|
||||
transactions.append({
|
||||
"date": date_str,
|
||||
"amount": amount,
|
||||
"sender": col(_COL_SENDER),
|
||||
"message": col(_COL_MESSAGE),
|
||||
"vs": col(_COL_VS),
|
||||
})
|
||||
|
||||
return transactions
|
||||
|
||||
|
||||
def fetch_transactions_api(
|
||||
token: str, date_from: str, date_to: str
|
||||
) -> list[dict]:
|
||||
"""Fetch transactions via Fio REST API (JSON).
|
||||
|
||||
Args:
|
||||
token: Fio API token
|
||||
date_from: YYYY-MM-DD format
|
||||
date_to: YYYY-MM-DD format
|
||||
"""
|
||||
url = (
|
||||
f"https://fioapi.fio.cz/v1/rest/periods/{token}"
|
||||
f"/{date_from}/{date_to}/transactions.json"
|
||||
)
|
||||
req = urllib.request.Request(url)
|
||||
with urllib.request.urlopen(req) as resp:
|
||||
data = json.loads(resp.read().decode("utf-8"))
|
||||
|
||||
transactions = []
|
||||
tx_list = data.get("accountStatement", {}).get("transactionList", {})
|
||||
for tx in (tx_list.get("transaction") or []):
|
||||
# Each field is {"value": ..., "name": ..., "id": ...} or null
|
||||
def val(col_id):
|
||||
col = tx.get(f"column{col_id}")
|
||||
return col["value"] if col else ""
|
||||
|
||||
amount = float(val(1) or 0)
|
||||
if amount <= 0:
|
||||
continue # Skip outgoing
|
||||
|
||||
date_raw = val(0) or ""
|
||||
# API returns date as "YYYY-MM-DD+HHMM" or ISO format
|
||||
date_str = date_raw[:10] if date_raw else ""
|
||||
|
||||
transactions.append({
|
||||
"date": date_str,
|
||||
"amount": amount,
|
||||
"sender": str(val(10) or ""), # column10 = sender name
|
||||
"message": str(val(16) or ""), # column16 = message for recipient
|
||||
"vs": str(val(5) or ""), # column5 = VS
|
||||
"user_id": str(val(7) or ""), # column7 = user identification
|
||||
"sender_account": str(val(2) or ""), # column2 = sender account
|
||||
})
|
||||
|
||||
return transactions
|
||||
|
||||
|
||||
def fetch_transactions(date_from: str, date_to: str) -> list[dict]:
|
||||
"""Fetch transactions, using API if token available, else transparent page."""
|
||||
token = os.environ.get("FIO_API_TOKEN", "").strip()
|
||||
if token:
|
||||
return fetch_transactions_api(token, date_from, date_to)
|
||||
|
||||
# Convert YYYY-MM-DD to DD.MM.YYYY for the transparent page URL
|
||||
from_dt = datetime.strptime(date_from, "%Y-%m-%d")
|
||||
to_dt = datetime.strptime(date_to, "%Y-%m-%d")
|
||||
return fetch_transactions_transparent(
|
||||
from_dt.strftime("%-d.%-m.%Y"),
|
||||
to_dt.strftime("%-d.%-m.%Y"),
|
||||
)
|
||||
from sync_fio_to_sheets import get_sheets_service, DEFAULT_SPREADSHEET_ID
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -255,34 +57,57 @@ def match_members(
|
||||
|
||||
for name in member_names:
|
||||
variants = _build_name_variants(name)
|
||||
# Full name match = high confidence
|
||||
full_name = variants[0] if variants else ""
|
||||
parts = full_name.split()
|
||||
|
||||
# 1. Full name match (exact sequence) = high confidence
|
||||
if full_name and full_name in normalized_text:
|
||||
matches.append((name, "auto"))
|
||||
continue
|
||||
|
||||
# Last name + first name both present = high confidence
|
||||
parts = full_name.split()
|
||||
# 2. Both first and last name present (any order) = high confidence
|
||||
if len(parts) >= 2:
|
||||
if parts[0] in normalized_text and parts[-1] in normalized_text:
|
||||
matches.append((name, "auto"))
|
||||
continue
|
||||
|
||||
# Nickname match = high confidence
|
||||
if len(variants) > 1 and variants[1] in normalized_text:
|
||||
matches.append((name, "auto"))
|
||||
continue
|
||||
# 3. Nickname + one part of the name = high confidence
|
||||
nickname = ""
|
||||
nickname_match = re.search(r"\(([^)]+)\)", name)
|
||||
if nickname_match:
|
||||
nickname = normalize(nickname_match.group(1))
|
||||
if nickname and nickname in normalized_text:
|
||||
# Nickname alone is often enough, but let's check if it's combined with a name part
|
||||
matches.append((name, "auto"))
|
||||
continue
|
||||
|
||||
# Last name only = lower confidence, but skip very common Czech surnames
|
||||
_COMMON_SURNAMES = {"novak", "novakova", "prach"}
|
||||
if (
|
||||
len(parts) >= 2
|
||||
and len(parts[-1]) >= 4
|
||||
and parts[-1] not in _COMMON_SURNAMES
|
||||
and parts[-1] in normalized_text
|
||||
):
|
||||
matches.append((name, "review"))
|
||||
continue
|
||||
# 4. Partial matches = review confidence
|
||||
if len(parts) >= 2:
|
||||
first_name = parts[0]
|
||||
last_name = parts[-1]
|
||||
_COMMON_SURNAMES = {"novak", "novakova", "prach"}
|
||||
|
||||
# Match last name
|
||||
if len(last_name) >= 4 and last_name not in _COMMON_SURNAMES and last_name in normalized_text:
|
||||
matches.append((name, "review"))
|
||||
continue
|
||||
|
||||
# Match first name (if not too short)
|
||||
if len(first_name) >= 3 and first_name in normalized_text:
|
||||
matches.append((name, "review"))
|
||||
continue
|
||||
elif len(parts) == 1:
|
||||
# Single name member
|
||||
if len(parts[0]) >= 4 and parts[0] in normalized_text:
|
||||
matches.append((name, "review"))
|
||||
continue
|
||||
|
||||
# --- Filtering ---
|
||||
# If we have any "auto" matches, discard all "review" matches
|
||||
auto_matches = [m for m in matches if m[1] == "auto"]
|
||||
if auto_matches:
|
||||
# If multiple auto matches, keep them (ambiguous but high priority)
|
||||
return auto_matches
|
||||
|
||||
return matches
|
||||
|
||||
@@ -291,10 +116,166 @@ def match_members(
|
||||
# Reconciliation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def infer_transaction_details(tx: dict, member_names: list[str]) -> dict:
|
||||
"""Infer member(s) and month(s) for a single transaction.
|
||||
|
||||
Returns:
|
||||
{
|
||||
'members': [(name, confidence)],
|
||||
'months': [YYYY-MM],
|
||||
'matched_text': str
|
||||
}
|
||||
"""
|
||||
# Combine sender + message for searching
|
||||
search_text = f"{tx.get('sender', '')} {tx.get('message', '')} {tx.get('user_id', '')}"
|
||||
matched_members = match_members(search_text, member_names)
|
||||
matched_months = parse_month_references(
|
||||
tx.get("message", "") + " " + tx.get("user_id", "")
|
||||
)
|
||||
|
||||
if not matched_members:
|
||||
# Try matching sender name alone with more lenient matching
|
||||
matched_members = match_members(tx.get("sender", ""), member_names)
|
||||
|
||||
if not matched_months:
|
||||
# If no month specified, try to infer from payment date
|
||||
tx_date = tx.get("date")
|
||||
if tx_date:
|
||||
try:
|
||||
if isinstance(tx_date, (int, float)):
|
||||
# Handle Google Sheets serial date
|
||||
dt = datetime(1899, 12, 30) + timedelta(days=tx_date)
|
||||
else:
|
||||
dt = datetime.strptime(str(tx_date), "%Y-%m-%d")
|
||||
# Assume payment is for the current month
|
||||
matched_months = [dt.strftime("%Y-%m")]
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
return {
|
||||
"members": matched_members,
|
||||
"months": matched_months,
|
||||
"search_text": search_text
|
||||
}
|
||||
|
||||
|
||||
def format_date(val) -> str:
|
||||
"""Normalize date from Google Sheet (handles serial numbers and strings)."""
|
||||
if val is None or val == "":
|
||||
return ""
|
||||
|
||||
# Handle Google Sheets serial dates (number of days since 1899-12-30)
|
||||
if isinstance(val, (int, float)):
|
||||
base_date = datetime(1899, 12, 30)
|
||||
dt = base_date + timedelta(days=val)
|
||||
return dt.strftime("%Y-%m-%d")
|
||||
|
||||
val_str = str(val).strip()
|
||||
if not val_str:
|
||||
return ""
|
||||
|
||||
# If already YYYY-MM-DD, return as is
|
||||
if len(val_str) == 10 and val_str[4] == "-" and val_str[7] == "-":
|
||||
return val_str
|
||||
|
||||
return val_str
|
||||
|
||||
def fetch_sheet_data(spreadsheet_id: str, credentials_path: str) -> list[dict]:
|
||||
"""Fetch all rows from the Google Sheet and convert to a list of dicts."""
|
||||
service = get_sheets_service(credentials_path)
|
||||
sheet = service.spreadsheets()
|
||||
|
||||
result = sheet.values().get(
|
||||
spreadsheetId=spreadsheet_id,
|
||||
range="A1:Z",
|
||||
valueRenderOption="UNFORMATTED_VALUE"
|
||||
).execute()
|
||||
rows = result.get("values", [])
|
||||
if not rows:
|
||||
return []
|
||||
|
||||
header = rows[0]
|
||||
def get_col_index(label):
|
||||
normalized_label = label.lower().strip()
|
||||
for i, h in enumerate(header):
|
||||
if h.lower().strip() == normalized_label:
|
||||
return i
|
||||
return -1
|
||||
|
||||
idx_date = get_col_index("Date")
|
||||
idx_amount = get_col_index("Amount")
|
||||
idx_manual = get_col_index("manual fix")
|
||||
idx_person = get_col_index("Person")
|
||||
idx_purpose = get_col_index("Purpose")
|
||||
idx_inferred_amount = get_col_index("Inferred Amount")
|
||||
idx_sender = get_col_index("Sender")
|
||||
idx_message = get_col_index("Message")
|
||||
idx_bank_id = get_col_index("Bank ID")
|
||||
|
||||
transactions = []
|
||||
for row in rows[1:]:
|
||||
def get_val(idx):
|
||||
return row[idx] if idx != -1 and idx < len(row) else ""
|
||||
|
||||
tx = {
|
||||
"date": format_date(get_val(idx_date)),
|
||||
"amount": get_val(idx_amount),
|
||||
"manual_fix": get_val(idx_manual),
|
||||
"person": get_val(idx_person),
|
||||
"purpose": get_val(idx_purpose),
|
||||
"inferred_amount": get_val(idx_inferred_amount),
|
||||
"sender": get_val(idx_sender),
|
||||
"message": get_val(idx_message),
|
||||
"bank_id": get_val(idx_bank_id),
|
||||
}
|
||||
transactions.append(tx)
|
||||
|
||||
return transactions
|
||||
|
||||
|
||||
def fetch_exceptions(spreadsheet_id: str, credentials_path: str) -> dict[tuple[str, str], dict]:
|
||||
"""Fetch manual fee overrides from the 'exceptions' sheet.
|
||||
|
||||
Returns a dict mapping (member_name, period_YYYYMM) to {'amount': int, 'note': str}.
|
||||
"""
|
||||
service = get_sheets_service(credentials_path)
|
||||
try:
|
||||
result = service.spreadsheets().values().get(
|
||||
spreadsheetId=spreadsheet_id,
|
||||
range="'exceptions'!A2:D",
|
||||
valueRenderOption="UNFORMATTED_VALUE"
|
||||
).execute()
|
||||
rows = result.get("values", [])
|
||||
except Exception as e:
|
||||
print(f"Warning: Could not fetch exceptions: {e}")
|
||||
return {}
|
||||
|
||||
exceptions = {}
|
||||
for row in rows:
|
||||
if len(row) < 3 or str(row[0]).lower().startswith("name"):
|
||||
continue
|
||||
|
||||
name = str(row[0]).strip()
|
||||
period = str(row[1]).strip()
|
||||
# Robust normalization using czech_utils.normalize
|
||||
norm_name = normalize(name)
|
||||
norm_period = normalize(period)
|
||||
|
||||
try:
|
||||
amount = int(row[2])
|
||||
note = str(row[3]).strip() if len(row) > 3 else ""
|
||||
exceptions[(norm_name, norm_period)] = {"amount": amount, "note": note}
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
|
||||
return exceptions
|
||||
|
||||
|
||||
def reconcile(
|
||||
members: list[tuple[str, str, dict[str, int]]],
|
||||
sorted_months: list[str],
|
||||
transactions: list[dict],
|
||||
exceptions: dict[tuple[str, str], dict] = None,
|
||||
) -> dict:
|
||||
"""Match transactions to members and months.
|
||||
|
||||
@@ -305,15 +286,36 @@ def reconcile(
|
||||
"""
|
||||
member_names = [name for name, _, _ 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
|
||||
ledger: dict[str, dict[str, dict]] = {}
|
||||
other_ledger: dict[str, list] = {}
|
||||
exceptions = exceptions or {}
|
||||
for name in member_names:
|
||||
ledger[name] = {}
|
||||
other_ledger[name] = []
|
||||
for m in sorted_months:
|
||||
# Robust normalization for lookup
|
||||
norm_name = normalize(name)
|
||||
norm_period = normalize(m)
|
||||
fee_data = member_fees[name].get(m, (0, 0))
|
||||
original_expected = fee_data[0] if isinstance(fee_data, tuple) else fee_data
|
||||
attendance_count = fee_data[1] if isinstance(fee_data, tuple) else 0
|
||||
|
||||
ex_data = exceptions.get((norm_name, norm_period))
|
||||
if ex_data is not None:
|
||||
expected = ex_data["amount"]
|
||||
exception_info = ex_data
|
||||
else:
|
||||
expected = original_expected
|
||||
exception_info = None
|
||||
|
||||
ledger[name][m] = {
|
||||
"expected": member_fees[name].get(m, 0),
|
||||
"expected": expected,
|
||||
"original_expected": original_expected,
|
||||
"attendance_count": attendance_count,
|
||||
"exception": exception_info,
|
||||
"paid": 0,
|
||||
"transactions": [],
|
||||
}
|
||||
@@ -322,41 +324,70 @@ def reconcile(
|
||||
credits: dict[str, int] = {}
|
||||
|
||||
for tx in transactions:
|
||||
# Combine sender + message for searching
|
||||
search_text = f"{tx['sender']} {tx['message']} {tx.get('user_id', '')}"
|
||||
matched_members = match_members(search_text, member_names)
|
||||
matched_months = parse_month_references(
|
||||
tx["message"] + " " + tx.get("user_id", "")
|
||||
)
|
||||
# Use sheet columns if they exist, otherwise fallback to inference
|
||||
person_str = str(tx.get("person", "")).strip()
|
||||
purpose_str = str(tx.get("purpose", "")).strip()
|
||||
|
||||
# Strip markers like [?]
|
||||
person_str = re.sub(r"\[\?\]\s*", "", person_str)
|
||||
is_other = purpose_str.lower().startswith("other:")
|
||||
|
||||
if not matched_members:
|
||||
# Try matching sender name alone with more lenient matching
|
||||
matched_members = match_members(tx["sender"], member_names)
|
||||
if person_str and purpose_str:
|
||||
# We have pre-matched data (either from script or manual)
|
||||
# Support multiple people/months in the comma-separated string
|
||||
matched_members = [(p.strip(), "auto") for p in person_str.split(",") if p.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
|
||||
amount = tx.get("inferred_amount")
|
||||
if amount is None or amount == "":
|
||||
amount = tx.get("amount", 0)
|
||||
try:
|
||||
amount = float(amount)
|
||||
except (ValueError, TypeError):
|
||||
amount = 0
|
||||
else:
|
||||
# Fallback to inference (for rows not yet processed by infer_payments.py)
|
||||
inference = infer_transaction_details(tx, member_names)
|
||||
matched_members = inference["members"]
|
||||
matched_months = inference["months"]
|
||||
amount = tx.get("amount", 0)
|
||||
try:
|
||||
amount = float(amount)
|
||||
except (ValueError, TypeError):
|
||||
amount = 0
|
||||
|
||||
if not matched_members:
|
||||
unmatched.append(tx)
|
||||
continue
|
||||
|
||||
if not matched_months:
|
||||
# If no month specified, try to infer from payment date
|
||||
tx_date = tx["date"]
|
||||
if tx_date:
|
||||
try:
|
||||
dt = datetime.strptime(tx_date, "%Y-%m-%d")
|
||||
# Assume payment is for the current month
|
||||
matched_months = [dt.strftime("%Y-%m")]
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
if not matched_months:
|
||||
if not matched_members or not matched_months:
|
||||
unmatched.append(tx)
|
||||
continue
|
||||
|
||||
# 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)
|
||||
per_allocation = tx["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:
|
||||
# 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:
|
||||
# Try matching by base name if it was Jan Novak (Kačerr) etc.
|
||||
pass
|
||||
|
||||
for month_key in matched_months:
|
||||
entry = {
|
||||
"amount": per_allocation,
|
||||
@@ -372,16 +403,27 @@ def reconcile(
|
||||
# Future month — track as credit
|
||||
credits[member_name] = credits.get(member_name, 0) + int(per_allocation)
|
||||
|
||||
# Calculate final total balances (window + off-window credits)
|
||||
final_balances: dict[str, int] = {}
|
||||
for name in member_names:
|
||||
window_balance = sum(
|
||||
int(mdata["paid"]) - (mdata["expected"] if isinstance(mdata["expected"], int) else 0)
|
||||
for mdata in ledger[name].values()
|
||||
)
|
||||
final_balances[name] = window_balance + credits.get(name, 0)
|
||||
|
||||
return {
|
||||
"members": {
|
||||
name: {
|
||||
"tier": member_tiers[name],
|
||||
"months": ledger[name],
|
||||
"other_transactions": other_ledger[name],
|
||||
"total_balance": final_balances[name]
|
||||
}
|
||||
for name in member_names
|
||||
},
|
||||
"unmatched": unmatched,
|
||||
"credits": credits,
|
||||
"credits": final_balances, # Redefine credits as any positive total balance
|
||||
}
|
||||
|
||||
|
||||
@@ -427,10 +469,12 @@ def print_report(result: dict, sorted_months: list[str]):
|
||||
for m in sorted_months:
|
||||
mdata = data["months"].get(m, {"expected": 0, "paid": 0})
|
||||
expected = mdata["expected"]
|
||||
original = mdata["original_expected"]
|
||||
paid = int(mdata["paid"])
|
||||
total_expected += expected
|
||||
total_paid += paid
|
||||
|
||||
|
||||
cell_status = ""
|
||||
if expected == 0 and paid == 0:
|
||||
cell = "-"
|
||||
elif paid >= expected and expected > 0:
|
||||
@@ -439,6 +483,7 @@ def print_report(result: dict, sorted_months: list[str]):
|
||||
cell = f"{paid}/{expected}"
|
||||
else:
|
||||
cell = f"UNPAID {expected}"
|
||||
|
||||
member_balance += paid - expected
|
||||
line += f" | {cell:>10}"
|
||||
balance_str = f"{member_balance:+d}" if member_balance != 0 else "0"
|
||||
@@ -452,12 +497,30 @@ def print_report(result: dict, sorted_months: list[str]):
|
||||
balance = total_paid - total_expected
|
||||
print(f" | {f'Expected: {total_expected}, Paid: {int(total_paid)}, Balance: {balance:+d}'}")
|
||||
|
||||
# --- Credits ---
|
||||
if result["credits"]:
|
||||
print(f"\n{'CREDITS (advance payments for future months)':}")
|
||||
for name, amount in sorted(result["credits"].items()):
|
||||
# --- Credits (Total Surplus) ---
|
||||
all_credits = {
|
||||
name: data["total_balance"]
|
||||
for name, data in result["members"].items()
|
||||
if data["total_balance"] > 0
|
||||
}
|
||||
|
||||
if all_credits:
|
||||
print(f"\n{'TOTAL CREDITS (advance payments or surplus):'}")
|
||||
for name, amount in sorted(all_credits.items()):
|
||||
print(f" {name}: {amount} CZK")
|
||||
|
||||
# --- Debts (Missing Payments) ---
|
||||
all_debts = {
|
||||
name: data["total_balance"]
|
||||
for name, data in result["members"].items()
|
||||
if data["total_balance"] < 0
|
||||
}
|
||||
|
||||
if all_debts:
|
||||
print(f"\n{'TOTAL DEBTS (missing payments):'}")
|
||||
for name, amount in sorted(all_debts.items()):
|
||||
print(f" {name}: {abs(amount)} CZK")
|
||||
|
||||
# --- Unmatched transactions ---
|
||||
if result["unmatched"]:
|
||||
print(f"\n{'UNMATCHED TRANSACTIONS (need manual review)':}")
|
||||
@@ -499,13 +562,14 @@ def main():
|
||||
description="Match bank payments against expected attendance fees."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--from", dest="date_from", default="2025-12-01",
|
||||
help="Start date YYYY-MM-DD (default: 2025-12-01)",
|
||||
"--sheet-id", default=DEFAULT_SPREADSHEET_ID, help="Google Sheet ID"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--to", dest="date_to",
|
||||
default=datetime.now().strftime("%Y-%m-%d"),
|
||||
help="End date YYYY-MM-DD (default: today)",
|
||||
"--credentials", default=".secret/fuj-management-bot-credentials.json",
|
||||
help="Path to Google API credentials JSON"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--bank", action="store_true", help="Scrape bank instead of using Sheet data"
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
@@ -515,11 +579,21 @@ def main():
|
||||
print("No attendance data found.")
|
||||
return
|
||||
|
||||
print(f"Fetching transactions from {args.date_from} to {args.date_to}...")
|
||||
transactions = fetch_transactions(args.date_from, args.date_to)
|
||||
print(f"Found {len(transactions)} incoming transactions.\n")
|
||||
if args.bank:
|
||||
print(f"Fetching transactions from Fio bank ({args.date_from} to {args.date_to})...")
|
||||
from fio_utils import fetch_transactions
|
||||
transactions = fetch_transactions(args.date_from, args.date_to)
|
||||
else:
|
||||
print(f"Fetching transactions from Google Sheet ({args.sheet_id})...")
|
||||
transactions = fetch_sheet_data(args.sheet_id, args.credentials)
|
||||
|
||||
print(f"Processing {len(transactions)} transactions.\n")
|
||||
|
||||
result = reconcile(members, sorted_months, transactions)
|
||||
exceptions = fetch_exceptions(args.sheet_id, args.credentials)
|
||||
if exceptions:
|
||||
print(f"Loaded {len(exceptions)} fee exceptions.")
|
||||
|
||||
result = reconcile(members, sorted_months, transactions, exceptions)
|
||||
print_report(result, sorted_months)
|
||||
|
||||
|
||||
|
||||
210
scripts/sync_fio_to_sheets.py
Normal file
210
scripts/sync_fio_to_sheets.py
Normal file
@@ -0,0 +1,210 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Sync Fio bank transactions to a Google Sheet intermediary ledger."""
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import os
|
||||
import pickle
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from google.auth.transport.requests import Request
|
||||
from google_auth_oauthlib.flow import InstalledAppFlow
|
||||
from google.oauth2 import service_account
|
||||
from googleapiclient.discovery import build
|
||||
|
||||
from fio_utils import fetch_transactions
|
||||
|
||||
# Configuration
|
||||
DEFAULT_SPREADSHEET_ID = "1Om0YPoDVCH5cV8BrNz5LG5eR5MMU05ypQC7UMN1xn_Y"
|
||||
SCOPES = ["https://www.googleapis.com/auth/spreadsheets"]
|
||||
TOKEN_FILE = "token.pickle"
|
||||
COLUMN_LABELS = ["Date", "Amount", "manual fix", "Person", "Purpose", "Inferred Amount", "Sender", "VS", "Message", "Bank ID", "Sync ID"]
|
||||
def get_sheets_service(credentials_path: str):
|
||||
"""Authenticate and return the Google Sheets API service."""
|
||||
if not os.path.exists(credentials_path):
|
||||
raise FileNotFoundError(f"Credentials file not found: {credentials_path}")
|
||||
|
||||
# Check if it's a service account
|
||||
import json
|
||||
with open(credentials_path, "r") as f:
|
||||
creds_data = json.load(f)
|
||||
|
||||
if creds_data.get("type") == "service_account":
|
||||
creds = service_account.Credentials.from_service_account_file(
|
||||
credentials_path, scopes=SCOPES
|
||||
)
|
||||
else:
|
||||
# Fallback to OAuth2 flow
|
||||
creds = None
|
||||
if os.path.exists(TOKEN_FILE):
|
||||
with open(TOKEN_FILE, "rb") as token:
|
||||
creds = pickle.load(token)
|
||||
|
||||
if not creds or not creds.valid:
|
||||
if creds and creds.expired and creds.refresh_token:
|
||||
creds.refresh(Request())
|
||||
else:
|
||||
flow = InstalledAppFlow.from_client_secrets_file(credentials_path, SCOPES)
|
||||
creds = flow.run_local_server(port=0)
|
||||
|
||||
with open(TOKEN_FILE, "wb") as token:
|
||||
pickle.dump(creds, token)
|
||||
|
||||
return build("sheets", "v4", credentials=creds)
|
||||
|
||||
|
||||
def generate_sync_id(tx: dict) -> str:
|
||||
"""Generate a unique SHA-256 hash for a transaction.
|
||||
|
||||
Hash components: date|amount|currency|sender|vs|message|bank_id
|
||||
"""
|
||||
components = [
|
||||
str(tx.get("date", "")),
|
||||
str(tx.get("amount", "")),
|
||||
str(tx.get("currency", "CZK")),
|
||||
str(tx.get("sender", "")),
|
||||
str(tx.get("vs", "")),
|
||||
str(tx.get("message", "")),
|
||||
str(tx.get("bank_id", "")),
|
||||
]
|
||||
raw_str = "|".join(components).lower()
|
||||
return hashlib.sha256(raw_str.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def sort_sheet_by_date(service, spreadsheet_id):
|
||||
"""Sort the sheet by the Date column (Column B)."""
|
||||
# Get the sheet ID (gid) of the first sheet
|
||||
spreadsheet = service.spreadsheets().get(spreadsheetId=spreadsheet_id).execute()
|
||||
sheet_id = spreadsheet['sheets'][0]['properties']['sheetId']
|
||||
|
||||
requests = [{
|
||||
"sortRange": {
|
||||
"range": {
|
||||
"sheetId": sheet_id,
|
||||
"startRowIndex": 1, # Skip header
|
||||
"endRowIndex": 10000
|
||||
},
|
||||
"sortSpecs": [{
|
||||
"dimensionIndex": 0, # Column A (Date)
|
||||
"sortOrder": "ASCENDING"
|
||||
}]
|
||||
}
|
||||
}]
|
||||
|
||||
service.spreadsheets().batchUpdate(
|
||||
spreadsheetId=spreadsheet_id,
|
||||
body={"requests": requests}
|
||||
).execute()
|
||||
print("Sheet sorted by date.")
|
||||
|
||||
|
||||
def sync_to_sheets(spreadsheet_id: str, credentials_path: str, days: int = None, date_from_str: str = None, date_to_str: str = None, sort_by_date: bool = False):
|
||||
print(f"Connecting to Google Sheets using {credentials_path}...")
|
||||
service = get_sheets_service(credentials_path)
|
||||
sheet = service.spreadsheets()
|
||||
|
||||
# 1. Fetch existing IDs from Column G (last column in A-G range)
|
||||
print(f"Reading existing sync IDs from sheet...")
|
||||
try:
|
||||
result = sheet.values().get(
|
||||
spreadsheetId=spreadsheet_id,
|
||||
range="A1:K" # Include header and all columns to check Sync ID
|
||||
).execute()
|
||||
values = result.get("values", [])
|
||||
|
||||
# Check and insert labels if missing
|
||||
if not values or values[0] != COLUMN_LABELS:
|
||||
print("Inserting column labels...")
|
||||
sheet.values().update(
|
||||
spreadsheetId=spreadsheet_id,
|
||||
range="A1",
|
||||
valueInputOption="USER_ENTERED",
|
||||
body={"values": [COLUMN_LABELS]}
|
||||
).execute()
|
||||
existing_ids = set()
|
||||
else:
|
||||
# Sync ID is now the last column (index 10)
|
||||
existing_ids = {row[10] for row in values[1:] if len(row) > 10}
|
||||
except Exception as e:
|
||||
print(f"Error reading sheet (maybe empty?): {e}")
|
||||
existing_ids = set()
|
||||
|
||||
# 2. Fetch Fio transactions
|
||||
if date_from_str and date_to_str:
|
||||
df_str = date_from_str
|
||||
dt_str = date_to_str
|
||||
else:
|
||||
now = datetime.now()
|
||||
date_to = now
|
||||
date_from = now - timedelta(days=days or 30)
|
||||
df_str = date_from.strftime("%Y-%m-%d")
|
||||
dt_str = date_to.strftime("%Y-%m-%d")
|
||||
|
||||
print(f"Fetching Fio transactions from {df_str} to {dt_str}...")
|
||||
transactions = fetch_transactions(df_str, dt_str)
|
||||
print(f"Found {len(transactions)} transactions.")
|
||||
|
||||
# 3. Filter for new transactions
|
||||
new_rows = []
|
||||
for tx in transactions:
|
||||
sync_id = generate_sync_id(tx)
|
||||
if sync_id not in existing_ids:
|
||||
# Schema: Date | Amount | Manual | Person | Purpose | Inferred Amount | Sender | VS | Message | Bank ID | Sync ID
|
||||
new_rows.append([
|
||||
tx.get("date", ""),
|
||||
tx.get("amount", ""),
|
||||
"", # Manual
|
||||
"", # Person
|
||||
"", # Purpose
|
||||
"", # Inferred Amount
|
||||
tx.get("sender", ""),
|
||||
tx.get("vs", ""),
|
||||
tx.get("message", ""),
|
||||
tx.get("bank_id", ""),
|
||||
sync_id,
|
||||
])
|
||||
|
||||
if not new_rows:
|
||||
print("No new transactions to sync.")
|
||||
return
|
||||
|
||||
# 4. Append to sheet
|
||||
print(f"Appending {len(new_rows)} new transactions to the sheet...")
|
||||
body = {"values": new_rows}
|
||||
sheet.values().append(
|
||||
spreadsheetId=spreadsheet_id,
|
||||
range="A2", # Appends to the end of the sheet
|
||||
valueInputOption="USER_ENTERED",
|
||||
body=body
|
||||
).execute()
|
||||
print("Sync completed successfully.")
|
||||
|
||||
if sort_by_date:
|
||||
sort_sheet_by_date(service, spreadsheet_id)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Sync Fio transactions to Google Sheets.")
|
||||
parser.add_argument("--days", type=int, default=30, help="Days to look back (default: 30)")
|
||||
parser.add_argument("--sheet-id", default=DEFAULT_SPREADSHEET_ID, help="Google Sheet ID")
|
||||
parser.add_argument("--credentials", default="credentials.json", help="Path to Google API credentials JSON")
|
||||
parser.add_argument("--from", dest="date_from", help="Start date YYYY-MM-DD")
|
||||
parser.add_argument("--to", dest="date_to", help="End date YYYY-MM-DD")
|
||||
parser.add_argument("--sort-by-date", action="store_true", help="Sort the sheet by date after sync")
|
||||
args = parser.parse_args()
|
||||
|
||||
try:
|
||||
sync_to_sheets(
|
||||
spreadsheet_id=args.sheet_id,
|
||||
credentials_path=args.credentials,
|
||||
days=args.days,
|
||||
date_from_str=args.date_from,
|
||||
date_to_str=args.date_to,
|
||||
sort_by_date=args.sort_by_date
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"Sync failed: {e}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
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">[Attendance/Fees]</a>
|
||||
<a href="/fees-juniors" class="active">[Junior Fees]</a>
|
||||
<a href="/reconcile">[Payment Reconciliation]</a>
|
||||
<a href="/reconcile-juniors">[Junior 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>
|
||||
@@ -101,11 +101,90 @@
|
||||
color: #aaaaaa;
|
||||
/* Light gray for normal cells */
|
||||
}
|
||||
|
||||
.cell-overridden {
|
||||
color: #ffa500 !important;
|
||||
/* Orange for overrides */
|
||||
}
|
||||
|
||||
.nav {
|
||||
margin-bottom: 20px;
|
||||
font-size: 12px;
|
||||
color: #555;
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.nav a {
|
||||
color: #00ff00;
|
||||
text-decoration: none;
|
||||
padding: 2px 8px;
|
||||
border: 1px solid #333;
|
||||
}
|
||||
|
||||
.nav a.active {
|
||||
color: #000;
|
||||
background-color: #00ff00;
|
||||
border-color: #00ff00;
|
||||
}
|
||||
|
||||
.nav a:hover {
|
||||
color: #fff;
|
||||
border-color: #555;
|
||||
}
|
||||
|
||||
.description {
|
||||
margin-bottom: 20px;
|
||||
text-align: center;
|
||||
color: #888;
|
||||
max-width: 800px;
|
||||
}
|
||||
|
||||
.description a {
|
||||
color: #00ff00;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.description a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.footer {
|
||||
margin-top: 50px;
|
||||
margin-bottom: 20px;
|
||||
color: #333;
|
||||
font-size: 9px;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.perf-breakdown {
|
||||
display: none;
|
||||
margin-top: 5px;
|
||||
color: #222;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="nav">
|
||||
<a href="/fees" class="active">[Attendance/Fees]</a>
|
||||
<a href="/fees-juniors">[Junior Fees]</a>
|
||||
<a href="/reconcile">[Payment Reconciliation]</a>
|
||||
<a href="/reconcile-juniors">[Junior Reconciliation]</a>
|
||||
<a href="/payments">[Payments Ledger]</a>
|
||||
</div>
|
||||
|
||||
<h1>FUJ Fees Dashboard</h1>
|
||||
|
||||
<div class="description">
|
||||
Calculated monthly fees based on attendance markers.<br>
|
||||
Source: <a href="{{ attendance_url }}" target="_blank">Attendance Sheet</a> |
|
||||
<a href="{{ payments_url }}" target="_blank">Payments Ledger</a>
|
||||
</div>
|
||||
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
@@ -120,8 +199,11 @@
|
||||
{% for row in results %}
|
||||
<tr>
|
||||
<td>{{ row.name }}</td>
|
||||
{% for cell in row.months %}
|
||||
<td class="{% if cell == '-' %}cell-empty{% else %}cell-paid{% endif %}">{{ cell }}</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 %}
|
||||
@@ -136,6 +218,14 @@
|
||||
</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>
|
||||
215
templates/payments.html
Normal file
215
templates/payments.html
Normal file
@@ -0,0 +1,215 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>FUJ Payments Ledger</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: 5px;
|
||||
text-transform: uppercase;
|
||||
width: 100%;
|
||||
max-width: 1000px;
|
||||
border-bottom: 1px solid #333;
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.ledger-container {
|
||||
width: 100%;
|
||||
max-width: 1000px;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.member-block {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.txn-table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.txn-table th,
|
||||
.txn-table td {
|
||||
padding: 2px 8px;
|
||||
text-align: left;
|
||||
border-bottom: 1px dashed #222;
|
||||
}
|
||||
|
||||
.txn-table th {
|
||||
color: #555;
|
||||
text-transform: lowercase;
|
||||
font-weight: normal;
|
||||
border-bottom: 1px solid #333;
|
||||
}
|
||||
|
||||
.txn-date {
|
||||
min-width: 80px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.txn-amount {
|
||||
min-width: 80px;
|
||||
text-align: right !important;
|
||||
color: #00ff00;
|
||||
}
|
||||
|
||||
.txn-purpose {
|
||||
min-width: 100px;
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.txn-message {
|
||||
color: #666;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
tr:hover {
|
||||
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>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="nav">
|
||||
<a href="/fees">[Attendance/Fees]</a>
|
||||
<a href="/fees-juniors">[Junior Fees]</a>
|
||||
<a href="/reconcile">[Payment Reconciliation]</a>
|
||||
<a href="/reconcile-juniors">[Junior Reconciliation]</a>
|
||||
<a href="/payments" class="active">[Payments Ledger]</a>
|
||||
</div>
|
||||
|
||||
<h1>Payments Ledger</h1>
|
||||
|
||||
<div class="description">
|
||||
All bank transactions from the Google Sheet, grouped by member.<br>
|
||||
Source: <a href="{{ attendance_url }}" target="_blank">Attendance Sheet</a> |
|
||||
<a href="{{ payments_url }}" target="_blank">Payments Ledger</a>
|
||||
</div>
|
||||
|
||||
<div class="ledger-container">
|
||||
{% for person in sorted_people %}
|
||||
<div class="member-block">
|
||||
<h2>{{ person }}</h2>
|
||||
<table class="txn-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="txn-date">Date</th>
|
||||
<th class="txn-amount">Amount</th>
|
||||
<th class="txn-purpose">Purpose</th>
|
||||
<th class="txn-message">Bank Message</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for tx in grouped_payments[person] %}
|
||||
<tr>
|
||||
<td class="txn-date">{{ tx.date }}</td>
|
||||
<td class="txn-amount">{{ tx.amount }} CZK</td>
|
||||
<td class="txn-purpose">{{ tx.purpose }}</td>
|
||||
<td class="txn-message">{{ tx.message }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</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>
|
||||
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">[Attendance/Fees]</a>
|
||||
<a href="/fees-juniors">[Junior Fees]</a>
|
||||
<a href="/reconcile">[Payment Reconciliation]</a>
|
||||
<a href="/reconcile-juniors" class="active">[Junior 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>
|
||||
```
|
||||
843
templates/reconcile.html
Normal file
843
templates/reconcile.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 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">[Attendance/Fees]</a>
|
||||
<a href="/fees-juniors">[Junior Fees]</a>
|
||||
<a href="/reconcile" class="active">[Payment Reconciliation]</a>
|
||||
<a href="/reconcile-juniors">[Junior Reconciliation]</a>
|
||||
<a href="/payments">[Payments Ledger]</a>
|
||||
</div>
|
||||
|
||||
<h1>Payment Reconciliation</h1>
|
||||
|
||||
<div class="description">
|
||||
Balances calculated by matching Google Sheet payments against 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>
|
||||
```
|
||||
127
tests/test_app.py
Normal file
127
tests/test_app.py
Normal file
@@ -0,0 +1,127 @@
|
||||
import unittest
|
||||
from unittest.mock import patch, MagicMock
|
||||
from app import app
|
||||
|
||||
class TestWebApp(unittest.TestCase):
|
||||
def setUp(self):
|
||||
# Configure app for testing
|
||||
app.config['TESTING'] = True
|
||||
self.client = app.test_client()
|
||||
|
||||
@patch('app.get_members_with_fees')
|
||||
def test_index_page(self, mock_get_members):
|
||||
"""Test that / returns the refresh meta tag"""
|
||||
response = self.client.get('/')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn(b'url=/fees', response.data)
|
||||
|
||||
@patch('app.get_members_with_fees')
|
||||
def test_fees_route(self, mock_get_members):
|
||||
"""Test that /fees returns 200 and renders the dashboard"""
|
||||
# Mock attendance data
|
||||
mock_get_members.return_value = (
|
||||
[('Test Member', 'A', {'2026-01': (750, 4)})],
|
||||
['2026-01']
|
||||
)
|
||||
|
||||
response = self.client.get('/fees')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn(b'FUJ Fees Dashboard', response.data)
|
||||
self.assertIn(b'Test Member', response.data)
|
||||
|
||||
@patch('app.get_junior_members_with_fees')
|
||||
def test_fees_juniors_route(self, mock_get_junior_members):
|
||||
"""Test that /fees-juniors returns 200 and renders the junior dashboard"""
|
||||
# Mock attendance data: one with string symbol '?', one with integer
|
||||
mock_get_junior_members.return_value = (
|
||||
[
|
||||
('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.fetch_sheet_data')
|
||||
@patch('app.get_members_with_fees')
|
||||
def test_reconcile_route(self, mock_get_members, mock_fetch_sheet):
|
||||
"""Test that /reconcile returns 200 and shows matches"""
|
||||
# Mock attendance data
|
||||
mock_get_members.return_value = (
|
||||
[('Test Member', 'A', {'2026-01': (750, 4)})],
|
||||
['2026-01']
|
||||
)
|
||||
# Mock sheet data - include all keys required by reconcile
|
||||
mock_fetch_sheet.return_value = [{
|
||||
'date': '2026-01-01',
|
||||
'amount': 750,
|
||||
'person': 'Test Member',
|
||||
'purpose': '2026-01',
|
||||
'message': 'test payment',
|
||||
'sender': 'External Bank User',
|
||||
'inferred_amount': 750
|
||||
}]
|
||||
|
||||
response = self.client.get('/reconcile')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn(b'Payment Reconciliation', response.data)
|
||||
self.assertIn(b'Test Member', response.data)
|
||||
self.assertIn(b'OK', response.data)
|
||||
|
||||
@patch('app.fetch_sheet_data')
|
||||
def test_payments_route(self, mock_fetch_sheet):
|
||||
"""Test that /payments returns 200 and groups transactions"""
|
||||
# Mock sheet data
|
||||
mock_fetch_sheet.return_value = [{
|
||||
'date': '2026-01-01',
|
||||
'amount': 750,
|
||||
'person': 'Test Member',
|
||||
'purpose': '2026-01',
|
||||
'message': 'Direct Member Payment',
|
||||
'sender': 'External Bank User'
|
||||
}]
|
||||
response = self.client.get('/payments')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn(b'Payments Ledger', response.data)
|
||||
self.assertIn(b'Test Member', response.data)
|
||||
self.assertIn(b'Direct Member Payment', response.data)
|
||||
|
||||
@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):
|
||||
"""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__':
|
||||
unittest.main()
|
||||
56
tests/test_reconcile_exceptions.py
Normal file
56
tests/test_reconcile_exceptions.py
Normal file
@@ -0,0 +1,56 @@
|
||||
import unittest
|
||||
from scripts.match_payments import reconcile
|
||||
|
||||
class TestReconcileWithExceptions(unittest.TestCase):
|
||||
def test_reconcile_applies_exceptions(self):
|
||||
# 1. Setup mock data
|
||||
# Member: Alice, Tier A, expected 750 (attendance-based)
|
||||
members = [
|
||||
('Alice', 'A', {'2026-01': (750, 4)})
|
||||
]
|
||||
sorted_months = ['2026-01']
|
||||
|
||||
# Exception: Alice should only pay 400 in 2026-01 (normalized keys, no accents)
|
||||
exceptions = {
|
||||
('alice', '2026-01'): {'amount': 400, 'note': 'Test exception'}
|
||||
}
|
||||
|
||||
# Transaction: Alice paid 400
|
||||
transactions = [{
|
||||
'date': '2026-01-05',
|
||||
'amount': 400,
|
||||
'person': 'Alice',
|
||||
'purpose': '2026-01',
|
||||
'inferred_amount': 400,
|
||||
'sender': 'Alice Sender',
|
||||
'message': 'fee'
|
||||
}]
|
||||
|
||||
# 2. Reconcile
|
||||
result = reconcile(members, sorted_months, transactions, exceptions)
|
||||
|
||||
# 3. Assertions
|
||||
alice_data = result['members']['Alice']
|
||||
jan_data = alice_data['months']['2026-01']
|
||||
|
||||
self.assertEqual(jan_data['expected'], 400, "Expected amount should be overridden by exception")
|
||||
self.assertEqual(jan_data['paid'], 400, "Paid amount should be 400")
|
||||
self.assertEqual(alice_data['total_balance'], 0, "Balance should be 0 because 400/400")
|
||||
|
||||
def test_reconcile_fallback_to_attendance(self):
|
||||
# Alice has attendance-based fee 750, NO exception
|
||||
members = [
|
||||
('Alice', 'A', {'2026-01': (750, 4)})
|
||||
]
|
||||
sorted_months = ['2026-01']
|
||||
exceptions = {} # No exceptions
|
||||
|
||||
transactions = []
|
||||
|
||||
result = reconcile(members, sorted_months, transactions, exceptions)
|
||||
|
||||
alice_data = result['members']['Alice']
|
||||
self.assertEqual(alice_data['months']['2026-01']['expected'], 750, "Should fallback to attendance fee")
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
617
uv.lock
generated
Normal file
617
uv.lock
generated
Normal file
@@ -0,0 +1,617 @@
|
||||
version = 1
|
||||
revision = 3
|
||||
requires-python = ">=3.13"
|
||||
|
||||
[[package]]
|
||||
name = "blinker"
|
||||
version = "1.9.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "certifi"
|
||||
version = "2026.2.25"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cffi"
|
||||
version = "2.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pycparser", marker = "implementation_name != 'PyPy'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "charset-normalizer"
|
||||
version = "3.4.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "click"
|
||||
version = "8.3.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "colorama"
|
||||
version = "0.4.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
|
||||
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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cryptography"
|
||||
version = "46.0.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/60/04/ee2a9e8542e4fa2773b81771ff8349ff19cdd56b7258a0cc442639052edb/cryptography-46.0.5.tar.gz", hash = "sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d", size = 750064, upload-time = "2026-02-10T19:18:38.255Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/81/b0bb27f2ba931a65409c6b8a8b358a7f03c0e46eceacddff55f7c84b1f3b/cryptography-46.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad", size = 7176289, upload-time = "2026-02-10T19:17:08.274Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/9e/6b4397a3e3d15123de3b1806ef342522393d50736c13b20ec4c9ea6693a6/cryptography-46.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b", size = 4275637, upload-time = "2026-02-10T19:17:10.53Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/e7/471ab61099a3920b0c77852ea3f0ea611c9702f651600397ac567848b897/cryptography-46.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b", size = 4424742, upload-time = "2026-02-10T19:17:12.388Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/53/a18500f270342d66bf7e4d9f091114e31e5ee9e7375a5aba2e85a91e0044/cryptography-46.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263", size = 4277528, upload-time = "2026-02-10T19:17:13.853Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/29/c2e812ebc38c57b40e7c583895e73c8c5adb4d1e4a0cc4c5a4fdab2b1acc/cryptography-46.0.5-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d", size = 4947993, upload-time = "2026-02-10T19:17:15.618Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/e7/237155ae19a9023de7e30ec64e5d99a9431a567407ac21170a046d22a5a3/cryptography-46.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed", size = 4456855, upload-time = "2026-02-10T19:17:17.221Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/87/fc628a7ad85b81206738abbd213b07702bcbdada1dd43f72236ef3cffbb5/cryptography-46.0.5-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2", size = 3984635, upload-time = "2026-02-10T19:17:18.792Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/29/65b55622bde135aedf4565dc509d99b560ee4095e56989e815f8fd2aa910/cryptography-46.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2", size = 4277038, upload-time = "2026-02-10T19:17:20.256Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/36/45e76c68d7311432741faf1fbf7fac8a196a0a735ca21f504c75d37e2558/cryptography-46.0.5-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0", size = 4912181, upload-time = "2026-02-10T19:17:21.825Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/1a/c1ba8fead184d6e3d5afcf03d569acac5ad063f3ac9fb7258af158f7e378/cryptography-46.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731", size = 4456482, upload-time = "2026-02-10T19:17:25.133Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/e5/3fb22e37f66827ced3b902cf895e6a6bc1d095b5b26be26bd13c441fdf19/cryptography-46.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82", size = 4405497, upload-time = "2026-02-10T19:17:26.66Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/df/9d58bb32b1121a8a2f27383fabae4d63080c7ca60b9b5c88be742be04ee7/cryptography-46.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1", size = 4667819, upload-time = "2026-02-10T19:17:28.569Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/ed/325d2a490c5e94038cdb0117da9397ece1f11201f425c4e9c57fe5b9f08b/cryptography-46.0.5-cp311-abi3-win32.whl", hash = "sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48", size = 3028230, upload-time = "2026-02-10T19:17:30.518Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/5a/ac0f49e48063ab4255d9e3b79f5def51697fce1a95ea1370f03dc9db76f6/cryptography-46.0.5-cp311-abi3-win_amd64.whl", hash = "sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4", size = 3480909, upload-time = "2026-02-10T19:17:32.083Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/13/3d278bfa7a15a96b9dc22db5a12ad1e48a9eb3d40e1827ef66a5df75d0d0/cryptography-46.0.5-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:94a76daa32eb78d61339aff7952ea819b1734b46f73646a07decb40e5b3448e2", size = 7119287, upload-time = "2026-02-10T19:17:33.801Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/c8/581a6702e14f0898a0848105cbefd20c058099e2c2d22ef4e476dfec75d7/cryptography-46.0.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5be7bf2fb40769e05739dd0046e7b26f9d4670badc7b032d6ce4db64dddc0678", size = 4265728, upload-time = "2026-02-10T19:17:35.569Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/4a/ba1a65ce8fc65435e5a849558379896c957870dd64fecea97b1ad5f46a37/cryptography-46.0.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe346b143ff9685e40192a4960938545c699054ba11d4f9029f94751e3f71d87", size = 4408287, upload-time = "2026-02-10T19:17:36.938Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/67/8ffdbf7b65ed1ac224d1c2df3943553766914a8ca718747ee3871da6107e/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:c69fd885df7d089548a42d5ec05be26050ebcd2283d89b3d30676eb32ff87dee", size = 4270291, upload-time = "2026-02-10T19:17:38.748Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/e5/f52377ee93bc2f2bba55a41a886fd208c15276ffbd2569f2ddc89d50e2c5/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:8293f3dea7fc929ef7240796ba231413afa7b68ce38fd21da2995549f5961981", size = 4927539, upload-time = "2026-02-10T19:17:40.241Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/02/cfe39181b02419bbbbcf3abdd16c1c5c8541f03ca8bda240debc467d5a12/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:1abfdb89b41c3be0365328a410baa9df3ff8a9110fb75e7b52e66803ddabc9a9", size = 4442199, upload-time = "2026-02-10T19:17:41.789Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/96/2fcaeb4873e536cf71421a388a6c11b5bc846e986b2b069c79363dc1648e/cryptography-46.0.5-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d66e421495fdb797610a08f43b05269e0a5ea7f5e652a89bfd5a7d3c1dee3648", size = 3960131, upload-time = "2026-02-10T19:17:43.379Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/d2/b27631f401ddd644e94c5cf33c9a4069f72011821cf3dc7309546b0642a0/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:4e817a8920bfbcff8940ecfd60f23d01836408242b30f1a708d93198393a80b4", size = 4270072, upload-time = "2026-02-10T19:17:45.481Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/a7/60d32b0370dae0b4ebe55ffa10e8599a2a59935b5ece1b9f06edb73abdeb/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:68f68d13f2e1cb95163fa3b4db4bf9a159a418f5f6e7242564fc75fcae667fd0", size = 4892170, upload-time = "2026-02-10T19:17:46.997Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/b9/cf73ddf8ef1164330eb0b199a589103c363afa0cf794218c24d524a58eab/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a3d1fae9863299076f05cb8a778c467578262fae09f9dc0ee9b12eb4268ce663", size = 4441741, upload-time = "2026-02-10T19:17:48.661Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/eb/eee00b28c84c726fe8fa0158c65afe312d9c3b78d9d01daf700f1f6e37ff/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4143987a42a2397f2fc3b4d7e3a7d313fbe684f67ff443999e803dd75a76826", size = 4396728, upload-time = "2026-02-10T19:17:50.058Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/f4/6bc1a9ed5aef7145045114b75b77c2a8261b4d38717bd8dea111a63c3442/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7d731d4b107030987fd61a7f8ab512b25b53cef8f233a97379ede116f30eb67d", size = 4652001, upload-time = "2026-02-10T19:17:51.54Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/ef/5d00ef966ddd71ac2e6951d278884a84a40ffbd88948ef0e294b214ae9e4/cryptography-46.0.5-cp314-cp314t-win32.whl", hash = "sha256:c3bcce8521d785d510b2aad26ae2c966092b7daa8f45dd8f44734a104dc0bc1a", size = 3003637, upload-time = "2026-02-10T19:17:52.997Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/57/f3f4160123da6d098db78350fdfd9705057aad21de7388eacb2401dceab9/cryptography-46.0.5-cp314-cp314t-win_amd64.whl", hash = "sha256:4d8ae8659ab18c65ced284993c2265910f6c9e650189d4e3f68445ef82a810e4", size = 3469487, upload-time = "2026-02-10T19:17:54.549Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/fa/a66aa722105ad6a458bebd64086ca2b72cdd361fed31763d20390f6f1389/cryptography-46.0.5-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31", size = 7170514, upload-time = "2026-02-10T19:17:56.267Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/04/c85bdeab78c8bc77b701bf0d9bdcf514c044e18a46dcff330df5448631b0/cryptography-46.0.5-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18", size = 4275349, upload-time = "2026-02-10T19:17:58.419Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/32/9b87132a2f91ee7f5223b091dc963055503e9b442c98fc0b8a5ca765fab0/cryptography-46.0.5-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235", size = 4420667, upload-time = "2026-02-10T19:18:00.619Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/a6/a7cb7010bec4b7c5692ca6f024150371b295ee1c108bdc1c400e4c44562b/cryptography-46.0.5-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a", size = 4276980, upload-time = "2026-02-10T19:18:02.379Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/7c/c4f45e0eeff9b91e3f12dbd0e165fcf2a38847288fcfd889deea99fb7b6d/cryptography-46.0.5-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76", size = 4939143, upload-time = "2026-02-10T19:18:03.964Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/19/e1b8f964a834eddb44fa1b9a9976f4e414cbb7aa62809b6760c8803d22d1/cryptography-46.0.5-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614", size = 4453674, upload-time = "2026-02-10T19:18:05.588Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/ed/db15d3956f65264ca204625597c410d420e26530c4e2943e05a0d2f24d51/cryptography-46.0.5-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229", size = 3978801, upload-time = "2026-02-10T19:18:07.167Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/e2/df40a31d82df0a70a0daf69791f91dbb70e47644c58581d654879b382d11/cryptography-46.0.5-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1", size = 4276755, upload-time = "2026-02-10T19:18:09.813Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/45/726809d1176959f4a896b86907b98ff4391a8aa29c0aaaf9450a8a10630e/cryptography-46.0.5-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d", size = 4901539, upload-time = "2026-02-10T19:18:11.263Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/0f/a3076874e9c88ecb2ecc31382f6e7c21b428ede6f55aafa1aa272613e3cd/cryptography-46.0.5-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c", size = 4452794, upload-time = "2026-02-10T19:18:12.914Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/ef/ffeb542d3683d24194a38f66ca17c0a4b8bf10631feef44a7ef64e631b1a/cryptography-46.0.5-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4", size = 4404160, upload-time = "2026-02-10T19:18:14.375Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/93/682d2b43c1d5f1406ed048f377c0fc9fc8f7b0447a478d5c65ab3d3a66eb/cryptography-46.0.5-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9", size = 4667123, upload-time = "2026-02-10T19:18:15.886Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/2d/9c5f2926cb5300a8eefc3f4f0b3f3df39db7f7ce40c8365444c49363cbda/cryptography-46.0.5-cp38-abi3-win32.whl", hash = "sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72", size = 3010220, upload-time = "2026-02-10T19:18:17.361Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/ef/0c2f4a8e31018a986949d34a01115dd057bf536905dca38897bacd21fac3/cryptography-46.0.5-cp38-abi3-win_amd64.whl", hash = "sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595", size = 3467050, upload-time = "2026-02-10T19:18:18.899Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "flask"
|
||||
version = "3.1.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "blinker" },
|
||||
{ name = "click" },
|
||||
{ name = "itsdangerous" },
|
||||
{ name = "jinja2" },
|
||||
{ name = "markupsafe" },
|
||||
{ name = "werkzeug" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/26/00/35d85dcce6c57fdc871f3867d465d780f302a175ea360f62533f12b27e2b/flask-3.1.3.tar.gz", hash = "sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb", size = 759004, upload-time = "2026-02-19T05:00:57.678Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/9c/34f6962f9b9e9c71f6e5ed806e0d0ff03c9d1b0b2340088a0cf4bce09b18/flask-3.1.3-py3-none-any.whl", hash = "sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c", size = 103424, upload-time = "2026-02-19T05:00:56.027Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fuj-management"
|
||||
version = "0.10"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "flask" },
|
||||
{ name = "google-api-python-client" },
|
||||
{ name = "google-auth-httplib2" },
|
||||
{ name = "google-auth-oauthlib" },
|
||||
{ name = "qrcode", extra = ["pil"] },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "flask", specifier = ">=3.1.3" },
|
||||
{ name = "google-api-python-client", specifier = ">=2.162.0" },
|
||||
{ name = "google-auth-httplib2", specifier = ">=0.2.0" },
|
||||
{ name = "google-auth-oauthlib", specifier = ">=1.2.1" },
|
||||
{ name = "qrcode", extras = ["pil"], specifier = ">=8.0" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "google-api-core"
|
||||
version = "2.30.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "google-auth" },
|
||||
{ name = "googleapis-common-protos" },
|
||||
{ name = "proto-plus" },
|
||||
{ name = "protobuf" },
|
||||
{ name = "requests" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/22/98/586ec94553b569080caef635f98a3723db36a38eac0e3d7eb3ea9d2e4b9a/google_api_core-2.30.0.tar.gz", hash = "sha256:02edfa9fab31e17fc0befb5f161b3bf93c9096d99aed584625f38065c511ad9b", size = 176959, upload-time = "2026-02-18T20:28:11.926Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/45/27/09c33d67f7e0dcf06d7ac17d196594e66989299374bfb0d4331d1038e76b/google_api_core-2.30.0-py3-none-any.whl", hash = "sha256:80be49ee937ff9aba0fd79a6eddfde35fe658b9953ab9b79c57dd7061afa8df5", size = 173288, upload-time = "2026-02-18T20:28:10.367Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "google-api-python-client"
|
||||
version = "2.190.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "google-api-core" },
|
||||
{ name = "google-auth" },
|
||||
{ name = "google-auth-httplib2" },
|
||||
{ name = "httplib2" },
|
||||
{ name = "uritemplate" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e4/8d/4ab3e3516b93bb50ed7814738ea61d49cba3f72f4e331dc9518ae2731e92/google_api_python_client-2.190.0.tar.gz", hash = "sha256:5357f34552e3724d80d2604c8fa146766e0a9d6bb0afada886fafed9feafeef6", size = 14111143, upload-time = "2026-02-12T00:38:03.37Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/07/ad/223d5f4b0b987669ffeb3eadd7e9f85ece633aa7fd3246f1e2f6238e1e05/google_api_python_client-2.190.0-py3-none-any.whl", hash = "sha256:d9b5266758f96c39b8c21d9bbfeb4e58c14dbfba3c931f7c5a8d7fdcd292dd57", size = 14682070, upload-time = "2026-02-12T00:38:00.974Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "google-auth"
|
||||
version = "2.48.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cryptography" },
|
||||
{ name = "pyasn1-modules" },
|
||||
{ name = "rsa" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/0c/41/242044323fbd746615884b1c16639749e73665b718209946ebad7ba8a813/google_auth-2.48.0.tar.gz", hash = "sha256:4f7e706b0cd3208a3d940a19a822c37a476ddba5450156c3e6624a71f7c841ce", size = 326522, upload-time = "2026-01-26T19:22:47.157Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/83/1d/d6466de3a5249d35e832a52834115ca9d1d0de6abc22065f049707516d47/google_auth-2.48.0-py3-none-any.whl", hash = "sha256:2e2a537873d449434252a9632c28bfc268b0adb1e53f9fb62afc5333a975903f", size = 236499, upload-time = "2026-01-26T19:22:45.099Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "google-auth-httplib2"
|
||||
version = "0.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "google-auth" },
|
||||
{ name = "httplib2" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d5/ad/c1f2b1175096a8d04cf202ad5ea6065f108d26be6fc7215876bde4a7981d/google_auth_httplib2-0.3.0.tar.gz", hash = "sha256:177898a0175252480d5ed916aeea183c2df87c1f9c26705d74ae6b951c268b0b", size = 11134, upload-time = "2025-12-15T22:13:51.825Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/99/d5/3c97526c8796d3caf5f4b3bed2b05e8a7102326f00a334e7a438237f3b22/google_auth_httplib2-0.3.0-py3-none-any.whl", hash = "sha256:426167e5df066e3f5a0fc7ea18768c08e7296046594ce4c8c409c2457dd1f776", size = 9529, upload-time = "2025-12-15T22:13:51.048Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "google-auth-oauthlib"
|
||||
version = "1.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "google-auth" },
|
||||
{ name = "requests-oauthlib" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ac/b4/1b19567e4c567b796f5c593d89895f3cfae5a38e04f27c6af87618fd0942/google_auth_oauthlib-1.3.0.tar.gz", hash = "sha256:cd39e807ac7229d6b8b9c1e297321d36fcc8a9e4857dff4301870985df51a528", size = 21777, upload-time = "2026-02-27T14:13:01.489Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/56/909fd5632226d3fba31d7aeffd4754410735d49362f5809956fe3e9af344/google_auth_oauthlib-1.3.0-py3-none-any.whl", hash = "sha256:386b3fb85cf4a5b819c6ad23e3128d975216b4cac76324de1d90b128aaf38f29", size = 19308, upload-time = "2026-02-27T14:12:47.865Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "googleapis-common-protos"
|
||||
version = "1.72.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "protobuf" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e5/7b/adfd75544c415c487b33061fe7ae526165241c1ea133f9a9125a56b39fd8/googleapis_common_protos-1.72.0.tar.gz", hash = "sha256:e55a601c1b32b52d7a3e65f43563e2aa61bcd737998ee672ac9b951cd49319f5", size = 147433, upload-time = "2025-11-06T18:29:24.087Z" }
|
||||
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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httplib2"
|
||||
version = "0.31.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pyparsing" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c1/1f/e86365613582c027dda5ddb64e1010e57a3d53e99ab8a72093fa13d565ec/httplib2-0.31.2.tar.gz", hash = "sha256:385e0869d7397484f4eab426197a4c020b606edd43372492337c0b4010ae5d24", size = 250800, upload-time = "2026-01-23T11:04:44.165Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/90/fd509079dfcab01102c0fdd87f3a9506894bc70afcf9e9785ef6b2b3aff6/httplib2-0.31.2-py3-none-any.whl", hash = "sha256:dbf0c2fa3862acf3c55c078ea9c0bc4481d7dc5117cae71be9514912cf9f8349", size = 91099, upload-time = "2026-01-23T11:04:42.78Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.11"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
|
||||
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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itsdangerous"
|
||||
version = "2.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jinja2"
|
||||
version = "3.1.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "markupsafe" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "markupsafe"
|
||||
version = "3.0.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "oauthlib"
|
||||
version = "3.3.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/0b/5f/19930f824ffeb0ad4372da4812c50edbd1434f678c90c2733e1188edfc63/oauthlib-3.3.1.tar.gz", hash = "sha256:0f0f8aa759826a193cf66c12ea1af1637f87b9b4622d46e866952bb022e538c9", size = 185918, upload-time = "2025-06-19T22:48:08.269Z" }
|
||||
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" },
|
||||
]
|
||||
|
||||
[[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 = "proto-plus"
|
||||
version = "1.27.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "protobuf" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/3a/02/8832cde80e7380c600fbf55090b6ab7b62bd6825dbedde6d6657c15a1f8e/proto_plus-1.27.1.tar.gz", hash = "sha256:912a7460446625b792f6448bade9e55cd4e41e6ac10e27009ef71a7f317fa147", size = 56929, upload-time = "2026-02-02T17:34:49.035Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/79/ac273cbbf744691821a9cca88957257f41afe271637794975ca090b9588b/proto_plus-1.27.1-py3-none-any.whl", hash = "sha256:e4643061f3a4d0de092d62aa4ad09fa4756b2cbb89d4627f3985018216f9fefc", size = 50480, upload-time = "2026-02-02T17:34:47.339Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "protobuf"
|
||||
version = "6.33.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ba/25/7c72c307aafc96fa87062aa6291d9f7c94836e43214d43722e86037aac02/protobuf-6.33.5.tar.gz", hash = "sha256:6ddcac2a081f8b7b9642c09406bc6a4290128fce5f471cddd165960bb9119e5c", size = 444465, upload-time = "2026-01-29T21:51:33.494Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/79/af92d0a8369732b027e6d6084251dd8e782c685c72da161bd4a2e00fbabb/protobuf-6.33.5-cp310-abi3-win32.whl", hash = "sha256:d71b040839446bac0f4d162e758bea99c8251161dae9d0983a3b88dee345153b", size = 425769, upload-time = "2026-01-29T21:51:21.751Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/75/bb9bc917d10e9ee13dee8607eb9ab963b7cf8be607c46e7862c748aa2af7/protobuf-6.33.5-cp310-abi3-win_amd64.whl", hash = "sha256:3093804752167bcab3998bec9f1048baae6e29505adaf1afd14a37bddede533c", size = 437118, upload-time = "2026-01-29T21:51:24.022Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/6b/e48dfc1191bc5b52950246275bf4089773e91cb5ba3592621723cdddca62/protobuf-6.33.5-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:a5cb85982d95d906df1e2210e58f8e4f1e3cdc088e52c921a041f9c9a0386de5", size = 427766, upload-time = "2026-01-29T21:51:25.413Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/b1/c79468184310de09d75095ed1314b839eb2f72df71097db9d1404a1b2717/protobuf-6.33.5-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:9b71e0281f36f179d00cbcb119cb19dec4d14a81393e5ea220f64b286173e190", size = 324638, upload-time = "2026-01-29T21:51:26.423Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/f5/65d838092fd01c44d16037953fd4c2cc851e783de9b8f02b27ec4ffd906f/protobuf-6.33.5-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:8afa18e1d6d20af15b417e728e9f60f3aa108ee76f23c3b2c07a2c3b546d3afd", size = 339411, upload-time = "2026-01-29T21:51:27.446Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/53/a9443aa3ca9ba8724fdfa02dd1887c1bcd8e89556b715cfbacca6b63dbec/protobuf-6.33.5-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:cbf16ba3350fb7b889fca858fb215967792dc125b35c7976ca4818bee3521cf0", size = 323465, upload-time = "2026-01-29T21:51:28.925Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/bf/2086963c69bdac3d7cff1cc7ff79b8ce5ea0bec6797a017e1be338a46248/protobuf-6.33.5-py3-none-any.whl", hash = "sha256:69915a973dd0f60f31a08b8318b73eab2bd6a392c79184b3612226b0a3f8ec02", size = 170687, upload-time = "2026-01-29T21:51:32.557Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyasn1"
|
||||
version = "0.6.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/fe/b6/6e630dff89739fcd427e3f72b3d905ce0acb85a45d4ec3e2678718a3487f/pyasn1-0.6.2.tar.gz", hash = "sha256:9b59a2b25ba7e4f8197db7686c09fb33e658b98339fadb826e9512629017833b", size = 146586, upload-time = "2026-01-16T18:04:18.534Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/44/b5/a96872e5184f354da9c84ae119971a0a4c221fe9b27a4d94bd43f2596727/pyasn1-0.6.2-py3-none-any.whl", hash = "sha256:1eb26d860996a18e9b6ed05e7aae0e9fc21619fcee6af91cca9bad4fbea224bf", size = 83371, upload-time = "2026-01-16T18:04:17.174Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyasn1-modules"
|
||||
version = "0.4.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pyasn1" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892, upload-time = "2025-03-28T02:41:22.17Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pycparser"
|
||||
version = "3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" }
|
||||
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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyparsing"
|
||||
version = "3.3.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f3/91/9c6ee907786a473bf81c5f53cf703ba0957b23ab84c264080fb5a450416f/pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc", size = 6851574, upload-time = "2026-01-21T03:57:59.36Z" }
|
||||
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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "requests"
|
||||
version = "2.32.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "certifi" },
|
||||
{ name = "charset-normalizer" },
|
||||
{ name = "idna" },
|
||||
{ name = "urllib3" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "requests-oauthlib"
|
||||
version = "2.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "oauthlib" },
|
||||
{ name = "requests" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/42/f2/05f29bc3913aea15eb670be136045bf5c5bbf4b99ecb839da9b422bb2c85/requests-oauthlib-2.0.0.tar.gz", hash = "sha256:b3dffaebd884d8cd778494369603a9e7b58d29111bf6b41bdc2dcd87203af4e9", size = 55650, upload-time = "2024-03-22T20:32:29.939Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/5d/63d4ae3b9daea098d5d6f5da83984853c1bbacd5dc826764b249fe119d24/requests_oauthlib-2.0.0-py2.py3-none-any.whl", hash = "sha256:7dd8a5c40426b779b0868c404bdef9768deccf22749cde15852df527e6269b36", size = 24179, upload-time = "2024-03-22T20:32:28.055Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rsa"
|
||||
version = "4.9.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pyasn1" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/da/8a/22b7beea3ee0d44b1916c0c1cb0ee3af23b700b6da9f04991899d0c555d4/rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75", size = 29034, upload-time = "2025-04-16T09:51:18.218Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", size = 34696, upload-time = "2025-04-16T09:51:17.142Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "uritemplate"
|
||||
version = "4.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/98/60/f174043244c5306c9988380d2cb10009f91563fc4b31293d27e17201af56/uritemplate-4.2.0.tar.gz", hash = "sha256:480c2ed180878955863323eea31b0ede668795de182617fef9c6ca09e6ec9d0e", size = 33267, upload-time = "2025-06-02T15:12:06.318Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/99/3ae339466c9183ea5b8ae87b34c0b897eda475d2aec2307cae60e5cd4f29/uritemplate-4.2.0-py3-none-any.whl", hash = "sha256:962201ba1c4edcab02e60f9a0d3821e82dfc5d2d6662a21abd533879bdb8a686", size = 11488, upload-time = "2025-06-02T15:12:03.405Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "urllib3"
|
||||
version = "2.6.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "werkzeug"
|
||||
version = "3.1.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "markupsafe" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/61/f1/ee81806690a87dab5f5653c1f146c92bc066d7f4cebc603ef88eb9e13957/werkzeug-3.1.6.tar.gz", hash = "sha256:210c6bede5a420a913956b4791a7f4d6843a43b6fcee4dfa08a65e93007d0d25", size = 864736, upload-time = "2026-02-19T15:17:18.884Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/ec/d58832f89ede95652fd01f4f24236af7d32b70cab2196dfcc2d2fd13c5c2/werkzeug-3.1.6-py3-none-any.whl", hash = "sha256:7ddf3357bb9564e407607f988f683d72038551200c704012bb9a4c523d42f131", size = 225166, upload-time = "2026-02-19T15:17:17.475Z" },
|
||||
]
|
||||
Reference in New Issue
Block a user