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

@@ -7,90 +7,6 @@ class Command(BaseCommand):
help = "Initialize default app configuration settings"
def handle(self, *args, **options):
# Paperless Integration Settings
paperless_settings = [
{
"key": "paperless_api_url",
"display_name": "Paperless API URL",
"description": "The base URL for your Paperless-NGX API (e.g., http://paperless.example.com:8000)",
"value": "http://192.168.178.167:30070",
"default_value": "http://192.168.178.167:30070",
"setting_type": "url",
"category": "paperless",
"order": 1,
},
{
"key": "paperless_api_token",
"display_name": "Paperless API Token",
"description": "The authentication token for Paperless API access",
"value": "",
"default_value": "",
"setting_type": "text",
"category": "paperless",
"order": 2,
},
{
"key": "paperless_destinataere_tag",
"display_name": "Destinatäre Tag Name",
"description": "The tag name used to identify Destinatäre documents in Paperless",
"value": "Stiftung_Destinatäre",
"default_value": "Stiftung_Destinatäre",
"setting_type": "tag",
"category": "paperless",
"order": 3,
},
{
"key": "paperless_destinataere_tag_id",
"display_name": "Destinatäre Tag ID",
"description": "The numeric ID of the Destinatäre tag in Paperless",
"value": "210",
"default_value": "210",
"setting_type": "tag_id",
"category": "paperless",
"order": 4,
},
{
"key": "paperless_land_tag",
"display_name": "Land & Pächter Tag Name",
"description": "The tag name used to identify Land and Pächter documents in Paperless",
"value": "Stiftung_Land_und_Pächter",
"default_value": "Stiftung_Land_und_Pächter",
"setting_type": "tag",
"category": "paperless",
"order": 5,
},
{
"key": "paperless_land_tag_id",
"display_name": "Land & Pächter Tag ID",
"description": "The numeric ID of the Land & Pächter tag in Paperless",
"value": "204",
"default_value": "204",
"setting_type": "tag_id",
"category": "paperless",
"order": 6,
},
{
"key": "paperless_admin_tag",
"display_name": "Administration Tag Name",
"description": "The tag name used to identify Administration documents in Paperless",
"value": "Stiftung_Administration",
"default_value": "Stiftung_Administration",
"setting_type": "tag",
"category": "paperless",
"order": 7,
},
{
"key": "paperless_admin_tag_id",
"display_name": "Administration Tag ID",
"description": "The numeric ID of the Administration tag in Paperless",
"value": "216",
"default_value": "216",
"setting_type": "tag_id",
"category": "paperless",
"order": 8,
},
]
# E-Mail / IMAP Settings
email_settings = [
{
@@ -155,7 +71,7 @@ class Command(BaseCommand):
},
]
all_settings = paperless_settings + email_settings
all_settings = email_settings
created_count = 0
updated_count = 0

View File

@@ -0,0 +1,33 @@
# Generated by Django 5.0.6 on 2026-03-12 08:11
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('stiftung', '0048_phase3_dms_dokument_datei'),
]
operations = [
migrations.AddField(
model_name='destinataeremaileingang',
name='dokument_dateien',
field=models.ManyToManyField(blank=True, help_text='Automatisch befüllte Anhänge als Django-DMS-Dateien.', related_name='email_eingaenge', to='stiftung.dokumentdatei', verbose_name='DMS-Dokumente (Anhänge)'),
),
migrations.AlterField(
model_name='appconfiguration',
name='category',
field=models.CharField(choices=[('paperless', 'Paperless Integration'), ('email', 'E-Mail / IMAP'), ('general', 'General Settings'), ('corporate', 'Corporate Identity'), ('notifications', 'Notifications'), ('system', 'System Settings')], default='general', max_length=50, verbose_name='Category'),
),
migrations.AlterField(
model_name='appconfiguration',
name='setting_type',
field=models.CharField(choices=[('text', 'Text'), ('password', 'Password'), ('number', 'Number'), ('boolean', 'Boolean'), ('url', 'URL'), ('tag', 'Tag Name'), ('tag_id', 'Tag ID')], default='text', max_length=20, verbose_name='Type'),
),
migrations.AlterField(
model_name='destinataeremaileingang',
name='paperless_dokument_ids',
field=models.JSONField(blank=True, default=list, help_text='Veraltet wird nach vollständiger Migration entfernt. Neue Anhänge in dokument_dateien.', verbose_name='Paperless Dokument-IDs (Anhänge, veraltet)'),
),
]

View File

