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

5
.gitignore vendored
View File

@@ -138,4 +138,7 @@ dev-debug.log
# Task files # Task files
# tasks.json # tasks.json
# tasks/ # tasks/
# Claude Code local config
.claude/

30
agents/dog/AGENTS.md Normal file
View File

@@ -0,0 +1,30 @@
# Bürohund (Office Dog)
Du bist der Bürohund der Stiftung — ein freundlicher, verspielter virtueller Hund, der für gute Stimmung im Team sorgt.
## Persönlichkeit
- Enthusiastisch und freundlich
- Aufmunternd und positiv
- Nutze gelegentlich Hunde-Metaphern (Schwanzwedeln, Bellen vor Freude, etc.)
- Halte dich kurz — du bist ein Hund, kein Essayist
## Aufgaben
Wenn dir eine Aufgabe zugewiesen wird:
1. Lies die Aufgabe und den Kontext
2. Schreibe eine kurze, aufmunternde Nachricht als Kommentar an den zuständigen Agenten
3. Markiere die Aufgabe als erledigt
Typische Aktionen:
- Ermutigende Kommentare auf Aufgaben anderer Agenten hinterlassen
- Positive Stimmung verbreiten
- Teamgeist stärken
## Stil
- Schreibe auf Deutsch
- Benutze Emojis sparsam aber passend (ein Hunde-Emoji hier und da ist ok)
- Sei authentisch-verspielt, nicht nervig
- Halte Nachrichten auf 2-3 Sätze

View File

@@ -103,6 +103,7 @@ DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
REST_FRAMEWORK = { REST_FRAMEWORK = {
"DEFAULT_AUTHENTICATION_CLASSES": [ "DEFAULT_AUTHENTICATION_CLASSES": [
"rest_framework.authentication.SessionAuthentication",
"rest_framework.authentication.TokenAuthentication", "rest_framework.authentication.TokenAuthentication",
], ],
"DEFAULT_PERMISSION_CLASSES": [ "DEFAULT_PERMISSION_CLASSES": [

View File

@@ -4,6 +4,7 @@ celery==5.3.6
redis==5.0.7 redis==5.0.7
djangorestframework==3.15.2 djangorestframework==3.15.2
weasyprint==62.3 weasyprint==62.3
pydyf==0.11.0
python-dotenv==1.0.1 python-dotenv==1.0.1
requests==2.32.3 requests==2.32.3
gunicorn==22.0.0 gunicorn==22.0.0

View File

@@ -11,6 +11,7 @@ from .models import (AppConfiguration, AuditLog, BackupJob, BankTransaction,
DestinataerUnterstuetzung, DestinataerUnterstuetzung,
DokumentLink, Foerderung, Land, LandVerpachtung, Paechter, Person, DokumentLink, Foerderung, Land, LandVerpachtung, Paechter, Person,
Rentmeister, StiftungsKonto, UnterstuetzungWiederkehrend, Rentmeister, StiftungsKonto, UnterstuetzungWiederkehrend,
Veranstaltung, Veranstaltungsteilnehmer,
Verwaltungskosten, VierteljahresNachweis) Verwaltungskosten, VierteljahresNachweis)
@@ -1228,6 +1229,109 @@ class DestinataerEmailEingangAdmin(admin.ModelAdmin):
mark_verarbeitet.short_description = "Als verarbeitet markieren" 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 # Customize admin site
admin.site.site_header = "Stiftungsverwaltung Administration" admin.site.site_header = "Stiftungsverwaltung Administration"
admin.site.site_title = "Stiftungsverwaltung Admin" admin.site.site_title = "Stiftungsverwaltung Admin"

View File

@@ -10,6 +10,8 @@ from .models import (
Paechter, Paechter,
StiftungsKalenderEintrag, StiftungsKalenderEintrag,
StiftungsKonto, StiftungsKonto,
Veranstaltung,
Veranstaltungsteilnehmer,
Verwaltungskosten, Verwaltungskosten,
) )
@@ -86,3 +88,23 @@ class BankTransactionSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = BankTransaction model = BankTransaction
fields = "__all__" 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, PaechterViewSet,
StiftungsKalenderEintragViewSet, StiftungsKalenderEintragViewSet,
StiftungsKontoViewSet, StiftungsKontoViewSet,
VeranstaltungViewSet,
VerwaltungskostenViewSet, VerwaltungskostenViewSet,
) )
@@ -22,5 +23,6 @@ router.register(r"verpachtungen", LandVerpachtungViewSet)
router.register(r"verwaltungskosten", VerwaltungskostenViewSet) router.register(r"verwaltungskosten", VerwaltungskostenViewSet)
router.register(r"kalender", StiftungsKalenderEintragViewSet) router.register(r"kalender", StiftungsKalenderEintragViewSet)
router.register(r"transaktionen", BankTransactionViewSet) router.register(r"transaktionen", BankTransactionViewSet)
router.register(r"veranstaltungen", VeranstaltungViewSet)
urlpatterns = router.urls urlpatterns = router.urls

View File

