diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index 670a241..44a1d20 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -9,7 +9,7 @@ | Kind | ID | Status | Lane | Source | | --- | --- | --- | --- | --- | | workplan | VERGABE-WP-0018 | blocked | — | workplans/VERGABE-WP-0018-customer-factory-delivery.md | -| workplan | VERGABE-WP-0019 | active | — | workplans/VERGABE-WP-0019-invited-company-pilot.md | +| workplan | VERGABE-WP-0019 | blocked | — | workplans/VERGABE-WP-0019-invited-company-pilot.md | | workplan | VT-WP-0001 | finished | — | workplans/VT-WP-0001-projektgeruest.md | | workplan | VT-WP-0002 | finished | — | workplans/VT-WP-0002-fachmodelle.md | | workplan | VT-WP-0003 | finished | — | workplans/VT-WP-0003-basis-ui.md | @@ -32,10 +32,10 @@ | task | VERGABE-WP-0018-T03 | wait | — | workplans/VERGABE-WP-0018-customer-factory-delivery.md | | task | VERGABE-WP-0019-T01 | done | — | workplans/VERGABE-WP-0019-invited-company-pilot.md | | task | VERGABE-WP-0019-T02 | done | — | workplans/VERGABE-WP-0019-invited-company-pilot.md | -| task | VERGABE-WP-0019-T03 | progress | — | workplans/VERGABE-WP-0019-invited-company-pilot.md | +| task | VERGABE-WP-0019-T03 | wait | — | workplans/VERGABE-WP-0019-invited-company-pilot.md | | task | VERGABE-WP-0019-T04 | wait | — | workplans/VERGABE-WP-0019-invited-company-pilot.md | | task | VERGABE-WP-0019-T05 | done | — | workplans/VERGABE-WP-0019-invited-company-pilot.md | -| task | VERGABE-WP-0019-T06 | progress | — | workplans/VERGABE-WP-0019-invited-company-pilot.md | +| task | VERGABE-WP-0019-T06 | wait | — | workplans/VERGABE-WP-0019-invited-company-pilot.md | | task | VT-WP-0001-T03 | done | — | workplans/VT-WP-0001-projektgeruest.md | | task | VT-WP-0001-T05 | done | — | workplans/VT-WP-0001-projektgeruest.md | | task | VT-WP-0001-T07 | done | — | workplans/VT-WP-0001-projektgeruest.md | @@ -45,6 +45,18 @@ | task | VT-WP-0002-T13 | done | — | workplans/VT-WP-0002-fachmodelle.md | | task | VT-WP-0003-T01 | done | — | workplans/VT-WP-0003-basis-ui.md | | task | VT-WP-0003-T10 | done | — | workplans/VT-WP-0003-basis-ui.md | +| task | VT-WP-0004-T01 | done | — | workplans/VT-WP-0004-dashboard-ausschreibungen.md | +| task | VT-WP-0004-T02 | done | — | workplans/VT-WP-0004-dashboard-ausschreibungen.md | +| task | VT-WP-0004-T03 | done | — | workplans/VT-WP-0004-dashboard-ausschreibungen.md | +| task | VT-WP-0004-T04 | done | — | workplans/VT-WP-0004-dashboard-ausschreibungen.md | +| task | VT-WP-0004-T05 | done | — | workplans/VT-WP-0004-dashboard-ausschreibungen.md | +| task | VT-WP-0004-T06 | done | — | workplans/VT-WP-0004-dashboard-ausschreibungen.md | +| task | VT-WP-0004-T07 | done | — | workplans/VT-WP-0004-dashboard-ausschreibungen.md | +| task | VT-WP-0004-T08 | done | — | workplans/VT-WP-0004-dashboard-ausschreibungen.md | +| task | VT-WP-0004-T09 | done | — | workplans/VT-WP-0004-dashboard-ausschreibungen.md | +| task | VT-WP-0004-T10 | done | — | workplans/VT-WP-0004-dashboard-ausschreibungen.md | +| task | VT-WP-0004-T11 | done | — | workplans/VT-WP-0004-dashboard-ausschreibungen.md | +| task | VT-WP-0004-T12 | done | — | workplans/VT-WP-0004-dashboard-ausschreibungen.md | | task | VT-WP-0005-T07 | done | — | workplans/VT-WP-0005-lose-anforderungen.md | | task | VT-WP-0012-T06 | done | — | workplans/VT-WP-0012-querschnitt.md | | task | VT-WP-0015-T03 | done | — | workplans/VT-WP-0015-aufgaben-verknuepfungen-frist-issuefacade.md | diff --git a/vergabe_teilnahme/apps/ausschreibungen/forms.py b/vergabe_teilnahme/apps/ausschreibungen/forms.py index b9a95da..a661ba3 100644 --- a/vergabe_teilnahme/apps/ausschreibungen/forms.py +++ b/vergabe_teilnahme/apps/ausschreibungen/forms.py @@ -38,6 +38,13 @@ class AusschreibungForm(forms.ModelForm): 'entscheidungsbegruendung': forms.Textarea(attrs={'class': 'form-input', 'rows': 3}), } - def __init__(self, *args, **kwargs): + def __init__(self, *args, historisch=False, **kwargs): super().__init__(*args, **kwargs) self.fields['teilnahmeentscheidung'].required = False + if historisch: + from vergabe_teilnahme.apps.nachbetrachtung.models import Nachbetrachtung + + self.fields['ergebnis'] = forms.ChoiceField( + choices=Nachbetrachtung.ERGEBNIS_CHOICES, initial='offen', + widget=forms.Select(attrs={'class': 'form-input'}), + ) diff --git a/vergabe_teilnahme/apps/ausschreibungen/tests.py b/vergabe_teilnahme/apps/ausschreibungen/tests.py index c3a7ad2..e0e6d36 100644 --- a/vergabe_teilnahme/apps/ausschreibungen/tests.py +++ b/vergabe_teilnahme/apps/ausschreibungen/tests.py @@ -98,3 +98,93 @@ def test_status_htmx_returns_partial(admitted_client): response = admitted_client.post(url, {"status": "3"}, HTTP_HX_REQUEST="true") assert response.status_code == 200 assert b"status-widget" in response.content + + +@pytest.mark.django_db +def test_dashboard_expiry_boundary(admitted_client): + from datetime import date, timedelta + + from vergabe_teilnahme.apps.bibliothek.models import Nachweis + + for title, days in [('Expired', -1), ('Boundary', 60), ('Later', 61)]: + Nachweis.objects.create(titel=title, gueltig_bis=date.today() + timedelta(days=days)) + Nachweis.objects.create(titel='Undated') + response = admitted_client.get(reverse('ausschreibungen:dashboard')) + assert response.status_code == 200 + assert [n.titel for n in response.context['ablaufende_nachweise']] == ['Expired', 'Boundary'] + assert 'Boundary' in response.content.decode() + + +@pytest.mark.django_db +def test_list_search_combines_filters_and_htmx(admitted_client): + matching = AusschreibungFactory(titel='Network upgrade', ausschreiber='City', status=4) + AusschreibungFactory(titel='Other', ausschreiber='City', status=3) + AusschreibungFactory(titel='Archived', ausschreiber='City', status=4, archiviert=True) + for query in ['Network', 'City']: + response = admitted_client.get(reverse('ausschreibungen:liste'), + {'q': query, 'status': '4'}, HTTP_HX_REQUEST='true') + assert list(response.context['ausschreibungen']) == [matching] + assert 'ausschreibungen/liste_partial.html' in [t.name for t in response.templates] + + +@pytest.mark.django_db +def test_historical_result_validation_and_save(admitted_client): + url = reverse('ausschreibungen:neu') + response = admitted_client.get(url, {'historisch': '1'}) + assert 'name="ergebnis"' in response.content.decode() + data = {'titel': 'Historical award', 'ausschreiber': 'City', + 'historisch_erfassen': '1', 'teilnahmeentscheidung': 'teilnahme', + 'ergebnis': 'invalid'} + response = admitted_client.post(url, data) + assert response.status_code == 200 + assert response.context['form'].errors['ergebnis'] + assert not Ausschreibung.objects.filter(titel=data['titel']).exists() + data['ergebnis'] = 'gewonnen' + response = admitted_client.post(url, data) + assert response.status_code == 302 + tender = Ausschreibung.objects.get(titel=data['titel']) + assert tender.nachbetrachtung.ergebnis == 'gewonnen' + assert tender.status == 10 + assert tender.teilnahmeentscheidung == 'teilnahme' + + +@pytest.mark.django_db +def test_global_search_tasks_partners_and_issuer(admitted_client): + from vergabe_teilnahme.apps.aufgaben.models import Aufgabe + from vergabe_teilnahme.apps.partner.models import Subunternehmer + + tender = AusschreibungFactory(titel='Tender', ausschreiber='UniqueIssuer') + task = Aufgabe.objects.create(titel='UniqueTask', ausschreibung=tender) + partner = Subunternehmer.objects.create(name='UniquePartner') + cases = [ + ('UniqueIssuer', reverse('ausschreibungen:detail', kwargs={'pk': tender.pk})), + ('UniqueTask', reverse('ausschreibungen:aufgaben:detail', + kwargs={'ausschreibung_id': tender.pk, 'pk': task.pk})), + ('UniquePartner', reverse('partner:su_detail', kwargs={'pk': partner.pk})), + ] + for query, target in cases: + response = admitted_client.get('/suche/', {'q': query}) + assert response.status_code == 200 + assert f'href="{target}"' in response.content.decode() + + + +@pytest.mark.django_db +def test_inline_status_with_rendered_csrf_header(mitarbeiter): + import json + import re + + from django.test import Client + + client = Client(enforce_csrf_checks=True) + client.force_login(mitarbeiter) + tender = AusschreibungFactory(status=1) + response = client.get(reverse('ausschreibungen:detail', kwargs={'pk': tender.pk})) + headers = json.loads(re.search(r"hx-headers='([^']+)'", response.content.decode())[1]) + url = reverse('ausschreibungen:status', kwargs={'pk': tender.pk}) + assert client.post(url, {'status': '4'}).status_code == 403 + response = client.post(url, {'status': '4'}, HTTP_HX_REQUEST='true', + HTTP_X_CSRFTOKEN=headers['X-CSRFToken']) + assert response.status_code == 200 + tender.refresh_from_db() + assert tender.status == 4 diff --git a/vergabe_teilnahme/apps/ausschreibungen/views.py b/vergabe_teilnahme/apps/ausschreibungen/views.py index f6e74e8..90816a7 100644 --- a/vergabe_teilnahme/apps/ausschreibungen/views.py +++ b/vergabe_teilnahme/apps/ausschreibungen/views.py @@ -1,5 +1,7 @@ from datetime import date, timedelta +from django.db import transaction +from django.db.models import Q from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse from django.utils import timezone @@ -15,6 +17,7 @@ def _is_htmx(request): def dashboard(request): from vergabe_teilnahme.apps.aufgaben.models import Aufgabe + from vergabe_teilnahme.apps.bibliothek.models import Nachweis heute = date.today() in_14_tagen = heute + timedelta(days=14) @@ -41,6 +44,10 @@ def dashboard(request): status__range=(1, 9), ).order_by('-geaendert_am')[:10], + 'ablaufende_nachweise': Nachweis.objects.filter( + gueltig_bis__lte=heute + timedelta(days=60), + ).order_by('gueltig_bis', 'pk'), + 'today': heute, 'breadcrumbs': [{'label': 'Übersicht', 'url': None}], } return render(request, 'ausschreibungen/dashboard.html', ctx) @@ -49,6 +56,10 @@ def dashboard(request): def ausschreibung_liste(request): qs = Ausschreibung.objects.all() + q = request.GET.get('q', '').strip() + if q: + qs = qs.filter(Q(titel__icontains=q) | Q(ausschreiber__icontains=q)) + status_filter = request.GET.get('status') if status_filter: qs = qs.filter(status=status_filter) @@ -60,7 +71,7 @@ def ausschreibung_liste(request): if bid_manager_filter: qs = qs.filter(bid_manager=bid_manager_filter) - from django.db.models import Count, Q + from django.db.models import Count qs = qs.select_related('bid_manager').annotate( aufgaben_total=Count('aufgaben', distinct=True), aufgaben_erledigt=Count( @@ -75,6 +86,7 @@ def ausschreibung_liste(request): 'status_choices': Ausschreibung.STATUS_CHOICES, 'mitarbeiter': Mitarbeiter.objects.all(), 'archiviert': archiviert, + 'q': q, 'current_status': status_filter or '', 'current_bid_manager': bid_manager_filter or '', 'breadcrumbs': [{'label': 'Ausschreibungen', 'url': None}], @@ -88,14 +100,28 @@ def ausschreibung_liste(request): def ausschreibung_neu(request): from .forms import AusschreibungForm - historisch = request.GET.get('historisch') == '1' + historisch = (request.GET.get('historisch') == '1' + or request.POST.get('historisch_erfassen') == '1') if request.method == 'POST': - form = AusschreibungForm(request.POST) + form = AusschreibungForm(request.POST, historisch=historisch) if form.is_valid(): - a = form.save() + with transaction.atomic(): + a = form.save() + if historisch: + from vergabe_teilnahme.apps.nachbetrachtung.models import Nachbetrachtung + + ergebnis = form.cleaned_data["ergebnis"] + Nachbetrachtung.objects.create(ausschreibung=a, ergebnis=ergebnis) + result_status = { + "gewonnen": 10, "verloren": 11, + "aufgehoben": 12, "zurueckgezogen": 13, + } + if ergebnis in result_status: + a.status = result_status[ergebnis] + a.save(update_fields=["status"]) return redirect('ausschreibungen:detail', pk=a.pk) else: - form = AusschreibungForm() + form = AusschreibungForm(historisch=historisch) return render(request, 'ausschreibungen/form.html', { 'form': form, diff --git a/vergabe_teilnahme/apps/core/views.py b/vergabe_teilnahme/apps/core/views.py index b880bba..b4ae25b 100644 --- a/vergabe_teilnahme/apps/core/views.py +++ b/vergabe_teilnahme/apps/core/views.py @@ -200,23 +200,35 @@ def suche(request): q = request.GET.get('q', '').strip() if not q or len(q) < 2: return HttpResponse('') + from django.db.models import Q + + from vergabe_teilnahme.apps.aufgaben.models import Aufgabe from vergabe_teilnahme.apps.ausschreibungen.models import Ausschreibung from vergabe_teilnahme.apps.bibliothek.models import Nachweis, Referenz from vergabe_teilnahme.apps.dokumente.models import Dokument from vergabe_teilnahme.apps.marktbegleiter.models import Marktbegleiter + from vergabe_teilnahme.apps.partner.models import Subunternehmer - ausschreibungen = Ausschreibung.objects.filter(titel__icontains=q)[:5] + ausschreibungen = Ausschreibung.objects.filter( + Q(titel__icontains=q) | Q(ausschreiber__icontains=q) + )[:5] + aufgaben = Aufgabe.objects.filter(titel__icontains=q)[:5] + subunternehmer = Subunternehmer.objects.filter(name__icontains=q)[:5] dokumente = Dokument.objects.filter(dateiname__icontains=q).select_related('ausschreibung')[:5] nachweise = Nachweis.objects.filter(titel__icontains=q)[:5] referenzen = Referenz.objects.filter(referenztitel__icontains=q)[:5] marktbegleiter = Marktbegleiter.objects.filter(name__icontains=q)[:5] - has_results = any([ausschreibungen, dokumente, nachweise, referenzen, marktbegleiter]) + has_results = any([ + ausschreibungen, aufgaben, subunternehmer, dokumente, nachweise, referenzen, marktbegleiter, + ]) if not has_results: return render(request, 'partials/search_results.html', {'q': q, 'empty': True}) return render(request, 'partials/search_results.html', { 'q': q, + 'aufgaben': aufgaben, + 'subunternehmer': subunternehmer, 'ausschreibungen': ausschreibungen, 'dokumente': dokumente, 'nachweise': nachweise, diff --git a/vergabe_teilnahme/templates/ausschreibungen/dashboard.html b/vergabe_teilnahme/templates/ausschreibungen/dashboard.html index aaa2edc..06f63a6 100644 --- a/vergabe_teilnahme/templates/ausschreibungen/dashboard.html +++ b/vergabe_teilnahme/templates/ausschreibungen/dashboard.html @@ -98,5 +98,19 @@ {% endif %} +
+
+

Ablaufende Nachweise (60 Tage)

+ {{ ablaufende_nachweise|length }} +
+ {% for n in ablaufende_nachweise %} +
+ {{ n.titel }} + {{ n.gueltig_bis|date:"d.m.Y" }} +
+ {% empty %} +

Keine ablaufenden Nachweise.

+ {% endfor %} +
{% endblock %} diff --git a/vergabe_teilnahme/templates/ausschreibungen/form.html b/vergabe_teilnahme/templates/ausschreibungen/form.html index 5c407e8..b5b67cb 100644 --- a/vergabe_teilnahme/templates/ausschreibungen/form.html +++ b/vergabe_teilnahme/templates/ausschreibungen/form.html @@ -119,6 +119,11 @@ {{ form.teilnahmeentscheidung }} +
+ + {{ form.ergebnis }} + {{ form.ergebnis.errors }} +
{{ form.entscheidungsbegruendung }} diff --git a/vergabe_teilnahme/templates/ausschreibungen/liste.html b/vergabe_teilnahme/templates/ausschreibungen/liste.html index 7661faa..32ef704 100644 --- a/vergabe_teilnahme/templates/ausschreibungen/liste.html +++ b/vergabe_teilnahme/templates/ausschreibungen/liste.html @@ -13,8 +13,12 @@ hx-get="{% url 'ausschreibungen:liste' %}" hx-target="#ausschreibungen-table" hx-push-url="true" - hx-trigger="change from:select, change from:input[type=checkbox]" + hx-trigger="change from:select, change from:input[type=checkbox], input changed delay:300ms from:input[type=search]" class="flex flex-wrap gap-3 items-end"> +
+ + +
``` -``` + +## Teilnahmeentscheidung-Seite (Phase 2, UC-AS-04) ```task id: VT-WP-0004-T06 -title: Teilnahmeentscheidung-Seite (Phase 2, UC-AS-04) -status: todo +status: done +priority: medium +``` `ausschreibungen/views.py` — Teilnahmeentscheidungs-View: ```python @@ -272,12 +286,14 @@ def ausschreibung_entscheidung(request, pk): - Zeigt Regelergebnis aus dem Katalog als strukturierte Liste - Formular: Radio-Buttons für Teilnahme/Nichtteilnahme/Weitere Prüfung, Begründungsfeld - "Freigabe erteilen"-Button (öffnet Freigabe-Modal, Implementierung in WP-0012) -``` + +## Entscheidungsregel-Auswertungs-Service ```task id: VT-WP-0004-T07 -title: Entscheidungsregel-Auswertungs-Service -status: todo +status: done +priority: medium +``` `vergabe_teilnahme/apps/ausschreibungen/services.py`: @@ -321,12 +337,14 @@ def _wende_regel_an(regel, ausschreibung): 'begruendung': f'Restlaufzeit {delta} Tage unter Schwellenwert.'} return {'empfehlung': 'pruefen', 'begruendung': regel.begruendung or '—'} ``` -``` + +## Ausschreibung archivieren und historisch erfassen (UC-AS-06, UC-AS-07) ```task id: VT-WP-0004-T08 -title: Ausschreibung archivieren und historisch erfassen (UC-AS-06, UC-AS-07) -status: todo +status: done +priority: medium +``` **Archivieren:** ```python @@ -351,12 +369,14 @@ zugänglich — keine Einschränkung. URL für historische Erfassung: `/ausschreibungen/neu/?historisch=1` Die View prüft diesen Parameter und setzt `historisch_erfassen` im initialen Form-Context. -``` + +## Globale Suchleiste — HTMX-Endpunkt und Ergebnis-Template ```task id: VT-WP-0004-T09 -title: Globale Suchleiste — HTMX-Endpunkt und Ergebnis-Template -status: todo +status: done +priority: medium +``` `core/views.py`: ```python @@ -397,12 +417,14 @@ def global_search(request): URL: `path('suche/', core_views.global_search, name='global_search')` Topbar-Formular (aus WP-0003-T02) zeigt Ergebnisse in `#search-results`. -``` + +## Ausschreibungen-URL-Verkabelung und App-Namespace ```task id: VT-WP-0004-T10 -title: Ausschreibungen-URL-Verkabelung und App-Namespace -status: todo +status: done +priority: medium +``` `vergabe_teilnahme/apps/ausschreibungen/urls.py`: ```python @@ -436,12 +458,14 @@ Jede referenzierte App-URL-Datei wird hier als leere Stub-Datei angelegt Prüfe: `uv run manage.py check --deploy` → keine URL-Fehler. Smoke-Test: alle Hauptseiten (/ausschreibungen/, /ausschreibungen/neu/) laden ohne 500. -``` + +## Ausschreibungs-Tests (Models und Views) ```task id: VT-WP-0004-T11 -title: Ausschreibungs-Tests (Models und Views) -status: todo +status: done +priority: medium +``` Erstelle `vergabe_teilnahme/apps/ausschreibungen/tests/`: @@ -468,12 +492,14 @@ class AusschreibungFactory(factory.django.DjangoModelFactory): ausschreiber = "Testausschreiber GmbH" status = 1 ``` -``` + +## Seed-Daten prüfen und Dashboard-Kacheln verifizieren ```task id: VT-WP-0004-T12 -title: Seed-Daten prüfen und Dashboard-Kacheln verifizieren -status: todo +status: done +priority: medium +``` Führe die gesamte Integrations-Smoke-Test-Sequenz durch: @@ -493,4 +519,37 @@ Führe die gesamte Integrations-Smoke-Test-Sequenz durch: 10. `uv run pytest vergabe_teilnahme/apps/ausschreibungen/` → alle Tests grün Erst wenn alle 10 Punkte erfüllt sind: Task als done markieren. -``` + +## Completion review — 2026-09-27 + +The finished plan retained twelve stale todo blocks with nested Markdown fences. +Reconciled the existing task IDs into one valid task block per section; no new +tasks were created. T03–T07 and T10–T11 already had application implementations +and model/view acceptance. T01 now includes the missing 60-day evidence-expiry +card (including expired evidence). T02 now searches title and issuing authority +alongside status/manager/archive filters with HTMX. T08 now validates and saves +a historical result atomically in the existing Nachbetrachtung model and sets +the corresponding terminal tender status. T09 now includes issuing authority, +tasks and partners alongside the already implemented search categories. + +The old illustrative fields are superseded by the current model: bid_manager +replaces hauptverantwortung, and ergebnis belongs to Nachbetrachtung. Historical +entry uses those models without a schema change. Existing approval, decision, +archive, detail and URL implementations remain in place. + +Validation: all 131 application tests pass on disposable SQLite; all 20 tender +model/view tests also pass on disposable PostgreSQL 16. Vite build, Django system +check and migration-drift check pass. Changed Python passes Ruff excluding the +pre-existing E501 long lines. Local tests retain the pre-existing naive-datetime +fixture and uncollected-static-directory warnings. T12 uses freshly migrated/seeded disposable PostgreSQL and local +Chromium for dashboard/navigation, tender list/detail, HTMX search/status and +historical creation. This is local source acceptance, not pilot-user evidence. + + +The final T12 server-log review exposed a missing CSRF header on inline status +POSTs. T05 now inherits the rendered Django CSRF token through base.html's +HTMX headers. A CSRF-enforcing regression proves missing-token rejection and +valid-token mutation. The Chromium check now changes to a different status, +requires HTTP 200 for the POST, and checks persistence after reload; an unchanged +seeded value is not accepted as proof. This supersedes the initial browser +script's false-positive status assertion.