@@ -0,0 +1,132 @@
# Phase 4: Generalize EmailEingang + Rechnungsworkflow
# - Rename DestinataerEmailEingang → EmailEingang
# - Add kategorie, verwaltungskosten FK, land FK, verpachtung FK
# - Expand status choices (rechnung_erfasst, zahlung_gebucht)
# - Add verwaltungskosten FK to DokumentDatei
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('stiftung', '0049_phase3_email_dms_m2m'),
]
operations = [
# 1. Rename model (preserves DB table, updates Django state)
migrations.RenameModel(
old_name='DestinataerEmailEingang',
new_name='EmailEingang',
),
# 2. Add kategorie field to EmailEingang
migrations.AddField(
model_name='emaileingang',
name='kategorie',
field=models.CharField(
choices=[
('destinataer', 'Destinataer'),
('rechnung', 'Rechnung'),
('land_pacht', 'Grundstueck / Pacht'),
('allgemein', 'Allgemein'),
],
default='allgemein',
max_length=20,
verbose_name='Kategorie',
),
),
# 3. Add verwaltungskosten FK to EmailEingang
migrations.AddField(
model_name='emaileingang',
name='verwaltungskosten',
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name='email_eingaenge',
to='stiftung.verwaltungskosten',
verbose_name='Verwaltungskosten / Rechnung',
),
),
# 4. Add land FK to EmailEingang
migrations.AddField(
model_name='emaileingang',
name='land',
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name='email_eingaenge',
to='stiftung.land',
verbose_name='Laenderei',
),
),
# 5. Add verpachtung FK to EmailEingang
migrations.AddField(
model_name='emaileingang',
name='verpachtung',
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name='email_eingaenge',
to='stiftung.landverpachtung',
verbose_name='Verpachtung',
),
),
# 6. Update status choices on EmailEingang
migrations.AlterField(
model_name='emaileingang',
name='status',
field=models.CharField(
choices=[
('neu', 'Neu / Unbearbeitet'),
('zugewiesen', 'Destinataer zugewiesen'),
('verarbeitet', 'Verarbeitet'),
('rechnung_erfasst', 'Rechnung erfasst'),
('zahlung_gebucht', 'Zahlung gebucht'),
('unbekannt', 'Unbekannter Absender'),
('fehler', 'Fehler bei Verarbeitung'),
],
default='neu',
max_length=20,
verbose_name='Status',
),
),
# 7. Update Meta on EmailEingang
migrations.AlterModelOptions(
name='emaileingang',
options={
'ordering': ['-eingangsdatum'],
'verbose_name': 'E-Mail-Eingang',
'verbose_name_plural': 'E-Mail-Eingaenge',
},
),
# 8. Set kategorie='destinataer' for existing emails that have a destinataer FK
migrations.RunSQL(
sql="UPDATE stiftung_emaileingang SET kategorie = 'destinataer' WHERE destinataer_id IS NOT NULL;",
reverse_sql=migrations.RunSQL.noop,
),
# 9. Add verwaltungskosten FK to DokumentDatei
migrations.AddField(
model_name='dokumentdatei',
name='verwaltungskosten',
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name='dms_dokumente',
to='stiftung.verwaltungskosten',
verbose_name='Verwaltungskosten / Rechnung',
),
),
]

View File

@@ -0,0 +1,29 @@
# Generated by Django 5.0.6 on 2026-03-12 09:45
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('stiftung', '0050_generalize_email_rechnungsworkflow'),
]
operations = [
migrations.AlterField(
model_name='emaileingang',
name='destinataer',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='email_eingaenge', to='stiftung.destinataer', verbose_name='Destinataer'),
),
migrations.AlterField(
model_name='emaileingang',
name='dokument_dateien',
field=models.ManyToManyField(blank=True, help_text='Automatisch befuellte Anhaenge als Django-DMS-Dateien.', related_name='email_eingaenge', to='stiftung.dokumentdatei', verbose_name='DMS-Dokumente (Anhaenge)'),
),
migrations.AlterField(
model_name='emaileingang',
name='paperless_dokument_ids',
field=models.JSONField(blank=True, default=list, help_text='Veraltet wird nach vollstaendiger Migration entfernt. Neue Anhaenge in dokument_dateien.', verbose_name='Paperless Dokument-IDs (Anhaenge, veraltet)'),
),
]

View File

@@ -0,0 +1,23 @@
# Generated by Django 5.0.6 on 2026-03-12 10:07
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('stiftung', '0051_alter_emaileingang_destinataer_and_more'),
]
operations = [
migrations.AlterField(
model_name='dokumentdatei',
name='kontext',
field=models.CharField(choices=[('pachtvertrag', 'Pachtvertrag'), ('antrag', 'Antrag / Förderantrag'), ('verwendungsnachweis', 'Verwendungsnachweis'), ('studiennachweis', 'Studiennachweis'), ('rechnung', 'Rechnung'), ('vertrag', 'Vertrag'), ('bericht', 'Bericht'), ('landkarte', 'Landkarte / Kataster'), ('korrespondenz', 'Korrespondenz / Brief'), ('bescheid', 'Bescheid / Behörde'), ('stiftungsgeschichte', 'Stiftungsgeschichte / Archiv'), ('anderes', 'Sonstiges')], default='anderes', max_length=30, verbose_name='Dokumententyp'),
),
migrations.AlterField(
model_name='emaileingang',
name='kategorie',
field=models.CharField(choices=[('destinataer', 'Destinataer'), ('rechnung', 'Rechnung'), ('land_pacht', 'Grundstueck / Pacht'), ('stiftungsgeschichte', 'Stiftungsgeschichte'), ('allgemein', 'Allgemein')], default='allgemein', max_length=20, verbose_name='Kategorie'),
),
]

View File

