Generalize email system with invoice workflow and Stiftungsgeschichte category

- Rename DestinataerEmailEingang → EmailEingang with category support
  (destinataer, rechnung, land_pacht, stiftungsgeschichte, allgemein)
- Add invoice capture workflow: create Verwaltungskosten from email,
  link DMS documents as invoice attachments, track payment status
- Add Stiftungsgeschichte email category with auto-detection patterns
  (Ahnenforschung, Genealogie, Chronik, etc.) and DMS integration
- Update poll_emails task with category detection and DMS context mapping
- Show available history documents in Geschichte editor sidebar
- Consolidate DMS views, remove legacy dokument templates
- Update all detail/form templates for DMS document linking
- Add deploy.sh script and streamline compose.yml

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
SysAdmin Agent
2026-03-12 10:17:14 +00:00
parent f4fc512ad3
commit e6f4c5ba1b
44 changed files with 1076 additions and 3428 deletions

View File

@@ -33,7 +33,7 @@ from rest_framework.response import Response
from stiftung.models import (AppConfiguration, AuditLog, BackupJob, BankTransaction,
BriefVorlage, CSVImport, Destinataer,
DestinataerEmailEingang, DestinataerNotiz,
DestinataerEmailEingang, EmailEingang, DestinataerNotiz,
DestinataerUnterstuetzung,
DokumentLink, Foerderung, GeschichteBild, GeschichteSeite,
Land, LandAbrechnung, LandVerpachtung, Paechter, Person,
@@ -105,11 +105,18 @@ def geschichte_create(request):
else:
form = GeschichteSeiteForm()
# Verfuegbare Stiftungsgeschichte-Dokumente aus DMS
from stiftung.models import DokumentDatei
geschichte_dokumente = DokumentDatei.objects.filter(
kontext="stiftungsgeschichte"
).order_by("-erstellt_am")[:20]
context = {
'form': form,
'title': 'Neue Geschichtsseite'
'title': 'Neue Geschichtsseite',
'geschichte_dokumente': geschichte_dokumente,
}
return render(request, 'stiftung/geschichte/form.html', context)
@@ -134,12 +141,19 @@ def geschichte_edit(request, slug):
else:
form = GeschichteSeiteForm(instance=seite)
# Verfuegbare Stiftungsgeschichte-Dokumente aus DMS
from stiftung.models import DokumentDatei
geschichte_dokumente = DokumentDatei.objects.filter(
kontext="stiftungsgeschichte"
).order_by("-erstellt_am")[:20]
context = {
'form': form,
'seite': seite,
'title': f'Bearbeiten: {seite.titel}'
'title': f'Bearbeiten: {seite.titel}',
'geschichte_dokumente': geschichte_dokumente,
}
return render(request, 'stiftung/geschichte/form.html', context)
@@ -583,16 +597,19 @@ def kalender_view(request):
@login_required
def email_eingang_list(request):
"""
Übersicht aller eingegangenen E-Mails von Destinatären.
Zeigt ungeklärte Absender zuerst, dann chronologisch absteigend.
Uebersicht aller eingegangenen E-Mails.
Filtert nach Status und Kategorie, zeigt ungeklaerte Absender zuerst.
"""
status_filter = request.GET.get("status", "")
kategorie_filter = request.GET.get("kategorie", "")
search = request.GET.get("q", "").strip()
qs = DestinataerEmailEingang.objects.select_related("destinataer", "quartalsnachweis")
qs = EmailEingang.objects.select_related("destinataer", "quartalsnachweis", "verwaltungskosten")
if status_filter:
qs = qs.filter(status=status_filter)
if kategorie_filter:
qs = qs.filter(kategorie=kategorie_filter)
if search:
qs = qs.filter(
Q(absender_email__icontains=search)
@@ -604,7 +621,7 @@ def email_eingang_list(request):
# Unbekannte Absender zuerst, dann nach Datum absteigend
qs = qs.order_by(
"status", # "unbekannt" kommt alphabetisch vor "verarbeitet" / "zugewiesen"
"status",
"-eingangsdatum",
)
@@ -612,16 +629,19 @@ def email_eingang_list(request):
page_obj = paginator.get_page(request.GET.get("page"))
context = {
"title": "E-Mail-Eingang (Destinatäre)",
"title": "E-Mail-Eingang",
"page_obj": page_obj,
"status_filter": status_filter,
"kategorie_filter": kategorie_filter,
"search": search,
"status_choices": DestinataerEmailEingang.STATUS_CHOICES,
"status_choices": EmailEingang.STATUS_CHOICES,
"kategorie_choices": EmailEingang.KATEGORIE_CHOICES,
"counts": {
"gesamt": DestinataerEmailEingang.objects.count(),
"neu": DestinataerEmailEingang.objects.filter(status="neu").count(),
"unbekannt": DestinataerEmailEingang.objects.filter(status="unbekannt").count(),
"fehler": DestinataerEmailEingang.objects.filter(status="fehler").count(),
"gesamt": EmailEingang.objects.count(),
"neu": EmailEingang.objects.filter(status="neu").count(),
"unbekannt": EmailEingang.objects.filter(status="unbekannt").count(),
"rechnung": EmailEingang.objects.filter(kategorie="rechnung").count(),
"fehler": EmailEingang.objects.filter(status="fehler").count(),
},
}
return render(request, "stiftung/email_eingang/list.html", context)
@@ -629,8 +649,8 @@ def email_eingang_list(request):
@login_required
def email_eingang_detail(request, pk):
"""Detailansicht einer eingegangenen E-Mail mit Möglichkeit zur manuellen Zuordnung."""
eingang = get_object_or_404(DestinataerEmailEingang, pk=pk)
"""Detailansicht einer eingegangenen E-Mail mit Zuordnung und Rechnungserfassung."""
eingang = get_object_or_404(EmailEingang, pk=pk)
if request.method == "POST":
action = request.POST.get("action")
@@ -641,19 +661,62 @@ def email_eingang_detail(request, pk):
try:
destinataer = Destinataer.objects.get(pk=dest_id)
eingang.destinataer = destinataer
eingang.kategorie = "destinataer"
eingang.status = "zugewiesen"
eingang.save()
# Verknüpfte DokumentLinks ebenfalls dem Destinatär zuordnen
if eingang.paperless_dokument_ids:
DokumentLink.objects.filter(
paperless_document_id__in=eingang.paperless_dokument_ids
).update(destinataer_id=destinataer.pk)
messages.success(
request,
f"E-Mail wurde {destinataer} zugeordnet.",
eingang.dokument_dateien.filter(destinataer__isnull=True).update(
destinataer=destinataer
)
messages.success(request, f"E-Mail wurde {destinataer} zugeordnet.")
except Destinataer.DoesNotExist:
messages.error(request, "Destinatär nicht gefunden.")
messages.error(request, "Destinataer nicht gefunden.")
return redirect("stiftung:email_eingang_detail", pk=pk)
elif action == "erfasse_rechnung":
# Erstelle Verwaltungskosten-Eintrag aus Email
bezeichnung = request.POST.get("bezeichnung", eingang.betreff[:200]).strip()
betrag = request.POST.get("betrag", "0").strip().replace(",", ".")
kategorie = request.POST.get("vk_kategorie", "rechnung_intern")
lieferant = request.POST.get("lieferant", eingang.absender_name or eingang.absender_email).strip()
rechnungsnummer = request.POST.get("rechnungsnummer", "").strip()
try:
from decimal import Decimal
vk = Verwaltungskosten(
bezeichnung=bezeichnung[:200],
kategorie=kategorie,
betrag=Decimal(betrag) if betrag else Decimal("0"),
datum=eingang.eingangsdatum.date(),
lieferant_firma=lieferant[:200],
rechnungsnummer=rechnungsnummer[:100],
status="erhalten",
beschreibung=f"Automatisch erfasst aus E-Mail-Eingang.\nBetreff: {eingang.betreff}\nAbsender: {eingang.absender_email}",
)
vk.save()
# Verknuepfe Email mit Verwaltungskosten
eingang.verwaltungskosten = vk
eingang.kategorie = "rechnung"
eingang.status = "rechnung_erfasst"
eingang.save()
# Verknuepfe angehaengte Dokumente mit Verwaltungskosten
for dok in eingang.dokument_dateien.all():
dok.verwaltungskosten = vk
dok.kontext = "rechnung"
dok.save()
messages.success(request, f'Rechnung "{bezeichnung}" erfasst (€{betrag}).')
except Exception as exc:
messages.error(request, f"Fehler beim Erfassen der Rechnung: {exc}")
return redirect("stiftung:email_eingang_detail", pk=pk)
elif action == "set_kategorie":
new_kategorie = request.POST.get("kategorie", "")
if new_kategorie in dict(EmailEingang.KATEGORIE_CHOICES):
eingang.kategorie = new_kategorie
eingang.save()
messages.success(request, f"Kategorie auf '{dict(EmailEingang.KATEGORIE_CHOICES)[new_kategorie]}' gesetzt.")
return redirect("stiftung:email_eingang_detail", pk=pk)
elif action == "mark_verarbeitet":
@@ -669,38 +732,29 @@ def email_eingang_detail(request, pk):
messages.success(request, "Notizen gespeichert.")
return redirect("stiftung:email_eingang_detail", pk=pk)
# Paperless-Links zusammenstellen
paperless_links = eingang.get_paperless_links()
# DMS-Dokumente
dms_dokumente = eingang.dokument_dateien.all().order_by("erstellt_am")
# DokumentLinks für diese E-Mail (über paperless_dokument_ids)
dokument_links = []
if eingang.paperless_dokument_ids:
dokument_links = DokumentLink.objects.filter(
paperless_document_id__in=eingang.paperless_dokument_ids
)
# Alle aktiven Destinatäre für manuelle Zuordnung
# Alle aktiven Destinataere fuer manuelle Zuordnung
alle_destinataere = Destinataer.objects.filter(aktiv=True).order_by("nachname", "vorname")
context = {
"title": f"E-Mail-Eingang: {eingang}",
"eingang": eingang,
"paperless_links": paperless_links,
"dokument_links": dokument_links,
"dms_dokumente": dms_dokumente,
"alle_destinataere": alle_destinataere,
"vk_kategorie_choices": Verwaltungskosten.KATEGORIE_CHOICES,
}
return render(request, "stiftung/email_eingang/detail.html", context)
@login_required
def email_eingang_poll_trigger(request):
"""Löst den IMAP-Poll manuell aus sucht alle E-Mails der letzten 30 Tage."""
"""Loest den IMAP-Poll manuell aus sucht alle E-Mails der letzten 30 Tage."""
if request.method == "POST":
from stiftung.tasks import poll_destinataer_emails
from stiftung.tasks import poll_emails
try:
# Synchron ausführen für sofortiges Feedback; sucht auch bereits
# gelesene E-Mails der letzten 30 Tage (Duplikate werden übersprungen).
result = poll_destinataer_emails.apply(kwargs={"search_all_recent_days": 30}).get(timeout=300)
result = poll_emails.apply(kwargs={"search_all_recent_days": 30}).get(timeout=300)
processed = result.get("processed", 0) if isinstance(result, dict) else 0
if result and result.get("status") == "skipped":
messages.warning(request, "IMAP ist nicht konfiguriert. Bitte Einstellungen unter Administration → E-Mail / IMAP prüfen.")
@@ -723,8 +777,8 @@ def email_eingang_poll_trigger(request):
@login_required
def email_eingang_delete(request, pk):
"""Löscht eine eingegangene E-Mail."""
eingang = get_object_or_404(DestinataerEmailEingang, pk=pk)
"""Loescht eine eingegangene E-Mail."""
eingang = get_object_or_404(EmailEingang, pk=pk)
if request.method == "POST":
betreff = eingang.betreff or "(kein Betreff)"
eingang.delete()