vergabe-teilnahme/workplans/VT-WP-0004-dashboard-ausschreibungen.md
tegwick 35df9bb772
All checks were successful
Application acceptance / application-tests (push) Successful in 1m26s
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Build and Publish Container Image / build-and-push (push) Successful in 34s
Complete dashboard loose ends and reconcile blocked pilot work
Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a0e394-2a69-7df0-8980-22a0ebe55216
2026-09-27 18:34:48 +02:00

21 KiB
Raw Blame History

id type title status phase created updated depends_on domain repo state_hub_workstream_id
VT-WP-0004 workplan Dashboard und Ausschreibungen-CRUD finished 4-of-12 2026-05-08 2026-09-27 WP-0003 communication vergabe-teilnahme 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

id: VT-WP-0004-T01
status: done
priority: medium

ausschreibungen/views.py — Dashboard-View:

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

id: VT-WP-0004-T02
status: done
priority: medium

ausschreibungen/views.py — ListView:

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)

id: VT-WP-0004-T03
status: done
priority: medium

ausschreibungen/forms.py:

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

Ausschreibung-Detailseite (Phase 1 — Stammdaten)

id: VT-WP-0004-T04
status: done
priority: medium

ausschreibungen/views.py — Detailview:

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

id: VT-WP-0004-T05
status: done
priority: medium

ausschreibungen/views.py:

Edit-View (gleiche Form wie Neu, aber mit instance=):

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>

Teilnahmeentscheidung-Seite (Phase 2, UC-AS-04)

id: VT-WP-0004-T06
status: done
priority: medium

ausschreibungen/views.py — Teilnahmeentscheidungs-View:

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

id: VT-WP-0004-T07
status: done
priority: medium

vergabe_teilnahme/apps/ausschreibungen/services.py:

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)

id: VT-WP-0004-T08
status: done
priority: medium

Archivieren:

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

id: VT-WP-0004-T09
status: done
priority: medium

core/views.py:

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.

Ausschreibungen-URL-Verkabelung und App-Namespace

id: VT-WP-0004-T10
status: done
priority: medium

vergabe_teilnahme/apps/ausschreibungen/urls.py:

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)

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

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

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.