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

@@ -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 %}