From 28621d2774defa7879977936b9b555e88261ad7c Mon Sep 17 00:00:00 2001 From: SysAdmin Agent Date: Tue, 10 Mar 2026 22:36:58 +0000 Subject: [PATCH] feat: Veranstaltungsmodul + Serienbrief mit editierbaren Feldern (STI-35, STI-39) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .gitignore | 5 +- agents/dog/AGENTS.md | 30 +++ app/core/settings.py | 1 + app/requirements.txt | 1 + app/stiftung/admin.py | 104 +++++++++ app/stiftung/api_serializers.py | 22 ++ app/stiftung/api_urls.py | 2 + app/stiftung/api_views.py | 7 + .../migrations/0044_veranstaltungsmodul.py | 61 ++++++ .../0045_add_serienbrief_editable_fields.py | 43 ++++ app/stiftung/models.py | 177 +++++++++++++++- app/stiftung/urls.py | 8 + app/stiftung/views.py | 75 +++++-- app/templates/base.html | 83 ++++---- app/templates/stiftung/land_detail.html | 4 + app/templates/stiftung/land_list.html | 16 +- .../stiftung/veranstaltung/detail.html | 199 ++++++++++++++++++ .../stiftung/veranstaltung/list.html | 75 +++++++ .../veranstaltung/serienbrief_pdf.html | 197 +++++++++++++++++ compose.dev.yml | 7 +- compose.yml | 5 +- deploy-production/docker-compose.prod.yml | 5 +- env-production.template | 2 +- scripts/init-paperless-db.sh | 11 + 24 files changed, 1072 insertions(+), 68 deletions(-) create mode 100644 agents/dog/AGENTS.md create mode 100644 app/stiftung/migrations/0044_veranstaltungsmodul.py create mode 100644 app/stiftung/migrations/0045_add_serienbrief_editable_fields.py create mode 100644 app/templates/stiftung/veranstaltung/detail.html create mode 100644 app/templates/stiftung/veranstaltung/list.html create mode 100644 app/templates/stiftung/veranstaltung/serienbrief_pdf.html create mode 100755 scripts/init-paperless-db.sh diff --git a/.gitignore b/.gitignore index e84b833..6bb5ac8 100644 --- a/.gitignore +++ b/.gitignore @@ -138,4 +138,7 @@ dev-debug.log # Task files # tasks.json -# tasks/ +# tasks/ + +# Claude Code local config +.claude/ diff --git a/agents/dog/AGENTS.md b/agents/dog/AGENTS.md new file mode 100644 index 0000000..7a05efe --- /dev/null +++ b/agents/dog/AGENTS.md @@ -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 diff --git a/app/core/settings.py b/app/core/settings.py index b874a11..d6d65ff 100644 --- a/app/core/settings.py +++ b/app/core/settings.py @@ -103,6 +103,7 @@ DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" REST_FRAMEWORK = { "DEFAULT_AUTHENTICATION_CLASSES": [ + "rest_framework.authentication.SessionAuthentication", "rest_framework.authentication.TokenAuthentication", ], "DEFAULT_PERMISSION_CLASSES": [ diff --git a/app/requirements.txt b/app/requirements.txt index 2e72461..df51b84 100644 --- a/app/requirements.txt +++ b/app/requirements.txt @@ -4,6 +4,7 @@ celery==5.3.6 redis==5.0.7 djangorestframework==3.15.2 weasyprint==62.3 +pydyf==0.11.0 python-dotenv==1.0.1 requests==2.32.3 gunicorn==22.0.0 diff --git a/app/stiftung/admin.py b/app/stiftung/admin.py index 140b6c9..97fe515 100644 --- a/app/stiftung/admin.py +++ b/app/stiftung/admin.py @@ -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( + 'Serienbrief-PDF generieren', 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" diff --git a/app/stiftung/api_serializers.py b/app/stiftung/api_serializers.py index 8c0dc35..804ed32 100644 --- a/app/stiftung/api_serializers.py +++ b/app/stiftung/api_serializers.py @@ -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__" diff --git a/app/stiftung/api_urls.py b/app/stiftung/api_urls.py index f059c6d..010690e 100644 --- a/app/stiftung/api_urls.py +++ b/app/stiftung/api_urls.py @@ -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 diff --git a/app/stiftung/api_views.py b/app/stiftung/api_views.py index ef46365..2d5fc4e 100644 --- a/app/stiftung/api_views.py +++ b/app/stiftung/api_views.py @@ -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 diff --git a/app/stiftung/migrations/0044_veranstaltungsmodul.py b/app/stiftung/migrations/0044_veranstaltungsmodul.py new file mode 100644 index 0000000..0076ddb --- /dev/null +++ b/app/stiftung/migrations/0044_veranstaltungsmodul.py @@ -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'], + }, + ), + ] diff --git a/app/stiftung/migrations/0045_add_serienbrief_editable_fields.py b/app/stiftung/migrations/0045_add_serienbrief_editable_fields.py new file mode 100644 index 0000000..e4b3d04 --- /dev/null +++ b/app/stiftung/migrations/0045_add_serienbrief_editable_fields.py @@ -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'), + ), + ] diff --git a/app/stiftung/models.py b/app/stiftung/models.py index 2e6273f..462ee71 100644 --- a/app/stiftung/models.py +++ b/app/stiftung/models.py @@ -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) diff --git a/app/stiftung/urls.py b/app/stiftung/urls.py index ba1c2ee..01277c2 100644 --- a/app/stiftung/urls.py +++ b/app/stiftung/urls.py @@ -359,6 +359,14 @@ urlpatterns = [ views.paperless_document_redirect, name="paperless_document_redirect", ), + # Veranstaltungsmodul + path("veranstaltungen/", views.veranstaltung_list, name="veranstaltung_list"), + path("veranstaltungen//", views.veranstaltung_detail, name="veranstaltung_detail"), + path( + "veranstaltungen//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"), diff --git a/app/stiftung/views.py b/app/stiftung/views.py index d2d07d7..578ea2f 100644 --- a/app/stiftung/views.py +++ b/app/stiftung/views.py @@ -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 diff --git a/app/templates/base.html b/app/templates/base.html index 890b65d..4e7ebc4 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -25,9 +25,9 @@ --orange-dark: #e8590c; } - /* Global Typography - More Compact */ + /* Global Typography */ html { - font-size: 14px; /* Reduced from default 16px */ + font-size: 15px; } body { @@ -69,26 +69,6 @@ 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: 0.25rem solid var(--racing-green) !important; @@ -209,19 +189,19 @@ margin-bottom: 0; } - /* Tables - More Compact */ + /* Tables */ .table { - font-size: 0.8rem; + font-size: 0.85rem; margin-bottom: 0.75rem; } - + .table th { border-top: none; font-weight: 600; color: var(--racing-green-dark); background-color: var(--grey-light); padding: 0.5rem; - font-size: 0.75rem; + font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.025em; } @@ -375,47 +355,61 @@ color: var(--orange-dark); } - /* Responsive adjustments for very compact design */ + /* Responsive adjustments */ @media (max-width: 768px) { html { font-size: 13px; } - + body { font-size: 0.8rem; } - + .navbar-brand { font-size: 1rem; } - + .navbar-nav .nav-link { font-size: 0.75rem; padding: 0.25rem 0.375rem !important; } - + .card-body { padding: 0.5rem; } - + .btn { font-size: 0.75rem; padding: 0.25rem 0.5rem; } - + .table { font-size: 0.75rem; } - + .table th, .table td { 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) { .container-lg { - max-width: 1400px; + max-width: 1600px; } } @@ -638,6 +632,23 @@ + + +