--- 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
{% status_badge ausschreibung.get_status_display ausschreibung.status %}
``` ``` ```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 %}
{% if ausschreibungen %}

Ausschreibungen

{% for a in ausschreibungen %} {{ a.titel }} — {{ a.ausschreiber }} {% endfor %}
{% endif %}
{% 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('/', 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'), # Unterseiten-URLs (Platzhalter für spätere Workplans): 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')), ] ``` 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// → 200 - Test: POST /ausschreibungen//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. ```