Files
vergabe-teilnahme/workplans/WP-0005-lose-anforderungen.md

7.3 KiB

id, title, status, phase, created, depends_on
id title status phase created depends_on
WP-0005 Lose und Anforderungen done 5-of-12 2026-05-08 WP-0004

WP-0005 — Lose und Anforderungen

Implementiert alle Views, Forms und Templates für Lose (UC-LA-01) und Anforderungen (UC-LA-02 bis UC-LA-05) inklusive Nachweis-Verknüpfung und Ausschlusskriterium-Eskalation.

URL-Präfix: /ausschreibungen/<ausschreibung_id>/lose/ und .../anforderungen/


id: WP-0005-T01
title: Lose-Liste und Lose anlegen (UC-LA-01)
status: done

`lose/views.py` — lose_liste und los_neu:

lose_liste: Zeigt alle Lose einer Ausschreibung geordnet nach Losnummer.
Template: `lose/liste.html` — Tabelle mit Losnummer, Lostitel, Zuständiger, Teilnahme (Badge), Status.
"+ Los hinzufügen"-Button öffnet Inline-Formular via HTMX.

`LosForm(ModelForm)`: Felder losnummer, lostitel, beschreibung, abgrenzung, zustaendiger, teilnahme.
Alle Inputs mit `form-input`, Textarea mit 3 rows.

los_neu (POST): Erstellt Los, gibt bei HTMX-Request nur neuen Tabellen-Row zurück.
los_bearbeiten (GET/POST): Edit in eigenem Template oder Inline.
los_loeschen (POST): Löscht Los nach Bestätigung.

URLs in `lose/urls.py`:
```python
path('', views.lose_liste, name='liste'),
path('neu/', views.los_neu, name='neu'),
path('<int:los_pk>/', views.los_detail, name='detail'),
path('<int:los_pk>/bearbeiten/', views.los_bearbeiten, name='bearbeiten'),

```task
id: WP-0005-T02
title: Los-Detail-Seite mit eingebetteten Anforderungen
status: done

`lose/views.py` — los_detail:
```python
def los_detail(request, ausschreibung_id, los_pk):
    los = get_object_or_404(Los, pk=los_pk, ausschreibung_id=ausschreibung_id)
    ctx = {
        'los': los,
        'ausschreibung': los.ausschreibung,
        'anforderungen': los.anforderungen.all().order_by('verbindlichkeit', 'titel'),
        'subunternehmer': SubunternehmerZuordnung.objects.filter(los=los),
        ...
    }
    return render(request, 'lose/detail.html', ctx)

lose/detail.html:

  • Los-Stammdaten (render_field Tags)
  • Abschnitt "Anforderungen" — Tabelle mit Link zur Anforderungsdetailseite
  • Abschnitt "Subunternehmer" — Liste zugeordneter Subunternehmer
  • CustomAttribute-Panel (HTMX lazy, wie in WP-0012)
  • Abschnitt "Teilnahme": Toggle Ja/Nein/Offen (HTMX POST)

```task
id: WP-0005-T03
title: Anforderungsliste nach Los gruppiert (UC-LA-02)
status: done

`lose/views.py` — anforderungen_liste:
Lädt alle Anforderungen der Ausschreibung, gruppiert nach Los.
Anforderungen ohne Los-Zuordnung erscheinen in "Allgemein".

Template `lose/anforderungen_liste.html`:
- Filter-Leiste: Verbindlichkeit (Muss/Soll/Kann), Erfüllungsstatus, Los, Zuständiger
- Alle Filteränderungen via HTMX ohne Reload
- Pro Gruppe: aufklappbarer Akkordeon-Abschnitt (Alpine.js x-show)
- Jede Zeile: Titel, Verbindlichkeit-Badge, Erfüllungsstatus-Badge, Zuständiger
- Rote Hervorhebung bei Ausschlusskriterium + nicht_erfuellbar
- "+ Anforderung" Button oben rechts
id: WP-0005-T04
title: Anforderung anlegen und Detailseite (UC-LA-02, UC-LA-03)
status: done

`AnforderungForm(ModelForm)`: alle Felder aus Modell.
Besonderer Widget für verbindlichkeit: Radio-Buttons statt Dropdown.

anforderung_neu: Liest ausschreibung_id aus URL. Los-Dropdown zeigt nur Lose der aktuellen Ausschreibung.

anforderung_detail: Zeigt alle Felder (via render_field), Kommentarverlauf als Timeline
(für v1: einfache Textarea + gespeicherte Kommentare als JSONField auf Anforderung).
Verknüpfte Dokumente, Nachweise und Aufgaben als Listen.

