Baseline für Vision 2026: Veranstaltungsmodul + ausstehende Änderungen

Alle bestehenden, nicht commiteten Änderungen als Ausgangsbasis für den
vision-2026 Branch übernommen (Veranstaltungsmodul, Serienbrief, etc.).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
SysAdmin Agent
2026-03-11 08:51:48 +00:00
parent 28621d2774
commit 709903e627
15 changed files with 1210 additions and 28 deletions

View File

@@ -177,7 +177,7 @@ if not DEBUG:
# django-otp settings # django-otp settings
OTP_TOTP_ISSUER = 'Stiftung Management System' 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 # Optional: Hide sensitive data in admin when not verified
OTP_ADMIN_HIDE_SENSITIVE_DATA = True OTP_ADMIN_HIDE_SENSITIVE_DATA = True

View File

@@ -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 || "<em style='color:#999;'>Kein Brieftext eingegeben.</em>";
}
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 */ });
}
})();

View File

@@ -1,3 +1,4 @@
from django import forms
from django.contrib import admin from django.contrib import admin
from django.db.models import Count, Sum from django.db.models import Count, Sum
from django.urls import reverse from django.urls import reverse
@@ -7,7 +8,7 @@ from django.utils.safestring import mark_safe
from . import models from . import models
from .models import (AppConfiguration, AuditLog, BackupJob, BankTransaction, from .models import (AppConfiguration, AuditLog, BackupJob, BankTransaction,
CSVImport, Destinataer, DestinataerEmailEingang, BriefVorlage, CSVImport, Destinataer, DestinataerEmailEingang,
DestinataerUnterstuetzung, DestinataerUnterstuetzung,
DokumentLink, Foerderung, Land, LandVerpachtung, Paechter, Person, DokumentLink, Foerderung, Land, LandVerpachtung, Paechter, Person,
Rentmeister, StiftungsKonto, UnterstuetzungWiederkehrend, 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) @admin.register(Veranstaltung)
class VeranstaltungAdmin(admin.ModelAdmin): class VeranstaltungAdmin(admin.ModelAdmin):
form = VeranstaltungAdminForm
list_display = [ list_display = [
"titel", "datum", "uhrzeit", "ort", "status", "titel", "datum", "uhrzeit", "ort", "status",
"get_teilnehmer_count", "get_zugesagte_count", "budget_pro_person", "get_teilnehmer_count", "get_zugesagte_count", "budget_pro_person",
@@ -1247,7 +1271,7 @@ class VeranstaltungAdmin(admin.ModelAdmin):
list_filter = ["status", "datum"] list_filter = ["status", "datum"]
search_fields = ["titel", "ort", "beschreibung"] search_fields = ["titel", "ort", "beschreibung"]
ordering = ["-datum"] ordering = ["-datum"]
readonly_fields = ["id", "erstellt_am", "aktualisiert_am", "serienbrief_link"] readonly_fields = ["id", "erstellt_am", "aktualisiert_am", "serienbrief_aktionen", "platzhalter_dokumentation"]
inlines = [VeranstaltungsteilnehmerInline] inlines = [VeranstaltungsteilnehmerInline]
fieldsets = ( fieldsets = (
@@ -1255,19 +1279,22 @@ class VeranstaltungAdmin(admin.ModelAdmin):
("Veranstaltungsort", {"fields": ("ort", "adresse")}), ("Veranstaltungsort", {"fields": ("ort", "adresse")}),
("Details", {"fields": ("beschreibung", "budget_pro_person")}), ("Details", {"fields": ("beschreibung", "budget_pro_person")}),
( (
"Serienbrief", "Serienbrief Vorlage",
{
"fields": (
"platzhalter_dokumentation",
"betreff",
"briefvorlage",
),
},
),
(
"Serienbrief Unterschriften & Aktionen",
{ {
"fields": ( "fields": (
"betreff", "briefvorlage",
"unterschrift_1_name", "unterschrift_1_titel", "unterschrift_1_name", "unterschrift_1_titel",
"unterschrift_2_name", "unterschrift_2_titel", "unterschrift_2_name", "unterschrift_2_titel",
"serienbrief_link", "serienbrief_aktionen",
),
"description": (
"Betreff leer = 'Einladung zum [Titel]'. "
"Platzhalter in der Vorlage: {{ anrede }}, {{ vorname }}, "
"{{ nachname }}, {{ strasse }}, {{ plz }}, {{ ort_teilnehmer }}, "
"{{ datum }}, {{ uhrzeit }}, {{ veranstaltungsort }}, {{ gasthaus_adresse }}"
), ),
}, },
), ),
@@ -1282,15 +1309,43 @@ class VeranstaltungAdmin(admin.ModelAdmin):
return obj.get_zugesagte_count() return obj.get_zugesagte_count()
get_zugesagte_count.short_description = "Zugesagt" get_zugesagte_count.short_description = "Zugesagt"
def serienbrief_link(self, obj): def platzhalter_dokumentation(self, obj):
return format_html(
"""<div class="help" style="background:#f8f9fa;border:1px solid #dee2e6;border-radius:4px;padding:10px 14px;margin-bottom:4px;">
<strong>Verfügbare Platzhalter im Brieftext:</strong><br>
<table style="margin-top:6px;border-collapse:collapse;font-size:13px;">
<tr><td style="padding:2px 12px 2px 0;font-family:monospace;color:#d63384;">{{{{ anrede }}}}</td><td>Anredetitel (Herr / Frau)</td></tr>
<tr><td style="padding:2px 12px 2px 0;font-family:monospace;color:#d63384;">{{{{ vorname }}}}</td><td>Vorname des Empfängers</td></tr>
<tr><td style="padding:2px 12px 2px 0;font-family:monospace;color:#d63384;">{{{{ nachname }}}}</td><td>Nachname des Empfängers</td></tr>
<tr><td style="padding:2px 12px 2px 0;font-family:monospace;color:#d63384;">{{{{ strasse }}}}</td><td>Straße und Hausnummer</td></tr>
<tr><td style="padding:2px 12px 2px 0;font-family:monospace;color:#d63384;">{{{{ plz }}}}</td><td>Postleitzahl</td></tr>
<tr><td style="padding:2px 12px 2px 0;font-family:monospace;color:#d63384;">{{{{ ort }}}}</td><td>Wohnort des Empfängers</td></tr>
<tr><td style="padding:2px 12px 2px 0;font-family:monospace;color:#d63384;">{{{{ datum }}}}</td><td>Datum der Veranstaltung (z.B. Freitag, 17. April 2026)</td></tr>
<tr><td style="padding:2px 12px 2px 0;font-family:monospace;color:#d63384;">{{{{ uhrzeit }}}}</td><td>Uhrzeit der Veranstaltung (z.B. 19:00 Uhr)</td></tr>
<tr><td style="padding:2px 12px 2px 0;font-family:monospace;color:#d63384;">{{{{ veranstaltungsort }}}}</td><td>Name des Veranstaltungsorts / Gasthaus</td></tr>
<tr><td style="padding:2px 12px 2px 0;font-family:monospace;color:#d63384;">{{{{ gasthaus_adresse }}}}</td><td>Adresse des Gasthauses</td></tr>
</table>
<div style="margin-top:8px;font-size:12px;color:#6c757d;">
Platzhalter werden beim PDF-Export automatisch mit den Empfänger- und Veranstaltungsdaten befüllt.
Tipp: Vorlagen unter <a href="/admin/stiftung/briefvorlage/" target="_blank">Verwaltung → Briefvorlagen</a> speichern und wiederverwenden.
</div>
</div>"""
)
platzhalter_dokumentation.short_description = "Platzhalter-Dokumentation"
platzhalter_dokumentation.allow_tags = True
def serienbrief_aktionen(self, obj):
if obj.pk: if obj.pk:
from django.urls import reverse as url_reverse 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( return format_html(
'<a href="{}" target="_blank" class="button">Serienbrief-PDF generieren</a>', url '<a href="{}" target="_blank" class="button" style="margin-right:8px;">Serienbrief-PDF generieren</a>'
'<a href="{}" target="_blank" class="button default">Vorschau im Browser</a>',
pdf_url, vorschau_url,
) )
return "" return ""
serienbrief_link.short_description = "Serienbrief" serienbrief_aktionen.short_description = "Aktionen"
actions = ["generate_serienbrief"] actions = ["generate_serienbrief"]
@@ -1310,6 +1365,34 @@ class VeranstaltungAdmin(admin.ModelAdmin):
generate_serienbrief.short_description = "Serienbrief-PDF generieren" 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) @admin.register(Veranstaltungsteilnehmer)
class VeranstaltungsteilnehmerAdmin(admin.ModelAdmin): class VeranstaltungsteilnehmerAdmin(admin.ModelAdmin):
list_display = [ list_display = [

View File

@@ -8,6 +8,7 @@ from .models import (BankTransaction, Destinataer, DestinataerNotiz,
DestinataerUnterstuetzung, DokumentLink, Foerderung, Land, DestinataerUnterstuetzung, DokumentLink, Foerderung, Land,
LandAbrechnung, LandVerpachtung, Paechter, Person, Rentmeister, LandAbrechnung, LandVerpachtung, Paechter, Person, Rentmeister,
StiftungsKonto, UnterstuetzungWiederkehrend, StiftungsKonto, UnterstuetzungWiederkehrend,
Veranstaltung, Veranstaltungsteilnehmer,
Verwaltungskosten, VierteljahresNachweis) Verwaltungskosten, VierteljahresNachweis)
@@ -1717,3 +1718,59 @@ class GeschichteBildForm(forms.ModelForm):
'alt_text': 'Wichtig für Barrierefreiheit', 'alt_text': 'Wichtig für Barrierefreiheit',
'sortierung': 'Reihenfolge in der Bildergalerie' '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"}),
}

View File

@@ -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'],
},
),
]

View File

@@ -2154,6 +2154,9 @@ class ApplicationPermission(models.Model):
("manage_backups", "Kann Backups erstellen und verwalten"), ("manage_backups", "Kann Backups erstellen und verwalten"),
("manage_users", "Kann Benutzer verwalten"), ("manage_users", "Kann Benutzer verwalten"),
("manage_permissions", "Kann Berechtigungen verwalten"), ("manage_permissions", "Kann Berechtigungen verwalten"),
# Veranstaltungen Permissions
("manage_veranstaltungen", "Kann Veranstaltungen verwalten"),
("view_veranstaltungen", "Kann Veranstaltungen anzeigen"),
# Import/Export Permissions # Import/Export Permissions
("import_data", "Kann Daten importieren"), ("import_data", "Kann Daten importieren"),
("export_data", "Kann Daten exportieren"), ("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): class Veranstaltung(models.Model):
"""Veranstaltungen der Stiftung, z.B. Stiftungsessen mit Rechnungslegung""" """Veranstaltungen der Stiftung, z.B. Stiftungsessen mit Rechnungslegung"""

View File

@@ -361,12 +361,36 @@ urlpatterns = [
), ),
# Veranstaltungsmodul # Veranstaltungsmodul
path("veranstaltungen/", views.veranstaltung_list, name="veranstaltung_list"), path("veranstaltungen/", views.veranstaltung_list, name="veranstaltung_list"),
path("veranstaltungen/neu/", views.veranstaltung_create, name="veranstaltung_create"),
path("veranstaltungen/<uuid:pk>/", views.veranstaltung_detail, name="veranstaltung_detail"), path("veranstaltungen/<uuid:pk>/", views.veranstaltung_detail, name="veranstaltung_detail"),
path("veranstaltungen/<uuid:pk>/bearbeiten/", views.veranstaltung_update, name="veranstaltung_update"),
path("veranstaltungen/<uuid:pk>/loeschen/", views.veranstaltung_delete, name="veranstaltung_delete"),
path( path(
"veranstaltungen/<uuid:pk>/serienbrief/", "veranstaltungen/<uuid:pk>/serienbrief/",
views.veranstaltung_serienbrief_pdf, views.veranstaltung_serienbrief_pdf,
name="veranstaltung_serienbrief_pdf", name="veranstaltung_serienbrief_pdf",
), ),
path(
"veranstaltungen/<uuid:pk>/serienbrief-vorschau/",
views.veranstaltung_serienbrief_vorschau,
name="veranstaltung_serienbrief_vorschau",
),
# Teilnehmer CRUD
path(
"veranstaltungen/<uuid:veranstaltung_pk>/teilnehmer/neu/",
views.teilnehmer_create,
name="teilnehmer_create",
),
path(
"veranstaltungen/<uuid:veranstaltung_pk>/teilnehmer/<uuid:pk>/bearbeiten/",
views.teilnehmer_update,
name="teilnehmer_update",
),
path(
"veranstaltungen/<uuid:veranstaltung_pk>/teilnehmer/<uuid:pk>/loeschen/",
views.teilnehmer_delete,
name="teilnehmer_delete",
),
# Gramps integration (probe) # Gramps integration (probe)
path("api/gramps/search/", views.gramps_search_api, name="gramps_search_api"), path("api/gramps/search/", views.gramps_search_api, name="gramps_search_api"),
path("api/gramps/debug/", views.gramps_debug_api, name="gramps_debug_api"), path("api/gramps/debug/", views.gramps_debug_api, name="gramps_debug_api"),

View File

@@ -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.db.models.functions import Cast, Coalesce, NullIf, Replace
from django.http import HttpResponse, JsonResponse from django.http import HttpResponse, JsonResponse
from django.shortcuts import get_object_or_404, redirect, render from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from django.views.decorators.csrf import csrf_exempt from django.views.decorators.csrf import csrf_exempt
from django_otp.decorators import otp_required from django_otp.decorators import otp_required
@@ -6430,13 +6431,20 @@ def user_login(request):
log_login(request, user) log_login(request, user)
messages.success(request, f"Willkommen zurück, {user.username}!") # Determine redirect target
# Redirect to safe next URL path or home
next_param = request.GET.get("next") or request.POST.get("next") next_param = request.GET.get("next") or request.POST.get("next")
if next_param and next_param.startswith("/"): if not next_param or not next_param.startswith("/"):
return redirect(next_param) next_param = reverse("stiftung:home")
return redirect("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: else:
messages.error(request, "Ungültige Anmeldedaten.") messages.error(request, "Ungültige Anmeldedaten.")
else: else:
@@ -8685,3 +8693,153 @@ def veranstaltung_serienbrief_pdf(request, pk):
response = HttpResponse(pdf, content_type="application/pdf") response = HttpResponse(pdf, content_type="application/pdf")
response["Content-Disposition"] = f'attachment; filename="{filename}"' response["Content-Disposition"] = f'attachment; filename="{filename}"'
return response 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,
})

