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,124 @@
# management/commands/migrate_paperless_dokumente.py
# Phase 3: Migriert DokumentLink-Einträge zu DokumentDatei (falls Paperless-Dateien lokal verfügbar)
#
# Verwendung:
# python manage.py migrate_paperless_dokumente [--dry-run] [--limit N]
#
# Was dieser Befehl tut:
# 1. Alle DokumentLink-Objekte abrufen (Paperless-Verweise)
# 2. Für jeden Link: DokumentDatei erstellen, falls noch keine existiert (paperless_dokument_id)
# 3. Suchvektor aktualisieren
# 4. paperless_dokument_id setzen, damit künftige Läufe Duplikate überspringen
import os
from django.core.management.base import BaseCommand, CommandError
from django.db import transaction
from stiftung.models import DokumentDatei, DokumentLink
class Command(BaseCommand):
help = "Migriert Paperless-DokumentLink-Einträge zu DokumentDatei (Metadaten only)"
def add_arguments(self, parser):
parser.add_argument(
"--dry-run",
action="store_true",
help="Zeigt an, was migriert würde, ohne Änderungen vorzunehmen.",
)
parser.add_argument(
"--limit",
type=int,
default=0,
help="Maximale Anzahl Einträge (0 = alle).",
)
def handle(self, *args, **options):
dry_run = options["dry_run"]
limit = options["limit"]
links = DokumentLink.objects.select_related(
"destinataer", "land", "paechter", "verpachtung"
).order_by("pk")
if limit > 0:
links = links[:limit]
total = links.count()
self.stdout.write(f"Gefundene DokumentLinks: {total}")
if dry_run:
self.stdout.write(self.style.WARNING("DRY-RUN keine Datenbankänderungen."))
created = 0
skipped = 0
for link in links:
# Bereits migriert?
if DokumentDatei.objects.filter(
paperless_dokument_id=link.paperless_document_id
).exists():
skipped += 1
continue
titel = link.titel or f"Paperless #{link.paperless_document_id}"
kontext = link.kontext or _guess_kontext(titel)
if dry_run:
self.stdout.write(
f" [DRY] Würde anlegen: {titel!r} (kontext={kontext}, "
f"paperless_id={link.paperless_document_id})"
)
created += 1
continue
with transaction.atomic():
dok = DokumentDatei(
titel=titel,
beschreibung=link.beschreibung or "",
kontext=kontext,
paperless_dokument_id=link.paperless_document_id,
)
# Assign FKs by ID (DokumentLink stores raw UUIDs, not FK relations)
if link.destinataer_id:
dok.destinataer_id = link.destinataer_id
if link.land_id:
dok.land_id = link.land_id
if link.paechter_id:
dok.paechter_id = link.paechter_id
if link.land_verpachtung_id:
dok.verpachtung_id = link.land_verpachtung_id
dok.save()
dok.update_suchvektor()
created += 1
self.stdout.write(
self.style.SUCCESS(
f"Fertig: {created} angelegt, {skipped} übersprungen (bereits migriert)."
)
)
def _guess_kontext(title_lower: str) -> str:
"""Leitet den Kontext-Code aus dem Titel ab."""
t = title_lower.lower()
if any(kw in t for kw in ["pachtvertrag", "pachtvertr"]):
return "pachtvertrag"
if any(kw in t for kw in ["antrag", "förderantrag"]):
return "antrag"
if any(kw in t for kw in ["nachweis", "verwendungsnachweis"]):
return "verwendungsnachweis"
if any(kw in t for kw in ["rechnung"]):
return "rechnung"
if any(kw in t for kw in ["bericht", "jahresbericht"]):
return "bericht"
if any(kw in t for kw in ["karte", "landkarte", "flurkarte"]):
return "landkarte"
if any(kw in t for kw in ["bescheid"]):
return "bescheid"
if any(kw in t for kw in ["korrespondenz", "brief"]):
return "korrespondenz"
if any(kw in t for kw in ["studium", "immatrikulation", "zeugnis"]):
return "studiennachweis"
return "anderes"

View File

