From c2c4ae3cbe4065085a9824ca0dce6ff2cb17b3d5 Mon Sep 17 00:00:00 2001 From: tegwick Date: Fri, 8 May 2026 18:33:04 +0200 Subject: [PATCH] Dokumentenmanagement --- vergabe_teilnahme/apps/dokumente/forms.py | 82 +++++++++ .../0003_add_quelle_bibliothek_nachweis.py | 30 ++++ vergabe_teilnahme/apps/dokumente/models.py | 7 +- vergabe_teilnahme/apps/dokumente/tests.py | 86 ++++++++- vergabe_teilnahme/apps/dokumente/urls.py | 15 +- vergabe_teilnahme/apps/dokumente/views.py | 170 +++++++++++++++++- .../templates/dokumente/bibliothek_modal.html | 46 +++++ .../templates/dokumente/detail.html | 122 +++++++++++++ .../templates/dokumente/form.html | 68 +++++++ .../templates/dokumente/liste.html | 85 +++++++++ .../templates/dokumente/neue_version.html | 26 +++ .../partials/finaler_status_badge.html | 5 + .../dokumente/partials/status_widget.html | 16 ++ workplans/WP-0007-dokumente.md | 14 +- 14 files changed, 759 insertions(+), 13 deletions(-) create mode 100644 vergabe_teilnahme/apps/dokumente/forms.py create mode 100644 vergabe_teilnahme/apps/dokumente/migrations/0003_add_quelle_bibliothek_nachweis.py create mode 100644 vergabe_teilnahme/templates/dokumente/bibliothek_modal.html create mode 100644 vergabe_teilnahme/templates/dokumente/detail.html create mode 100644 vergabe_teilnahme/templates/dokumente/form.html create mode 100644 vergabe_teilnahme/templates/dokumente/liste.html create mode 100644 vergabe_teilnahme/templates/dokumente/neue_version.html create mode 100644 vergabe_teilnahme/templates/dokumente/partials/finaler_status_badge.html create mode 100644 vergabe_teilnahme/templates/dokumente/partials/status_widget.html diff --git a/vergabe_teilnahme/apps/dokumente/forms.py b/vergabe_teilnahme/apps/dokumente/forms.py new file mode 100644 index 0000000..6fcd494 --- /dev/null +++ b/vergabe_teilnahme/apps/dokumente/forms.py @@ -0,0 +1,82 @@ +import os + +from django import forms +from django.conf import settings + +from vergabe_teilnahme.apps.lose.models import Los + +from .models import ALLOWED_EXTENSIONS, Dokument + + +class DokumentForm(forms.ModelForm): + class Meta: + model = Dokument + fields = ['datei', 'kategorie', 'version', 'quelle', 'verantwortlicher', 'pruefer', 'los', 'beschreibung'] + widgets = { + 'datei': forms.FileInput(attrs={'class': 'form-input'}), + 'kategorie': forms.Select(attrs={'class': 'form-input'}), + 'version': forms.TextInput(attrs={'class': 'form-input'}), + 'quelle': forms.TextInput(attrs={'class': 'form-input'}), + 'verantwortlicher': forms.Select(attrs={'class': 'form-input'}), + 'pruefer': forms.Select(attrs={'class': 'form-input'}), + 'los': forms.Select(attrs={'class': 'form-input'}), + 'beschreibung': forms.Textarea(attrs={'class': 'form-input', 'rows': 3}), + } + + def __init__(self, *args, ausschreibung=None, **kwargs): + super().__init__(*args, **kwargs) + if ausschreibung is not None: + self.fields['los'].queryset = Los.objects.filter(ausschreibung=ausschreibung) + self.fields['quelle'].required = False + self.fields['verantwortlicher'].required = False + self.fields['pruefer'].required = False + self.fields['los'].required = False + self.fields['beschreibung'].required = False + + def clean_datei(self): + datei = self.cleaned_data.get('datei') + if datei: + ext = os.path.splitext(datei.name)[1].lower() + if ext not in ALLOWED_EXTENSIONS: + raise forms.ValidationError( + f'Dateityp "{ext}" nicht erlaubt. Erlaubt: {", ".join(sorted(ALLOWED_EXTENSIONS))}' + ) + max_size = getattr(settings, 'MAX_UPLOAD_SIZE', 52428800) + if hasattr(datei, 'size') and datei.size > max_size: + raise forms.ValidationError( + f'Datei zu groß. Maximum: {max_size // 1024 // 1024} MB' + ) + return datei + + +class DokumentVersionForm(forms.ModelForm): + class Meta: + model = Dokument + fields = ['datei', 'version'] + widgets = { + 'datei': forms.FileInput(attrs={'class': 'form-input'}), + 'version': forms.TextInput(attrs={'class': 'form-input'}), + } + + def clean_datei(self): + datei = self.cleaned_data.get('datei') + if datei: + ext = os.path.splitext(datei.name)[1].lower() + if ext not in ALLOWED_EXTENSIONS: + raise forms.ValidationError( + f'Dateityp "{ext}" nicht erlaubt. Erlaubt: {", ".join(sorted(ALLOWED_EXTENSIONS))}' + ) + max_size = getattr(settings, 'MAX_UPLOAD_SIZE', 52428800) + if hasattr(datei, 'size') and datei.size > max_size: + raise forms.ValidationError( + f'Datei zu groß. Maximum: {max_size // 1024 // 1024} MB' + ) + return datei + + +def naechste_version(alte_version: str) -> str: + try: + major = int(alte_version.split('.')[0]) + return f'{major + 1}.0' + except (ValueError, IndexError): + return '2.0' diff --git a/vergabe_teilnahme/apps/dokumente/migrations/0003_add_quelle_bibliothek_nachweis.py b/vergabe_teilnahme/apps/dokumente/migrations/0003_add_quelle_bibliothek_nachweis.py new file mode 100644 index 0000000..6dff234 --- /dev/null +++ b/vergabe_teilnahme/apps/dokumente/migrations/0003_add_quelle_bibliothek_nachweis.py @@ -0,0 +1,30 @@ +# Generated by Django 6.0.5 on 2026-05-08 16:27 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('bibliothek', '0001_initial'), + ('dokumente', '0002_initial'), + ] + + operations = [ + migrations.AddField( + model_name='dokument', + name='bibliothek_nachweis', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='verwendete_dokumente', to='bibliothek.nachweis'), + ), + migrations.AddField( + model_name='dokument', + name='quelle', + field=models.CharField(blank=True, max_length=300), + ), + migrations.AlterField( + model_name='dokument', + name='datei', + field=models.FileField(blank=True, null=True, upload_to='dokumente/%Y/%m/'), + ), + ] diff --git a/vergabe_teilnahme/apps/dokumente/models.py b/vergabe_teilnahme/apps/dokumente/models.py index 138f7c0..2e9e354 100644 --- a/vergabe_teilnahme/apps/dokumente/models.py +++ b/vergabe_teilnahme/apps/dokumente/models.py @@ -39,8 +39,13 @@ class Dokument(FlexibleModel): 'lose.Los', on_delete=models.SET_NULL, null=True, blank=True, related_name='dokumente' ) - datei = models.FileField(upload_to='dokumente/%Y/%m/') + datei = models.FileField(upload_to='dokumente/%Y/%m/', null=True, blank=True) dateiname = models.CharField(max_length=300, blank=True) + quelle = models.CharField(max_length=300, blank=True) + bibliothek_nachweis = models.ForeignKey( + 'bibliothek.Nachweis', on_delete=models.SET_NULL, + null=True, blank=True, related_name='verwendete_dokumente' + ) kategorie = models.CharField(max_length=30, choices=KATEGORIE_CHOICES, default='sonstiges') status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='entwurf') version = models.CharField(max_length=20, default='1.0') diff --git a/vergabe_teilnahme/apps/dokumente/tests.py b/vergabe_teilnahme/apps/dokumente/tests.py index 7ce503c..2e6d010 100644 --- a/vergabe_teilnahme/apps/dokumente/tests.py +++ b/vergabe_teilnahme/apps/dokumente/tests.py @@ -1,3 +1,85 @@ -from django.test import TestCase +import io -# Create your tests here. +import factory +import pytest +from django.core.files.uploadedfile import SimpleUploadedFile +from django.urls import reverse + +from vergabe_teilnahme.apps.ausschreibungen.tests import AusschreibungFactory + +from .models import Dokument + + +class DokumentFactory(factory.django.DjangoModelFactory): + class Meta: + model = Dokument + + ausschreibung = factory.SubFactory(AusschreibungFactory) + dateiname = factory.Sequence(lambda n: f'dokument_{n}.pdf') + kategorie = 'intern' + status = 'entwurf' + version = '1.0' + + +def _pdf_file(name='test.pdf'): + return SimpleUploadedFile(name, b'%PDF-1.4 fake content', content_type='application/pdf') + + +@pytest.mark.django_db +def test_dokument_upload_valid(client, tmp_path, settings): + settings.MEDIA_ROOT = tmp_path + a = AusschreibungFactory() + url = reverse('ausschreibungen:dokumente:upload', kwargs={'ausschreibung_id': a.pk}) + response = client.post(url, {'datei': _pdf_file(), 'kategorie': 'intern', 'version': '1.0'}) + assert response.status_code == 302 + assert Dokument.objects.filter(ausschreibung=a).exists() + + +@pytest.mark.django_db +def test_dokument_upload_invalid_extension(client, tmp_path, settings): + settings.MEDIA_ROOT = tmp_path + a = AusschreibungFactory() + url = reverse('ausschreibungen:dokumente:upload', kwargs={'ausschreibung_id': a.pk}) + bad_file = SimpleUploadedFile('malware.exe', b'MZ bad', content_type='application/octet-stream') + response = client.post(url, {'datei': bad_file, 'kategorie': 'intern', 'version': '1.0'}) + assert response.status_code == 200 + assert not Dokument.objects.filter(ausschreibung=a).exists() + + +@pytest.mark.django_db +def test_dokument_upload_too_large(client, tmp_path, settings): + settings.MEDIA_ROOT = tmp_path + settings.MAX_UPLOAD_SIZE = 10 + a = AusschreibungFactory() + url = reverse('ausschreibungen:dokumente:upload', kwargs={'ausschreibung_id': a.pk}) + big_file = SimpleUploadedFile('big.pdf', b'%PDF' + b'x' * 100, content_type='application/pdf') + response = client.post(url, {'datei': big_file, 'kategorie': 'intern', 'version': '1.0'}) + assert response.status_code == 200 + assert not Dokument.objects.filter(ausschreibung=a).exists() + + +@pytest.mark.django_db +def test_dokument_neue_version(client, tmp_path, settings): + settings.MEDIA_ROOT = tmp_path + altes_dok = DokumentFactory(version='1.0') + a = altes_dok.ausschreibung + url = reverse('ausschreibungen:dokumente:neue_version', + kwargs={'ausschreibung_id': a.pk, 'pk': altes_dok.pk}) + response = client.post(url, {'datei': _pdf_file('v2.pdf'), 'version': '2.0'}) + assert response.status_code == 302 + altes_dok.refresh_from_db() + assert altes_dok.status == 'ersetzt' + assert Dokument.objects.filter(ausschreibung=a, version='2.0').exists() + + +@pytest.mark.django_db +def test_dokument_finale_version(client): + dok = DokumentFactory(status='freigegeben') + a = dok.ausschreibung + url = reverse('ausschreibungen:dokumente:finale_version', + kwargs={'ausschreibung_id': a.pk, 'pk': dok.pk}) + response = client.post(url) + assert response.status_code == 200 + dok.refresh_from_db() + assert dok.finale_abgabeversion is True + assert dok.status == 'final_abgegeben' diff --git a/vergabe_teilnahme/apps/dokumente/urls.py b/vergabe_teilnahme/apps/dokumente/urls.py index eb89e3b..1240ef0 100644 --- a/vergabe_teilnahme/apps/dokumente/urls.py +++ b/vergabe_teilnahme/apps/dokumente/urls.py @@ -1,2 +1,15 @@ from django.urls import path -urlpatterns = [] + +from . import views + +app_name = 'dokumente' + +urlpatterns = [ + path('', views.dokumente_liste, name='liste'), + path('hochladen/', views.dokument_upload, name='upload'), + path('/', views.dokument_detail, name='detail'), + path('/version/', views.dokument_neue_version, name='neue_version'), + path('/status/', views.dokument_status, name='status'), + path('/final/', views.dokument_finale_version, name='finale_version'), + path('/bibliothek/', views.dokument_bibliothek_zuordnen, name='bibliothek_zuordnen'), +] diff --git a/vergabe_teilnahme/apps/dokumente/views.py b/vergabe_teilnahme/apps/dokumente/views.py index 91ea44a..7a79f06 100644 --- a/vergabe_teilnahme/apps/dokumente/views.py +++ b/vergabe_teilnahme/apps/dokumente/views.py @@ -1,3 +1,169 @@ -from django.shortcuts import render +import os +from itertools import groupby -# Create your views here. +from django.contrib.contenttypes.models import ContentType +from django.shortcuts import get_object_or_404, redirect, render + +from vergabe_teilnahme.apps.ausschreibungen.models import Ausschreibung +from vergabe_teilnahme.apps.bibliothek.models import Nachweis +from vergabe_teilnahme.apps.core.models import Freigabe + +from .forms import DokumentForm, DokumentVersionForm, naechste_version +from .models import Dokument + +STATUS_WORKFLOW = [ + 'entwurf', + 'in_pruefung', + 'freigegeben', + 'final_abgegeben', +] + + +def _get_ausschreibung(ausschreibung_id): + return get_object_or_404(Ausschreibung, pk=ausschreibung_id) + + +def dokumente_liste(request, ausschreibung_id): + ausschreibung = _get_ausschreibung(ausschreibung_id) + qs = Dokument.objects.filter(ausschreibung=ausschreibung).select_related('verantwortlicher', 'pruefer', 'los') + + status_filter = request.GET.get('status', '') + kategorie_filter = request.GET.get('kategorie', '') + verantwortlicher_filter = request.GET.get('verantwortlicher', '') + + if status_filter: + qs = qs.filter(status=status_filter) + if kategorie_filter: + qs = qs.filter(kategorie=kategorie_filter) + if verantwortlicher_filter: + qs = qs.filter(verantwortlicher_id=verantwortlicher_filter) + + grouped = {} + for dok in qs: + grouped.setdefault(dok.get_kategorie_display(), []).append(dok) + + return render(request, 'dokumente/liste.html', { + 'ausschreibung': ausschreibung, + 'grouped_dokumente': grouped, + 'status_choices': Dokument.STATUS_CHOICES, + 'kategorie_choices': Dokument.KATEGORIE_CHOICES, + 'current_status': status_filter, + 'current_kategorie': kategorie_filter, + }) + + +def dokument_upload(request, ausschreibung_id): + ausschreibung = _get_ausschreibung(ausschreibung_id) + if request.method == 'POST': + form = DokumentForm(request.POST, request.FILES, ausschreibung=ausschreibung) + if form.is_valid(): + dok = form.save(commit=False) + dok.ausschreibung = ausschreibung + if dok.datei: + dok.dateiname = os.path.basename(dok.datei.name) + dok.save() + return redirect('ausschreibungen:dokumente:liste', ausschreibung_id=ausschreibung_id) + else: + form = DokumentForm(ausschreibung=ausschreibung) + return render(request, 'dokumente/form.html', { + 'ausschreibung': ausschreibung, + 'form': form, + 'titel': 'Dokument hochladen', + }) + + +def dokument_detail(request, ausschreibung_id, pk): + ausschreibung = _get_ausschreibung(ausschreibung_id) + dok = get_object_or_404(Dokument, pk=pk, ausschreibung=ausschreibung) + ct = ContentType.objects.get_for_model(Dokument) + freigaben = Freigabe.objects.filter(content_type=ct, object_id=dok.pk) + versionen = Dokument.objects.filter( + ausschreibung=ausschreibung, + kategorie=dok.kategorie, + dateiname=dok.dateiname, + ).order_by('version') + return render(request, 'dokumente/detail.html', { + 'ausschreibung': ausschreibung, + 'dokument': dok, + 'freigaben': freigaben, + 'versionen': versionen, + }) + + +def dokument_neue_version(request, ausschreibung_id, pk): + ausschreibung = _get_ausschreibung(ausschreibung_id) + altes_dokument = get_object_or_404(Dokument, pk=pk, ausschreibung=ausschreibung) + initial_version = naechste_version(altes_dokument.version) + if request.method == 'POST': + form = DokumentVersionForm(request.POST, request.FILES) + if form.is_valid(): + neues_dok = form.save(commit=False) + neues_dok.ausschreibung = altes_dokument.ausschreibung + neues_dok.los = altes_dokument.los + neues_dok.kategorie = altes_dokument.kategorie + neues_dok.verantwortlicher = altes_dokument.verantwortlicher + if neues_dok.datei: + neues_dok.dateiname = os.path.basename(neues_dok.datei.name) + neues_dok.save() + altes_dokument.status = 'ersetzt' + altes_dokument.save(update_fields=['status']) + return redirect('ausschreibungen:dokumente:detail', ausschreibung_id=ausschreibung_id, pk=neues_dok.pk) + else: + form = DokumentVersionForm(initial={'version': initial_version}) + return render(request, 'dokumente/neue_version.html', { + 'ausschreibung': ausschreibung, + 'form': form, + 'dokument': altes_dokument, + }) + + +def dokument_status(request, ausschreibung_id, pk): + ausschreibung = _get_ausschreibung(ausschreibung_id) + dok = get_object_or_404(Dokument, pk=pk, ausschreibung=ausschreibung) + if request.method == 'POST' and dok.status != 'final_abgegeben': + neuer_status = request.POST.get('status', '') + valid = [s for s, _ in Dokument.STATUS_CHOICES if s not in ('ersetzt', 'archiviert')] + if neuer_status in valid: + dok.status = neuer_status + if neuer_status == 'final_abgegeben': + dok.finale_abgabeversion = True + dok.save(update_fields=['status', 'finale_abgabeversion']) + return render(request, 'dokumente/partials/status_widget.html', { + 'ausschreibung': ausschreibung, + 'dokument': dok, + }) + + +def dokument_finale_version(request, ausschreibung_id, pk): + ausschreibung = _get_ausschreibung(ausschreibung_id) + dok = get_object_or_404(Dokument, pk=pk, ausschreibung=ausschreibung) + if request.method == 'POST': + dok.finale_abgabeversion = True + dok.status = 'final_abgegeben' + dok.save(update_fields=['finale_abgabeversion', 'status']) + return render(request, 'dokumente/partials/finaler_status_badge.html', { + 'dokument': dok, + }) + + +def dokument_bibliothek_zuordnen(request, ausschreibung_id, pk): + ausschreibung = _get_ausschreibung(ausschreibung_id) + dok = get_object_or_404(Dokument, pk=pk, ausschreibung=ausschreibung) + q = request.GET.get('q', '').strip() + nachweise = [] + if q: + nachweise = Nachweis.objects.filter(titel__icontains=q) + if request.method == 'POST': + nachweis_id = request.POST.get('nachweis_id') + nachweis = get_object_or_404(Nachweis, pk=nachweis_id) + dok.bibliothek_nachweis = nachweis + dok.quelle = f'Bibliothek: {nachweis.titel}' + dok.dateiname = nachweis.titel + dok.save(update_fields=['bibliothek_nachweis', 'quelle', 'dateiname']) + return redirect('ausschreibungen:dokumente:detail', ausschreibung_id=ausschreibung_id, pk=dok.pk) + return render(request, 'dokumente/bibliothek_modal.html', { + 'ausschreibung': ausschreibung, + 'dokument': dok, + 'nachweise': nachweise, + 'q': q, + }) diff --git a/vergabe_teilnahme/templates/dokumente/bibliothek_modal.html b/vergabe_teilnahme/templates/dokumente/bibliothek_modal.html new file mode 100644 index 0000000..dcda4db --- /dev/null +++ b/vergabe_teilnahme/templates/dokumente/bibliothek_modal.html @@ -0,0 +1,46 @@ +{% extends "base.html" %} +{% block title %}Aus Bibliothek zuordnen{% endblock %} +{% block content %} +
+

