Complete dashboard loose ends and reconcile blocked pilot work
All checks were successful
Application acceptance / application-tests (push) Successful in 1m26s
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Build and Publish Container Image / build-and-push (push) Successful in 34s

Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a0e394-2a69-7df0-8980-22a0ebe55216
This commit is contained in:
tegwick 2026-09-27 18:34:48 +02:00
parent 387b7e9782
commit 35df9bb772
13 changed files with 325 additions and 55 deletions

View file

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

View file

@ -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'}),
)

View file

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

View file

@ -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,

View file

@ -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,

View file

@ -98,5 +98,19 @@
{% endif %}
</div>
<div class="card">
<div class="flex items-center justify-between mb-3">
<h2 class="text-sm font-semibold text-slate-700">Ablaufende Nachweise (60 Tage)</h2>
<span class="text-sm">{{ ablaufende_nachweise|length }}</span>
</div>
{% for n in ablaufende_nachweise %}
<div class="flex items-center justify-between py-1 text-sm">
<a href="{% url 'bibliothek:nachweis_detail' pk=n.pk %}" class="text-brand-700 hover:underline">{{ n.titel }}</a>
<span class="{% if n.gueltig_bis < today %}text-red-600{% else %}text-amber-600{% endif %}">{{ n.gueltig_bis|date:"d.m.Y" }}</span>
</div>
{% empty %}
<p class="text-slate-400 text-sm">Keine ablaufenden Nachweise.</p>
{% endfor %}
</div>
</div>
{% endblock %}

View file

@ -119,6 +119,11 @@
<label class="form-label">Teilnahmeentscheidung</label>
{{ form.teilnahmeentscheidung }}
</div>
<div>
<label class="form-label" for="id_ergebnis">Ergebnis</label>
{{ form.ergebnis }}
{{ form.ergebnis.errors }}
</div>
<div>
<label class="form-label">Begründung</label>
{{ form.entscheidungsbegruendung }}

View file

@ -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">
<div>
<label for="tender-search" class="form-label">Suche</label>
<input id="tender-search" type="search" name="q" value="{{ q }}" class="form-input" placeholder="Titel oder Ausschreiber">
</div>
<div>
<label class="form-label">Status</label>
<select name="status" class="form-input">

View file

@ -8,7 +8,7 @@
<link rel="stylesheet" href="{% static 'dist/main.css' %}">
<script src="{% static 'vendor/alpinejs/alpine.min.js' %}" defer></script>
</head>
<body class="bg-paper-2 min-h-screen text-ink">
<body class="bg-paper-2 min-h-screen text-ink" hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'>
{% include "partials/topbar.html" %}
<div class="flex h-[calc(100vh-56px)]">

View file

@ -15,6 +15,19 @@
{% endfor %}
{% endif %}
{% if aufgaben %}
<div class="px-3 py-1 text-xs font-medium text-slate-400 uppercase tracking-wide">Aufgaben</div>
{% for a in aufgaben %}
<a href="{% url 'ausschreibungen:aufgaben:detail' ausschreibung_id=a.ausschreibung_id pk=a.pk %}" class="block px-4 py-2 text-sm text-slate-700 hover:bg-slate-50">{{ a.titel }}</a>
{% endfor %}
{% endif %}
{% if subunternehmer %}
<div class="px-3 py-1 text-xs font-medium text-slate-400 uppercase tracking-wide">Subunternehmer</div>
{% for s in subunternehmer %}
<a href="{% url 'partner:su_detail' pk=s.pk %}" class="block px-4 py-2 text-sm text-slate-700 hover:bg-slate-50">{{ s.name }}</a>
{% endfor %}
{% endif %}
{% if dokumente %}
<div class="px-3 py-1 text-xs font-medium text-slate-400 uppercase tracking-wide mt-1">Dokumente</div>
{% for d in dokumente %}

View file

