diff --git a/vergabe_teilnahme/apps/aufgaben/bieterfragen_urls.py b/vergabe_teilnahme/apps/aufgaben/bieterfragen_urls.py new file mode 100644 index 0000000..e39cb2c --- /dev/null +++ b/vergabe_teilnahme/apps/aufgaben/bieterfragen_urls.py @@ -0,0 +1,3 @@ +from django.urls import path + +urlpatterns = [] diff --git a/vergabe_teilnahme/apps/ausschreibungen/forms.py b/vergabe_teilnahme/apps/ausschreibungen/forms.py new file mode 100644 index 0000000..fa1a463 --- /dev/null +++ b/vergabe_teilnahme/apps/ausschreibungen/forms.py @@ -0,0 +1,40 @@ +from django import forms + +from .models import Ausschreibung + + +class AusschreibungForm(forms.ModelForm): + class Meta: + model = Ausschreibung + fields = [ + 'titel', 'ausschreiber', 'vergabeplattform', 'vergabenummer', 'vergabeart', + 'fundstelle_url', 'bid_manager', 'leistungsbeschreibung', + 'branche', 'schlagwoerter', 'geschaetztes_volumen', + 'veroeffentlichungsdatum', 'bieterfragen_bis', 'abgabe_bis', 'bindefrist', + 'unterlagen_erhalten', 'unterlagen_erhalten_am', + 'teilnahmeentscheidung', 'entscheidungsbegruendung', + ] + widgets = { + 'titel': forms.TextInput(attrs={'class': 'form-input', 'autofocus': True}), + 'ausschreiber': forms.TextInput(attrs={'class': 'form-input'}), + 'vergabeplattform': forms.TextInput(attrs={'class': 'form-input'}), + 'vergabenummer': forms.TextInput(attrs={'class': 'form-input'}), + 'vergabeart': forms.Select(attrs={'class': 'form-input'}), + 'fundstelle_url': forms.URLInput(attrs={'class': 'form-input'}), + 'bid_manager': forms.Select(attrs={'class': 'form-input'}), + 'leistungsbeschreibung': forms.Textarea(attrs={'class': 'form-input', 'rows': 4}), + 'branche': forms.TextInput(attrs={'class': 'form-input'}), + 'schlagwoerter': forms.TextInput(attrs={'class': 'form-input'}), + 'geschaetztes_volumen': forms.NumberInput(attrs={'class': 'form-input'}), + 'veroeffentlichungsdatum': forms.DateInput(attrs={'class': 'form-input', 'type': 'date'}), + 'bieterfragen_bis': forms.DateInput(attrs={'class': 'form-input', 'type': 'date'}), + 'abgabe_bis': forms.DateTimeInput(attrs={'class': 'form-input', 'type': 'datetime-local'}), + 'bindefrist': forms.DateInput(attrs={'class': 'form-input', 'type': 'date'}), + 'unterlagen_erhalten_am': forms.DateInput(attrs={'class': 'form-input', 'type': 'date'}), + 'teilnahmeentscheidung': forms.Select(attrs={'class': 'form-input'}), + 'entscheidungsbegruendung': forms.Textarea(attrs={'class': 'form-input', 'rows': 3}), + } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields['teilnahmeentscheidung'].required = False diff --git a/vergabe_teilnahme/apps/ausschreibungen/migrations/0002_add_archiviert.py b/vergabe_teilnahme/apps/ausschreibungen/migrations/0002_add_archiviert.py new file mode 100644 index 0000000..acb0fb6 --- /dev/null +++ b/vergabe_teilnahme/apps/ausschreibungen/migrations/0002_add_archiviert.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.5 on 2026-05-08 12:39 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('ausschreibungen', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='ausschreibung', + name='archiviert', + field=models.BooleanField(db_index=True, default=False), + ), + ] diff --git a/vergabe_teilnahme/apps/ausschreibungen/models.py b/vergabe_teilnahme/apps/ausschreibungen/models.py index 22a0d79..ba8e46c 100644 --- a/vergabe_teilnahme/apps/ausschreibungen/models.py +++ b/vergabe_teilnahme/apps/ausschreibungen/models.py @@ -74,6 +74,8 @@ class Ausschreibung(FlexibleModel): unterlagen_erhalten = models.BooleanField(default=False) unterlagen_erhalten_am = models.DateField(null=True, blank=True) + archiviert = models.BooleanField(default=False, db_index=True) + # Timestamps erstellt_am = models.DateTimeField(auto_now_add=True) geaendert_am = models.DateTimeField(auto_now=True) diff --git a/vergabe_teilnahme/apps/ausschreibungen/services.py b/vergabe_teilnahme/apps/ausschreibungen/services.py new file mode 100644 index 0000000..178a3a5 --- /dev/null +++ b/vergabe_teilnahme/apps/ausschreibungen/services.py @@ -0,0 +1,49 @@ +from datetime import date + + +def entscheidungsregel_auswertung(ausschreibung): + from vergabe_teilnahme.apps.bibliothek.models import Entscheidungsregel + + regeln = Entscheidungsregel.objects.filter(aktiv=True).order_by('-gewichtung') + return [ + { + 'regel': regel, + **_wende_regel_an(regel, ausschreibung), + } + for regel in regeln + ] + + +def _wende_regel_an(regel, ausschreibung): + kat = regel.kategorie + + if kat == 'ausschlusskriterium' and hasattr(ausschreibung, 'anforderungen'): + hat_ausschluss = ausschreibung.anforderungen.filter( + ausschlusskriterium=True, erfuellungsstatus='nicht_erfuellbar' + ).exists() + if hat_ausschluss: + return { + 'empfehlung': 'nicht_teilnehmen', + 'begruendung': 'Nicht erfüllbares Ausschlusskriterium vorhanden.', + 'warnung': True, + } + + if kat == 'frist' and ausschreibung.abgabe_bis: + abgabe_date = ( + ausschreibung.abgabe_bis.date() + if hasattr(ausschreibung.abgabe_bis, 'date') + else ausschreibung.abgabe_bis + ) + delta = (abgabe_date - date.today()).days + if regel.schwellenwert and delta < int(regel.schwellenwert): + return { + 'empfehlung': 'nicht_teilnehmen', + 'begruendung': f'Restlaufzeit {delta} Tage liegt unter Schwellenwert {int(regel.schwellenwert)} Tage.', + 'warnung': True, + } + + return { + 'empfehlung': 'pruefen', + 'begruendung': regel.begruendung or '—', + 'warnung': False, + } diff --git a/vergabe_teilnahme/apps/ausschreibungen/tests.py b/vergabe_teilnahme/apps/ausschreibungen/tests.py index 7ce503c..b4b2731 100644 --- a/vergabe_teilnahme/apps/ausschreibungen/tests.py +++ b/vergabe_teilnahme/apps/ausschreibungen/tests.py @@ -1,3 +1,100 @@ -from django.test import TestCase +import factory +import pytest +from django.urls import reverse -# Create your tests here. +from .models import Ausschreibung + + +class AusschreibungFactory(factory.django.DjangoModelFactory): + class Meta: + model = Ausschreibung + + titel = factory.Sequence(lambda n: f"Ausschreibung {n}") + ausschreiber = "Testausschreiber GmbH" + status = 1 + + +# --- Model tests --- + +@pytest.mark.django_db +def test_ausschreibung_str(): + a = AusschreibungFactory(titel="Test Ausschreibung") + assert str(a) == "Test Ausschreibung" + + +@pytest.mark.django_db +@pytest.mark.parametrize("status,expected", [ + (1, True), (5, True), (9, True), + (10, False), (11, False), (13, False), +]) +def test_ist_aktiv(status, expected): + a = AusschreibungFactory(status=status) + assert a.ist_aktiv == expected + + +@pytest.mark.django_db +def test_naechste_frist_returns_earlier(): + from datetime import date, timedelta + heute = date.today() + a = AusschreibungFactory( + bieterfragen_bis=heute + timedelta(days=5), + abgabe_bis=heute + timedelta(days=10), + ) + assert a.naechste_frist == heute + timedelta(days=5) + + +@pytest.mark.django_db +def test_naechste_frist_none_when_past(): + from datetime import date, timedelta + gestern = date.today() - timedelta(days=1) + a = AusschreibungFactory(bieterfragen_bis=gestern, abgabe_bis=None) + assert a.naechste_frist is None + + +# --- View tests --- + +@pytest.mark.django_db +def test_liste_get(client): + response = client.get(reverse("ausschreibungen:liste")) + assert response.status_code == 200 + + +@pytest.mark.django_db +def test_neu_get(client): + response = client.get(reverse("ausschreibungen:neu")) + assert response.status_code == 200 + + +@pytest.mark.django_db +def test_neu_post_valid(client): + data = {"titel": "Neue Ausschreibung", "ausschreiber": "Stadt XY"} + response = client.post(reverse("ausschreibungen:neu"), data) + assert response.status_code == 302 + a = Ausschreibung.objects.get(titel="Neue Ausschreibung") + assert response.url == reverse("ausschreibungen:detail", kwargs={"pk": a.pk}) + + +@pytest.mark.django_db +def test_detail_get(client): + a = AusschreibungFactory() + response = client.get(reverse("ausschreibungen:detail", kwargs={"pk": a.pk})) + assert response.status_code == 200 + + +@pytest.mark.django_db +def test_status_post(client): + a = AusschreibungFactory(status=1) + url = reverse("ausschreibungen:status", kwargs={"pk": a.pk}) + response = client.post(url, {"status": "4"}) + assert response.status_code == 200 + a.refresh_from_db() + assert a.status == 4 + + +@pytest.mark.django_db +def test_status_htmx_returns_partial(client): + a = AusschreibungFactory(status=1) + url = reverse("ausschreibungen:status", kwargs={"pk": a.pk}) + response = client.post(url, {"status": "3"}, HTTP_HX_REQUEST="true") + assert response.status_code == 200 + assert b"status-widget" in response.content diff --git a/vergabe_teilnahme/apps/ausschreibungen/urls.py b/vergabe_teilnahme/apps/ausschreibungen/urls.py index d72ac44..bafb01a 100644 --- a/vergabe_teilnahme/apps/ausschreibungen/urls.py +++ b/vergabe_teilnahme/apps/ausschreibungen/urls.py @@ -1,9 +1,24 @@ -from django.urls import path +from django.urls import include, path from . import views app_name = 'ausschreibungen' urlpatterns = [ - path('', views.dashboard, name='dashboard'), + path('', views.ausschreibung_liste, name='liste'), + path('dashboard/', views.dashboard, name='dashboard'), + path('neu/', views.ausschreibung_neu, name='neu'), + path('/', views.ausschreibung_detail, name='detail'), + path('/bearbeiten/', views.ausschreibung_bearbeiten, name='bearbeiten'), + path('/status/', views.ausschreibung_status, name='status'), + path('/entscheidung/', views.ausschreibung_entscheidung, name='entscheidung'), + path('/archivieren/', views.ausschreibung_archivieren, name='archivieren'), + path('/lose/', include('vergabe_teilnahme.apps.lose.urls')), + path('/aufgaben/', include('vergabe_teilnahme.apps.aufgaben.urls')), + path('/bieterfragen/', include('vergabe_teilnahme.apps.aufgaben.bieterfragen_urls')), + path('/dokumente/', include('vergabe_teilnahme.apps.dokumente.urls')), + path('/preise/', include('vergabe_teilnahme.apps.preise.urls')), + path('/abgabe/', include('vergabe_teilnahme.apps.nachbetrachtung.abgabe_urls')), + path('/nachbetrachtung/', include('vergabe_teilnahme.apps.nachbetrachtung.urls')), + path('/marktbegleiter/', include('vergabe_teilnahme.apps.marktbegleiter.passagen_urls')), ] diff --git a/vergabe_teilnahme/apps/ausschreibungen/views.py b/vergabe_teilnahme/apps/ausschreibungen/views.py index 1ef8141..d53b849 100644 --- a/vergabe_teilnahme/apps/ausschreibungen/views.py +++ b/vergabe_teilnahme/apps/ausschreibungen/views.py @@ -1,7 +1,195 @@ -from django.shortcuts import render +from datetime import date, timedelta + +from django.shortcuts import get_object_or_404, redirect, render +from django.utils import timezone + +from vergabe_teilnahme.apps.accounts.models import Mitarbeiter + +from .models import Ausschreibung + + +def _is_htmx(request): + return request.headers.get('HX-Request') == 'true' def dashboard(request): - return render(request, 'ausschreibungen/dashboard.html', { + from vergabe_teilnahme.apps.aufgaben.models import Aufgabe + + heute = date.today() + in_14_tagen = heute + timedelta(days=14) + + ctx = { + 'kritische_fristen': Ausschreibung.objects.filter( + abgabe_bis__date__lte=in_14_tagen, + abgabe_bis__date__gte=heute, + status__lt=10, + ).order_by('abgabe_bis')[:10], + + 'ohne_entscheidung': Ausschreibung.objects.filter( + teilnahmeentscheidung='offen', + erstellt_am__lte=timezone.now() - timedelta(days=3), + status__lt=10, + ).order_by('erstellt_am')[:10], + + 'ueberfaellige_aufgaben': Aufgabe.objects.filter( + frist__lt=heute, + status__in=['offen', 'in_bearbeitung', 'wartend_intern', 'wartend_sub', 'wartend_ausschreiber'], + ).select_related('ausschreibung').order_by('frist')[:15], + + 'laufende_ausschreibungen': Ausschreibung.objects.filter( + status__range=(1, 9), + ).order_by('-geaendert_am')[:10], + 'breadcrumbs': [{'label': 'Übersicht', 'url': None}], + } + return render(request, 'ausschreibungen/dashboard.html', ctx) + + +def ausschreibung_liste(request): + qs = Ausschreibung.objects.all() + + status_filter = request.GET.get('status') + if status_filter: + qs = qs.filter(status=status_filter) + + archiviert = request.GET.get('archiviert', '0') == '1' + qs = qs.filter(archiviert=archiviert) + + bid_manager_filter = request.GET.get('bid_manager') + if bid_manager_filter: + qs = qs.filter(bid_manager=bid_manager_filter) + + qs = qs.select_related('bid_manager').order_by('-geaendert_am') + + ctx = { + 'ausschreibungen': qs, + 'status_choices': Ausschreibung.STATUS_CHOICES, + 'mitarbeiter': Mitarbeiter.objects.all(), + 'archiviert': archiviert, + 'current_status': status_filter or '', + 'current_bid_manager': bid_manager_filter or '', + 'breadcrumbs': [{'label': 'Ausschreibungen', 'url': None}], + } + + if _is_htmx(request): + return render(request, 'ausschreibungen/liste_partial.html', ctx) + return render(request, 'ausschreibungen/liste.html', ctx) + + +def ausschreibung_neu(request): + from .forms import AusschreibungForm + + historisch = request.GET.get('historisch') == '1' + if request.method == 'POST': + form = AusschreibungForm(request.POST) + if form.is_valid(): + a = form.save() + return redirect('ausschreibungen:detail', pk=a.pk) + else: + form = AusschreibungForm() + + return render(request, 'ausschreibungen/form.html', { + 'form': form, + 'historisch': historisch, + 'titel': 'Neue Ausschreibung', + 'breadcrumbs': [ + {'label': 'Ausschreibungen', 'url': '/ausschreibungen/'}, + {'label': 'Neu', 'url': None}, + ], + }) + + +def ausschreibung_detail(request, pk): + from vergabe_teilnahme.apps.core.services import build_phase_nav, get_deadline_warnings + + a = get_object_or_404(Ausschreibung, pk=pk) + ctx = { + 'ausschreibung': a, + 'ausschreibung_id': pk, + 'phases': build_phase_nav(a), + 'warnungen': get_deadline_warnings(a), + 'breadcrumbs': [ + {'label': 'Ausschreibungen', 'url': '/ausschreibungen/'}, + {'label': a.titel, 'url': None}, + ], + } + return render(request, 'ausschreibungen/detail.html', ctx) + + +def ausschreibung_bearbeiten(request, pk): + from .forms import AusschreibungForm + + a = get_object_or_404(Ausschreibung, pk=pk) + form = AusschreibungForm(request.POST or None, instance=a) + if request.method == 'POST' and form.is_valid(): + form.save() + return redirect('ausschreibungen:detail', pk=pk) + return render(request, 'ausschreibungen/form.html', { + 'form': form, + 'titel': 'Ausschreibung bearbeiten', + 'ausschreibung': a, + 'breadcrumbs': [ + {'label': 'Ausschreibungen', 'url': '/ausschreibungen/'}, + {'label': a.titel, 'url': f'/ausschreibungen/{pk}/'}, + {'label': 'Bearbeiten', 'url': None}, + ], + }) + + +def ausschreibung_status(request, pk): + a = get_object_or_404(Ausschreibung, pk=pk) + if request.method == 'POST': + neuer_status = request.POST.get('status') + if neuer_status and neuer_status.isdigit(): + a.status = int(neuer_status) + a.save(update_fields=['status', 'geaendert_am']) + return render(request, 'ausschreibungen/partials/status_widget.html', {'ausschreibung': a}) + + +def ausschreibung_entscheidung(request, pk): + from .services import entscheidungsregel_auswertung + + a = get_object_or_404(Ausschreibung, pk=pk) + if request.method == 'POST': + a.teilnahmeentscheidung = request.POST.get('teilnahmeentscheidung', 'offen') + a.entscheidungsbegruendung = request.POST.get('begruendung', a.entscheidungsbegruendung) + if a.teilnahmeentscheidung in ['teilnahme', 'ablehnung']: + a.status = max(a.status, 3) + a.save() + return redirect('ausschreibungen:detail', pk=pk) + + from vergabe_teilnahme.apps.lose.models import Anforderung + ausschlusskriterien = Anforderung.objects.filter( + ausschreibung=a, + ausschlusskriterium=True, + erfuellungsstatus='nicht_erfuellbar', + ).select_related('los') + + ctx = { + 'ausschreibung': a, + 'regelergebnis': entscheidungsregel_auswertung(a), + 'ausschlusskriterien_nicht_erfuellbar': ausschlusskriterien, + 'breadcrumbs': [ + {'label': 'Ausschreibungen', 'url': '/ausschreibungen/'}, + {'label': a.titel, 'url': f'/ausschreibungen/{pk}/'}, + {'label': 'Teilnahmeentscheidung', 'url': None}, + ], + } + return render(request, 'ausschreibungen/entscheidung.html', ctx) + + +def ausschreibung_archivieren(request, pk): + a = get_object_or_404(Ausschreibung, pk=pk) + if request.method == 'POST': + a.archiviert = True + a.status = 13 + a.save(update_fields=['archiviert', 'status', 'geaendert_am']) + return redirect('ausschreibungen:liste') + return render(request, 'ausschreibungen/archivieren_confirm.html', { + 'ausschreibung': a, + 'breadcrumbs': [ + {'label': 'Ausschreibungen', 'url': '/ausschreibungen/'}, + {'label': a.titel, 'url': f'/ausschreibungen/{pk}/'}, + {'label': 'Archivieren', 'url': None}, + ], }) diff --git a/vergabe_teilnahme/apps/lose/forms.py b/vergabe_teilnahme/apps/lose/forms.py new file mode 100644 index 0000000..378f8d3 --- /dev/null +++ b/vergabe_teilnahme/apps/lose/forms.py @@ -0,0 +1,52 @@ +from django import forms + +from vergabe_teilnahme.apps.accounts.models import Mitarbeiter + +from .models import Anforderung, Los + + +class LosForm(forms.ModelForm): + class Meta: + model = Los + fields = ['losnummer', 'lostitel', 'beschreibung', 'abgrenzung', 'zustaendiger', 'teilnahme'] + widgets = { + 'losnummer': forms.TextInput(attrs={'class': 'form-input', 'autofocus': True}), + 'lostitel': forms.TextInput(attrs={'class': 'form-input'}), + 'beschreibung': forms.Textarea(attrs={'class': 'form-input', 'rows': 3}), + 'abgrenzung': forms.Textarea(attrs={'class': 'form-input', 'rows': 3}), + 'zustaendiger': forms.Select(attrs={'class': 'form-input'}), + 'teilnahme': forms.NullBooleanSelect(attrs={'class': 'form-input'}), + } + + def __init__(self, *args, **kwargs): + ausschreibung = kwargs.pop('ausschreibung', None) + super().__init__(*args, **kwargs) + self.fields['teilnahme'].required = False + + +class AnforderungForm(forms.ModelForm): + class Meta: + model = Anforderung + fields = [ + 'titel', 'beschreibung', 'quelle_im_dokument', 'kategorie', 'verbindlichkeit', + 'ausschlusskriterium', 'bewertungskriterium', 'zustaendiger', + 'erfuellungsstatus', 'nachweis_erforderlich', 'los', + ] + widgets = { + 'titel': forms.TextInput(attrs={'class': 'form-input', 'autofocus': True}), + 'beschreibung': forms.Textarea(attrs={'class': 'form-input', 'rows': 3}), + 'quelle_im_dokument': forms.TextInput(attrs={'class': 'form-input'}), + 'kategorie': forms.Select(attrs={'class': 'form-input'}), + 'verbindlichkeit': forms.RadioSelect(), + 'zustaendiger': forms.Select(attrs={'class': 'form-input'}), + 'erfuellungsstatus': forms.Select(attrs={'class': 'form-input'}), + 'los': forms.Select(attrs={'class': 'form-input'}), + } + + 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['los'].required = False + self.fields['zustaendiger'].required = False + self.fields['kategorie'].required = False diff --git a/vergabe_teilnahme/apps/lose/tests.py b/vergabe_teilnahme/apps/lose/tests.py index 7ce503c..f10c3a1 100644 --- a/vergabe_teilnahme/apps/lose/tests.py +++ b/vergabe_teilnahme/apps/lose/tests.py @@ -1,3 +1,114 @@ -from django.test import TestCase +import factory +import pytest +from django.urls import reverse -# Create your tests here. +from vergabe_teilnahme.apps.ausschreibungen.models import Ausschreibung +from vergabe_teilnahme.apps.ausschreibungen.tests import AusschreibungFactory +from vergabe_teilnahme.apps.bibliothek.models import Nachweis + +from .models import Anforderung, Los + + +class LosFactory(factory.django.DjangoModelFactory): + class Meta: + model = Los + + ausschreibung = factory.SubFactory(AusschreibungFactory) + losnummer = factory.Sequence(lambda n: f"L{n:02d}") + lostitel = factory.Sequence(lambda n: f"Los {n}") + + +class AnforderungFactory(factory.django.DjangoModelFactory): + class Meta: + model = Anforderung + + ausschreibung = factory.SubFactory(AusschreibungFactory) + titel = factory.Sequence(lambda n: f"Anforderung {n}") + verbindlichkeit = 'muss' + erfuellungsstatus = 'offen' + + +class NachweisFactory(factory.django.DjangoModelFactory): + class Meta: + model = Nachweis + + titel = factory.Sequence(lambda n: f"Nachweis {n}") + + +# ─── Lose ────────────────────────────────────────────────────────────────── + + +@pytest.mark.django_db +def test_lose_liste_get(client): + a = AusschreibungFactory() + url = reverse('ausschreibungen:lose:liste', kwargs={'ausschreibung_id': a.pk}) + response = client.get(url) + assert response.status_code == 200 + + +@pytest.mark.django_db +def test_los_neu_post(client): + a = AusschreibungFactory() + url = reverse('ausschreibungen:lose:neu', kwargs={'ausschreibung_id': a.pk}) + response = client.post(url, {'losnummer': 'L01', 'lostitel': 'Testlos'}) + assert response.status_code == 302 + assert Los.objects.filter(ausschreibung=a, losnummer='L01').exists() + + +@pytest.mark.django_db +def test_los_detail_get(client): + los = LosFactory() + url = reverse('ausschreibungen:lose:detail', + kwargs={'ausschreibung_id': los.ausschreibung_id, 'los_pk': los.pk}) + response = client.get(url) + assert response.status_code == 200 + + +# ─── Anforderungen ───────────────────────────────────────────────────────── + + +@pytest.mark.django_db +def test_anforderung_neu_post_muss(client): + a = AusschreibungFactory() + url = reverse('ausschreibungen:lose:anforderung_neu', kwargs={'ausschreibung_id': a.pk}) + response = client.post(url, {'titel': 'Neue Anforderung', 'verbindlichkeit': 'muss', + 'erfuellungsstatus': 'offen'}) + assert response.status_code == 302 + assert Anforderung.objects.filter(ausschreibung=a, titel='Neue Anforderung').exists() + + +@pytest.mark.django_db +def test_anforderung_status_htmx(client): + anf = AnforderungFactory() + url = reverse('ausschreibungen:lose:anforderung_status', + kwargs={'ausschreibung_id': anf.ausschreibung_id, 'pk': anf.pk}) + response = client.post(url, {'erfuellungsstatus': 'nicht_erfuellbar'}, + HTTP_HX_REQUEST='true') + assert response.status_code == 200 + anf.refresh_from_db() + assert anf.erfuellungsstatus == 'nicht_erfuellbar' + + +@pytest.mark.django_db +def test_ausschlusskriterium_banner_shown(client): + a = AusschreibungFactory() + AnforderungFactory( + ausschreibung=a, + ausschlusskriterium=True, + erfuellungsstatus='nicht_erfuellbar', + ) + url = reverse('ausschreibungen:entscheidung', kwargs={'pk': a.pk}) + response = client.get(url) + assert response.status_code == 200 + assert b'Nicht erf\xc3\xbcllbare Ausschlusskriterien' in response.content + + +@pytest.mark.django_db +def test_nachweis_zuordnen(client): + anf = AnforderungFactory() + n = NachweisFactory() + url = reverse('ausschreibungen:lose:nachweis_zuordnen', + kwargs={'ausschreibung_id': anf.ausschreibung_id, 'pk': anf.pk}) + response = client.post(url, {'nachweis_pk': n.pk}) + assert response.status_code == 200 + assert anf.nachweise.filter(pk=n.pk).exists() diff --git a/vergabe_teilnahme/apps/lose/urls.py b/vergabe_teilnahme/apps/lose/urls.py index eb89e3b..7e9df23 100644 --- a/vergabe_teilnahme/apps/lose/urls.py +++ b/vergabe_teilnahme/apps/lose/urls.py @@ -1,2 +1,22 @@ from django.urls import path -urlpatterns = [] + +from . import views + +app_name = 'lose' + +urlpatterns = [ + path('', views.lose_liste, name='liste'), + path('neu/', views.los_neu, name='neu'), + path('/', views.los_detail, name='detail'), + path('/bearbeiten/', views.los_bearbeiten, name='bearbeiten'), + path('/loeschen/', views.los_loeschen, name='loeschen'), + path('anforderungen/', views.anforderungen_liste, name='anforderungen_liste'), + path('anforderungen/neu/', views.anforderung_neu, name='anforderung_neu'), + path('anforderungen//', views.anforderung_detail, name='anforderung_detail'), + path('anforderungen//bearbeiten/', views.anforderung_bearbeiten, name='anforderung_bearbeiten'), + path('anforderungen//status/', views.anforderung_status, name='anforderung_status'), + path('anforderungen//nachweis/', views.nachweis_suche_modal, name='nachweis_suche'), + path('anforderungen//nachweis/zuordnen/', views.nachweis_zuordnen, name='nachweis_zuordnen'), + path('anforderungen//nachweis//entfernen/', views.nachweis_entfernen, name='nachweis_entfernen'), + path('anforderungen//aufgabe/', views.anforderung_aufgabe_erstellen, name='anforderung_aufgabe'), +] diff --git a/vergabe_teilnahme/apps/lose/views.py b/vergabe_teilnahme/apps/lose/views.py index 91ea44a..7db9000 100644 --- a/vergabe_teilnahme/apps/lose/views.py +++ b/vergabe_teilnahme/apps/lose/views.py @@ -1,3 +1,287 @@ -from django.shortcuts import render +from django.shortcuts import get_object_or_404, redirect, render -# Create your views here. +from vergabe_teilnahme.apps.ausschreibungen.models import Ausschreibung +from vergabe_teilnahme.apps.bibliothek.models import Nachweis + +from .forms import AnforderungForm, LosForm +from .models import Anforderung, Los + + +def _is_htmx(request): + return request.headers.get('HX-Request') == 'true' + + +def _ausschreibung_breadcrumbs(ausschreibung, *extra): + crumbs = [ + {'label': 'Ausschreibungen', 'url': '/ausschreibungen/'}, + {'label': ausschreibung.titel, 'url': f'/ausschreibungen/{ausschreibung.pk}/'}, + ] + for label, url in extra: + crumbs.append({'label': label, 'url': url}) + return crumbs + + +# ─── Lose ──────────────────────────────────────────────────────────────────── + +def lose_liste(request, ausschreibung_id): + ausschreibung = get_object_or_404(Ausschreibung, pk=ausschreibung_id) + lose = Los.objects.filter(ausschreibung=ausschreibung).order_by('losnummer') + return render(request, 'lose/liste.html', { + 'ausschreibung': ausschreibung, + 'lose': lose, + 'breadcrumbs': _ausschreibung_breadcrumbs(ausschreibung, ('Lose', None)), + }) + + +def los_neu(request, ausschreibung_id): + ausschreibung = get_object_or_404(Ausschreibung, pk=ausschreibung_id) + if request.method == 'POST': + form = LosForm(request.POST, ausschreibung=ausschreibung) + if form.is_valid(): + los = form.save(commit=False) + los.ausschreibung = ausschreibung + los.save() + if _is_htmx(request): + return render(request, 'lose/partials/los_row.html', {'los': los, 'ausschreibung': ausschreibung}) + return redirect('ausschreibungen:lose:liste', ausschreibung_id=ausschreibung_id) + else: + form = LosForm(ausschreibung=ausschreibung) + return render(request, 'lose/form.html', { + 'form': form, + 'ausschreibung': ausschreibung, + 'titel': 'Los hinzufügen', + 'breadcrumbs': _ausschreibung_breadcrumbs(ausschreibung, + ('Lose', f'/ausschreibungen/{ausschreibung_id}/lose/'), + ('Neu', None)), + }) + + +def los_detail(request, ausschreibung_id, los_pk): + ausschreibung = get_object_or_404(Ausschreibung, pk=ausschreibung_id) + los = get_object_or_404(Los, pk=los_pk, ausschreibung=ausschreibung) + anforderungen = los.anforderungen.all().order_by('verbindlichkeit', 'titel') + return render(request, 'lose/detail.html', { + 'ausschreibung': ausschreibung, + 'los': los, + 'anforderungen': anforderungen, + 'breadcrumbs': _ausschreibung_breadcrumbs(ausschreibung, + ('Lose', f'/ausschreibungen/{ausschreibung_id}/lose/'), + (str(los), None)), + }) + + +def los_bearbeiten(request, ausschreibung_id, los_pk): + ausschreibung = get_object_or_404(Ausschreibung, pk=ausschreibung_id) + los = get_object_or_404(Los, pk=los_pk, ausschreibung=ausschreibung) + form = LosForm(request.POST or None, instance=los, ausschreibung=ausschreibung) + if request.method == 'POST' and form.is_valid(): + form.save() + return redirect('ausschreibungen:lose:detail', ausschreibung_id=ausschreibung_id, los_pk=los_pk) + return render(request, 'lose/form.html', { + 'form': form, + 'ausschreibung': ausschreibung, + 'los': los, + 'titel': 'Los bearbeiten', + 'breadcrumbs': _ausschreibung_breadcrumbs(ausschreibung, + ('Lose', f'/ausschreibungen/{ausschreibung_id}/lose/'), + (str(los), f'/ausschreibungen/{ausschreibung_id}/lose/{los_pk}/'), + ('Bearbeiten', None)), + }) + + +def los_loeschen(request, ausschreibung_id, los_pk): + ausschreibung = get_object_or_404(Ausschreibung, pk=ausschreibung_id) + los = get_object_or_404(Los, pk=los_pk, ausschreibung=ausschreibung) + if request.method == 'POST': + los.delete() + return redirect('ausschreibungen:lose:liste', ausschreibung_id=ausschreibung_id) + return render(request, 'lose/loeschen_confirm.html', { + 'ausschreibung': ausschreibung, + 'los': los, + 'breadcrumbs': _ausschreibung_breadcrumbs(ausschreibung, + ('Lose', f'/ausschreibungen/{ausschreibung_id}/lose/'), + (str(los), f'/ausschreibungen/{ausschreibung_id}/lose/{los_pk}/'), + ('Löschen', None)), + }) + + +# ─── Anforderungen ─────────────────────────────────────────────────────────── + +def anforderungen_liste(request, ausschreibung_id): + ausschreibung = get_object_or_404(Ausschreibung, pk=ausschreibung_id) + qs = Anforderung.objects.filter(ausschreibung=ausschreibung).select_related('los', 'zustaendiger') + + verbindlichkeit = request.GET.get('verbindlichkeit', '') + if verbindlichkeit: + qs = qs.filter(verbindlichkeit=verbindlichkeit) + + status_filter = request.GET.get('erfuellungsstatus', '') + if status_filter: + qs = qs.filter(erfuellungsstatus=status_filter) + + los_filter = request.GET.get('los', '') + if los_filter == 'allgemein': + qs = qs.filter(los__isnull=True) + elif los_filter: + qs = qs.filter(los_id=los_filter) + + lose = Los.objects.filter(ausschreibung=ausschreibung).order_by('losnummer') + + grouped = {} + for los in lose: + grouped[los] = [] + grouped[None] = [] + + for a in qs.order_by('verbindlichkeit', 'titel'): + grouped[a.los].append(a) + + grouped = {k: v for k, v in grouped.items() if v} + + ctx = { + 'ausschreibung': ausschreibung, + 'grouped': grouped, + 'lose': lose, + 'verbindlichkeit_choices': Anforderung.VERBINDLICHKEIT_CHOICES, + 'erfuellung_choices': Anforderung.ERFUELLUNG_CHOICES, + 'current_verbindlichkeit': verbindlichkeit, + 'current_status': status_filter, + 'current_los': los_filter, + 'breadcrumbs': _ausschreibung_breadcrumbs(ausschreibung, ('Anforderungen', None)), + } + + if _is_htmx(request): + return render(request, 'lose/anforderungen_liste_partial.html', ctx) + return render(request, 'lose/anforderungen_liste.html', ctx) + + +def anforderung_neu(request, ausschreibung_id): + ausschreibung = get_object_or_404(Ausschreibung, pk=ausschreibung_id) + if request.method == 'POST': + form = AnforderungForm(request.POST, ausschreibung=ausschreibung) + if form.is_valid(): + a = form.save(commit=False) + a.ausschreibung = ausschreibung + a.save() + return redirect('ausschreibungen:lose:anforderung_detail', + ausschreibung_id=ausschreibung_id, pk=a.pk) + else: + los_pk = request.GET.get('los') + initial = {'los': los_pk} if los_pk else {} + form = AnforderungForm(ausschreibung=ausschreibung, initial=initial) + return render(request, 'lose/anforderung_form.html', { + 'form': form, + 'ausschreibung': ausschreibung, + 'titel': 'Anforderung anlegen', + 'breadcrumbs': _ausschreibung_breadcrumbs(ausschreibung, + ('Anforderungen', f'/ausschreibungen/{ausschreibung_id}/lose/anforderungen/'), + ('Neu', None)), + }) + + +def anforderung_detail(request, ausschreibung_id, pk): + ausschreibung = get_object_or_404(Ausschreibung, pk=ausschreibung_id) + anforderung = get_object_or_404(Anforderung, pk=pk, ausschreibung=ausschreibung) + aufgaben = anforderung.aufgaben.all().order_by('prioritaet', 'frist') + return render(request, 'lose/anforderung_detail.html', { + 'ausschreibung': ausschreibung, + 'anforderung': anforderung, + 'aufgaben': aufgaben, + 'breadcrumbs': _ausschreibung_breadcrumbs(ausschreibung, + ('Anforderungen', f'/ausschreibungen/{ausschreibung_id}/lose/anforderungen/'), + (anforderung.titel[:50], None)), + }) + + +def anforderung_bearbeiten(request, ausschreibung_id, pk): + ausschreibung = get_object_or_404(Ausschreibung, pk=ausschreibung_id) + anforderung = get_object_or_404(Anforderung, pk=pk, ausschreibung=ausschreibung) + form = AnforderungForm(request.POST or None, instance=anforderung, ausschreibung=ausschreibung) + if request.method == 'POST' and form.is_valid(): + form.save() + return redirect('ausschreibungen:lose:anforderung_detail', + ausschreibung_id=ausschreibung_id, pk=pk) + return render(request, 'lose/anforderung_form.html', { + 'form': form, + 'ausschreibung': ausschreibung, + 'anforderung': anforderung, + 'titel': 'Anforderung bearbeiten', + 'breadcrumbs': _ausschreibung_breadcrumbs(ausschreibung, + ('Anforderungen', f'/ausschreibungen/{ausschreibung_id}/lose/anforderungen/'), + (anforderung.titel[:50], f'/ausschreibungen/{ausschreibung_id}/lose/anforderungen/{pk}/'), + ('Bearbeiten', None)), + }) + + +def anforderung_status(request, ausschreibung_id, pk): + anforderung = get_object_or_404(Anforderung, pk=pk, ausschreibung_id=ausschreibung_id) + if request.method == 'POST': + neuer_status = request.POST.get('erfuellungsstatus') + if neuer_status: + anforderung.erfuellungsstatus = neuer_status + anforderung.save(update_fields=['erfuellungsstatus']) + return render(request, 'lose/partials/erfuellungsstatus_widget.html', {'anforderung': anforderung}) + + +# ─── Nachweise ─────────────────────────────────────────────────────────────── + +def nachweis_suche_modal(request, ausschreibung_id, pk): + anforderung = get_object_or_404(Anforderung, pk=pk, ausschreibung_id=ausschreibung_id) + q = request.GET.get('q', '') + nachweise = Nachweis.objects.all() + if q: + nachweise = nachweise.filter(titel__icontains=q) + bereits_zugeordnet = anforderung.nachweise.values_list('pk', flat=True) + return render(request, 'lose/partials/nachweis_modal.html', { + 'anforderung': anforderung, + 'nachweise': nachweise[:20], + 'bereits_zugeordnet': list(bereits_zugeordnet), + 'q': q, + 'ausschreibung_id': ausschreibung_id, + }) + + +def nachweis_zuordnen(request, ausschreibung_id, pk): + anforderung = get_object_or_404(Anforderung, pk=pk, ausschreibung_id=ausschreibung_id) + if request.method == 'POST': + nachweis_pk = request.POST.get('nachweis_pk') + if nachweis_pk: + nachweis = get_object_or_404(Nachweis, pk=nachweis_pk) + anforderung.nachweise.add(nachweis) + return render(request, 'lose/partials/nachweise_liste.html', { + 'anforderung': anforderung, + 'ausschreibung_id': ausschreibung_id, + }) + + +def nachweis_entfernen(request, ausschreibung_id, pk, nachweis_pk): + anforderung = get_object_or_404(Anforderung, pk=pk, ausschreibung_id=ausschreibung_id) + if request.method == 'POST': + nachweis = get_object_or_404(Nachweis, pk=nachweis_pk) + anforderung.nachweise.remove(nachweis) + return render(request, 'lose/partials/nachweise_liste.html', { + 'anforderung': anforderung, + 'ausschreibung_id': ausschreibung_id, + }) + + +# ─── Aufgabe aus Anforderung ───────────────────────────────────────────────── + +def anforderung_aufgabe_erstellen(request, ausschreibung_id, pk): + ausschreibung = get_object_or_404(Ausschreibung, pk=ausschreibung_id) + anforderung = get_object_or_404(Anforderung, pk=pk, ausschreibung=ausschreibung) + if request.method == 'POST': + from vergabe_teilnahme.apps.aufgaben.models import Aufgabe + Aufgabe.objects.create( + ausschreibung=ausschreibung, + los=anforderung.los, + anforderung=anforderung, + titel=f'Klärung: {anforderung.titel[:200]}', + typ='fachlich', + verantwortlicher=anforderung.zustaendiger, + ) + return redirect('ausschreibungen:lose:anforderung_detail', + ausschreibung_id=ausschreibung_id, pk=pk) + return render(request, 'lose/aufgabe_erstellen_confirm.html', { + 'ausschreibung': ausschreibung, + 'anforderung': anforderung, + }) diff --git a/vergabe_teilnahme/apps/marktbegleiter/passagen_urls.py b/vergabe_teilnahme/apps/marktbegleiter/passagen_urls.py new file mode 100644 index 0000000..e39cb2c --- /dev/null +++ b/vergabe_teilnahme/apps/marktbegleiter/passagen_urls.py @@ -0,0 +1,3 @@ +from django.urls import path + +urlpatterns = [] diff --git a/vergabe_teilnahme/apps/nachbetrachtung/abgabe_urls.py b/vergabe_teilnahme/apps/nachbetrachtung/abgabe_urls.py new file mode 100644 index 0000000..e39cb2c --- /dev/null +++ b/vergabe_teilnahme/apps/nachbetrachtung/abgabe_urls.py @@ -0,0 +1,3 @@ +from django.urls import path + +urlpatterns = [] diff --git a/vergabe_teilnahme/templates/ausschreibungen/archivieren_confirm.html b/vergabe_teilnahme/templates/ausschreibungen/archivieren_confirm.html new file mode 100644 index 0000000..d78e841 --- /dev/null +++ b/vergabe_teilnahme/templates/ausschreibungen/archivieren_confirm.html @@ -0,0 +1,18 @@ +{% extends "base.html" %} +{% block title %}Ausschreibung archivieren{% endblock %} +{% block content %} +
+
+

