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:
@@ -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'
|
||||
|
||||
124
app/stiftung/management/commands/migrate_paperless_dokumente.py
Normal file
124
app/stiftung/management/commands/migrate_paperless_dokumente.py
Normal 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"
|
||||
51
app/stiftung/migrations/0048_phase3_dms_dokument_datei.py
Normal file
51
app/stiftung/migrations/0048_phase3_dms_dokument_datei.py
Normal 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')],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -10,6 +10,10 @@ from .system import ( # noqa: F401
|
||||
HelpBox,
|
||||
)
|
||||
|
||||
from .dokumente import ( # noqa: F401
|
||||
DokumentDatei,
|
||||
)
|
||||
|
||||
from .land import ( # noqa: F401
|
||||
DokumentLink,
|
||||
Land,
|
||||
|
||||
180
app/stiftung/models/dokumente.py
Normal file
180
app/stiftung/models/dokumente.py
Normal file
@@ -0,0 +1,180 @@
|
||||
# models/dokumente.py
|
||||
# Phase 3: Django-natives DMS – ersetzt Paperless-NGX-Integration
|
||||
import uuid
|
||||
import os
|
||||
|
||||
from django.contrib.postgres.indexes import GinIndex
|
||||
from django.contrib.postgres.search import SearchVector, SearchVectorField
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
|
||||
|
||||
def dokument_upload_path(instance, filename):
|
||||
"""Speichert Dateien in MEDIA_ROOT/dokumente/YYYY/MM/<uuid>/<original_filename>"""
|
||||
ext = os.path.splitext(filename)[1].lower()
|
||||
safe_name = os.path.basename(filename)[:100]
|
||||
return f"dokumente/{timezone.now().strftime('%Y/%m')}/{instance.id}/{safe_name}"
|
||||
|
||||
|
||||
class DokumentDatei(models.Model):
|
||||
"""Nativ gespeicherte Datei im Django-DMS – ersetzt Paperless-Referenzen."""
|
||||
|
||||
KONTEXT_CHOICES = [
|
||||
("pachtvertrag", "Pachtvertrag"),
|
||||
("antrag", "Antrag / Förderantrag"),
|
||||
("verwendungsnachweis", "Verwendungsnachweis"),
|
||||
("studiennachweis", "Studiennachweis"),
|
||||
("rechnung", "Rechnung"),
|
||||
("vertrag", "Vertrag"),
|
||||
("bericht", "Bericht"),
|
||||
("landkarte", "Landkarte / Kataster"),
|
||||
("korrespondenz", "Korrespondenz / Brief"),
|
||||
("bescheid", "Bescheid / Behörde"),
|
||||
("anderes", "Sonstiges"),
|
||||
]
|
||||
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
titel = models.CharField(max_length=255, verbose_name="Titel")
|
||||
beschreibung = models.TextField(blank=True, verbose_name="Beschreibung")
|
||||
kontext = models.CharField(
|
||||
max_length=30,
|
||||
choices=KONTEXT_CHOICES,
|
||||
default="anderes",
|
||||
verbose_name="Dokumententyp",
|
||||
)
|
||||
datei = models.FileField(
|
||||
upload_to=dokument_upload_path,
|
||||
verbose_name="Datei",
|
||||
)
|
||||
dateiname_original = models.CharField(
|
||||
max_length=255, blank=True, verbose_name="Originaldateiname"
|
||||
)
|
||||
dateityp = models.CharField(
|
||||
max_length=100, blank=True, verbose_name="MIME-Typ"
|
||||
)
|
||||
dateigroesse = models.PositiveIntegerField(
|
||||
default=0, verbose_name="Dateigröße (Bytes)"
|
||||
)
|
||||
|
||||
# Volltext-Index (PostgreSQL FTS, befüllt per Signal)
|
||||
inhaltstext = models.TextField(
|
||||
blank=True,
|
||||
verbose_name="Extrahierter Textinhalt",
|
||||
help_text="Automatisch befüllt für PDF/TXT-Dateien. Basis für Volltextsuche.",
|
||||
)
|
||||
suchvektor = SearchVectorField(
|
||||
null=True, blank=True, verbose_name="Such-Vektor (FTS)"
|
||||
)
|
||||
|
||||
# Zuordnungsfelder – optional, ein Dokument kann mehreren Entitäten gehören
|
||||
land = models.ForeignKey(
|
||||
"Land",
|
||||
on_delete=models.SET_NULL,
|
||||
null=True, blank=True,
|
||||
related_name="dms_dokumente",
|
||||
verbose_name="Länderei",
|
||||
)
|
||||
paechter = models.ForeignKey(
|
||||
"Paechter",
|
||||
on_delete=models.SET_NULL,
|
||||
null=True, blank=True,
|
||||
related_name="dms_dokumente",
|
||||
verbose_name="Pächter",
|
||||
)
|
||||
verpachtung = models.ForeignKey(
|
||||
"LandVerpachtung",
|
||||
on_delete=models.SET_NULL,
|
||||
null=True, blank=True,
|
||||
related_name="dms_dokumente",
|
||||
verbose_name="Verpachtung",
|
||||
)
|
||||
destinataer = models.ForeignKey(
|
||||
"Destinataer",
|
||||
on_delete=models.SET_NULL,
|
||||
null=True, blank=True,
|
||||
related_name="dms_dokumente",
|
||||
verbose_name="Destinatär",
|
||||
)
|
||||
foerderung = models.ForeignKey(
|
||||
"Foerderung",
|
||||
on_delete=models.SET_NULL,
|
||||
null=True, blank=True,
|
||||
related_name="dms_dokumente",
|
||||
verbose_name="Förderung",
|
||||
)
|
||||
rentmeister = models.ForeignKey(
|
||||
"Rentmeister",
|
||||
on_delete=models.SET_NULL,
|
||||
null=True, blank=True,
|
||||
related_name="dms_dokumente",
|
||||
verbose_name="Rentmeister",
|
||||
)
|
||||
|
||||
# Herkunft (optional: Verweis auf altes Paperless-Dokument zur Rückverfolgung)
|
||||
paperless_dokument_id = models.IntegerField(
|
||||
null=True, blank=True,
|
||||
verbose_name="Paperless-ID (Migration)",
|
||||
help_text="Wird nach vollständiger Migration entfernt.",
|
||||
)
|
||||
|
||||
# Audit
|
||||
erstellt_von = models.ForeignKey(
|
||||
"auth.User",
|
||||
on_delete=models.SET_NULL,
|
||||
null=True, blank=True,
|
||||
related_name="hochgeladene_dokumente",
|
||||
verbose_name="Erstellt von",
|
||||
)
|
||||
erstellt_am = models.DateTimeField(auto_now_add=True, verbose_name="Erstellt am")
|
||||
aktualisiert_am = models.DateTimeField(auto_now=True, verbose_name="Aktualisiert am")
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Dokument"
|
||||
verbose_name_plural = "Dokumente (DMS)"
|
||||
ordering = ["-erstellt_am"]
|
||||
indexes = [
|
||||
# PostgreSQL GIN-Index für Volltextsuche
|
||||
GinIndex(fields=["suchvektor"], name="dms_suchvektor_gin_idx"),
|
||||
models.Index(fields=["kontext"]),
|
||||
models.Index(fields=["destinataer", "kontext"]),
|
||||
models.Index(fields=["land", "kontext"]),
|
||||
models.Index(fields=["paechter", "kontext"]),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return self.titel or self.dateiname_original or str(self.id)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
# Originaldateiname aus FileField ableiten
|
||||
if self.datei and not self.dateiname_original:
|
||||
self.dateiname_original = os.path.basename(self.datei.name)
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def update_suchvektor(self):
|
||||
"""Aktualisiert den Such-Vektor aus Titel, Beschreibung und Inhaltstext."""
|
||||
DokumentDatei.objects.filter(pk=self.pk).update(
|
||||
suchvektor=SearchVector("titel", weight="A")
|
||||
+ SearchVector("beschreibung", weight="B")
|
||||
+ SearchVector("inhaltstext", weight="C"),
|
||||
)
|
||||
|
||||
def get_datei_url(self):
|
||||
"""Gibt die Download-URL zurück."""
|
||||
if self.datei:
|
||||
return self.datei.url
|
||||
return None
|
||||
|
||||
def is_pdf(self):
|
||||
return self.dateityp == "application/pdf" or (
|
||||
self.dateiname_original and self.dateiname_original.lower().endswith(".pdf")
|
||||
)
|
||||
|
||||
def get_human_size(self):
|
||||
"""Gibt die Dateigröße leserlich zurück."""
|
||||
size = self.dateigroesse
|
||||
if size < 1024:
|
||||
return f"{size} B"
|
||||
elif size < 1024 * 1024:
|
||||
return f"{size / 1024:.1f} KB"
|
||||
else:
|
||||
return f"{size / (1024 * 1024):.1f} MB"
|
||||
@@ -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"),
|
||||
]
|
||||
|
||||
@@ -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
252
app/stiftung/views/dms.py
Normal 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)
|
||||
@@ -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>
|
||||
|
||||
|
||||
119
app/templates/stiftung/dms/detail.html
Normal file
119
app/templates/stiftung/dms/detail.html
Normal 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 %}
|
||||
66
app/templates/stiftung/dms/edit.html
Normal file
66
app/templates/stiftung/dms/edit.html
Normal 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 %}
|
||||
153
app/templates/stiftung/dms/list.html
Normal file
153
app/templates/stiftung/dms/list.html
Normal 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 %}
|
||||
39
app/templates/stiftung/dms/partials/search_results.html
Normal file
39
app/templates/stiftung/dms/partials/search_results.html
Normal 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 %}
|
||||
10
app/templates/stiftung/dms/partials/upload_success.html
Normal file
10
app/templates/stiftung/dms/partials/upload_success.html
Normal 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>
|
||||
164
app/templates/stiftung/dms/upload.html
Normal file
164
app/templates/stiftung/dms/upload.html
Normal 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 %}
|
||||
Reference in New Issue
Block a user