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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user