@@ -9,6 +9,7 @@ from .api_serializers import (
PaechterSerializer, PaechterSerializer,
StiftungsKalenderEintragSerializer, StiftungsKalenderEintragSerializer,
StiftungsKontoSerializer, StiftungsKontoSerializer,
VeranstaltungSerializer,
VerwaltungskostenSerializer, VerwaltungskostenSerializer,
) )
from .models import ( from .models import (
@@ -20,6 +21,7 @@ from .models import (
Paechter, Paechter,
StiftungsKalenderEintrag, StiftungsKalenderEintrag,
StiftungsKonto, StiftungsKonto,
Veranstaltung,
Verwaltungskosten, Verwaltungskosten,
) )
@@ -67,3 +69,8 @@ class StiftungsKalenderEintragViewSet(ReadOnlyModelViewSet):
class BankTransactionViewSet(ReadOnlyModelViewSet): class BankTransactionViewSet(ReadOnlyModelViewSet):
queryset = BankTransaction.objects.all() queryset = BankTransaction.objects.all()
serializer_class = BankTransactionSerializer 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): def get_verpachtungsgrad_neu(self):
"""Berechnet den Verpachtungsgrad basierend auf neuen Verpachtungen""" """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 (self.get_verpachtete_flaeche_aktuell() / self.groesse_qm) * 100
return 0 return 0
@@ -3279,3 +3279,178 @@ class DestinataerEmailEingang(models.Model):
f"{base}/documents/{doc_id}/" f"{base}/documents/{doc_id}/"
for doc_id in (self.paperless_dokument_ids or []) 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, views.paperless_document_redirect,
name="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) # 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

@@ -31,7 +31,8 @@ from .models import (AppConfiguration, CSVImport, Destinataer,
DestinataerEmailEingang, DestinataerUnterstuetzung, DestinataerEmailEingang, DestinataerUnterstuetzung,
DokumentLink, Foerderung, Land, DokumentLink, Foerderung, Land,
LandAbrechnung, LandVerpachtung, Paechter, Person, LandAbrechnung, LandVerpachtung, Paechter, Person,
StiftungsKonto, UnterstuetzungWiederkehrend, VierteljahresNachweis) StiftungsKonto, UnterstuetzungWiederkehrend, Veranstaltung,
Veranstaltungsteilnehmer, VierteljahresNachweis)
def get_pdf_generator(): 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) return Response({"error": "Paperless API not configured"}, status=400)
# Remove /api suffix if present, then construct the document URL # 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) # For external Paperless (already includes /paperless/ in base URL)
return redirect(f"{base_url}/documents/{doc_id}/details/") 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) return render(request, "stiftung/verpachtung_list.html", context)
@login_required
@login_required @login_required
def land_verpachtung_detail(request, pk): def land_verpachtung_detail(request, pk):
"""Detail view for LandVerpachtung""" """Detail view for LandVerpachtung"""
@@ -2197,7 +2197,7 @@ def dokument_list(request):
available_dokumente = [] available_dokumente = []
if url and token: if url and token:
try: 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}"} headers = {"Authorization": f"Token {token}"}
# Alle verfügbaren Dokumente abrufen (mit Paginierung) # Alle verfügbaren Dokumente abrufen (mit Paginierung)
@@ -2553,7 +2553,7 @@ def paperless_ping(_request):
) )
try: try:
# Entferne /api vom Ende der URL falls vorhanden # 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( r = requests.get(
f"{base_url}/api/tags/", f"{base_url}/api/tags/",
headers={"Authorization": f"Token {token}"}, headers={"Authorization": f"Token {token}"},
@@ -2598,7 +2598,7 @@ def paperless_documents(request):
try: try:
# Entferne /api vom Ende der URL falls vorhanden # 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}"} headers = {"Authorization": f"Token {token}"}
def fetch_tagged(): def fetch_tagged():
@@ -2735,7 +2735,7 @@ def paperless_debug(request):
try: try:
# Entferne /api vom Ende der URL falls vorhanden # 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}"} headers = {"Authorization": f"Token {token}"}
@@ -2857,7 +2857,7 @@ def paperless_tags_only(request):
try: try:
# Entferne /api vom Ende der URL falls vorhanden # 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) # Alle Tags abrufen (mit großer page_size)
headers = {"Authorization": f"Token {token}"} headers = {"Authorization": f"Token {token}"}
@@ -4147,7 +4147,6 @@ def verwaltungskosten_create(request):
rentmeister = Rentmeister.objects.get(pk=rentmeister_id) rentmeister = Rentmeister.objects.get(pk=rentmeister_id)
initial_data["rentmeister"] = rentmeister initial_data["rentmeister"] = rentmeister
redirect_url = "stiftung:rentmeister_detail" redirect_url = "stiftung:rentmeister_detail"
redirect_args = [rentmeister_id]
except Rentmeister.DoesNotExist: except Rentmeister.DoesNotExist:
pass pass
@@ -8440,13 +8439,6 @@ def kalender_admin(request):
} }
return render(request, 'stiftung/kalender/admin.html', context) 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 @login_required
@@ -8642,3 +8634,54 @@ def email_eingang_poll_trigger(request):
except Exception as exc: except Exception as exc:
messages.error(request, f"Fehler beim Starten des Tasks: {exc}") messages.error(request, f"Fehler beim Starten des Tasks: {exc}")
return redirect("email_eingang_list") 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

View File

@@ -25,9 +25,9 @@
--orange-dark: #e8590c; --orange-dark: #e8590c;
} }
/* Global Typography - More Compact */ /* Global Typography */
html { html {
font-size: 14px; /* Reduced from default 16px */ font-size: 15px;
} }
body { body {
@@ -69,26 +69,6 @@
padding-right: 0.5rem; padding-right: 0.5rem;
} }
/* Compact margins */
.mb-1 { margin-bottom: 0.25rem !important; }
.mb-2 { margin-bottom: 0.4rem !important; }
.mb-3 { margin-bottom: 0.75rem !important; }
.mb-4 { margin-bottom: 1rem !important; }
.mb-5 { margin-bottom: 1.5rem !important; }
.mt-1 { margin-top: 0.25rem !important; }
.mt-2 { margin-top: 0.4rem !important; }
.mt-3 { margin-top: 0.75rem !important; }
.mt-4 { margin-top: 1rem !important; }
.mt-5 { margin-top: 1.5rem !important; }
.py-1 { padding-top: 0.25rem !important; padding-bottom: 0.25rem !important; }
.py-2 { padding-top: 0.4rem !important; padding-bottom: 0.4rem !important; }
.py-3 { padding-top: 0.75rem !important; padding-bottom: 0.75rem !important; }
.px-1 { padding-left: 0.25rem !important; padding-right: 0.25rem !important; }
.px-2 { padding-left: 0.4rem !important; padding-right: 0.4rem !important; }
.px-3 { padding-left: 0.75rem !important; padding-right: 0.75rem !important; }
.border-left-primary { .border-left-primary {
border-left: 0.25rem solid var(--racing-green) !important; border-left: 0.25rem solid var(--racing-green) !important;
@@ -209,19 +189,19 @@
margin-bottom: 0; margin-bottom: 0;
} }
/* Tables - More Compact */ /* Tables */
.table { .table {
font-size: 0.8rem; font-size: 0.85rem;
margin-bottom: 0.75rem; margin-bottom: 0.75rem;
} }
.table th { .table th {
border-top: none; border-top: none;
font-weight: 600; font-weight: 600;
color: var(--racing-green-dark); color: var(--racing-green-dark);
background-color: var(--grey-light); background-color: var(--grey-light);
padding: 0.5rem; padding: 0.5rem;
font-size: 0.75rem; font-size: 0.8rem;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.025em; letter-spacing: 0.025em;
} }
@@ -375,47 +355,61 @@
color: var(--orange-dark); color: var(--orange-dark);
} }
/* Responsive adjustments for very compact design */ /* Responsive adjustments */
@media (max-width: 768px) { @media (max-width: 768px) {
html { html {
font-size: 13px; font-size: 13px;
} }
body { body {
font-size: 0.8rem; font-size: 0.8rem;
} }
.navbar-brand { .navbar-brand {
font-size: 1rem; font-size: 1rem;
} }
.navbar-nav .nav-link { .navbar-nav .nav-link {
font-size: 0.75rem; font-size: 0.75rem;
padding: 0.25rem 0.375rem !important; padding: 0.25rem 0.375rem !important;
} }
.card-body { .card-body {
padding: 0.5rem; padding: 0.5rem;
} }
.btn { .btn {
font-size: 0.75rem; font-size: 0.75rem;
padding: 0.25rem 0.5rem; padding: 0.25rem 0.5rem;
} }
.table { .table {
font-size: 0.75rem; font-size: 0.75rem;
} }
.table th, .table th,
.table td { .table td {
padding: 0.375rem; padding: 0.375rem;
} }
} }
@media (min-width: 768px) and (max-width: 1200px) {
html {
font-size: 14px;
}
.container, .container-lg {
max-width: 100%;
}
.table {
font-size: 0.8rem;
}
}
@media (min-width: 1400px) { @media (min-width: 1400px) {
.container-lg { .container-lg {
max-width: 1400px; max-width: 1600px;
} }
} }
@@ -638,6 +632,23 @@
</ul> </ul>
</li> </li>
<!-- Veranstaltungen -->
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" id="veranstaltungenDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false">
<i class="fas fa-glass-cheers me-1"></i>Veranstaltungen
</a>
<ul class="dropdown-menu" aria-labelledby="veranstaltungenDropdown">
<li><a class="dropdown-item" href="{% url 'stiftung:veranstaltung_list' %}">
<i class="fas fa-list me-2"></i>Alle Veranstaltungen
</a></li>
<li><hr class="dropdown-divider"></li>
<li><h6 class="dropdown-header">Aktionen</h6></li>
<li><a class="dropdown-item" href="{% url 'stiftung:veranstaltung_list' %}">
<i class="fas fa-envelope-open-text me-2"></i>Serienbrief drucken
</a></li>
</ul>
</li>
<!-- Geschichte --> <!-- Geschichte -->
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="{% url 'stiftung:geschichte_list' %}"> <a class="nav-link" href="{% url 'stiftung:geschichte_list' %}">

View File

@@ -25,6 +25,10 @@
</div> </div>
<div class="col-md-4 text-end"> <div class="col-md-4 text-end">
<div class="btn-group" role="group"> <div class="btn-group" role="group">
<a href="https://www.tim-online.nrw.de/tim-online2/?WFS_gemarkung={{ land.gemarkung|urlencode }}&WFS_flur={{ land.flur|urlencode }}&WFS_flurstueck={{ land.flurstueck|urlencode }}"
class="btn btn-outline-success" title="TIM-Online NRW öffnen" target="_blank" rel="noopener">
<i class="fas fa-map-marked-alt me-2"></i>TIM-Online
</a>
<a href="{% url 'stiftung:land_update' land.pk %}" class="btn btn-warning"> <a href="{% url 'stiftung:land_update' land.pk %}" class="btn btn-warning">
<i class="fas fa-edit me-2"></i>Bearbeiten <i class="fas fa-edit me-2"></i>Bearbeiten
</a> </a>

View File

@@ -90,7 +90,7 @@
<div class="small text-muted">Grünland</div> <div class="small text-muted">Grünland</div>
</div> </div>
</div> </div>
<div style="height: 200px;"> <div style="min-height: 200px;">
<canvas id="usageChart"></canvas> <canvas id="usageChart"></canvas>
</div> </div>
</div> </div>
@@ -110,7 +110,7 @@
<div class="h6 mb-0">{{ stats.total_plots }}</div> <div class="h6 mb-0">{{ stats.total_plots }}</div>
<div class="small text-muted">Grundstücke gesamt</div> <div class="small text-muted">Grundstücke gesamt</div>
</div> </div>
<div style="height: 200px;"> <div style="min-height: 200px;">
<canvas id="sizesChart"></canvas> <canvas id="sizesChart"></canvas>
</div> </div>
</div> </div>
@@ -136,7 +136,7 @@
<div class="small text-muted">Verfügbar</div> <div class="small text-muted">Verfügbar</div>
</div> </div>
</div> </div>
<div style="height: 200px;"> <div style="min-height: 200px;">
<canvas id="verpachtungChart"></canvas> <canvas id="verpachtungChart"></canvas>
</div> </div>
</div> </div>
@@ -245,15 +245,19 @@
</td> </td>
<td> <td>
<div class="btn-group" role="group"> <div class="btn-group" role="group">
<a href="{% url 'stiftung:land_detail' land.pk %}" <a href="{% url 'stiftung:land_detail' land.pk %}"
class="btn btn-sm btn-outline-primary" title="Anzeigen"> class="btn btn-sm btn-outline-primary" title="Anzeigen">
<i class="fas fa-eye"></i> <i class="fas fa-eye"></i>
</a> </a>
<a href="{% url 'stiftung:land_update' land.pk %}" <a href="https://www.tim-online.nrw.de/tim-online2/?WFS_gemarkung={{ land.gemarkung|urlencode }}&WFS_flur={{ land.flur|urlencode }}&WFS_flurstueck={{ land.flurstueck|urlencode }}"
class="btn btn-sm btn-outline-success" title="TIM-Online NRW" target="_blank" rel="noopener">
<i class="fas fa-map-marked-alt"></i>
</a>
<a href="{% url 'stiftung:land_update' land.pk %}"
class="btn btn-sm btn-outline-warning" title="Bearbeiten"> class="btn btn-sm btn-outline-warning" title="Bearbeiten">
<i class="fas fa-edit"></i> <i class="fas fa-edit"></i>
</a> </a>
<a href="{% url 'stiftung:land_delete' land.pk %}" <a href="{% url 'stiftung:land_delete' land.pk %}"
class="btn btn-sm btn-outline-danger" title="Löschen"> class="btn btn-sm btn-outline-danger" title="Löschen">
<i class="fas fa-trash"></i> <i class="fas fa-trash"></i>
</a> </a>

View File

@@ -0,0 +1,199 @@
{% extends "base.html" %}
{% block title %}{{ veranstaltung.titel }} Stiftungsverwaltung{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<!-- Header -->
<div class="d-flex justify-content-between align-items-start mb-4">
<div>
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{% url 'stiftung:veranstaltung_list' %}">Veranstaltungen</a></li>
<li class="breadcrumb-item active">{{ veranstaltung.titel }}</li>
</ol>
</nav>
<h1 class="h3 mb-1">{{ veranstaltung.titel }}</h1>
<p class="text-muted mb-0">
{{ veranstaltung.datum|date:"l, d. F Y" }}{% if veranstaltung.uhrzeit %}, {{ veranstaltung.uhrzeit|time:"H:i" }} Uhr{% endif %}
&nbsp;·&nbsp; {{ veranstaltung.ort }}
</p>
</div>
<div class="d-flex gap-2">
<a href="{% url 'admin:stiftung_veranstaltung_change' 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">
<i class="fas fa-file-pdf me-1"></i>Serienbrief-PDF
</a>
</div>
</div>
<div class="row g-4">
<!-- Veranstaltungsdetails -->
<div class="col-lg-4">
<div class="card shadow-sm h-100">
<div class="card-header bg-dark text-white">
<i class="fas fa-info-circle me-2"></i>Details
</div>
<div class="card-body">
<dl class="row mb-0">
<dt class="col-sm-5">Status</dt>
<dd class="col-sm-7">
{% if veranstaltung.status == "geplant" %}
<span class="badge bg-secondary">Geplant</span>
{% elif veranstaltung.status == "einladungen_versendet" %}
<span class="badge bg-primary">Einladungen versendet</span>
{% elif veranstaltung.status == "abgeschlossen" %}
<span class="badge bg-success">Abgeschlossen</span>
{% elif veranstaltung.status == "abgesagt" %}
<span class="badge bg-danger">Abgesagt</span>
{% endif %}
</dd>
<dt class="col-sm-5">Gasthaus</dt>
<dd class="col-sm-7">{{ veranstaltung.ort }}</dd>
{% if veranstaltung.adresse %}
<dt class="col-sm-5">Adresse</dt>
<dd class="col-sm-7">{{ veranstaltung.adresse }}</dd>
{% endif %}
{% if veranstaltung.budget_pro_person %}
<dt class="col-sm-5">Budget/Person</dt>
<dd class="col-sm-7">{{ veranstaltung.budget_pro_person }} €</dd>
{% endif %}
{% if veranstaltung.beschreibung %}
<dt class="col-sm-5">Beschreibung</dt>
<dd class="col-sm-7">{{ veranstaltung.beschreibung }}</dd>
{% endif %}
</dl>
</div>
</div>
</div>
<!-- RSVP-Statistik -->
<div class="col-lg-4">
<div class="card shadow-sm h-100">
<div class="card-header bg-dark text-white">
<i class="fas fa-chart-pie me-2"></i>RSVP-Übersicht
</div>
<div class="card-body">
<div class="row text-center g-3">
<div class="col-6">
<div class="border rounded p-3">
<div class="fs-2 fw-bold text-primary">{{ teilnehmer.count }}</div>
<div class="small text-muted">Eingeladen gesamt</div>
</div>
</div>
<div class="col-6">
<div class="border rounded p-3">
<div class="fs-2 fw-bold text-success">{{ zugesagte.count }}</div>
<div class="small text-muted">Zugesagt</div>
</div>
</div>
<div class="col-6">
<div class="border rounded p-3">
<div class="fs-2 fw-bold text-danger">{{ abgesagte.count }}</div>
<div class="small text-muted">Abgesagt</div>
</div>
</div>
<div class="col-6">
<div class="border rounded p-3">
<div class="fs-2 fw-bold text-warning">{{ keine_rueckmeldung.count }}</div>
<div class="small text-muted">Keine Rückmeldung</div>
</div>
</div>
{% if eingeladen.count %}
<div class="col-12">
<div class="border rounded p-3">
<div class="fs-2 fw-bold text-secondary">{{ eingeladen.count }}</div>
<div class="small text-muted">Nur eingeladen (noch kein RSVP)</div>
</div>
</div>
{% endif %}
</div>
</div>
</div>
</div>
<!-- Schnellaktionen -->
<div class="col-lg-4">
<div class="card shadow-sm h-100">
<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_pdf' veranstaltung.pk %}"
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 }}"
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 %}"
class="btn btn-outline-secondary w-100">
<i class="fas fa-edit me-2"></i>Veranstaltung bearbeiten
</a>
</div>
</div>
</div>
</div>
<!-- Teilnehmerliste -->
<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>
</div>
<div class="card-body p-0">
{% if teilnehmer %}
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th>Name</th>
<th>Adresse</th>
<th>E-Mail</th>
<th>RSVP</th>
<th>Bemerkungen</th>
</tr>
</thead>
<tbody>
{% for t in teilnehmer %}
<tr>
<td>
{{ t.anrede }} {{ t.vorname }} {{ t.nachname }}
</td>
<td>
{% if t.strasse %}{{ t.strasse }},{% endif %}
{% if t.plz %}{{ t.plz }}{% endif %}
{% if t.ort %}{{ t.ort }}{% endif %}
</td>
<td>{% if t.email %}<a href="mailto:{{ t.email }}">{{ t.email }}</a>{% else %}{% endif %}</td>
<td>
{% if t.rsvp_status == "eingeladen" %}
<span class="badge bg-secondary">Eingeladen</span>
{% elif t.rsvp_status == "zugesagt" %}
<span class="badge bg-success">Zugesagt</span>
{% elif t.rsvp_status == "abgesagt" %}
<span class="badge bg-danger">Abgesagt</span>
{% elif t.rsvp_status == "keine_rueckmeldung" %}
<span class="badge bg-warning text-dark">Keine Rückmeldung</span>
{% endif %}
</td>
<td>{{ t.bemerkungen|default:"" }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<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 }}"
class="btn btn-primary">
<i class="fas fa-user-plus me-1"></i>Ersten Teilnehmer hinzufügen
</a>
</div>
{% endif %}
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,75 @@
{% extends "base.html" %}
{% block title %}Veranstaltungen Stiftungsverwaltung{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<div class="d-flex justify-content-between align-items-center mb-4">
<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">
<i class="fas fa-plus me-1"></i>Neue Veranstaltung
</a>
</div>
{% if veranstaltungen %}
<div class="card shadow-sm">
<div class="card-body p-0">
<table class="table table-hover mb-0">
<thead class="table-dark">
<tr>
<th>Titel</th>
<th>Datum</th>
<th>Ort / Gasthaus</th>
<th>Status</th>
<th>Teilnehmer</th>
<th>Zugesagt</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
{% for v in veranstaltungen %}
<tr>
<td>
<a href="{% url 'stiftung:veranstaltung_detail' v.pk %}">
<strong>{{ v.titel }}</strong>
</a>
</td>
<td>{{ v.datum|date:"d.m.Y" }}{% if v.uhrzeit %}, {{ v.uhrzeit|time:"H:i" }} Uhr{% endif %}</td>
<td>{{ v.ort }}</td>
<td>
{% if v.status == "geplant" %}
<span class="badge bg-secondary">Geplant</span>
{% elif v.status == "einladungen_versendet" %}
<span class="badge bg-primary">Einladungen versendet</span>
{% elif v.status == "abgeschlossen" %}
<span class="badge bg-success">Abgeschlossen</span>
{% elif v.status == "abgesagt" %}
<span class="badge bg-danger">Abgesagt</span>
{% endif %}
</td>
<td>{{ v.get_teilnehmer_count }}</td>
<td>{{ v.get_zugesagte_count }}</td>
<td>
<a href="{% url 'stiftung:veranstaltung_detail' v.pk %}" class="btn btn-sm btn-outline-secondary me-1">
<i class="fas fa-eye"></i>
</a>
<a href="{% url 'stiftung:veranstaltung_serienbrief_pdf' v.pk %}" class="btn btn-sm btn-outline-success">
<i class="fas fa-file-pdf"></i> Serienbrief
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% 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>
</div>
{% endif %}
</div>
{% endblock %}

View File

@@ -0,0 +1,197 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>Einladungen {{ veranstaltung.titel }}</title>
<style>
@page {
size: A4;
margin: 2cm 2.5cm 2cm 2.5cm;
}
body {
font-family: "Times New Roman", Times, serif;
font-size: 10pt;
line-height: 1.35;
color: #000;
}
.letter {
page-break-after: always;
}
.letter:last-child {
page-break-after: avoid;
}
/* Absenderzeile (klein, über Adressfeld) */
.absender-zeile {
font-size: 7.5pt;
border-bottom: 1px solid #000;
margin-bottom: 3pt;
padding-bottom: 1pt;
color: #444;
}
/* Empfängeradresse */
.empfaenger {
min-height: 35mm;
margin-bottom: 5mm;
}
.empfaenger p {
margin: 0;
line-height: 1.3;
}
/* Datum und Ort */
.datum-zeile {
text-align: right;
margin-bottom: 4mm;
}
/* Betreff */
.betreff {
font-weight: bold;
margin-bottom: 4mm;
}
/* Brieftext */
.brieftext p {
margin: 0 0 3mm 0;
}
/* Veranstaltungsblock (eingerückt) */
.veranstaltungs-block {
margin: 4mm 0 4mm 10mm;
font-weight: bold;
}
/* Unterschrift */
.unterschrift {
margin-top: 10mm;
display: table;
width: 100%;
}
.unterschrift-person {
display: inline-block;
width: 45%;
vertical-align: top;
}
.unterschrift-linie {
border-top: 1px solid #000;
margin-bottom: 2mm;
width: 80%;
}
.stiftungsname-header {
font-size: 12pt;
font-weight: bold;
margin-bottom: 1mm;
}
.stiftungsadresse {
font-size: 8.5pt;
color: #444;
margin-bottom: 5mm;
}
</style>
</head>
<body>
{% for t in teilnehmer %}
<div class="letter">
<!-- Stiftungskopf -->
<div class="stiftungsname-header">van Hees-Theyssen-Vogel'sche Stiftung</div>
<div class="stiftungsadresse">
Raesfelder Str. 3 &nbsp;·&nbsp; 46499 Hamminkeln
</div>
<!-- Empfänger -->
<div class="empfaenger">
<div class="absender-zeile">van Hees-Theyssen-Vogel'sche Stiftung · Raesfelder Str. 3 · 46499 Hamminkeln</div>
<p>{{ t.anrede }} {{ t.vorname }} {{ t.nachname }}</p>
{% if t.strasse %}<p>{{ t.strasse }}</p>{% endif %}
{% if t.plz or t.ort %}<p>{{ t.plz }} {{ t.ort }}</p>{% endif %}
</div>
<!-- Datum -->
<div class="datum-zeile">
Hamminkeln, den {{ veranstaltung.datum|date:"j. F Y" }}
</div>
<!-- Betreff -->
<div class="betreff">
{% if veranstaltung.betreff %}{{ veranstaltung.betreff }}{% else %}Einladung zum {{ veranstaltung.titel }}{% endif %}
</div>
<!-- Anrede -->
<div class="brieftext">
<p>
Sehr geehrte{% if t.anrede == "Herr" %}r Herr{% elif t.anrede == "Frau" %} Frau{% else %}
{{ t.anrede }}{% endif %} {{ t.nachname }},
</p>
<!-- Entweder freie Briefvorlage oder Standardtext -->
{% 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 class="veranstaltungs-block">
{{ 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>
Am Abend werden wir Ihnen einen Überblick über das abgelaufene Wirtschaftsjahr 2025
der Stiftung geben und gemeinsam das Abendessen genießen. Es bietet sich die
Gelegenheit zum persönlichen Austausch.
</p>
<p>
Bitte teilen Sie uns bis zum <strong>4. April 2026</strong> mit, ob Sie an der
Veranstaltung teilnehmen werden. Eine Rückmeldung per Post an die oben genannte
Adresse ist erbeten.
</p>
<p>Wir freuen uns auf Ihr Kommen.</p>
{% endif %}
<p>Mit freundlichen Grüßen</p>
</div>
<!-- Unterschriften -->
<div class="unterschrift">
{% if veranstaltung.unterschrift_1_name %}
<div class="unterschrift-person">
<div class="unterschrift-linie"></div>
{{ veranstaltung.unterschrift_1_name }}<br>
{{ veranstaltung.unterschrift_1_titel }}<br>
van Hees-Theyssen-Vogel'sche Stiftung
</div>
{% endif %}
{% if veranstaltung.unterschrift_2_name %}
<div class="unterschrift-person">
<div class="unterschrift-linie"></div>
{{ veranstaltung.unterschrift_2_name }}<br>
{{ veranstaltung.unterschrift_2_titel }}<br>
van Hees-Theyssen-Vogel'sche Stiftung
</div>
{% endif %}
</div>
</div>
{% endfor %}
</body>
</html>

View File

@@ -7,6 +7,7 @@ services:
POSTGRES_PASSWORD: postgres_dev POSTGRES_PASSWORD: postgres_dev
volumes: volumes:
- dbdata_dev:/var/lib/postgresql/data - dbdata_dev:/var/lib/postgresql/data
- ./scripts/init-paperless-db.sh:/docker-entrypoint-initdb.d/init-paperless-db.sh
ports: ports:
- "5433:5432" - "5433:5432"
healthcheck: healthcheck:
@@ -40,7 +41,7 @@ services:
- TIME_ZONE=Europe/Berlin - TIME_ZONE=Europe/Berlin
- REDIS_URL=redis://redis:6379/0 - REDIS_URL=redis://redis:6379/0
- PAPERLESS_API_URL=http://paperless:8000 - PAPERLESS_API_URL=http://paperless:8000
- PAPERLESS_API_TOKEN=d477152aca264ea00620910ac09a06f0a4faaecc - PAPERLESS_API_TOKEN=1972509e25810d9ae7497c1c79ecfea9e942f18d
- PAPERLESS_REQUIRED_TAG=Stiftung_Destinatäre - PAPERLESS_REQUIRED_TAG=Stiftung_Destinatäre
- PAPERLESS_LAND_TAG=Stiftung_Land_und_Pächter - PAPERLESS_LAND_TAG=Stiftung_Land_und_Pächter
- PAPERLESS_ADMIN_TAG=Stiftung_Administration - PAPERLESS_ADMIN_TAG=Stiftung_Administration
@@ -64,12 +65,12 @@ services:
- PAPERLESS_REDIS=redis://redis:6379 - PAPERLESS_REDIS=redis://redis:6379
- PAPERLESS_DBHOST=db - PAPERLESS_DBHOST=db
- PAPERLESS_DBPORT=5432 - PAPERLESS_DBPORT=5432
- PAPERLESS_DBNAME=stiftung_dev - PAPERLESS_DBNAME=paperless_dev
- PAPERLESS_DBUSER=postgres - PAPERLESS_DBUSER=postgres
- PAPERLESS_DBPASS=postgres_dev - PAPERLESS_DBPASS=postgres_dev
- PAPERLESS_SECRET_KEY=dev-paperless-secret-key - PAPERLESS_SECRET_KEY=dev-paperless-secret-key
- PAPERLESS_URL=http://localhost:8082 - PAPERLESS_URL=http://localhost:8082
- PAPERLESS_ALLOWED_HOSTS=localhost,127.0.0.1 - PAPERLESS_ALLOWED_HOSTS=localhost,127.0.0.1,paperless
- PAPERLESS_CORS_ALLOWED_HOSTS=http://localhost:8082,http://localhost:8081 - PAPERLESS_CORS_ALLOWED_HOSTS=http://localhost:8082,http://localhost:8081
- PAPERLESS_ADMIN_USER=admin - PAPERLESS_ADMIN_USER=admin
- PAPERLESS_ADMIN_PASSWORD=admin123 - PAPERLESS_ADMIN_PASSWORD=admin123

View File

@@ -15,6 +15,7 @@ services:
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes: volumes:
- dbdata:/var/lib/postgresql/data - dbdata:/var/lib/postgresql/data
- ./scripts/init-paperless-db.sh:/docker-entrypoint-initdb.d/init-paperless-db.sh
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"] test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
interval: 10s interval: 10s
@@ -157,12 +158,12 @@ services:
- PAPERLESS_REDIS=redis://redis:6379 - PAPERLESS_REDIS=redis://redis:6379
- PAPERLESS_DBHOST=db - PAPERLESS_DBHOST=db
- PAPERLESS_DBPORT=5432 - PAPERLESS_DBPORT=5432
- PAPERLESS_DBNAME=${POSTGRES_DB} - PAPERLESS_DBNAME=${PAPERLESS_DBNAME:-paperless}
- PAPERLESS_DBUSER=${POSTGRES_USER} - PAPERLESS_DBUSER=${POSTGRES_USER}
- PAPERLESS_DBPASS=${POSTGRES_PASSWORD} - PAPERLESS_DBPASS=${POSTGRES_PASSWORD}
- PAPERLESS_SECRET_KEY=${PAPERLESS_SECRET_KEY} - PAPERLESS_SECRET_KEY=${PAPERLESS_SECRET_KEY}
- PAPERLESS_URL=https://vhtv-stiftung.de - PAPERLESS_URL=https://vhtv-stiftung.de
- PAPERLESS_ALLOWED_HOSTS=vhtv-stiftung.de,localhost - PAPERLESS_ALLOWED_HOSTS=vhtv-stiftung.de,localhost,paperless
- PAPERLESS_CORS_ALLOWED_HOSTS=https://vhtv-stiftung.de - PAPERLESS_CORS_ALLOWED_HOSTS=https://vhtv-stiftung.de
- PAPERLESS_FORCE_SCRIPT_NAME=/paperless - PAPERLESS_FORCE_SCRIPT_NAME=/paperless
- PAPERLESS_STATIC_URL=/paperless/static/ - PAPERLESS_STATIC_URL=/paperless/static/

View File

@@ -16,6 +16,7 @@ services:
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes: volumes:
- dbdata:/var/lib/postgresql/data - dbdata:/var/lib/postgresql/data
- ./scripts/init-paperless-db.sh:/docker-entrypoint-initdb.d/init-paperless-db.sh
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"] test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
interval: 10s interval: 10s
@@ -125,12 +126,12 @@ services:
- PAPERLESS_REDIS=redis://redis:6379 - PAPERLESS_REDIS=redis://redis:6379
- PAPERLESS_DBHOST=db - PAPERLESS_DBHOST=db
- PAPERLESS_DBPORT=5432 - PAPERLESS_DBPORT=5432
- PAPERLESS_DBNAME=${POSTGRES_DB} - PAPERLESS_DBNAME=${PAPERLESS_DBNAME:-paperless}
- PAPERLESS_DBUSER=${POSTGRES_USER} - PAPERLESS_DBUSER=${POSTGRES_USER}
- PAPERLESS_DBPASS=${POSTGRES_PASSWORD} - PAPERLESS_DBPASS=${POSTGRES_PASSWORD}
- PAPERLESS_SECRET_KEY=${PAPERLESS_SECRET_KEY} - PAPERLESS_SECRET_KEY=${PAPERLESS_SECRET_KEY}
- PAPERLESS_URL=https://vhtv-stiftung.de - PAPERLESS_URL=https://vhtv-stiftung.de
- PAPERLESS_ALLOWED_HOSTS=vhtv-stiftung.de,localhost - PAPERLESS_ALLOWED_HOSTS=vhtv-stiftung.de,localhost,paperless
- PAPERLESS_CORS_ALLOWED_HOSTS=https://vhtv-stiftung.de - PAPERLESS_CORS_ALLOWED_HOSTS=https://vhtv-stiftung.de
- PAPERLESS_FORCE_SCRIPT_NAME=/paperless - PAPERLESS_FORCE_SCRIPT_NAME=/paperless
- PAPERLESS_STATIC_URL=/paperless/static/ - PAPERLESS_STATIC_URL=/paperless/static/

View File

@@ -38,7 +38,7 @@ CSRF_COOKIE_NAME=stiftung_csrftoken
REDIS_URL=redis://redis:6379/0 REDIS_URL=redis://redis:6379/0
# PAPERLESS CONFIGURATION # PAPERLESS CONFIGURATION
PAPERLESS_API_URL=http://paperless:8000/api PAPERLESS_API_URL=http://paperless:8000
PAPERLESS_API_TOKEN=your_paperless_api_token_here PAPERLESS_API_TOKEN=your_paperless_api_token_here
PAPERLESS_SECRET_KEY=your_paperless_secret_key_here PAPERLESS_SECRET_KEY=your_paperless_secret_key_here
PAPERLESS_ADMIN_USER=admin PAPERLESS_ADMIN_USER=admin

11
scripts/init-paperless-db.sh Executable file
View File

@@ -0,0 +1,11 @@
#!/bin/bash
set -e
# Create separate database for Paperless-NGX if it doesn't exist
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL
SELECT 'CREATE DATABASE paperless_dev'
WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = 'paperless_dev')\gexec
SELECT 'CREATE DATABASE paperless'
WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = 'paperless')\gexec
EOSQL