feat: Veranstaltungsmodul + Serienbrief mit editierbaren Feldern (STI-35, STI-39)
Some checks failed
CI/CD Pipeline / test (push) Has been cancelled
CI/CD Pipeline / deploy (push) Has been cancelled
Code Quality / quality (push) Has been cancelled

Implementierung des Veranstaltungsmoduls inkl. Serienbrief-PDF-Generator
mit dynamischen, editierbaren Feldern für Betreff und Unterschriften.

### Veranstaltungsmodul (STI-35)
- Neues Veranstaltungs-Modell: Titel, Datum, Uhrzeit, Ort, Gasthaus-Adresse,
  Briefvorlage, Gästeliste (VerstaltungsGast mit freien/Destinatär-Feldern)
- Views: Veranstaltungsliste, -detail, Serienbrief-PDF-Generator
- Templates: list.html, detail.html, serienbrief_pdf.html (A4, einseitig)
- API: Serializer + Endpunkte für Veranstaltungen
- Admin: Inline-Bearbeitung der Gästeliste
- Migration: 0044_veranstaltungsmodul

### Serienbrief editierbare Felder + PDF-Fix (STI-39)
- Neue Felder an Veranstaltung: betreff, unterschrift_1_name/titel,
  unterschrift_2_name/titel (mit Defaults: Katrin Kleinpaß / Jan Remmer Siebels)
- PDF-CSS: Margins, Font-Sizes und Line-Heights reduziert für einseitigen Druck
- Migration: 0045_add_serienbrief_editable_fields

### Infrastruktur
- scripts/init-paperless-db.sh: Erstellt separate Paperless-DB beim DB-Init
- compose.yml: init-paperless-db.sh eingebunden, PAPERLESS_DBNAME-Fix
- .gitignore: .claude/ ausgeschlossen

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
SysAdmin Agent
2026-03-10 22:36:58 +00:00
parent f8f9dc3319
commit 28621d2774
24 changed files with 1072 additions and 68 deletions

View File

@@ -11,6 +11,7 @@ from .models import (AppConfiguration, AuditLog, BackupJob, BankTransaction,
DestinataerUnterstuetzung,
DokumentLink, Foerderung, Land, LandVerpachtung, Paechter, Person,
Rentmeister, StiftungsKonto, UnterstuetzungWiederkehrend,
Veranstaltung, Veranstaltungsteilnehmer,
Verwaltungskosten, VierteljahresNachweis)
@@ -1228,6 +1229,109 @@ class DestinataerEmailEingangAdmin(admin.ModelAdmin):
mark_verarbeitet.short_description = "Als verarbeitet markieren"
class VeranstaltungsteilnehmerInline(admin.TabularInline):
model = Veranstaltungsteilnehmer
extra = 1
fields = [
"anrede", "vorname", "nachname", "strasse", "plz", "ort",
"email", "rsvp_status", "bemerkungen",
]
@admin.register(Veranstaltung)
class VeranstaltungAdmin(admin.ModelAdmin):
list_display = [
"titel", "datum", "uhrzeit", "ort", "status",
"get_teilnehmer_count", "get_zugesagte_count", "budget_pro_person",
]
list_filter = ["status", "datum"]
search_fields = ["titel", "ort", "beschreibung"]
ordering = ["-datum"]
readonly_fields = ["id", "erstellt_am", "aktualisiert_am", "serienbrief_link"]
inlines = [VeranstaltungsteilnehmerInline]
fieldsets = (
("Grunddaten", {"fields": ("titel", "datum", "uhrzeit", "status")}),
("Veranstaltungsort", {"fields": ("ort", "adresse")}),
("Details", {"fields": ("beschreibung", "budget_pro_person")}),
(
"Serienbrief",
{
"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 }}"
),
},
),
("System", {"fields": ("id", "erstellt_am", "aktualisiert_am"), "classes": ("collapse",)}),
)
def get_teilnehmer_count(self, obj):
return obj.get_teilnehmer_count()
get_teilnehmer_count.short_description = "Teilnehmer gesamt"
def get_zugesagte_count(self, obj):
return obj.get_zugesagte_count()
get_zugesagte_count.short_description = "Zugesagt"
def serienbrief_link(self, obj):
if obj.pk:
from django.urls import reverse as url_reverse
url = url_reverse("stiftung:veranstaltung_serienbrief_pdf", args=[obj.pk])
return format_html(
'<a href="{}" target="_blank" class="button">Serienbrief-PDF generieren</a>', url
)
return ""
serienbrief_link.short_description = "Serienbrief"
actions = ["generate_serienbrief"]
def generate_serienbrief(self, request, queryset):
if queryset.count() != 1:
self.message_user(
request,
"Bitte genau eine Veranstaltung auswählen.",
level="error",
)
return
from django.urls import reverse as url_reverse
from django.shortcuts import redirect
veranstaltung = queryset.first()
url = url_reverse("stiftung:veranstaltung_serienbrief_pdf", args=[veranstaltung.pk])
return redirect(url)
generate_serienbrief.short_description = "Serienbrief-PDF generieren"
@admin.register(Veranstaltungsteilnehmer)
class VeranstaltungsteilnehmerAdmin(admin.ModelAdmin):
list_display = [
"nachname", "vorname", "anrede", "veranstaltung", "rsvp_status", "ort",
]
list_filter = ["rsvp_status", "veranstaltung", "anrede"]
search_fields = ["vorname", "nachname", "ort", "email"]
ordering = ["veranstaltung", "nachname", "vorname"]
readonly_fields = ["id", "erstellt_am"]
fieldsets = (
("Zuordnung", {"fields": ("veranstaltung", "paechter", "destinataer")}),
(
"Persönliche Daten",
{"fields": ("anrede", "vorname", "nachname", "email")},
),
("Adresse", {"fields": ("strasse", "plz", "ort")}),
("RSVP", {"fields": ("rsvp_status", "bemerkungen")}),
("System", {"fields": ("id", "erstellt_am"), "classes": ("collapse",)}),
)
# Customize admin site
admin.site.site_header = "Stiftungsverwaltung Administration"
admin.site.site_title = "Stiftungsverwaltung Admin"