Ausschreibung archivieren?

+

+ {{ ausschreibung.titel }} wird archiviert und aus der aktiven Liste entfernt. + Die Daten bleiben erhalten. +

+
+ {% csrf_token %} + + Abbrechen +
+
+
+{% endblock %} diff --git a/vergabe_teilnahme/templates/ausschreibungen/dashboard.html b/vergabe_teilnahme/templates/ausschreibungen/dashboard.html index 819ed7a..44034be 100644 --- a/vergabe_teilnahme/templates/ausschreibungen/dashboard.html +++ b/vergabe_teilnahme/templates/ausschreibungen/dashboard.html @@ -1,6 +1,102 @@ {% extends "base.html" %} +{% load vergabe_tags %} {% block title %}Übersicht{% endblock %} {% block content %}

Übersicht

-

Dashboard wird in WP-0004 implementiert.

+ +
+ + +
+
+

Kritische Fristen (14 Tage)

+ + {{ kritische_fristen|length }} + +
+ {% if kritische_fristen %} +
    + {% for a in kritische_fristen %} +
  • + {{ a.titel }} + + {{ a.abgabe_bis|date:"d.m.Y H:i" }} + +
  • + {% endfor %} +
+ {% else %} +

Keine kritischen Fristen.

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

Ohne Teilnahmeentscheidung

