Files
vergabe-teilnahme/workplans/WP-0004-dashboard-ausschreibungen.md

18 KiB

id, title, status, phase, created, depends_on
id title status phase created depends_on
WP-0004 Dashboard und Ausschreibungen-CRUD done 4-of-12 2026-05-08 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.


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):

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:

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:

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

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