View File

@@ -10,6 +10,8 @@ from .models import (
Paechter,
StiftungsKalenderEintrag,
StiftungsKonto,
Veranstaltung,
Veranstaltungsteilnehmer,
Verwaltungskosten,
)
@@ -86,3 +88,23 @@ class BankTransactionSerializer(serializers.ModelSerializer):
class Meta:
model = BankTransaction
fields = "__all__"
class VeranstaltungsteilnehmerSerializer(serializers.ModelSerializer):
class Meta:
model = Veranstaltungsteilnehmer
fields = "__all__"
class VeranstaltungSerializer(serializers.ModelSerializer):
teilnehmer = VeranstaltungsteilnehmerSerializer(many=True, read_only=True)
teilnehmer_count = serializers.IntegerField(
source="get_teilnehmer_count", read_only=True
)
zugesagte_count = serializers.IntegerField(
source="get_zugesagte_count", read_only=True
)
class Meta:
model = Veranstaltung
fields = "__all__"

View File

@@ -9,6 +9,7 @@ from .api_views import (
PaechterViewSet,
StiftungsKalenderEintragViewSet,
StiftungsKontoViewSet,
VeranstaltungViewSet,
VerwaltungskostenViewSet,
)
@@ -22,5 +23,6 @@ router.register(r"verpachtungen", LandVerpachtungViewSet)
router.register(r"verwaltungskosten", VerwaltungskostenViewSet)
router.register(r"kalender", StiftungsKalenderEintragViewSet)
router.register(r"transaktionen", BankTransactionViewSet)
router.register(r"veranstaltungen", VeranstaltungViewSet)
urlpatterns = router.urls

View File

