Files
stiftung-management-system/app/stiftung/admin.py
SysAdmin Agent 709903e627 Baseline für Vision 2026: Veranstaltungsmodul + ausstehende Änderungen
Alle bestehenden, nicht commiteten Änderungen als Ausgangsbasis für den
vision-2026 Branch übernommen (Veranstaltungsmodul, Serienbrief, etc.).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 08:51:48 +00:00

1422 lines
44 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 import forms
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,
BriefVorlage, 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",
]
class BriefVorlageWidget(forms.Textarea):
"""Erweitertes Textarea-Widget für HTML-Briefvorlagen mit Editor-Panel und Platzhalter-Hilfe."""
class Media:
js = ["stiftung/js/briefvorlage_editor.js"]
def __init__(self, attrs=None):
default_attrs = {"rows": 18, "cols": 80, "class": "briefvorlage-textarea", "style": "font-family: monospace; font-size: 13px;"}
if attrs:
default_attrs.update(attrs)
super().__init__(attrs=default_attrs)
class VeranstaltungAdminForm(forms.ModelForm):
class Meta:
model = Veranstaltung
fields = "__all__"
widgets = {
"briefvorlage": BriefVorlageWidget(),
}
@admin.register(Veranstaltung)
class VeranstaltungAdmin(admin.ModelAdmin):
form = VeranstaltungAdminForm
list_display = [
"titel", "datum", "uhrzeit", "ort", "status",
"get_teilnehmer_count", "get_zugesagte_count", "budget_pro_person",
]
list_filter = ["status", "datum"]
search_fields = ["titel", "ort", "beschreibung"]
ordering = ["-datum"]
readonly_fields = ["id", "erstellt_am", "aktualisiert_am", "serienbrief_aktionen", "platzhalter_dokumentation"]
inlines = [VeranstaltungsteilnehmerInline]
fieldsets = (
("Grunddaten", {"fields": ("titel", "datum", "uhrzeit", "status")}),
("Veranstaltungsort", {"fields": ("ort", "adresse")}),
("Details", {"fields": ("beschreibung", "budget_pro_person")}),
(
"Serienbrief Vorlage",
{
"fields": (
"platzhalter_dokumentation",
"betreff",
"briefvorlage",
),
},
),
(
"Serienbrief Unterschriften & Aktionen",
{
"fields": (
"unterschrift_1_name", "unterschrift_1_titel",
"unterschrift_2_name", "unterschrift_2_titel",
"serienbrief_aktionen",
),
},
),
("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 platzhalter_dokumentation(self, obj):
return format_html(
"""<div class="help" style="background:#f8f9fa;border:1px solid #dee2e6;border-radius:4px;padding:10px 14px;margin-bottom:4px;">
<strong>Verfügbare Platzhalter im Brieftext:</strong><br>
<table style="margin-top:6px;border-collapse:collapse;font-size:13px;">
<tr><td style="padding:2px 12px 2px 0;font-family:monospace;color:#d63384;">{{{{ anrede }}}}</td><td>Anredetitel (Herr / Frau)</td></tr>
<tr><td style="padding:2px 12px 2px 0;font-family:monospace;color:#d63384;">{{{{ vorname }}}}</td><td>Vorname des Empfängers</td></tr>
<tr><td style="padding:2px 12px 2px 0;font-family:monospace;color:#d63384;">{{{{ nachname }}}}</td><td>Nachname des Empfängers</td></tr>
<tr><td style="padding:2px 12px 2px 0;font-family:monospace;color:#d63384;">{{{{ strasse }}}}</td><td>Straße und Hausnummer</td></tr>
<tr><td style="padding:2px 12px 2px 0;font-family:monospace;color:#d63384;">{{{{ plz }}}}</td><td>Postleitzahl</td></tr>
<tr><td style="padding:2px 12px 2px 0;font-family:monospace;color:#d63384;">{{{{ ort }}}}</td><td>Wohnort des Empfängers</td></tr>
<tr><td style="padding:2px 12px 2px 0;font-family:monospace;color:#d63384;">{{{{ datum }}}}</td><td>Datum der Veranstaltung (z.B. Freitag, 17. April 2026)</td></tr>
<tr><td style="padding:2px 12px 2px 0;font-family:monospace;color:#d63384;">{{{{ uhrzeit }}}}</td><td>Uhrzeit der Veranstaltung (z.B. 19:00 Uhr)</td></tr>
<tr><td style="padding:2px 12px 2px 0;font-family:monospace;color:#d63384;">{{{{ veranstaltungsort }}}}</td><td>Name des Veranstaltungsorts / Gasthaus</td></tr>
<tr><td style="padding:2px 12px 2px 0;font-family:monospace;color:#d63384;">{{{{ gasthaus_adresse }}}}</td><td>Adresse des Gasthauses</td></tr>
</table>
<div style="margin-top:8px;font-size:12px;color:#6c757d;">
Platzhalter werden beim PDF-Export automatisch mit den Empfänger- und Veranstaltungsdaten befüllt.
Tipp: Vorlagen unter <a href="/admin/stiftung/briefvorlage/" target="_blank">Verwaltung → Briefvorlagen</a> speichern und wiederverwenden.
</div>
</div>"""
)
platzhalter_dokumentation.short_description = "Platzhalter-Dokumentation"
platzhalter_dokumentation.allow_tags = True
def serienbrief_aktionen(self, obj):
if obj.pk:
from django.urls import reverse as url_reverse
pdf_url = url_reverse("stiftung:veranstaltung_serienbrief_pdf", args=[obj.pk])
vorschau_url = url_reverse("stiftung:veranstaltung_serienbrief_vorschau", args=[obj.pk])
return format_html(
'<a href="{}" target="_blank" class="button" style="margin-right:8px;">Serienbrief-PDF generieren</a>'
'<a href="{}" target="_blank" class="button default">Vorschau im Browser</a>',
pdf_url, vorschau_url,
)
return ""
serienbrief_aktionen.short_description = "Aktionen"
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(BriefVorlage)
class BriefVorlageAdmin(admin.ModelAdmin):
list_display = ["name", "beschreibung_kurz", "erstellt_am", "aktualisiert_am"]
search_fields = ["name", "beschreibung"]
ordering = ["name"]
readonly_fields = ["erstellt_am", "aktualisiert_am"]
fieldsets = (
(None, {"fields": ("name", "beschreibung")}),
(
"Briefinhalt",
{
"fields": ("betreff", "briefvorlage"),
"description": (
"Verfügbare Platzhalter: {{ anrede }}, {{ vorname }}, {{ nachname }}, "
"{{ strasse }}, {{ plz }}, {{ ort }}, {{ datum }}, {{ uhrzeit }}, "
"{{ veranstaltungsort }}, {{ gasthaus_adresse }}"
),
},
),
("System", {"fields": ("erstellt_am", "aktualisiert_am"), "classes": ("collapse",)}),
)
def beschreibung_kurz(self, obj):
return obj.beschreibung[:80] + "" if len(obj.beschreibung) > 80 else obj.beschreibung
beschreibung_kurz.short_description = "Beschreibung"
@admin.register(Veranstaltungsteilnehmer)
class VeranstaltungsteilnehmerAdmin(admin.ModelAdmin):
list_display = [
"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"