2 Commits
0.08 ... 0.10

Author SHA1 Message Date
Jan Novak
9ee2dd782d fix: add missing qrcode and pillow dependencies to Dockerfile and pyproject.toml
All checks were successful
Deploy to K8s / deploy (push) Successful in 10s
Build and Push / build (push) Successful in 30s
This fixes the 'ModuleNotFoundError: No module named qrcode' error in the container.
Updated pyproject.toml version to 0.10.

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

Co-authored-by: Antigravity <antigravity@google.com>
2026-03-02 22:54:48 +01:00
5 changed files with 193 additions and 21 deletions

View File

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

78
app.py
View File

@@ -3,7 +3,10 @@ from pathlib import Path
from datetime import datetime from datetime import datetime
import re import re
import time import time
from flask import Flask, render_template, g import os
import io
import qrcode
from flask import Flask, render_template, g, send_file, request
# Add scripts directory to path to allow importing from it # Add scripts directory to path to allow importing from it
scripts_dir = Path(__file__).parent / "scripts" scripts_dir = Path(__file__).parent / "scripts"
@@ -14,6 +17,9 @@ from match_payments import reconcile, fetch_sheet_data, fetch_exceptions, normal
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()
@@ -141,20 +147,34 @@ def reconcile_view():
for m in sorted_months: for m in sorted_months:
mdata = data["months"].get(m, {"expected": 0, "original_expected": 0, "paid": 0}) mdata = data["months"].get(m, {"expected": 0, "original_expected": 0, "paid": 0})
expected = mdata["expected"] expected = mdata["expected"]
original = mdata["original_expected"]
paid = int(mdata["paid"]) paid = int(mdata["paid"])
cell_status = "" status = "empty"
if expected == 0 and paid == 0: cell_text = "-"
cell = "-" amount_to_pay = 0
elif paid >= expected and expected > 0:
cell = "OK"
elif paid > 0:
cell = f"{paid}/{expected}"
else:
cell = f"UNPAID {expected}"
row["months"].append(cell) if expected > 0:
if paid >= expected:
status = "ok"
cell_text = "OK"
elif paid > 0:
status = "partial"
cell_text = f"{paid}/{expected}"
amount_to_pay = expected - paid
else:
status = "unpaid"
cell_text = f"UNPAID {expected}"
amount_to_pay = expected
elif paid > 0:
status = "surplus"
cell_text = f"PAID {paid}"
row["months"].append({
"text": cell_text,
"status": status,
"amount": amount_to_pay,
"month": month_labels[m]
})
row["balance"] = data["total_balance"] # Updated to use total_balance row["balance"] = data["total_balance"] # Updated to use total_balance
formatted_results.append(row) formatted_results.append(row)
@@ -178,7 +198,8 @@ def reconcile_view():
debts=debts, debts=debts,
unmatched=unmatched, unmatched=unmatched,
attendance_url=attendance_url, attendance_url=attendance_url,
payments_url=payments_url payments_url=payments_url,
bank_account=BANK_ACCOUNT
) )
@app.route("/payments") @app.route("/payments")
@@ -221,5 +242,36 @@ def payments():
payments_url=payments_url payments_url=payments_url
) )
@app.route("/qr")
def qr_code():
account = request.args.get("account", BANK_ACCOUNT)
amount = request.args.get("amount", "0")
message = request.args.get("message", "")
# QR Platba standard: SPD*1.0*ACC:accountNumber*BC:bankCode*AM:amount*CC:CZK*MSG:message
acc_parts = account.split('/')
if len(acc_parts) == 2:
acc_str = f"{acc_parts[0]}*BC:{acc_parts[1]}"
else:
acc_str = account
try:
amt_val = float(amount)
amt_str = f"{amt_val:.2f}"
except ValueError:
amt_str = "0.00"
# Message max 60 characters
msg_str = message[:60]
qr_data = f"SPD*1.0*ACC:{acc_str}*AM:{amt_str}*CC:CZK*MSG:{msg_str}"
img = qrcode.make(qr_data)
buf = io.BytesIO()
img.save(buf, format='PNG')
buf.seek(0)
return send_file(buf, mimetype='image/png')
if __name__ == "__main__": if __name__ == "__main__":
app.run(debug=True, host='0.0.0.0', port=5001) app.run(debug=True, host='0.0.0.0', port=5001)

View File

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

View File

@@ -1,12 +1,13 @@
[project] [project]
name = "fuj-management" name = "fuj-management"
version = "0.06" version = "0.10"
description = "Management tools for FUJ (Frisbee Ultimate Jablonec)" description = "Management tools for FUJ (Frisbee Ultimate Jablonec)"
dependencies = [ dependencies = [
"flask>=3.1.3", "flask>=3.1.3",
"google-api-python-client>=2.162.0", "google-api-python-client>=2.162.0",
"google-auth-httplib2>=0.2.0", "google-auth-httplib2>=0.2.0",
"google-auth-oauthlib>=1.2.1", "google-auth-oauthlib>=1.2.1",
"qrcode[pil]>=8.0",
] ]
requires-python = ">=3.13" requires-python = ">=3.13"

