From 8fe744afe11f24f3b77d2f4d1ffec4f7c6a33336 Mon Sep 17 00:00:00 2001 From: Niklas Meinzer Date: Wed, 15 Oct 2025 11:12:43 +0200 Subject: [PATCH] Add support for PDF view --- pyproject.toml | 1 + src/meal_manager/main.py | 21 +++++- src/meal_manager/pdf.py | 101 ++++++++++++++++++++++++++ src/meal_manager/templates/event.html | 3 + uv.lock | 73 +++++++++++++++++++ 5 files changed, 198 insertions(+), 1 deletion(-) create mode 100644 src/meal_manager/pdf.py diff --git a/pyproject.toml b/pyproject.toml index ff60057..3b7a5fd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,6 +8,7 @@ dependencies = [ "alembic>=1.17.0", "fastapi[standard]>=0.116.0", "python-dotenv>=1.1.1", + "reportlab>=4.4.4", "sqlalchemy>=2.0.44", "uvicorn[standard]>=0.35.0", ] diff --git a/src/meal_manager/main.py b/src/meal_manager/main.py index ed92a88..d20ed7e 100644 --- a/src/meal_manager/main.py +++ b/src/meal_manager/main.py @@ -6,7 +6,7 @@ from functools import partial from typing import Annotated import starlette.status as status -from fastapi import Depends, FastAPI, HTTPException, Request +from fastapi import Depends, FastAPI, HTTPException, Request, Response from fastapi.responses import RedirectResponse from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates @@ -21,6 +21,7 @@ from meal_manager.models import ( Subscription, TeamRegistration, ) +from meal_manager.pdf import build_dinner_overview_pdf sqlite_file_name = "database.db" sqlite_url = f"sqlite:///{sqlite_file_name}" @@ -332,3 +333,21 @@ async def delete_team_registration( session.delete(session.scalars(statement).one()) session.commit() return RedirectResponse(url=f"/event/{event_id}", status_code=status.HTTP_302_FOUND) + +@app.get("/event/{event_id}/pdf") +def get_event_attendance_pdf(event_id: int, session: SessionDep): + + statement = select(Event).where(Event.id == event_id) + event = session.scalars(statement).one() + + pdf_buffer = build_dinner_overview_pdf(event) + + headers = { + "Content-Disposition": f"inline; filename=attendance_event_{event_id}.pdf" + } + + return Response( + content=pdf_buffer.getvalue(), + media_type="application/pdf", + headers=headers + ) \ No newline at end of file diff --git a/src/meal_manager/pdf.py b/src/meal_manager/pdf.py new file mode 100644 index 0000000..72a93f2 --- /dev/null +++ b/src/meal_manager/pdf.py @@ -0,0 +1,101 @@ +from io import BytesIO + +from reportlab.lib import colors +from reportlab.lib.pagesizes import A4, portrait +from reportlab.lib.styles import getSampleStyleSheet +from reportlab.platypus import Paragraph, SimpleDocTemplate, Spacer, Table, TableStyle + +from meal_manager.models import Event + + +def build_dinner_overview_pdf(event: Event) -> BytesIO: + """Build a PDF with an overview of the event's attendance.""" + # Create an in-memory PDF + buffer = BytesIO() + doc = SimpleDocTemplate(buffer, pagesize=portrait(A4), topMargin=30, bottomMargin=30, leftMargin=40, rightMargin=40) + styles = getSampleStyleSheet() + elements = [] + + # Title + title_style = styles["Title"] + title_style.fontSize = 16 + title_style.spaceAfter = 20 + elements.append(Paragraph(f"Anwesenheitsliste – {event.title} ({event.event_time.date().strftime('%d.%m.%y')})", + title_style)) + elements.append(Spacer(1, 20)) + + # Team overview section + elements.append(Paragraph("Dienste", styles["Heading2"])) + elements.append(Spacer(1, 12)) + + team_data = [["Team", "Personen"]] + team_types = { + "Kochen": [r for r in event.team if r.work_type == "cooking"], + "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"] + } + + for team_name, registrations in team_types.items(): + members = ", ".join(r.person_name for r in registrations) + team_data.append([team_name, members]) + + team_table = Table(team_data, repeatRows=1, colWidths=[100, '*']) + team_table.setStyle(TableStyle([ + ("BACKGROUND", (0, 0), (-1, 0), colors.HexColor('#E8E8E8')), + ("GRID", (0, 0), (-1, -1), 0.5, colors.HexColor('#A0A0A0')), + ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), + ("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"), + ("FONTSIZE", (0, 0), (-1, -1), 10), + ("BOTTOMPADDING", (0, 0), (-1, -1), 8), + ("TOPPADDING", (0, 0), (-1, -1), 8), + ])) + + elements.append(team_table) + elements.append(Spacer(1, 25)) + + sum_adults = 0 + sum_children = 0 + sum_small_children = 0 + for r in event.registrations: + sum_adults += r.num_adult_meals + sum_children += r.num_children_meals + sum_small_children += r.num_small_children_meals + + # Attendance section + elements.append(Paragraph("Teilnehmende", styles["Heading2"])) + elements.append(Paragraph(f"Gesamt: {sum_adults} Erwachsene, {sum_children} Kinder, {sum_small_children} Kleinkinder")) + elements.append(Spacer(1, 12)) + + # Table header + data = [["Haushalt", "Erwachsene", "Kinder >7", "Kinder <7", "Kommentar", "Anwesend?"]] + + # Table rows + for r in event.registrations: + data.append([ + r.household.name, + r.num_adult_meals, + r.num_children_meals, + r.num_small_children_meals, + Paragraph(r.comment), + "" + ]) + for _ in range(5): + data.append([""] * 6) + + # Create table + table = Table(data, repeatRows=1, colWidths=[120, 70, 60, 60, '*', 65]) + table.setStyle(TableStyle([ + ("BACKGROUND", (0, 0), (-1, 0), colors.HexColor('#E8E8E8')), + ("GRID", (0, 0), (-1, -1), 0.5, colors.HexColor('#A0A0A0')), + ("ALIGN", (1, 1), (-2, -1), "CENTER"), + ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), + ("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"), + ("FONTSIZE", (0, 0), (-1, -1), 10), + ("BOTTOMPADDING", (0, 0), (-1, -1), 8), + ("TOPPADDING", (0, 0), (-1, -1), 8), + ])) + + elements.append(table) + doc.build(elements) + buffer.seek(0) + return buffer diff --git a/src/meal_manager/templates/event.html b/src/meal_manager/templates/event.html index 715162f..d8ea601 100644 --- a/src/meal_manager/templates/event.html +++ b/src/meal_manager/templates/event.html @@ -34,6 +34,9 @@ Original Rezept ansehen {% endif %} + + Druckansicht + {% if user and user.admin %}