Nachweis aus Bibliothek zuordnen

+

Dokument: {{ dokument.dateiname|default:"—" }}

+ +
+
+ + +
+ +
+ + {% if nachweise %} +
+ {% for nw in nachweise %} +
+ {% csrf_token %} + +
+

{{ nw.titel }}

+

{{ nw.kategorie }}{% if nw.version %} · v{{ nw.version }}{% endif %}{% if nw.gueltig_bis %} · Gültig bis {{ nw.gueltig_bis|date:"d.m.Y" }}{% endif %}

+ {% if nw.ist_abgelaufen %} +

Abgelaufen

+ {% elif nw.gueltig_bis %} + {% now "Y-m-d" as today_str %} +

Bald ablaufend

+ {% endif %} +
+ +
+ {% endfor %} +
+ {% elif q %} +

Keine Nachweise für "{{ q }}" gefunden.

+ {% else %} +

Suchbegriff eingeben, um Nachweise zu finden.

+ {% endif %} + + +
+{% endblock %} diff --git a/vergabe_teilnahme/templates/dokumente/detail.html b/vergabe_teilnahme/templates/dokumente/detail.html new file mode 100644 index 0000000..b98b867 --- /dev/null +++ b/vergabe_teilnahme/templates/dokumente/detail.html @@ -0,0 +1,122 @@ +{% extends "base.html" %} +{% load vergabe_tags %} +{% block title %}{{ dokument.dateiname }}{% endblock %} +{% block content %} + +
+