@@ -32,6 +32,7 @@ from .finanzen import ( # noqa: F401
from .destinataere import ( # noqa: F401
Destinataer,
DestinataerEmailEingang,
EmailEingang,
DestinataerNotiz,
DestinataerUnterstuetzung,
Foerderung,

View File

@@ -1104,34 +1104,79 @@ class VierteljahresNachweis(models.Model):
return None
class DestinataerEmailEingang(models.Model):
class EmailEingang(models.Model):
"""
Erfasst eingehende E-Mails von Destinatären.
Erfasst eingehende E-Mails (Destinataere, Rechnungen, Grundstuecke, Allgemein).
Wird automatisch durch den Celery-Task `poll_destinataer_emails` befüllt,
der das IMAP-Postfach der Stiftung (paperless@vhtv-stiftung.de) überwacht.
Anhänge werden automatisch in Paperless-NGX hochgeladen und als DokumentLink
mit dem jeweiligen Destinatär verknüpft.
Wird automatisch durch den Celery-Task `poll_emails` befuellt,
der das IMAP-Postfach der Stiftung (paperless@vhtv-stiftung.de) ueberwacht.
Anhaenge werden direkt als DokumentDatei im Django-DMS gespeichert.
"""
KATEGORIE_CHOICES = [
("destinataer", "Destinataer"),
("rechnung", "Rechnung"),
("land_pacht", "Grundstueck / Pacht"),
("stiftungsgeschichte", "Stiftungsgeschichte"),
("allgemein", "Allgemein"),
]
STATUS_CHOICES = [
("neu", "Neu / Unbearbeitet"),
("zugewiesen", "Destinatär zugewiesen"),
("zugewiesen", "Destinataer zugewiesen"),
("verarbeitet", "Verarbeitet"),
("rechnung_erfasst", "Rechnung erfasst"),
("zahlung_gebucht", "Zahlung gebucht"),
("unbekannt", "Unbekannter Absender"),
("fehler", "Fehler bei Verarbeitung"),
]
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
# Verknüpfung zum Destinatär (None = unbekannter Absender)
# Klassifizierung
kategorie = models.CharField(
max_length=20,
choices=KATEGORIE_CHOICES,
default="allgemein",
verbose_name="Kategorie",
)
# Verknuepfung zum Destinataer (None = kein Destinataer-Bezug)
destinataer = models.ForeignKey(
Destinataer,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="email_eingaenge",
verbose_name="Destinatär",
verbose_name="Destinataer",
)
# Verknuepfung zu Verwaltungskosten (Rechnungsworkflow)
verwaltungskosten = models.ForeignKey(
"Verwaltungskosten",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="email_eingaenge",
verbose_name="Verwaltungskosten / Rechnung",
)
# Verknuepfung zu Land / Verpachtung
land = models.ForeignKey(
"Land",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="email_eingaenge",
verbose_name="Laenderei",
)
verpachtung = models.ForeignKey(
"LandVerpachtung",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="email_eingaenge",
verbose_name="Verpachtung",
)
# E-Mail-Metadaten
@@ -1143,12 +1188,21 @@ class DestinataerEmailEingang(models.Model):
eingangsdatum = models.DateTimeField(verbose_name="Eingangsdatum")
email_text = models.TextField(blank=True, verbose_name="E-Mail-Text")
# Anhänge: Liste der Paperless-Dokument-IDs (JSON-Format)
# Anhaenge: DMS-Dokumente (Phase 3 DokumentDatei)
dokument_dateien = models.ManyToManyField(
"DokumentDatei",
blank=True,
related_name="email_eingaenge",
verbose_name="DMS-Dokumente (Anhaenge)",
help_text="Automatisch befuellte Anhaenge als Django-DMS-Dateien.",
)
# Anhaenge: Liste der Paperless-Dokument-IDs (JSON-Format, deprecated)
paperless_dokument_ids = models.JSONField(
default=list,
blank=True,
verbose_name="Paperless Dokument-IDs (Anhänge)",
help_text="Automatisch befüllte Liste der hochgeladenen Anhänge in Paperless-NGX",
verbose_name="Paperless Dokument-IDs (Anhaenge, veraltet)",
help_text="Veraltet wird nach vollstaendiger Migration entfernt. Neue Anhaenge in dokument_dateien.",
)
# Verarbeitungsstatus
@@ -1182,8 +1236,8 @@ class DestinataerEmailEingang(models.Model):
created_at = models.DateTimeField(auto_now_add=True, verbose_name="Erfasst am")
class Meta:
verbose_name = "E-Mail-Eingang (Destinatär)"
verbose_name_plural = "E-Mail-Eingänge (Destinatäre)"
verbose_name = "E-Mail-Eingang"
verbose_name_plural = "E-Mail-Eingaenge"
ordering = ["-eingangsdatum"]
def __str__(self):
@@ -1191,10 +1245,18 @@ class DestinataerEmailEingang(models.Model):
return f"[{self.eingangsdatum.strftime('%d.%m.%Y')}] {dest}: {self.betreff[:60]}"
def get_paperless_links(self):
"""Gibt Liste der Paperless-Dokument-URLs zurück."""
"""Gibt Liste der Paperless-Dokument-URLs zurueck (deprecated)."""
from django.conf import settings
base = settings.PAPERLESS_API_URL or ""
return [
f"{base}/documents/{doc_id}/"
for doc_id in (self.paperless_dokument_ids or [])
]
def get_dms_dokumente(self):
"""Gibt alle verknuepften DokumentDatei-Objekte zurueck."""
return self.dokument_dateien.all()
# Backward-compatible alias
DestinataerEmailEingang = EmailEingang

View File

@@ -30,6 +30,7 @@ class DokumentDatei(models.Model):
("landkarte", "Landkarte / Kataster"),
("korrespondenz", "Korrespondenz / Brief"),
("bescheid", "Bescheid / Behörde"),
("stiftungsgeschichte", "Stiftungsgeschichte / Archiv"),
("anderes", "Sonstiges"),
]
@@ -109,6 +110,13 @@ class DokumentDatei(models.Model):
related_name="dms_dokumente",
verbose_name="Rentmeister",
)
verwaltungskosten = models.ForeignKey(
"Verwaltungskosten",
on_delete=models.SET_NULL,
null=True, blank=True,
related_name="dms_dokumente",
verbose_name="Verwaltungskosten / Rechnung",
)
# Herkunft (optional: Verweis auf altes Paperless-Dokument zur Rückverfolgung)
paperless_dokument_id = models.IntegerField(

View File

@@ -972,6 +972,11 @@ class LandAbrechnung(models.Model):
class DokumentLink(models.Model):
"""
DEPRECATED: Paperless-basierte Dokumentverknüpfung.
Wird durch DokumentDatei (Django-natives DMS) ersetzt.
Dieses Modell bleibt für historische Daten erhalten, wird aber nicht mehr aktiv genutzt.
"""
KONTEXT_CHOICES = [
("pachtvertrag", "Pachtvertrag"),
("antrag", "Antrag"),
@@ -1020,18 +1025,6 @@ class DokumentLink(models.Model):
def __str__(self):
return f"{self.titel} ({self.get_kontext_display()})"
def get_paperless_url(self):
"""Gibt die URL zum Dokument in Paperless zurück (über Django Redirect)"""
return f"/api/paperless/documents/{self.paperless_document_id}/"
def get_paperless_thumbnail_url(self):
"""Gibt die URL zum Thumbnail in Paperless zurück"""
from django.conf import settings
if settings.PAPERLESS_API_URL:
return f"{settings.PAPERLESS_API_URL}/api/documents/{self.paperless_document_id}/thumb/"
return None
def get_verpachtung(self):
"""Gibt die verknüpfte Verpachtung zurück"""
if self.verpachtung_id:

View File

@@ -1,20 +1,20 @@
"""
Celery-Tasks für die automatische Verarbeitung von Destinatär-E-Mails.
Celery-Tasks fuer die automatische Verarbeitung eingehender E-Mails.
Workflow:
1. `poll_destinataer_emails` läuft alle 15 Minuten (Celery Beat)
2. Er liest ungelesene E-Mails aus dem IMAP-Postfach (paperless@vhtv-stiftung.de)
3. Für jede E-Mail:
a) Absender wird mit Destinatär-Datenbank abgeglichen (E-Mail-Feld)
b) Ein DestinataerEmailEingang-Datensatz wird angelegt
c) Alle Anhänge werden per Paperless-API hochgeladen
d) Für jeden Anhang wird ein DokumentLink erstellt
1. `poll_emails` laeuft alle 15 Minuten (Celery Beat)
2. Er liest ungelesene E-Mails aus dem IMAP-Postfach
3. Fuer jede E-Mail:
a) Absender wird mit Destinataer-Datenbank abgeglichen (E-Mail-Feld)
b) Betreff/Body wird auf Rechnungs-Keywords geprueft
c) Ein EmailEingang-Datensatz wird angelegt (mit Kategorie)
d) Alle Anhaenge werden als DokumentDatei im Django-DMS gespeichert
4. Nicht erkannte Absender werden als "unbekannt" markiert (manuelle Nachbearbeitung)
Konfiguration (Umgebungsvariablen in .env / compose.yml):
IMAP_HOST — IMAP-Server-Adresse (z. B. mail.vhtv-stiftung.de)
IMAP_PORT — Port (Standard: 993 für SSL)
IMAP_USER — Benutzername (z. B. paperless@vhtv-stiftung.de)
IMAP_PORT — Port (Standard: 993 fuer SSL)
IMAP_USER — Benutzername
IMAP_PASSWORD — Passwort
IMAP_FOLDER — Ordner (Standard: INBOX)
"""
@@ -22,22 +22,39 @@ Konfiguration (Umgebungsvariablen in .env / compose.yml):
import email
import email.utils
import imaplib
import io
import logging
import mimetypes
import re
from datetime import datetime, timezone as dt_timezone
from email.header import decode_header, make_header
import time
import requests
from celery import shared_task
from django.conf import settings
from django.utils import timezone
logger = logging.getLogger(__name__)
# Patterns fuer Rechnungserkennung im Betreff/Body
RECHNUNG_PATTERNS = [
re.compile(r"\brechnung\b", re.IGNORECASE),
re.compile(r"\binvoice\b", re.IGNORECASE),
re.compile(r"\brechnungs[-\s]?nr\.?\s*[:\s]?\s*\d+", re.IGNORECASE),
re.compile(r"\bRE[-/]\d{4,}", re.IGNORECASE), # RE-2024001, RE/20240315
]
GESCHICHTE_PATTERNS = [
re.compile(r"\bstiftungsgeschichte\b", re.IGNORECASE),
re.compile(r"\bahnenforschung\b", re.IGNORECASE),
re.compile(r"\bgenealogie\b", re.IGNORECASE),
re.compile(r"\bstammbaum\b", re.IGNORECASE),
re.compile(r"\bhistorisch", re.IGNORECASE),
re.compile(r"\bchronik\b", re.IGNORECASE),
re.compile(r"\barchiv\b", re.IGNORECASE),
re.compile(r"\bfamiliengeschichte\b", re.IGNORECASE),
re.compile(r"\burkunde\b", re.IGNORECASE),
]
# ---------------------------------------------------------------------------
# Hilfsfunktionen
# ---------------------------------------------------------------------------
@@ -54,7 +71,7 @@ def _decode_header_value(raw_value: str) -> str:
def _parse_email_date(date_str: str) -> datetime:
"""Parst das E-Mail-Datum und gibt ein timezone-aware datetime zurück."""
"""Parst das E-Mail-Datum und gibt ein timezone-aware datetime zurueck."""
try:
parsed = email.utils.parsedate_to_datetime(date_str)
if parsed.tzinfo is None:
@@ -86,148 +103,88 @@ def _get_email_body(msg) -> str:
return "\n".join(body_parts).strip()
def _poll_paperless_task(api_url: str, headers: dict, task_id: str, filename: str, max_wait: int = 120) -> int | None:
def _detect_kategorie(betreff: str, email_text: str, has_destinataer: bool) -> str:
"""
Pollt den Paperless-ngx Task-Status bis das Dokument verarbeitet wurde.
Gibt die Dokument-ID zurück, oder None bei Fehler/Timeout.
Erkennt die Kategorie einer Email anhand von Betreff und Body.
Gibt 'destinataer', 'rechnung', 'stiftungsgeschichte', oder 'allgemein' zurueck.
"""
task_url = f"{api_url.rstrip('/')}/api/tasks/?task_id={task_id}"
waited = 0
interval = 2
while waited < max_wait:
try:
resp = requests.get(task_url, headers=headers, timeout=30)
resp.raise_for_status()
tasks = resp.json()
if tasks:
task = tasks[0] if isinstance(tasks, list) else tasks
status = task.get("status", "")
if status == "SUCCESS":
related = task.get("related_document")
if related:
# related_document can be a URL like "/api/documents/42/"
# or just an ID
if isinstance(related, str):
parts = related.rstrip("/").split("/")
try:
return int(parts[-1])
except (ValueError, IndexError):
logger.warning("Konnte Dokument-ID nicht aus '%s' extrahieren.", related)
return None
return int(related)
# Some versions use result_id or document_id
for key in ("result_id", "document_id", "id"):
val = task.get(key)
if val is not None:
try:
return int(val)
except (ValueError, TypeError):
pass
logger.warning("Task %s erfolgreich aber keine Dokument-ID gefunden: %s", task_id, task)
return None
elif status == "FAILURE":
logger.error("Paperless-Task %s fehlgeschlagen für '%s': %s", task_id, filename, task.get("result"))
return None
except requests.RequestException as exc:
logger.warning("Fehler beim Abfragen von Paperless-Task %s: %s", task_id, exc)
time.sleep(interval)
waited += interval
logger.error("Timeout beim Warten auf Paperless-Task %s für '%s' (%ds).", task_id, filename, max_wait)
return None
if has_destinataer:
return "destinataer"
text_to_check = f"{betreff}\n{email_text[:2000]}"
# Rechnungserkennung via Patterns
for pattern in RECHNUNG_PATTERNS:
if pattern.search(text_to_check):
return "rechnung"
# Stiftungsgeschichte-Erkennung
for pattern in GESCHICHTE_PATTERNS:
if pattern.search(text_to_check):
return "stiftungsgeschichte"
return "allgemein"
def _upload_to_paperless(content: bytes, filename: str, destinataer=None, betreff: str = "") -> int | None:
def _save_to_dms(content: bytes, filename: str, destinataer=None, betreff: str = "", kontext: str = "korrespondenz"):
"""
Lädt einen Anhang in Paperless-NGX hoch.
Speichert einen E-Mail-Anhang direkt als DokumentDatei im Django-DMS.
Gibt die neue Paperless-Dokument-ID zurück, oder None bei Fehler.
Gibt das DokumentDatei-Objekt zurueck, oder None bei Fehler.
"""
api_url = getattr(settings, "PAPERLESS_API_URL", None)
api_token = getattr(settings, "PAPERLESS_API_TOKEN", None)
from stiftung.models import DokumentDatei
from django.core.files.base import ContentFile
if not api_url or not api_token:
logger.warning("Paperless nicht konfiguriert Anhang '%s' wird nicht hochgeladen.", filename)
return None
# Tag-ID für Destinatäre ermitteln
tag_ids = []
dest_tag_id = getattr(settings, "PAPERLESS_DESTINATAERE_TAG_ID", None)
if dest_tag_id:
try:
tag_ids.append(int(dest_tag_id))
except (ValueError, TypeError):
pass
# Correspondent: Name des Destinatärs (optional, Paperless sucht/erstellt ihn)
correspondent_name = None
if destinataer:
correspondent_name = f"{destinataer.vorname} {destinataer.nachname}".strip()
# Dateiname bereinigen
safe_filename = filename or "anhang.pdf"
# Mime-Type bestimmen
safe_filename = filename or "anhang.bin"
mime_type, _ = mimetypes.guess_type(safe_filename)
mime_type = mime_type or "application/octet-stream"
upload_url = f"{api_url.rstrip('/')}/api/documents/post_document/"
headers = {"Authorization": f"Token {api_token}"}
form_data = {}
if tag_ids:
form_data["tags"] = tag_ids
if correspondent_name:
form_data["correspondent_name"] = correspondent_name
if betreff:
form_data["title"] = betreff[:128]
files = {"document": (safe_filename, io.BytesIO(content), mime_type)}
titel = f"{betreff[:100]} {safe_filename}" if betreff else safe_filename
beschreibung = ""
if destinataer:
beschreibung = (
f"Automatisch importiert aus E-Mail-Eingang.\n"
f"Absender: {destinataer.vorname} {destinataer.nachname} <{destinataer.email}>"
)
try:
response = requests.post(
upload_url,
headers=headers,
data=form_data,
files=files,
timeout=300, # 5 Minuten für große Anhänge
doc = DokumentDatei(
titel=titel[:255],
beschreibung=beschreibung,
kontext=kontext,
dateiname_original=safe_filename,
dateityp=mime_type,
dateigroesse=len(content),
destinataer=destinataer,
)
response.raise_for_status()
result = response.json()
# Paperless-ngx post_document returns a task UUID string.
# We need to poll the task status to get the actual document ID.
if isinstance(result, str):
task_id = result
doc_id = _poll_paperless_task(api_url, headers, task_id, safe_filename)
elif isinstance(result, int):
doc_id = result
else:
doc_id = result.get("id")
logger.info("Anhang '%s' erfolgreich in Paperless hochgeladen (ID: %s).", safe_filename, doc_id)
return doc_id
except requests.RequestException as exc:
logger.error("Fehler beim Hochladen von '%s' in Paperless: %s", safe_filename, exc)
doc.datei.save(safe_filename, ContentFile(content), save=False)
doc.save()
logger.info("Anhang '%s' als DokumentDatei gespeichert (ID: %s).", safe_filename, doc.pk)
return doc
except Exception as exc:
logger.error("Fehler beim Speichern von '%s' im DMS: %s", safe_filename, exc)
return None
# ---------------------------------------------------------------------------
# Haupttask
# ---------------------------------------------------------------------------
@shared_task(bind=True, max_retries=3, default_retry_delay=300, name="stiftung.tasks.poll_destinataer_emails")
def poll_destinataer_emails(self, search_all_recent_days=0):
@shared_task(bind=True, max_retries=3, default_retry_delay=300, name="stiftung.tasks.poll_emails")
def poll_emails(self, search_all_recent_days=0):
"""
Liest E-Mails aus dem IMAP-Postfach und verarbeitet sie.
Wird durch Celery Beat alle 15 Minuten ausgeführt.
Wird durch Celery Beat alle 15 Minuten ausgefuehrt.
Erkennt automatisch Destinataer-Emails, Rechnungen und allgemeine Post.
Args:
search_all_recent_days: Wenn > 0, werden alle E-Mails der letzten N Tage
durchsucht (nicht nur ungelesene). Nützlich für manuellen Abruf.
durchsucht (nicht nur ungelesene). Nuetzlich fuer manuellen Abruf.
"""
from stiftung.models import Destinataer, DestinataerEmailEingang, DokumentLink
from stiftung.models import Destinataer, EmailEingang
# IMAP-Konfiguration: DB (AppConfiguration) mit Fallback auf env/settings
from stiftung.utils.config import get_config
@@ -242,12 +199,12 @@ def poll_destinataer_emails(self, search_all_recent_days=0):
if not all([imap_host, imap_user, imap_password]):
logger.warning(
"IMAP nicht konfiguriert (IMAP_HOST, IMAP_USER, IMAP_PASSWORD fehlen). "
"Task wird übersprungen."
"Task wird uebersprungen."
)
return {"status": "skipped", "reason": "IMAP not configured"}
# Vorab: Destinatär-E-Mail-Index für schnelle Zuordnung
# Nur aktive Destinatäre mit gesetzter E-Mail-Adresse
# Vorab: Destinataer-E-Mail-Index fuer schnelle Zuordnung
# Nur aktive Destinataere mit gesetzter E-Mail-Adresse
destinataer_by_email = {
d.email.lower(): d
for d in Destinataer.objects.filter(aktiv=True, email__isnull=False).exclude(email="")
@@ -257,8 +214,8 @@ def poll_destinataer_emails(self, search_all_recent_days=0):
errors = 0
try:
# IMAP-Verbindung aufbauen (mit Socket-Timeout für große E-Mails)
imap_timeout = 120 # Sekunden genug für große Anhänge
# IMAP-Verbindung aufbauen (mit Socket-Timeout fuer grosse E-Mails)
imap_timeout = 120 # Sekunden genug fuer grosse Anhaenge
if imap_use_ssl:
mail = imaplib.IMAP4_SSL(imap_host, imap_port, timeout=imap_timeout)
else:
@@ -289,7 +246,7 @@ def poll_destinataer_emails(self, search_all_recent_days=0):
# Absender ermitteln
from_raw = msg.get("From", "")
absender_name_raw, absender_email_raw = email.utils.parseaddr(from_raw)
absender_email = absender_email_raw.lower().strip()
absender_email_addr = absender_email_raw.lower().strip()
absender_name = _decode_header_value(absender_name_raw)
# Betreff
@@ -301,30 +258,48 @@ def poll_destinataer_emails(self, search_all_recent_days=0):
# E-Mail-Text
email_text = _get_email_body(msg)
# Destinatär zuordnen
destinataer = destinataer_by_email.get(absender_email)
status = "zugewiesen" if destinataer else "unbekannt"
# Destinataer zuordnen
destinataer = destinataer_by_email.get(absender_email_addr)
# Prüfen ob diese E-Mail bereits verarbeitet wurde (Duplikat-Check via
# Kategorie erkennen
kategorie = _detect_kategorie(betreff, email_text, has_destinataer=bool(destinataer))
# Status basierend auf Kategorie
if destinataer:
status = "zugewiesen"
elif kategorie == "rechnung":
status = "neu" # Muss manuell als Rechnung erfasst werden
else:
status = "unbekannt"
# DMS-Kontext fuer Anhaenge basierend auf Kategorie
dms_kontext_map = {
"rechnung": "rechnung",
"stiftungsgeschichte": "stiftungsgeschichte",
}
dms_kontext = dms_kontext_map.get(kategorie, "korrespondenz")
# Pruefen ob diese E-Mail bereits verarbeitet wurde (Duplikat-Check via
# Datum + Absender + Betreff)
already_exists = DestinataerEmailEingang.objects.filter(
absender_email=absender_email,
already_exists = EmailEingang.objects.filter(
absender_email=absender_email_addr,
eingangsdatum=eingangsdatum,
betreff=betreff[:500],
).exists()
if already_exists:
logger.debug(
"E-Mail von %s am %s bereits vorhanden wird übersprungen.",
absender_email, eingangsdatum,
"E-Mail von %s am %s bereits vorhanden wird uebersprungen.",
absender_email_addr, eingangsdatum,
)
# Als gelesen markieren
mail.store(msg_id, "+FLAGS", "\\Seen")
continue
# Datensatz anlegen
eingang = DestinataerEmailEingang(
eingang = EmailEingang(
kategorie=kategorie,
destinataer=destinataer,
absender_email=absender_email,
absender_email=absender_email_addr,
absender_name=absender_name,
betreff=betreff[:500],
eingangsdatum=eingangsdatum,
@@ -332,8 +307,8 @@ def poll_destinataer_emails(self, search_all_recent_days=0):
status=status,
)
# Anhänge verarbeiten
paperless_ids = []
# Anhaenge verarbeiten und als DokumentDatei im DMS speichern
dms_dokumente = []
if msg.is_multipart():
for part in msg.walk():
disposition = str(part.get_content_disposition() or "")
@@ -342,51 +317,42 @@ def poll_destinataer_emails(self, search_all_recent_days=0):
content = part.get_payload(decode=True)
if not content:
logger.warning(
"Anhang '%s' hat keinen Inhalt (möglicherweise zu groß oder beschädigt) wird übersprungen.",
"Anhang '%s' hat keinen Inhalt wird uebersprungen.",
filename,
)
continue
doc_id = _upload_to_paperless(
doc = _save_to_dms(
content=content,
filename=filename,
destinataer=destinataer,
betreff=betreff,
kontext=dms_kontext,
)
if doc_id:
paperless_ids.append(doc_id)
# DokumentLink anlegen
DokumentLink.objects.create(
paperless_document_id=doc_id,
kontext="verwendungsnachweis",
titel=f"{betreff[:100]} {filename}" if filename else betreff[:200],
beschreibung=(
f"Automatisch importiert aus E-Mail-Eingang.\n"
f"Absender: {absender_name} <{absender_email}>\n"
f"Datum: {eingangsdatum.strftime('%d.%m.%Y %H:%M')}"
),
destinataer_id=destinataer.pk if destinataer else None,
)
if doc:
dms_dokumente.append(doc)
eingang.paperless_dokument_ids = paperless_ids
if paperless_ids:
eingang.status = "verarbeitet" if destinataer else "unbekannt"
if dms_dokumente:
eingang.status = "verarbeitet" if destinataer else status
eingang.save()
if dms_dokumente:
eingang.dokument_dateien.set(dms_dokumente)
# Als gelesen markieren
mail.store(msg_id, "+FLAGS", "\\Seen")
processed += 1
logger.info(
"E-Mail verarbeitet: von=%s, Destinatär=%s, Anhänge=%d",
absender_email,
str(destinataer) if destinataer else "unbekannt",
len(paperless_ids),
"E-Mail verarbeitet: von=%s, Kategorie=%s, Destinataer=%s, Anhaenge=%d",
absender_email_addr,
kategorie,
str(destinataer) if destinataer else "",
len(dms_dokumente),
)
except Exception as exc:
errors += 1
logger.exception("Fehler bei Verarbeitung von Nachricht %s: %s", msg_id, exc)
# Nicht als gelesen markieren wird beim nächsten Lauf erneut versucht
# Nicht als gelesen markieren wird beim naechsten Lauf erneut versucht
mail.close()
mail.logout()
@@ -395,9 +361,13 @@ def poll_destinataer_emails(self, search_all_recent_days=0):
logger.error("IMAP-Fehler: %s", exc)
raise self.retry(exc=exc)
except Exception as exc:
logger.exception("Unerwarteter Fehler im poll_destinataer_emails-Task: %s", exc)
logger.exception("Unerwarteter Fehler im poll_emails-Task: %s", exc)
raise self.retry(exc=exc)
result = {"status": "done", "processed": processed, "errors": errors}
logger.info("poll_destinataer_emails abgeschlossen: %s", result)
logger.info("poll_emails abgeschlossen: %s", result)
return result
# Backward-compatible alias for existing Celery Beat schedules
poll_destinataer_emails = poll_emails

View File

@@ -140,46 +140,7 @@ urlpatterns = [
views.foerderung_delete,
name="foerderung_delete",
),
# Dokumente URLs
path("dokumente/", views.dokument_list, name="dokument_list"),
path("dokumente/<uuid:pk>/", views.dokument_detail, name="dokument_detail"),
path("dokumente/neu/", views.dokument_create, name="dokument_create"),
path(
"dokumente/<uuid:pk>/bearbeiten/", views.dokument_update, name="dokument_update"
),
path(
"dokumente/<uuid:pk>/loeschen/", views.dokument_delete, name="dokument_delete"
),
# Dokumentenverwaltung (Paperless-Integration, Verwaltung & Verknüpfung)
path(
"dokumente/verwaltung/", views.dokument_management, name="dokument_management"
),
# Legacy document URLs removed - use dokument_management instead
# Dokument-Verknüpfung
path(
"api/link-document/search/",
views.link_document_search,
name="link_document_search",
),
path(
"api/link-document/create/",
views.link_document_create,
name="link_document_create",
),
path(
"api/link-document/list/", views.link_document_list, name="link_document_list"
),
path(
"api/link-document/update/",
views.link_document_update,
name="link_document_update",
),
path(
"api/link-document/delete/<uuid:link_id>/",
views.link_document_delete,
name="link_document_delete",
),
# Legacy dokument_verknuepfung URL removed - use dokument_management instead
# Dokumente-URLs (DMS) Legacy-Paperless-URLs entfernt (Phase 3)
# Jahresbericht URLs
path("berichte/", views.bericht_list, name="bericht_list"),
path(
@@ -355,19 +316,6 @@ urlpatterns = [
# API URLs
path("api/land-stats/", views.land_stats_api, name="land_stats_api"),
path("api/health/", views.health_check, name="health_check"),
path("api/paperless/ping/", views.paperless_ping, name="paperless_ping"),
path(
"api/paperless/documents/",
views.paperless_documents,
name="paperless_documents",
),
path("api/paperless/tags/", views.paperless_tags_only, name="paperless_tags_only"),
path("api/paperless/debug/", views.paperless_debug, name="paperless_debug"),
path(
"api/paperless/documents/<int:doc_id>/",
views.paperless_document_redirect,
name="paperless_document_redirect",
),
# Veranstaltungsmodul
path("veranstaltungen/", views.veranstaltung_list, name="veranstaltung_list"),
path("veranstaltungen/neu/", views.veranstaltung_create, name="veranstaltung_create"),

View File

@@ -30,25 +30,6 @@ def get_config(key, default=None, fallback_to_settings=True):
return value if value is not None else default
def get_paperless_config():
"""
Get all Paperless-related configuration values
Returns:
dict: Dictionary containing all Paperless configuration
"""
return {
"api_url": get_config("paperless_api_url"),
"api_token": get_config("paperless_api_token"),
"destinataere_tag": get_config("paperless_destinataere_tag"),
"destinataere_tag_id": get_config("paperless_destinataere_tag_id"),
"land_tag": get_config("paperless_land_tag"),
"land_tag_id": get_config("paperless_land_tag_id"),
"admin_tag": get_config("paperless_admin_tag"),
"admin_tag_id": get_config("paperless_admin_tag_id"),
}
def set_config(key, value, **kwargs):
"""
Set a configuration value
@@ -63,13 +44,3 @@ def set_config(key, value, **kwargs):
"""
return AppConfiguration.set_setting(key, value, **kwargs)
def is_paperless_configured():
"""
Check if Paperless is properly configured
Returns:
bool: True if API URL and token are configured
"""
config = get_paperless_config()
return bool(config["api_url"] and config["api_token"])

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 = {