+ + {{ ohne_entscheidung|length }} + +
+ {% if ohne_entscheidung %} +
    + {% for a in ohne_entscheidung %} +
  • + {{ a.titel }} + seit {{ a.erstellt_am|date:"d.m.Y" }} +
  • + {% endfor %} +
+ {% else %} +

Alle Ausschreibungen haben eine Entscheidung.

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

Überfällige Aufgaben

+ + {{ ueberfaellige_aufgaben|length }} + +
+ {% if ueberfaellige_aufgaben %} +
    + {% for aufgabe in ueberfaellige_aufgaben %} +
  • + + {{ aufgabe.titel }} + + {{ aufgabe.frist|date:"d.m.Y" }} +
  • + {% endfor %} +
+ {% else %} +

Keine überfälligen Aufgaben.

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

Laufende Ausschreibungen

+ + {{ laufende_ausschreibungen|length }} + +
+ {% if laufende_ausschreibungen %} +
    + {% for a in laufende_ausschreibungen %} +
  • + {{ a.titel }} + {% status_badge a.get_status_display a.status %} +
  • + {% endfor %} +
+ {% else %} +

Keine laufenden Ausschreibungen.

+ {% endif %} +
+ +
{% endblock %} diff --git a/vergabe_teilnahme/templates/ausschreibungen/detail.html b/vergabe_teilnahme/templates/ausschreibungen/detail.html new file mode 100644 index 0000000..20b0848 --- /dev/null +++ b/vergabe_teilnahme/templates/ausschreibungen/detail.html @@ -0,0 +1,94 @@ +{% extends "base.html" %} +{% load vergabe_tags %} +{% block title %}{{ ausschreibung.titel }}{% endblock %} +{% block content %} + + +
+
+