{{ dokument.dateiname|default:"Dokument" }}

+ ← Übersicht +
+ +
+
+ +
+
+ {% status_badge dokument.status dokument.get_status_display %} + {% if dokument.finale_abgabeversion %} + Finale Abgabeversion + {% endif %} + v{{ dokument.version }} +
+ {% if dokument.datei %} + ↓ Herunterladen + {% endif %} + {% if dokument.beschreibung %} +

{{ dokument.beschreibung }}

+ {% endif %} +
+ + {% if versionen.count > 1 %} +
+

Versionshistorie

+ + + + + + + + + + {% for v in versionen %} + + + + + + {% endfor %} + +
VersionStatusDatum
+ + v{{ v.version }} + + {% status_badge v.status v.get_status_display %}{{ v.upload_datum|date:"d.m.Y" }}
+
+ {% endif %} + + {% if freigaben %} +
+

Freigaben

+
    + {% for fg in freigaben %} +
  • + {% status_badge fg.status fg.get_status_display %} + {{ fg.freigebender }} + {% if fg.kommentar %}— {{ fg.kommentar }}{% endif %} +
  • + {% endfor %} +
+
+ {% endif %} + +
+ +
+ +
+

Details

+

Kategorie: {{ dokument.get_kategorie_display }}

+

Hochgeladen: {{ dokument.upload_datum|date:"d.m.Y H:i" }}