@@ -9,6 +9,7 @@ from .api_serializers import (
PaechterSerializer,
StiftungsKalenderEintragSerializer,
StiftungsKontoSerializer,
VeranstaltungSerializer,
VerwaltungskostenSerializer,
)
from .models import (
@@ -20,6 +21,7 @@ from .models import (
Paechter,
StiftungsKalenderEintrag,
StiftungsKonto,
Veranstaltung,
Verwaltungskosten,
)
@@ -67,3 +69,8 @@ class StiftungsKalenderEintragViewSet(ReadOnlyModelViewSet):
class BankTransactionViewSet(ReadOnlyModelViewSet):
queryset = BankTransaction.objects.all()
serializer_class = BankTransactionSerializer
class VeranstaltungViewSet(ReadOnlyModelViewSet):
queryset = Veranstaltung.objects.all()
serializer_class = VeranstaltungSerializer

View File

@@ -0,0 +1,61 @@
# Generated by Django 5.0.6 on 2026-03-10 21:47
import django.db.models.deletion
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('stiftung', '0043_destinataer_email_eingang'),
]
operations = [
migrations.CreateModel(
name='Veranstaltung',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('titel', models.CharField(max_length=200, verbose_name='Titel')),
('datum', models.DateField(verbose_name='Datum')),
('uhrzeit', models.TimeField(blank=True, null=True, verbose_name='Uhrzeit')),
('ort', models.CharField(max_length=200, verbose_name='Ort / Gasthaus')),
('adresse', models.TextField(blank=True, verbose_name='Adresse Gasthaus')),
('beschreibung', models.TextField(blank=True, verbose_name='Beschreibung / Zweck')),
('status', models.CharField(choices=[('geplant', 'Geplant'), ('einladungen_versendet', 'Einladungen versendet'), ('abgeschlossen', 'Abgeschlossen'), ('abgesagt', 'Abgesagt')], default='geplant', max_length=30, verbose_name='Status')),
('budget_pro_person', models.DecimalField(blank=True, decimal_places=2, help_text='Geschätztes Budget je Teilnehmer in €', max_digits=8, null=True, verbose_name='Budget pro Person (€)')),
('briefvorlage', models.TextField(blank=True, help_text='HTML/Text-Template für Serienbrief. Platzhalter: {{ anrede }}, {{ vorname }}, {{ nachname }}, {{ strasse }}, {{ plz }}, {{ ort }}, {{ datum }}, {{ uhrzeit }}, {{ veranstaltungsort }}, {{ gasthaus_adresse }}', verbose_name='Briefvorlage')),
('erstellt_am', models.DateTimeField(auto_now_add=True)),
('aktualisiert_am', models.DateTimeField(auto_now=True)),
],
options={
'verbose_name': 'Veranstaltung',
'verbose_name_plural': 'Veranstaltungen',
'ordering': ['-datum'],
},
),
migrations.CreateModel(
name='Veranstaltungsteilnehmer',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('anrede', models.CharField(blank=True, choices=[('Herr', 'Herr'), ('Frau', 'Frau'), ('', 'Keine Anrede')], max_length=10, verbose_name='Anrede')),
('vorname', models.CharField(max_length=100, verbose_name='Vorname')),
('nachname', models.CharField(max_length=100, verbose_name='Nachname')),
('strasse', models.CharField(blank=True, max_length=200, verbose_name='Straße')),
('plz', models.CharField(blank=True, max_length=10, verbose_name='PLZ')),
('ort', models.CharField(blank=True, max_length=100, verbose_name='Ort')),
('email', models.EmailField(blank=True, help_text='Optional, für späteren E-Mail-Versand', max_length=254, verbose_name='E-Mail')),
('rsvp_status', models.CharField(choices=[('eingeladen', 'Eingeladen'), ('zugesagt', 'Zugesagt'), ('abgesagt', 'Abgesagt'), ('keine_rueckmeldung', 'Keine Rückmeldung')], default='eingeladen', max_length=20, verbose_name='RSVP-Status')),
('bemerkungen', models.TextField(blank=True, verbose_name='Bemerkungen')),
('erstellt_am', models.DateTimeField(auto_now_add=True)),
('destinataer', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='stiftung.destinataer', verbose_name='Destinatär (optional)')),
('paechter', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='stiftung.paechter', verbose_name='Pächter (optional)')),
('veranstaltung', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='teilnehmer', to='stiftung.veranstaltung', verbose_name='Veranstaltung')),
],
options={
'verbose_name': 'Veranstaltungsteilnehmer',
'verbose_name_plural': 'Veranstaltungsteilnehmer',
'ordering': ['nachname', 'vorname'],
},
),
]

View File

@@ -0,0 +1,43 @@
# Generated by Django 5.0.6 on 2026-03-10 22:31
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('stiftung', '0044_veranstaltungsmodul'),
]
operations = [
migrations.AddField(
model_name='veranstaltung',
name='betreff',
field=models.CharField(blank=True, help_text='Betreffzeile des Serienbriefs. Leer = Standardbetreff.', max_length=300, verbose_name='Betreff'),
),
migrations.AddField(
model_name='veranstaltung',
name='unterschrift_1_name',
field=models.CharField(blank=True, default='Katrin Kleinpaß', max_length=100, verbose_name='Unterschrift 1 Name'),
),
migrations.AddField(
model_name='veranstaltung',
name='unterschrift_1_titel',
field=models.CharField(blank=True, default='Rentmeisterin', max_length=100, verbose_name='Unterschrift 1 Titel'),
),
migrations.AddField(
model_name='veranstaltung',
name='unterschrift_2_name',
field=models.CharField(blank=True, default='Jan Remmer Siebels', max_length=100, verbose_name='Unterschrift 2 Name'),
),
migrations.AddField(
model_name='veranstaltung',
name='unterschrift_2_titel',
field=models.CharField(blank=True, default='Rentmeister', max_length=100, verbose_name='Unterschrift 2 Titel'),
),
migrations.AlterField(
model_name='vierteljahresnachweis',
name='faelligkeitsdatum',
field=models.DateField(blank=True, help_text='Veraltet - wird durch studiennachweis_faelligkeitsdatum und zahlung_faelligkeitsdatum ersetzt', null=True, verbose_name='Fälligkeitsdatum'),
),
]

