From 709903e62788fdef4d06c1ac5edba9e200632277 Mon Sep 17 00:00:00 2001 From: SysAdmin Agent Date: Wed, 11 Mar 2026 08:51:48 +0000 Subject: [PATCH] =?UTF-8?q?Baseline=20f=C3=BCr=20Vision=202026:=20Veransta?= =?UTF-8?q?ltungsmodul=20+=20ausstehende=20=C3=84nderungen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Alle bestehenden, nicht commiteten Änderungen als Ausgangsbasis für den vision-2026 Branch übernommen (Veranstaltungsmodul, Serienbrief, etc.). Co-Authored-By: Claude Opus 4.6 --- app/core/settings.py | 2 +- app/static/stiftung/js/briefvorlage_editor.js | 250 ++++++++++++++++++ app/stiftung/admin.py | 113 ++++++-- app/stiftung/forms.py | 57 ++++ .../migrations/0046_briefvorlage_model.py | 30 +++ app/stiftung/models.py | 40 +++ app/stiftung/urls.py | 24 ++ app/stiftung/views.py | 170 +++++++++++- .../stiftung/veranstaltung/delete.html | 36 +++ .../stiftung/veranstaltung/detail.html | 24 +- .../stiftung/veranstaltung/form.html | 200 ++++++++++++++ .../stiftung/veranstaltung/list.html | 4 +- .../veranstaltung/serienbrief_vorschau.html | 134 ++++++++++ .../veranstaltung/teilnehmer_delete.html | 35 +++ .../veranstaltung/teilnehmer_form.html | 119 +++++++++ 15 files changed, 1210 insertions(+), 28 deletions(-) create mode 100644 app/static/stiftung/js/briefvorlage_editor.js create mode 100644 app/stiftung/migrations/0046_briefvorlage_model.py create mode 100644 app/templates/stiftung/veranstaltung/delete.html create mode 100644 app/templates/stiftung/veranstaltung/form.html create mode 100644 app/templates/stiftung/veranstaltung/serienbrief_vorschau.html create mode 100644 app/templates/stiftung/veranstaltung/teilnehmer_delete.html create mode 100644 app/templates/stiftung/veranstaltung/teilnehmer_form.html 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 %} +
+
+
+
+
+ Veranstaltung löschen +
+
+

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. +
+
+ {% csrf_token %} +
+ + Abbrechen + + +
+
+
+
+
+
+
+{% 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 @@
Teilnehmerliste ({{ teilnehmer.count }}) + + Hinzufügen +
{% 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 %} +
+
+
+
+

+ + {{ title }} +

+ + Zurück zur Liste + +
+
+
+ +
+ {% csrf_token %} + + {% if form.non_field_errors %} +
+
    + {% for error in form.non_field_errors %} +
  • {{ error }}
  • + {% endfor %} +
+
+ {% endif %} + +
+
+ +
+
+ Grunddaten +
+
+
+
+ + {{ form.titel }} + {% if form.titel.errors %}
{{ form.titel.errors.0 }}
{% endif %} +
+
+ + {{ form.datum }} + {% if form.datum.errors %}
{{ form.datum.errors.0 }}
{% endif %} +
+
+ + {{ form.uhrzeit }} + {% if form.uhrzeit.errors %}
{{ form.uhrzeit.errors.0 }}
{% endif %} +
+
+ + {{ form.status }} +
+
+ + {{ form.ort }} + {% if form.ort.errors %}
{{ form.ort.errors.0 }}
{% endif %} +
+
+ + {{ form.budget_pro_person }} +
+
+ + {{ form.adresse }} +
+
+ + {{ form.beschreibung }} +
+
+
+
+ + +
+
+ Serienbrief – Vorlage +
+
+
+ + {{ form.betreff }} +
{{ form.betreff.help_text }}
+
+
+ + {{ form.briefvorlage }} + {% if form.briefvorlage.errors %}
{{ form.briefvorlage.errors.0 }}
{% endif %} +
{{ form.briefvorlage.help_text }}
+
+
+
+ + +
+
+ Serienbrief – Unterschriften +
+
+
+
+ + {{ form.unterschrift_1_name }} +
+
+ + {{ form.unterschrift_1_titel }} +
+
+ + {{ form.unterschrift_2_name }} +
+
+ + {{ form.unterschrift_2_titel }} +
+
+
+
+
+ + +
+
+
+ Platzhalter-Hilfe +
+
+

Verfügbare Platzhalter für die Briefvorlage:

+ + + + + + + + + + + + + +
{{ anrede }}Herr / Frau
{{ vorname }}Vorname
{{ nachname }}Nachname
{{ strasse }}Straße + Nr.
{{ plz }}PLZ
{{ ort }}Wohnort
{{ datum }}Veranstaltungsdatum
{{ uhrzeit }}Uhrzeit
{{ veranstaltungsort }}Gasthaus / Ort
{{ gasthaus_adresse }}Adresse Gasthaus
+

Platzhalter werden beim PDF-Export automatisch befüllt.

+
+
+ + {% if veranstaltung %} + + {% endif %} +
+
+ + +
+
+
+
+ + Abbrechen + + +
+
+
+
+
+{% 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. +
+
+ + +
+ Empfänger: + {% for t in teilnehmer %} + + {{ t.nachname }}, {{ t.vorname }} + + {% endfor %} +
+ + + {% 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 %} +
+
+
+
+
+ Teilnehmer entfernen +
+
+

Möchten Sie den folgenden Teilnehmer wirklich aus der Veranstaltung entfernen?

+
+ {{ teilnehmer.anrede }} {{ teilnehmer.vorname }} {{ teilnehmer.nachname }}
+ Veranstaltung: {{ veranstaltung.titel }} +
+
+ {% csrf_token %} +
+ + Abbrechen + + +
+
+
+
+
+
+
+{% 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 %} +
+
+
+ +

+ {{ title }} +

+
+
+ +
+
+
+
+ Teilnehmerdaten +
+
+
+ {% csrf_token %} + + {% if form.non_field_errors %} +
+
    + {% for error in form.non_field_errors %} +
  • {{ error }}
  • + {% endfor %} +
+
+ {% endif %} + +
+
+ + {{ form.anrede }} +
+
+ + {{ form.vorname }} + {% if form.vorname.errors %}
{{ form.vorname.errors.0 }}
{% endif %} +
+
+ + {{ form.nachname }} + {% if form.nachname.errors %}
{{ form.nachname.errors.0 }}
{% endif %} +
+
+ +
+
+ + {{ form.strasse }} +
+
+ + {{ form.plz }} +
+
+ + {{ form.ort }} +
+
+ +
+
+ + {{ form.email }} +
+
+ + {{ form.rsvp_status }} +
+
+ +
+
+ + {{ form.paechter }} +
Optional: Verknüpfung mit bestehendem Pächter
+
+
+ + {{ form.destinataer }} +
Optional: Verknüpfung mit bestehendem Destinatär
+
+
+ +
+ + {{ form.bemerkungen }} +
+ +
+
+ + Abbrechen + + +
+
+
+
+
+
+
+{% endblock %}