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

@@ -40,6 +40,7 @@ INSTALLED_APPS = [
"django_otp.plugins.otp_totp",
"django_otp.plugins.otp_static",
"stiftung",
"django.contrib.postgres",
]
# Add this to app/core/settings.py
SESSION_COOKIE_NAME = 'stiftung_sessionid' # Different from default 'sessionid'

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)

View File

@@ -651,13 +651,13 @@
<!-- Dokumente -->
<div class="sidebar-section">
<div class="sidebar-heading">Dokumente</div>
<a class="sidebar-link" href="{% url 'stiftung:dokument_management' %}">
<a class="sidebar-link" href="{% url 'stiftung:dms_list' %}">
<i class="fas fa-folder-open"></i>
<span>Dokumentenverwaltung</span>
<span>DMS</span>
</a>
<a class="sidebar-link" href="{% url 'stiftung:dokument_list' %}">
<i class="fas fa-database"></i>
<span>Archiv</span>
<a class="sidebar-link" href="{% url 'stiftung:dokument_management' %}">
<i class="fas fa-archive"></i>
<span>Paperless (Legacy)</span>
</a>
</div>

View File

@@ -0,0 +1,119 @@
{% extends 'base.html' %}
{% load static %}
{% block title %}{{ dok.titel }} DMS Stiftungsverwaltung{% endblock %}
{% block content %}
<div class="row">
<div class="col-lg-8">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h3">
{% if dok.is_pdf %}
<i class="fas fa-file-pdf text-danger me-2"></i>
{% else %}
<i class="fas fa-file text-primary me-2"></i>
{% endif %}
{{ dok.titel }}
</h1>
<div class="d-flex gap-2">
<a href="{% url 'stiftung:dms_download' pk=dok.pk %}" class="btn btn-outline-success">
<i class="fas fa-download me-2"></i>Herunterladen
</a>
<a href="{% url 'stiftung:dms_edit' pk=dok.pk %}" class="btn btn-outline-secondary">
<i class="fas fa-edit me-2"></i>Bearbeiten
</a>
<a href="{% url 'stiftung:dms_list' %}" class="btn btn-outline-secondary">
<i class="fas fa-arrow-left me-2"></i>Zurück
</a>
</div>
</div>
<!-- Metadaten -->
<div class="card shadow mb-4">
<div class="card-header bg-dark text-white py-2">
<span class="small fw-bold"><i class="fas fa-info-circle me-2"></i>Dokument-Informationen</span>
</div>
<div class="card-body">
<dl class="row mb-0">
<dt class="col-sm-4 text-muted small">Typ</dt>
<dd class="col-sm-8"><span class="badge bg-secondary">{{ dok.get_kontext_display }}</span></dd>
{% if dok.beschreibung %}
<dt class="col-sm-4 text-muted small">Beschreibung</dt>
<dd class="col-sm-8">{{ dok.beschreibung }}</dd>
{% endif %}
<dt class="col-sm-4 text-muted small">Dateiname</dt>
<dd class="col-sm-8 font-monospace small">{{ dok.dateiname_original|default:dok.datei.name }}</dd>
<dt class="col-sm-4 text-muted small">Dateigröße</dt>
<dd class="col-sm-8">{{ dok.get_human_size }}</dd>
<dt class="col-sm-4 text-muted small">Hochgeladen am</dt>
<dd class="col-sm-8">{{ dok.erstellt_am|date:"d.m.Y H:i" }} Uhr</dd>
{% if dok.erstellt_von %}
<dt class="col-sm-4 text-muted small">Hochgeladen von</dt>
<dd class="col-sm-8">{{ dok.erstellt_von.get_full_name|default:dok.erstellt_von.username }}</dd>
{% endif %}
</dl>
</div>
</div>
<!-- Zuordnungen -->
{% if dok.destinataer or dok.land or dok.paechter or dok.verpachtung %}
<div class="card shadow mb-4">
<div class="card-header bg-dark text-white py-2">
<span class="small fw-bold"><i class="fas fa-link me-2"></i>Zuordnungen</span>
</div>
<div class="card-body">
{% if dok.destinataer %}
<div class="mb-2">
<span class="text-muted small"><i class="fas fa-user me-1"></i>Destinatär:</span>
<a href="{% url 'stiftung:destinataer_detail' pk=dok.destinataer.pk %}" class="ms-2">
{{ dok.destinataer.get_full_name }}
</a>
</div>
{% endif %}
{% if dok.land %}
<div class="mb-2">
<span class="text-muted small"><i class="fas fa-map me-1"></i>Länderei:</span>
<a href="{% url 'stiftung:land_detail' pk=dok.land.pk %}" class="ms-2">
{{ dok.land.lfd_nr }}{% if dok.land.gemeinde %} {{ dok.land.gemeinde }}{% endif %}
</a>
</div>
{% endif %}
{% if dok.paechter %}
<div class="mb-2">
<span class="text-muted small"><i class="fas fa-user-tie me-1"></i>Pächter:</span>
<a href="{% url 'stiftung:paechter_detail' pk=dok.paechter.pk %}" class="ms-2">
{{ dok.paechter.get_full_name }}
</a>
</div>
{% endif %}
{% if dok.verpachtung %}
<div class="mb-2">
<span class="text-muted small"><i class="fas fa-handshake me-1"></i>Verpachtung:</span>
<a href="{% url 'stiftung:land_verpachtung_detail' pk=dok.verpachtung.pk %}" class="ms-2">
Vertrag #{{ dok.verpachtung.pk|stringformat:'s'|slice:':8' }}
</a>
</div>
{% endif %}
</div>
</div>
{% endif %}
<!-- Aktionen -->
<div class="d-flex gap-2 mt-2">
<form method="post" action="{% url 'stiftung:dms_delete' pk=dok.pk %}"
class="d-inline" onsubmit="return confirm('Dokument „{{ dok.titel }}" unwiderruflich löschen?')">
{% csrf_token %}
<input type="hidden" name="next" value="{% url 'stiftung:dms_list' %}">
<button type="submit" class="btn btn-outline-danger btn-sm">
<i class="fas fa-trash me-2"></i>Dokument löschen
</button>
</form>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,66 @@
{% extends 'base.html' %}
{% load static %}
{% block title %}{{ dok.titel }} bearbeiten DMS Stiftungsverwaltung{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-lg-7">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h3">
<i class="fas fa-edit text-primary me-2"></i>
Metadaten bearbeiten
</h1>
<a href="{% url 'stiftung:dms_detail' pk=dok.pk %}" class="btn btn-outline-secondary">
<i class="fas fa-arrow-left me-2"></i>Zurück
</a>
</div>
<div class="card shadow">
<div class="card-body">
<form method="post">
{% csrf_token %}
<div class="mb-3">
<label class="form-label fw-semibold">Titel</label>
<input type="text" name="titel" class="form-control"
value="{{ dok.titel }}" required maxlength="255">
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Typ / Kontext</label>
<select name="kontext" class="form-select">
{% for code, label in kontext_choices %}
<option value="{{ code }}" {% if code == dok.kontext %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</div>
<div class="mb-4">
<label class="form-label fw-semibold">Beschreibung</label>
<textarea name="beschreibung" class="form-control" rows="3">{{ dok.beschreibung }}</textarea>
</div>
<div class="d-flex gap-2 justify-content-end">
<a href="{% url 'stiftung:dms_detail' pk=dok.pk %}" class="btn btn-outline-secondary">Abbrechen</a>
<button type="submit" class="btn btn-primary">
<i class="fas fa-save me-2"></i>Speichern
</button>
</div>
</form>
</div>
</div>
<!-- Datei-Info (read-only) -->
<div class="card shadow mt-4">
<div class="card-header bg-light py-2">
<span class="small text-muted"><i class="fas fa-file me-2"></i>Datei (nicht änderbar)</span>
</div>
<div class="card-body py-2">
<span class="font-monospace small text-muted">{{ dok.dateiname_original|default:dok.datei.name }}</span>
<span class="text-muted small ms-3">{{ dok.get_human_size }}</span>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,153 @@
{% extends 'base.html' %}
{% load static %}
{% block title %}DMS Dokumente Stiftungsverwaltung{% endblock %}
{% block content %}
<div class="row">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h3">
<i class="fas fa-folder-open text-primary me-2"></i>
Dokumentenverwaltung (DMS)
</h1>
<a href="{% url 'stiftung:dms_upload' %}" class="btn btn-primary">
<i class="fas fa-upload me-2"></i>Dokument hochladen
</a>
</div>
<!-- Suche & Filter -->
<div class="card shadow mb-4">
<div class="card-body py-2">
<form method="get" class="row g-2 align-items-center">
<div class="col-md-5">
<div class="input-group input-group-sm">
<span class="input-group-text"><i class="fas fa-search"></i></span>
<input type="text" name="q" value="{{ q }}" class="form-control"
placeholder="Volltextsuche (Titel, Beschreibung, Inhalt)"
hx-get="{% url 'stiftung:dms_search_api' %}"
hx-target="#search-results"
hx-trigger="keyup changed delay:400ms"
hx-include="[name='q']">
</div>
</div>
<div class="col-md-3">
<select name="kontext" class="form-select form-select-sm" onchange="this.form.submit()">
<option value="">Alle Typen</option>
{% for code, label in kontext_choices %}
<option value="{{ code }}" {% if code == kontext_filter %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-2">
<button type="submit" class="btn btn-sm btn-outline-primary w-100">Suchen</button>
</div>
{% if q or kontext_filter %}
<div class="col-md-2">
<a href="{% url 'stiftung:dms_list' %}" class="btn btn-sm btn-outline-secondary w-100">Reset</a>
</div>
{% endif %}
</form>
</div>
</div>
<!-- HTMX Live-Suchergebnisse -->
<div id="search-results"></div>
<!-- Dokument-Liste -->
<div class="card shadow">
<div class="card-header bg-dark text-white py-2 d-flex justify-content-between">
<span class="small fw-bold"><i class="fas fa-file me-2"></i>{{ gesamt }} Dokument(e)</span>
{% if q %}<span class="small text-warning">Suche: „{{ q }}"</span>{% endif %}
</div>
<div class="card-body p-0">
{% if page_obj.object_list %}
<div class="table-responsive">
<table class="table table-sm table-hover mb-0">
<thead class="table-light">
<tr>
<th>Titel</th>
<th>Typ</th>
<th>Zuordnung</th>
<th>Größe</th>
<th>Hochgeladen</th>
<th></th>
</tr>
</thead>
<tbody>
{% for dok in page_obj %}
<tr>
<td class="align-middle">
<a href="{% url 'stiftung:dms_detail' pk=dok.pk %}" class="text-decoration-none fw-semibold">
{% if dok.is_pdf %}<i class="fas fa-file-pdf text-danger me-1"></i>{% else %}<i class="fas fa-file text-muted me-1"></i>{% endif %}
{{ dok.titel|truncatechars:60 }}
</a>
{% if dok.beschreibung %}
<div class="small text-muted">{{ dok.beschreibung|truncatechars:80 }}</div>
{% endif %}
</td>
<td class="align-middle">
<span class="badge bg-secondary small">{{ dok.get_kontext_display }}</span>
</td>
<td class="align-middle small text-muted">
{% if dok.destinataer %}<div><i class="fas fa-user me-1"></i>{{ dok.destinataer.get_full_name }}</div>{% endif %}
{% if dok.land %}<div><i class="fas fa-map me-1"></i>{{ dok.land.lfd_nr }}</div>{% endif %}
{% if dok.paechter %}<div><i class="fas fa-user-tie me-1"></i>{{ dok.paechter.get_full_name }}</div>{% endif %}
</td>
<td class="align-middle small text-muted text-nowrap">{{ dok.get_human_size }}</td>
<td class="align-middle small text-muted text-nowrap">
{{ dok.erstellt_am|date:"d.m.Y" }}
{% if dok.erstellt_von %}<br>{{ dok.erstellt_von.get_full_name|default:dok.erstellt_von.username }}{% endif %}
</td>
<td class="align-middle text-end">
<a href="{% url 'stiftung:dms_download' pk=dok.pk %}" class="btn btn-xs btn-outline-success me-1" style="font-size:0.7rem;padding:2px 6px;" title="Herunterladen">
<i class="fas fa-download"></i>
</a>
<a href="{% url 'stiftung:dms_edit' pk=dok.pk %}" class="btn btn-xs btn-outline-secondary me-1" style="font-size:0.7rem;padding:2px 6px;" title="Bearbeiten">
<i class="fas fa-edit"></i>
</a>
<form method="post" action="{% url 'stiftung:dms_delete' pk=dok.pk %}" class="d-inline" onsubmit="return confirm('Dokument löschen?')">
{% csrf_token %}
<input type="hidden" name="next" value="{% url 'stiftung:dms_list' %}">
<button type="submit" class="btn btn-xs btn-outline-danger" style="font-size:0.7rem;padding:2px 6px;" title="Löschen">
<i class="fas fa-trash"></i>
</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Pagination -->
{% if page_obj.has_other_pages %}
<div class="d-flex justify-content-center py-3">
<nav>
<ul class="pagination pagination-sm mb-0">
{% if page_obj.has_previous %}
<li class="page-item"><a class="page-link" href="?page={{ page_obj.previous_page_number }}&q={{ q }}&kontext={{ kontext_filter }}"></a></li>
{% endif %}
<li class="page-item active"><a class="page-link">{{ page_obj.number }}/{{ page_obj.paginator.num_pages }}</a></li>
{% if page_obj.has_next %}
<li class="page-item"><a class="page-link" href="?page={{ page_obj.next_page_number }}&q={{ q }}&kontext={{ kontext_filter }}"></a></li>
{% endif %}
</ul>
</nav>
</div>
{% endif %}
{% else %}
<div class="text-muted text-center py-5">
<i class="fas fa-folder-open fa-3x mb-3 d-block opacity-25"></i>
{% if q %}Keine Dokumente für „{{ q }}" gefunden.{% else %}Noch keine Dokumente vorhanden.{% endif %}
<div class="mt-3">
<a href="{% url 'stiftung:dms_upload' %}" class="btn btn-primary btn-sm">
<i class="fas fa-upload me-1"></i>Erstes Dokument hochladen
</a>
</div>
</div>
{% endif %}
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,39 @@
{% if results %}
<div class="card shadow mb-4 border-primary">
<div class="card-header bg-primary text-white py-2">
<span class="small fw-bold">
<i class="fas fa-search me-2"></i>Live-Suche: „{{ q }}" {{ results|length }} Treffer
</span>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-sm table-hover mb-0">
<tbody>
{% for dok in results %}
<tr>
<td class="align-middle">
<a href="{% url 'stiftung:dms_detail' pk=dok.pk %}" class="text-decoration-none fw-semibold">
{% if dok.is_pdf %}<i class="fas fa-file-pdf text-danger me-1"></i>{% else %}<i class="fas fa-file text-muted me-1"></i>{% endif %}
{{ dok.titel|truncatechars:60 }}
</a>
{% if dok.beschreibung %}
<div class="small text-muted">{{ dok.beschreibung|truncatechars:80 }}</div>
{% endif %}
</td>
<td class="align-middle">
<span class="badge bg-secondary small">{{ dok.get_kontext_display }}</span>
</td>
<td class="align-middle small text-muted text-nowrap">{{ dok.erstellt_am|date:"d.m.Y" }}</td>
<td class="align-middle text-end">
<a href="{% url 'stiftung:dms_download' pk=dok.pk %}" class="btn btn-xs btn-outline-success" style="font-size:0.7rem;padding:2px 6px;">
<i class="fas fa-download"></i>
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endif %}

View File

@@ -0,0 +1,10 @@
<div class="alert alert-success d-flex align-items-center gap-3 mt-3">
<i class="fas fa-check-circle fa-2x"></i>
<div>
<strong>Erfolgreich hochgeladen!</strong>
<div class="small">
„{{ dok.titel }}" — {{ dok.get_human_size }}
<a href="{% url 'stiftung:dms_detail' pk=dok.pk %}" class="ms-2">Details ansehen</a>
</div>
</div>
</div>

View File

@@ -0,0 +1,164 @@
{% extends 'base.html' %}
{% load static %}
{% block title %}Dokument hochladen Stiftungsverwaltung{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-lg-8">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h3">
<i class="fas fa-upload text-primary me-2"></i>
Dokument hochladen
</h1>
<a href="{% url 'stiftung:dms_list' %}" class="btn btn-outline-secondary">
<i class="fas fa-arrow-left me-2"></i>Zurück
</a>
</div>
<form method="post" enctype="multipart/form-data" id="upload-form">
{% csrf_token %}
<!-- Drag & Drop Zone -->
<div class="card shadow mb-4">
<div class="card-body">
<div id="drop-zone"
class="border border-2 border-dashed rounded p-5 text-center"
style="border-color: #ccc !important; cursor: pointer; transition: all 0.2s;"
onclick="document.getElementById('datei-input').click()">
<i class="fas fa-cloud-upload-alt fa-3x text-muted mb-3 d-block"></i>
<p class="mb-1 fw-semibold">Datei hierher ziehen oder klicken zum Auswählen</p>
<p class="small text-muted mb-0">PDF, Word, Excel, Bilder — max. 50 MB</p>
<div id="file-preview" class="mt-3 d-none">
<span class="badge bg-success fs-6 px-3 py-2">
<i class="fas fa-file me-2"></i><span id="file-name"></span>
</span>
</div>
</div>
<input type="file" name="datei" id="datei-input" class="d-none" required>
</div>
</div>
<!-- Metadaten -->
<div class="card shadow mb-4">
<div class="card-header bg-dark text-white py-2">
<span class="small fw-bold"><i class="fas fa-tag me-2"></i>Metadaten</span>
</div>
<div class="card-body">
<div class="mb-3">
<label class="form-label fw-semibold">Titel</label>
<input type="text" name="titel" class="form-control"
placeholder="Wird automatisch aus Dateiname abgeleitet wenn leer">
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Typ / Kontext</label>
<select name="kontext" class="form-select">
{% for code, label in kontext_choices %}
<option value="{{ code }}" {% if code == initial.kontext %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Beschreibung <span class="text-muted fw-normal">(optional)</span></label>
<textarea name="beschreibung" class="form-control" rows="2"
placeholder="Kurze Beschreibung des Dokuments"></textarea>
</div>
</div>
</div>
<!-- Zuordnung -->
<div class="card shadow mb-4">
<div class="card-header bg-dark text-white py-2">
<span class="small fw-bold"><i class="fas fa-link me-2"></i>Zuordnung <span class="fw-normal opacity-75">(optional)</span></span>
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-md-6">
<label class="form-label small fw-semibold">Destinatär</label>
<select name="destinataer_id" class="form-select form-select-sm">
<option value=""> keine </option>
{% for d in destinataere %}
<option value="{{ d.pk }}" {% if d.pk|stringformat:'s' == initial.destinataer_id %}selected{% endif %}>
{{ d.get_full_name }}
</option>
{% endfor %}
</select>
</div>
<div class="col-md-6">
<label class="form-label small fw-semibold">Länderei</label>
<select name="land_id" class="form-select form-select-sm">
<option value=""> keine </option>
{% for l in laendereien %}
<option value="{{ l.pk }}" {% if l.pk|stringformat:'s' == initial.land_id %}selected{% endif %}>
{{ l.lfd_nr }}{% if l.gemeinde %} {{ l.gemeinde }}{% endif %}
</option>
{% endfor %}
</select>
</div>
<div class="col-md-6">
<label class="form-label small fw-semibold">Pächter</label>
<select name="paechter_id" class="form-select form-select-sm">
<option value=""> keine </option>
{% for p in paechter_qs %}
<option value="{{ p.pk }}" {% if p.pk|stringformat:'s' == initial.paechter_id %}selected{% endif %}>
{{ p.get_full_name }}
</option>
{% endfor %}
</select>
</div>
</div>
</div>
</div>
<div class="d-flex gap-2 justify-content-end">
<a href="{% url 'stiftung:dms_list' %}" class="btn btn-outline-secondary">Abbrechen</a>
<button type="submit" class="btn btn-primary">
<i class="fas fa-upload me-2"></i>Hochladen
</button>
</div>
</form>
</div>
</div>
<script>
(function () {
const dropZone = document.getElementById('drop-zone');
const fileInput = document.getElementById('datei-input');
const filePreview = document.getElementById('file-preview');
const fileName = document.getElementById('file-name');
function showFile(file) {
fileName.textContent = file.name;
filePreview.classList.remove('d-none');
dropZone.style.borderColor = '#198754 !important';
dropZone.classList.add('border-success');
}
fileInput.addEventListener('change', function () {
if (this.files[0]) showFile(this.files[0]);
});
dropZone.addEventListener('dragover', function (e) {
e.preventDefault();
this.style.backgroundColor = '#f0f7ff';
this.style.borderColor = '#0d6efd';
});
dropZone.addEventListener('dragleave', function () {
this.style.backgroundColor = '';
this.style.borderColor = '#ccc';
});
dropZone.addEventListener('drop', function (e) {
e.preventDefault();
this.style.backgroundColor = '';
this.style.borderColor = '#ccc';
const files = e.dataTransfer.files;
if (files.length > 0) {
fileInput.files = files;
showFile(files[0]);
}
});
})();
</script>
{% endblock %}

View File

@@ -150,36 +150,38 @@ services:
- db
- redis
paperless:
image: ghcr.io/remmerinio/stiftung-management-system-paperless:latest
ports:
- "8080:8000"
environment:
- PAPERLESS_REDIS=redis://redis:6379
- PAPERLESS_DBHOST=db
- PAPERLESS_DBPORT=5432
- PAPERLESS_DBNAME=${PAPERLESS_DBNAME:-paperless}
- PAPERLESS_DBUSER=${POSTGRES_USER}
- PAPERLESS_DBPASS=${POSTGRES_PASSWORD}
- PAPERLESS_SECRET_KEY=${PAPERLESS_SECRET_KEY}
- PAPERLESS_URL=https://vhtv-stiftung.de
- PAPERLESS_ALLOWED_HOSTS=vhtv-stiftung.de,localhost,paperless
- PAPERLESS_CORS_ALLOWED_HOSTS=https://vhtv-stiftung.de
- PAPERLESS_FORCE_SCRIPT_NAME=/paperless
- PAPERLESS_STATIC_URL=/paperless/static/
- PAPERLESS_LOGIN_REDIRECT_URL=/paperless/
- PAPERLESS_LOGOUT_REDIRECT_URL=/paperless/
- PAPERLESS_ADMIN_USER=${PAPERLESS_ADMIN_USER}
- PAPERLESS_ADMIN_PASSWORD=${PAPERLESS_ADMIN_PASSWORD}
- PAPERLESS_ADMIN_MAIL=${PAPERLESS_ADMIN_MAIL}
volumes:
- paperless_data:/usr/src/paperless/data
- paperless_media:/usr/src/paperless/media
- paperless_export:/usr/src/paperless/export
- paperless_consume:/usr/src/paperless/consume
depends_on:
- db
- redis
# Phase 3 (Vision 2026): Paperless-NGX durch Django-natives DMS ersetzt.
# Dienst deaktiviert. Bestehende Dokumente via: python manage.py migrate_paperless_dokumente
# paperless:
# image: ghcr.io/remmerinio/stiftung-management-system-paperless:latest
# ports:
# - "8080:8000"
# environment:
# - PAPERLESS_REDIS=redis://redis:6379
# - PAPERLESS_DBHOST=db
# - PAPERLESS_DBPORT=5432
# - PAPERLESS_DBNAME=${PAPERLESS_DBNAME:-paperless}
# - PAPERLESS_DBUSER=${POSTGRES_USER}
# - PAPERLESS_DBPASS=${POSTGRES_PASSWORD}
# - PAPERLESS_SECRET_KEY=${PAPERLESS_SECRET_KEY}
# - PAPERLESS_URL=https://vhtv-stiftung.de
# - PAPERLESS_ALLOWED_HOSTS=vhtv-stiftung.de,localhost,paperless
# - PAPERLESS_CORS_ALLOWED_HOSTS=https://vhtv-stiftung.de
# - PAPERLESS_FORCE_SCRIPT_NAME=/paperless
# - PAPERLESS_STATIC_URL=/paperless/static/
# - PAPERLESS_LOGIN_REDIRECT_URL=/paperless/
# - PAPERLESS_LOGOUT_REDIRECT_URL=/paperless/
# - PAPERLESS_ADMIN_USER=${PAPERLESS_ADMIN_USER}
# - PAPERLESS_ADMIN_PASSWORD=${PAPERLESS_ADMIN_PASSWORD}
# - PAPERLESS_ADMIN_MAIL=${PAPERLESS_ADMIN_MAIL}
# volumes:
# - paperless_data:/usr/src/paperless/data
# - paperless_media:/usr/src/paperless/media
# - paperless_export:/usr/src/paperless/export
# - paperless_consume:/usr/src/paperless/consume
# depends_on:
# - db
# - redis
volumes:
dbdata: