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

@@ -23,25 +23,6 @@ from .destinataere import ( # noqa: F401
destinataer_export,
)
from .dokumente import ( # noqa: F401
dokument_management,
paperless_document_redirect,
dokument_list,
dokument_detail,
dokument_create,
dokument_update,
dokument_delete,
paperless_ping,
paperless_documents,
paperless_debug,
paperless_tags_only,
link_document_search,
create_paechter_link_for_verpachtung,
link_document_create,
link_document_list,
link_document_update,
link_document_delete,
)
from .finanzen import ( # noqa: F401
bericht_list,

View File

@@ -35,7 +35,7 @@ from stiftung.models import (AppConfiguration, AuditLog, BackupJob, BankTransact
BriefVorlage, CSVImport, Destinataer,
DestinataerEmailEingang, DestinataerNotiz,
DestinataerUnterstuetzung,
DokumentLink, Foerderung, GeschichteBild, GeschichteSeite,
DokumentDatei, DokumentLink, Foerderung, GeschichteBild, GeschichteSeite,
Land, LandAbrechnung, LandVerpachtung, Paechter, Person,
Rentmeister, StiftungsKalenderEintrag, StiftungsKonto,
UnterstuetzungWiederkehrend, Veranstaltung,
@@ -268,8 +268,8 @@ def destinataer_detail(request, pk):
destinataer = get_object_or_404(Destinataer, pk=pk)
# Alle mit diesem Destinatär verknüpften Dokumente laden
verknuepfte_dokumente = DokumentLink.objects.filter(
destinataer_id=destinataer.pk
verknuepfte_dokumente = DokumentDatei.objects.filter(
destinataer=destinataer
).order_by("kontext", "titel")
# Förderungen für diesen Destinatär laden

View File

@@ -115,6 +115,7 @@ def dms_upload(request):
"land_id": request.GET.get("land", ""),
"paechter_id": request.GET.get("paechter", ""),
"verpachtung_id": request.GET.get("verpachtung", ""),
"foerderung_id": request.GET.get("foerderung", ""),
"kontext": request.GET.get("kontext", "anderes"),
}
@@ -144,6 +145,7 @@ def dms_upload(request):
land_id = request.POST.get("land_id", "").strip()
paechter_id = request.POST.get("paechter_id", "").strip()
verp_id = request.POST.get("verpachtung_id", "").strip()
foerd_id = request.POST.get("foerderung_id", "").strip()
if dest_id:
try:
@@ -165,6 +167,11 @@ def dms_upload(request):
dok.verpachtung_id = verp_id
except Exception:
pass
if foerd_id:
try:
dok.foerderung_id = foerd_id
except Exception:
pass
_save_upload(request, dok)

File diff suppressed because it is too large Load Diff

View File

@@ -35,7 +35,7 @@ from stiftung.models import (AppConfiguration, AuditLog, BackupJob, BankTransact
BriefVorlage, CSVImport, Destinataer,
DestinataerEmailEingang, DestinataerNotiz,
DestinataerUnterstuetzung,
DokumentLink, Foerderung, GeschichteBild, GeschichteSeite,
DokumentDatei, DokumentLink, Foerderung, GeschichteBild, GeschichteSeite,
Land, LandAbrechnung, LandVerpachtung, Paechter, Person,
Rentmeister, StiftungsKalenderEintrag, StiftungsKonto,
UnterstuetzungWiederkehrend, Veranstaltung,
@@ -138,8 +138,8 @@ def foerderung_detail(request, pk):
)
# Alle mit dieser Förderung verknüpften Dokumente laden
verknuepfte_dokumente = DokumentLink.objects.filter(
foerderung_id=foerderung.pk
verknuepfte_dokumente = DokumentDatei.objects.filter(
foerderung=foerderung
).order_by("kontext", "titel")
context = {

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()

View File

@@ -11,8 +11,6 @@ from decimal import Decimal
import qrcode
import qrcode.image.svg
import requests
from django.conf import settings
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.core.paginator import Paginator
@@ -35,7 +33,7 @@ from stiftung.models import (AppConfiguration, AuditLog, BackupJob, BankTransact
BriefVorlage, CSVImport, Destinataer,
DestinataerEmailEingang, DestinataerNotiz,
DestinataerUnterstuetzung,
DokumentLink, Foerderung, GeschichteBild, GeschichteSeite,
DokumentDatei, DokumentLink, Foerderung, GeschichteBild, GeschichteSeite,
Land, LandAbrechnung, LandVerpachtung, Paechter, Person,
Rentmeister, StiftungsKalenderEintrag, StiftungsKonto,
UnterstuetzungWiederkehrend, Veranstaltung,
@@ -143,8 +141,8 @@ def paechter_detail(request, pk):
paechter = get_object_or_404(Paechter, pk=pk)
# Alle mit diesem Pächter verknüpften Dokumente laden
verknuepfte_dokumente = DokumentLink.objects.filter(
paechter_id=paechter.pk
verknuepfte_dokumente = DokumentDatei.objects.filter(
paechter=paechter
).order_by("kontext", "titel")
# Neue LandVerpachtungen für diesen Pächter laden
@@ -392,7 +390,7 @@ def land_detail(request, pk):
land = get_object_or_404(Land, pk=pk)
# Alle mit dieser Länderei verknüpften Dokumente laden
verknuepfte_dokumente = DokumentLink.objects.filter(land_id=land.pk).order_by(
verknuepfte_dokumente = DokumentDatei.objects.filter(land=land).order_by(
"kontext", "titel"
)
@@ -584,8 +582,8 @@ def land_verpachtung_detail(request, pk):
verpachtung = get_object_or_404(LandVerpachtung, pk=pk)
# Alle mit dieser Verpachtung verknüpften Dokumente laden
verknuepfte_dokumente = DokumentLink.objects.filter(
land_verpachtung_id=verpachtung.pk
verknuepfte_dokumente = DokumentDatei.objects.filter(
verpachtung=verpachtung
).order_by("kontext", "titel")
context = {
@@ -747,55 +745,26 @@ def paechter_export(request, pk):
json.dumps(entity_data, indent=2, ensure_ascii=False),
)
# 2. Linked documents from Paperless
dokumente = DokumentLink.objects.filter(paechter_id=paechter.pk)
# 2. DMS-Dokumente (Django-natives DMS)
dokumente = DokumentDatei.objects.filter(paechter=paechter)
docs_data = []
for doc in dokumente:
doc_data = {
"paperless_id": doc.paperless_document_id,
"dms_id": str(doc.id),
"titel": doc.titel,
"kontext": doc.get_kontext_display(),
"beschreibung": doc.beschreibung,
"dateiname": doc.dateiname_original,
}
docs_data.append(doc_data)
# Try to download document from Paperless
try:
if (
hasattr(settings, "PAPERLESS_API_URL")
and settings.PAPERLESS_API_URL
):
doc_url = f"{settings.PAPERLESS_API_URL}/api/documents/{doc.paperless_document_id}/download/"
headers = {}
if (
hasattr(settings, "PAPERLESS_API_TOKEN")
and settings.PAPERLESS_API_TOKEN
):
headers["Authorization"] = (
f"Token {settings.PAPERLESS_API_TOKEN}"
)
response = requests.get(doc_url, headers=headers, timeout=30)
if response.status_code == 200:
content_type = response.headers.get("content-type", "")
if "pdf" in content_type:
ext = ".pdf"
elif "jpeg" in content_type or "jpg" in content_type:
ext = ".jpg"
elif "png" in content_type:
ext = ".png"
else:
ext = ".pdf"
safe_filename = f"dokument_{doc.paperless_document_id}_{doc.titel.replace('/', '_')[:50]}{ext}"
zipf.writestr(
f"dokumente/{safe_filename}", response.content
)
doc_data["downloaded"] = True
else:
doc_data["download_error"] = f"HTTP {response.status_code}"
if doc.datei:
safe_filename = doc.dateiname_original or str(doc.id)
zipf.writestr(f"dokumente/{safe_filename}", doc.datei.read())
doc_data["included"] = True
except Exception as e:
doc_data["download_error"] = str(e)
doc_data["error"] = str(e)
if docs_data:
zipf.writestr(
@@ -871,55 +840,26 @@ def land_export(request, pk):
"land_data.json", json.dumps(entity_data, indent=2, ensure_ascii=False)
)
# 2. Linked documents from Paperless
dokumente = DokumentLink.objects.filter(land_id=land.pk)
# 2. DMS-Dokumente (Django-natives DMS)
dokumente = DokumentDatei.objects.filter(land=land)
docs_data = []
for doc in dokumente:
doc_data = {
"paperless_id": doc.paperless_document_id,
"dms_id": str(doc.id),
"titel": doc.titel,
"kontext": doc.get_kontext_display(),
"beschreibung": doc.beschreibung,
"dateiname": doc.dateiname_original,
}
docs_data.append(doc_data)
# Try to download document from Paperless
try:
if (
hasattr(settings, "PAPERLESS_API_URL")
and settings.PAPERLESS_API_URL
):
doc_url = f"{settings.PAPERLESS_API_URL}/api/documents/{doc.paperless_document_id}/download/"
headers = {}
if (
hasattr(settings, "PAPERLESS_API_TOKEN")
and settings.PAPERLESS_API_TOKEN
):
headers["Authorization"] = (
f"Token {settings.PAPERLESS_API_TOKEN}"
)
response = requests.get(doc_url, headers=headers, timeout=30)
if response.status_code == 200:
content_type = response.headers.get("content-type", "")
if "pdf" in content_type:
ext = ".pdf"
elif "jpeg" in content_type or "jpg" in content_type:
ext = ".jpg"
elif "png" in content_type:
ext = ".png"
else:
ext = ".pdf"
safe_filename = f"dokument_{doc.paperless_document_id}_{doc.titel.replace('/', '_')[:50]}{ext}"
zipf.writestr(
f"dokumente/{safe_filename}", response.content
)
doc_data["downloaded"] = True
else:
doc_data["download_error"] = f"HTTP {response.status_code}"
if doc.datei:
safe_filename = doc.dateiname_original or str(doc.id)
zipf.writestr(f"dokumente/{safe_filename}", doc.datei.read())
doc_data["included"] = True
except Exception as e:
doc_data["download_error"] = str(e)
doc_data["error"] = str(e)
if docs_data:
zipf.writestr(
@@ -996,55 +936,26 @@ def verpachtung_export(request, pk):
json.dumps(entity_data, indent=2, ensure_ascii=False),
)
# 2. Linked documents from Paperless
dokumente = DokumentLink.objects.filter(verpachtung_id=verpachtung.pk)
# 2. DMS-Dokumente (Django-natives DMS)
dokumente = DokumentDatei.objects.filter(verpachtung=verpachtung)
docs_data = []
for doc in dokumente:
doc_data = {
"paperless_id": doc.paperless_document_id,
"dms_id": str(doc.id),
"titel": doc.titel,
"kontext": doc.get_kontext_display(),
"beschreibung": doc.beschreibung,
"dateiname": doc.dateiname_original,
}
docs_data.append(doc_data)
# Try to download document from Paperless
try:
if (
hasattr(settings, "PAPERLESS_API_URL")
and settings.PAPERLESS_API_URL
):
doc_url = f"{settings.PAPERLESS_API_URL}/api/documents/{doc.paperless_document_id}/download/"
headers = {}
if (
hasattr(settings, "PAPERLESS_API_TOKEN")
and settings.PAPERLESS_API_TOKEN
):
headers["Authorization"] = (
f"Token {settings.PAPERLESS_API_TOKEN}"
)
response = requests.get(doc_url, headers=headers, timeout=30)
if response.status_code == 200:
content_type = response.headers.get("content-type", "")
if "pdf" in content_type:
ext = ".pdf"
elif "jpeg" in content_type or "jpg" in content_type:
ext = ".jpg"
elif "png" in content_type:
ext = ".png"
else:
ext = ".pdf"
safe_filename = f"dokument_{doc.paperless_document_id}_{doc.titel.replace('/', '_')[:50]}{ext}"
zipf.writestr(
f"dokumente/{safe_filename}", response.content
)
doc_data["downloaded"] = True
else:
doc_data["download_error"] = f"HTTP {response.status_code}"
if doc.datei:
safe_filename = doc.dateiname_original or str(doc.id)
zipf.writestr(f"dokumente/{safe_filename}", doc.datei.read())
doc_data["included"] = True
except Exception as e:
doc_data["download_error"] = str(e)
doc_data["error"] = str(e)
if docs_data:
zipf.writestr(
@@ -1438,8 +1349,8 @@ def verpachtung_detail(request, pk):
verpachtung = get_object_or_404(LandVerpachtung, pk=pk)
# Alle mit dieser Verpachtung verknüpften Dokumente laden
verknuepfte_dokumente = DokumentLink.objects.filter(
land_verpachtung_id=verpachtung.pk
verknuepfte_dokumente = DokumentDatei.objects.filter(
verpachtung=verpachtung
).order_by("kontext", "titel")
context = {