{{ ausschreibung.titel }}

+

{{ ausschreibung.ausschreiber }}

+
+
+ {% include "ausschreibungen/partials/status_widget.html" %} + Bearbeiten + Archivieren +
+
+ + +{% if warnungen %} +
+ {% for w in warnungen %} +
+ + {% if w.typ == 'bieterfragen' %}Bieterfragen-Frist{% else %}Abgabe-Frist{% endif %}: + + {% if w.tage < 0 %} + überfällig + {% elif w.tage == 0 %} + heute! + {% else %} + noch {{ w.tage }} Tag{% if w.tage != 1 %}e{% endif %} + {% endif %} +
+ {% endfor %} +
+{% endif %} + + +
+ {% for phase in phases %} + + {{ phase.nummer }} + {{ phase.name }} + + {% endfor %} +
+ + +
+
+

Stammdaten

+
+ {% render_field ausschreibung "ausschreiber" "Ausschreiber" %} + {% render_field ausschreibung "vergabeart" "Vergabeart" %} + {% render_field ausschreibung "vergabenummer" "Vergabenummer" %} + {% render_field ausschreibung "vergabeplattform" "Plattform" %} + {% render_field ausschreibung "branche" "Branche" %} + {% render_field ausschreibung "schlagwoerter" "Schlagwörter" %} + {% render_field ausschreibung "geschaetztes_volumen" "Geschätztes Volumen (€)" %} +
+
+ +
+

