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

252
app/stiftung/views/dms.py Normal file
View File

@@ -0,0 +1,252 @@
# views/dms.py
# Phase 3: Django-natives DMS Dokumentenverwaltung ohne Paperless-NGX
import os
from datetime import date
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.contrib.postgres.search import SearchQuery, SearchRank
from django.core.paginator import Paginator
from django.http import FileResponse, Http404, JsonResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.utils import timezone
from django.views.decorators.http import require_POST
from stiftung.models import (
Destinataer, DokumentDatei, Foerderung, Land, LandVerpachtung, Paechter, Rentmeister
)
# ---------------------------------------------------------------------------
# Hilfsfunktionen
# ---------------------------------------------------------------------------
def _save_upload(request, instance: DokumentDatei):
"""Speichert Upload-Metadaten (Dateityp, Größe, FTS)."""
if instance.datei:
f = instance.datei
instance.dateiname_original = os.path.basename(f.name)
instance.dateityp = getattr(f, "content_type", "") or ""
instance.dateigroesse = f.size if hasattr(f, "size") else 0
instance.erstellt_von = request.user
instance.save()
instance.update_suchvektor()
# ---------------------------------------------------------------------------
# DMS Hauptseiten
# ---------------------------------------------------------------------------
@login_required
def dms_list(request):
"""Dokumenten-Übersicht mit Filter und Suche."""
q = request.GET.get("q", "").strip()
kontext_filter = request.GET.get("kontext", "")
entity_filter = request.GET.get("entity", "") # z.B. "destinataer"
entity_id = request.GET.get("entity_id", "")
qs = DokumentDatei.objects.select_related(
"destinataer", "land", "paechter", "verpachtung", "erstellt_von"
)
# Volltextsuche
if q:
search_query = SearchQuery(q, config="german")
qs = qs.annotate(rank=SearchRank("suchvektor", search_query)).filter(
rank__gt=0.01
).order_by("-rank")
else:
qs = qs.order_by("-erstellt_am")
if kontext_filter:
qs = qs.filter(kontext=kontext_filter)
if entity_filter == "destinataer" and entity_id:
qs = qs.filter(destinataer_id=entity_id)
elif entity_filter == "land" and entity_id:
qs = qs.filter(land_id=entity_id)
elif entity_filter == "paechter" and entity_id:
qs = qs.filter(paechter_id=entity_id)
elif entity_filter == "verpachtung" and entity_id:
qs = qs.filter(verpachtung_id=entity_id)
paginator = Paginator(qs, 25)
page_obj = paginator.get_page(request.GET.get("page"))
context = {
"page_obj": page_obj,
"q": q,
"kontext_filter": kontext_filter,
"kontext_choices": DokumentDatei.KONTEXT_CHOICES,
"gesamt": qs.count() if not q else paginator.count,
}
return render(request, "stiftung/dms/list.html", context)
@login_required
def dms_detail(request, pk):
"""Dokument-Detailseite mit Download-Link."""
dok = get_object_or_404(DokumentDatei, pk=pk)
context = {"dok": dok}
return render(request, "stiftung/dms/detail.html", context)
@login_required
def dms_download(request, pk):
"""Direkter Datei-Download."""
dok = get_object_or_404(DokumentDatei, pk=pk)
if not dok.datei or not dok.datei.storage.exists(dok.datei.name):
raise Http404("Datei nicht gefunden.")
response = FileResponse(
dok.datei.open("rb"),
as_attachment=True,
filename=dok.dateiname_original or os.path.basename(dok.datei.name),
)
return response
@login_required
def dms_upload(request):
"""HTMX-Drag&Drop-Upload unterstützt normale POST-Anfragen und HTMX-Requests."""
# Pre-fill entity links from GET params
initial = {
"destinataer_id": request.GET.get("destinataer", ""),
"land_id": request.GET.get("land", ""),
"paechter_id": request.GET.get("paechter", ""),
"verpachtung_id": request.GET.get("verpachtung", ""),
"kontext": request.GET.get("kontext", "anderes"),
}
if request.method == "POST":
datei = request.FILES.get("datei")
titel = request.POST.get("titel", "").strip()
beschreibung = request.POST.get("beschreibung", "").strip()
kontext = request.POST.get("kontext", "anderes")
if not datei:
if request.htmx:
return JsonResponse({"error": "Keine Datei übermittelt."}, status=400)
messages.error(request, "Bitte eine Datei auswählen.")
else:
if not titel:
titel = os.path.splitext(datei.name)[0][:255]
dok = DokumentDatei(
titel=titel,
beschreibung=beschreibung,
kontext=kontext,
datei=datei,
)
# Entity links
dest_id = request.POST.get("destinataer_id", "").strip()
land_id = request.POST.get("land_id", "").strip()
paechter_id = request.POST.get("paechter_id", "").strip()
verp_id = request.POST.get("verpachtung_id", "").strip()
if dest_id:
try:
dok.destinataer_id = dest_id
except Exception:
pass
if land_id:
try:
dok.land_id = land_id
except Exception:
pass
if paechter_id:
try:
dok.paechter_id = paechter_id
except Exception:
pass
if verp_id:
try:
dok.verpachtung_id = verp_id
except Exception:
pass
_save_upload(request, dok)
if request.htmx:
return render(request, "stiftung/dms/partials/upload_success.html", {"dok": dok})
messages.success(request, f'Dokument \u201e{dok.titel}\u201c erfolgreich hochgeladen.')
return redirect("stiftung:dms_detail", pk=dok.pk)
# GET: zeige Upload-Formular
destinataere = Destinataer.objects.filter(aktiv=True).order_by("nachname", "vorname")
laendereien = Land.objects.filter(aktiv=True).order_by("lfd_nr")
paechter_qs = Paechter.objects.filter(aktiv=True).order_by("nachname")
context = {
"initial": initial,
"kontext_choices": DokumentDatei.KONTEXT_CHOICES,
"destinataere": destinataere,
"laendereien": laendereien,
"paechter_qs": paechter_qs,
}
return render(request, "stiftung/dms/upload.html", context)
@login_required
@require_POST
def dms_delete(request, pk):
"""Löscht ein Dokument inkl. Datei."""
dok = get_object_or_404(DokumentDatei, pk=pk)
titel = dok.titel
# Datei physisch löschen
if dok.datei:
try:
dok.datei.delete(save=False)
except Exception:
pass
dok.delete()
messages.success(request, f'Dokument \u201e{titel}\u201c gel\u00f6scht.')
next_url = request.POST.get("next") or "stiftung:dms_list"
if next_url.startswith("/"):
return redirect(next_url)
return redirect(next_url)
@login_required
def dms_search_api(request):
"""HTMX-Suche: gibt gerendertes Partial mit Suchergebnissen zurück."""
q = request.GET.get("q", "").strip()
if not q:
return render(request, "stiftung/dms/partials/search_results.html", {"results": []})
search_query = SearchQuery(q, config="german")
results = (
DokumentDatei.objects.annotate(rank=SearchRank("suchvektor", search_query))
.filter(rank__gt=0.01)
.select_related("destinataer", "land")
.order_by("-rank")[:20]
)
return render(
request,
"stiftung/dms/partials/search_results.html",
{"results": results, "q": q},
)
@login_required
def dms_edit(request, pk):
"""Bearbeite Metadaten eines Dokuments (kein Datei-Austausch)."""
dok = get_object_or_404(DokumentDatei, pk=pk)
if request.method == "POST":
dok.titel = request.POST.get("titel", dok.titel).strip()[:255]
dok.beschreibung = request.POST.get("beschreibung", "").strip()
dok.kontext = request.POST.get("kontext", dok.kontext)
dok.save()
dok.update_suchvektor()
messages.success(request, "Metadaten gespeichert.")
return redirect("stiftung:dms_detail", pk=dok.pk)
context = {
"dok": dok,
"kontext_choices": DokumentDatei.KONTEXT_CHOICES,
}
return render(request, "stiftung/dms/edit.html", context)