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:
@@ -10,6 +10,10 @@ from .system import ( # noqa: F401
|
||||
HelpBox,
|
||||
)
|
||||
|
||||
from .dokumente import ( # noqa: F401
|
||||
DokumentDatei,
|
||||
)
|
||||
|
||||
from .land import ( # noqa: F401
|
||||
DokumentLink,
|
||||
Land,
|
||||
|
||||
180
app/stiftung/models/dokumente.py
Normal file
180
app/stiftung/models/dokumente.py
Normal 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"
|
||||
Reference in New Issue
Block a user