Files
stiftung-management-system/app/stiftung/admin.py
SysAdmin Agent 28621d2774
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
feat: Veranstaltungsmodul + Serienbrief mit editierbaren Feldern (STI-35, STI-39)
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>
2026-03-10 22:36:58 +00:00

1339 lines
40 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from django.contrib import admin
from django.db.models import Count, Sum
from django.urls import reverse
from django.utils import timezone
from django.utils.html import format_html
from django.utils.safestring import mark_safe
from . import models
from .models import (AppConfiguration, AuditLog, BackupJob, BankTransaction,
CSVImport, Destinataer, DestinataerEmailEingang,
DestinataerUnterstuetzung,
DokumentLink, Foerderung, Land, LandVerpachtung, Paechter, Person,
Rentmeister, StiftungsKonto, UnterstuetzungWiederkehrend,
Veranstaltung, Veranstaltungsteilnehmer,
Verwaltungskosten, VierteljahresNachweis)
@admin.register(CSVImport)
class CSVImportAdmin(admin.ModelAdmin):
list_display = [
"import_type",
"filename",
"status",
"total_rows",
"imported_rows",
"failed_rows",
"created_by",
"started_at",
"duration_display",
]
list_filter = ["import_type", "status", "started_at"]
search_fields = ["filename", "created_by"]
readonly_fields = ["id", "started_at", "completed_at", "get_success_rate"]
ordering = ["-started_at"]
fieldsets = (
(
"Grundinformationen",
{"fields": ("import_type", "filename", "file_size", "status")},
),
(
"Ergebnisse",
{
"fields": (
"total_rows",
"imported_rows",
"failed_rows",
"get_success_rate",
"error_log",
)
},
),
("Metadaten", {"fields": ("created_by", "started_at", "completed_at")}),
)
def duration_display(self, obj):
duration = obj.get_duration()
if duration:
return f"{duration.total_seconds():.1f}s"
return "-"
duration_display.short_description = "Dauer"
def get_success_rate(self, obj):
rate = obj.get_success_rate()
if rate >= 90:
color = "success"
elif rate >= 70:
color = "warning"
else:
color = "danger"
return format_html('<span class="badge bg-{}">{:.1f}%</span>', color, rate)
get_success_rate.short_description = "Erfolgsrate"
@admin.register(Person)
class PersonAdmin(admin.ModelAdmin):
list_display = [
"nachname",
"vorname",
"familienzweig",
"geburtsdatum",
"email",
"iban_display",
]
list_filter = ["familienzweig", "geburtsdatum"]
search_fields = ["nachname", "vorname", "email", "familienzweig"]
ordering = ["nachname", "vorname"]
readonly_fields = ["id"]
fieldsets = (
(
"Persönliche Daten",
{"fields": ("vorname", "nachname", "geburtsdatum", "email", "telefon")},
),
("Stiftungsdaten", {"fields": ("familienzweig", "iban", "adresse")}),
("Zusätzlich", {"fields": ("notizen", "aktiv")}),
("System", {"fields": ("id",), "classes": ("collapse",)}),
)
def iban_display(self, obj):
if obj.iban:
return format_html(
'<span style="font-family: monospace;">{}</span>', obj.iban
)
return "-"
iban_display.short_description = "IBAN"
def get_queryset(self, request):
return (
super()
.get_queryset(request)
.annotate(total_foerderungen=Sum("foerderung__betrag"))
)
@admin.register(Paechter)
class PaechterAdmin(admin.ModelAdmin):
list_display = [
"nachname",
"vorname",
"pachtnummer",
"pachtzins_aktuell",
"landwirtschaftliche_ausbildung",
"aktiv",
]
list_filter = ["landwirtschaftliche_ausbildung", "aktiv"]
search_fields = ["nachname", "vorname", "email", "pachtnummer"]
ordering = ["nachname", "vorname"]
readonly_fields = ["id"]
fieldsets = (
(
"Persönliche Daten",
{"fields": ("vorname", "nachname", "geburtsdatum", "email", "telefon")},
),
(
"Pacht-Informationen",
{
"fields": (
"pachtnummer",
"pachtbeginn_erste",
"pachtende_letzte",
"pachtzins_aktuell",
)
},
),
(
"Landwirtschaftliche Qualifikation",
{
"fields": (
"landwirtschaftliche_ausbildung",
"berufserfahrung_jahre",
"spezialisierung",
)
},
),
("Kontaktdaten", {"fields": ("iban", "strasse", "plz", "ort")}),
("Pächter-Typ", {"fields": ("personentyp",)}),
("Zusätzlich", {"fields": ("notizen", "aktiv")}),
("System", {"fields": ("id",), "classes": ("collapse",)}),
)
def iban_display(self, obj):
if obj.iban:
return format_html(
'<span style="font-family: monospace;">{}</span>', obj.iban
)
return "-"
iban_display.short_description = "IBAN"
@admin.register(Destinataer)
class DestinataerAdmin(admin.ModelAdmin):
list_display = [
"nachname",
"vorname",
"familienzweig",
"berufsgruppe",
"institution",
"finanzielle_notlage",
"aktiv",
]
list_filter = ["familienzweig", "berufsgruppe", "finanzielle_notlage", "aktiv"]
search_fields = ["nachname", "vorname", "email", "institution", "familienzweig"]
ordering = ["nachname", "vorname"]
readonly_fields = ["id"]
fieldsets = (
(
"Persönliche Daten",
{"fields": ("vorname", "nachname", "geburtsdatum", "email", "telefon")},
),
(
"Berufliche Informationen",
{"fields": ("berufsgruppe", "ausbildungsstand", "institution")},
),
(
"Projekt & Finanzen",
{
"fields": (
"projekt_beschreibung",
"jaehrliches_einkommen",
"finanzielle_notlage",
)
},
),
(
"Stiftungsdaten",
{"fields": ("familienzweig", "iban", "strasse", "plz", "ort")},
),
("Zusätzlich", {"fields": ("notizen", "aktiv")}),
("System", {"fields": ("id",), "classes": ("collapse",)}),
)
def iban_display(self, obj):
if obj.iban:
return format_html(
'<span style="font-family: monospace;">{}</span>', obj.iban
)
return "-"
iban_display.short_description = "IBAN"
@admin.register(Land)
class LandAdmin(admin.ModelAdmin):
list_display = [
"lfd_nr",
"gemeinde",
"gemarkung",
"flur",
"flurstueck",
"groesse_qm",
"verp_flaeche_aktuell",
"verpachtungsgrad_display",
"aktiv",
]
list_filter = ["gemeinde", "gemarkung", "aktiv"]
search_fields = ["lfd_nr", "gemeinde", "gemarkung", "flur", "flurstueck"]
ordering = ["gemeinde", "gemarkung", "flur", "flurstueck"]
readonly_fields = ["id", "gesamtflaeche_berechnet", "verpachtungsgrad_berechnet"]
fieldsets = (
("Identifikation", {"fields": ("lfd_nr", "ew_nummer")}),
("Gerichtliche Zuständigkeit", {"fields": ("amtsgericht",)}),
(
"Verwaltungsstruktur",
{"fields": ("gemeinde", "gemarkung", "flur", "flurstueck")},
),
(
"Flächenangaben",
{
"fields": (
"groesse_qm",
"gruenland_qm",
"acker_qm",
"wald_qm",
"sonstiges_qm",
)
},
),
(
"Verpachtung",
{
"fields": (
"verpachtete_gesamtflaeche",
"flaeche_alte_liste",
"verp_flaeche_aktuell",
)
},
),
("Steuern und Abgaben", {"fields": ("anteil_grundsteuer", "anteil_lwk")}),
("Status", {"fields": ("aktiv", "notizen")}),
(
"System",
{
"fields": ("id", "erstellt_am", "aktualisiert_am"),
"classes": ("collapse",),
},
),
)
def verpachtungsgrad_display(self, obj):
grad = obj.get_verpachtungsgrad()
if grad > 90:
color = "green"
elif grad > 70:
color = "orange"
else:
color = "red"
return format_html('<span style="color: {};">{:.1f}%</span>', color, grad)
verpachtungsgrad_display.short_description = "Verpachtungsgrad"
def gesamtflaeche_berechnet(self, obj):
return f"{obj.get_gesamtflaeche():.2f} qm"
gesamtflaeche_berechnet.short_description = "Berechnete Gesamtfläche"
def verpachtungsgrad_berechnet(self, obj):
return f"{obj.get_verpachtungsgrad():.1f}%"
verpachtungsgrad_berechnet.short_description = "Verpachtungsgrad"
@admin.register(LandVerpachtung)
class LandVerpachtungAdmin(admin.ModelAdmin):
list_display = [
"land",
"paechter",
"pachtzins_pauschal",
"pachtbeginn",
"pachtende",
"status_display",
"erstellt_am",
]
list_filter = ["status", "pachtbeginn", "pachtende", "erstellt_am"]
search_fields = ["land__lfd_nr", "land__gemeinde", "paechter__vorname", "paechter__nachname", "vertragsnummer"]
ordering = ["-erstellt_am"]
readonly_fields = ["id", "erstellt_am", "aktualisiert_am"]
fieldsets = (
("Verpachtungsdetails", {
"fields": ("land", "paechter", "vertragsnummer", "status")
}),
("Laufzeit", {
"fields": ("pachtbeginn", "pachtende", "verlaengerung_klausel")
}),
("Fläche", {
"fields": ("verpachtete_flaeche",)
}),
("Pachtzins", {
"fields": ("pachtzins_pauschal", "pachtzins_pro_ha", "zahlungsweise")
}),
("Umsatzsteuer", {
"fields": ("ust_option", "ust_satz"),
"classes": ("collapse",)
}),
("Umlagen", {
"fields": ("grundsteuer_umlage", "versicherungen_umlage", "verbandsbeitraege_umlage", "jagdpacht_anteil_umlage"),
"classes": ("collapse",)
}),
("Zusatzinformationen", {
"fields": ("bemerkungen",),
"classes": ("collapse",)
}),
("System", {
"fields": ("id", "erstellt_am", "aktualisiert_am"),
"classes": ("collapse",)
}),
)
def status_display(self, obj):
colors = {
'aktiv': 'green',
'beendet': 'red',
'geplant': 'orange',
'gekündigt': 'red'
}
color = colors.get(obj.status, 'black')
return format_html(
'<span style="color: {}; font-weight: bold;">{}</span>',
color,
obj.get_status_display()
)
status_display.short_description = "Status"
@admin.register(DokumentLink)
class DokumentLinkAdmin(admin.ModelAdmin):
list_display = ["titel", "kontext", "paperless_document_id"]
list_filter = ["kontext"]
search_fields = ["titel", "kontext"]
ordering = ["titel"]
readonly_fields = ["id"]
fieldsets = (
(
"Dokument",
{"fields": ("titel", "kontext", "paperless_document_id", "beschreibung")},
),
("System", {"fields": ("id",), "classes": ("collapse",)}),
)
@admin.register(Foerderung)
class FoerderungAdmin(admin.ModelAdmin):
list_display = [
"destinataer",
"jahr",
"betrag",
"verwendungsnachweis_link",
"total_for_destinataer",
]
list_filter = ["jahr", "destinataer__familienzweig"]
search_fields = [
"destinataer__nachname",
"destinataer__vorname",
"destinataer__familienzweig",
]
ordering = ["-jahr", "-betrag"]
readonly_fields = ["id"]
fieldsets = (
(
"Förderung",
{
"fields": (
"destinataer",
"person",
"jahr",
"betrag",
"kategorie",
"status",
)
},
),
("Dokumentation", {"fields": ("verwendungsnachweis", "bemerkungen")}),
("Daten", {"fields": ("antragsdatum", "entscheidungsdatum")}),
("System", {"fields": ("id",), "classes": ("collapse",)}),
)
def verwendungsnachweis_link(self, obj):
if obj.verwendungsnachweis:
return format_html(
'<a href="{}">{}</a>',
reverse(
"admin:stiftung_dokumentlink_change",
args=[obj.verwendungsnachweis.id],
),
obj.verwendungsnachweis.titel,
)
return "-"
verwendungsnachweis_link.short_description = "Verwendungsnachweis"
def total_for_destinataer(self, obj):
total = (
Foerderung.objects.filter(destinataer=obj.destinataer).aggregate(
Sum("betrag")
)["betrag__sum"]
or 0
)
return f"{total:,.2f}"
total_for_destinataer.short_description = "Gesamt für Destinatär"
@admin.register(Rentmeister)
class RentmeisterAdmin(admin.ModelAdmin):
list_display = [
"__str__",
"email",
"telefon",
"seit_datum",
"bis_datum",
"aktiv",
"monatliche_verguetung",
]
list_filter = ["aktiv", "seit_datum", "anrede"]
search_fields = ["vorname", "nachname", "email", "telefon", "ort"]
ordering = ["nachname", "vorname"]
readonly_fields = ["id", "erstellt_am", "aktualisiert_am"]
fieldsets = (
("Persönliche Daten", {"fields": ("anrede", "vorname", "nachname", "titel")}),
(
"Kontaktdaten",
{"fields": ("email", "telefon", "mobil", "strasse", "plz", "ort")},
),
(
"Bankdaten",
{"fields": ("iban", "bic", "bank_name"), "classes": ["collapse"]},
),
(
"Stiftungsdaten",
{
"fields": (
"seit_datum",
"bis_datum",
"aktiv",
"monatliche_verguetung",
"km_pauschale",
)
},
),
(
"Zusätzliche Informationen",
{"fields": ("notizen",), "classes": ["collapse"]},
),
(
"System",
{
"fields": ("id", "erstellt_am", "aktualisiert_am"),
"classes": ["collapse"],
},
),
)
@admin.register(StiftungsKonto)
class StiftungsKontoAdmin(admin.ModelAdmin):
list_display = [
"kontoname",
"bank_name",
"konto_typ",
"saldo",
"saldo_datum",
"aktiv",
]
list_filter = ["konto_typ", "aktiv", "bank_name"]
search_fields = ["kontoname", "bank_name", "iban"]
ordering = ["bank_name", "kontoname"]
readonly_fields = ["id", "erstellt_am", "aktualisiert_am"]
fieldsets = (
(
"Kontodaten",
{"fields": ("kontoname", "bank_name", "iban", "bic", "konto_typ")},
),
(
"Finanzdaten",
{"fields": ("saldo", "saldo_datum", "zinssatz", "laufzeit_bis")},
),
("Status", {"fields": ("aktiv", "notizen")}),
(
"System",
{
"fields": ("id", "erstellt_am", "aktualisiert_am"),
"classes": ["collapse"],
},
),
)
@admin.register(Verwaltungskosten)
class VerwaltungskostenAdmin(admin.ModelAdmin):
list_display = [
"bezeichnung",
"kategorie",
"betrag",
"datum",
"status",
"rentmeister",
"konto",
]
list_filter = ["kategorie", "status", "datum", "rentmeister", "konto"]
search_fields = [
"bezeichnung",
"lieferant_firma",
"rechnungsnummer",
"beschreibung",
]
ordering = ["-datum", "-erstellt_am"]
readonly_fields = ["id", "erstellt_am", "aktualisiert_am"]
date_hierarchy = "datum"
fieldsets = (
(
"Grunddaten",
{"fields": ("bezeichnung", "kategorie", "betrag", "datum", "status")},
),
("Zuordnung", {"fields": ("rentmeister", "konto")}),
(
"Lieferant/Rechnung",
{"fields": ("lieferant_firma", "rechnungsnummer"), "classes": ["collapse"]},
),
(
"Fahrtkosten",
{
"fields": ("km_anzahl", "km_satz", "von_ort", "nach_ort", "zweck"),
"classes": ["collapse"],
"description": 'Nur für Kategorie "Fahrtkosten" relevant',
},
),
(
"Zusätzliche Informationen",
{"fields": ("beschreibung", "notizen"), "classes": ["collapse"]},
),
(
"System",
{
"fields": ("id", "erstellt_am", "aktualisiert_am"),
"classes": ["collapse"],
},
),
)
@admin.register(BankTransaction)
class BankTransactionAdmin(admin.ModelAdmin):
list_display = [
"datum",
"konto",
"betrag",
"empfaenger_zahlungspflichtiger",
"transaction_type",
"status",
"verwaltungskosten",
]
list_filter = ["konto", "transaction_type", "status", "datum", "importiert_am"]
search_fields = ["verwendungszweck", "empfaenger_zahlungspflichtiger", "referenz"]
readonly_fields = ["importiert_am", "import_datei"]
ordering = ["-datum", "-importiert_am"]
fieldsets = (
("Basisdaten", {"fields": ("konto", "datum", "valuta", "betrag", "waehrung")}),
(
"Transaktionsdetails",
{
"fields": (
"verwendungszweck",
"empfaenger_zahlungspflichtiger",
"iban_gegenpartei",
"bic_gegenpartei",
"referenz",
"transaction_type",
)
},
),
("Verwaltung", {"fields": ("status", "kommentare", "verwaltungskosten")}),
(
"Import-Information",
{
"fields": ("import_datei", "importiert_am", "saldo_nach_buchung"),
"classes": ("collapse",),
},
),
)
def get_queryset(self, request):
return (
super().get_queryset(request).select_related("konto", "verwaltungskosten")
)
@admin.register(AuditLog)
class AuditLogAdmin(admin.ModelAdmin):
list_display = [
"timestamp",
"username",
"action",
"entity_type",
"entity_name",
"ip_address",
]
list_filter = ["action", "entity_type", "timestamp", "username"]
search_fields = ["username", "entity_name", "description", "ip_address"]
readonly_fields = [
"id",
"timestamp",
"user",
"username",
"action",
"entity_type",
"entity_id",
"entity_name",
"description",
"changes",
"ip_address",
"user_agent",
"session_key",
]
ordering = ["-timestamp"]
date_hierarchy = "timestamp"
fieldsets = (
(
"Benutzer und Zeit",
{"fields": ("timestamp", "user", "username", "session_key")},
),
(
"Aktion",
{
"fields": (
"action",
"entity_type",
"entity_id",
"entity_name",
"description",
)
},
),
("Änderungsdetails", {"fields": ("changes",), "classes": ["collapse"]}),
(
"Request-Informationen",
{"fields": ("ip_address", "user_agent"), "classes": ["collapse"]},
),
("System", {"fields": ("id",), "classes": ["collapse"]}),
)
def has_add_permission(self, request):
return False # Don't allow manual creation
def has_change_permission(self, request, obj=None):
return False # Don't allow editing
@admin.register(BackupJob)
class BackupJobAdmin(admin.ModelAdmin):
list_display = [
"created_at",
"backup_type",
"status",
"backup_size_display",
"duration_display",
"created_by",
]
list_filter = ["backup_type", "status", "created_at"]
search_fields = ["backup_filename", "created_by__username"]
readonly_fields = [
"id",
"created_at",
"started_at",
"completed_at",
"backup_size",
"get_duration",
]
ordering = ["-created_at"]
fieldsets = (
("Job-Details", {"fields": ("backup_type", "status", "created_by")}),
(
"Zeitpunkte",
{"fields": ("created_at", "started_at", "completed_at", "get_duration")},
),
(
"Ergebnis",
{
"fields": (
"backup_filename",
"backup_size",
"database_size",
"files_count",
)
},
),
("Fehlerbehandlung", {"fields": ("error_message",), "classes": ["collapse"]}),
("System", {"fields": ("id",), "classes": ["collapse"]}),
)
def backup_size_display(self, obj):
return obj.get_size_display()
backup_size_display.short_description = "Backup-Größe"
def duration_display(self, obj):
duration = obj.get_duration()
if duration:
return f"{duration.total_seconds():.1f}s"
return "-"
duration_display.short_description = "Dauer"
def has_add_permission(self, request):
return False # Use the web interface for creating backups
@admin.register(AppConfiguration)
class AppConfigurationAdmin(admin.ModelAdmin):
list_display = [
"display_name",
"key",
"value_display",
"category",
"setting_type",
"is_active",
"updated_at",
]
list_filter = ["category", "setting_type", "is_active"]
search_fields = ["key", "display_name", "description"]
readonly_fields = ["id", "created_at", "updated_at"]
ordering = ["category", "order", "display_name"]
fieldsets = (
(
"Basic Information",
{
"fields": (
"key",
"display_name",
"description",
"category",
"setting_type",
)
},
),
("Value Configuration", {"fields": ("value", "default_value")}),
("Options", {"fields": ("is_active", "is_system", "order")}),
(
"Metadata",
{"fields": ("created_at", "updated_at"), "classes": ("collapse",)},
),
)
def value_display(self, obj):
"""Display value with type formatting"""
value = obj.value
if obj.setting_type == "boolean":
icon = "" if obj.get_typed_value() else ""
return format_html("{} {}", icon, value)
elif obj.setting_type == "url":
return format_html(
'<a href="{}" target="_blank">{}</a>',
value,
value[:50] + "..." if len(value) > 50 else value,
)
elif len(value) > 100:
return value[:100] + "..."
return value
value_display.short_description = "Current Value"
def get_readonly_fields(self, request, obj=None):
readonly = list(self.readonly_fields)
if obj and obj.is_system:
readonly.extend(["key", "setting_type", "is_system"])
return readonly
@admin.register(DestinataerUnterstuetzung)
class DestinataerUnterstuetzungAdmin(admin.ModelAdmin):
list_display = [
"__str__",
"destinataer",
"betrag",
"faellig_am",
"status",
"wiederkehrend_von",
"ausgezahlt_am",
]
list_filter = ["status", "faellig_am", "erstellt_am", "konto"]
search_fields = [
"destinataer__vorname",
"destinataer__nachname",
"beschreibung",
"empfaenger_name",
]
readonly_fields = ["id", "erstellt_am", "aktualisiert_am"]
fieldsets = (
(
"Grundinformationen",
{
"fields": (
"destinataer",
"konto",
"betrag",
"faellig_am",
"status",
"beschreibung",
)
},
),
(
"Überweisungsdaten",
{"fields": ("empfaenger_iban", "empfaenger_name", "verwendungszweck")},
),
("Zahlungsinformationen", {"fields": ("ausgezahlt_am", "ausgezahlt_von")}),
("Wiederkehrend", {"fields": ("wiederkehrend_von",)}),
(
"Metadaten",
{
"fields": ("id", "erstellt_am", "aktualisiert_am"),
"classes": ("collapse",),
},
),
)
@admin.register(UnterstuetzungWiederkehrend)
class UnterstuetzungWiederkehrendAdmin(admin.ModelAdmin):
list_display = [
"__str__",
"destinataer",
"betrag",
"intervall",
"aktiv",
"naechste_generierung",
]
list_filter = ["intervall", "aktiv", "erstellt_am"]
search_fields = [
"destinataer__vorname",
"destinataer__nachname",
"beschreibung",
"empfaenger_name",
]
readonly_fields = ["id", "erstellt_am"]
fieldsets = (
(
"Grundinformationen",
{
"fields": (
"destinataer",
"konto",
"betrag",
"intervall",
"beschreibung",
"aktiv",
)
},
),
(
"Überweisungsdaten",
{"fields": ("empfaenger_iban", "empfaenger_name", "verwendungszweck")},
),
(
"Zeitplanung",
{
"fields": (
"erste_zahlung_am",
"letzte_zahlung_am",
"naechste_generierung",
)
},
),
(
"Metadaten",
{"fields": ("id", "erstellt_von", "erstellt_am"), "classes": ("collapse",)},
),
)
@admin.register(models.HelpBox)
class HelpBoxAdmin(admin.ModelAdmin):
list_display = [
"get_page_display",
"title",
"is_active",
"updated_at",
"updated_by",
]
list_filter = ["page_key", "is_active", "updated_at"]
search_fields = ["title", "content"]
fieldsets = (
("Grundinformationen", {"fields": ("page_key", "title", "is_active")}),
(
"Inhalt",
{
"fields": ("content",),
"description": "Markdown wird unterstützt: **fett**, *kursiv*, `code`, [Link](url), Listen mit - oder 1.",
},
),
(
"Metadaten",
{
"fields": ("created_at", "updated_at", "created_by", "updated_by"),
"classes": ("collapse",),
},
),
)
readonly_fields = ["created_at", "updated_at"]
def get_page_display(self, obj):
return obj.get_page_key_display()
get_page_display.short_description = "Seite"
def save_model(self, request, obj, form, change):
if not change: # Neues Objekt
obj.created_by = request.user.username
obj.updated_by = request.user.username
super().save_model(request, obj, form, change)
@admin.register(VierteljahresNachweis)
class VierteljahresNachweisAdmin(admin.ModelAdmin):
list_display = [
"destinataer",
"jahr",
"quartal",
"status",
"completion_percentage",
"faelligkeitsdatum",
"is_overdue_display",
"eingereicht_am",
"geprueft_von",
]
list_filter = [
"jahr",
"quartal",
"status",
"studiennachweis_erforderlich",
"studiennachweis_eingereicht",
"einkommenssituation_bestaetigt",
"vermogenssituation_bestaetigt",
"faelligkeitsdatum",
]
search_fields = [
"destinataer__vorname",
"destinataer__nachname",
"destinataer__email",
]
readonly_fields = [
"id",
"erstellt_am",
"aktualisiert_am",
"completion_percentage",
"is_overdue_display",
]
ordering = ["-jahr", "-quartal", "destinataer__nachname"]
fieldsets = (
(
"Grundinformationen",
{
"fields": (
"destinataer",
"jahr",
"quartal",
"status",
"faelligkeitsdatum",
)
},
),
(
"Studiennachweis",
{
"fields": (
"studiennachweis_erforderlich",
"studiennachweis_eingereicht",
"studiennachweis_datei",
"studiennachweis_bemerkung",
),
"classes": ("collapse",),
},
),
(
"Einkommenssituation",
{
"fields": (
"einkommenssituation_bestaetigt",
"einkommenssituation_text",
"einkommenssituation_datei",
),
"classes": ("collapse",),
},
),
(
"Vermögenssituation",
{
"fields": (
"vermogenssituation_bestaetigt",
"vermogenssituation_text",
"vermogenssituation_datei",
),
"classes": ("collapse",),
},
),
(
"Weitere Dokumente",
{
"fields": (
"weitere_dokumente",
"weitere_dokumente_beschreibung",
),
"classes": ("collapse",),
},
),
(
"Verwaltung & Prüfung",
{
"fields": (
"interne_notizen",
"eingereicht_am",
"geprueft_am",
"geprueft_von",
),
"classes": ("collapse",),
},
),
(
"Metadaten",
{
"fields": (
"id",
"erstellt_am",
"aktualisiert_am",
"completion_percentage",
"is_overdue_display",
)
},
),
)
def completion_percentage(self, obj):
"""Show completion percentage as colored badge"""
percentage = obj.get_completion_percentage()
if percentage == 100:
color = "success"
elif percentage >= 70:
color = "info"
elif percentage >= 30:
color = "warning"
else:
color = "danger"
return format_html(
'<span class="badge bg-{}">{} %</span>',
color,
percentage
)
completion_percentage.short_description = "Fortschritt"
def is_overdue_display(self, obj):
"""Display overdue status with icon"""
if obj.is_overdue():
return format_html(
'<span class="text-danger"><i class="fas fa-exclamation-triangle"></i> Ja</span>'
)
return format_html(
'<span class="text-success"><i class="fas fa-check"></i> Nein</span>'
)
is_overdue_display.short_description = "Überfällig"
actions = ["mark_as_approved", "mark_as_needs_revision"]
def mark_as_approved(self, request, queryset):
"""Bulk action to approve submitted confirmations"""
count = 0
for nachweis in queryset.filter(status="eingereicht"):
nachweis.status = "geprueft"
nachweis.geprueft_am = timezone.now()
nachweis.geprueft_von = request.user
nachweis.save()
count += 1
if count:
self.message_user(
request,
f"{count} Nachweise wurden als geprüft und freigegeben markiert."
)
else:
self.message_user(
request,
"Keine eingereichten Nachweise gefunden.",
level="warning"
)
mark_as_approved.short_description = "Ausgewählte Nachweise freigeben"
def mark_as_needs_revision(self, request, queryset):
"""Bulk action to mark confirmations as needing revision"""
count = queryset.exclude(status__in=["offen", "nachbesserung"]).update(
status="nachbesserung"
)
if count:
self.message_user(
request,
f"{count} Nachweise wurden als nachbesserungsbedürftig markiert."
)
mark_as_needs_revision.short_description = "Nachbesserung erforderlich markieren"
@admin.register(DestinataerEmailEingang)
class DestinataerEmailEingangAdmin(admin.ModelAdmin):
list_display = [
"eingangsdatum",
"absender_email",
"absender_name",
"destinataer_link",
"betreff_kurz",
"anzahl_anhaenge",
"status",
"created_at",
]
list_filter = ["status", "eingangsdatum"]
search_fields = [
"absender_email",
"absender_name",
"betreff",
"destinataer__vorname",
"destinataer__nachname",
]
readonly_fields = ["created_at", "absender_email", "absender_name", "eingangsdatum",
"email_text", "paperless_dokument_ids", "fehler_details"]
raw_id_fields = ["destinataer", "quartalsnachweis"]
date_hierarchy = "eingangsdatum"
ordering = ["-eingangsdatum"]
fieldsets = [
("E-Mail-Metadaten", {
"fields": ["eingangsdatum", "absender_name", "absender_email", "betreff"],
}),
("Zuordnung", {
"fields": ["destinataer", "status", "quartalsnachweis"],
}),
("Inhalt & Anhänge", {
"fields": ["email_text", "paperless_dokument_ids"],
}),
("Notizen & Fehler", {
"fields": ["notizen", "fehler_details"],
"classes": ["collapse"],
}),
("System", {
"fields": ["created_at"],
"classes": ["collapse"],
}),
]
def destinataer_link(self, obj):
if obj.destinataer:
url = reverse("admin:stiftung_destinataer_change", args=[obj.destinataer.pk])
return format_html('<a href="{}">{}</a>', url, obj.destinataer)
return format_html('<span style="color:red;"></span>')
destinataer_link.short_description = "Destinatär"
def betreff_kurz(self, obj):
return (obj.betreff or "")[:60]
betreff_kurz.short_description = "Betreff"
def anzahl_anhaenge(self, obj):
n = len(obj.paperless_dokument_ids or [])
return n if n else ""
anzahl_anhaenge.short_description = "Anhänge"
actions = ["mark_verarbeitet"]
def mark_verarbeitet(self, request, queryset):
updated = queryset.filter(status__in=["neu", "zugewiesen"]).update(status="verarbeitet")
self.message_user(request, f"{updated} E-Mail(s) als verarbeitet markiert.")
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"
admin.site.index_title = "Willkommen zur Stiftungsverwaltung"