+ {% if dokument.verantwortlicher %} +

Verantwortlicher: {{ dokument.verantwortlicher }}

+ {% endif %} + {% if dokument.pruefer %} +

Prüfer: {{ dokument.pruefer }}

+ {% endif %} + {% if dokument.los %} +

Los: {{ dokument.los }}

+ {% endif %} + {% if dokument.quelle %} +

Quelle: {{ dokument.quelle }}

+ {% endif %} + {% if dokument.bibliothek_nachweis %} +
+

Aus Bibliothek

+

{{ dokument.bibliothek_nachweis.titel }}

+ {% if dokument.bibliothek_nachweis.ist_abgelaufen %} +

Nachweis abgelaufen!

+ {% elif dokument.bibliothek_nachweis.gueltig_bis %} +

Gültig bis: {{ dokument.bibliothek_nachweis.gueltig_bis|date:"d.m.Y" }}

+ {% endif %} +
+ {% endif %} +
+ +
+

Aktionen

+ {% if not dokument.finale_abgabeversion %} + Neue Version hochladen +
+ {% csrf_token %} + +
+ {% endif %} + Aus Bibliothek zuordnen +
+ +
+
+ +{% endblock %} diff --git a/vergabe_teilnahme/templates/dokumente/form.html b/vergabe_teilnahme/templates/dokumente/form.html new file mode 100644 index 0000000..7d0d4b8 --- /dev/null +++ b/vergabe_teilnahme/templates/dokumente/form.html @@ -0,0 +1,68 @@ +{% extends "base.html" %} +{% block title %}{{ titel }}{% endblock %} +{% block content %} +
+