View File

@@ -0,0 +1,36 @@
{% extends 'base.html' %}
{% block title %}Veranstaltung löschen Stiftungsverwaltung{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<div class="row justify-content-center">
<div class="col-lg-6">
<div class="card shadow-sm">
<div class="card-header bg-danger text-white">
<i class="fas fa-exclamation-triangle me-2"></i>Veranstaltung löschen
</div>
<div class="card-body">
<p>Möchten Sie die folgende Veranstaltung wirklich löschen?</p>
<div class="alert alert-warning">
<strong>{{ veranstaltung.titel }}</strong><br>
{{ veranstaltung.datum|date:"d.m.Y" }} {{ veranstaltung.ort }}<br>
<small class="text-muted">{{ veranstaltung.get_teilnehmer_count }} Teilnehmer werden ebenfalls entfernt.</small>
</div>
<form method="post">
{% csrf_token %}
<div class="d-flex justify-content-between">
<a href="{% url 'stiftung:veranstaltung_detail' veranstaltung.pk %}" class="btn btn-secondary">
<i class="fas fa-arrow-left me-2"></i>Abbrechen
</a>
<button type="submit" class="btn btn-danger">
<i class="fas fa-trash me-2"></i>Endgültig löschen
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -20,7 +20,7 @@
</p> </p>
</div> </div>
<div class="d-flex gap-2"> <div class="d-flex gap-2">
<a href="{% url 'admin:stiftung_veranstaltung_change' veranstaltung.pk %}" class="btn btn-outline-secondary"> <a href="{% url 'stiftung:veranstaltung_update' veranstaltung.pk %}" class="btn btn-outline-secondary">
<i class="fas fa-edit me-1"></i>Bearbeiten <i class="fas fa-edit me-1"></i>Bearbeiten
</a> </a>
<a href="{% url 'stiftung:veranstaltung_serienbrief_pdf' veranstaltung.pk %}" class="btn btn-success"> <a href="{% url 'stiftung:veranstaltung_serienbrief_pdf' veranstaltung.pk %}" class="btn btn-success">
@@ -125,11 +125,15 @@
class="btn btn-success w-100"> class="btn btn-success w-100">
<i class="fas fa-file-pdf me-2"></i>Serienbrief-PDF (alle Teilnehmer) <i class="fas fa-file-pdf me-2"></i>Serienbrief-PDF (alle Teilnehmer)
</a> </a>
<a href="{% url 'admin:stiftung_veranstaltungsteilnehmer_add' %}?veranstaltung={{ veranstaltung.pk }}" <a href="{% url 'stiftung:veranstaltung_serienbrief_vorschau' veranstaltung.pk %}"
class="btn btn-outline-primary w-100" target="_blank">
<i class="fas fa-eye me-2"></i>Serienbrief-Vorschau
</a>
<a href="{% url 'stiftung:teilnehmer_create' veranstaltung.pk %}"
class="btn btn-outline-primary w-100"> class="btn btn-outline-primary w-100">
<i class="fas fa-user-plus me-2"></i>Teilnehmer hinzufügen <i class="fas fa-user-plus me-2"></i>Teilnehmer hinzufügen
</a> </a>
<a href="{% url 'admin:stiftung_veranstaltung_change' veranstaltung.pk %}" <a href="{% url 'stiftung:veranstaltung_update' veranstaltung.pk %}"
class="btn btn-outline-secondary w-100"> class="btn btn-outline-secondary w-100">
<i class="fas fa-edit me-2"></i>Veranstaltung bearbeiten <i class="fas fa-edit me-2"></i>Veranstaltung bearbeiten
</a> </a>
@@ -142,6 +146,9 @@
<div class="card shadow-sm mt-4"> <div class="card shadow-sm mt-4">
<div class="card-header bg-dark text-white d-flex justify-content-between align-items-center"> <div class="card-header bg-dark text-white d-flex justify-content-between align-items-center">
<span><i class="fas fa-users me-2"></i>Teilnehmerliste ({{ teilnehmer.count }})</span> <span><i class="fas fa-users me-2"></i>Teilnehmerliste ({{ teilnehmer.count }})</span>
<a href="{% url 'stiftung:teilnehmer_create' veranstaltung.pk %}" class="btn btn-sm btn-outline-light">
<i class="fas fa-user-plus me-1"></i>Hinzufügen
</a>
</div> </div>
<div class="card-body p-0"> <div class="card-body p-0">
{% if teilnehmer %} {% if teilnehmer %}
@@ -153,6 +160,7 @@
<th>E-Mail</th> <th>E-Mail</th>
<th>RSVP</th> <th>RSVP</th>
<th>Bemerkungen</th> <th>Bemerkungen</th>
<th>Aktionen</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -179,6 +187,14 @@
{% endif %} {% endif %}
</td> </td>
<td>{{ t.bemerkungen|default:"" }}</td> <td>{{ t.bemerkungen|default:"" }}</td>
<td>
<a href="{% url 'stiftung:teilnehmer_update' veranstaltung.pk t.pk %}" class="btn btn-sm btn-outline-secondary me-1" title="Bearbeiten">
<i class="fas fa-edit"></i>
</a>
<a href="{% url 'stiftung:teilnehmer_delete' veranstaltung.pk t.pk %}" class="btn btn-sm btn-outline-danger" title="Entfernen">
<i class="fas fa-trash"></i>
</a>
</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
@@ -187,7 +203,7 @@
<div class="p-4 text-center text-muted"> <div class="p-4 text-center text-muted">
<i class="fas fa-users fa-2x mb-2"></i> <i class="fas fa-users fa-2x mb-2"></i>
<p>Noch keine Teilnehmer eingetragen.</p> <p>Noch keine Teilnehmer eingetragen.</p>
<a href="{% url 'admin:stiftung_veranstaltungsteilnehmer_add' %}?veranstaltung={{ veranstaltung.pk }}" <a href="{% url 'stiftung:teilnehmer_create' veranstaltung.pk %}"
class="btn btn-primary"> class="btn btn-primary">
<i class="fas fa-user-plus me-1"></i>Ersten Teilnehmer hinzufügen <i class="fas fa-user-plus me-1"></i>Ersten Teilnehmer hinzufügen
</a> </a>

View File

@@ -0,0 +1,200 @@
{% extends 'base.html' %}
{% load static %}
{% block title %}{{ title }} - Stiftungsverwaltung{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<div class="row">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h3">
<i class="fas fa-calendar-alt text-primary me-2"></i>
{{ title }}
</h1>
<a href="{% url 'stiftung:veranstaltung_list' %}" class="btn btn-outline-secondary">
<i class="fas fa-arrow-left me-2"></i>Zurück zur Liste
</a>
</div>
</div>
</div>
<form method="post" novalidate>
{% csrf_token %}
{% if form.non_field_errors %}
<div class="alert alert-danger">
<ul class="mb-0">
{% for error in form.non_field_errors %}
<li>{{ error }}</li>
{% endfor %}
</ul>
</div>
{% endif %}
<div class="row g-4">
<div class="col-lg-8">
<!-- Grunddaten -->
<div class="card shadow-sm mb-4">
<div class="card-header bg-dark text-white">
<i class="fas fa-info-circle me-2"></i>Grunddaten
</div>
<div class="card-body">
<div class="row">
<div class="col-12 mb-3">
<label for="{{ form.titel.id_for_label }}" class="form-label">{{ form.titel.label }} *</label>
{{ form.titel }}
{% if form.titel.errors %}<div class="invalid-feedback d-block">{{ form.titel.errors.0 }}</div>{% endif %}
</div>
<div class="col-md-4 mb-3">
<label for="{{ form.datum.id_for_label }}" class="form-label">{{ form.datum.label }} *</label>
{{ form.datum }}
{% if form.datum.errors %}<div class="invalid-feedback d-block">{{ form.datum.errors.0 }}</div>{% endif %}
</div>
<div class="col-md-4 mb-3">
<label for="{{ form.uhrzeit.id_for_label }}" class="form-label">{{ form.uhrzeit.label }}</label>
{{ form.uhrzeit }}
{% if form.uhrzeit.errors %}<div class="invalid-feedback d-block">{{ form.uhrzeit.errors.0 }}</div>{% endif %}
</div>
<div class="col-md-4 mb-3">
<label for="{{ form.status.id_for_label }}" class="form-label">{{ form.status.label }}</label>
{{ form.status }}
</div>
<div class="col-md-6 mb-3">
<label for="{{ form.ort.id_for_label }}" class="form-label">{{ form.ort.label }} *</label>
{{ form.ort }}
{% if form.ort.errors %}<div class="invalid-feedback d-block">{{ form.ort.errors.0 }}</div>{% endif %}
</div>
<div class="col-md-6 mb-3">
<label for="{{ form.budget_pro_person.id_for_label }}" class="form-label">{{ form.budget_pro_person.label }}</label>
{{ form.budget_pro_person }}
</div>
<div class="col-12 mb-3">
<label for="{{ form.adresse.id_for_label }}" class="form-label">{{ form.adresse.label }}</label>
{{ form.adresse }}
</div>
<div class="col-12 mb-3">
<label for="{{ form.beschreibung.id_for_label }}" class="form-label">{{ form.beschreibung.label }}</label>
{{ form.beschreibung }}
</div>
</div>
</div>
</div>
<!-- Serienbrief-Vorlage -->
<div class="card shadow-sm mb-4">
<div class="card-header bg-dark text-white">
<i class="fas fa-envelope-open-text me-2"></i>Serienbrief Vorlage
</div>
<div class="card-body">
<div class="col-12 mb-3">
<label for="{{ form.betreff.id_for_label }}" class="form-label">{{ form.betreff.label }}</label>
{{ form.betreff }}
<div class="form-text">{{ form.betreff.help_text }}</div>
</div>
<div class="col-12 mb-3">
<label for="{{ form.briefvorlage.id_for_label }}" class="form-label">{{ form.briefvorlage.label }}</label>
{{ form.briefvorlage }}
{% if form.briefvorlage.errors %}<div class="invalid-feedback d-block">{{ form.briefvorlage.errors.0 }}</div>{% endif %}
<div class="form-text">{{ form.briefvorlage.help_text }}</div>
</div>
</div>
</div>
<!-- Unterschriften -->
<div class="card shadow-sm mb-4">
<div class="card-header bg-dark text-white">
<i class="fas fa-signature me-2"></i>Serienbrief Unterschriften
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6 mb-3">
<label for="{{ form.unterschrift_1_name.id_for_label }}" class="form-label">{{ form.unterschrift_1_name.label }}</label>
{{ form.unterschrift_1_name }}
</div>
<div class="col-md-6 mb-3">
<label for="{{ form.unterschrift_1_titel.id_for_label }}" class="form-label">{{ form.unterschrift_1_titel.label }}</label>
{{ form.unterschrift_1_titel }}
</div>
<div class="col-md-6 mb-3">
<label for="{{ form.unterschrift_2_name.id_for_label }}" class="form-label">{{ form.unterschrift_2_name.label }}</label>
{{ form.unterschrift_2_name }}
</div>
<div class="col-md-6 mb-3">
<label for="{{ form.unterschrift_2_titel.id_for_label }}" class="form-label">{{ form.unterschrift_2_titel.label }}</label>
{{ form.unterschrift_2_titel }}
</div>
</div>
</div>
</div>
</div>
<!-- Sidebar -->
<div class="col-lg-4">
<div class="card shadow-sm mb-4">
<div class="card-header bg-dark text-white">
<i class="fas fa-question-circle me-2"></i>Platzhalter-Hilfe
</div>
<div class="card-body">
<p class="small text-muted mb-2">Verfügbare Platzhalter für die Briefvorlage:</p>
<table class="table table-sm small">
<tbody>
<tr><td class="font-monospace text-danger">{{ anrede }}</td><td>Herr / Frau</td></tr>
<tr><td class="font-monospace text-danger">{{ vorname }}</td><td>Vorname</td></tr>
<tr><td class="font-monospace text-danger">{{ nachname }}</td><td>Nachname</td></tr>
<tr><td class="font-monospace text-danger">{{ strasse }}</td><td>Straße + Nr.</td></tr>
<tr><td class="font-monospace text-danger">{{ plz }}</td><td>PLZ</td></tr>
<tr><td class="font-monospace text-danger">{{ ort }}</td><td>Wohnort</td></tr>
<tr><td class="font-monospace text-danger">{{ datum }}</td><td>Veranstaltungsdatum</td></tr>
<tr><td class="font-monospace text-danger">{{ uhrzeit }}</td><td>Uhrzeit</td></tr>
<tr><td class="font-monospace text-danger">{{ veranstaltungsort }}</td><td>Gasthaus / Ort</td></tr>
<tr><td class="font-monospace text-danger">{{ gasthaus_adresse }}</td><td>Adresse Gasthaus</td></tr>
</tbody>
</table>
<p class="small text-muted mb-0">Platzhalter werden beim PDF-Export automatisch befüllt.</p>
</div>
</div>
{% if veranstaltung %}
<div class="card shadow-sm">
<div class="card-header bg-dark text-white">
<i class="fas fa-tools me-2"></i>Aktionen
</div>
<div class="card-body d-flex flex-column gap-2">
<a href="{% url 'stiftung:veranstaltung_serienbrief_vorschau' veranstaltung.pk %}"
class="btn btn-outline-primary w-100" target="_blank">
<i class="fas fa-eye me-2"></i>Serienbrief-Vorschau
</a>
<a href="{% url 'stiftung:veranstaltung_serienbrief_pdf' veranstaltung.pk %}"
class="btn btn-outline-success w-100">
<i class="fas fa-file-pdf me-2"></i>Serienbrief-PDF
</a>
<hr>
<a href="{% url 'stiftung:veranstaltung_delete' veranstaltung.pk %}"
class="btn btn-outline-danger w-100">
<i class="fas fa-trash me-2"></i>Veranstaltung löschen
</a>
</div>
</div>
{% endif %}
</div>
</div>
<!-- Submit -->
<div class="row mt-3">
<div class="col-lg-8">
<hr>
<div class="d-flex justify-content-between">
<a href="{% url 'stiftung:veranstaltung_list' %}" class="btn btn-secondary">
<i class="fas fa-times me-2"></i>Abbrechen
</a>
<button type="submit" class="btn btn-success">
<i class="fas fa-save me-2"></i>
{% if veranstaltung %}Aktualisieren{% else %}Erstellen{% endif %}
</button>
</div>
</div>
</div>
</form>
</div>
{% endblock %}

View File

@@ -8,7 +8,7 @@
<h1 class="h3 mb-0"> <h1 class="h3 mb-0">
<i class="fas fa-calendar-alt me-2"></i>Veranstaltungen <i class="fas fa-calendar-alt me-2"></i>Veranstaltungen
</h1> </h1>
<a href="{% url 'admin:stiftung_veranstaltung_add' %}" class="btn btn-primary"> <a href="{% url 'stiftung:veranstaltung_create' %}" class="btn btn-primary">
<i class="fas fa-plus me-1"></i>Neue Veranstaltung <i class="fas fa-plus me-1"></i>Neue Veranstaltung
</a> </a>
</div> </div>
@@ -68,7 +68,7 @@
{% else %} {% else %}
<div class="alert alert-info"> <div class="alert alert-info">
<i class="fas fa-info-circle me-2"></i>Noch keine Veranstaltungen angelegt. <i class="fas fa-info-circle me-2"></i>Noch keine Veranstaltungen angelegt.
<a href="{% url 'admin:stiftung_veranstaltung_add' %}">Jetzt erste Veranstaltung erstellen.</a> <a href="{% url 'stiftung:veranstaltung_create' %}">Jetzt erste Veranstaltung erstellen.</a>
</div> </div>
{% endif %} {% endif %}
</div> </div>

View File

@@ -0,0 +1,134 @@
{% extends "base.html" %}
{% block title %}Serienbrief-Vorschau {{ veranstaltung.titel }}{% endblock %}
{% block content %}
<div class="container-fluid mt-3">
<div class="d-flex justify-content-between align-items-center mb-3">
<h2>
Serienbrief-Vorschau
<small class="text-muted fs-6">{{ veranstaltung.titel }} ({{ veranstaltung.datum|date:"j. F Y" }})</small>
</h2>
<div class="d-flex gap-2">
<a href="{% url 'stiftung:veranstaltung_serienbrief_pdf' veranstaltung.pk %}"
class="btn btn-primary">
PDF generieren
</a>
<a href="{% url 'stiftung:veranstaltung_detail' veranstaltung.pk %}"
class="btn btn-outline-secondary">
Zurück zur Veranstaltung
</a>
</div>
</div>
{% if not teilnehmer %}
<div class="alert alert-warning">
Diese Veranstaltung hat noch keine Teilnehmer. Bitte zuerst Teilnehmer anlegen.
</div>
{% else %}
<div class="alert alert-info d-flex align-items-start gap-2 mb-3">
<span></span>
<div>
<strong>{{ teilnehmer.count }} Brief{% if teilnehmer.count != 1 %}e{% endif %}</strong> werden generiert.
Die Vorschau zeigt jeden Brief auf einer separaten Seite.
Platzhalter wie <code>{% verbatim %}{{ vorname }}{% endverbatim %}</code> sind hier bereits durch Beispieldaten ersetzt.
</div>
</div>
<!-- Navigation zwischen Briefen -->
<div class="mb-3 d-flex gap-2 align-items-center flex-wrap">
<strong>Empfänger:</strong>
{% for t in teilnehmer %}
<a href="#brief-{{ forloop.counter }}"
class="btn btn-sm btn-outline-secondary">
{{ t.nachname }}, {{ t.vorname }}
</a>
{% endfor %}
</div>
<!-- Einzelne Briefe -->
{% for t in teilnehmer %}
<div id="brief-{{ forloop.counter }}"
style="max-width:210mm;margin:0 auto 40px;padding:20mm 25mm;border:1px solid #dee2e6;background:#fff;box-shadow:0 2px 8px rgba(0,0,0,.1);font-family:'Times New Roman',Times,serif;font-size:11pt;line-height:1.4;">
<!-- Stiftungskopf -->
<div style="font-size:12pt;font-weight:bold;margin-bottom:2mm;">van Hees-Theyssen-Vogel'sche Stiftung</div>
<div style="font-size:8.5pt;color:#444;margin-bottom:5mm;">
Raesfelder Str. 3 &nbsp;·&nbsp; 46499 Hamminkeln
</div>
<!-- Empfänger -->
<div style="min-height:35mm;margin-bottom:5mm;">
<div style="font-size:7.5pt;border-bottom:1px solid #000;margin-bottom:3pt;padding-bottom:1pt;color:#444;">
van Hees-Theyssen-Vogel'sche Stiftung · Raesfelder Str. 3 · 46499 Hamminkeln
</div>
<p style="margin:0;line-height:1.3;">{{ t.anrede }} {{ t.vorname }} {{ t.nachname }}</p>
{% if t.strasse %}<p style="margin:0;line-height:1.3;">{{ t.strasse }}</p>{% endif %}
{% if t.plz or t.ort %}<p style="margin:0;line-height:1.3;">{{ t.plz }} {{ t.ort }}</p>{% endif %}
</div>
<!-- Datum -->
<div style="text-align:right;margin-bottom:4mm;">
Hamminkeln, den {{ veranstaltung.datum|date:"j. F Y" }}
</div>
<!-- Betreff -->
<div style="font-weight:bold;margin-bottom:4mm;">
{% if veranstaltung.betreff %}{{ veranstaltung.betreff }}{% else %}Einladung zum {{ veranstaltung.titel }}{% endif %}
</div>
<!-- Anrede -->
<div style="margin-bottom:3mm;">
Sehr geehrte{% if t.anrede == "Herr" %}r Herr{% elif t.anrede == "Frau" %} Frau{% else %}
{{ t.anrede }}{% endif %} {{ t.nachname }},
</div>
<!-- Brieftext -->
<div class="brieftext">
{% if veranstaltung.briefvorlage %}
{{ veranstaltung.briefvorlage|safe }}
{% else %}
<p>wir laden Sie herzlich ein, an der jährlichen Vorstellung der Rechnungslegung
der van Hees-Theyssen-Vogel'schen Stiftung teilzunehmen.</p>
<p>Die Veranstaltung findet statt am:</p>
<div style="margin:4mm 0 4mm 10mm;font-weight:bold;">
{{ veranstaltung.datum|date:"l, j. F Y" }}{% if veranstaltung.uhrzeit %}, {{ veranstaltung.uhrzeit|time:"H:i" }} Uhr{% endif %}<br>
{{ veranstaltung.ort }}<br>
{% if veranstaltung.adresse %}{{ veranstaltung.adresse }}{% endif %}
</div>
<p>Bitte teilen Sie uns Ihre Teilnahme bis zum <strong>4. April 2026</strong> mit.</p>
<p>Wir freuen uns auf Ihr Kommen.</p>
{% endif %}
<p>Mit freundlichen Grüßen</p>
</div>
<!-- Unterschriften -->
<div style="margin-top:10mm;">
<div style="display:inline-block;width:45%;vertical-align:top;">
{% if veranstaltung.unterschrift_1_name %}
<div style="border-top:1px solid #000;margin-bottom:2mm;width:80%;"></div>
{{ veranstaltung.unterschrift_1_name }}<br>
{{ veranstaltung.unterschrift_1_titel }}<br>
van Hees-Theyssen-Vogel'sche Stiftung
{% endif %}
</div>
<div style="display:inline-block;width:45%;vertical-align:top;">
{% if veranstaltung.unterschrift_2_name %}
<div style="border-top:1px solid #000;margin-bottom:2mm;width:80%;"></div>
{{ veranstaltung.unterschrift_2_name }}<br>
{{ veranstaltung.unterschrift_2_titel }}<br>
van Hees-Theyssen-Vogel'sche Stiftung
{% endif %}
</div>
</div>
<div style="text-align:right;margin-top:12mm;font-size:9pt;color:#999;">
Brief {{ forloop.counter }} von {{ teilnehmer.count }}
</div>
</div>
{% endfor %}
{% endif %}
</div>
{% endblock %}

View File

@@ -0,0 +1,35 @@
{% extends 'base.html' %}
{% block title %}Teilnehmer entfernen Stiftungsverwaltung{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<div class="row justify-content-center">
<div class="col-lg-6">
<div class="card shadow-sm">
<div class="card-header bg-danger text-white">
<i class="fas fa-exclamation-triangle me-2"></i>Teilnehmer entfernen
</div>
<div class="card-body">
<p>Möchten Sie den folgenden Teilnehmer wirklich aus der Veranstaltung entfernen?</p>
<div class="alert alert-warning">
<strong>{{ teilnehmer.anrede }} {{ teilnehmer.vorname }} {{ teilnehmer.nachname }}</strong><br>
<small class="text-muted">Veranstaltung: {{ veranstaltung.titel }}</small>
</div>
<form method="post">
{% csrf_token %}
<div class="d-flex justify-content-between">
<a href="{% url 'stiftung:veranstaltung_detail' veranstaltung.pk %}" class="btn btn-secondary">
<i class="fas fa-arrow-left me-2"></i>Abbrechen
</a>
<button type="submit" class="btn btn-danger">
<i class="fas fa-trash me-2"></i>Entfernen
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,119 @@
{% extends 'base.html' %}
{% block title %}{{ title }} Stiftungsverwaltung{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<div class="row">
<div class="col-12">
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{% url 'stiftung:veranstaltung_list' %}">Veranstaltungen</a></li>
<li class="breadcrumb-item"><a href="{% url 'stiftung:veranstaltung_detail' veranstaltung.pk %}">{{ veranstaltung.titel }}</a></li>
<li class="breadcrumb-item active">{{ title }}</li>
</ol>
</nav>
<h1 class="h3 mb-4">
<i class="fas fa-user-plus text-primary me-2"></i>{{ title }}
</h1>
</div>
</div>
<div class="row">
<div class="col-lg-8">
<div class="card shadow-sm">
<div class="card-header bg-dark text-white">
<i class="fas fa-user me-2"></i>Teilnehmerdaten
</div>
<div class="card-body">
<form method="post" novalidate>
{% csrf_token %}
{% if form.non_field_errors %}
<div class="alert alert-danger">
<ul class="mb-0">
{% for error in form.non_field_errors %}
<li>{{ error }}</li>
{% endfor %}
</ul>
</div>
{% endif %}
<div class="row">
<div class="col-md-4 mb-3">
<label for="{{ form.anrede.id_for_label }}" class="form-label">{{ form.anrede.label }}</label>
{{ form.anrede }}
</div>
<div class="col-md-4 mb-3">
<label for="{{ form.vorname.id_for_label }}" class="form-label">{{ form.vorname.label }} *</label>
{{ form.vorname }}
{% if form.vorname.errors %}<div class="invalid-feedback d-block">{{ form.vorname.errors.0 }}</div>{% endif %}
</div>
<div class="col-md-4 mb-3">
<label for="{{ form.nachname.id_for_label }}" class="form-label">{{ form.nachname.label }} *</label>
{{ form.nachname }}
{% if form.nachname.errors %}<div class="invalid-feedback d-block">{{ form.nachname.errors.0 }}</div>{% endif %}
</div>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label for="{{ form.strasse.id_for_label }}" class="form-label">{{ form.strasse.label }}</label>
{{ form.strasse }}
</div>
<div class="col-md-2 mb-3">
<label for="{{ form.plz.id_for_label }}" class="form-label">{{ form.plz.label }}</label>
{{ form.plz }}
</div>
<div class="col-md-4 mb-3">
<label for="{{ form.ort.id_for_label }}" class="form-label">{{ form.ort.label }}</label>
{{ form.ort }}
</div>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label for="{{ form.email.id_for_label }}" class="form-label">{{ form.email.label }}</label>
{{ form.email }}
</div>
<div class="col-md-6 mb-3">
<label for="{{ form.rsvp_status.id_for_label }}" class="form-label">{{ form.rsvp_status.label }}</label>
{{ form.rsvp_status }}
</div>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label for="{{ form.paechter.id_for_label }}" class="form-label">{{ form.paechter.label }}</label>
{{ form.paechter }}
<div class="form-text">Optional: Verknüpfung mit bestehendem Pächter</div>
</div>
<div class="col-md-6 mb-3">
<label for="{{ form.destinataer.id_for_label }}" class="form-label">{{ form.destinataer.label }}</label>
{{ form.destinataer }}
<div class="form-text">Optional: Verknüpfung mit bestehendem Destinatär</div>
</div>
</div>
<div class="mb-3">
<label for="{{ form.bemerkungen.id_for_label }}" class="form-label">{{ form.bemerkungen.label }}</label>
{{ form.bemerkungen }}
</div>
<hr>
<div class="d-flex justify-content-between">
<a href="{% url 'stiftung:veranstaltung_detail' veranstaltung.pk %}" class="btn btn-secondary">
<i class="fas fa-times me-2"></i>Abbrechen
</a>
<button type="submit" class="btn btn-success">
<i class="fas fa-save me-2"></i>
{% if teilnehmer %}Aktualisieren{% else %}Hinzufügen{% endif %}
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
{% endblock %}