View File

@@ -671,7 +671,7 @@ class Land(models.Model):
def get_verpachtungsgrad_neu(self):
"""Berechnet den Verpachtungsgrad basierend auf neuen Verpachtungen"""
if self.groesse_qm > 0:
if self.groesse_qm and self.groesse_qm > 0:
return (self.get_verpachtete_flaeche_aktuell() / self.groesse_qm) * 100
return 0
@@ -3279,3 +3279,178 @@ class DestinataerEmailEingang(models.Model):
f"{base}/documents/{doc_id}/"
for doc_id in (self.paperless_dokument_ids or [])
]
class Veranstaltung(models.Model):
"""Veranstaltungen der Stiftung, z.B. Stiftungsessen mit Rechnungslegung"""
STATUS_CHOICES = [
("geplant", "Geplant"),
("einladungen_versendet", "Einladungen versendet"),
("abgeschlossen", "Abgeschlossen"),
("abgesagt", "Abgesagt"),
]
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
titel = models.CharField(max_length=200, verbose_name="Titel")
datum = models.DateField(verbose_name="Datum")
uhrzeit = models.TimeField(null=True, blank=True, verbose_name="Uhrzeit")
ort = models.CharField(max_length=200, verbose_name="Ort / Gasthaus")
adresse = models.TextField(blank=True, verbose_name="Adresse Gasthaus")
beschreibung = models.TextField(blank=True, verbose_name="Beschreibung / Zweck")
status = models.CharField(
max_length=30,
choices=STATUS_CHOICES,
default="geplant",
verbose_name="Status",
)
budget_pro_person = models.DecimalField(
max_digits=8,
decimal_places=2,
null=True,
blank=True,
verbose_name="Budget pro Person (€)",
help_text="Geschätztes Budget je Teilnehmer in €",
)
briefvorlage = models.TextField(
blank=True,
verbose_name="Briefvorlage",
help_text=(
"HTML/Text-Template für Serienbrief. Platzhalter: "
"{{ anrede }}, {{ vorname }}, {{ nachname }}, {{ strasse }}, "
"{{ plz }}, {{ ort }}, {{ datum }}, {{ uhrzeit }}, "
"{{ veranstaltungsort }}, {{ gasthaus_adresse }}"
),
)
betreff = models.CharField(
max_length=300,
blank=True,
verbose_name="Betreff",
help_text="Betreffzeile des Serienbriefs. Leer = Standardbetreff.",
)
unterschrift_1_name = models.CharField(
max_length=100,
blank=True,
default="Katrin Kleinpaß",
verbose_name="Unterschrift 1 Name",
)
unterschrift_1_titel = models.CharField(
max_length=100,
blank=True,
default="Rentmeisterin",
verbose_name="Unterschrift 1 Titel",
)
unterschrift_2_name = models.CharField(
max_length=100,
blank=True,
default="Jan Remmer Siebels",
verbose_name="Unterschrift 2 Name",
)
unterschrift_2_titel = models.CharField(
max_length=100,
blank=True,
default="Rentmeister",
verbose_name="Unterschrift 2 Titel",
)
erstellt_am = models.DateTimeField(auto_now_add=True)
aktualisiert_am = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = "Veranstaltung"
verbose_name_plural = "Veranstaltungen"
ordering = ["-datum"]
def __str__(self):
return f"{self.titel} ({self.datum})"
def get_teilnehmer_count(self):
return self.teilnehmer.count()
def get_zugesagte_count(self):
return self.teilnehmer.filter(rsvp_status="zugesagt").count()
def get_abgesagte_count(self):
return self.teilnehmer.filter(rsvp_status="abgesagt").count()
def get_keine_rueckmeldung_count(self):
return self.teilnehmer.filter(rsvp_status="keine_rueckmeldung").count()
class Veranstaltungsteilnehmer(models.Model):
"""Teilnehmer einer Veranstaltung primär freie Eingabe für Familienmitglieder"""
ANREDE_CHOICES = [
("Herr", "Herr"),
("Frau", "Frau"),
("", "Keine Anrede"),
]
RSVP_CHOICES = [
("eingeladen", "Eingeladen"),
("zugesagt", "Zugesagt"),
("abgesagt", "Abgesagt"),
("keine_rueckmeldung", "Keine Rückmeldung"),
]
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
veranstaltung = models.ForeignKey(
Veranstaltung,
on_delete=models.CASCADE,
related_name="teilnehmer",
verbose_name="Veranstaltung",
)
# Optionale Verknüpfung zu bestehenden Datensätzen
paechter = models.ForeignKey(
"Paechter",
null=True,
blank=True,
on_delete=models.SET_NULL,
verbose_name="Pächter (optional)",
)
destinataer = models.ForeignKey(
"Destinataer",
null=True,
blank=True,
on_delete=models.SET_NULL,
verbose_name="Destinatär (optional)",
)
# Freie Felder (Pflichtfelder für Serienbrief)
anrede = models.CharField(
max_length=10, choices=ANREDE_CHOICES, blank=True, verbose_name="Anrede"
)
vorname = models.CharField(max_length=100, verbose_name="Vorname")
nachname = models.CharField(max_length=100, verbose_name="Nachname")
strasse = models.CharField(max_length=200, blank=True, verbose_name="Straße")
plz = models.CharField(max_length=10, blank=True, verbose_name="PLZ")
ort = models.CharField(max_length=100, blank=True, verbose_name="Ort")
email = models.EmailField(
blank=True, verbose_name="E-Mail", help_text="Optional, für späteren E-Mail-Versand"
)
rsvp_status = models.CharField(
max_length=20,
choices=RSVP_CHOICES,
default="eingeladen",
verbose_name="RSVP-Status",
)
bemerkungen = models.TextField(blank=True, verbose_name="Bemerkungen")
erstellt_am = models.DateTimeField(auto_now_add=True)
class Meta:
verbose_name = "Veranstaltungsteilnehmer"
verbose_name_plural = "Veranstaltungsteilnehmer"
ordering = ["nachname", "vorname"]
def __str__(self):
return f"{self.anrede} {self.vorname} {self.nachname}".strip()
def get_full_name(self):
return f"{self.vorname} {self.nachname}".strip()
def get_full_address(self):
parts = [self.strasse, f"{self.plz} {self.ort}".strip()]
return ", ".join(p for p in parts if p)