@ -9,7 +9,7 @@ flavor: implementation
owner: the-custodian
topic_slug: vergabe-teilnahme
created: "2026-09-08"
updated: "2026-09-11"
updated: "2026-09-27"
related: [HFACT-WP-0001, REUSE-WP-0022]
state_hub_workstream_id: "27a95f7b-cec4-50ca-8383-c1c95d996217"
---
@ -63,7 +63,7 @@ status: wait
priority: high
assignee: the-custodian
depends_on: [VERGABE-WP-0018-T02, HFACT-WP-0001-T05]
blocking_reason: "Image main-fa9f082 is tested and published. Await HFACT-WP-0001-T05 governed worker evidence plus exact Railiance tenant, deployment, database, storage, access and recovery acceptance."
blocking_reason: "Await HFACT-WP-0001-T05 natural governed Railiance worker trace and VERGABE-WP-0019 complete invited-user/recovery acceptance. Pilot placement and one sign-in are already evidenced; main-fa9f082 is superseded."
state_hub_task_id: "6e428152-aad6-5184-8b1d-8860ec0b9ae6"
```
@ -118,3 +118,14 @@ worker dependency. This record continues to own factory-produced delivery
acceptance, so a manually prepared customer pilot cannot falsely close HFACT
worker proof. The old main-fa9f082 image predates the required login gate and
must not be used as the invited-pilot release merely because its CI passed.
## Loose-end review — 2026-09-27
HFACT-WP-0001-T05 remains wait in prj-helixforge-factory: protected runtime and
synthetic proof exist, but upstream admission and natural queue/model execution
are outstanding. The September 12 pilot deployment, September 24 founder login
and September 25 database restore supersede the old missing-placement summary.
They do not close governed-worker proof, two-user product acceptance, or coherent
off-host recovery. T03 and this workplan remain blocked on those existing owners;
no duplicate tasks were opened.

View file

@ -4,12 +4,12 @@ type: workplan
title: "Admit the first invited company pilot with protected access and recoverable data"
domain: communication
repo: vergabe-teilnahme
status: active
status: blocked
flavor: implementation
owner: the-custodian
topic_slug: vergabe-teilnahme
created: "2026-09-11"
updated: "2026-09-25"
updated: "2026-09-27"
related: [VERGABE-WP-0018, RAPPS-WP-0014, HFACT-WP-0001, CUST-WP-0071]
state_hub_workstream_id: "85b5f304-d497-5570-bebf-3a3669ef6a7d"
---
@ -68,7 +68,8 @@ Ruff. Source acceptance is not native customer admission.
```task
id: VERGABE-WP-0019-T03
status: progress
status: wait
blocking_reason: "Await RAPPS-WP-0014-T03 coherent PostgreSQL/media/issue-state recovery, populated document round-trip and off-host backup evidence; September 25 proves database restore and restart only."
priority: high
assignee: the-custodian
depends_on: [VERGABE-WP-0019-T02, RAPPS-WP-0014-T02, RAPPS-WP-0014-T03]
@ -207,7 +208,8 @@ preceding service-login failure. It does not create a Django account or session.
```task
id: VERGABE-WP-0019-T06
status: progress
status: wait
blocking_reason: "Source and live SSO/recovery are delivered, and one founder sign-in passed September 24. Await invited recipient setup-to-welcome-to-workflow acceptance and a second ordinary user."
priority: high
assignee: the-custodian
state_hub_task_id: "455b33f9-163a-5389-86a3-04000c32d176"
@ -307,3 +309,18 @@ checks and six fresh anonymous Chromium checks pass, including actual provider
logout POST and return to the portal without test overrides. Real-user identity
switching is still awaiting operator evidence; no authenticated/MFA acceptance
is inferred. Detailed receipt: railiance-apps/docs/evidence/2026-09-12-account-recovery-live.md.
## Loose-end review — 2026-09-27
No further product implementation is identified in T03/T04/T06. T03 awaits
railiance-apps recovery evidence for all three stores and an off-host copy;
the September 25 age artifact stayed on the workstation and the two file
claims were empty. A database restore does not close that contract. T04 needs
Bernd Worsch and the company contact to demonstrate two ordinary users,
document/task collaboration, revocation, restored workflow and support handoff.
T06's September 24 founder login resolves the old issuer-login blocker, but
cannot establish a fresh invited recipient's complete setup-to-company journey.
Both previously progressing tasks now wait; the workplan is blocked. No new
workplan or task was opened, and no live deployment or user impersonation was
performed by this review.

View file

@ -5,6 +5,7 @@ title: Dashboard und Ausschreibungen-CRUD
status: finished
phase: 4-of-12
created: "2026-05-08"
updated: "2026-09-27"
depends_on: WP-0003
domain: communication
repo: vergabe-teilnahme
@ -21,10 +22,13 @@ Entscheidungsregel-Auswertung, Archivierung und historische Erfassung.
---
## Dashboard-View mit Kacheln und Fristenliste
```task
id: VT-WP-0004-T01
title: Dashboard-View mit Kacheln und Fristenliste
status: todo
status: done
priority: medium
```
`ausschreibungen/views.py` — Dashboard-View:
```python
@ -67,12 +71,14 @@ Jede Kachel: Überschrift, Anzahl-Badge, Liste der Einträge mit Direktlinks.
Nutze `.card`-Klasse, `status_badge`-Tag und relative Fristangaben (z. B. "in 3 Tagen").
Ablaufende Nachweise: Nachweis-Modell aus Bibliothek mit `gueltig_bis ≤ heute + 60 Tage`.
```
## Ausschreibungsliste mit Filter und HTMX-Suche
```task
id: VT-WP-0004-T02
title: Ausschreibungsliste mit Filter und HTMX-Suche
status: todo
status: done
priority: medium
```
`ausschreibungen/views.py` — ListView:
```python
@ -107,12 +113,14 @@ Alle Filter-Änderungen: `hx-get="/ausschreibungen/" hx-target="#ausschreibungen
Tabelle: Titel, Ausschreiber, Status (status_badge), Abgabefrist (farbig wenn < 14 Tage),
Verantwortlicher, Link zum Detail.
```
## Ausschreibung anlegen — Form und View (UC-AS-01)
```task
id: VT-WP-0004-T03
title: Ausschreibung anlegen — Form und View (UC-AS-01)
status: todo
status: done
priority: medium
```
`ausschreibungen/forms.py`:
```python
@ -158,12 +166,14 @@ def ausschreibung_neu(request):
`ausschreibungen/form.html` — einfaches, gut gelayoutetes Formular.
Sections: Stammdaten, Fristen. Alle Felder nutzen `form-input` und `form-label`.
Submit: "Speichern" (btn-primary), "Abbrechen" (btn-ghost, zurück zur Liste).
```
## Ausschreibung-Detailseite (Phase 1 — Stammdaten)
```task
id: VT-WP-0004-T04
title: Ausschreibung-Detailseite (Phase 1 — Stammdaten)
status: todo
status: done
priority: medium
```
`ausschreibungen/views.py` — Detailview:
```python
@ -192,12 +202,14 @@ def ausschreibung_detail(request, pk):
- Tab-Navigation zu Unterseiten (Lose, Anforderungen, Aufgaben, Bieterfragen, Preise, Abgabe, Nachbetrachtung)
als horizontale Link-Leiste unterhalb des Titels
- "Weitere Attribute" CustomAttribute-Panel (HTMX lazy-load, Implementierung in WP-0012)
```
## Ausschreibung bearbeiten (Edit-View) und Status inline wechseln
```task
id: VT-WP-0004-T05
title: Ausschreibung bearbeiten (Edit-View) und Status inline wechseln
status: todo
status: done
priority: medium
```
`ausschreibungen/views.py`:
@ -236,12 +248,14 @@ def ausschreibung_status(request, pk):
</select>
</div>
```
```
## 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.