diff --git a/vergabe_teilnahme/apps/aufgaben/forms.py b/vergabe_teilnahme/apps/aufgaben/forms.py index a8c2e65..4a5e5c3 100644 --- a/vergabe_teilnahme/apps/aufgaben/forms.py +++ b/vergabe_teilnahme/apps/aufgaben/forms.py @@ -7,13 +7,14 @@ class AufgabeForm(forms.ModelForm): class Meta: model = Aufgabe fields = [ - 'titel', 'beschreibung', 'typ', 'prioritaet', 'frist', + 'titel', 'beschreibung', 'typ', 'phase', 'prioritaet', 'frist', 'verantwortlicher', 'los', 'anforderung', 'bieterfrage', ] widgets = { 'titel': forms.TextInput(attrs={'class': 'form-input', 'autofocus': True}), 'beschreibung': forms.Textarea(attrs={'class': 'form-input', 'rows': 3}), 'typ': forms.Select(attrs={'class': 'form-input'}), + 'phase': forms.Select(attrs={'class': 'form-input'}), 'prioritaet': forms.RadioSelect(), 'frist': forms.DateInput(attrs={'class': 'form-input', 'type': 'date'}), 'verantwortlicher': forms.Select(attrs={'class': 'form-input'}), @@ -30,6 +31,7 @@ class AufgabeForm(forms.ModelForm): self.fields['anforderung'].queryset = Anforderung.objects.filter(ausschreibung=ausschreibung) self.fields['bieterfrage'].queryset = Bieterfrage.objects.filter(ausschreibung=ausschreibung) self.fields['beschreibung'].required = False + self.fields['phase'].required = False self.fields['frist'].required = False self.fields['verantwortlicher'].required = False self.fields['los'].required = False diff --git a/vergabe_teilnahme/apps/aufgaben/migrations/0003_phase_feld.py b/vergabe_teilnahme/apps/aufgaben/migrations/0003_phase_feld.py new file mode 100644 index 0000000..04e6202 --- /dev/null +++ b/vergabe_teilnahme/apps/aufgaben/migrations/0003_phase_feld.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.5 on 2026-05-13 23:39 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('aufgaben', '0002_initial'), + ] + + operations = [ + migrations.AddField( + model_name='aufgabe', + name='phase', + field=models.PositiveSmallIntegerField(blank=True, choices=[(1, 'Recherche & Unterlagen'), (2, 'Teilnahmeentscheidung'), (3, 'Detaillierte Durchsicht'), (4, 'Bieterfragen & Klärung'), (5, 'Preismodell'), (6, 'Unterlagen finalisieren'), (7, 'Abgabe'), (8, 'Zuschlag / Nachbetrachtung')], null=True), + ), + ] diff --git a/vergabe_teilnahme/apps/aufgaben/models.py b/vergabe_teilnahme/apps/aufgaben/models.py index 47deaa1..525e313 100644 --- a/vergabe_teilnahme/apps/aufgaben/models.py +++ b/vergabe_teilnahme/apps/aufgaben/models.py @@ -26,6 +26,16 @@ class Aufgabe(FlexibleModel): ('ueberfaellig', 'Überfällig'), ] PRIORITAET_CHOICES = [(1, 'Hoch'), (2, 'Mittel'), (3, 'Niedrig')] + PHASE_CHOICES = [ + (1, 'Recherche & Unterlagen'), + (2, 'Teilnahmeentscheidung'), + (3, 'Detaillierte Durchsicht'), + (4, 'Bieterfragen & Klärung'), + (5, 'Preismodell'), + (6, 'Unterlagen finalisieren'), + (7, 'Abgabe'), + (8, 'Zuschlag / Nachbetrachtung'), + ] ausschreibung = models.ForeignKey( 'ausschreibungen.Ausschreibung', on_delete=models.CASCADE, related_name='aufgaben' @@ -55,6 +65,7 @@ class Aufgabe(FlexibleModel): 'accounts.Mitarbeiter', on_delete=models.SET_NULL, null=True, blank=True ) ergebnis = models.TextField(blank=True) + phase = models.PositiveSmallIntegerField(choices=PHASE_CHOICES, null=True, blank=True) class Meta: ordering = ['prioritaet', 'frist'] diff --git a/vergabe_teilnahme/apps/aufgaben/views.py b/vergabe_teilnahme/apps/aufgaben/views.py index 5e0fe7b..294a871 100644 --- a/vergabe_teilnahme/apps/aufgaben/views.py +++ b/vergabe_teilnahme/apps/aufgaben/views.py @@ -40,6 +40,10 @@ def aufgaben_liste(request, ausschreibung_id=None): if typ_filter: qs = qs.filter(typ=typ_filter) + phase_filter = request.GET.get('phase') + if phase_filter: + qs = qs.filter(phase=phase_filter) + verantwortlicher_filter = request.GET.get('verantwortlicher') if verantwortlicher_filter: qs = qs.filter(verantwortlicher=verantwortlicher_filter) @@ -67,6 +71,8 @@ def aufgaben_liste(request, ausschreibung_id=None): 'mitarbeiter': Mitarbeiter.objects.all(), 'current_status': status_filter or '', 'current_typ': typ_filter or '', + 'phase_choices': Aufgabe.PHASE_CHOICES, + 'current_phase': phase_filter or '', 'current_verantwortlicher': verantwortlicher_filter or '', 'breadcrumbs': breadcrumbs, } diff --git a/vergabe_teilnahme/apps/ausschreibungen/views.py b/vergabe_teilnahme/apps/ausschreibungen/views.py index 5022fca..064d3cd 100644 --- a/vergabe_teilnahme/apps/ausschreibungen/views.py +++ b/vergabe_teilnahme/apps/ausschreibungen/views.py @@ -59,7 +59,15 @@ def ausschreibung_liste(request): if bid_manager_filter: qs = qs.filter(bid_manager=bid_manager_filter) - qs = qs.select_related('bid_manager').order_by('-geaendert_am') + from django.db.models import Count, Q + qs = qs.select_related('bid_manager').annotate( + aufgaben_total=Count('aufgaben', distinct=True), + aufgaben_erledigt=Count( + 'aufgaben', + filter=Q(aufgaben__status__in=['erledigt', 'verworfen']), + distinct=True, + ), + ).order_by('-geaendert_am') ctx = { 'ausschreibungen': qs, @@ -100,7 +108,8 @@ def ausschreibung_neu(request): def ausschreibung_detail(request, pk): - from vergabe_teilnahme.apps.core.services import build_phase_nav, get_deadline_warnings + from vergabe_teilnahme.apps.aufgaben.models import Aufgabe + from vergabe_teilnahme.apps.core.services import aufgaben_score, build_phase_nav, get_deadline_warnings a = get_object_or_404(Ausschreibung, pk=pk) ctx = { @@ -108,6 +117,7 @@ def ausschreibung_detail(request, pk): 'ausschreibung_id': pk, 'phases': build_phase_nav(a), 'warnungen': get_deadline_warnings(a), + 'aufgaben_score': aufgaben_score(Aufgabe.objects.filter(ausschreibung=a)), 'breadcrumbs': [ {'label': 'Ausschreibungen', 'url': '/ausschreibungen/'}, {'label': a.titel, 'url': None}, diff --git a/vergabe_teilnahme/apps/core/services.py b/vergabe_teilnahme/apps/core/services.py index a5bdc54..a874e80 100644 --- a/vergabe_teilnahme/apps/core/services.py +++ b/vergabe_teilnahme/apps/core/services.py @@ -25,7 +25,15 @@ STATUS_TO_PHASE = { } +def aufgaben_score(qs): + 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} + + def build_phase_nav(ausschreibung, current_url=''): + from vergabe_teilnahme.apps.aufgaben.models import Aufgabe aktuelle_phase = STATUS_TO_PHASE.get(ausschreibung.status, 1) base = f'/ausschreibungen/{ausschreibung.pk}' phase_urls = { @@ -38,6 +46,11 @@ def build_phase_nav(ausschreibung, current_url=''): 7: f'{base}/abgabe/', 8: f'{base}/nachbetrachtung/', } + aufgaben_qs = Aufgabe.objects.filter(ausschreibung=ausschreibung) + phasen_stats = { + num: aufgaben_score(aufgaben_qs.filter(phase=num)) + for num, _ in PHASEN + } return [ { 'nummer': num, @@ -46,6 +59,7 @@ def build_phase_nav(ausschreibung, current_url=''): 'aktiv': num == aktuelle_phase, 'erledigt': num < aktuelle_phase, 'warnung': False, + 'aufgaben_score': phasen_stats[num], } for num, name in PHASEN ] diff --git a/vergabe_teilnahme/apps/lose/views.py b/vergabe_teilnahme/apps/lose/views.py index 7db9000..925afcf 100644 --- a/vergabe_teilnahme/apps/lose/views.py +++ b/vergabe_teilnahme/apps/lose/views.py @@ -24,8 +24,16 @@ def _ausschreibung_breadcrumbs(ausschreibung, *extra): # ─── Lose ──────────────────────────────────────────────────────────────────── def lose_liste(request, ausschreibung_id): + from django.db.models import Count, Q ausschreibung = get_object_or_404(Ausschreibung, pk=ausschreibung_id) - lose = Los.objects.filter(ausschreibung=ausschreibung).order_by('losnummer') + lose = Los.objects.filter(ausschreibung=ausschreibung).annotate( + aufgaben_total=Count('aufgaben', distinct=True), + aufgaben_erledigt=Count( + 'aufgaben', + filter=Q(aufgaben__status__in=['erledigt', 'verworfen']), + distinct=True, + ), + ).order_by('losnummer') return render(request, 'lose/liste.html', { 'ausschreibung': ausschreibung, 'lose': lose, diff --git a/vergabe_teilnahme/templates/aufgaben/liste.html b/vergabe_teilnahme/templates/aufgaben/liste.html index f09c93e..bb98bff 100644 --- a/vergabe_teilnahme/templates/aufgaben/liste.html +++ b/vergabe_teilnahme/templates/aufgaben/liste.html @@ -32,6 +32,15 @@ {% endfor %} +
+ + +
+ + {% for val, label in phase_choices %} + + {% endfor %} + +
+``` + +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 +``` +```