{{ titel }}

+
+ {% csrf_token %} +
+
+ + + + {% if form.datei.errors %}

{{ form.datei.errors.0 }}

{% endif %} +
+
+ + {{ form.kategorie }} + {% if form.kategorie.errors %}

{{ form.kategorie.errors.0 }}

{% endif %} +
+
+
+ + {{ form.version }} +
+
+ + {{ form.quelle }} +
+
+
+ + {{ form.beschreibung }} +
+
+
+

Zuständigkeiten (optional)

+
+
+ + {{ form.verantwortlicher }} +
+
+ + {{ form.pruefer }} +
+
+
+ + {{ form.los }} +
+
+
+ + Abbrechen +
+
+
+{% endblock %} diff --git a/vergabe_teilnahme/templates/dokumente/liste.html b/vergabe_teilnahme/templates/dokumente/liste.html new file mode 100644 index 0000000..9561a61 --- /dev/null +++ b/vergabe_teilnahme/templates/dokumente/liste.html @@ -0,0 +1,85 @@ +{% extends "base.html" %} +{% load vergabe_tags %} +{% block title %}Dokumente — {{ ausschreibung.titel }}{% endblock %} +{% block content %} + +
+

Dokumente

+ + Hochladen +
+ +
+
+ + +
+
+ + +
+
+ +{% if grouped_dokumente %} + {% for kategorie_label, dokumente in grouped_dokumente.items %} +
+ +
+ + + + + + + + + + + + + {% for dok in dokumente %} + + + + + + + + + {% endfor %} + +
DateiVersionStatusVerantwortlicherPrüferDatum
+ + {{ dok.dateiname|default:dok.pk }} + + {% if dok.finale_abgabeversion %} + Final + {% endif %} + {% if dok.quelle %} +

