243 lines
6 KiB
Markdown
243 lines
6 KiB
Markdown
|
|
---
|
|||
|
|
id: WP-0014
|
|||
|
|
title: Aufgaben-Phasenzuordnung und Fertigstellungs-Scores in Übersichten
|
|||
|
|
status: done
|
|||
|
|
phase: 14-of-n
|
|||
|
|
created: "2026-05-14"
|
|||
|
|
depends_on: WP-0013
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
# WP-0014 — Aufgaben-Phasenzuordnung und Scores
|
|||
|
|
|
|||
|
|
Aufgaben bekommen ein Phasenfeld (1–8). In den Übersichten von
|
|||
|
|
Ausschreibungen, Losen und der Phasennavigation wird jeweils die Anzahl
|
|||
|
|
der verknüpften Aufgaben sowie ein Fertigstellungs-Score angezeigt
|
|||
|
|
(z.B. "8/10 (80%)"). Status "erledigt" und "verworfen" gelten als
|
|||
|
|
abgeschlossen.
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
```task
|
|||
|
|
id: WP-0014-T01
|
|||
|
|
title: Aufgabe.phase — Feld + Migration
|
|||
|
|
status: done
|
|||
|
|
|
|||
|
|
`apps/aufgaben/models.py` — Feld ergänzen:
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
PHASE_CHOICES = [
|
|||
|
|
(1, 'Recherche & Unterlagen'),
|
|||
|
|
(2, 'Teilnahmeentscheidung'),
|
|||
|
|
(3, 'Detaillierte Durchsicht'),
|
|||
|
|
(4, 'Bieterfragen & Klärung'),
|
|||
|
|
(5, 'Preismodell'),
|
|||
|
|
(6, 'Unterlagen finalisieren'),
|
|||
|
|
(7, 'Abgabe'),
|
|||
|
|
(8, 'Zuschlag / Nachbetrachtung'),
|
|||
|
|
]
|
|||
|
|
phase = models.PositiveSmallIntegerField(
|
|||
|
|
choices=PHASE_CHOICES, null=True, blank=True
|
|||
|
|
)
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
`apps/aufgaben/forms.py` — `phase` zu den Feldern hinzufügen,
|
|||
|
|
Widget: `forms.Select(attrs={'class': 'form-input'})`.
|
|||
|
|
|
|||
|
|
Migration erstellen und ausführen.
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
```task
|
|||
|
|
id: WP-0014-T02
|
|||
|
|
title: aufgaben_score() Helper in core/services.py
|
|||
|
|
status: done
|
|||
|
|
|
|||
|
|
Wiederverwendbare Hilfsfunktion, die für ein beliebiges Aufgaben-QuerySet
|
|||
|
|
den Score berechnet:
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
def aufgaben_score(qs):
|
|||
|
|
"""
|
|||
|
|
Gibt dict zurück: total, abgeschlossen, score_pct
|
|||
|
|
Abgeschlossen = status in ['erledigt', 'verworfen']
|
|||
|
|
"""
|
|||
|
|
total = qs.count()
|
|||
|
|
abgeschlossen = qs.filter(status__in=['erledigt', 'verworfen']).count()
|
|||
|
|
pct = round(abgeschlossen / total * 100) if total else 0
|
|||
|
|
return {'total': total, 'abgeschlossen': abgeschlossen, 'score_pct': pct}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Kein Template-Tag — die Views rufen die Funktion auf und übergeben
|
|||
|
|
das Ergebnis direkt im Kontext.
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
```task
|
|||
|
|
id: WP-0014-T03
|
|||
|
|
title: Aufgaben-Score in Ausschreibungen-Liste
|
|||
|
|
status: done
|
|||
|
|
|
|||
|
|
`apps/ausschreibungen/views.py` — in der Liste-View das QuerySet mit
|
|||
|
|
`annotate` ergänzen:
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
from django.db.models import Count, Q
|
|||
|
|
qs = qs.annotate(
|
|||
|
|
aufgaben_total=Count('aufgaben', distinct=True),
|
|||
|
|
aufgaben_erledigt=Count(
|
|||
|
|
'aufgaben',
|
|||
|
|
filter=Q(aufgaben__status__in=['erledigt', 'verworfen']),
|
|||
|
|
distinct=True,
|
|||
|
|
),
|
|||
|
|
)
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
`templates/ausschreibungen/liste_partial.html` — neue Spalte "Aufgaben"
|
|||
|
|
nach der Volumen-Spalte:
|
|||
|
|
|
|||
|
|
```html
|
|||
|
|
<th>Aufgaben</th>
|
|||
|
|
...
|
|||
|
|
<td>
|
|||
|
|
{% if a.aufgaben_total %}
|
|||
|
|
{{ a.aufgaben_erledigt }}/{{ a.aufgaben_total }}
|
|||
|
|
({% widthratio a.aufgaben_erledigt a.aufgaben_total 100 %} %)
|
|||
|
|
{% else %}—{% endif %}
|
|||
|
|
</td>
|
|||
|
|
```
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
```task
|
|||
|
|
id: WP-0014-T04
|
|||
|
|
title: Aufgaben-Score in Ausschreibungs-Detail
|
|||
|
|
status: done
|
|||
|
|
|
|||
|
|
`apps/ausschreibungen/views.py` — in `ausschreibung_detail` den Score
|
|||
|
|
berechnen und übergeben:
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
from vergabe_teilnahme.apps.core.services import aufgaben_score
|
|||
|
|
from vergabe_teilnahme.apps.aufgaben.models import Aufgabe
|
|||
|
|
ctx['aufgaben_score'] = aufgaben_score(
|
|||
|
|
Aufgabe.objects.filter(ausschreibung=a)
|
|||
|
|
)
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
`templates/ausschreibungen/detail.html` — Aufgaben-Score-Badge im
|
|||
|
|
Kopfbereich (neben Phase-Status), z.B.:
|
|||
|
|
|
|||
|
|
```html
|
|||
|
|
{% with s=aufgaben_score %}
|
|||
|
|
{% if s.total %}
|
|||
|
|
<span class="text-xs text-slate-500">
|
|||
|
|
Aufgaben: {{ s.abgeschlossen }}/{{ s.total }} ({{ s.score_pct }} %)
|
|||
|
|
</span>
|
|||
|
|
{% endif %}
|
|||
|
|
{% endwith %}
|
|||
|
|
```
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
```task
|
|||
|
|
id: WP-0014-T05
|
|||
|
|
title: Aufgaben-Score in Lose-Liste
|
|||
|
|
status: done
|
|||
|
|
|
|||
|
|
`apps/lose/views.py` — in `lose_liste` das Lose-QuerySet annotieren:
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
from django.db.models import Count, Q
|
|||
|
|
lose = Los.objects.filter(ausschreibung=a).annotate(
|
|||
|
|
aufgaben_total=Count('aufgaben', distinct=True),
|
|||
|
|
aufgaben_erledigt=Count(
|
|||
|
|
'aufgaben',
|
|||
|
|
filter=Q(aufgaben__status__in=['erledigt', 'verworfen']),
|
|||
|
|
distinct=True,
|
|||
|
|
),
|
|||
|
|
)
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
`templates/lose/partials/los_row.html` und `templates/lose/liste.html`
|
|||
|
|
— Spalte "Aufgaben" mit Score ergänzen.
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
```task
|
|||
|
|
id: WP-0014-T06
|
|||
|
|
title: Aufgaben-Score in Phase-Navigation
|
|||
|
|
status: done
|
|||
|
|
|
|||
|
|
`apps/core/services.py` — `build_phase_nav` erweitern: für jede Phase
|
|||
|
|
die zugehörigen Aufgaben der Ausschreibung zählen und den Score
|
|||
|
|
berechnen. Nur sinnvoll wenn Aufgaben vorhanden sind.
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
from vergabe_teilnahme.apps.aufgaben.models import Aufgabe
|
|||
|
|
from django.db.models import Count, Q
|
|||
|
|
|
|||
|
|
aufgaben_qs = Aufgabe.objects.filter(ausschreibung=ausschreibung)
|
|||
|
|
phasen_stats = {
|
|||
|
|
num: aufgaben_score(aufgaben_qs.filter(phase=num))
|
|||
|
|
for num, _ in PHASEN
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Jeden Phase-Dict um `aufgaben_score`-Werte erweitern.
|
|||
|
|
|
|||
|
|
`templates/partials/phase_nav.html` — pro Phaseneintrag einen
|
|||
|
|
Fortschrittsbalken oder Score-Text anzeigen wenn `aufgaben_total > 0`:
|
|||
|
|
|
|||
|
|
```html
|
|||
|
|
{% if phase.aufgaben_score.total %}
|
|||
|
|
<span class="text-xs text-slate-400">
|
|||
|
|
{{ phase.aufgaben_score.abgeschlossen }}/{{ phase.aufgaben_score.total }}
|
|||
|
|
</span>
|
|||
|
|
{% endif %}
|
|||
|
|
```
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
```task
|
|||
|
|
id: WP-0014-T07
|
|||
|
|
title: Phasen-Filter in Aufgaben-Liste
|
|||
|
|
status: done
|
|||
|
|
|
|||
|
|
`apps/aufgaben/views.py` — Phasenfilter ergänzen (analog zum
|
|||
|
|
bestehenden Status- und Typ-Filter):
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
phase_filter = request.GET.get('phase')
|
|||
|
|
if phase_filter:
|
|||
|
|
qs = qs.filter(phase=phase_filter)
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
`templates/aufgaben/liste.html` — Phasen-Dropdown im Filterbereich:
|
|||
|
|
|
|||
|
|
```html
|
|||
|
|
<div>
|
|||
|
|
<label class="form-label">Phase</label>
|
|||
|
|
<select name="phase" class="form-input text-xs">
|
|||
|
|
<option value="">Alle Phasen</option>
|
|||
|
|
{% for val, label in phase_choices %}
|
|||
|
|
<option value="{{ val }}"{% if current_phase == val|stringformat:"s" %} selected{% endif %}>
|
|||
|
|
{{ val }}. {{ label }}
|
|||
|
|
</option>
|
|||
|
|
{% endfor %}
|
|||
|
|
</select>
|
|||
|
|
</div>
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Kontext: `phase_choices=Aufgabe.PHASE_CHOICES`, `current_phase=phase_filter or ''`.
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
```task
|
|||
|
|
id: WP-0014-T08
|
|||
|
|
title: Tests + Smoke-Check aller geänderten Seiten
|
|||
|
|
status: done
|
|||
|
|
|
|||
|
|
pytest ausführen — alle 68 bestehenden Tests müssen grün bleiben.
|
|||
|
|
|
|||
|
|
Zusätzlich per Django-Test-Client prüfen:
|
|||
|
|
- Ausschreibungen-Liste: Status 200, enthält Score-Spalte
|
|||
|
|
- Lose-Liste: Status 200
|
|||
|
|
- Aufgaben-Liste mit ?phase=1: Status 200
|
|||
|
|
- Ausschreibungs-Detail: Status 200
|
|||
|
|
- Aufgabe anlegen mit phase=3: wird korrekt gespeichert
|
|||
|
|
```
|
|||
|
|
```
|