View File

@@ -359,6 +359,14 @@ urlpatterns = [
views.paperless_document_redirect,
name="paperless_document_redirect",
),
# Veranstaltungsmodul
path("veranstaltungen/", views.veranstaltung_list, name="veranstaltung_list"),
path("veranstaltungen/<uuid:pk>/", views.veranstaltung_detail, name="veranstaltung_detail"),
path(
"veranstaltungen/<uuid:pk>/serienbrief/",
views.veranstaltung_serienbrief_pdf,
name="veranstaltung_serienbrief_pdf",
),
# 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

@@ -31,7 +31,8 @@ from .models import (AppConfiguration, CSVImport, Destinataer,
DestinataerEmailEingang, DestinataerUnterstuetzung,
DokumentLink, Foerderung, Land,
LandAbrechnung, LandVerpachtung, Paechter, Person,
StiftungsKonto, UnterstuetzungWiederkehrend, VierteljahresNachweis)
StiftungsKonto, UnterstuetzungWiederkehrend, Veranstaltung,
Veranstaltungsteilnehmer, VierteljahresNachweis)
def get_pdf_generator():
@@ -279,7 +280,7 @@ def paperless_document_redirect(_request, doc_id: int):
return Response({"error": "Paperless API not configured"}, status=400)
# Remove /api suffix if present, then construct the document URL
base_url = url.rstrip("/api") if url.endswith("/api") else url
base_url = url[:-4] if url.endswith("/api") else url
# For external Paperless (already includes /paperless/ in base URL)
return redirect(f"{base_url}/documents/{doc_id}/details/")
@@ -1930,7 +1931,6 @@ def verpachtung_list(request):
return render(request, "stiftung/verpachtung_list.html", context)
@login_required
@login_required
def land_verpachtung_detail(request, pk):
"""Detail view for LandVerpachtung"""
@@ -2197,7 +2197,7 @@ def dokument_list(request):
available_dokumente = []
if url and token:
try:
base_url = url.rstrip("/api") if url.endswith("/api") else url
base_url = url[:-4] if url.endswith("/api") else url
headers = {"Authorization": f"Token {token}"}
# Alle verfügbaren Dokumente abrufen (mit Paginierung)
@@ -2553,7 +2553,7 @@ def paperless_ping(_request):
)
try:
# Entferne /api vom Ende der URL falls vorhanden
base_url = url.rstrip("/api") if url.endswith("/api") else url
base_url = url[:-4] if url.endswith("/api") else url
r = requests.get(
f"{base_url}/api/tags/",
headers={"Authorization": f"Token {token}"},
@@ -2598,7 +2598,7 @@ def paperless_documents(request):
try:
# Entferne /api vom Ende der URL falls vorhanden
base_url = url.rstrip("/api") if url.endswith("/api") else url
base_url = url[:-4] if url.endswith("/api") else url
headers = {"Authorization": f"Token {token}"}
def fetch_tagged():
@@ -2735,7 +2735,7 @@ def paperless_debug(request):
try:
# Entferne /api vom Ende der URL falls vorhanden
base_url = url.rstrip("/api") if url.endswith("/api") else url
base_url = url[:-4] if url.endswith("/api") else url
headers = {"Authorization": f"Token {token}"}
@@ -2857,7 +2857,7 @@ def paperless_tags_only(request):
try:
# Entferne /api vom Ende der URL falls vorhanden
base_url = url.rstrip("/api") if url.endswith("/api") else url
base_url = url[:-4] if url.endswith("/api") else url
# Alle Tags abrufen (mit großer page_size)
headers = {"Authorization": f"Token {token}"}
@@ -4147,7 +4147,6 @@ def verwaltungskosten_create(request):
rentmeister = Rentmeister.objects.get(pk=rentmeister_id)
initial_data["rentmeister"] = rentmeister
redirect_url = "stiftung:rentmeister_detail"
redirect_args = [rentmeister_id]
except Rentmeister.DoesNotExist:
pass
@@ -8440,13 +8439,6 @@ def kalender_admin(request):
}
return render(request, 'stiftung/kalender/admin.html', context)
context = {
'title': f'Löschen: {event.titel}',
'event': event,
}
return render(request, 'stiftung/kalender/delete.html', context)
@login_required
@@ -8642,3 +8634,54 @@ def email_eingang_poll_trigger(request):
except Exception as exc:
messages.error(request, f"Fehler beim Starten des Tasks: {exc}")
return redirect("email_eingang_list")
# ============================================================
# Veranstaltungsmodul
# ============================================================
@login_required
def veranstaltung_list(request):
"""Liste aller Veranstaltungen"""
veranstaltungen = Veranstaltung.objects.all()
return render(request, "stiftung/veranstaltung/list.html", {"veranstaltungen": veranstaltungen})
@login_required
def veranstaltung_detail(request, pk):
"""Detail-Ansicht einer Veranstaltung mit RSVP-Übersicht"""
veranstaltung = get_object_or_404(Veranstaltung, pk=pk)
teilnehmer = veranstaltung.teilnehmer.all()
context = {
"veranstaltung": veranstaltung,
"teilnehmer": teilnehmer,
"zugesagte": teilnehmer.filter(rsvp_status="zugesagt"),
"abgesagte": teilnehmer.filter(rsvp_status="abgesagt"),
"keine_rueckmeldung": teilnehmer.filter(rsvp_status="keine_rueckmeldung"),
"eingeladen": teilnehmer.filter(rsvp_status="eingeladen"),
}
return render(request, "stiftung/veranstaltung/detail.html", context)
@login_required
def veranstaltung_serienbrief_pdf(request, pk):
"""Generiert Serienbrief-PDF für alle Teilnehmer einer Veranstaltung"""
from weasyprint import HTML
from django.template.loader import render_to_string
veranstaltung = get_object_or_404(Veranstaltung, pk=pk)
teilnehmer = veranstaltung.teilnehmer.all().order_by("nachname", "vorname")
# Render HTML for all letters
html_string = render_to_string(
"stiftung/veranstaltung/serienbrief_pdf.html",
{
"veranstaltung": veranstaltung,
"teilnehmer": teilnehmer,
},
)
pdf = HTML(string=html_string).write_pdf()
filename = f"einladungen_{veranstaltung.datum}_{veranstaltung.titel[:30].replace(' ', '_')}.pdf"
response = HttpResponse(pdf, content_type="application/pdf")
response["Content-Disposition"] = f'attachment; filename="{filename}"'
return response