diff --git a/app/core/settings.py b/app/core/settings.py
index d6d65ff..dedda34 100644
--- a/app/core/settings.py
+++ b/app/core/settings.py
@@ -177,7 +177,7 @@ if not DEBUG:
# django-otp settings
OTP_TOTP_ISSUER = 'Stiftung Management System'
-OTP_LOGIN_URL = '/two-factor/login/'
+OTP_LOGIN_URL = '/auth/2fa/verify/'
# Optional: Hide sensitive data in admin when not verified
OTP_ADMIN_HIDE_SENSITIVE_DATA = True
diff --git a/app/static/stiftung/js/briefvorlage_editor.js b/app/static/stiftung/js/briefvorlage_editor.js
new file mode 100644
index 0000000..b9cb46e
--- /dev/null
+++ b/app/static/stiftung/js/briefvorlage_editor.js
@@ -0,0 +1,250 @@
+/**
+ * Briefvorlage-Editor: Minimal-WYSIWYG + Vorschau-Panel + Vorlagen-Loader
+ * für Django Admin – keine externen Abhängigkeiten.
+ */
+(function () {
+ "use strict";
+
+ // Warte auf DOM
+ document.addEventListener("DOMContentLoaded", function () {
+ var textareas = document.querySelectorAll("textarea.briefvorlage-textarea");
+ textareas.forEach(function (textarea) {
+ initEditor(textarea);
+ });
+ });
+
+ function initEditor(textarea) {
+ var wrapper = document.createElement("div");
+ wrapper.style.cssText = "border:1px solid #ccc;border-radius:4px;overflow:hidden;margin-top:4px;";
+
+ // ---- Toolbar ----
+ var toolbar = document.createElement("div");
+ toolbar.style.cssText = "background:#f5f5f5;border-bottom:1px solid #ccc;padding:5px 8px;display:flex;flex-wrap:wrap;gap:4px;align-items:center;";
+
+ var buttons = [
+ { label: "B", cmd: "bold", title: "Fett (Strg+B)", style: "font-weight:bold;" },
+ { label: "I", cmd: "italic", title: "Kursiv (Strg+I)", style: "font-style:italic;" },
+ { label: "U", cmd: "underline", title: "Unterstrichen (Strg+U)", style: "text-decoration:underline;" },
+ { label: "¶", cmd: "insertParagraph", title: "Absatz einfügen" },
+ { label: "• Liste", cmd: "insertUnorderedList", title: "Aufzählung" },
+ { label: "1. Liste", cmd: "insertOrderedList", title: "Nummerierte Liste" },
+ ];
+
+ buttons.forEach(function (b) {
+ var btn = document.createElement("button");
+ btn.type = "button";
+ btn.title = b.title;
+ btn.innerHTML = b.label;
+ btn.style.cssText = "padding:3px 8px;cursor:pointer;border:1px solid #ccc;border-radius:3px;background:#fff;font-size:13px;" + (b.style || "");
+ btn.addEventListener("click", function (e) {
+ e.preventDefault();
+ editor.focus();
+ document.execCommand(b.cmd, false, null);
+ syncToTextarea();
+ });
+ toolbar.appendChild(btn);
+ });
+
+ // Trennlinie
+ var sep = document.createElement("span");
+ sep.style.cssText = "border-left:1px solid #ccc;height:20px;margin:0 4px;";
+ toolbar.appendChild(sep);
+
+ // Tab-Buttons: Editor / HTML / Vorschau
+ var tabEditor = createTabBtn("Editor", true);
+ var tabHtml = createTabBtn("HTML", false);
+ var tabVorschau = createTabBtn("Vorschau", false);
+ toolbar.appendChild(tabEditor);
+ toolbar.appendChild(tabHtml);
+ toolbar.appendChild(tabVorschau);
+
+ // Vorlage-Loader (nur wenn BriefVorlage-API verfügbar)
+ var sep2 = document.createElement("span");
+ sep2.style.cssText = "border-left:1px solid #ccc;height:20px;margin:0 4px;";
+ toolbar.appendChild(sep2);
+
+ var vorlagenSelect = document.createElement("select");
+ vorlagenSelect.style.cssText = "font-size:12px;padding:2px 6px;border:1px solid #ccc;border-radius:3px;max-width:200px;";
+ var defaultOption = document.createElement("option");
+ defaultOption.value = "";
+ defaultOption.textContent = "– Vorlage laden –";
+ vorlagenSelect.appendChild(defaultOption);
+ toolbar.appendChild(vorlagenSelect);
+
+ // Vorlagen asynchron laden
+ loadVorlagen(vorlagenSelect);
+
+ var ladeBtn = document.createElement("button");
+ ladeBtn.type = "button";
+ ladeBtn.textContent = "Laden";
+ ladeBtn.title = "Ausgewählte Vorlage in den Editor laden";
+ ladeBtn.style.cssText = "padding:3px 8px;cursor:pointer;border:1px solid #0d6efd;border-radius:3px;background:#0d6efd;color:#fff;font-size:12px;";
+ ladeBtn.addEventListener("click", function (e) {
+ e.preventDefault();
+ var val = vorlagenSelect.value;
+ if (!val) return;
+ var opt = vorlagenSelect.querySelector("option[value='" + val + "']");
+ if (!opt) return;
+ var html = opt.dataset.briefvorlage || "";
+ var betreff = opt.dataset.betreff || "";
+ if (confirm("Vorlage \"" + opt.textContent + "\" laden?\nDer aktuelle Brieftext wird überschrieben.")) {
+ editor.innerHTML = html;
+ textarea.value = html;
+ // Betreff-Feld befüllen falls vorhanden und nicht leer
+ if (betreff) {
+ var betreffField = document.getElementById("id_betreff");
+ if (betreffField && !betreffField.value) {
+ betreffField.value = betreff;
+ }
+ }
+ updatePreview();
+ }
+ });
+ toolbar.appendChild(ladeBtn);
+
+ // ---- Editor-Div (WYSIWYG) ----
+ var editor = document.createElement("div");
+ editor.contentEditable = "true";
+ editor.style.cssText = "min-height:300px;padding:12px;font-family:Times New Roman,serif;font-size:11pt;line-height:1.4;outline:none;background:#fff;";
+ editor.innerHTML = textarea.value;
+ editor.addEventListener("input", syncToTextarea);
+ editor.addEventListener("keyup", syncToTextarea);
+
+ // ---- HTML-Textarea (Quelltext) ----
+ textarea.style.cssText += "display:none;width:100%;box-sizing:border-box;border:none;padding:12px;font-family:monospace;font-size:13px;";
+ textarea.addEventListener("input", function () {
+ editor.innerHTML = textarea.value;
+ updatePreview();
+ });
+
+ // ---- Vorschau-Panel ----
+ var preview = document.createElement("div");
+ preview.style.cssText = "display:none;min-height:300px;padding:12px;background:#fff;font-family:'Times New Roman',serif;font-size:11pt;line-height:1.4;";
+
+ // Tab-Logik
+ function showTab(which) {
+ editor.style.display = "none";
+ textarea.style.display = "none";
+ preview.style.display = "none";
+ tabEditor.style.background = "#f5f5f5";
+ tabHtml.style.background = "#f5f5f5";
+ tabVorschau.style.background = "#f5f5f5";
+ if (which === "editor") {
+ editor.style.display = "block";
+ tabEditor.style.background = "#fff";
+ tabEditor.style.fontWeight = "bold";
+ } else if (which === "html") {
+ textarea.style.display = "block";
+ tabHtml.style.background = "#fff";
+ tabHtml.style.fontWeight = "bold";
+ } else {
+ preview.style.display = "block";
+ tabVorschau.style.background = "#fff";
+ tabVorschau.style.fontWeight = "bold";
+ updatePreview();
+ }
+ }
+
+ tabEditor.addEventListener("click", function (e) { e.preventDefault(); showTab("editor"); });
+ tabHtml.addEventListener("click", function (e) {
+ e.preventDefault();
+ syncToTextarea();
+ showTab("html");
+ });
+ tabVorschau.addEventListener("click", function (e) { e.preventDefault(); showTab("vorschau"); });
+
+ // Zusammenbauen
+ wrapper.appendChild(toolbar);
+ wrapper.appendChild(editor);
+ wrapper.appendChild(preview);
+
+ // Textarea hinter Editor platzieren
+ textarea.parentNode.insertBefore(wrapper, textarea);
+ wrapper.appendChild(textarea);
+
+ // Initial: Editor-Tab aktiv
+ showTab("editor");
+
+ // ---- Hilfsfunktionen ----
+ function syncToTextarea() {
+ textarea.value = editor.innerHTML;
+ }
+
+ function updatePreview() {
+ // Platzhalter durch Beispielwerte ersetzen für Vorschau
+ var html = textarea.value;
+ var replacements = {
+ "{{ anrede }}": "Frau",
+ "{{ vorname }}": "Maria",
+ "{{ nachname }}": "Mustermann",
+ "{{ strasse }}": "Musterstraße 12",
+ "{{ plz }}": "46499",
+ "{{ ort }}": "Hamminkeln",
+ "{{ datum }}": "Freitag, 17. April 2026",
+ "{{ uhrzeit }}": "19:00 Uhr",
+ "{{ veranstaltungsort }}": "Marienthaler Gasthof",
+ "{{ gasthaus_adresse }}": "Pastor-Winkelmann-Str. 2, 46499 Hamminkeln",
+ };
+ for (var key in replacements) {
+ html = html.split(key).join(replacements[key]);
+ }
+ preview.innerHTML = html || "Kein Brieftext eingegeben.";
+ }
+
+ function createTabBtn(label, active) {
+ var btn = document.createElement("button");
+ btn.type = "button";
+ btn.textContent = label;
+ btn.style.cssText = "padding:3px 10px;cursor:pointer;border:1px solid #ccc;border-radius:3px;font-size:12px;background:" + (active ? "#fff" : "#f5f5f5") + ";";
+ if (active) btn.style.fontWeight = "bold";
+ return btn;
+ }
+ }
+
+ function loadVorlagen(selectEl) {
+ // Lese Vorlagen über einfachen Admin-API-Aufruf
+ fetch("/admin/stiftung/briefvorlage/?format=json", {
+ headers: { "X-Requested-With": "XMLHttpRequest" }
+ })
+ .then(function (r) { return r.ok ? r.json() : null; })
+ .then(function (data) {
+ if (!data || !data.results) return;
+ data.results.forEach(function (v) {
+ var opt = document.createElement("option");
+ opt.value = v.id || v.pk;
+ opt.textContent = v.name || v.fields && v.fields.name;
+ opt.dataset.briefvorlage = v.briefvorlage || v.fields && v.fields.briefvorlage || "";
+ opt.dataset.betreff = v.betreff || v.fields && v.fields.betreff || "";
+ selectEl.appendChild(opt);
+ });
+ })
+ .catch(function () {
+ // Kein API-Endpunkt – Vorlage-Loader deaktivieren
+ });
+
+ // Alternativ: REST-API
+ fetch("/api/v1/briefvorlagen/", {
+ headers: { "X-Requested-With": "XMLHttpRequest" }
+ })
+ .then(function (r) { return r.ok ? r.json() : null; })
+ .then(function (data) {
+ if (!data) return;
+ var results = Array.isArray(data) ? data : data.results;
+ if (!results) return;
+ // Bereits vorhandene Optionen nicht doppeln
+ var existing = Array.from(selectEl.options).map(function (o) { return o.value; });
+ results.forEach(function (v) {
+ var id = String(v.id || v.pk || "");
+ if (existing.includes(id)) return;
+ var opt = document.createElement("option");
+ opt.value = id;
+ opt.textContent = v.name;
+ opt.dataset.briefvorlage = v.briefvorlage || "";
+ opt.dataset.betreff = v.betreff || "";
+ selectEl.appendChild(opt);
+ });
+ })
+ .catch(function () { /* kein REST-Endpunkt */ });
+ }
+
+})();
diff --git a/app/stiftung/admin.py b/app/stiftung/admin.py
index 97fe515..91921a4 100644
--- a/app/stiftung/admin.py
+++ b/app/stiftung/admin.py
@@ -1,3 +1,4 @@
+from django import forms
from django.contrib import admin
from django.db.models import Count, Sum
from django.urls import reverse
@@ -7,7 +8,7 @@ from django.utils.safestring import mark_safe
from . import models
from .models import (AppConfiguration, AuditLog, BackupJob, BankTransaction,
- CSVImport, Destinataer, DestinataerEmailEingang,
+ BriefVorlage, CSVImport, Destinataer, DestinataerEmailEingang,
DestinataerUnterstuetzung,
DokumentLink, Foerderung, Land, LandVerpachtung, Paechter, Person,
Rentmeister, StiftungsKonto, UnterstuetzungWiederkehrend,
@@ -1238,8 +1239,31 @@ class VeranstaltungsteilnehmerInline(admin.TabularInline):
]
+class BriefVorlageWidget(forms.Textarea):
+ """Erweitertes Textarea-Widget für HTML-Briefvorlagen mit Editor-Panel und Platzhalter-Hilfe."""
+
+ class Media:
+ js = ["stiftung/js/briefvorlage_editor.js"]
+
+ def __init__(self, attrs=None):
+ default_attrs = {"rows": 18, "cols": 80, "class": "briefvorlage-textarea", "style": "font-family: monospace; font-size: 13px;"}
+ if attrs:
+ default_attrs.update(attrs)
+ super().__init__(attrs=default_attrs)
+
+
+class VeranstaltungAdminForm(forms.ModelForm):
+ class Meta:
+ model = Veranstaltung
+ fields = "__all__"
+ widgets = {
+ "briefvorlage": BriefVorlageWidget(),
+ }
+
+
@admin.register(Veranstaltung)
class VeranstaltungAdmin(admin.ModelAdmin):
+ form = VeranstaltungAdminForm
list_display = [
"titel", "datum", "uhrzeit", "ort", "status",
"get_teilnehmer_count", "get_zugesagte_count", "budget_pro_person",
@@ -1247,7 +1271,7 @@ class VeranstaltungAdmin(admin.ModelAdmin):
list_filter = ["status", "datum"]
search_fields = ["titel", "ort", "beschreibung"]
ordering = ["-datum"]
- readonly_fields = ["id", "erstellt_am", "aktualisiert_am", "serienbrief_link"]
+ readonly_fields = ["id", "erstellt_am", "aktualisiert_am", "serienbrief_aktionen", "platzhalter_dokumentation"]
inlines = [VeranstaltungsteilnehmerInline]
fieldsets = (
@@ -1255,19 +1279,22 @@ class VeranstaltungAdmin(admin.ModelAdmin):
("Veranstaltungsort", {"fields": ("ort", "adresse")}),
("Details", {"fields": ("beschreibung", "budget_pro_person")}),
(
- "Serienbrief",
+ "Serienbrief – Vorlage",
+ {
+ "fields": (
+ "platzhalter_dokumentation",
+ "betreff",
+ "briefvorlage",
+ ),
+ },
+ ),
+ (
+ "Serienbrief – Unterschriften & Aktionen",
{
"fields": (
- "betreff", "briefvorlage",
"unterschrift_1_name", "unterschrift_1_titel",
"unterschrift_2_name", "unterschrift_2_titel",
- "serienbrief_link",
- ),
- "description": (
- "Betreff leer = 'Einladung zum [Titel]'. "
- "Platzhalter in der Vorlage: {{ anrede }}, {{ vorname }}, "
- "{{ nachname }}, {{ strasse }}, {{ plz }}, {{ ort_teilnehmer }}, "
- "{{ datum }}, {{ uhrzeit }}, {{ veranstaltungsort }}, {{ gasthaus_adresse }}"
+ "serienbrief_aktionen",
),
},
),
@@ -1282,15 +1309,43 @@ class VeranstaltungAdmin(admin.ModelAdmin):
return obj.get_zugesagte_count()
get_zugesagte_count.short_description = "Zugesagt"
- def serienbrief_link(self, obj):
+ def platzhalter_dokumentation(self, obj):
+ return format_html(
+ """
+
Verfügbare Platzhalter im Brieftext:
+
+ | {{{{ anrede }}}} | Anredetitel (Herr / Frau) |
+ | {{{{ vorname }}}} | Vorname des Empfängers |
+ | {{{{ nachname }}}} | Nachname des Empfängers |
+ | {{{{ strasse }}}} | Straße und Hausnummer |
+ | {{{{ plz }}}} | Postleitzahl |
+ | {{{{ ort }}}} | Wohnort des Empfängers |
+ | {{{{ datum }}}} | Datum der Veranstaltung (z.B. Freitag, 17. April 2026) |
+ | {{{{ uhrzeit }}}} | Uhrzeit der Veranstaltung (z.B. 19:00 Uhr) |
+ | {{{{ veranstaltungsort }}}} | Name des Veranstaltungsorts / Gasthaus |
+ | {{{{ gasthaus_adresse }}}} | Adresse des Gasthauses |
+
+
+ Platzhalter werden beim PDF-Export automatisch mit den Empfänger- und Veranstaltungsdaten befüllt.
+ Tipp: Vorlagen unter
Verwaltung → Briefvorlagen speichern und wiederverwenden.
+
+
"""
+ )
+ platzhalter_dokumentation.short_description = "Platzhalter-Dokumentation"
+ platzhalter_dokumentation.allow_tags = True
+
+ def serienbrief_aktionen(self, obj):
if obj.pk:
from django.urls import reverse as url_reverse
- url = url_reverse("stiftung:veranstaltung_serienbrief_pdf", args=[obj.pk])
+ pdf_url = url_reverse("stiftung:veranstaltung_serienbrief_pdf", args=[obj.pk])
+ vorschau_url = url_reverse("stiftung:veranstaltung_serienbrief_vorschau", args=[obj.pk])
return format_html(
- 'Serienbrief-PDF generieren', url
+ 'Serienbrief-PDF generieren'
+ 'Vorschau im Browser',
+ pdf_url, vorschau_url,
)
return "–"
- serienbrief_link.short_description = "Serienbrief"
+ serienbrief_aktionen.short_description = "Aktionen"
actions = ["generate_serienbrief"]
@@ -1310,6 +1365,34 @@ class VeranstaltungAdmin(admin.ModelAdmin):
generate_serienbrief.short_description = "Serienbrief-PDF generieren"
+@admin.register(BriefVorlage)
+class BriefVorlageAdmin(admin.ModelAdmin):
+ list_display = ["name", "beschreibung_kurz", "erstellt_am", "aktualisiert_am"]
+ search_fields = ["name", "beschreibung"]
+ ordering = ["name"]
+ readonly_fields = ["erstellt_am", "aktualisiert_am"]
+
+ fieldsets = (
+ (None, {"fields": ("name", "beschreibung")}),
+ (
+ "Briefinhalt",
+ {
+ "fields": ("betreff", "briefvorlage"),
+ "description": (
+ "Verfügbare Platzhalter: {{ anrede }}, {{ vorname }}, {{ nachname }}, "
+ "{{ strasse }}, {{ plz }}, {{ ort }}, {{ datum }}, {{ uhrzeit }}, "
+ "{{ veranstaltungsort }}, {{ gasthaus_adresse }}"
+ ),
+ },
+ ),
+ ("System", {"fields": ("erstellt_am", "aktualisiert_am"), "classes": ("collapse",)}),
+ )
+
+ def beschreibung_kurz(self, obj):
+ return obj.beschreibung[:80] + "…" if len(obj.beschreibung) > 80 else obj.beschreibung
+ beschreibung_kurz.short_description = "Beschreibung"
+
+
@admin.register(Veranstaltungsteilnehmer)
class VeranstaltungsteilnehmerAdmin(admin.ModelAdmin):
list_display = [
diff --git a/app/stiftung/forms.py b/app/stiftung/forms.py
index 97b0a28..131dfbe 100644
--- a/app/stiftung/forms.py
+++ b/app/stiftung/forms.py
@@ -8,6 +8,7 @@ from .models import (BankTransaction, Destinataer, DestinataerNotiz,
DestinataerUnterstuetzung, DokumentLink, Foerderung, Land,
LandAbrechnung, LandVerpachtung, Paechter, Person, Rentmeister,
StiftungsKonto, UnterstuetzungWiederkehrend,
+ Veranstaltung, Veranstaltungsteilnehmer,
Verwaltungskosten, VierteljahresNachweis)
@@ -1717,3 +1718,59 @@ class GeschichteBildForm(forms.ModelForm):
'alt_text': 'Wichtig für Barrierefreiheit',
'sortierung': 'Reihenfolge in der Bildergalerie'
}
+
+
+class VeranstaltungForm(forms.ModelForm):
+ """Form für das Erstellen und Bearbeiten von Veranstaltungen inkl. Serienbrief-Felder"""
+
+ class Meta:
+ model = Veranstaltung
+ fields = [
+ "titel", "datum", "uhrzeit", "ort", "adresse",
+ "beschreibung", "status", "budget_pro_person",
+ "betreff", "briefvorlage",
+ "unterschrift_1_name", "unterschrift_1_titel",
+ "unterschrift_2_name", "unterschrift_2_titel",
+ ]
+ widgets = {
+ "titel": forms.TextInput(attrs={"class": "form-control"}),
+ "datum": forms.DateInput(attrs={"class": "form-control", "type": "date"}),
+ "uhrzeit": forms.TimeInput(attrs={"class": "form-control", "type": "time"}),
+ "ort": forms.TextInput(attrs={"class": "form-control"}),
+ "adresse": forms.Textarea(attrs={"class": "form-control", "rows": 2}),
+ "beschreibung": forms.Textarea(attrs={"class": "form-control", "rows": 3}),
+ "status": forms.Select(attrs={"class": "form-select"}),
+ "budget_pro_person": forms.NumberInput(attrs={"class": "form-control", "step": "0.01"}),
+ "betreff": forms.TextInput(attrs={"class": "form-control"}),
+ "briefvorlage": forms.Textarea(attrs={"class": "form-control", "rows": 12}),
+ "unterschrift_1_name": forms.TextInput(attrs={"class": "form-control"}),
+ "unterschrift_1_titel": forms.TextInput(attrs={"class": "form-control"}),
+ "unterschrift_2_name": forms.TextInput(attrs={"class": "form-control"}),
+ "unterschrift_2_titel": forms.TextInput(attrs={"class": "form-control"}),
+ }
+
+
+class VeranstaltungsteilnehmerForm(forms.ModelForm):
+ """Form für das Erstellen und Bearbeiten von Veranstaltungsteilnehmern"""
+
+ class Meta:
+ model = Veranstaltungsteilnehmer
+ fields = [
+ "anrede", "vorname", "nachname",
+ "strasse", "plz", "ort", "email",
+ "rsvp_status", "bemerkungen",
+ "paechter", "destinataer",
+ ]
+ widgets = {
+ "anrede": forms.Select(attrs={"class": "form-select"}),
+ "vorname": forms.TextInput(attrs={"class": "form-control"}),
+ "nachname": forms.TextInput(attrs={"class": "form-control"}),
+ "strasse": forms.TextInput(attrs={"class": "form-control"}),
+ "plz": forms.TextInput(attrs={"class": "form-control"}),
+ "ort": forms.TextInput(attrs={"class": "form-control"}),
+ "email": forms.EmailInput(attrs={"class": "form-control"}),
+ "rsvp_status": forms.Select(attrs={"class": "form-select"}),
+ "bemerkungen": forms.Textarea(attrs={"class": "form-control", "rows": 2}),
+ "paechter": forms.Select(attrs={"class": "form-select"}),
+ "destinataer": forms.Select(attrs={"class": "form-select"}),
+ }
diff --git a/app/stiftung/migrations/0046_briefvorlage_model.py b/app/stiftung/migrations/0046_briefvorlage_model.py
new file mode 100644
index 0000000..cd19118
--- /dev/null
+++ b/app/stiftung/migrations/0046_briefvorlage_model.py
@@ -0,0 +1,30 @@
+# Generated by Django 5.0.6 on 2026-03-10 22:37
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('stiftung', '0045_add_serienbrief_editable_fields'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='BriefVorlage',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('name', models.CharField(max_length=100, verbose_name='Vorlagenname')),
+ ('beschreibung', models.TextField(blank=True, help_text='Kurze Beschreibung des Verwendungszwecks dieser Vorlage.', verbose_name='Beschreibung')),
+ ('briefvorlage', models.TextField(help_text='HTML-Text des Briefs. Verfügbare Platzhalter: {{ anrede }}, {{ vorname }}, {{ nachname }}, {{ strasse }}, {{ plz }}, {{ ort }}, {{ datum }}, {{ uhrzeit }}, {{ veranstaltungsort }}, {{ gasthaus_adresse }}', verbose_name='Brieftext (HTML)')),
+ ('betreff', models.CharField(blank=True, help_text='Wird beim Laden der Vorlage in die Veranstaltung übernommen. Leer = unveränderter Betreff.', max_length=300, verbose_name='Standard-Betreff')),
+ ('erstellt_am', models.DateTimeField(auto_now_add=True)),
+ ('aktualisiert_am', models.DateTimeField(auto_now=True)),
+ ],
+ options={
+ 'verbose_name': 'Briefvorlage',
+ 'verbose_name_plural': 'Briefvorlagen',
+ 'ordering': ['name'],
+ },
+ ),
+ ]
diff --git a/app/stiftung/models.py b/app/stiftung/models.py
index 462ee71..c978e17 100644
--- a/app/stiftung/models.py
+++ b/app/stiftung/models.py
@@ -2154,6 +2154,9 @@ class ApplicationPermission(models.Model):
("manage_backups", "Kann Backups erstellen und verwalten"),
("manage_users", "Kann Benutzer verwalten"),
("manage_permissions", "Kann Berechtigungen verwalten"),
+ # Veranstaltungen Permissions
+ ("manage_veranstaltungen", "Kann Veranstaltungen verwalten"),
+ ("view_veranstaltungen", "Kann Veranstaltungen anzeigen"),
# Import/Export Permissions
("import_data", "Kann Daten importieren"),
("export_data", "Kann Daten exportieren"),
@@ -3281,6 +3284,43 @@ class DestinataerEmailEingang(models.Model):
]
+class BriefVorlage(models.Model):
+ """Wiederverwendbare Briefvorlagen für Serienbriefe (Veranstaltungseinladungen u.ä.)"""
+
+ name = models.CharField(max_length=100, verbose_name="Vorlagenname")
+ beschreibung = models.TextField(
+ blank=True,
+ verbose_name="Beschreibung",
+ help_text="Kurze Beschreibung des Verwendungszwecks dieser Vorlage.",
+ )
+ briefvorlage = models.TextField(
+ verbose_name="Brieftext (HTML)",
+ help_text=(
+ "HTML-Text des Briefs. Verfügbare Platzhalter: "
+ "{{ anrede }}, {{ vorname }}, {{ nachname }}, {{ strasse }}, "
+ "{{ plz }}, {{ ort }}, {{ datum }}, {{ uhrzeit }}, "
+ "{{ veranstaltungsort }}, {{ gasthaus_adresse }}"
+ ),
+ )
+ betreff = models.CharField(
+ max_length=300,
+ blank=True,
+ verbose_name="Standard-Betreff",
+ help_text="Wird beim Laden der Vorlage in die Veranstaltung übernommen. Leer = unveränderter Betreff.",
+ )
+
+ erstellt_am = models.DateTimeField(auto_now_add=True)
+ aktualisiert_am = models.DateTimeField(auto_now=True)
+
+ class Meta:
+ verbose_name = "Briefvorlage"
+ verbose_name_plural = "Briefvorlagen"
+ ordering = ["name"]
+
+ def __str__(self):
+ return self.name
+
+
class Veranstaltung(models.Model):
"""Veranstaltungen der Stiftung, z.B. Stiftungsessen mit Rechnungslegung"""
diff --git a/app/stiftung/urls.py b/app/stiftung/urls.py
index 01277c2..e11f2af 100644
--- a/app/stiftung/urls.py
+++ b/app/stiftung/urls.py
@@ -361,12 +361,36 @@ urlpatterns = [
),
# Veranstaltungsmodul
path("veranstaltungen/", views.veranstaltung_list, name="veranstaltung_list"),
+ path("veranstaltungen/neu/", views.veranstaltung_create, name="veranstaltung_create"),
path("veranstaltungen//", views.veranstaltung_detail, name="veranstaltung_detail"),
+ path("veranstaltungen//bearbeiten/", views.veranstaltung_update, name="veranstaltung_update"),
+ path("veranstaltungen//loeschen/", views.veranstaltung_delete, name="veranstaltung_delete"),
path(
"veranstaltungen//serienbrief/",
views.veranstaltung_serienbrief_pdf,
name="veranstaltung_serienbrief_pdf",
),
+ path(
+ "veranstaltungen//serienbrief-vorschau/",
+ views.veranstaltung_serienbrief_vorschau,
+ name="veranstaltung_serienbrief_vorschau",
+ ),
+ # Teilnehmer CRUD
+ path(
+ "veranstaltungen//teilnehmer/neu/",
+ views.teilnehmer_create,
+ name="teilnehmer_create",
+ ),
+ path(
+ "veranstaltungen//teilnehmer//bearbeiten/",
+ views.teilnehmer_update,
+ name="teilnehmer_update",
+ ),
+ path(
+ "veranstaltungen//teilnehmer//loeschen/",
+ views.teilnehmer_delete,
+ name="teilnehmer_delete",
+ ),
# Gramps integration (probe)
path("api/gramps/search/", views.gramps_search_api, name="gramps_search_api"),
path("api/gramps/debug/", views.gramps_debug_api, name="gramps_debug_api"),
diff --git a/app/stiftung/views.py b/app/stiftung/views.py
index 578ea2f..86ca6d2 100644
--- a/app/stiftung/views.py
+++ b/app/stiftung/views.py
@@ -18,6 +18,7 @@ from django.db.models import (Avg, Count, DecimalField, F, IntegerField, Q,
from django.db.models.functions import Cast, Coalesce, NullIf, Replace
from django.http import HttpResponse, JsonResponse
from django.shortcuts import get_object_or_404, redirect, render
+from django.urls import reverse
from django.utils import timezone
from django.views.decorators.csrf import csrf_exempt
from django_otp.decorators import otp_required
@@ -6430,13 +6431,20 @@ def user_login(request):
log_login(request, user)
- messages.success(request, f"Willkommen zurück, {user.username}!")
-
- # Redirect to safe next URL path or home
+ # Determine redirect target
next_param = request.GET.get("next") or request.POST.get("next")
- if next_param and next_param.startswith("/"):
- return redirect(next_param)
- return redirect("stiftung:home")
+ if not next_param or not next_param.startswith("/"):
+ next_param = reverse("stiftung:home")
+
+ # Check if user has 2FA enabled - redirect to verification first
+ has_2fa = TOTPDevice.objects.filter(user=user, confirmed=True).exists()
+ if has_2fa:
+ from urllib.parse import urlencode
+ verify_url = reverse("stiftung:two_factor_verify") + "?" + urlencode({"next": next_param})
+ return redirect(verify_url)
+
+ messages.success(request, f"Willkommen zurück, {user.username}!")
+ return redirect(next_param)
else:
messages.error(request, "Ungültige Anmeldedaten.")
else:
@@ -8685,3 +8693,153 @@ def veranstaltung_serienbrief_pdf(request, pk):
response = HttpResponse(pdf, content_type="application/pdf")
response["Content-Disposition"] = f'attachment; filename="{filename}"'
return response
+
+
+@login_required
+def veranstaltung_serienbrief_vorschau(request, pk):
+ """HTML-Vorschau des Serienbriefs im Browser (kein PDF-Download)"""
+ veranstaltung = get_object_or_404(Veranstaltung, pk=pk)
+ teilnehmer = veranstaltung.teilnehmer.all().order_by("nachname", "vorname")
+ return render(
+ request,
+ "stiftung/veranstaltung/serienbrief_vorschau.html",
+ {
+ "veranstaltung": veranstaltung,
+ "teilnehmer": teilnehmer,
+ },
+ )
+
+
+@login_required
+def veranstaltung_create(request):
+ """Neue Veranstaltung erstellen"""
+ from .forms import VeranstaltungForm
+
+ if request.method == "POST":
+ form = VeranstaltungForm(request.POST)
+ if form.is_valid():
+ veranstaltung = form.save()
+ messages.success(request, f'Veranstaltung "{veranstaltung.titel}" wurde erstellt.')
+ return redirect("stiftung:veranstaltung_detail", pk=veranstaltung.pk)
+ else:
+ messages.error(request, "Bitte korrigieren Sie die Fehler im Formular.")
+ else:
+ form = VeranstaltungForm()
+
+ return render(request, "stiftung/veranstaltung/form.html", {
+ "form": form,
+ "title": "Neue Veranstaltung erstellen",
+ })
+
+
+@login_required
+def veranstaltung_update(request, pk):
+ """Veranstaltung bearbeiten (inkl. Serienbrief-Felder)"""
+ from .forms import VeranstaltungForm
+
+ veranstaltung = get_object_or_404(Veranstaltung, pk=pk)
+
+ if request.method == "POST":
+ form = VeranstaltungForm(request.POST, instance=veranstaltung)
+ if form.is_valid():
+ form.save()
+ messages.success(request, f'Veranstaltung "{veranstaltung.titel}" wurde aktualisiert.')
+ return redirect("stiftung:veranstaltung_detail", pk=veranstaltung.pk)
+ else:
+ messages.error(request, "Bitte korrigieren Sie die Fehler im Formular.")
+ else:
+ form = VeranstaltungForm(instance=veranstaltung)
+
+ return render(request, "stiftung/veranstaltung/form.html", {
+ "form": form,
+ "veranstaltung": veranstaltung,
+ "title": f"Veranstaltung bearbeiten: {veranstaltung.titel}",
+ })
+
+
+@login_required
+def veranstaltung_delete(request, pk):
+ """Veranstaltung löschen"""
+ veranstaltung = get_object_or_404(Veranstaltung, pk=pk)
+
+ if request.method == "POST":
+ titel = veranstaltung.titel
+ veranstaltung.delete()
+ messages.success(request, f'Veranstaltung "{titel}" wurde gelöscht.')
+ return redirect("stiftung:veranstaltung_list")
+
+ return render(request, "stiftung/veranstaltung/delete.html", {
+ "veranstaltung": veranstaltung,
+ })
+
+
+@login_required
+def teilnehmer_create(request, veranstaltung_pk):
+ """Teilnehmer zu einer Veranstaltung hinzufügen"""
+ from .forms import VeranstaltungsteilnehmerForm
+
+ veranstaltung = get_object_or_404(Veranstaltung, pk=veranstaltung_pk)
+
+ if request.method == "POST":
+ form = VeranstaltungsteilnehmerForm(request.POST)
+ if form.is_valid():
+ teilnehmer = form.save(commit=False)
+ teilnehmer.veranstaltung = veranstaltung
+ teilnehmer.save()
+ messages.success(request, f"{teilnehmer.vorname} {teilnehmer.nachname} wurde hinzugefügt.")
+ return redirect("stiftung:veranstaltung_detail", pk=veranstaltung.pk)
+ else:
+ messages.error(request, "Bitte korrigieren Sie die Fehler im Formular.")
+ else:
+ form = VeranstaltungsteilnehmerForm()
+
+ return render(request, "stiftung/veranstaltung/teilnehmer_form.html", {
+ "form": form,
+ "veranstaltung": veranstaltung,
+ "title": "Teilnehmer hinzufügen",
+ })
+
+
+@login_required
+def teilnehmer_update(request, veranstaltung_pk, pk):
+ """Teilnehmer bearbeiten"""
+ from .forms import VeranstaltungsteilnehmerForm
+
+ veranstaltung = get_object_or_404(Veranstaltung, pk=veranstaltung_pk)
+ teilnehmer = get_object_or_404(Veranstaltungsteilnehmer, pk=pk, veranstaltung=veranstaltung)
+
+ if request.method == "POST":
+ form = VeranstaltungsteilnehmerForm(request.POST, instance=teilnehmer)
+ if form.is_valid():
+ form.save()
+ messages.success(request, f"{teilnehmer.vorname} {teilnehmer.nachname} wurde aktualisiert.")
+ return redirect("stiftung:veranstaltung_detail", pk=veranstaltung.pk)
+ else:
+ messages.error(request, "Bitte korrigieren Sie die Fehler im Formular.")
+ else:
+ form = VeranstaltungsteilnehmerForm(instance=teilnehmer)
+
+ return render(request, "stiftung/veranstaltung/teilnehmer_form.html", {
+ "form": form,
+ "veranstaltung": veranstaltung,
+ "teilnehmer": teilnehmer,
+ "title": f"Teilnehmer bearbeiten: {teilnehmer.vorname} {teilnehmer.nachname}",
+ })
+
+
+@login_required
+def teilnehmer_delete(request, veranstaltung_pk, pk):
+ """Teilnehmer aus Veranstaltung entfernen"""
+ veranstaltung = get_object_or_404(Veranstaltung, pk=veranstaltung_pk)
+ teilnehmer = get_object_or_404(Veranstaltungsteilnehmer, pk=pk, veranstaltung=veranstaltung)
+
+ if request.method == "POST":
+ name = f"{teilnehmer.vorname} {teilnehmer.nachname}"
+ teilnehmer.delete()
+ messages.success(request, f"{name} wurde aus der Teilnehmerliste entfernt.")
+ return redirect("stiftung:veranstaltung_detail", pk=veranstaltung.pk)
+
+ return render(request, "stiftung/veranstaltung/teilnehmer_delete.html", {
+ "veranstaltung": veranstaltung,
+ "teilnehmer": teilnehmer,
+ })
diff --git a/app/templates/stiftung/veranstaltung/delete.html b/app/templates/stiftung/veranstaltung/delete.html
new file mode 100644
index 0000000..175bb94
--- /dev/null
+++ b/app/templates/stiftung/veranstaltung/delete.html
@@ -0,0 +1,36 @@
+{% extends 'base.html' %}
+
+{% block title %}Veranstaltung löschen – Stiftungsverwaltung{% endblock %}
+
+{% block content %}
+
+
+
+
+
+
+
Möchten Sie die folgende Veranstaltung wirklich löschen?
+
+ {{ veranstaltung.titel }}
+ {{ veranstaltung.datum|date:"d.m.Y" }} – {{ veranstaltung.ort }}
+ {{ veranstaltung.get_teilnehmer_count }} Teilnehmer werden ebenfalls entfernt.
+
+
+
+
+
+
+
+{% endblock %}
diff --git a/app/templates/stiftung/veranstaltung/detail.html b/app/templates/stiftung/veranstaltung/detail.html
index 911aac9..f4526a0 100644
--- a/app/templates/stiftung/veranstaltung/detail.html
+++ b/app/templates/stiftung/veranstaltung/detail.html
@@ -20,7 +20,7 @@
-
+
Bearbeiten
@@ -125,11 +125,15 @@
class="btn btn-success w-100">
Serienbrief-PDF (alle Teilnehmer)
-
+ Serienbrief-Vorschau
+
+
Teilnehmer hinzufügen
-
Veranstaltung bearbeiten
@@ -142,6 +146,9 @@
{% if teilnehmer %}
@@ -153,6 +160,7 @@
E-Mail |
RSVP |
Bemerkungen |
+
Aktionen |
@@ -179,6 +187,14 @@
{% endif %}
{{ t.bemerkungen|default:"–" }} |
+
+
+
+
+
+
+
+ |
{% endfor %}
@@ -187,7 +203,7 @@
Noch keine Teilnehmer eingetragen.
-
Ersten Teilnehmer hinzufügen
diff --git a/app/templates/stiftung/veranstaltung/form.html b/app/templates/stiftung/veranstaltung/form.html
new file mode 100644
index 0000000..379139f
--- /dev/null
+++ b/app/templates/stiftung/veranstaltung/form.html
@@ -0,0 +1,200 @@
+{% extends 'base.html' %}
+{% load static %}
+
+{% block title %}{{ title }} - Stiftungsverwaltung{% endblock %}
+
+{% block content %}
+
+{% endblock %}
diff --git a/app/templates/stiftung/veranstaltung/list.html b/app/templates/stiftung/veranstaltung/list.html
index 6c24fee..bd5b09e 100644
--- a/app/templates/stiftung/veranstaltung/list.html
+++ b/app/templates/stiftung/veranstaltung/list.html
@@ -8,7 +8,7 @@
Veranstaltungen
-
+
Neue Veranstaltung
@@ -68,7 +68,7 @@
{% else %}
{% endif %}
diff --git a/app/templates/stiftung/veranstaltung/serienbrief_vorschau.html b/app/templates/stiftung/veranstaltung/serienbrief_vorschau.html
new file mode 100644
index 0000000..f1c6dea
--- /dev/null
+++ b/app/templates/stiftung/veranstaltung/serienbrief_vorschau.html
@@ -0,0 +1,134 @@
+{% extends "base.html" %}
+{% block title %}Serienbrief-Vorschau – {{ veranstaltung.titel }}{% endblock %}
+
+{% block content %}
+
+
+
+
+ Serienbrief-Vorschau
+ {{ veranstaltung.titel }} ({{ veranstaltung.datum|date:"j. F Y" }})
+
+
+
+
+ {% if not teilnehmer %}
+
+ Diese Veranstaltung hat noch keine Teilnehmer. Bitte zuerst Teilnehmer anlegen.
+
+ {% else %}
+
+
+
ℹ️
+
+ {{ teilnehmer.count }} Brief{% if teilnehmer.count != 1 %}e{% endif %} werden generiert.
+ Die Vorschau zeigt jeden Brief auf einer separaten Seite.
+ Platzhalter wie {% verbatim %}{{ vorname }}{% endverbatim %} sind hier bereits durch Beispieldaten ersetzt.
+
+
+
+
+
+
+
+ {% for t in teilnehmer %}
+
+
+
+
van Hees-Theyssen-Vogel'sche Stiftung
+
+ Raesfelder Str. 3 · 46499 Hamminkeln
+
+
+
+
+
+ van Hees-Theyssen-Vogel'sche Stiftung · Raesfelder Str. 3 · 46499 Hamminkeln
+
+
{{ t.anrede }} {{ t.vorname }} {{ t.nachname }}
+ {% if t.strasse %}
{{ t.strasse }}
{% endif %}
+ {% if t.plz or t.ort %}
{{ t.plz }} {{ t.ort }}
{% endif %}
+
+
+
+
+ Hamminkeln, den {{ veranstaltung.datum|date:"j. F Y" }}
+
+
+
+
+ {% if veranstaltung.betreff %}{{ veranstaltung.betreff }}{% else %}Einladung zum {{ veranstaltung.titel }}{% endif %}
+
+
+
+
+ Sehr geehrte{% if t.anrede == "Herr" %}r Herr{% elif t.anrede == "Frau" %} Frau{% else %}
+ {{ t.anrede }}{% endif %} {{ t.nachname }},
+
+
+
+
+ {% if veranstaltung.briefvorlage %}
+ {{ veranstaltung.briefvorlage|safe }}
+ {% else %}
+
wir laden Sie herzlich ein, an der jährlichen Vorstellung der Rechnungslegung
+ der van Hees-Theyssen-Vogel'schen Stiftung teilzunehmen.
+
Die Veranstaltung findet statt am:
+
+ {{ veranstaltung.datum|date:"l, j. F Y" }}{% if veranstaltung.uhrzeit %}, {{ veranstaltung.uhrzeit|time:"H:i" }} Uhr{% endif %}
+ {{ veranstaltung.ort }}
+ {% if veranstaltung.adresse %}{{ veranstaltung.adresse }}{% endif %}
+
+
Bitte teilen Sie uns Ihre Teilnahme bis zum 4. April 2026 mit.
+
Wir freuen uns auf Ihr Kommen.
+ {% endif %}
+
Mit freundlichen Grüßen
+
+
+
+
+
+ {% if veranstaltung.unterschrift_1_name %}
+
+ {{ veranstaltung.unterschrift_1_name }}
+ {{ veranstaltung.unterschrift_1_titel }}
+ van Hees-Theyssen-Vogel'sche Stiftung
+ {% endif %}
+
+
+ {% if veranstaltung.unterschrift_2_name %}
+
+ {{ veranstaltung.unterschrift_2_name }}
+ {{ veranstaltung.unterschrift_2_titel }}
+ van Hees-Theyssen-Vogel'sche Stiftung
+ {% endif %}
+
+
+
+
+ Brief {{ forloop.counter }} von {{ teilnehmer.count }}
+
+
+ {% endfor %}
+
+ {% endif %}
+
+{% endblock %}
diff --git a/app/templates/stiftung/veranstaltung/teilnehmer_delete.html b/app/templates/stiftung/veranstaltung/teilnehmer_delete.html
new file mode 100644
index 0000000..82787a6
--- /dev/null
+++ b/app/templates/stiftung/veranstaltung/teilnehmer_delete.html
@@ -0,0 +1,35 @@
+{% extends 'base.html' %}
+
+{% block title %}Teilnehmer entfernen – Stiftungsverwaltung{% endblock %}
+
+{% block content %}
+
+
+
+
+
+
+
Möchten Sie den folgenden Teilnehmer wirklich aus der Veranstaltung entfernen?
+
+ {{ teilnehmer.anrede }} {{ teilnehmer.vorname }} {{ teilnehmer.nachname }}
+ Veranstaltung: {{ veranstaltung.titel }}
+
+
+
+
+
+
+
+{% endblock %}
diff --git a/app/templates/stiftung/veranstaltung/teilnehmer_form.html b/app/templates/stiftung/veranstaltung/teilnehmer_form.html
new file mode 100644
index 0000000..2a829a3
--- /dev/null
+++ b/app/templates/stiftung/veranstaltung/teilnehmer_form.html
@@ -0,0 +1,119 @@
+{% extends 'base.html' %}
+
+{% block title %}{{ title }} – Stiftungsverwaltung{% endblock %}
+
+{% block content %}
+
+{% endblock %}