generated from coulomb/repo-seed
212 lines
7.3 KiB
Markdown
212 lines
7.3 KiB
Markdown
---
|
|
id: WP-0005
|
|
title: Lose und Anforderungen
|
|
status: done
|
|
phase: 5-of-12
|
|
created: "2026-05-08"
|
|
depends_on: 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/`
|
|
|
|
---
|
|
|
|
```task
|
|
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
|
|
```
|
|
|
|
```task
|
|
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.
|
|
```
|