Erfüllungsstatus inline ändern (UC-LA-03):
HTMX-Endpunkt `anforderung_status`:
```python
def anforderung_status(request, ausschreibung_id, pk):
    a = get_object_or_404(Anforderung, pk=pk, ausschreibung_id=ausschreibung_id)
    if request.method == 'POST':
        a.erfuellungsstatus = request.POST['erfuellungsstatus']
        a.save(update_fields=['erfuellungsstatus'])
    return render(request, 'lose/partials/erfuellungsstatus_widget.html', {'anforderung': a})

```task
id: WP-0005-T05
title: Nachweis-Verknüpfung mit Bibliothek (UC-LA-04)
status: done

`lose/views.py` — nachweis_suche_modal und nachweis_zuordnen:

Auf der Anforderungsdetailseite: Button "Nachweis zuordnen".
```html
<button hx-get="{% url 'lose:nachweis_suche' ausschreibung.pk anforderung.pk %}"
        hx-target="#nachweis-modal"
        class="btn-secondary">Nachweis zuordnen</button>
<div id="nachweis-modal"></div>

nachweis_suche_modal (GET): Gibt Such-Modal zurück mit Textfeld + Ergebnisliste (HTMX-Suche im Bibliothek-Bestand). Jeder Treffer zeigt: Titel, Ablaufdatum, Freigabestatus. Ablaufende/abgelaufene Nachweise in Orange/Rot.

nachweis_zuordnen (POST): Fügt Nachweis via M2M hinzu. nachweis_entfernen (DELETE/POST): Entfernt M2M-Verknüpfung.

Zeige zugeordnete Nachweise auf Anforderungsdetail als Liste mit Ablaufstatus-Badge.


```task
id: WP-0005-T06
title: Ausschlusskriterium-Eskalation auf Phase-2-Seite (UC-LA-05)
status: done

Ergänze `ausschreibungen/views.py` — ausschreibung_entscheidung:

Vor dem Laden der Seite: Prüfe ob es Anforderungen mit
`ausschlusskriterium=True AND erfuellungsstatus='nicht_erfuellbar'` gibt.

Falls ja: Zeige oben auf der Entscheidungsseite einen roten Alert-Banner:
```html
{% if ausschlusskriterien_nicht_erfuellbar %}
<div class="bg-red-50 border border-red-300 rounded-lg p-4 mb-6">
  <p class="font-semibold text-red-700">⚠ Nicht erfüllbare Ausschlusskriterien</p>
  <ul class="mt-2 text-sm text-red-600 list-disc ml-4">
    {% for a in ausschlusskriterien_nicht_erfuellbar %}
    <li>{{ a.titel }} (Los: {{ a.los|default:"Allgemein" }})</li>
    {% endfor %}
  </ul>
  <p class="text-sm text-red-500 mt-2">Empfehlung: Nichtteilnahme</p>
</div>
{% endif %}

Prüfe: Seed-Daten mit einer nicht erfüllbaren Muss-Anforderung + Ausschlusskriterium anlegen, dann Phase-2-Seite öffnen → Banner erscheint.


```task
id: WP-0005-T07
title: Aufgabe aus Anforderung ableiten (UC-AU-02)
status: done

Auf der Anforderungsdetailseite: Button "Aufgabe erstellen".
```python
def anforderung_aufgabe_erstellen(request, ausschreibung_id, pk):
    anforderung = get_object_or_404(Anforderung, pk=pk)
    if request.method == 'POST':
        from vergabe_teilnahme.apps.aufgaben.models import Aufgabe
        Aufgabe.objects.create(
            ausschreibung_id=ausschreibung_id,
            los=anforderung.los,
            anforderung=anforderung,
            titel=f"Klärung: {anforderung.titel[:200]}",
            typ='fachlich',
            verantwortlicher=anforderung.zustaendiger,
        )
        return redirect('ausschreibungen:lose:anforderung_detail',
                       ausschreibung_id=ausschreibung_id, pk=pk)
    return render(request, 'lose/aufgabe_erstellen_confirm.html', {'anforderung': anforderung})

Nach Erstellen: Anforderungsdetail zeigt die neue Aufgabe im Abschnitt "Verbundene Aufgaben".


```task
id: WP-0005-T08
title: Tests für Lose und Anforderungen
status: done

`lose/tests/test_views.py`:
- Test: Lose-Liste gibt 200 zurück
- Test: Los anlegen mit POST → Redirect, Los existiert in DB
- Test: Anforderung anlegen mit Muss-Verbindlichkeit
- Test: Erfüllungsstatus via HTMX-POST auf 'nicht_erfuellbar' setzen
- Test: Ausschlusskriterium + nicht_erfuellbar → Entscheidungsseite zeigt Alert-Banner
- Test: Nachweis-Verknüpfung über M2M

Nutze AusschreibungFactory und erstelle Factories für Los und Anforderung.