Fristen

+
+ {% render_field ausschreibung "veroeffentlichungsdatum" "Veröffentlicht" %} + {% render_field ausschreibung "bieterfragen_bis" "Bieterfragen bis" %} + {% render_field ausschreibung "abgabe_bis" "Abgabe bis" %} + {% render_field ausschreibung "bindefrist" "Bindefrist" %} +
+
+
+ +{% if ausschreibung.leistungsbeschreibung %} +
+

Leistungsbeschreibung

+

{{ ausschreibung.leistungsbeschreibung }}

+
+{% endif %} + + + +{% endblock %} diff --git a/vergabe_teilnahme/templates/ausschreibungen/entscheidung.html b/vergabe_teilnahme/templates/ausschreibungen/entscheidung.html new file mode 100644 index 0000000..85e2de5 --- /dev/null +++ b/vergabe_teilnahme/templates/ausschreibungen/entscheidung.html @@ -0,0 +1,69 @@ +{% extends "base.html" %} +{% load vergabe_tags %} +{% block title %}Teilnahmeentscheidung — {{ ausschreibung.titel }}{% endblock %} +{% block content %} +

Teilnahmeentscheidung

+

{{ ausschreibung.titel }}

+ +{% if ausschlusskriterien_nicht_erfuellbar %} +
+

