- 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>
189 lines
6.4 KiB
Python
189 lines
6.4 KiB
Python
# models/dokumente.py
|
||
# Phase 3: Django-natives DMS – ersetzt Paperless-NGX-Integration
|
||
import uuid
|
||
import os
|
||
|
||
from django.contrib.postgres.indexes import GinIndex
|
||
from django.contrib.postgres.search import SearchVector, SearchVectorField
|
||
from django.db import models
|
||
from django.utils import timezone
|
||
|
||
|
||
def dokument_upload_path(instance, filename):
|
||
"""Speichert Dateien in MEDIA_ROOT/dokumente/YYYY/MM/<uuid>/<original_filename>"""
|
||
ext = os.path.splitext(filename)[1].lower()
|
||
safe_name = os.path.basename(filename)[:100]
|
||
return f"dokumente/{timezone.now().strftime('%Y/%m')}/{instance.id}/{safe_name}"
|
||
|
||
|
||
class DokumentDatei(models.Model):
|
||
"""Nativ gespeicherte Datei im Django-DMS – ersetzt Paperless-Referenzen."""
|
||
|
||
KONTEXT_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"),
|
||
]
|
||
|
||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||
titel = models.CharField(max_length=255, verbose_name="Titel")
|
||
beschreibung = models.TextField(blank=True, verbose_name="Beschreibung")
|
||
kontext = models.CharField(
|
||
max_length=30,
|
||
choices=KONTEXT_CHOICES,
|
||
default="anderes",
|
||
verbose_name="Dokumententyp",
|
||
)
|
||
datei = models.FileField(
|
||
upload_to=dokument_upload_path,
|
||
verbose_name="Datei",
|
||
)
|
||
dateiname_original = models.CharField(
|
||
max_length=255, blank=True, verbose_name="Originaldateiname"
|
||
)
|
||
dateityp = models.CharField(
|
||
max_length=100, blank=True, verbose_name="MIME-Typ"
|
||
)
|
||
dateigroesse = models.PositiveIntegerField(
|
||
default=0, verbose_name="Dateigröße (Bytes)"
|
||
)
|
||
|
||
# Volltext-Index (PostgreSQL FTS, befüllt per Signal)
|
||
inhaltstext = models.TextField(
|
||
blank=True,
|
||
verbose_name="Extrahierter Textinhalt",
|
||
help_text="Automatisch befüllt für PDF/TXT-Dateien. Basis für Volltextsuche.",
|
||
)
|
||
suchvektor = SearchVectorField(
|
||
null=True, blank=True, verbose_name="Such-Vektor (FTS)"
|
||
)
|
||
|
||
# Zuordnungsfelder – optional, ein Dokument kann mehreren Entitäten gehören
|
||
land = models.ForeignKey(
|
||
"Land",
|
||
on_delete=models.SET_NULL,
|
||
null=True, blank=True,
|
||
related_name="dms_dokumente",
|
||
verbose_name="Länderei",
|
||
)
|
||
paechter = models.ForeignKey(
|
||
"Paechter",
|
||
on_delete=models.SET_NULL,
|
||
null=True, blank=True,
|
||
related_name="dms_dokumente",
|
||
verbose_name="Pächter",
|
||
)
|
||
verpachtung = models.ForeignKey(
|
||
"LandVerpachtung",
|
||
on_delete=models.SET_NULL,
|
||
null=True, blank=True,
|
||
related_name="dms_dokumente",
|
||
verbose_name="Verpachtung",
|
||
)
|
||
destinataer = models.ForeignKey(
|
||
"Destinataer",
|
||
on_delete=models.SET_NULL,
|
||
null=True, blank=True,
|
||
related_name="dms_dokumente",
|
||
verbose_name="Destinatär",
|
||
)
|
||
foerderung = models.ForeignKey(
|
||
"Foerderung",
|
||
on_delete=models.SET_NULL,
|
||
null=True, blank=True,
|
||
related_name="dms_dokumente",
|
||
verbose_name="Förderung",
|
||
)
|
||
rentmeister = models.ForeignKey(
|
||
"Rentmeister",
|
||
on_delete=models.SET_NULL,
|
||
null=True, blank=True,
|
||
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(
|
||
null=True, blank=True,
|
||
verbose_name="Paperless-ID (Migration)",
|
||
help_text="Wird nach vollständiger Migration entfernt.",
|
||
)
|
||
|
||
# Audit
|
||
erstellt_von = models.ForeignKey(
|
||
"auth.User",
|
||
on_delete=models.SET_NULL,
|
||
null=True, blank=True,
|
||
related_name="hochgeladene_dokumente",
|
||
verbose_name="Erstellt von",
|
||
)
|
||
erstellt_am = models.DateTimeField(auto_now_add=True, verbose_name="Erstellt am")
|
||
aktualisiert_am = models.DateTimeField(auto_now=True, verbose_name="Aktualisiert am")
|
||
|
||
class Meta:
|
||
verbose_name = "Dokument"
|
||
verbose_name_plural = "Dokumente (DMS)"
|
||
ordering = ["-erstellt_am"]
|
||
indexes = [
|
||
# PostgreSQL GIN-Index für Volltextsuche
|
||
GinIndex(fields=["suchvektor"], name="dms_suchvektor_gin_idx"),
|
||
models.Index(fields=["kontext"]),
|
||
models.Index(fields=["destinataer", "kontext"]),
|
||
models.Index(fields=["land", "kontext"]),
|
||
models.Index(fields=["paechter", "kontext"]),
|
||
]
|
||
|
||
def __str__(self):
|
||
return self.titel or self.dateiname_original or str(self.id)
|
||
|
||
def save(self, *args, **kwargs):
|
||
# Originaldateiname aus FileField ableiten
|
||
if self.datei and not self.dateiname_original:
|
||
self.dateiname_original = os.path.basename(self.datei.name)
|
||
super().save(*args, **kwargs)
|
||
|
||
def update_suchvektor(self):
|
||
"""Aktualisiert den Such-Vektor aus Titel, Beschreibung und Inhaltstext."""
|
||
DokumentDatei.objects.filter(pk=self.pk).update(
|
||
suchvektor=SearchVector("titel", weight="A")
|
||
+ SearchVector("beschreibung", weight="B")
|
||
+ SearchVector("inhaltstext", weight="C"),
|
||
)
|
||
|
||
def get_datei_url(self):
|
||
"""Gibt die Download-URL zurück."""
|
||
if self.datei:
|
||
return self.datei.url
|
||
return None
|
||
|
||
def is_pdf(self):
|
||
return self.dateityp == "application/pdf" or (
|
||
self.dateiname_original and self.dateiname_original.lower().endswith(".pdf")
|
||
)
|
||
|
||
def get_human_size(self):
|
||
"""Gibt die Dateigröße leserlich zurück."""
|
||
size = self.dateigroesse
|
||
if size < 1024:
|
||
return f"{size} B"
|
||
elif size < 1024 * 1024:
|
||
return f"{size / 1024:.1f} KB"
|
||
else:
|
||
return f"{size / (1024 * 1024):.1f} MB"
|