{{ dok.quelle }}

+ {% endif %} +
{{ dok.version }} + {% include "dokumente/partials/status_widget.html" with dokument=dok %} + {{ dok.verantwortlicher|default:"—" }}{{ dok.pruefer|default:"—" }}{{ dok.upload_datum|date:"d.m.Y" }}
+
+
+ {% endfor %} +{% else %} +
Noch keine Dokumente hochgeladen.
+{% endif %} + +{% endblock %} diff --git a/vergabe_teilnahme/templates/dokumente/neue_version.html b/vergabe_teilnahme/templates/dokumente/neue_version.html new file mode 100644 index 0000000..1c0c53d --- /dev/null +++ b/vergabe_teilnahme/templates/dokumente/neue_version.html @@ -0,0 +1,26 @@ +{% extends "base.html" %} +{% block title %}Neue Version — {{ dokument.dateiname }}{% endblock %} +{% block content %} +
+

Neue Version hochladen

+

Ersetzt: {{ dokument.dateiname }} v{{ dokument.version }}

+
+ {% csrf_token %} +
+
+ + {{ form.datei }} + {% if form.datei.errors %}

{{ form.datei.errors.0 }}

{% endif %} +
+
+ + {{ form.version }} +
+
+
+ + Abbrechen +
+
+
+{% endblock %} diff --git a/vergabe_teilnahme/templates/dokumente/partials/finaler_status_badge.html b/vergabe_teilnahme/templates/dokumente/partials/finaler_status_badge.html new file mode 100644 index 0000000..b60c2b5 --- /dev/null +++ b/vergabe_teilnahme/templates/dokumente/partials/finaler_status_badge.html @@ -0,0 +1,5 @@ +{% load vergabe_tags %} + + {% status_badge dokument.status dokument.get_status_display %} + Final ✓ + diff --git a/vergabe_teilnahme/templates/dokumente/partials/status_widget.html b/vergabe_teilnahme/templates/dokumente/partials/status_widget.html new file mode 100644 index 0000000..1f7bc13 --- /dev/null +++ b/vergabe_teilnahme/templates/dokumente/partials/status_widget.html @@ -0,0 +1,16 @@ +{% load vergabe_tags %} +{% if dokument.status == 'final_abgegeben' or dokument.status == 'ersetzt' or dokument.status == 'archiviert' %} + {% status_badge dokument.status dokument.get_status_display %} +{% else %} + +{% endif %} diff --git a/workplans/WP-0007-dokumente.md b/workplans/WP-0007-dokumente.md index 45d8392..5825f80 100644 --- a/workplans/WP-0007-dokumente.md +++ b/workplans/WP-0007-dokumente.md @@ -1,7 +1,7 @@ --- id: WP-0007 title: Dokumentenmanagement -status: todo +status: done phase: 7-of-12 created: "2026-05-08" depends_on: WP-0006 @@ -17,7 +17,7 @@ für alle Dokumente. Referenz: UC-DO-01 bis UC-DO-05. ```task id: WP-0007-T01 title: Dokument-Upload und Kategorisierung (UC-DO-01) -status: todo +status: done `dokumente/views.py` — dokument_upload: @@ -39,7 +39,7 @@ gewählten Dateien (Dateinamen-Liste). Für jede Datei eigenes Formular-Submit ```task id: WP-0007-T02 title: Dokumentenliste und Dokumentdetail -status: todo +status: done `dokumente/views.py` — dokumente_liste: Zeigt alle Dokumente einer Ausschreibung, gruppiert nach Kategorie. @@ -60,7 +60,7 @@ Template `dokumente/liste.html`: ```task id: WP-0007-T03 title: Neue Dokumentversion hochladen (UC-DO-02) -status: todo +status: done `dokument_neue_version (POST)`: ```python @@ -87,7 +87,7 @@ Logik `naechste_version(alte_version_str)`: "1.0" → "2.0", "2.3" → "3.0" (Ma ```task id: WP-0007-T04 title: Dokumentstatus-Workflow und finale Abgabeversion (UC-DO-03, UC-DO-04) -status: todo +status: done **Status-Workflow** — HTMX-Widget analog zum Aufgaben-Status. Statusübergänge: hochgeladen → zu_pruefen → in_bearbeitung → geprueft → freigegeben → final_abgegeben. @@ -114,7 +114,7 @@ Nach Kennzeichnung erscheint grüner "Final" Badge; weitere Uploads zu dieser Ve ```task id: WP-0007-T05 title: Standarddokument aus Bibliothek zuordnen (UC-DO-05) -status: todo +status: done `dokument_bibliothek_zuordnen`: HTMX-Modal mit Suchfeld. Suche in `bibliothek.Nachweis` und Bibliothek-Dokumente. @@ -132,7 +132,7 @@ Ablaufende/abgelaufene Nachweise: Zeige Warnung in orange/rot. ```task id: WP-0007-T06 title: Dokument-URL-Verkabelung und Tests -status: todo +status: done `dokumente/urls.py`: ```python