- Python 79.1%
- HTML 9.9%
- CSS 7.1%
- JavaScript 2.2%
- Dockerfile 1.7%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
- .page füllt die umgebende (iframe-)Spalte (width:100%), gedeckelt bei --content-max (960px) für Lesbarkeit; nach unten weiterhin responsiv - leeren <header class="form-header"> aus index/form/success entfernt Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
| .claude | ||
| app | ||
| output | ||
| .env.example | ||
| .gitignore | ||
| docker-compose.yml | ||
| PROJEKT_BRIEFING.md | ||
| README.md | ||
Digitale Vereinsformulare – Gartenverein Stahmeln-Süd e.V.
Eigenständige Python-Webanwendung, die Vereinsformulare online anbietet, beim
Absenden serverseitig ein PDF erzeugt und es per E-Mail mit PDF-Anhang an
den Vorstand versendet. Die App läuft als Docker-Stack und wird später per
<iframe> in die WordPress-Seite des Vereins eingebettet.
Architektur-Überblick
Browser ──GET /form/{slug}──▶ FastAPI rendert HTML aus dem Schema
──POST /form/{slug}─▶ Honeypot + Rate-Limit → Validierung →
fpdf2 (render_pdf-Funktion) → SMTP an Vorstand
→ Redirect auf /form/{slug}/success
| Baustein | Technik |
|---|---|
| Web-Framework | FastAPI + Uvicorn |
| Templating | Jinja2 (HTML-Formularseiten im Browser) |
| PDF-Erzeugung | fpdf2 (reines Python, programmatisches Layout) |
SMTP (smtplib, lokal MailHog) oder sendmail |
|
| Validierung | Schema-Definition + serverseitige Prüfung |
| Anti-Spam | Honeypot + In-Memory-Rate-Limit (+ Captcha-Schalter) |
| Container | Docker + docker-compose (nur lokale Entwicklung) |
PDF mit fpdf2 statt WeasyPrint: bewusste Entscheidung für den Zielhoster
(Uberspace, Shared Hosting ohne Root) mit zu alter Pango-Version. fpdf2 ist
reines Python und baut das Layout programmatisch auf — keine Grafik-System-
bibliotheken (libpango/libcairo) nötig. Umlaute/Unicode über eine eingebettete
TrueType-Schrift (DejaVuSans unter app/assets/fonts/); die fpdf2-Core-Fonts
sind auf Latin-1 beschränkt.
Schema-getrieben: Jedes Formular ist eine Definitionsdatei unter
app/forms/definitions/. Routing, HTML-Rendering, Validierung und Mailversand
sind generisch; das PDF baut jede Definition über ihre eigene render_pdf-
Funktion (mit den Layout-Helfern aus app/services/pdf.py). Ein neues Formular
= eine neue Definitionsdatei (siehe
Neues Formular hinzufügen).
Keine Datenbank im ersten Wurf. Die Architektur (Services, klare Routen) hält eine spätere Persistenz (SQLite/Postgres) leicht ergänzbar.
Schnellstart
Voraussetzung: Docker + Docker Compose.
cp .env.example .env
docker compose up --build
Dann im Browser:
- Formular Mitgliedsantrag: http://localhost:8000/form/mitgliedsantrag
- Übersicht aller Formulare: http://localhost:8000/
- MailHog (versendete Mails inkl. PDF-Anhang): http://localhost:8025
Ablauf zum Testen: Formular ausfüllen → absenden → Erfolgsseite → in MailHog
erscheint die Mail an VORSTAND_EMAIL mit dem PDF im Anhang. Bei aktivem
SAVE_PDF=true liegt das PDF zusätzlich unter ./output/.
Verzeichnisstruktur siehe PROJEKT_BRIEFING.md, Abschnitt 3.
Konfiguration (.env)
Alle Einstellungen kommen aus Umgebungsvariablen. Die wichtigsten:
| Variable | Bedeutung | Default (lokal) |
|---|---|---|
MAIL_TRANSPORT |
Versandweg: smtp oder sendmail |
smtp |
SENDMAIL_PATH |
Pfad zum sendmail-Binary (nur bei sendmail) |
/usr/sbin/sendmail |
SMTP_HOST / SMTP_PORT |
SMTP-Server (nur bei smtp) |
mailhog / 1025 |
SMTP_USER / SMTP_PASSWORD |
SMTP-Login (leer bei MailHog) | leer |
SMTP_USE_TLS |
STARTTLS aktivieren (Prod meist true) |
false |
SMTP_FROM |
Absenderadresse (beide Transportwege) | vereinsformulare@… |
VORSTAND_EMAIL |
Empfänger der Anträge (kommagetrennt mehrere) | vorstand@gv-ss.de |
ALLOWED_FRAME_ANCESTORS |
erlaubte iframe-Eltern (CSP frame-ancestors) |
* |
RATE_LIMIT_MAX |
max. Einsendungen pro IP & Zeitfenster | 5 |
RATE_LIMIT_WINDOW_SECONDS |
Länge des Zeitfensters in Sekunden | 3600 |
HONEYPOT_FIELD |
Feldname des versteckten Honeypots | website |
CAPTCHA_ENABLED |
Captcha-Platzhalter aktivieren | false |
SAVE_PDF |
lokale PDF-Ablage ein/aus | true |
OUTPUT_DIR |
Zielpfad der App für PDFs (Container: /app/output) |
/app/output |
OUTPUT_DIR_HOST |
nur docker-compose: Host-Mount auf /app/output |
./output |
LOGO_PATH |
Logo für den PDF-Kopf (rel. zu app/ oder absolut) |
assets/logo.png |
Logo im PDF
Der PDF-Kopf bindet das Logo aus LOGO_PATH ein (Default
app/assets/logo.png). Fehlt die Datei, zeigt das PDF einen
gestrichelten Platzhalter – die Erzeugung schlägt nie fehl. Eigenes Logo
einsetzen: PNG (mit Transparenz) oder JPG als app/assets/logo.png ablegen,
dann docker compose up --build (die Datei wird ins Image kopiert). Quadratische
Motive passen am besten; das Seitenverhältnis bleibt erhalten.
Ablageort der PDFs ändern
- Lokal (docker):
OUTPUT_DIR_HOSTin.envauf den gewünschten Host-Pfad setzen (z. B.OUTPUT_DIR_HOST=/Users/…/antraege); der Container-Pfad bleibt/app/output. - Ohne docker (z. B. Uberspace): direkt
OUTPUT_DIRauf einen absoluten, beschreibbaren Pfad setzen – oder mitSAVE_PDF=falseganz abschalten.
Neues Formular hinzufügen
Es ist nur eine Datei nötig – keine Änderung an Routing oder Kernlogik:
eine Definitionsdatei app/forms/definitions/<name>.py mit dem Feld-Schema und
einer render_pdf-Funktion, die das PDF mit den Layout-Helfern aus
app/services/pdf.py aufbaut.
from datetime import datetime
from config import get_settings
from services import pdf
from ..schema import Field, FormDefinition
def render_pdf(data: dict[str, str], submitted_at: datetime) -> bytes:
s = get_settings()
doc = pdf.new_doc("Kündigung")
doc.club_header(s.verein_name, s.verein_adresse)
doc.doc_title("Kündigung der Mitgliedschaft")
doc.section("Mitglied")
doc.row("Name", data.get("name", ""))
doc.row("Vorname", data.get("vorname", ""))
doc.row("Kündigung zum", pdf.de_date(data.get("kuendigung_zum", "")))
doc.note(f"Online eingereicht am {submitted_at.strftime('%d.%m.%Y um %H:%M Uhr')}.")
return bytes(doc.output())
FORM = FormDefinition(
slug="kuendigung", # URL: /form/kuendigung
title="Kündigung der Mitgliedschaft",
email_subject="Kündigung: {vorname} {name}",
render_pdf=render_pdf,
fields=[
Field("name", "Name", required=True, group="Mitglied"),
Field("vorname", "Vorname", required=True, group="Mitglied"),
Field("kuendigung_zum", "Kündigung zum", type="date", group="Kündigung"),
Field("email", "E-Mail", type="email", group="Kontakt"),
],
)
Unterstützte Feldtypen: text, email, date, tel, checkbox,
select (mit options=[(value, label), …]), textarea. Optionen je Feld:
required, group (Gruppierung/Abschnitt), placeholder, help_text,
autocomplete, no_future (Datum nicht in der Zukunft), eigener validator
(Funktion str -> str|None).
Layout-Helfer in services/pdf.py (über pdf.new_doc(...)): club_header
(Vereinskopf + Logo-Platzhalter), doc_title, section (Abschnittsüberschrift),
row (Label-Wert-Zeile), note (Hinweis-Kasten), confirmation_block
(umrahmter Block mit Unterschriftslinien). pdf.de_date(...) formatiert
ISO-Daten als TT.MM.JJJJ.
Beim nächsten Start wird das Formular automatisch unter /form/<slug>
registriert und erscheint auf der Übersichtsseite.
Das mitgelieferte Dummy-Formular „Adressänderung" (
adressaenderung.py, http://localhost:8000/form/adressaenderung) demonstriert genau das – eine einzige Datei, Schema +render_pdf.
WordPress-Einbettung (iframe)
Die App setzt kein X-Frame-Options: DENY, sondern eine
Content-Security-Policy mit frame-ancestors. In Produktion die
WordPress-Domain freigeben:
ALLOWED_FRAME_ANCESTORS='self' https://www.gv-ss.de
iframe in die WordPress-Seite einbetten (z. B. per HTML-Block):
<iframe id="gvforms" src="https://formulare.gv-ss.de/form/mitgliedsantrag"
style="width:100%; border:0; height:1200px;" loading="lazy"
title="Mitgliedsantrag"></iframe>
<script>
// Höhe automatisch an den gemeldeten Inhalt anpassen.
window.addEventListener("message", function (e) {
var d = e.data;
if (d && d.type === "gvforms:height" && typeof d.height === "number") {
var f = document.getElementById("gvforms");
if (f) f.style.height = d.height + "px";
}
});
</script>
Die App meldet ihre Dokumenthöhe per postMessage (iframe-resize.js).
Fällt JavaScript aus, sorgt die feste Höhe (height:1200px) als Fallback für
eine brauchbare Darstellung.
Deployment auf Uberspace (Produktion, ohne Docker)
Der docker-compose-Stack dient nur der lokalen Entwicklung. In Produktion läuft die App auf Uberspace (Shared Hosting, kein Root, kein Docker) als direkter Uvicorn-Prozess. Da fpdf2 reines Python ist und die Schrift im Projekt liegt, sind keine System-Grafikbibliotheken nötig — der Wechsel ist reine Konfig.
Auf Uberspace ist der Default-
pythonnoch 2.7. Durchgängig die versionierten Binariespython3.13/pip3.13verwenden.
1) Code & virtuelles Environment
cd ~ && git clone <repo-url> vereinsformulare # oder per rsync hochladen
cd ~/vereinsformulare/app
python3.13 -m venv ~/vereinsformulare/venv
~/vereinsformulare/venv/bin/pip3.13 install --upgrade pip
~/vereinsformulare/venv/bin/pip3.13 install -r requirements.txt
# .env ins app-Verzeichnis legen (dort ist das Arbeitsverzeichnis des Prozesses,
# pydantic-settings liest die .env relativ dazu). Danach anpassen (s. u.).
cp ../.env.example .env
2) app/.env für Produktion anpassen
- Mailversand: Auf Uberspace am einfachsten das lokale sendmail-Binary
nutzen —
MAIL_TRANSPORT=sendmail(Default-Pfad/usr/sbin/sendmailpasst), dann sind keine SMTP-Zugangsdaten nötig. Alternativ SMTP einer Uberspace- Mailbox:MAIL_TRANSPORT=smtp,SMTP_HOST=…,SMTP_PORT=587,SMTP_USER,SMTP_PASSWORD,SMTP_USE_TLS=true.SMTP_FROMauf eine zulässige Absenderadresse setzen. VORSTAND_EMAILauf die echte Vorstandsadresse.ALLOWED_FRAME_ANCESTORS='self' https://www.gv-ss.de(die WordPress-Domain).- Den Container-Pfad
OUTPUT_DIR=/app/outputdurch einen beschreibbaren, absoluten Pfad im Home ersetzen (z. B.OUTPUT_DIR=/home/<user>/vereinsformulare/output) oder die lokale Ablage mitSAVE_PDF=falseabschalten — die Daten gehen ohnehin per Mail an den Vorstand.
Dank ENV-Konfiguration ist die SMTP-Umstellung ein reiner Konfig-Wechsel ohne Codeänderung.
3) Dauerbetrieb via supervisord
Uvicorn muss auf 0.0.0.0 lauschen (nicht localhost/127.0.0.1), sonst
findet das Web-Backend den Dienst nicht. Port frei zwischen 1024–65535 wählen
(hier 8000). Datei ~/etc/services.d/vereinsformulare.ini anlegen:
[program:vereinsformulare]
directory=%(ENV_HOME)s/vereinsformulare/app
command=%(ENV_HOME)s/vereinsformulare/venv/bin/uvicorn main:app --host 0.0.0.0 --port 8000
autostart=true
autorestart=true
stdout_logfile=%(ENV_HOME)s/logs/vereinsformulare.log
stderr_logfile=%(ENV_HOME)s/logs/vereinsformulare.err.log
Aktivieren und Status prüfen:
supervisorctl reread
supervisorctl update
supervisorctl status vereinsformulare
4) Domain mit dem Uvicorn-Port verbinden (Web-Backend)
uberspace web backend set / --http --port 8000
HTTPS stellt Uberspace automatisch bereit (für die iframe-Einbettung zwingend).
5) PDF / Schrift
fpdf2 braucht keine Systembibliotheken. Sicherstellen, dass die mitgelieferte
TrueType-Schrift unter app/assets/fonts/DejaVuSans*.ttf vorhanden ist (wird im
Repo mitgeliefert) — sie wird relativ zum Projekt referenziert. Das Logo unter
app/assets/logo.png (bzw. LOGO_PATH) wird ebenfalls direkt aus dem Projekt
gelesen; ohne Datei erscheint der Platzhalter.
Updates ausrollen: neuen Code einspielen, ggf. pip3.13 install -r requirements.txt, dann supervisorctl restart vereinsformulare.
Sicherheit & Anti-Spam
- Honeypot: verstecktes Feld (
HONEYPOT_FIELD); ausgefüllt ⇒ Einsendung wird still verworfen (dem Bot wird Erfolg vorgegaukelt). - Rate-Limit: In-Memory-Zähler pro IP. Für Produktion gehört das an den Reverse-Proxy oder in einen geteilten Store (Redis), da der In-Memory-Zähler weder mehrinstanz-sicher ist noch Neustarts überlebt (Hinweis im Code).
- Captcha: datensparsames Captcha (Cloudflare Turnstile oder hCaptcha,
bewusst nicht Google reCAPTCHA) ist als Schalter vorbereitet
(
CAPTCHA_ENABLED). Zum Scharfschalten inservices/antispam.pydie Token-Verifikation gegen die Provider-API ergänzen und Site-Key im Template einbinden. - Serverseitige Validierung aller Felder (Client-Validierung genügt nie).
- Datensparsames Logging: es werden keine vollständigen Formularinhalte geloggt, nur Metadaten (Anzahl Empfänger, Dateiname).
DSGVO- & Produktionshinweise
- HTTPS in Produktion zwingend (z. B. Reverse-Proxy mit Let's Encrypt). Lokal ist HTTP zum Testen in Ordnung.
- EU-Hosting empfohlen; mit dem Hoster einen Auftragsverarbeitungs- vertrag (AVV) abschließen.
- Verarbeitung im Verzeichnis der Verarbeitungstätigkeiten dokumentieren.
- Datenschutzerklärung des Vereins verlinken (
DATENSCHUTZ_URL); die Pflicht-Checkbox im Formular ist daran gekoppelt. - Keine personenbezogenen Daten in URL-Parametern (das Formular sendet per
POST). - Lokale PDF-Ablage (
./output) enthält personenbezogene Daten: Zugriff beschränken, Aufbewahrung regeln, nicht ins Versionsverwaltungssystem einchecken (siehe.gitignore). - Wartungsverantwortung klären: Updates von App, Abhängigkeiten und Image.
Bewusst nicht im Prototyp
Keine elektronische Signatur (Beitritt formfrei), kein SEPA-Mandat, keine
zweite antragstellende Person, keine Mitglieder-Datenbank/Login, keine
Deployment-Automatisierung. Siehe PROJEKT_BRIEFING.md, Abschnitt 11.