---
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 %}
{% 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.
```