⚠ Nicht erfüllbare Ausschlusskriterien

+
    + {% for a in ausschlusskriterien_nicht_erfuellbar %} +
  • {{ a.titel }} (Los: {{ a.los|default:"Allgemein" }})
  • + {% endfor %} +
+

Empfehlung: Nichtteilnahme

+
+{% endif %} + +{% if regelergebnis %} +
+

Regelauswertung

+
    + {% for item in regelergebnis %} +
  • + + {{ item.regel.bezeichnung }} + + + + {{ item.begruendung }} + + + {{ item.empfehlung|upper }} + +
  • + {% endfor %} +
+
+{% endif %} + +
+

Entscheidung treffen

+
+ {% csrf_token %} +
+ {% for val, label in ausschreibung.TEILNAHME_CHOICES %} + + {% endfor %} +
+
+ + +
+
+ + Abbrechen +
+
+
+{% endblock %} diff --git a/vergabe_teilnahme/templates/ausschreibungen/form.html b/vergabe_teilnahme/templates/ausschreibungen/form.html new file mode 100644 index 0000000..22336b6 --- /dev/null +++ b/vergabe_teilnahme/templates/ausschreibungen/form.html @@ -0,0 +1,125 @@ +{% extends "base.html" %} +{% block title %}{{ titel }}{% endblock %} +{% block content %} +

{{ titel }}

+ +
+ {% csrf_token %} + {% if historisch %} + + {% endif %} + +
+

Stammdaten

+
+ + {{ form.titel }} + {% if form.titel.errors %}

{{ form.titel.errors.0 }}

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

{{ form.ausschreiber.errors.0 }}

{% endif %} +
+
+ + {{ form.vergabeart }} +
+
+
+
+ + {{ form.vergabeplattform }} +
+
+ + {{ form.vergabenummer }} +
+
+
+ + {{ form.fundstelle_url }} +
+
+ + {{ form.bid_manager }} +
+
+ + {{ form.leistungsbeschreibung }} +
+
+
+ + {{ form.branche }} +
+
+ + {{ form.schlagwoerter }} +
+
+
+ + {{ form.geschaetztes_volumen }} +
+
+ +
+

Fristen

+
+
+ + {{ form.veroeffentlichungsdatum }} +
+
+ + {{ form.bieterfragen_bis }} +
+
+
+
+ + {{ form.abgabe_bis }} +
+
+ + {{ form.bindefrist }} +
+
+
+
+ {{ form.unterlagen_erhalten }} + +
+
+ + {{ form.unterlagen_erhalten_am }} +
+
+
+ + {% if historisch %} +
+

Historische Erfassung

+
+ + {{ form.teilnahmeentscheidung }} +
+
+ + {{ form.entscheidungsbegruendung }} +
+
+ {% endif %} + +
+ + {% if ausschreibung %} + Abbrechen + {% else %} + Abbrechen + {% endif %} +
+
+{% endblock %} diff --git a/vergabe_teilnahme/templates/ausschreibungen/liste.html b/vergabe_teilnahme/templates/ausschreibungen/liste.html new file mode 100644 index 0000000..7661faa --- /dev/null +++ b/vergabe_teilnahme/templates/ausschreibungen/liste.html @@ -0,0 +1,47 @@ +{% extends "base.html" %} +{% load vergabe_tags %} +{% block title %}Ausschreibungen{% endblock %} +{% block content %} +
+

Ausschreibungen

+ + Neue Ausschreibung +
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+ {% include "ausschreibungen/liste_partial.html" %} +
+{% endblock %} diff --git a/vergabe_teilnahme/templates/ausschreibungen/liste_partial.html b/vergabe_teilnahme/templates/ausschreibungen/liste_partial.html new file mode 100644 index 0000000..8329e92 --- /dev/null +++ b/vergabe_teilnahme/templates/ausschreibungen/liste_partial.html @@ -0,0 +1,42 @@ +{% load vergabe_tags %} +{% if ausschreibungen %} +
+ + + + + + + + + + + + {% for a in ausschreibungen %} + + + + + + + + {% endfor %} + +
TitelAusschreiberStatusAbgabeBid Manager
+ + {{ a.titel }} + + {{ a.ausschreiber }} + {% status_badge a.status a.get_status_display %} + + {% if a.abgabe_bis %}{{ a.abgabe_bis|date:"d.m.Y H:i" }}{% else %}—{% endif %} + + {% if a.bid_manager %}{{ a.bid_manager.get_full_name|default:a.bid_manager.username }}{% else %}—{% endif %} +
+
+{% else %} +
+ Keine Ausschreibungen gefunden. + Jetzt anlegen +
+{% endif %} diff --git a/vergabe_teilnahme/templates/ausschreibungen/partials/status_widget.html b/vergabe_teilnahme/templates/ausschreibungen/partials/status_widget.html new file mode 100644 index 0000000..32dfdcb --- /dev/null +++ b/vergabe_teilnahme/templates/ausschreibungen/partials/status_widget.html @@ -0,0 +1,15 @@ +{% load vergabe_tags %} +
+ {% status_badge ausschreibung.status ausschreibung.get_status_display %} + +
diff --git a/vergabe_teilnahme/templates/lose/anforderung_detail.html b/vergabe_teilnahme/templates/lose/anforderung_detail.html new file mode 100644 index 0000000..cba565e --- /dev/null +++ b/vergabe_teilnahme/templates/lose/anforderung_detail.html @@ -0,0 +1,93 @@ +{% extends "base.html" %} +{% load vergabe_tags %} +{% block title %}{{ anforderung.titel }}{% endblock %} +{% block content %} + +
+
+

