generated from coulomb/repo-seed
493 lines
18 KiB
Markdown
493 lines
18 KiB
Markdown
---
|
|
id: WP-0004
|
|
title: Dashboard und Ausschreibungen-CRUD
|
|
status: done
|
|
phase: 4-of-12
|
|
created: "2026-05-08"
|
|
depends_on: WP-0003
|
|
---
|
|
|
|
# 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.
|
|
|
|
---
|
|
|
|
```task
|
|
id: WP-0004-T01
|
|
title: Dashboard-View mit Kacheln und Fristenliste
|
|
status: todo
|
|
|
|
`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`.
|
|
```
|
|
|
|
```task
|
|
id: WP-0004-T02
|
|
title: Ausschreibungsliste mit Filter und HTMX-Suche
|
|
status: todo
|
|
|
|
`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.
|
|
```
|
|
|
|
```task
|
|
id: WP-0004-T03
|
|
title: Ausschreibung anlegen — Form und View (UC-AS-01)
|
|
status: todo
|
|
|
|
`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).
|
|
```
|
|
|
|
```task
|
|
id: WP-0004-T04
|
|
title: Ausschreibung-Detailseite (Phase 1 — Stammdaten)
|
|
status: todo
|
|
|
|
`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)
|
|
```
|
|
|
|
```task
|
|
id: WP-0004-T05
|
|
title: Ausschreibung bearbeiten (Edit-View) und Status inline wechseln
|
|
status: todo
|
|
|
|
`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>
|
|
```
|
|
```
|
|
|
|
```task
|
|
id: WP-0004-T06
|
|
title: Teilnahmeentscheidung-Seite (Phase 2, UC-AS-04)
|
|
status: todo
|
|
|
|
`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)
|
|
```
|
|
|
|
```task
|
|
id: WP-0004-T07
|
|
title: Entscheidungsregel-Auswertungs-Service
|
|
status: todo
|
|
|
|
`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 '—'}
|
|
```
|
|
```
|
|
|
|
```task
|
|
id: WP-0004-T08
|
|
title: Ausschreibung archivieren und historisch erfassen (UC-AS-06, UC-AS-07)
|
|
status: todo
|
|
|
|
**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.
|
|
```
|
|
|
|
```task
|
|
id: WP-0004-T09
|
|
title: Globale Suchleiste — HTMX-Endpunkt und Ergebnis-Template
|
|
status: todo
|
|
|
|
`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`.
|
|
```
|
|
|
|
```task
|
|
id: WP-0004-T10
|
|
title: Ausschreibungen-URL-Verkabelung und App-Namespace
|
|
status: todo
|
|
|
|
`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.
|
|
```
|
|
|
|
```task
|
|
id: WP-0004-T11
|
|
title: Ausschreibungs-Tests (Models und Views)
|
|
status: todo
|
|
|
|
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
|
|
```
|
|
```
|
|
|
|
```task
|
|
id: WP-0004-T12
|
|
title: Seed-Daten prüfen und Dashboard-Kacheln verifizieren
|
|
status: todo
|
|
|
|
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.
|
|
```
|