3 Commits

Author SHA1 Message Date
7d51f9ca77 Merge pull request 'refactor: code quality improvements across the backend' (#3) from claude-suggested-fixes into main
All checks were successful
Deploy to K8s / deploy (push) Successful in 7s
Reviewed-on: #3
2026-03-11 10:55:52 +00:00
033349cafa refactor: code quality improvements across the backend
All checks were successful
Deploy to K8s / deploy (push) Successful in 13s
Build and Push / build (push) Successful in 32s
- Remove insecure SSL verification bypass in attendance.py
- Add gunicorn as production WSGI server (Dockerfile + entrypoint)
- Fix silent data loss in reconciliation (log + surface unmatched members)
- Add required column validation in payment sheet parsing
- Add input validation on /qr route (account format, amount bounds, SPD injection)
- Centralize configuration into scripts/config.py
- Extract credentials path to env-configurable constant
- Hide unmatched transactions from reconcile-juniors page
- Fix test mocks to bypass cache layer (all 8 tests now pass reliably)
- Add pytest + pytest-cov dev dependencies
- Fix typo "Inffering" in infer_payments.py
- Update CLAUDE.md to reflect current project state

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 11:40:32 +01:00
0d0c2af778 Merge pull request 'google-documents-read-caching' (#2) from google-documents-read-caching into main
All checks were successful
Deploy to K8s / deploy (push) Successful in 10s
Reviewed-on: #2
2026-03-11 10:13:18 +00:00
13 changed files with 293 additions and 88 deletions

View File

@@ -4,22 +4,12 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project Status ## Project Status
This is a greenfield project in early discovery/design phase. No source code exists yet. The project aims to automate financial and operational management for a small sports club. Flask-based financial management system for FUJ (Frisbee Ultimate Jablonec). Handles attendance-based fee calculation, Fio bank transaction sync, payment reconciliation, and a web dashboard.
See `docs/project-notes.md` for the current brainstorming state, domain model, and open questions that need answering before implementation begins.
## Key Constraints ## Key Constraints
- **PII separation**: Member data (names, emails, payment info) must never be committed to git. Enforce config/data separation from day one. - **PII separation**: Member data (names, emails, payment info) must never be committed to git. Enforce config/data separation from day one.
- **Incremental approach**: Start with highest-ROI automation (likely fee billing & payment tracking), not a full platform. - **Configuration**: External service IDs, credentials, and tunable parameters are centralized in `scripts/config.py`. Domain-specific constants (fees, merged months) stay in their respective modules.
## Development Workflow
This project uses a hybrid workflow:
- Claude.ai chat for brainstorming and design exploration
- Claude Code for implementation
## When Code Exists
## Development Setup ## Development Setup
@@ -40,7 +30,7 @@ Alternatively, use the Makefile:
- `make web` - Start dashboard - `make web` - Start dashboard
- `make image` - Build Docker image - `make image` - Build Docker image
Requires `credentials.json` in the root for Google Sheets API access. Requires `.secret/fuj-management-bot-credentials.json` for Google Sheets API access (configurable via `CREDENTIALS_PATH` env var).
## Git Commits ## Git Commits

34
app.py
View File

@@ -17,8 +17,12 @@ logging.basicConfig(level=getattr(logging, log_level, logging.INFO), format='%(a
scripts_dir = Path(__file__).parent / "scripts" scripts_dir = Path(__file__).parent / "scripts"
sys.path.append(str(scripts_dir)) sys.path.append(str(scripts_dir))
from attendance import get_members_with_fees, get_junior_members_with_fees, SHEET_ID as ATTENDANCE_SHEET_ID, JUNIOR_SHEET_GID, ADULT_MERGED_MONTHS, JUNIOR_MERGED_MONTHS from config import (
from match_payments import reconcile, fetch_sheet_data, fetch_exceptions, normalize, DEFAULT_SPREADSHEET_ID as PAYMENTS_SHEET_ID ATTENDANCE_SHEET_ID, PAYMENTS_SHEET_ID, JUNIOR_SHEET_GID,
BANK_ACCOUNT, CREDENTIALS_PATH,
)
from attendance import get_members_with_fees, get_junior_members_with_fees, ADULT_MERGED_MONTHS, JUNIOR_MERGED_MONTHS
from match_payments import reconcile, fetch_sheet_data, fetch_exceptions, normalize
from cache_utils import get_sheet_modified_time, read_cache, write_cache, _LAST_CHECKED from cache_utils import get_sheet_modified_time, read_cache, write_cache, _LAST_CHECKED
def get_cached_data(cache_key, sheet_id, fetch_func, *args, serialize=None, deserialize=None, **kwargs): def get_cached_data(cache_key, sheet_id, fetch_func, *args, serialize=None, deserialize=None, **kwargs):
@@ -53,9 +57,6 @@ def get_month_labels(sorted_months, merged_months):
app = Flask(__name__) app = Flask(__name__)
# Bank account for QR code payments (can be overridden by ENV)
BANK_ACCOUNT = os.environ.get("BANK_ACCOUNT", "CZ8520100000002800359168")
@app.before_request @app.before_request
def start_timer(): def start_timer():
g.start_time = time.perf_counter() g.start_time = time.perf_counter()
@@ -110,7 +111,7 @@ def fees():
monthly_totals = {m: 0 for m in sorted_months} monthly_totals = {m: 0 for m in sorted_months}
# Get exceptions for formatting # Get exceptions for formatting
credentials_path = ".secret/fuj-management-bot-credentials.json" credentials_path = CREDENTIALS_PATH
exceptions = get_cached_data( exceptions = get_cached_data(
"exceptions_dict", PAYMENTS_SHEET_ID, fetch_exceptions, "exceptions_dict", PAYMENTS_SHEET_ID, fetch_exceptions,
PAYMENTS_SHEET_ID, credentials_path, PAYMENTS_SHEET_ID, credentials_path,
@@ -173,7 +174,7 @@ def fees_juniors():
monthly_totals = {m: 0 for m in sorted_months} monthly_totals = {m: 0 for m in sorted_months}
# Get exceptions for formatting (reusing payments sheet) # Get exceptions for formatting (reusing payments sheet)
credentials_path = ".secret/fuj-management-bot-credentials.json" credentials_path = CREDENTIALS_PATH
exceptions = get_cached_data( exceptions = get_cached_data(
"exceptions_dict", PAYMENTS_SHEET_ID, fetch_exceptions, "exceptions_dict", PAYMENTS_SHEET_ID, fetch_exceptions,
PAYMENTS_SHEET_ID, credentials_path, PAYMENTS_SHEET_ID, credentials_path,
@@ -241,7 +242,7 @@ def reconcile_view():
payments_url = f"https://docs.google.com/spreadsheets/d/{PAYMENTS_SHEET_ID}/edit" payments_url = f"https://docs.google.com/spreadsheets/d/{PAYMENTS_SHEET_ID}/edit"
# Use hardcoded credentials path for now, consistent with other scripts # Use hardcoded credentials path for now, consistent with other scripts
credentials_path = ".secret/fuj-management-bot-credentials.json" credentials_path = CREDENTIALS_PATH
members_data = get_cached_data("attendance_regular", ATTENDANCE_SHEET_ID, get_members_with_fees) members_data = get_cached_data("attendance_regular", ATTENDANCE_SHEET_ID, get_members_with_fees)
record_step("fetch_members") record_step("fetch_members")
@@ -339,7 +340,7 @@ def reconcile_juniors_view():
attendance_url = f"https://docs.google.com/spreadsheets/d/{ATTENDANCE_SHEET_ID}/edit#gid={JUNIOR_SHEET_GID}" attendance_url = f"https://docs.google.com/spreadsheets/d/{ATTENDANCE_SHEET_ID}/edit#gid={JUNIOR_SHEET_GID}"
payments_url = f"https://docs.google.com/spreadsheets/d/{PAYMENTS_SHEET_ID}/edit" payments_url = f"https://docs.google.com/spreadsheets/d/{PAYMENTS_SHEET_ID}/edit"
credentials_path = ".secret/fuj-management-bot-credentials.json" credentials_path = CREDENTIALS_PATH
junior_members_data = get_cached_data("attendance_juniors", ATTENDANCE_SHEET_ID, get_junior_members_with_fees) junior_members_data = get_cached_data("attendance_juniors", ATTENDANCE_SHEET_ID, get_junior_members_with_fees)
record_step("fetch_junior_members") record_step("fetch_junior_members")
@@ -429,7 +430,6 @@ def reconcile_juniors_view():
# Format credits and debts # Format credits and debts
credits = sorted([{"name": n, "amount": a["total_balance"]} for n, a in result["members"].items() if a["total_balance"] > 0], key=lambda x: x["name"]) credits = sorted([{"name": n, "amount": a["total_balance"]} for n, a in result["members"].items() if a["total_balance"] > 0], key=lambda x: x["name"])
debts = sorted([{"name": n, "amount": abs(a["total_balance"])} for n, a in result["members"].items() if a["total_balance"] < 0], key=lambda x: x["name"]) debts = sorted([{"name": n, "amount": abs(a["total_balance"])} for n, a in result["members"].items() if a["total_balance"] < 0], key=lambda x: x["name"])
unmatched = result["unmatched"]
import json import json
record_step("process_data") record_step("process_data")
@@ -443,7 +443,7 @@ def reconcile_juniors_view():
month_labels_json=json.dumps(month_labels), month_labels_json=json.dumps(month_labels),
credits=credits, credits=credits,
debts=debts, debts=debts,
unmatched=unmatched, unmatched=[],
attendance_url=attendance_url, attendance_url=attendance_url,
payments_url=payments_url, payments_url=payments_url,
bank_account=BANK_ACCOUNT bank_account=BANK_ACCOUNT
@@ -453,7 +453,7 @@ def reconcile_juniors_view():
def payments(): def payments():
attendance_url = f"https://docs.google.com/spreadsheets/d/{ATTENDANCE_SHEET_ID}/edit" attendance_url = f"https://docs.google.com/spreadsheets/d/{ATTENDANCE_SHEET_ID}/edit"
payments_url = f"https://docs.google.com/spreadsheets/d/{PAYMENTS_SHEET_ID}/edit" payments_url = f"https://docs.google.com/spreadsheets/d/{PAYMENTS_SHEET_ID}/edit"
credentials_path = ".secret/fuj-management-bot-credentials.json" credentials_path = CREDENTIALS_PATH
transactions = get_cached_data("payments_transactions", PAYMENTS_SHEET_ID, fetch_sheet_data, PAYMENTS_SHEET_ID, credentials_path) transactions = get_cached_data("payments_transactions", PAYMENTS_SHEET_ID, fetch_sheet_data, PAYMENTS_SHEET_ID, credentials_path)
record_step("fetch_payments") record_step("fetch_payments")
@@ -495,6 +495,10 @@ def qr_code():
amount = request.args.get("amount", "0") amount = request.args.get("amount", "0")
message = request.args.get("message", "") message = request.args.get("message", "")
# Validate account: allow IBAN (letters+digits) or Czech format (digits/digits)
if not re.match(r'^[A-Z]{2}\d{2,34}$|^\d{1,16}/\d{4}$', account):
account = BANK_ACCOUNT
# QR Platba standard: SPD*1.0*ACC:accountNumber*BC:bankCode*AM:amount*CC:CZK*MSG:message # QR Platba standard: SPD*1.0*ACC:accountNumber*BC:bankCode*AM:amount*CC:CZK*MSG:message
acc_parts = account.split('/') acc_parts = account.split('/')
if len(acc_parts) == 2: if len(acc_parts) == 2:
@@ -504,12 +508,14 @@ def qr_code():
try: try:
amt_val = float(amount) amt_val = float(amount)
if amt_val < 0 or amt_val > 10_000_000:
amt_val = 0
amt_str = f"{amt_val:.2f}" amt_str = f"{amt_val:.2f}"
except ValueError: except ValueError:
amt_str = "0.00" amt_str = "0.00"
# Message max 60 characters # Message max 60 characters, strip SPD delimiters to prevent injection
msg_str = message[:60] msg_str = message[:60].replace("*", "")
qr_data = f"SPD*1.0*ACC:{acc_str}*AM:{amt_str}*CC:CZK*MSG:{msg_str}" qr_data = f"SPD*1.0*ACC:{acc_str}*AM:{amt_str}*CC:CZK*MSG:{msg_str}"

View File

@@ -14,7 +14,8 @@ RUN pip install --no-cache-dir \
google-auth-httplib2 \ google-auth-httplib2 \
google-auth-oauthlib \ google-auth-oauthlib \
qrcode \ qrcode \
pillow pillow \
gunicorn
COPY app.py Makefile ./ COPY app.py Makefile ./
COPY scripts/ ./scripts/ COPY scripts/ ./scripts/

View File

@@ -1,8 +1,11 @@
#!/bin/bash #!/bin/bash
set -euo pipefail set -euo pipefail
echo "[entrypoint] Starting Flask app on port 5001..." echo "[entrypoint] Starting gunicorn on port 5001..."
# Running the app directly via python exec gunicorn \
# For a production setup, we would ideally use gunicorn/waitress, but sticking to what's in app.py for now. --bind 0.0.0.0:5001 \
exec python3 /app/app.py --workers "${GUNICORN_WORKERS:-2}" \
--timeout "${GUNICORN_TIMEOUT:-120}" \
--access-logfile - \
app:app

View File

@@ -8,8 +8,15 @@ dependencies = [
"google-auth-httplib2>=0.2.0", "google-auth-httplib2>=0.2.0",
"google-auth-oauthlib>=1.2.1", "google-auth-oauthlib>=1.2.1",
"qrcode[pil]>=8.0", "qrcode[pil]>=8.0",
"gunicorn>=23.0",
] ]
requires-python = ">=3.13" requires-python = ">=3.13"
[dependency-groups]
dev = [
"pytest>=8.0",
"pytest-cov>=6.0",
]
[tool.uv] [tool.uv]
package = false package = false

View File

@@ -5,8 +5,8 @@ import io
import urllib.request import urllib.request
from datetime import datetime from datetime import datetime
SHEET_ID = "1E2e_gT_K5AwSRCDLDTa2UetZTkHmBOcz0kFbBUNUNBA" from config import ATTENDANCE_SHEET_ID as SHEET_ID, JUNIOR_SHEET_GID
JUNIOR_SHEET_GID = "1213318614"
EXPORT_URL = f"https://docs.google.com/spreadsheets/d/{SHEET_ID}/export?format=csv&gid=0" EXPORT_URL = f"https://docs.google.com/spreadsheets/d/{SHEET_ID}/export?format=csv&gid=0"
JUNIOR_EXPORT_URL = f"https://docs.google.com/spreadsheets/d/{SHEET_ID}/export?format=csv&gid={JUNIOR_SHEET_GID}" JUNIOR_EXPORT_URL = f"https://docs.google.com/spreadsheets/d/{SHEET_ID}/export?format=csv&gid={JUNIOR_SHEET_GID}"
@@ -34,13 +34,8 @@ FIRST_DATE_COL = 3
def fetch_csv(url: str = EXPORT_URL) -> list[list[str]]: def fetch_csv(url: str = EXPORT_URL) -> list[list[str]]:
"""Fetch the attendance Google Sheet as parsed CSV rows.""" """Fetch the attendance Google Sheet as parsed CSV rows."""
import ssl
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
req = urllib.request.Request(url) req = urllib.request.Request(url)
with urllib.request.urlopen(req, context=ctx) as resp: with urllib.request.urlopen(req) as resp:
text = resp.read().decode("utf-8") text = resp.read().decode("utf-8")
reader = csv.reader(io.StringIO(text)) reader = csv.reader(io.StringIO(text))
return list(reader) return list(reader)

View File

@@ -1,29 +1,17 @@
import json import json
import os
import socket import socket
import logging import logging
from datetime import datetime from datetime import datetime
from pathlib import Path
from google.oauth2 import service_account from google.oauth2 import service_account
from googleapiclient.discovery import build from googleapiclient.discovery import build
from config import (
CACHE_DIR, CREDENTIALS_PATH as CREDS_PATH, DRIVE_TIMEOUT,
CACHE_TTL_SECONDS, CACHE_API_CHECK_TTL_SECONDS, CACHE_SHEET_MAP,
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Constants
CACHE_DIR = Path(__file__).parent.parent / "tmp"
CREDS_PATH = Path(__file__).parent.parent / ".secret" / "fuj-management-bot-credentials.json"
DRIVE_TIMEOUT = 10 # seconds
CACHE_TTL_SECONDS = int(os.environ.get("CACHE_TTL_SECONDS", 300)) # 30 min default for max cache age
CACHE_API_CHECK_TTL_SECONDS = int(os.environ.get("CACHE_API_CHECK_TTL_SECONDS", 300)) # 5 min default
# Known mappings mapping "cache name" to Google Sheet ID
CACHE_SHEET_MAP = {
"attendance_regular": "1E2e_gT_K5AwSRCDLDTa2UetZTkHmBOcz0kFbBUNUNBA",
"attendance_juniors": "1E2e_gT_K5AwSRCDLDTa2UetZTkHmBOcz0kFbBUNUNBA",
"exceptions_dict": "1Om0YPoDVCH5cV8BrNz5LG5eR5MMU05ypQC7UMN1xn_Y",
"payments_transactions": "1Om0YPoDVCH5cV8BrNz5LG5eR5MMU05ypQC7UMN1xn_Y"
}
# Global state to track last Drive API check time per sheet # Global state to track last Drive API check time per sheet
_LAST_CHECKED = {} _LAST_CHECKED = {}
_DRIVE_SERVICE = None _DRIVE_SERVICE = None
@@ -87,7 +75,7 @@ def get_sheet_modified_time(cache_key: str) -> str | None:
# 2. Check if the cache file is simply too new (legacy check) # 2. Check if the cache file is simply too new (legacy check)
if CACHE_TTL_SECONDS > 0 and cache_file.exists(): if CACHE_TTL_SECONDS > 0 and cache_file.exists():
try: try:
file_mtime = os.path.getmtime(cache_file) file_mtime = cache_file.stat().st_mtime
if time.time() - file_mtime < CACHE_TTL_SECONDS: if time.time() - file_mtime < CACHE_TTL_SECONDS:
with open(cache_file, "r", encoding="utf-8") as f: with open(cache_file, "r", encoding="utf-8") as f:
cache_data = json.load(f) cache_data = json.load(f)

39
scripts/config.py Normal file
View File

@@ -0,0 +1,39 @@
"""Centralized configuration for FUJ management scripts.
External service IDs, credentials, and tunable parameters.
Domain-specific constants (fees, column indices) stay in their respective modules.
"""
import os
from pathlib import Path
# Paths
PROJECT_ROOT = Path(__file__).parent.parent
CREDENTIALS_PATH = Path(os.environ.get(
"CREDENTIALS_PATH",
str(PROJECT_ROOT / ".secret" / "fuj-management-bot-credentials.json"),
))
# Google Sheets IDs
ATTENDANCE_SHEET_ID = "1E2e_gT_K5AwSRCDLDTa2UetZTkHmBOcz0kFbBUNUNBA"
PAYMENTS_SHEET_ID = "1Om0YPoDVCH5cV8BrNz5LG5eR5MMU05ypQC7UMN1xn_Y"
# Attendance sheet tab GIDs
JUNIOR_SHEET_GID = "1213318614"
# Bank
BANK_ACCOUNT = os.environ.get("BANK_ACCOUNT", "CZ8520100000002800359168")
# Cache settings
CACHE_DIR = PROJECT_ROOT / "tmp"
DRIVE_TIMEOUT = 10 # seconds
CACHE_TTL_SECONDS = int(os.environ.get("CACHE_TTL_SECONDS", 300)) # 5 min default
CACHE_API_CHECK_TTL_SECONDS = int(os.environ.get("CACHE_API_CHECK_TTL_SECONDS", 300)) # 5 min default
# Maps cache keys to their source sheet IDs (used by cache_utils)
CACHE_SHEET_MAP = {
"attendance_regular": ATTENDANCE_SHEET_ID,
"attendance_juniors": ATTENDANCE_SHEET_ID,
"exceptions_dict": PAYMENTS_SHEET_ID,
"payments_transactions": PAYMENTS_SHEET_ID,
}

View File

@@ -102,7 +102,7 @@ def infer_payments(spreadsheet_id: str, credentials_path: str, dry_run: bool = F
member_names = [m[0] for m in members_data] member_names = [m[0] for m in members_data]
# 3. Process rows # 3. Process rows
print("Inffering details for empty rows...") print("Inferring details for empty rows...")
updates = [] updates = []
for i, row in enumerate(rows[1:], start=2): for i, row in enumerate(rows[1:], start=2):

View File

@@ -3,12 +3,15 @@
import argparse import argparse
import json import json
import logging
import os import os
import re import re
import urllib.request import urllib.request
from datetime import datetime, timedelta from datetime import datetime, timedelta
from html.parser import HTMLParser from html.parser import HTMLParser
logger = logging.getLogger(__name__)
from attendance import get_members_with_fees from attendance import get_members_with_fees
from czech_utils import normalize, parse_month_references from czech_utils import normalize, parse_month_references
from sync_fio_to_sheets import get_sheets_service, DEFAULT_SPREADSHEET_ID from sync_fio_to_sheets import get_sheets_service, DEFAULT_SPREADSHEET_ID
@@ -212,6 +215,11 @@ def fetch_sheet_data(spreadsheet_id: str, credentials_path: str) -> list[dict]:
idx_message = get_col_index("Message") idx_message = get_col_index("Message")
idx_bank_id = get_col_index("Bank ID") idx_bank_id = get_col_index("Bank ID")
required = {"Date": idx_date, "Amount": idx_amount, "Person": idx_person, "Purpose": idx_purpose}
missing = [name for name, idx in required.items() if idx == -1]
if missing:
raise ValueError(f"Required columns missing from payments sheet: {', '.join(missing)}. Found headers: {header}")
transactions = [] transactions = []
for row in rows[1:]: for row in rows[1:]:
def get_val(idx): def get_val(idx):
@@ -381,12 +389,13 @@ def reconcile(
per_allocation = amount / num_allocations if num_allocations > 0 else 0 per_allocation = amount / num_allocations if num_allocations > 0 else 0
for member_name, confidence in matched_members: for member_name, confidence in matched_members:
# If we matched via sheet 'Person' column, name might be partial or have markers
# but usually it's the exact member name from get_members_with_fees.
# Let's ensure it exists in our ledger.
if member_name not in ledger: if member_name not in ledger:
# Try matching by base name if it was Jan Novak (Kačerr) etc. logger.warning(
pass "Payment matched to unknown member %r (tx: %s, %s) — adding to unmatched",
member_name, tx.get("date", "?"), tx.get("message", "?"),
)
unmatched.append(tx)
continue
for month_key in matched_months: for month_key in matched_months:
entry = { entry = {
@@ -396,7 +405,7 @@ def reconcile(
"message": tx["message"], "message": tx["message"],
"confidence": confidence, "confidence": confidence,
} }
if month_key in ledger.get(member_name, {}): if month_key in ledger[member_name]:
ledger[member_name][month_key]["paid"] += per_allocation ledger[member_name][month_key]["paid"] += per_allocation
ledger[member_name][month_key]["transactions"].append(entry) ledger[member_name][month_key]["transactions"].append(entry)
else: else:

View File

@@ -14,8 +14,7 @@ from googleapiclient.discovery import build
from fio_utils import fetch_transactions from fio_utils import fetch_transactions
# Configuration from config import PAYMENTS_SHEET_ID as DEFAULT_SPREADSHEET_ID
DEFAULT_SPREADSHEET_ID = "1Om0YPoDVCH5cV8BrNz5LG5eR5MMU05ypQC7UMN1xn_Y"
SCOPES = ["https://www.googleapis.com/auth/spreadsheets"] SCOPES = ["https://www.googleapis.com/auth/spreadsheets"]
TOKEN_FILE = "token.pickle" TOKEN_FILE = "token.pickle"
COLUMN_LABELS = ["Date", "Amount", "manual fix", "Person", "Purpose", "Inferred Amount", "Sender", "VS", "Message", "Bank ID", "Sync ID"] COLUMN_LABELS = ["Date", "Amount", "manual fix", "Person", "Purpose", "Inferred Amount", "Sender", "VS", "Message", "Bank ID", "Sync ID"]

View File

@@ -1,24 +1,29 @@
import unittest import unittest
from unittest.mock import patch, MagicMock from unittest.mock import patch
from app import app from app import app
def _bypass_cache(cache_key, sheet_id, fetch_func, *args, serialize=None, deserialize=None, **kwargs):
"""Test helper: call fetch_func directly, bypassing the cache layer."""
return fetch_func(*args, **kwargs)
class TestWebApp(unittest.TestCase): class TestWebApp(unittest.TestCase):
def setUp(self): def setUp(self):
# Configure app for testing
app.config['TESTING'] = True app.config['TESTING'] = True
self.client = app.test_client() self.client = app.test_client()
@patch('app.get_members_with_fees') def test_index_page(self):
def test_index_page(self, mock_get_members):
"""Test that / returns the refresh meta tag""" """Test that / returns the refresh meta tag"""
response = self.client.get('/') response = self.client.get('/')
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertIn(b'url=/fees', response.data) self.assertIn(b'url=/fees', response.data)
@patch('app.get_cached_data', side_effect=_bypass_cache)
@patch('app.get_members_with_fees') @patch('app.get_members_with_fees')
def test_fees_route(self, mock_get_members): @patch('app.fetch_exceptions', return_value={})
def test_fees_route(self, mock_exceptions, mock_get_members, mock_cache):
"""Test that /fees returns 200 and renders the dashboard""" """Test that /fees returns 200 and renders the dashboard"""
# Mock attendance data
mock_get_members.return_value = ( mock_get_members.return_value = (
[('Test Member', 'A', {'2026-01': (750, 4)})], [('Test Member', 'A', {'2026-01': (750, 4)})],
['2026-01'] ['2026-01']
@@ -29,10 +34,11 @@ class TestWebApp(unittest.TestCase):
self.assertIn(b'FUJ Fees Dashboard', response.data) self.assertIn(b'FUJ Fees Dashboard', response.data)
self.assertIn(b'Test Member', response.data) self.assertIn(b'Test Member', response.data)
@patch('app.get_cached_data', side_effect=_bypass_cache)
@patch('app.get_junior_members_with_fees') @patch('app.get_junior_members_with_fees')
def test_fees_juniors_route(self, mock_get_junior_members): @patch('app.fetch_exceptions', return_value={})
def test_fees_juniors_route(self, mock_exceptions, mock_get_junior_members, mock_cache):
"""Test that /fees-juniors returns 200 and renders the junior dashboard""" """Test that /fees-juniors returns 200 and renders the junior dashboard"""
# Mock attendance data: one with string symbol '?', one with integer
mock_get_junior_members.return_value = ( mock_get_junior_members.return_value = (
[ [
('Test Junior 1', 'J', {'2026-01': ('?', 1, 0, 1)}), ('Test Junior 1', 'J', {'2026-01': ('?', 1, 0, 1)}),
@@ -48,16 +54,16 @@ class TestWebApp(unittest.TestCase):
self.assertIn(b'? / 1 (J)', response.data) self.assertIn(b'? / 1 (J)', response.data)
self.assertIn(b'500 CZK / 4 (1A+3J)', response.data) self.assertIn(b'500 CZK / 4 (1A+3J)', response.data)
@patch('app.get_cached_data', side_effect=_bypass_cache)
@patch('app.fetch_sheet_data') @patch('app.fetch_sheet_data')
@patch('app.fetch_exceptions', return_value={})
@patch('app.get_members_with_fees') @patch('app.get_members_with_fees')
def test_reconcile_route(self, mock_get_members, mock_fetch_sheet): def test_reconcile_route(self, mock_get_members, mock_exceptions, mock_fetch_sheet, mock_cache):
"""Test that /reconcile returns 200 and shows matches""" """Test that /reconcile returns 200 and shows matches"""
# Mock attendance data
mock_get_members.return_value = ( mock_get_members.return_value = (
[('Test Member', 'A', {'2026-01': (750, 4)})], [('Test Member', 'A', {'2026-01': (750, 4)})],
['2026-01'] ['2026-01']
) )
# Mock sheet data - include all keys required by reconcile
mock_fetch_sheet.return_value = [{ mock_fetch_sheet.return_value = [{
'date': '2026-01-01', 'date': '2026-01-01',
'amount': 750, 'amount': 750,
@@ -74,10 +80,10 @@ class TestWebApp(unittest.TestCase):
self.assertIn(b'Test Member', response.data) self.assertIn(b'Test Member', response.data)
self.assertIn(b'OK', response.data) self.assertIn(b'OK', response.data)
@patch('app.get_cached_data', side_effect=_bypass_cache)
@patch('app.fetch_sheet_data') @patch('app.fetch_sheet_data')
def test_payments_route(self, mock_fetch_sheet): def test_payments_route(self, mock_fetch_sheet, mock_cache):
"""Test that /payments returns 200 and groups transactions""" """Test that /payments returns 200 and groups transactions"""
# Mock sheet data
mock_fetch_sheet.return_value = [{ mock_fetch_sheet.return_value = [{
'date': '2026-01-01', 'date': '2026-01-01',
'amount': 750, 'amount': 750,
@@ -92,10 +98,11 @@ class TestWebApp(unittest.TestCase):
self.assertIn(b'Test Member', response.data) self.assertIn(b'Test Member', response.data)
self.assertIn(b'Direct Member Payment', response.data) self.assertIn(b'Direct Member Payment', response.data)
@patch('app.get_cached_data', side_effect=_bypass_cache)
@patch('app.fetch_sheet_data') @patch('app.fetch_sheet_data')
@patch('app.fetch_exceptions') @patch('app.fetch_exceptions')
@patch('app.get_junior_members_with_fees') @patch('app.get_junior_members_with_fees')
def test_reconcile_juniors_route(self, mock_get_junior, mock_exceptions, mock_transactions): def test_reconcile_juniors_route(self, mock_get_junior, mock_exceptions, mock_transactions, mock_cache):
"""Test that /reconcile-juniors correctly computes balances for juniors.""" """Test that /reconcile-juniors correctly computes balances for juniors."""
mock_get_junior.return_value = ( mock_get_junior.return_value = (
[ [

161
uv.lock generated
View File

@@ -127,6 +127,75 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
] ]
[[package]]
name = "coverage"
version = "7.13.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/24/56/95b7e30fa389756cb56630faa728da46a27b8c6eb46f9d557c68fff12b65/coverage-7.13.4.tar.gz", hash = "sha256:e5c8f6ed1e61a8b2dcdf31eb0b9bbf0130750ca79c1c49eb898e2ad86f5ccc91", size = 827239, upload-time = "2026-02-09T12:59:03.86Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/db/23/aad45061a31677d68e47499197a131eea55da4875d16c1f42021ab963503/coverage-7.13.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b66a2da594b6068b48b2692f043f35d4d3693fb639d5ea8b39533c2ad9ac3ab9", size = 219474, upload-time = "2026-02-09T12:57:19.332Z" },
{ url = "https://files.pythonhosted.org/packages/a5/70/9b8b67a0945f3dfec1fd896c5cefb7c19d5a3a6d74630b99a895170999ae/coverage-7.13.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3599eb3992d814d23b35c536c28df1a882caa950f8f507cef23d1cbf334995ac", size = 219844, upload-time = "2026-02-09T12:57:20.66Z" },
{ url = "https://files.pythonhosted.org/packages/97/fd/7e859f8fab324cef6c4ad7cff156ca7c489fef9179d5749b0c8d321281c2/coverage-7.13.4-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:93550784d9281e374fb5a12bf1324cc8a963fd63b2d2f223503ef0fd4aa339ea", size = 250832, upload-time = "2026-02-09T12:57:22.007Z" },
{ url = "https://files.pythonhosted.org/packages/e4/dc/b2442d10020c2f52617828862d8b6ee337859cd8f3a1f13d607dddda9cf7/coverage-7.13.4-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b720ce6a88a2755f7c697c23268ddc47a571b88052e6b155224347389fdf6a3b", size = 253434, upload-time = "2026-02-09T12:57:23.339Z" },
{ url = "https://files.pythonhosted.org/packages/5a/88/6728a7ad17428b18d836540630487231f5470fb82454871149502f5e5aa2/coverage-7.13.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7b322db1284a2ed3aa28ffd8ebe3db91c929b7a333c0820abec3d838ef5b3525", size = 254676, upload-time = "2026-02-09T12:57:24.774Z" },
{ url = "https://files.pythonhosted.org/packages/7c/bc/21244b1b8cedf0dff0a2b53b208015fe798d5f2a8d5348dbfece04224fff/coverage-7.13.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f4594c67d8a7c89cf922d9df0438c7c7bb022ad506eddb0fdb2863359ff78242", size = 256807, upload-time = "2026-02-09T12:57:26.125Z" },
{ url = "https://files.pythonhosted.org/packages/97/a0/ddba7ed3251cff51006737a727d84e05b61517d1784a9988a846ba508877/coverage-7.13.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:53d133df809c743eb8bce33b24bcababb371f4441340578cd406e084d94a6148", size = 251058, upload-time = "2026-02-09T12:57:27.614Z" },
{ url = "https://files.pythonhosted.org/packages/9b/55/e289addf7ff54d3a540526f33751951bf0878f3809b47f6dfb3def69c6f7/coverage-7.13.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76451d1978b95ba6507a039090ba076105c87cc76fc3efd5d35d72093964d49a", size = 252805, upload-time = "2026-02-09T12:57:29.066Z" },
{ url = "https://files.pythonhosted.org/packages/13/4e/cc276b1fa4a59be56d96f1dabddbdc30f4ba22e3b1cd42504c37b3313255/coverage-7.13.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7f57b33491e281e962021de110b451ab8a24182589be17e12a22c79047935e23", size = 250766, upload-time = "2026-02-09T12:57:30.522Z" },
{ url = "https://files.pythonhosted.org/packages/94/44/1093b8f93018f8b41a8cf29636c9292502f05e4a113d4d107d14a3acd044/coverage-7.13.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1731dc33dc276dafc410a885cbf5992f1ff171393e48a21453b78727d090de80", size = 254923, upload-time = "2026-02-09T12:57:31.946Z" },
{ url = "https://files.pythonhosted.org/packages/8b/55/ea2796da2d42257f37dbea1aab239ba9263b31bd91d5527cdd6db5efe174/coverage-7.13.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:bd60d4fe2f6fa7dff9223ca1bbc9f05d2b6697bc5961072e5d3b952d46e1b1ea", size = 250591, upload-time = "2026-02-09T12:57:33.842Z" },
{ url = "https://files.pythonhosted.org/packages/d4/fa/7c4bb72aacf8af5020675aa633e59c1fbe296d22aed191b6a5b711eb2bc7/coverage-7.13.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9181a3ccead280b828fae232df12b16652702b49d41e99d657f46cc7b1f6ec7a", size = 252364, upload-time = "2026-02-09T12:57:35.743Z" },
{ url = "https://files.pythonhosted.org/packages/5c/38/a8d2ec0146479c20bbaa7181b5b455a0c41101eed57f10dd19a78ab44c80/coverage-7.13.4-cp313-cp313-win32.whl", hash = "sha256:f53d492307962561ac7de4cd1de3e363589b000ab69617c6156a16ba7237998d", size = 222010, upload-time = "2026-02-09T12:57:37.25Z" },
{ url = "https://files.pythonhosted.org/packages/e2/0c/dbfafbe90a185943dcfbc766fe0e1909f658811492d79b741523a414a6cc/coverage-7.13.4-cp313-cp313-win_amd64.whl", hash = "sha256:e6f70dec1cc557e52df5306d051ef56003f74d56e9c4dd7ddb07e07ef32a84dd", size = 222818, upload-time = "2026-02-09T12:57:38.734Z" },
{ url = "https://files.pythonhosted.org/packages/04/d1/934918a138c932c90d78301f45f677fb05c39a3112b96fd2c8e60503cdc7/coverage-7.13.4-cp313-cp313-win_arm64.whl", hash = "sha256:fb07dc5da7e849e2ad31a5d74e9bece81f30ecf5a42909d0a695f8bd1874d6af", size = 221438, upload-time = "2026-02-09T12:57:40.223Z" },
{ url = "https://files.pythonhosted.org/packages/52/57/ee93ced533bcb3e6df961c0c6e42da2fc6addae53fb95b94a89b1e33ebd7/coverage-7.13.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:40d74da8e6c4b9ac18b15331c4b5ebc35a17069410cad462ad4f40dcd2d50c0d", size = 220165, upload-time = "2026-02-09T12:57:41.639Z" },
{ url = "https://files.pythonhosted.org/packages/c5/e0/969fc285a6fbdda49d91af278488d904dcd7651b2693872f0ff94e40e84a/coverage-7.13.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4223b4230a376138939a9173f1bdd6521994f2aff8047fae100d6d94d50c5a12", size = 220516, upload-time = "2026-02-09T12:57:44.215Z" },
{ url = "https://files.pythonhosted.org/packages/b1/b8/9531944e16267e2735a30a9641ff49671f07e8138ecf1ca13db9fd2560c7/coverage-7.13.4-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1d4be36a5114c499f9f1f9195e95ebf979460dbe2d88e6816ea202010ba1c34b", size = 261804, upload-time = "2026-02-09T12:57:45.989Z" },
{ url = "https://files.pythonhosted.org/packages/8a/f3/e63df6d500314a2a60390d1989240d5f27318a7a68fa30ad3806e2a9323e/coverage-7.13.4-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:200dea7d1e8095cc6e98cdabe3fd1d21ab17d3cee6dab00cadbb2fe35d9c15b9", size = 263885, upload-time = "2026-02-09T12:57:47.42Z" },
{ url = "https://files.pythonhosted.org/packages/f3/67/7654810de580e14b37670b60a09c599fa348e48312db5b216d730857ffe6/coverage-7.13.4-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8eb931ee8e6d8243e253e5ed7336deea6904369d2fd8ae6e43f68abbf167092", size = 266308, upload-time = "2026-02-09T12:57:49.345Z" },
{ url = "https://files.pythonhosted.org/packages/37/6f/39d41eca0eab3cc82115953ad41c4e77935286c930e8fad15eaed1389d83/coverage-7.13.4-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:75eab1ebe4f2f64d9509b984f9314d4aa788540368218b858dad56dc8f3e5eb9", size = 267452, upload-time = "2026-02-09T12:57:50.811Z" },
{ url = "https://files.pythonhosted.org/packages/50/6d/39c0fbb8fc5cd4d2090811e553c2108cf5112e882f82505ee7495349a6bf/coverage-7.13.4-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c35eb28c1d085eb7d8c9b3296567a1bebe03ce72962e932431b9a61f28facf26", size = 261057, upload-time = "2026-02-09T12:57:52.447Z" },
{ url = "https://files.pythonhosted.org/packages/a4/a2/60010c669df5fa603bb5a97fb75407e191a846510da70ac657eb696b7fce/coverage-7.13.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:eb88b316ec33760714a4720feb2816a3a59180fd58c1985012054fa7aebee4c2", size = 263875, upload-time = "2026-02-09T12:57:53.938Z" },
{ url = "https://files.pythonhosted.org/packages/3e/d9/63b22a6bdbd17f1f96e9ed58604c2a6b0e72a9133e37d663bef185877cf6/coverage-7.13.4-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7d41eead3cc673cbd38a4417deb7fd0b4ca26954ff7dc6078e33f6ff97bed940", size = 261500, upload-time = "2026-02-09T12:57:56.012Z" },
{ url = "https://files.pythonhosted.org/packages/70/bf/69f86ba1ad85bc3ad240e4c0e57a2e620fbc0e1645a47b5c62f0e941ad7f/coverage-7.13.4-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:fb26a934946a6afe0e326aebe0730cdff393a8bc0bbb65a2f41e30feddca399c", size = 265212, upload-time = "2026-02-09T12:57:57.5Z" },
{ url = "https://files.pythonhosted.org/packages/ae/f2/5f65a278a8c2148731831574c73e42f57204243d33bedaaf18fa79c5958f/coverage-7.13.4-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:dae88bc0fc77edaa65c14be099bd57ee140cf507e6bfdeea7938457ab387efb0", size = 260398, upload-time = "2026-02-09T12:57:59.027Z" },
{ url = "https://files.pythonhosted.org/packages/ef/80/6e8280a350ee9fea92f14b8357448a242dcaa243cb2c72ab0ca591f66c8c/coverage-7.13.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:845f352911777a8e722bfce168958214951e07e47e5d5d9744109fa5fe77f79b", size = 262584, upload-time = "2026-02-09T12:58:01.129Z" },
{ url = "https://files.pythonhosted.org/packages/22/63/01ff182fc95f260b539590fb12c11ad3e21332c15f9799cb5e2386f71d9f/coverage-7.13.4-cp313-cp313t-win32.whl", hash = "sha256:2fa8d5f8de70688a28240de9e139fa16b153cc3cbb01c5f16d88d6505ebdadf9", size = 222688, upload-time = "2026-02-09T12:58:02.736Z" },
{ url = "https://files.pythonhosted.org/packages/a9/43/89de4ef5d3cd53b886afa114065f7e9d3707bdb3e5efae13535b46ae483d/coverage-7.13.4-cp313-cp313t-win_amd64.whl", hash = "sha256:9351229c8c8407645840edcc277f4a2d44814d1bc34a2128c11c2a031d45a5dd", size = 223746, upload-time = "2026-02-09T12:58:05.362Z" },
{ url = "https://files.pythonhosted.org/packages/35/39/7cf0aa9a10d470a5309b38b289b9bb07ddeac5d61af9b664fe9775a4cb3e/coverage-7.13.4-cp313-cp313t-win_arm64.whl", hash = "sha256:30b8d0512f2dc8c8747557e8fb459d6176a2c9e5731e2b74d311c03b78451997", size = 222003, upload-time = "2026-02-09T12:58:06.952Z" },
{ url = "https://files.pythonhosted.org/packages/92/11/a9cf762bb83386467737d32187756a42094927150c3e107df4cb078e8590/coverage-7.13.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:300deaee342f90696ed186e3a00c71b5b3d27bffe9e827677954f4ee56969601", size = 219522, upload-time = "2026-02-09T12:58:08.623Z" },
{ url = "https://files.pythonhosted.org/packages/d3/28/56e6d892b7b052236d67c95f1936b6a7cf7c3e2634bf27610b8cbd7f9c60/coverage-7.13.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:29e3220258d682b6226a9b0925bc563ed9a1ebcff3cad30f043eceea7eaf2689", size = 219855, upload-time = "2026-02-09T12:58:10.176Z" },
{ url = "https://files.pythonhosted.org/packages/e5/69/233459ee9eb0c0d10fcc2fe425a029b3fa5ce0f040c966ebce851d030c70/coverage-7.13.4-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:391ee8f19bef69210978363ca930f7328081c6a0152f1166c91f0b5fdd2a773c", size = 250887, upload-time = "2026-02-09T12:58:12.503Z" },
{ url = "https://files.pythonhosted.org/packages/06/90/2cdab0974b9b5bbc1623f7876b73603aecac11b8d95b85b5b86b32de5eab/coverage-7.13.4-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0dd7ab8278f0d58a0128ba2fca25824321f05d059c1441800e934ff2efa52129", size = 253396, upload-time = "2026-02-09T12:58:14.615Z" },
{ url = "https://files.pythonhosted.org/packages/ac/15/ea4da0f85bf7d7b27635039e649e99deb8173fe551096ea15017f7053537/coverage-7.13.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78cdf0d578b15148b009ccf18c686aa4f719d887e76e6b40c38ffb61d264a552", size = 254745, upload-time = "2026-02-09T12:58:16.162Z" },
{ url = "https://files.pythonhosted.org/packages/99/11/bb356e86920c655ca4d61daee4e2bbc7258f0a37de0be32d233b561134ff/coverage-7.13.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:48685fee12c2eb3b27c62f2658e7ea21e9c3239cba5a8a242801a0a3f6a8c62a", size = 257055, upload-time = "2026-02-09T12:58:17.892Z" },
{ url = "https://files.pythonhosted.org/packages/c9/0f/9ae1f8cb17029e09da06ca4e28c9e1d5c1c0a511c7074592e37e0836c915/coverage-7.13.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4e83efc079eb39480e6346a15a1bcb3e9b04759c5202d157e1dd4303cd619356", size = 250911, upload-time = "2026-02-09T12:58:19.495Z" },
{ url = "https://files.pythonhosted.org/packages/89/3a/adfb68558fa815cbc29747b553bc833d2150228f251b127f1ce97e48547c/coverage-7.13.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ecae9737b72408d6a950f7e525f30aca12d4bd8dd95e37342e5beb3a2a8c4f71", size = 252754, upload-time = "2026-02-09T12:58:21.064Z" },
{ url = "https://files.pythonhosted.org/packages/32/b1/540d0c27c4e748bd3cd0bd001076ee416eda993c2bae47a73b7cc9357931/coverage-7.13.4-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ae4578f8528569d3cf303fef2ea569c7f4c4059a38c8667ccef15c6e1f118aa5", size = 250720, upload-time = "2026-02-09T12:58:22.622Z" },
{ url = "https://files.pythonhosted.org/packages/c7/95/383609462b3ffb1fe133014a7c84fc0dd01ed55ac6140fa1093b5af7ebb1/coverage-7.13.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:6fdef321fdfbb30a197efa02d48fcd9981f0d8ad2ae8903ac318adc653f5df98", size = 254994, upload-time = "2026-02-09T12:58:24.548Z" },
{ url = "https://files.pythonhosted.org/packages/f7/ba/1761138e86c81680bfc3c49579d66312865457f9fe405b033184e5793cb3/coverage-7.13.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b0f6ccf3dbe577170bebfce1318707d0e8c3650003cb4b3a9dd744575daa8b5", size = 250531, upload-time = "2026-02-09T12:58:26.271Z" },
{ url = "https://files.pythonhosted.org/packages/f8/8e/05900df797a9c11837ab59c4d6fe94094e029582aab75c3309a93e6fb4e3/coverage-7.13.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75fcd519f2a5765db3f0e391eb3b7d150cce1a771bf4c9f861aeab86c767a3c0", size = 252189, upload-time = "2026-02-09T12:58:27.807Z" },
{ url = "https://files.pythonhosted.org/packages/00/bd/29c9f2db9ea4ed2738b8a9508c35626eb205d51af4ab7bf56a21a2e49926/coverage-7.13.4-cp314-cp314-win32.whl", hash = "sha256:8e798c266c378da2bd819b0677df41ab46d78065fb2a399558f3f6cae78b2fbb", size = 222258, upload-time = "2026-02-09T12:58:29.441Z" },
{ url = "https://files.pythonhosted.org/packages/a7/4d/1f8e723f6829977410efeb88f73673d794075091c8c7c18848d273dc9d73/coverage-7.13.4-cp314-cp314-win_amd64.whl", hash = "sha256:245e37f664d89861cf2329c9afa2c1fe9e6d4e1a09d872c947e70718aeeac505", size = 223073, upload-time = "2026-02-09T12:58:31.026Z" },
{ url = "https://files.pythonhosted.org/packages/51/5b/84100025be913b44e082ea32abcf1afbf4e872f5120b7a1cab1d331b1e13/coverage-7.13.4-cp314-cp314-win_arm64.whl", hash = "sha256:ad27098a189e5838900ce4c2a99f2fe42a0bf0c2093c17c69b45a71579e8d4a2", size = 221638, upload-time = "2026-02-09T12:58:32.599Z" },
{ url = "https://files.pythonhosted.org/packages/a7/e4/c884a405d6ead1370433dad1e3720216b4f9fd8ef5b64bfd984a2a60a11a/coverage-7.13.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:85480adfb35ffc32d40918aad81b89c69c9cc5661a9b8a81476d3e645321a056", size = 220246, upload-time = "2026-02-09T12:58:34.181Z" },
{ url = "https://files.pythonhosted.org/packages/81/5c/4d7ed8b23b233b0fffbc9dfec53c232be2e695468523242ea9fd30f97ad2/coverage-7.13.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:79be69cf7f3bf9b0deeeb062eab7ac7f36cd4cc4c4dd694bd28921ba4d8596cc", size = 220514, upload-time = "2026-02-09T12:58:35.704Z" },
{ url = "https://files.pythonhosted.org/packages/2f/6f/3284d4203fd2f28edd73034968398cd2d4cb04ab192abc8cff007ea35679/coverage-7.13.4-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:caa421e2684e382c5d8973ac55e4f36bed6821a9bad5c953494de960c74595c9", size = 261877, upload-time = "2026-02-09T12:58:37.864Z" },
{ url = "https://files.pythonhosted.org/packages/09/aa/b672a647bbe1556a85337dc95bfd40d146e9965ead9cc2fe81bde1e5cbce/coverage-7.13.4-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:14375934243ee05f56c45393fe2ce81fe5cc503c07cee2bdf1725fb8bef3ffaf", size = 264004, upload-time = "2026-02-09T12:58:39.492Z" },
{ url = "https://files.pythonhosted.org/packages/79/a1/aa384dbe9181f98bba87dd23dda436f0c6cf2e148aecbb4e50fc51c1a656/coverage-7.13.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25a41c3104d08edb094d9db0d905ca54d0cd41c928bb6be3c4c799a54753af55", size = 266408, upload-time = "2026-02-09T12:58:41.852Z" },
{ url = "https://files.pythonhosted.org/packages/53/5e/5150bf17b4019bc600799f376bb9606941e55bd5a775dc1e096b6ffea952/coverage-7.13.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f01afcff62bf9a08fb32b2c1d6e924236c0383c02c790732b6537269e466a72", size = 267544, upload-time = "2026-02-09T12:58:44.093Z" },
{ url = "https://files.pythonhosted.org/packages/e0/ed/f1de5c675987a4a7a672250d2c5c9d73d289dbf13410f00ed7181d8017dd/coverage-7.13.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eb9078108fbf0bcdde37c3f4779303673c2fa1fe8f7956e68d447d0dd426d38a", size = 260980, upload-time = "2026-02-09T12:58:45.721Z" },
{ url = "https://files.pythonhosted.org/packages/b3/e3/fe758d01850aa172419a6743fe76ba8b92c29d181d4f676ffe2dae2ba631/coverage-7.13.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e086334e8537ddd17e5f16a344777c1ab8194986ec533711cbe6c41cde841b6", size = 263871, upload-time = "2026-02-09T12:58:47.334Z" },
{ url = "https://files.pythonhosted.org/packages/b6/76/b829869d464115e22499541def9796b25312b8cf235d3bb00b39f1675395/coverage-7.13.4-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:725d985c5ab621268b2edb8e50dfe57633dc69bda071abc470fed55a14935fd3", size = 261472, upload-time = "2026-02-09T12:58:48.995Z" },
{ url = "https://files.pythonhosted.org/packages/14/9e/caedb1679e73e2f6ad240173f55218488bfe043e38da577c4ec977489915/coverage-7.13.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:3c06f0f1337c667b971ca2f975523347e63ec5e500b9aa5882d91931cd3ef750", size = 265210, upload-time = "2026-02-09T12:58:51.178Z" },
{ url = "https://files.pythonhosted.org/packages/3a/10/0dd02cb009b16ede425b49ec344aba13a6ae1dc39600840ea6abcb085ac4/coverage-7.13.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:590c0ed4bf8e85f745e6b805b2e1c457b2e33d5255dd9729743165253bc9ad39", size = 260319, upload-time = "2026-02-09T12:58:53.081Z" },
{ url = "https://files.pythonhosted.org/packages/92/8e/234d2c927af27c6d7a5ffad5bd2cf31634c46a477b4c7adfbfa66baf7ebb/coverage-7.13.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:eb30bf180de3f632cd043322dad5751390e5385108b2807368997d1a92a509d0", size = 262638, upload-time = "2026-02-09T12:58:55.258Z" },
{ url = "https://files.pythonhosted.org/packages/2f/64/e5547c8ff6964e5965c35a480855911b61509cce544f4d442caa759a0702/coverage-7.13.4-cp314-cp314t-win32.whl", hash = "sha256:c4240e7eded42d131a2d2c4dec70374b781b043ddc79a9de4d55ca71f8e98aea", size = 223040, upload-time = "2026-02-09T12:58:56.936Z" },
{ url = "https://files.pythonhosted.org/packages/c7/96/38086d58a181aac86d503dfa9c47eb20715a79c3e3acbdf786e92e5c09a8/coverage-7.13.4-cp314-cp314t-win_amd64.whl", hash = "sha256:4c7d3cc01e7350f2f0f6f7036caaf5673fb56b6998889ccfe9e1c1fe75a9c932", size = 224148, upload-time = "2026-02-09T12:58:58.645Z" },
{ url = "https://files.pythonhosted.org/packages/ce/72/8d10abd3740a0beb98c305e0c3faf454366221c0f37a8bcf8f60020bb65a/coverage-7.13.4-cp314-cp314t-win_arm64.whl", hash = "sha256:23e3f687cf945070d1c90f85db66d11e3025665d8dafa831301a0e0038f3db9b", size = 222172, upload-time = "2026-02-09T12:59:00.396Z" },
{ url = "https://files.pythonhosted.org/packages/0d/4a/331fe2caf6799d591109bb9c08083080f6de90a823695d412a935622abb2/coverage-7.13.4-py3-none-any.whl", hash = "sha256:1af1641e57cf7ba1bd67d677c9abdbcd6cc2ab7da3bca7fa1e2b7e50e65f2ad0", size = 211242, upload-time = "2026-02-09T12:59:02.032Z" },
]
[[package]] [[package]]
name = "cryptography" name = "cryptography"
version = "46.0.5" version = "46.0.5"
@@ -206,18 +275,32 @@ dependencies = [
{ name = "google-api-python-client" }, { name = "google-api-python-client" },
{ name = "google-auth-httplib2" }, { name = "google-auth-httplib2" },
{ name = "google-auth-oauthlib" }, { name = "google-auth-oauthlib" },
{ name = "gunicorn" },
{ name = "qrcode", extra = ["pil"] }, { name = "qrcode", extra = ["pil"] },
] ]
[package.dev-dependencies]
dev = [
{ name = "pytest" },
{ name = "pytest-cov" },
]
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "flask", specifier = ">=3.1.3" }, { name = "flask", specifier = ">=3.1.3" },
{ name = "google-api-python-client", specifier = ">=2.162.0" }, { name = "google-api-python-client", specifier = ">=2.162.0" },
{ name = "google-auth-httplib2", specifier = ">=0.2.0" }, { name = "google-auth-httplib2", specifier = ">=0.2.0" },
{ name = "google-auth-oauthlib", specifier = ">=1.2.1" }, { name = "google-auth-oauthlib", specifier = ">=1.2.1" },
{ name = "gunicorn", specifier = ">=23.0" },
{ name = "qrcode", extras = ["pil"], specifier = ">=8.0" }, { name = "qrcode", extras = ["pil"], specifier = ">=8.0" },
] ]
[package.metadata.requires-dev]
dev = [
{ name = "pytest", specifier = ">=8.0" },
{ name = "pytest-cov", specifier = ">=6.0" },
]
[[package]] [[package]]
name = "google-api-core" name = "google-api-core"
version = "2.30.0" version = "2.30.0"
@@ -302,6 +385,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c4/ab/09169d5a4612a5f92490806649ac8d41e3ec9129c636754575b3553f4ea4/googleapis_common_protos-1.72.0-py3-none-any.whl", hash = "sha256:4299c5a82d5ae1a9702ada957347726b167f9f8d1fc352477702a1e851ff4038", size = 297515, upload-time = "2025-11-06T18:29:13.14Z" }, { url = "https://files.pythonhosted.org/packages/c4/ab/09169d5a4612a5f92490806649ac8d41e3ec9129c636754575b3553f4ea4/googleapis_common_protos-1.72.0-py3-none-any.whl", hash = "sha256:4299c5a82d5ae1a9702ada957347726b167f9f8d1fc352477702a1e851ff4038", size = 297515, upload-time = "2025-11-06T18:29:13.14Z" },
] ]
[[package]]
name = "gunicorn"
version = "25.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "packaging" },
]
sdist = { url = "https://files.pythonhosted.org/packages/66/13/ef67f59f6a7896fdc2c1d62b5665c5219d6b0a9a1784938eb9a28e55e128/gunicorn-25.1.0.tar.gz", hash = "sha256:1426611d959fa77e7de89f8c0f32eed6aa03ee735f98c01efba3e281b1c47616", size = 594377, upload-time = "2026-02-13T11:09:58.989Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/da/73/4ad5b1f6a2e21cf1e85afdaad2b7b1a933985e2f5d679147a1953aaa192c/gunicorn-25.1.0-py3-none-any.whl", hash = "sha256:d0b1236ccf27f72cfe14bce7caadf467186f19e865094ca84221424e839b8b8b", size = 197067, upload-time = "2026-02-13T11:09:57.146Z" },
]
[[package]] [[package]]
name = "httplib2" name = "httplib2"
version = "0.31.2" version = "0.31.2"
@@ -323,6 +418,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
] ]
[[package]]
name = "iniconfig"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]] [[package]]
name = "itsdangerous" name = "itsdangerous"
version = "2.2.0" version = "2.2.0"
@@ -405,6 +509,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/be/9c/92789c596b8df838baa98fa71844d84283302f7604ed565dafe5a6b5041a/oauthlib-3.3.1-py3-none-any.whl", hash = "sha256:88119c938d2b8fb88561af5f6ee0eec8cc8d552b7bb1f712743136eb7523b7a1", size = 160065, upload-time = "2025-06-19T22:48:06.508Z" }, { url = "https://files.pythonhosted.org/packages/be/9c/92789c596b8df838baa98fa71844d84283302f7604ed565dafe5a6b5041a/oauthlib-3.3.1-py3-none-any.whl", hash = "sha256:88119c938d2b8fb88561af5f6ee0eec8cc8d552b7bb1f712743136eb7523b7a1", size = 160065, upload-time = "2025-06-19T22:48:06.508Z" },
] ]
[[package]]
name = "packaging"
version = "26.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
]
[[package]] [[package]]
name = "pillow" name = "pillow"
version = "12.1.1" version = "12.1.1"
@@ -463,6 +576,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/d2/de599c95ba0a973b94410477f8bf0b6f0b5e67360eb89bcb1ad365258beb/pillow-12.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:7b03048319bfc6170e93bd60728a1af51d3dd7704935feb228c4d4faab35d334", size = 2546446, upload-time = "2026-02-11T04:22:50.342Z" }, { url = "https://files.pythonhosted.org/packages/ec/d2/de599c95ba0a973b94410477f8bf0b6f0b5e67360eb89bcb1ad365258beb/pillow-12.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:7b03048319bfc6170e93bd60728a1af51d3dd7704935feb228c4d4faab35d334", size = 2546446, upload-time = "2026-02-11T04:22:50.342Z" },
] ]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]] [[package]]
name = "proto-plus" name = "proto-plus"
version = "1.27.1" version = "1.27.1"
@@ -520,6 +642,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
] ]
[[package]]
name = "pygments"
version = "2.19.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
]
[[package]] [[package]]
name = "pyparsing" name = "pyparsing"
version = "3.3.2" version = "3.3.2"
@@ -529,6 +660,36 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" }, { url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" },
] ]
[[package]]
name = "pytest"
version = "9.0.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
]
[[package]]
name = "pytest-cov"
version = "7.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "coverage" },
{ name = "pluggy" },
{ name = "pytest" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" },
]
[[package]] [[package]]
name = "qrcode" name = "qrcode"
version = "8.2" version = "8.2"