{{ anforderung.titel }}

+

+ {% if anforderung.los %}Los {{ anforderung.los.losnummer }}{% else %}Allgemein{% endif %} + · {{ ausschreibung.titel }} +

+
+
+ {% include "lose/partials/erfuellungsstatus_widget.html" %} + Bearbeiten +
+
+ +{% if anforderung.ausschlusskriterium and anforderung.erfuellungsstatus == 'nicht_erfuellbar' %} +
+

⚠ Nicht erfüllbares Ausschlusskriterium

+

Diese Anforderung gefährdet die Teilnahmemöglichkeit.

+
+{% endif %} + +
+
+

Details

+
+ {% render_field anforderung "verbindlichkeit" "Verbindlichkeit" %} + {% render_field anforderung "kategorie" "Kategorie" %} + {% render_field anforderung "quelle_im_dokument" "Quelle im Dokument" %} + {% render_field anforderung "zustaendiger" "Zuständiger" %} +
+
+ {% if anforderung.ausschlusskriterium %} + Ausschlusskriterium + {% endif %} + {% if anforderung.bewertungskriterium %} + Bewertungskriterium + {% endif %} + {% if anforderung.nachweis_erforderlich %} + Nachweis erforderlich + {% endif %} +
+
+ + +
+
+

Nachweise

+ +
+
+ {% include "lose/partials/nachweise_liste.html" %} +
+
+
+
+ +{% if anforderung.beschreibung %} +
+

Beschreibung

+

{{ anforderung.beschreibung }}

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

Verbundene Aufgaben ({{ aufgaben.count }})

+ + Aufgabe erstellen +
+ {% if aufgaben %} +
    + {% for a in aufgaben %} +
  • + {{ a.titel }} + {% status_badge a.status a.get_status_display %} +
  • + {% endfor %} +
+ {% else %} +

Keine Aufgaben verknüpft.

+ {% endif %} +
+ +{% endblock %} diff --git a/vergabe_teilnahme/templates/lose/anforderung_form.html b/vergabe_teilnahme/templates/lose/anforderung_form.html new file mode 100644 index 0000000..fe0028c --- /dev/null +++ b/vergabe_teilnahme/templates/lose/anforderung_form.html @@ -0,0 +1,93 @@ +{% extends "base.html" %} +{% block title %}{{ titel }}{% endblock %} +{% block content %} + +

{{ titel }}

+ +
+ {% csrf_token %} + +
+

Allgemein

+ +
+ + {{ form.titel }} + {% if form.titel.errors %}

{{ form.titel.errors.0 }}

{% endif %} +
+ +
+ + {{ form.beschreibung }} +
+ +
+
+ + {{ form.kategorie }} +
+
+ + {{ form.los }} +
+
+ +
+ + {{ form.quelle_im_dokument }} +
+
+ +
+

Verbindlichkeit & Bewertung

+ +
+ +
+ {% for widget in form.verbindlichkeit %} + + {% endfor %} +
+
+ +
+ + + +
+ +
+
+ + {{ form.erfuellungsstatus }} +
+
+ + {{ form.zustaendiger }} +
+
+
+ +
+ + {% if anforderung %} + Abbrechen + {% else %} + Abbrechen + {% endif %} +
+
+ +{% endblock %} diff --git a/vergabe_teilnahme/templates/lose/anforderungen_liste.html b/vergabe_teilnahme/templates/lose/anforderungen_liste.html new file mode 100644 index 0000000..f5b970d --- /dev/null +++ b/vergabe_teilnahme/templates/lose/anforderungen_liste.html @@ -0,0 +1,51 @@ +{% extends "base.html" %} +{% load vergabe_tags %} +{% block title %}Anforderungen — {{ ausschreibung.titel }}{% endblock %} +{% block content %} + +
+

Anforderungen

+ + Anforderung +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ {% include "lose/anforderungen_liste_partial.html" %} +
+ +{% endblock %} diff --git a/vergabe_teilnahme/templates/lose/anforderungen_liste_partial.html b/vergabe_teilnahme/templates/lose/anforderungen_liste_partial.html new file mode 100644 index 0000000..54d77ce --- /dev/null +++ b/vergabe_teilnahme/templates/lose/anforderungen_liste_partial.html @@ -0,0 +1,49 @@ +{% load vergabe_tags %} +{% if grouped %} + {% for los, anforderungen in grouped.items %} +
+ + +
+ + + + + + + + + + + {% for a in anforderungen %} + + + + + + + {% endfor %} + +
TitelVerbindlichkeitStatusZuständig
+ {{ a.titel }} + {% if a.ausschlusskriterium %} + ⚠ AK + {% endif %} + {% status_badge a.verbindlichkeit a.get_verbindlichkeit_display %}{% status_badge a.erfuellungsstatus a.get_erfuellungsstatus_display %}{{ a.zustaendiger|default:"—" }}
+
+
+ {% endfor %} +{% else %} +
+ Keine Anforderungen gefunden. +
+{% endif %} diff --git a/vergabe_teilnahme/templates/lose/aufgabe_erstellen_confirm.html b/vergabe_teilnahme/templates/lose/aufgabe_erstellen_confirm.html new file mode 100644 index 0000000..2448bde --- /dev/null +++ b/vergabe_teilnahme/templates/lose/aufgabe_erstellen_confirm.html @@ -0,0 +1,21 @@ +{% extends "base.html" %} +{% block title %}Aufgabe erstellen{% endblock %} +{% block content %} +
+
+

Aufgabe aus Anforderung erstellen?

+

+ Eine neue Aufgabe vom Typ Fachlich wird erstellt: +

+

+ Klärung: {{ anforderung.titel|truncatechars:200 }} +

+
+ {% csrf_token %} + + Abbrechen +
+
+
+{% endblock %} diff --git a/vergabe_teilnahme/templates/lose/detail.html b/vergabe_teilnahme/templates/lose/detail.html new file mode 100644 index 0000000..98e2c48 --- /dev/null +++ b/vergabe_teilnahme/templates/lose/detail.html @@ -0,0 +1,89 @@ +{% extends "base.html" %} +{% load vergabe_tags %} +{% block title %}{{ los }} — {{ ausschreibung.titel }}{% endblock %} +{% block content %} + +
+
+

{{ los.lostitel }}

+

Los {{ los.losnummer }} · {{ ausschreibung.titel }}

+
+ +
+ +
+
+

Stammdaten

+
+ {% render_field los "losnummer" "Losnummer" %} + {% render_field los "lostitel" "Lostitel" %} + {% render_field los "zustaendiger" "Zuständiger" %} +
+
+ +
+

Teilnahme

+

+ {% if los.teilnahme is None %} + Noch nicht entschieden + {% elif los.teilnahme %} + Teilnahme + {% else %} + Keine Teilnahme + {% endif %} +

+
+
+ +{% if los.beschreibung %} +
+

Beschreibung

+

{{ los.beschreibung }}

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

Abgrenzung

+

{{ los.abgrenzung }}

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

Anforderungen ({{ anforderungen.count }})

+ + Anforderung +
+ {% if anforderungen %} + + + + + + + + + + {% for a in anforderungen %} + + + + + + {% endfor %} + +
TitelVerbindlichkeitStatus
+ {{ a.titel }} + {% status_badge a.verbindlichkeit a.get_verbindlichkeit_display %}{% status_badge a.erfuellungsstatus a.get_erfuellungsstatus_display %}
+ {% else %} +

Noch keine Anforderungen für dieses Los.

+ {% endif %} +
+ +{% endblock %} diff --git a/vergabe_teilnahme/templates/lose/form.html b/vergabe_teilnahme/templates/lose/form.html new file mode 100644 index 0000000..6b92e03 --- /dev/null +++ b/vergabe_teilnahme/templates/lose/form.html @@ -0,0 +1,56 @@ +{% extends "base.html" %} +{% block title %}{{ titel }}{% endblock %} +{% block content %} + +

{{ titel }}

