All checks were successful
Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a0e394-2a69-7df0-8980-22a0ebe55216
555 lines
21 KiB
Markdown
555 lines
21 KiB
Markdown
---
|
||
id: VT-WP-0004
|
||
type: workplan
|
||
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
|
||
state_hub_workstream_id: "0cc0da42-a90a-5c30-9789-35d855f217b4"
|
||
---
|
||
|
||
# WP-0004 — Dashboard und Ausschreibungen-CRUD
|
||
|
||
Vollständige Implementierung des Dashboards und aller Ausschreibungs-Views:
|
||
Liste, Suche/Filter, Anlegen, Detailseite, Status-Wechsel (HTMX), Teilnahmeentscheidung,
|
||
Entscheidungsregel-Auswertung, Archivierung und historische Erfassung.
|
||
|
||
**Referenz:** UseCaseCatalog UC-OV-01 bis UC-OV-03, UC-AS-01 bis UC-AS-07.
|
||
|
||
---
|
||
|
||
## Dashboard-View mit Kacheln und Fristenliste
|
||
|
||
```task
|
||
id: VT-WP-0004-T01
|
||
status: done
|
||
priority: medium
|
||
```
|
||
|
||
`ausschreibungen/views.py` — Dashboard-View:
|
||
```python
|
||
def dashboard(request):
|
||
from vergabe_teilnahme.apps.core.services import get_deadline_warnings
|
||
from vergabe_teilnahme.apps.aufgaben.models import Aufgabe
|
||
from datetime import date, timedelta
|
||
|
||
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(
|
||
status__in=[1, 2],
|
||
erstellt_am__lte=timezone.now() - timedelta(days=3)
|
||
).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', 'verantwortlicher').order_by('frist')[:15],
|
||
|
||
'laufende_ausschreibungen': Ausschreibung.objects.filter(
|
||
status__range=(3, 9)
|
||
).order_by('-geaendert_am')[:10],
|
||
|
||
'breadcrumbs': [{'label': 'Übersicht', 'url': None}],
|
||
}
|
||
return render(request, 'ausschreibungen/dashboard.html', ctx)
|
||
```
|
||
|
||
`ausschreibungen/dashboard.html` zeigt vier Kacheln-Zeilen:
|
||
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
|
||
status: done
|
||
priority: medium
|
||
```
|
||
|
||
`ausschreibungen/views.py` — ListView:
|
||
```python
|
||
def ausschreibung_liste(request):
|
||
qs = Ausschreibung.objects.all()
|
||
# Filter-Parameter
|
||
status = request.GET.get('status')
|
||
if status:
|
||
qs = qs.filter(status=status)
|
||
archiviert = request.GET.get('archiviert', '0') == '1'
|
||
qs = qs.filter(archiviert=archiviert)
|
||
verantwortlicher = request.GET.get('verantwortlicher')
|
||
if verantwortlicher:
|
||
qs = qs.filter(hauptverantwortung=verantwortlicher)
|
||
|
||
qs = qs.select_related('hauptverantwortung').order_by('-geaendert_am')
|
||
ctx = {
|
||
'ausschreibungen': qs,
|
||
'status_choices': Ausschreibung.STATUS_CHOICES,
|
||
'mitarbeiter': Mitarbeiter.objects.all(),
|
||
'breadcrumbs': [{'label': 'Ausschreibungen', 'url': None}],
|
||
}
|
||
template = 'ausschreibungen/liste_partial.html' if request.htmx else 'ausschreibungen/liste.html'
|
||
return render(request, template, ctx)
|
||
```
|
||
|
||
`liste.html` — vollständige Seite mit Filterleiste oben und eingebetteter Tabelle.
|
||
`liste_partial.html` — nur die Tabellen-Rows (für HTMX-Filter-Update).
|
||
|
||
Filterleiste: Dropdowns für Status, Verantwortlicher, Checkbox "Archivierte anzeigen".
|
||
Alle Filter-Änderungen: `hx-get="/ausschreibungen/" hx-target="#ausschreibungen-table" hx-push-url="true"`.
|
||
|
||
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
|
||
status: done
|
||
priority: medium
|
||
```
|
||
|
||
`ausschreibungen/forms.py`:
|
||
```python
|
||
from django import forms
|
||
|
||
class AusschreibungForm(forms.ModelForm):
|
||
class Meta:
|
||
model = Ausschreibung
|
||
fields = ['titel', 'ausschreiber', 'plattform', 'plattform_link',
|
||
'ansprechpartner', 'hauptverantwortung', 'beschreibung',
|
||
'strategische_relevanz', 'bieterfragen_bis', 'abgabe_bis',
|
||
'zuschlag_bis', 'produktiv_bis']
|
||
widgets = {
|
||
'titel': forms.TextInput(attrs={'class': 'form-input', 'autofocus': True}),
|
||
'ausschreiber': forms.TextInput(attrs={'class': 'form-input'}),
|
||
'beschreibung': forms.Textarea(attrs={'class': 'form-input', 'rows': 3}),
|
||
'abgabe_bis': forms.DateTimeInput(attrs={'class': 'form-input', 'type': 'datetime-local'}),
|
||
'bieterfragen_bis': forms.DateInput(attrs={'class': 'form-input', 'type': 'date'}),
|
||
# alle Datums-Widgets als type="date"
|
||
}
|
||
```
|
||
|
||
`ausschreibungen/views.py` — CreateView (function-based):
|
||
```python
|
||
def ausschreibung_neu(request):
|
||
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,
|
||
'titel': 'Neue Ausschreibung',
|
||
'breadcrumbs': [
|
||
{'label': 'Ausschreibungen', 'url': '/ausschreibungen/'},
|
||
{'label': 'Neu', 'url': None}
|
||
],
|
||
})
|
||
```
|
||
|
||
`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
|
||
status: done
|
||
priority: medium
|
||
```
|
||
|
||
`ausschreibungen/views.py` — Detailview:
|
||
```python
|
||
def ausschreibung_detail(request, pk):
|
||
a = get_object_or_404(Ausschreibung, pk=pk)
|
||
from vergabe_teilnahme.apps.core.services import get_deadline_warnings, build_phase_nav
|
||
ctx = {
|
||
'ausschreibung': a,
|
||
'ausschreibung_id': pk, # für Context-Processor / Phase-Navigator
|
||
'warnungen': get_deadline_warnings(a),
|
||
'freigaben': a.freigabe_set.all() if hasattr(a, 'freigabe_set') else [],
|
||
'breadcrumbs': [
|
||
{'label': 'Ausschreibungen', 'url': '/ausschreibungen/'},
|
||
{'label': a.titel, 'url': None}
|
||
],
|
||
}
|
||
return render(request, 'ausschreibungen/detail.html', ctx)
|
||
```
|
||
|
||
`ausschreibungen/detail.html`:
|
||
- Seitentitel: Ausschreibungstitel + Status-Badge + Edit-Button
|
||
- Warnungs-Banner (gelb/rot) falls `warnungen` nicht leer
|
||
- Abschnitt "Stammdaten": nutze `{% render_field ausschreibung "ausschreiber" "Ausschreiber" %}` für alle Felder
|
||
- Abschnitt "Fristen": alle Datums-Felder mit Restlaufzeit-Anzeige
|
||
- Abschnitt "Freigaben": kompakte Liste (typ, person, datum)
|
||
- 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
|
||
status: done
|
||
priority: medium
|
||
```
|
||
|
||
`ausschreibungen/views.py`:
|
||
|
||
**Edit-View** (gleiche Form wie Neu, aber mit `instance=`):
|
||
```python
|
||
def ausschreibung_bearbeiten(request, pk):
|
||
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': 'Bearbeiten', ...})
|
||
```
|
||
|
||
**Status-Wechsel HTMX-Endpunkt:**
|
||
```python
|
||
def ausschreibung_status(request, pk):
|
||
a = get_object_or_404(Ausschreibung, pk=pk)
|
||
if request.method == 'POST':
|
||
neuer_status = int(request.POST.get('status', a.status))
|
||
a.status = neuer_status
|
||
a.save(update_fields=['status', 'geaendert_am'])
|
||
return render(request, 'ausschreibungen/partials/status_widget.html', {'ausschreibung': a})
|
||
```
|
||
|
||
`ausschreibungen/partials/status_widget.html`:
|
||
```html
|
||
<div id="status-widget-{{ ausschreibung.pk }}"
|
||
hx-target="#status-widget-{{ ausschreibung.pk }}" hx-swap="outerHTML">
|
||
{% status_badge ausschreibung.get_status_display ausschreibung.status %}
|
||
<select name="status" hx-post="{% url 'ausschreibungen:status' ausschreibung.pk %}"
|
||
hx-trigger="change" class="form-input ml-2 w-auto">
|
||
{% for val, label in ausschreibung.STATUS_CHOICES %}
|
||
<option value="{{ val }}" {% if val == ausschreibung.status %}selected{% endif %}>{{ label }}</option>
|
||
{% endfor %}
|
||
</select>
|
||
</div>
|
||
```
|
||
|
||
## Teilnahmeentscheidung-Seite (Phase 2, UC-AS-04)
|
||
|
||
```task
|
||
id: VT-WP-0004-T06
|
||
status: done
|
||
priority: medium
|
||
```
|
||
|
||
`ausschreibungen/views.py` — Teilnahmeentscheidungs-View:
|
||
```python
|
||
def ausschreibung_entscheidung(request, pk):
|
||
a = get_object_or_404(Ausschreibung, pk=pk)
|
||
if request.method == 'POST':
|
||
a.teilnahmeentscheidung = request.POST.get('teilnahmeentscheidung', 'offen')
|
||
a.beschreibung = request.POST.get('begruendung', a.beschreibung)
|
||
if a.teilnahmeentscheidung in ['teilnahme', 'ablehnung']:
|
||
a.status = max(a.status, 3)
|
||
a.save()
|
||
return redirect('ausschreibungen:detail', pk=pk)
|
||
|
||
from vergabe_teilnahme.apps.ausschreibungen.services import entscheidungsregel_auswertung
|
||
ctx = {
|
||
'ausschreibung': a,
|
||
'regelergebnis': entscheidungsregel_auswertung(a),
|
||
'ausschlusskriterien_nicht_erfuellbar': a.anforderung_set.filter(
|
||
ausschlusskriterium=True, erfuellungsstatus='nicht_erfuellbar'
|
||
) if hasattr(a, 'anforderung_set') else [],
|
||
'breadcrumbs': [...],
|
||
}
|
||
return render(request, 'ausschreibungen/entscheidung.html', ctx)
|
||
```
|
||
|
||
`ausschreibungen/entscheidung.html`:
|
||
- Zeigt offene Ausschlusskriterien als rote Warnmeldungen (wenn vorhanden)
|
||
- 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
|
||
status: done
|
||
priority: medium
|
||
```
|
||
|
||
`vergabe_teilnahme/apps/ausschreibungen/services.py`:
|
||
|
||
```python
|
||
def entscheidungsregel_auswertung(ausschreibung):
|
||
"""
|
||
Wendet alle aktiven Entscheidungsregeln auf eine Ausschreibung an.
|
||
Gibt Liste von Ergebnis-Dicts zurück.
|
||
"""
|
||
from vergabe_teilnahme.apps.bibliothek.models import Entscheidungsregel
|
||
regeln = Entscheidungsregel.objects.filter(aktiv=True).order_by('-gewichtung')
|
||
ergebnisse = []
|
||
for regel in regeln:
|
||
ergebnis = _wende_regel_an(regel, ausschreibung)
|
||
ergebnisse.append({
|
||
'regel': regel,
|
||
'empfehlung': ergebnis['empfehlung'],
|
||
'begruendung': ergebnis['begruendung'],
|
||
'warnung': ergebnis['empfehlung'] == 'nicht_teilnehmen',
|
||
})
|
||
return ergebnisse
|
||
|
||
def _wende_regel_an(regel, ausschreibung):
|
||
"""
|
||
Einfache Heuristik für v1: Überprüft bekannte Regel-Kategorien.
|
||
Für unbekannte Kategorien: gibt neutrale Empfehlung zurück.
|
||
"""
|
||
kat = regel.kategorie
|
||
if kat == 'ausschlusskriterium' and hasattr(ausschreibung, 'anforderung_set'):
|
||
hat_ausschluss = ausschreibung.anforderung_set.filter(
|
||
ausschlusskriterium=True, erfuellungsstatus='nicht_erfuellbar'
|
||
).exists()
|
||
if hat_ausschluss:
|
||
return {'empfehlung': 'nicht_teilnehmen',
|
||
'begruendung': 'Nicht erfüllbares Ausschlusskriterium vorhanden.'}
|
||
if kat == 'frist' and ausschreibung.abgabe_bis:
|
||
from datetime import date
|
||
delta = (ausschreibung.abgabe_bis.date() - date.today()).days
|
||
if regel.schwellenwert and delta < regel.schwellenwert:
|
||
return {'empfehlung': 'nicht_teilnehmen',
|
||
'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
|
||
status: done
|
||
priority: medium
|
||
```
|
||
|
||
**Archivieren:**
|
||
```python
|
||
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})
|
||
```
|
||
|
||
`archivieren_confirm.html`: Einfacher Bestätigungsdialog (Alpine.js Modal oder eigene Seite).
|
||
|
||
**Historisch erfassen:**
|
||
Ergänze `AusschreibungForm` um ein BooleanField `historisch_erfassen` (Widget: HiddenInput).
|
||
Bei `historisch_erfassen=True` zeigt das Formular zusätzlich die Felder:
|
||
`ergebnis`, `teilnahmeentscheidung` — direkt befüllbar ohne Phasenreihenfolge.
|
||
Die Detailseite wird nach dem Speichern sofort mit allen Unterseiten (Preise, Nachbetrachtung etc.)
|
||
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
|
||
status: done
|
||
priority: medium
|
||
```
|
||
|
||
`core/views.py`:
|
||
```python
|
||
def global_search(request):
|
||
q = request.GET.get('q', '').strip()
|
||
if len(q) < 2:
|
||
return HttpResponse('')
|
||
ctx = {
|
||
'q': q,
|
||
'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],
|
||
'marktbegleiter': Marktbegleiter.objects.filter(name__icontains=q)[:3],
|
||
}
|
||
return render(request, 'partials/search_results.html', ctx)
|
||
```
|
||
|
||
`partials/search_results.html`:
|
||
```html
|
||
{% if ausschreibungen or aufgaben or subunternehmer %}
|
||
<div class="p-3 space-y-3">
|
||
{% if ausschreibungen %}
|
||
<div>
|
||
<p class="text-xs font-semibold text-slate-500 uppercase mb-1">Ausschreibungen</p>
|
||
{% for a in ausschreibungen %}
|
||
<a href="/ausschreibungen/{{ a.pk }}/" class="block px-2 py-1 rounded hover:bg-slate-50 text-sm">
|
||
{{ a.titel }} <span class="text-slate-400">— {{ a.ausschreiber }}</span>
|
||
</a>
|
||
{% endfor %}
|
||
</div>
|
||
{% endif %}
|
||
<!-- analog für andere Kategorien -->
|
||
</div>
|
||
{% endif %}
|
||
```
|
||
|
||
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
|
||
status: done
|
||
priority: medium
|
||
```
|
||
|
||
`vergabe_teilnahme/apps/ausschreibungen/urls.py`:
|
||
```python
|
||
from django.urls import path
|
||
from . import views
|
||
|
||
app_name = 'ausschreibungen'
|
||
|
||
urlpatterns = [
|
||
path('', views.ausschreibung_liste, name='liste'),
|
||
path('neu/', views.ausschreibung_neu, name='neu'),
|
||
path('<int:pk>/', views.ausschreibung_detail, name='detail'),
|
||
path('<int:pk>/bearbeiten/', views.ausschreibung_bearbeiten, name='bearbeiten'),
|
||
path('<int:pk>/status/', views.ausschreibung_status, name='status'),
|
||
path('<int:pk>/entscheidung/', views.ausschreibung_entscheidung, name='entscheidung'),
|
||
path('<int:pk>/archivieren/', views.ausschreibung_archivieren, name='archivieren'),
|
||
# Unterseiten-URLs (Platzhalter für spätere Workplans):
|
||
path('<int:ausschreibung_id>/lose/', include('vergabe_teilnahme.apps.lose.urls')),
|
||
path('<int:ausschreibung_id>/aufgaben/', include('vergabe_teilnahme.apps.aufgaben.urls')),
|
||
path('<int:ausschreibung_id>/bieterfragen/', include('vergabe_teilnahme.apps.aufgaben.bieterfragen_urls')),
|
||
path('<int:ausschreibung_id>/dokumente/', include('vergabe_teilnahme.apps.dokumente.urls')),
|
||
path('<int:ausschreibung_id>/preise/', include('vergabe_teilnahme.apps.preise.urls')),
|
||
path('<int:ausschreibung_id>/abgabe/', include('vergabe_teilnahme.apps.nachbetrachtung.abgabe_urls')),
|
||
path('<int:ausschreibung_id>/nachbetrachtung/', include('vergabe_teilnahme.apps.nachbetrachtung.urls')),
|
||
path('<int:ausschreibung_id>/marktbegleiter/', include('vergabe_teilnahme.apps.marktbegleiter.passagen_urls')),
|
||
]
|
||
```
|
||
|
||
Jede referenzierte App-URL-Datei wird hier als leere Stub-Datei angelegt
|
||
(`urlpatterns = []`) damit die includes nicht zu ImportErrors führen.
|
||
|
||
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
|
||
status: done
|
||
priority: medium
|
||
```
|
||
|
||
Erstelle `vergabe_teilnahme/apps/ausschreibungen/tests/`:
|
||
|
||
`test_models.py`:
|
||
- Test: Ausschreibung `__str__` gibt Titel zurück
|
||
- Test: `ist_aktiv` property für Status 1-9 (True) und 10-13 (False)
|
||
- Test: `naechste_frist` gibt das frühere von bieterfragen_bis/abgabe_bis zurück
|
||
|
||
`test_views.py` (nutze `pytest-django` + `client` fixture):
|
||
- Test: GET /ausschreibungen/ → 200
|
||
- Test: GET /ausschreibungen/neu/ → 200
|
||
- Test: POST /ausschreibungen/neu/ mit validen Daten → Redirect zur Detailseite
|
||
- Test: GET /ausschreibungen/<pk>/ → 200
|
||
- Test: POST /ausschreibungen/<pk>/status/ mit status=4 → 200, Ausschreibung hat status=4
|
||
- Test: Status-Wechsel mit HTMX-Header → partial template response
|
||
|
||
Nutze `factory_boy` für Factories:
|
||
```python
|
||
import factory
|
||
class AusschreibungFactory(factory.django.DjangoModelFactory):
|
||
class Meta:
|
||
model = Ausschreibung
|
||
titel = factory.Sequence(lambda n: f"Ausschreibung {n}")
|
||
ausschreiber = "Testausschreiber GmbH"
|
||
status = 1
|
||
```
|
||
|
||
## Seed-Daten prüfen und Dashboard-Kacheln verifizieren
|
||
|
||
```task
|
||
id: VT-WP-0004-T12
|
||
status: done
|
||
priority: medium
|
||
```
|
||
|
||
Führe die gesamte Integrations-Smoke-Test-Sequenz durch:
|
||
|
||
1. `make db` → PostgreSQL läuft
|
||
2. `uv run manage.py migrate` → alle Migrationen sauber
|
||
3. `uv run manage.py seed_dev` → Seed-Daten angelegt
|
||
4. `make dev` → Server läuft
|
||
5. Browser öffnen: `http://localhost:8000/`
|
||
→ Dashboard zeigt Kacheln (auch wenn leer)
|
||
→ Sidebar zeigt alle globalen Navpunkte
|
||
→ Topbar mit Suchleiste sichtbar
|
||
6. `http://localhost:8000/ausschreibungen/`
|
||
→ Liste zeigt die Seed-Ausschreibung
|
||
7. Ausschreibung öffnen → Detail-Seite rendert mit Stammdaten
|
||
8. Status-Dropdown wechseln → HTMX aktualisiert Status inline
|
||
9. `http://localhost:8000/ausschreibungen/neu/` → Formular funktioniert
|
||
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.
|