Add sync to grist functionality

This commit is contained in:
2025-10-26 21:09:22 +01:00
parent a11f1a6c38
commit 9efccccc21
6 changed files with 192 additions and 39 deletions

View File

@@ -7,6 +7,7 @@ requires-python = "~=3.13.0"
dependencies = [ dependencies = [
"alembic>=1.17.0", "alembic>=1.17.0",
"fastapi[standard]>=0.116.0", "fastapi[standard]>=0.116.0",
"pygrister>=0.8.0",
"python-dotenv>=1.1.1", "python-dotenv>=1.1.1",
"reportlab>=4.4.4", "reportlab>=4.4.4",
"sqlalchemy>=2.0.44", "sqlalchemy>=2.0.44",

56
src/meal_manager/grist.py Normal file
View File

@@ -0,0 +1,56 @@
import datetime
import os
from dotenv import load_dotenv
load_dotenv()
from pygrister.api import GristApi
config = {
"GRIST_API_SERVER": "allmende-gufi.de",
"GRIST_TEAM_SITE": "grist",
"GRIST_API_KEY": os.getenv("GRIST_API_KEY"),
"GRIST_DOC_ID": "xmEcaq5pvxUB3mBfEY8BLe",
}
def sync_with_grist(event):
grist = GristApi(config=config)
status_code, grist_response = grist.list_records(
"Transactions", filter={"meal_manager_event_id": [event.id]}
)
existing_records = set()
for entry in grist_response:
existing_records.add(entry["Partei_Konto"])
new_records = []
today = datetime.date.today().isoformat()
for registration in event.registrations:
if registration.household.name in existing_records:
continue
if registration.num_adult_meals > 0:
new_records.append(
{
"Datum": event.event_time.date().isoformat(),
"Typ": "Essen",
"Partei_Konto": registration.household.name,
"Betrag": -3.5 * registration.num_adult_meals,
"Date_Added": today,
"meal_manager_event_id": event.id,
}
)
if registration.num_children_meals > 0:
new_records.append(
{
"Datum": event.event_time.date().isoformat(),
"Typ": "Essen Kind",
"Partei_Konto": registration.household.name,
"Betrag": -2 * registration.num_children_meals,
"Date_Added": today,
"meal_manager_event_id": event.id,
}
)
grist.add_records("Transactions", new_records)

View File

@@ -13,6 +13,7 @@ from fastapi.templating import Jinja2Templates
from sqlalchemy import create_engine, select from sqlalchemy import create_engine, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from meal_manager.grist import sync_with_grist
from meal_manager.models import ( from meal_manager.models import (
Base, Base,
Event, Event,
@@ -295,7 +296,11 @@ async def delete_event(
@app.get("/event/{event_id}") @app.get("/event/{event_id}")
async def read_event( async def read_event(
request: Request, event_id: int, session: SessionDep, user: UserDep request: Request,
event_id: int,
session: SessionDep,
user: UserDep,
message: str | None = None,
): ):
statement = select(Event).where(Event.id == event_id) statement = select(Event).where(Event.id == event_id)
event = session.scalars(statement).one() event = session.scalars(statement).one()
@@ -318,6 +323,7 @@ async def read_event(
"households": households, "households": households,
"now": datetime.now(), "now": datetime.now(),
"user": user, "user": user,
"message": message,
}, },
) )
@@ -423,3 +429,17 @@ def get_event_attendance_pdf(event_id: int, session: SessionDep):
return Response( return Response(
content=pdf_buffer.getvalue(), media_type="application/pdf", headers=headers content=pdf_buffer.getvalue(), media_type="application/pdf", headers=headers
) )
@app.get("/event/{event_id}/sync_with_grist")
def sync_with_grist_route(event_id: int, session: SessionDep, user: StrictUserDep):
statement = select(Event).where(Event.id == event_id)
event = session.scalars(statement).one()
sync_with_grist(event)
return RedirectResponse(
url=f"/event/{event_id}?message=Erfolgreich%20an%20Abrechnung%20%C3%BCbertragen",
status_code=status.HTTP_302_FOUND,
)

View File

@@ -12,7 +12,14 @@ def build_dinner_overview_pdf(event: Event) -> BytesIO:
"""Build a PDF with an overview of the event's attendance.""" """Build a PDF with an overview of the event's attendance."""
# Create an in-memory PDF # Create an in-memory PDF
buffer = BytesIO() buffer = BytesIO()
doc = SimpleDocTemplate(buffer, pagesize=portrait(A4), topMargin=30, bottomMargin=30, leftMargin=40, rightMargin=40) doc = SimpleDocTemplate(
buffer,
pagesize=portrait(A4),
topMargin=30,
bottomMargin=30,
leftMargin=40,
rightMargin=40,
)
styles = getSampleStyleSheet() styles = getSampleStyleSheet()
elements = [] elements = []
@@ -20,8 +27,12 @@ def build_dinner_overview_pdf(event: Event) -> BytesIO:
title_style = styles["Title"] title_style = styles["Title"]
title_style.fontSize = 16 title_style.fontSize = 16
title_style.spaceAfter = 20 title_style.spaceAfter = 20
elements.append(Paragraph(f"Anwesenheitsliste {event.title} ({event.event_time.date().strftime('%d.%m.%y')})", elements.append(
title_style)) Paragraph(
f"Anwesenheitsliste {event.title} ({event.event_time.date().strftime('%d.%m.%y')})",
title_style,
)
)
elements.append(Spacer(1, 20)) elements.append(Spacer(1, 20))
# Team overview section # Team overview section
@@ -32,23 +43,27 @@ def build_dinner_overview_pdf(event: Event) -> BytesIO:
team_types = { team_types = {
"Kochen": [r for r in event.team if r.work_type == "cooking"], "Kochen": [r for r in event.team if r.work_type == "cooking"],
"Abwaschen": [r for r in event.team if r.work_type == "dishes"], "Abwaschen": [r for r in event.team if r.work_type == "dishes"],
"Tische decken": [r for r in event.team if r.work_type == "tables"] "Tische decken": [r for r in event.team if r.work_type == "tables"],
} }
for team_name, registrations in team_types.items(): for team_name, registrations in team_types.items():
members = ", ".join(r.person_name for r in registrations) members = ", ".join(r.person_name for r in registrations)
team_data.append([team_name, members]) team_data.append([team_name, members])
team_table = Table(team_data, repeatRows=1, colWidths=[100, '*']) team_table = Table(team_data, repeatRows=1, colWidths=[100, "*"])
team_table.setStyle(TableStyle([ team_table.setStyle(
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor('#E8E8E8')), TableStyle(
("GRID", (0, 0), (-1, -1), 0.5, colors.HexColor('#A0A0A0')), [
("VALIGN", (0, 0), (-1, -1), "MIDDLE"), ("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#E8E8E8")),
("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"), ("GRID", (0, 0), (-1, -1), 0.5, colors.HexColor("#A0A0A0")),
("FONTSIZE", (0, 0), (-1, -1), 10), ("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
("BOTTOMPADDING", (0, 0), (-1, -1), 8), ("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
("TOPPADDING", (0, 0), (-1, -1), 8), ("FONTSIZE", (0, 0), (-1, -1), 10),
])) ("BOTTOMPADDING", (0, 0), (-1, -1), 8),
("TOPPADDING", (0, 0), (-1, -1), 8),
]
)
)
elements.append(team_table) elements.append(team_table)
elements.append(Spacer(1, 25)) elements.append(Spacer(1, 25))
@@ -63,37 +78,49 @@ def build_dinner_overview_pdf(event: Event) -> BytesIO:
# Attendance section # Attendance section
elements.append(Paragraph("Teilnehmende", styles["Heading2"])) elements.append(Paragraph("Teilnehmende", styles["Heading2"]))
elements.append(Paragraph(f"Gesamt: {sum_adults} Erwachsene, {sum_children} Kinder, {sum_small_children} Kleinkinder")) elements.append(
Paragraph(
f"Gesamt: {sum_adults} Erwachsene, {sum_children} Kinder, {sum_small_children} Kleinkinder"
)
)
elements.append(Spacer(1, 12)) elements.append(Spacer(1, 12))
# Table header # Table header
data = [["Haushalt", "Erwachsene", "Kinder >7", "Kinder <7", "Kommentar", "Anwesend?"]] data = [
["Haushalt", "Erwachsene", "Kinder >7", "Kinder <7", "Kommentar", "Anwesend?"]
]
# Table rows # Table rows
for r in event.registrations: for r in event.registrations:
data.append([ data.append(
r.household.name, [
r.num_adult_meals, r.household.name,
r.num_children_meals, r.num_adult_meals,
r.num_small_children_meals, r.num_children_meals,
Paragraph(r.comment or ""), r.num_small_children_meals,
"" Paragraph(r.comment or ""),
]) "",
]
)
for _ in range(5): for _ in range(5):
data.append([""] * 6) data.append([""] * 6)
# Create table # Create table
table = Table(data, repeatRows=1, colWidths=[120, 70, 60, 60, '*', 65]) table = Table(data, repeatRows=1, colWidths=[120, 70, 60, 60, "*", 65])
table.setStyle(TableStyle([ table.setStyle(
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor('#E8E8E8')), TableStyle(
("GRID", (0, 0), (-1, -1), 0.5, colors.HexColor('#A0A0A0')), [
("ALIGN", (1, 1), (-2, -1), "CENTER"), ("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#E8E8E8")),
("VALIGN", (0, 0), (-1, -1), "MIDDLE"), ("GRID", (0, 0), (-1, -1), 0.5, colors.HexColor("#A0A0A0")),
("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"), ("ALIGN", (1, 1), (-2, -1), "CENTER"),
("FONTSIZE", (0, 0), (-1, -1), 10), ("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
("BOTTOMPADDING", (0, 0), (-1, -1), 3), ("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
("TOPPADDING", (0, 0), (-1, -1), 3), ("FONTSIZE", (0, 0), (-1, -1), 10),
])) ("BOTTOMPADDING", (0, 0), (-1, -1), 3),
("TOPPADDING", (0, 0), (-1, -1), 3),
]
)
)
elements.append(table) elements.append(table)
doc.build(elements) doc.build(elements)

View File

@@ -20,7 +20,15 @@
<p class="text-muted">{{ event.event_time.strftime('%A, %d.%m.%Y') }}</p> <p class="text-muted">{{ event.event_time.strftime('%A, %d.%m.%Y') }}</p>
<p>{{ event.description }}</p> <p>{{ event.description }}</p>
<hr class="hr"/> <hr class="hr"/>
{% if message %}
<div class="alert alert-info alert-dismissible fade show" role="alert">
<i class="bi bi-info-circle me-2"></i>
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
{% endif %}
<div class="container"> <div class="container">
<p class="h3">Anmeldungen</p> <p class="h3">Anmeldungen</p>
{% if not user %} {% if not user %}
<div class="alert alert-warning m-2"> <div class="alert alert-warning m-2">
@@ -62,9 +70,20 @@
</div> </div>
</div> </div>
{% if user and user.admin %} {% if user and user.admin %}
<button type="button" class="btn btn-danger mb-2 w-100" data-bs-toggle="modal" data-bs-target="#deleteEvent"> <div class="row w-100">
Event Löschen <div class="col-6 p-1">
</button> <button type="button" class="btn btn-danger w-100" data-bs-toggle="modal" data-bs-target="#deleteEvent">
Event Löschen
</button>
</div>
<div class="col-6 p-1">
<a href="/event/{{event.id}}/sync_with_grist" class="btn btn-secondary w-100">
<i class="bi bi-cash-coin m-2"></i> Abrechnen
</a>
</div>
</div>
{% endif %} {% endif %}
</div> </div>
<div class="col-md-4"> <div class="col-md-4">

30
uv.lock generated
View File

@@ -364,6 +364,7 @@ source = { editable = "." }
dependencies = [ dependencies = [
{ name = "alembic" }, { name = "alembic" },
{ name = "fastapi", extra = ["standard"] }, { name = "fastapi", extra = ["standard"] },
{ name = "pygrister" },
{ name = "python-dotenv" }, { name = "python-dotenv" },
{ name = "reportlab" }, { name = "reportlab" },
{ name = "sqlalchemy" }, { name = "sqlalchemy" },
@@ -380,6 +381,7 @@ dev = [
requires-dist = [ requires-dist = [
{ name = "alembic", specifier = ">=1.17.0" }, { name = "alembic", specifier = ">=1.17.0" },
{ name = "fastapi", extras = ["standard"], specifier = ">=0.116.0" }, { name = "fastapi", extras = ["standard"], specifier = ">=0.116.0" },
{ name = "pygrister", specifier = ">=0.8.0" },
{ name = "python-dotenv", specifier = ">=1.1.1" }, { name = "python-dotenv", specifier = ">=1.1.1" },
{ name = "reportlab", specifier = ">=4.4.4" }, { name = "reportlab", specifier = ">=4.4.4" },
{ name = "sqlalchemy", specifier = ">=2.0.44" }, { name = "sqlalchemy", specifier = ">=2.0.44" },
@@ -518,6 +520,19 @@ 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" }, { 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]]
name = "pygrister"
version = "0.8.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "requests" },
{ name = "typer" },
]
sdist = { url = "https://files.pythonhosted.org/packages/6a/a2/804b3e63bce91fb0c7f093b6f3e522e476d9f3074cd3384d713f73fff78b/pygrister-0.8.0.tar.gz", hash = "sha256:4faaad23b27c9ae46dc7b321a0de376f3bfbdaa5faa7ffd769566105667cd478", size = 41472, upload-time = "2025-08-10T10:53:47.561Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f2/47/e43f2d8b88477a9b27b39d97e3c61534ae3cda4c99771f8b3c81d2469486/pygrister-0.8.0-py3-none-any.whl", hash = "sha256:b882a93db0aae642435d23f8e6f6a50f737befa35e3cce72f234bedd0ef4bee6", size = 31863, upload-time = "2025-08-10T10:53:46.345Z" },
]
[[package]] [[package]]
name = "python-dotenv" name = "python-dotenv"
version = "1.1.1" version = "1.1.1"
@@ -566,6 +581,21 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/57/66/e040586fe6f9ae7f3a6986186653791fb865947f0b745290ee4ab026b834/reportlab-4.4.4-py3-none-any.whl", hash = "sha256:299b3b0534e7202bb94ed2ddcd7179b818dcda7de9d8518a57c85a58a1ebaadb", size = 1954981, upload-time = "2025-09-19T10:43:33.589Z" }, { url = "https://files.pythonhosted.org/packages/57/66/e040586fe6f9ae7f3a6986186653791fb865947f0b745290ee4ab026b834/reportlab-4.4.4-py3-none-any.whl", hash = "sha256:299b3b0534e7202bb94ed2ddcd7179b818dcda7de9d8518a57c85a58a1ebaadb", size = 1954981, upload-time = "2025-09-19T10:43:33.589Z" },
] ]
[[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]] [[package]]
name = "rich" name = "rich"
version = "14.1.0" version = "14.1.0"