+ +
+ {% csrf_token %} + +
+

Los-Daten

+ +
+ + {{ form.losnummer }} + {% if form.losnummer.errors %}

{{ form.losnummer.errors.0 }}

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

{{ form.lostitel.errors.0 }}

{% endif %} +
+ +
+ + {{ form.beschreibung }} +
+ +
+ + {{ form.abgrenzung }} +
+ +
+ + {{ form.zustaendiger }} +
+ +
+ + {{ form.teilnahme }} +
+
+ +
+ + {% if los %} + Abbrechen + {% else %} + Abbrechen + {% endif %} +
+
+ +{% endblock %} diff --git a/vergabe_teilnahme/templates/lose/liste.html b/vergabe_teilnahme/templates/lose/liste.html new file mode 100644 index 0000000..84ca5bf --- /dev/null +++ b/vergabe_teilnahme/templates/lose/liste.html @@ -0,0 +1,36 @@ +{% extends "base.html" %} +{% load vergabe_tags %} +{% block title %}Lose — {{ ausschreibung.titel }}{% endblock %} +{% block content %} + +
+

Lose

+ + Los hinzufügen +
+ +{% if lose %} +
+ + + + + + + + + + + + {% for los in lose %} + {% include "lose/partials/los_row.html" %} + {% endfor %} + +
Nr.TitelZuständigTeilnahme
+
+{% else %} +
+ Noch keine Lose angelegt. +
+{% endif %} + +{% endblock %} diff --git a/vergabe_teilnahme/templates/lose/loeschen_confirm.html b/vergabe_teilnahme/templates/lose/loeschen_confirm.html new file mode 100644 index 0000000..507e7ab --- /dev/null +++ b/vergabe_teilnahme/templates/lose/loeschen_confirm.html @@ -0,0 +1,17 @@ +{% extends "base.html" %} +{% block title %}Los löschen{% endblock %} +{% block content %} +
+
+

Los löschen?

+

+ {{ los }} und alle zugehörigen Anforderungen werden unwiderruflich gelöscht. +

+
+ {% csrf_token %} + + Abbrechen +
+
+
+{% endblock %} diff --git a/vergabe_teilnahme/templates/lose/partials/erfuellungsstatus_widget.html b/vergabe_teilnahme/templates/lose/partials/erfuellungsstatus_widget.html new file mode 100644 index 0000000..a0b1878 --- /dev/null +++ b/vergabe_teilnahme/templates/lose/partials/erfuellungsstatus_widget.html @@ -0,0 +1,14 @@ +{% load vergabe_tags %} +
+ {% status_badge anforderung.erfuellungsstatus anforderung.get_erfuellungsstatus_display %} + +
diff --git a/vergabe_teilnahme/templates/lose/partials/los_row.html b/vergabe_teilnahme/templates/lose/partials/los_row.html new file mode 100644 index 0000000..b6ef255 --- /dev/null +++ b/vergabe_teilnahme/templates/lose/partials/los_row.html @@ -0,0 +1,22 @@ +{% load vergabe_tags %} + + {{ los.losnummer }} + + {{ los.lostitel }} + + {{ los.zustaendiger|default:"—" }} + + {% if los.teilnahme is None %} + Offen + {% elif los.teilnahme %} + Ja + {% else %} + Nein + {% endif %} + + + Bearbeiten + + diff --git a/vergabe_teilnahme/templates/lose/partials/nachweis_modal.html b/vergabe_teilnahme/templates/lose/partials/nachweis_modal.html new file mode 100644 index 0000000..c14ee22 --- /dev/null +++ b/vergabe_teilnahme/templates/lose/partials/nachweis_modal.html @@ -0,0 +1,53 @@ +{% load vergabe_tags %} +
+
+
+

Nachweis zuordnen

+ +
+ +
+ +
+ + {% if nachweise %} +
    + {% for n in nachweise %} +
  • +
    + {{ n.titel }} + {% if n.gueltig_bis %} + + bis {{ n.gueltig_bis }} + + {% endif %} + {% status_badge n.freigabestatus n.get_freigabestatus_display %} +
    + {% if n.pk in bereits_zugeordnet %} + Bereits zugeordnet + {% else %} +
    + {% csrf_token %} + + +
    + {% endif %} +
  • + {% endfor %} +
+ {% else %} +

Keine Nachweise gefunden.

+ {% endif %} +
+
diff --git a/vergabe_teilnahme/templates/lose/partials/nachweise_liste.html b/vergabe_teilnahme/templates/lose/partials/nachweise_liste.html new file mode 100644 index 0000000..2017142 --- /dev/null +++ b/vergabe_teilnahme/templates/lose/partials/nachweise_liste.html @@ -0,0 +1,29 @@ +{% load vergabe_tags %} +{% with nachweise=anforderung.nachweise.all %} +{% if nachweise %} +
    + {% for n in nachweise %} +
  • +
    + {{ n.titel }} + {% if n.ist_abgelaufen %} + abgelaufen + {% elif n.gueltig_bis %} + bis {{ n.gueltig_bis }} + {% endif %} +
    +
    + {% csrf_token %} + +
    +
  • + {% endfor %} +
+{% else %} +

Keine Nachweise zugeordnet.

+{% endif %} +{% endwith %} diff --git a/workplans/WP-0004-dashboard-ausschreibungen.md b/workplans/WP-0004-dashboard-ausschreibungen.md index d0a0417..1d7b43d 100644 --- a/workplans/WP-0004-dashboard-ausschreibungen.md +++ b/workplans/WP-0004-dashboard-ausschreibungen.md @@ -1,7 +1,7 @@ --- id: WP-0004 title: Dashboard und Ausschreibungen-CRUD -status: todo +status: done phase: 4-of-12 created: "2026-05-08" depends_on: WP-0003 diff --git a/workplans/WP-0005-lose-anforderungen.md b/workplans/WP-0005-lose-anforderungen.md index 7fc3f89..4899e0a 100644 --- a/workplans/WP-0005-lose-anforderungen.md +++ b/workplans/WP-0005-lose-anforderungen.md @@ -1,7 +1,7 @@ --- id: WP-0005 title: Lose und Anforderungen -status: todo +status: done phase: 5-of-12 created: "2026-05-08" depends_on: WP-0004 @@ -19,7 +19,7 @@ Implementiert alle Views, Forms und Templates für Lose (UC-LA-01) und Anforderu ```task id: WP-0005-T01 title: Lose-Liste und Lose anlegen (UC-LA-01) -status: todo +status: done `lose/views.py` — lose_liste und los_neu: @@ -46,7 +46,7 @@ path('/bearbeiten/', views.los_bearbeiten, name='bearbeiten'), ```task id: WP-0005-T02 title: Los-Detail-Seite mit eingebetteten Anforderungen -status: todo +status: done `lose/views.py` — los_detail: ```python @@ -73,7 +73,7 @@ def los_detail(request, ausschreibung_id, los_pk): ```task id: WP-0005-T03 title: Anforderungsliste nach Los gruppiert (UC-LA-02) -status: todo +status: done `lose/views.py` — anforderungen_liste: Lädt alle Anforderungen der Ausschreibung, gruppiert nach Los. @@ -91,7 +91,7 @@ Template `lose/anforderungen_liste.html`: ```task id: WP-0005-T04 title: Anforderung anlegen und Detailseite (UC-LA-02, UC-LA-03) -status: todo +status: done `AnforderungForm(ModelForm)`: alle Felder aus Modell. Besonderer Widget für verbindlichkeit: Radio-Buttons statt Dropdown. @@ -117,7 +117,7 @@ def anforderung_status(request, ausschreibung_id, pk): ```task id: WP-0005-T05 title: Nachweis-Verknüpfung mit Bibliothek (UC-LA-04) -status: todo +status: done `lose/views.py` — nachweis_suche_modal und nachweis_zuordnen: @@ -141,7 +141,7 @@ Zeige zugeordnete Nachweise auf Anforderungsdetail als Liste mit Ablaufstatus-Ba ```task id: WP-0005-T06 title: Ausschlusskriterium-Eskalation auf Phase-2-Seite (UC-LA-05) -status: todo +status: done Ergänze `ausschreibungen/views.py` — ausschreibung_entscheidung: @@ -170,7 +170,7 @@ dann Phase-2-Seite öffnen → Banner erscheint. ```task id: WP-0005-T07 title: Aufgabe aus Anforderung ableiten (UC-AU-02) -status: todo +status: done Auf der Anforderungsdetailseite: Button "Aufgabe erstellen". ```python @@ -197,7 +197,7 @@ Nach Erstellen: Anforderungsdetail zeigt die neue Aufgabe im Abschnitt "Verbunde ```task id: WP-0005-T08 title: Tests für Lose und Anforderungen -status: todo +status: done `lose/tests/test_views.py`: - Test: Lose-Liste gibt 200 zurück