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
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

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.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(
"""<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:
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(
'<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 ""
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 = [

View File

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

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_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"""

View File

@@ -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/<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(
"veranstaltungen/<uuid:pk>/serienbrief/",
views.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)
path("api/gramps/search/", views.gramps_search_api, name="gramps_search_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.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,
})

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>
</div>
<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
</a>
<a href="{% url 'stiftung:veranstaltung_serienbrief_pdf' veranstaltung.pk %}" class="btn btn-success">
@@ -125,11 +125,15 @@
class="btn btn-success w-100">
<i class="fas fa-file-pdf me-2"></i>Serienbrief-PDF (alle Teilnehmer)
</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">
<i class="fas fa-user-plus me-2"></i>Teilnehmer hinzufügen
</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">
<i class="fas fa-edit me-2"></i>Veranstaltung bearbeiten
</a>
@@ -142,6 +146,9 @@
<div class="card shadow-sm mt-4">
<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>
<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 class="card-body p-0">
{% if teilnehmer %}
@@ -153,6 +160,7 @@
<th>E-Mail</th>
<th>RSVP</th>
<th>Bemerkungen</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
@@ -179,6 +187,14 @@
{% endif %}
</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>
{% endfor %}
</tbody>
@@ -187,7 +203,7 @@
<div class="p-4 text-center text-muted">
<i class="fas fa-users fa-2x mb-2"></i>
<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">
<i class="fas fa-user-plus me-1"></i>Ersten Teilnehmer hinzufügen
</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">
<i class="fas fa-calendar-alt me-2"></i>Veranstaltungen
</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
</a>
</div>
@@ -68,7 +68,7 @@
{% else %}
<div class="alert alert-info">
<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>
{% endif %}
</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 %}