View File

@@ -138,6 +138,28 @@
.cell-unpaid { .cell-unpaid {
color: #ff3333; color: #ff3333;
background-color: rgba(255, 51, 51, 0.05);
position: relative;
}
.pay-btn {
display: none;
position: absolute;
right: 5px;
top: 50%;
transform: translateY(-50%);
background: #ff3333;
color: white;
border: none;
border-radius: 3px;
padding: 2px 6px;
font-size: 10px;
cursor: pointer;
font-weight: bold;
}
.member-row:hover .pay-btn {
display: inline-block;
} }
.cell-empty { .cell-empty {
@@ -360,6 +382,42 @@
margin-top: 5px; margin-top: 5px;
color: #222; color: #222;
} }
/* QR Modal styles */
#qrModal .modal-content {
max-width: 400px;
text-align: center;
}
.qr-image {
background: white;
padding: 10px;
border-radius: 5px;
margin: 20px 0;
display: inline-block;
}
.qr-image img {
display: block;
width: 250px;
height: 250px;
}
.qr-details {
text-align: left;
margin-top: 15px;
font-size: 14px;
color: #ccc;
}
.qr-details div {
margin-bottom: 5px;
}
.qr-details span {
color: #00ff00;
font-family: monospace;
}
</style> </style>
</head> </head>
@@ -403,8 +461,12 @@
</td> </td>
{% for cell in row.months %} {% for cell in row.months %}
<td <td
class="{% if cell == '-' %}cell-empty{% elif 'UNPAID' in cell %}cell-unpaid{% elif cell == 'OK' %}cell-ok{% endif %}"> class="{% if cell.status == 'empty' %}cell-empty{% elif cell.status == 'unpaid' or cell.status == 'partial' %}cell-unpaid{% elif cell.status == 'ok' %}cell-ok{% endif %}">
{{ cell }} {{ cell.text }}
{% if cell.status == 'unpaid' or cell.status == 'partial' %}
<button class="pay-btn"
onclick="showPayQR('{{ row.name|e }}', {{ cell.amount }}, '{{ cell.month|e }}')">Pay</button>
{% endif %}
</td> </td>
{% endfor %} {% endfor %}
<td class="{% if row.balance > 0 %}balance-pos{% elif row.balance < 0 %}balance-neg{% endif %}"> <td class="{% if row.balance > 0 %}balance-pos{% elif row.balance < 0 %}balance-neg{% endif %}">
@@ -460,6 +522,25 @@
</div> </div>
{% endif %} {% endif %}
<!-- QR Code Modal -->
<div id="qrModal" class="modal"
style="display:none; position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; background-color: rgba(0, 0, 0, 0.9); z-index: 9999; justify-content: center; align-items: center;">
<div class="modal-content">
<div class="modal-header">
<div class="modal-title" id="qrTitle">Payment for ...</div>
<div class="close-btn" onclick="closeModal('qrModal')">[close]</div>
</div>
<div class="qr-image">
<img id="qrImg" src="" alt="Payment QR Code">
</div>
<div class="qr-details">
<div>Account: <span id="qrAccount"></span></div>
<div>Amount: <span id="qrAmount"></span> CZK</div>
<div>Message: <span id="qrMessage"></span></div>
</div>
</div>
</div>
<div id="memberModal"> <div id="memberModal">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
@@ -620,8 +701,15 @@
document.getElementById('memberModal').classList.add('active'); document.getElementById('memberModal').classList.add('active');
} }
function closeModal() { function closeModal(id) {
document.getElementById('memberModal').classList.remove('active'); 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 // Existing filter script
@@ -644,6 +732,33 @@
document.addEventListener('keydown', function (e) { document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') closeModal(); if (e.key === 'Escape') closeModal();
}); });
function showPayQR(name, amount, month) {
const account = "{{ bank_account }}";
const message = `${name} / ${month}`;
const qrTitle = document.getElementById('qrTitle');
const qrImg = document.getElementById('qrImg');
const qrAccount = document.getElementById('qrAccount');
const qrAmount = document.getElementById('qrAmount');
const qrMessage = document.getElementById('qrMessage');
qrTitle.innerText = `Payment for ${month}`;
qrAccount.innerText = account;
qrAmount.innerText = amount;
qrMessage.innerText = message;
const encodedMessage = encodeURIComponent(message);
const qrUrl = `/qr?account=${encodeURIComponent(account)}&amount=${amount}&message=${encodedMessage}`;
qrImg.src = qrUrl;
document.getElementById('qrModal').style.display = 'block';
}
// Close modal when clicking outside
window.onclick = function (event) {
if (event.target.className === 'modal') {
event.target.style.display = 'none';
}
}
</script> </script>
</body> </body>