@@ -0,0 +1,51 @@
# Generated by Django 5.0.6 on 2026-03-11 11:09
import django.contrib.postgres.indexes
import django.contrib.postgres.search
import django.db.models.deletion
import stiftung.models.dokumente
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('stiftung', '0047_phase2_zahlungs_pipeline'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='DokumentDatei',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('titel', models.CharField(max_length=255, verbose_name='Titel')),
('beschreibung', models.TextField(blank=True, verbose_name='Beschreibung')),
('kontext', 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'), ('anderes', 'Sonstiges')], default='anderes', max_length=30, verbose_name='Dokumententyp')),
('datei', models.FileField(upload_to=stiftung.models.dokumente.dokument_upload_path, verbose_name='Datei')),
('dateiname_original', models.CharField(blank=True, max_length=255, verbose_name='Originaldateiname')),
('dateityp', models.CharField(blank=True, max_length=100, verbose_name='MIME-Typ')),
('dateigroesse', models.PositiveIntegerField(default=0, verbose_name='Dateigröße (Bytes)')),
('inhaltstext', models.TextField(blank=True, help_text='Automatisch befüllt für PDF/TXT-Dateien. Basis für Volltextsuche.', verbose_name='Extrahierter Textinhalt')),
('suchvektor', django.contrib.postgres.search.SearchVectorField(blank=True, null=True, verbose_name='Such-Vektor (FTS)')),
('paperless_dokument_id', models.IntegerField(blank=True, help_text='Wird nach vollständiger Migration entfernt.', null=True, verbose_name='Paperless-ID (Migration)')),
('erstellt_am', models.DateTimeField(auto_now_add=True, verbose_name='Erstellt am')),
('aktualisiert_am', models.DateTimeField(auto_now=True, verbose_name='Aktualisiert am')),
('destinataer', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='dms_dokumente', to='stiftung.destinataer', verbose_name='Destinatär')),
('erstellt_von', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='hochgeladene_dokumente', to=settings.AUTH_USER_MODEL, verbose_name='Erstellt von')),
('foerderung', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='dms_dokumente', to='stiftung.foerderung', verbose_name='Förderung')),
('land', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='dms_dokumente', to='stiftung.land', verbose_name='Länderei')),
('paechter', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='dms_dokumente', to='stiftung.paechter', verbose_name='Pächter')),
('rentmeister', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='dms_dokumente', to='stiftung.rentmeister', verbose_name='Rentmeister')),
('verpachtung', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='dms_dokumente', to='stiftung.landverpachtung', verbose_name='Verpachtung')),
],
options={
'verbose_name': 'Dokument',
'verbose_name_plural': 'Dokumente (DMS)',
'ordering': ['-erstellt_am'],
'indexes': [django.contrib.postgres.indexes.GinIndex(fields=['suchvektor'], name='dms_suchvektor_gin_idx'), models.Index(fields=['kontext'], name='stiftung_do_kontext_c6a21e_idx'), models.Index(fields=['destinataer', 'kontext'], name='stiftung_do_destina_1189f2_idx'), models.Index(fields=['land', 'kontext'], name='stiftung_do_land_id_6668ac_idx'), models.Index(fields=['paechter', 'kontext'], name='stiftung_do_paechte_05586e_idx')],
},
),
]

View File

@@ -10,6 +10,10 @@ from .system import ( # noqa: F401
HelpBox,
)
from .dokumente import ( # noqa: F401
DokumentDatei,
)
from .land import ( # noqa: F401
DokumentLink,
Land,

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"

View File

@@ -477,4 +477,13 @@ urlpatterns = [
# Phase 2: Pächter-Workflow (2d)
path("paechter/workflow/", views.paechter_workflow, name="paechter_workflow"),
# Phase 3: DMS Django-natives Dokumentenmanagement
path("dms/", views.dms_list, name="dms_list"),
path("dms/hochladen/", views.dms_upload, name="dms_upload"),
path("dms/suche/", views.dms_search_api, name="dms_search_api"),
path("dms/<uuid:pk>/", views.dms_detail, name="dms_detail"),
path("dms/<uuid:pk>/herunterladen/", views.dms_download, name="dms_download"),
path("dms/<uuid:pk>/bearbeiten/", views.dms_edit, name="dms_edit"),
path("dms/<uuid:pk>/loeschen/", views.dms_delete, name="dms_delete"),
]

View File

@@ -193,6 +193,16 @@ from .unterstuetzungen import ( # noqa: F401
sepa_xml_export,
)
from .dms import ( # noqa: F401
dms_list,
dms_detail,
dms_download,
dms_upload,
dms_delete,
dms_search_api,
dms_edit,
)
from .veranstaltung import ( # noqa: F401
veranstaltung_list,
veranstaltung_detail,

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)