diff --git a/app/core/settings.py b/app/core/settings.py index 831bb04..fc3cbd4 100644 --- a/app/core/settings.py +++ b/app/core/settings.py @@ -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' diff --git a/app/stiftung/management/commands/migrate_paperless_dokumente.py b/app/stiftung/management/commands/migrate_paperless_dokumente.py new file mode 100644 index 0000000..8a3ca70 --- /dev/null +++ b/app/stiftung/management/commands/migrate_paperless_dokumente.py @@ -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" diff --git a/app/stiftung/migrations/0048_phase3_dms_dokument_datei.py b/app/stiftung/migrations/0048_phase3_dms_dokument_datei.py new file mode 100644 index 0000000..a482c6b --- /dev/null +++ b/app/stiftung/migrations/0048_phase3_dms_dokument_datei.py @@ -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')], + }, + ), + ] diff --git a/app/stiftung/models/__init__.py b/app/stiftung/models/__init__.py index 67ab57f..d8b5dc0 100644 --- a/app/stiftung/models/__init__.py +++ b/app/stiftung/models/__init__.py @@ -10,6 +10,10 @@ from .system import ( # noqa: F401 HelpBox, ) +from .dokumente import ( # noqa: F401 + DokumentDatei, +) + from .land import ( # noqa: F401 DokumentLink, Land, diff --git a/app/stiftung/models/dokumente.py b/app/stiftung/models/dokumente.py new file mode 100644 index 0000000..57aac35 --- /dev/null +++ b/app/stiftung/models/dokumente.py @@ -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//""" + 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" diff --git a/app/stiftung/urls.py b/app/stiftung/urls.py index 59f562b..f8272f4 100644 --- a/app/stiftung/urls.py +++ b/app/stiftung/urls.py @@ -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//", views.dms_detail, name="dms_detail"), + path("dms//herunterladen/", views.dms_download, name="dms_download"), + path("dms//bearbeiten/", views.dms_edit, name="dms_edit"), + path("dms//loeschen/", views.dms_delete, name="dms_delete"), ] diff --git a/app/stiftung/views/__init__.py b/app/stiftung/views/__init__.py index e61d942..044f431 100644 --- a/app/stiftung/views/__init__.py +++ b/app/stiftung/views/__init__.py @@ -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, diff --git a/app/stiftung/views/dms.py b/app/stiftung/views/dms.py new file mode 100644 index 0000000..55fdff9 --- /dev/null +++ b/app/stiftung/views/dms.py @@ -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) diff --git a/app/templates/base.html b/app/templates/base.html index e769270..9749a84 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -651,13 +651,13 @@ diff --git a/app/templates/stiftung/dms/detail.html b/app/templates/stiftung/dms/detail.html new file mode 100644 index 0000000..784f5f3 --- /dev/null +++ b/app/templates/stiftung/dms/detail.html @@ -0,0 +1,119 @@ +{% extends 'base.html' %} +{% load static %} + +{% block title %}{{ dok.titel }} – DMS – Stiftungsverwaltung{% endblock %} + +{% block content %} +
+
+
+

+ {% if dok.is_pdf %} + + {% else %} + + {% endif %} + {{ dok.titel }} +

+ +
+ + +
+
+ Dokument-Informationen +
+
+
+
Typ
+
{{ dok.get_kontext_display }}
+ + {% if dok.beschreibung %} +
Beschreibung
+
{{ dok.beschreibung }}
+ {% endif %} + +
Dateiname
+
{{ dok.dateiname_original|default:dok.datei.name }}
+ +
Dateigröße
+
{{ dok.get_human_size }}
+ +
Hochgeladen am
+
{{ dok.erstellt_am|date:"d.m.Y H:i" }} Uhr
+ + {% if dok.erstellt_von %} +
Hochgeladen von
+
{{ dok.erstellt_von.get_full_name|default:dok.erstellt_von.username }}
+ {% endif %} +
+
+
+ + + {% if dok.destinataer or dok.land or dok.paechter or dok.verpachtung %} +
+
+ Zuordnungen +
+
+ {% if dok.destinataer %} + + {% endif %} + {% if dok.land %} + + {% endif %} + {% if dok.paechter %} + + {% endif %} + {% if dok.verpachtung %} + + {% endif %} +
+
+ {% endif %} + + +
+
+ {% csrf_token %} + + +
+
+
+
+{% endblock %} diff --git a/app/templates/stiftung/dms/edit.html b/app/templates/stiftung/dms/edit.html new file mode 100644 index 0000000..c1e81d6 --- /dev/null +++ b/app/templates/stiftung/dms/edit.html @@ -0,0 +1,66 @@ +{% extends 'base.html' %} +{% load static %} + +{% block title %}{{ dok.titel }} bearbeiten – DMS – Stiftungsverwaltung{% endblock %} + +{% block content %} +
+
+
+

+ + Metadaten bearbeiten +

+ + Zurück + +
+ +
+
+
+ {% csrf_token %} + +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ Abbrechen + +
+
+
+
+ + +
+
+ Datei (nicht änderbar) +
+
+ {{ dok.dateiname_original|default:dok.datei.name }} + {{ dok.get_human_size }} +
+
+
+
+{% endblock %} diff --git a/app/templates/stiftung/dms/list.html b/app/templates/stiftung/dms/list.html new file mode 100644 index 0000000..9c5f36d --- /dev/null +++ b/app/templates/stiftung/dms/list.html @@ -0,0 +1,153 @@ +{% extends 'base.html' %} +{% load static %} + +{% block title %}DMS – Dokumente – Stiftungsverwaltung{% endblock %} + +{% block content %} +
+
+
+

+ + Dokumentenverwaltung (DMS) +

+ + Dokument hochladen + +
+ + +
+
+
+
+
+ + +
+
+
+ +
+
+ +
+ {% if q or kontext_filter %} +
+ Reset +
+ {% endif %} +
+
+
+ + +
+ + +
+
+ {{ gesamt }} Dokument(e) + {% if q %}Suche: „{{ q }}"{% endif %} +
+
+ {% if page_obj.object_list %} +
+ + + + + + + + + + + + + {% for dok in page_obj %} + + + + + + + + + {% endfor %} + +
TitelTypZuordnungGrößeHochgeladen
+ + {% if dok.is_pdf %}{% else %}{% endif %} + {{ dok.titel|truncatechars:60 }} + + {% if dok.beschreibung %} +
{{ dok.beschreibung|truncatechars:80 }}
+ {% endif %} +
+ {{ dok.get_kontext_display }} + + {% if dok.destinataer %}
{{ dok.destinataer.get_full_name }}
{% endif %} + {% if dok.land %}
{{ dok.land.lfd_nr }}
{% endif %} + {% if dok.paechter %}
{{ dok.paechter.get_full_name }}
{% endif %} +
{{ dok.get_human_size }} + {{ dok.erstellt_am|date:"d.m.Y" }} + {% if dok.erstellt_von %}
{{ dok.erstellt_von.get_full_name|default:dok.erstellt_von.username }}{% endif %} +
+ + + + + + +
+ {% csrf_token %} + + +
+
+
+ + {% if page_obj.has_other_pages %} +
+ +
+ {% endif %} + {% else %} +
+ + {% if q %}Keine Dokumente für „{{ q }}" gefunden.{% else %}Noch keine Dokumente vorhanden.{% endif %} + +
+ {% endif %} +
+
+
+
+{% endblock %} diff --git a/app/templates/stiftung/dms/partials/search_results.html b/app/templates/stiftung/dms/partials/search_results.html new file mode 100644 index 0000000..a6f7335 --- /dev/null +++ b/app/templates/stiftung/dms/partials/search_results.html @@ -0,0 +1,39 @@ +{% if results %} +
+
+ + Live-Suche: „{{ q }}" – {{ results|length }} Treffer + +
+
+
+ + + {% for dok in results %} + + + + + + + {% endfor %} + +
+ + {% if dok.is_pdf %}{% else %}{% endif %} + {{ dok.titel|truncatechars:60 }} + + {% if dok.beschreibung %} +
{{ dok.beschreibung|truncatechars:80 }}
+ {% endif %} +
+ {{ dok.get_kontext_display }} + {{ dok.erstellt_am|date:"d.m.Y" }} + + + +
+
+
+
+{% endif %} diff --git a/app/templates/stiftung/dms/partials/upload_success.html b/app/templates/stiftung/dms/partials/upload_success.html new file mode 100644 index 0000000..0267114 --- /dev/null +++ b/app/templates/stiftung/dms/partials/upload_success.html @@ -0,0 +1,10 @@ +
+ +
+ Erfolgreich hochgeladen! +
+ „{{ dok.titel }}" — {{ dok.get_human_size }} + Details ansehen +
+
+
diff --git a/app/templates/stiftung/dms/upload.html b/app/templates/stiftung/dms/upload.html new file mode 100644 index 0000000..6d57567 --- /dev/null +++ b/app/templates/stiftung/dms/upload.html @@ -0,0 +1,164 @@ +{% extends 'base.html' %} +{% load static %} + +{% block title %}Dokument hochladen – Stiftungsverwaltung{% endblock %} + +{% block content %} +
+
+
+

+ + Dokument hochladen +

+ + Zurück + +
+ +
+ {% csrf_token %} + + +
+
+
+ +

Datei hierher ziehen oder klicken zum Auswählen

+

PDF, Word, Excel, Bilder — max. 50 MB

+
+ + + +
+
+ +
+
+ + +
+
+ Metadaten +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ Zuordnung (optional) +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ +
+ Abbrechen + +
+
+
+
+ + +{% endblock %} diff --git a/compose.yml b/compose.yml index 4548e81..2fbe4f0 100644 --- a/compose.yml +++ b/compose.yml @@ -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: