Phase 3: Django-natives DMS – Paperless-NGX durch DokumentDatei ersetzt

- Neues Modell DokumentDatei mit PostgreSQL FTS (SearchVectorField, GinIndex)
- Upload-Pfad: dokumente/YYYY/MM/<uuid>/dateiname
- 7 DMS-Views: list, detail, download, upload (HTMX Drag&Drop), delete, edit, search_api
- Templates: list, detail, edit, upload mit Drag&Drop-Zone, Partials
- URLs: /dms/ komplett verdrahtet
- Sidebar: DMS als Primäreintrag, Paperless als Legacy
- Migrationsskript: manage.py migrate_paperless_dokumente (DokumentLink → DokumentDatei)
- compose.yml: paperless-Dienst deaktiviert (Legacy-Kommentarblock)
- Migration 0048 angewendet

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
SysAdmin Agent
2026-03-11 11:10:08 +00:00
parent ee2c827d85
commit a79a0989d6
16 changed files with 1219 additions and 35 deletions

View File

@@ -0,0 +1,180 @@
# 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"),
("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",
)
# 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"