No description
  • Python 79.1%
  • HTML 9.9%
  • CSS 7.1%
  • JavaScript 2.2%
  • Dockerfile 1.7%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Hannes Bohring 7bbd493a7b Formular responsiv an Spaltenbreite + leeren Seitenkopf entfernen
- .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>
2026-07-23 20:34:30 +02:00
.claude update 2026-07-23 19:30:50 +02:00
app Formular responsiv an Spaltenbreite + leeren Seitenkopf entfernen 2026-07-23 20:34:30 +02:00
output first commit 2026-06-28 15:13:10 +02:00
.env.example Mailversand umschaltbar: SMTP oder sendmail 2026-06-29 22:30:47 +02:00
.gitignore Mitgliedsantrag: mehr Pflichtfelder, Base-Image & Abhängigkeiten aktualisiert 2026-07-19 11:32:21 +02:00
docker-compose.yml PDF-Logo einbetten und Output-Ordner konfigurierbar machen 2026-06-29 22:17:15 +02:00
PROJEKT_BRIEFING.md first commit 2026-06-28 15:13:10 +02:00
README.md Mailversand umschaltbar: SMTP oder sendmail 2026-06-29 22:30:47 +02:00

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)
E-Mail 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:

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_HOST in .env auf 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_DIR auf einen absoluten, beschreibbaren Pfad setzen – oder mit SAVE_PDF=false ganz 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-python noch 2.7. Durchgängig die versionierten Binaries python3.13 / pip3.13 verwenden.

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/sendmail passt), 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_FROM auf eine zulässige Absenderadresse setzen.
  • VORSTAND_EMAIL auf die echte Vorstandsadresse.
  • ALLOWED_FRAME_ANCESTORS='self' https://www.gv-ss.de (die WordPress-Domain).
  • Den Container-Pfad OUTPUT_DIR=/app/output durch einen beschreibbaren, absoluten Pfad im Home ersetzen (z. B. OUTPUT_DIR=/home/<user>/vereinsformulare/output) oder die lokale Ablage mit SAVE_PDF=false abschalten — 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 in services/antispam.py die 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.