diff --git a/vergabe_teilnahme/apps/nachbetrachtung/abgabe_urls.py b/vergabe_teilnahme/apps/nachbetrachtung/abgabe_urls.py index e39cb2c..40f9ce8 100644 --- a/vergabe_teilnahme/apps/nachbetrachtung/abgabe_urls.py +++ b/vergabe_teilnahme/apps/nachbetrachtung/abgabe_urls.py @@ -1,3 +1,11 @@ from django.urls import path -urlpatterns = [] +from . import abgabe_views as views + +app_name = 'abgabe' + +urlpatterns = [ + path('', views.abgabe_checkliste, name='checkliste'), + path('dokumentieren/', views.abgabe_dokumentieren, name='dokumentieren'), + path('problem/', views.abgabe_problem, name='problem'), +] diff --git a/vergabe_teilnahme/apps/nachbetrachtung/abgabe_views.py b/vergabe_teilnahme/apps/nachbetrachtung/abgabe_views.py new file mode 100644 index 0000000..1f7581f --- /dev/null +++ b/vergabe_teilnahme/apps/nachbetrachtung/abgabe_views.py @@ -0,0 +1,120 @@ +from django import forms +from django.contrib import messages +from django.shortcuts import get_object_or_404, redirect, render + +from vergabe_teilnahme.apps.accounts.models import Mitarbeiter +from vergabe_teilnahme.apps.ausschreibungen.models import Ausschreibung +from vergabe_teilnahme.apps.core.models import Freigabe +from vergabe_teilnahme.apps.dokumente.models import Dokument + + +def abgabe_vollstaendigkeit(ausschreibung): + from django.contrib.contenttypes.models import ContentType + + ct = ContentType.objects.get_for_model(ausschreibung) + freigaben = Freigabe.objects.filter(content_type=ct, object_id=ausschreibung.pk) + + def hat_freigabe(typ): + return freigaben.filter(freigabe_typ=typ, status='erteilt').exists() + + return { + 'dokumente_gesamt': Dokument.objects.filter(ausschreibung=ausschreibung).count(), + 'dokumente_freigegeben': Dokument.objects.filter( + ausschreibung=ausschreibung, status__in=['freigegeben', 'final_abgegeben']).count(), + 'teilnahme_freigabe': hat_freigabe('teilnahme'), + 'preis_freigabe': hat_freigabe('preis'), + 'recht_freigabe': hat_freigabe('recht'), + 'abgabe_freigabe': hat_freigabe('abgabe'), + 'entscheidung_getroffen': ausschreibung.teilnahmeentscheidung == 'teilnahme', + } + + +def abgabe_checkliste(request, ausschreibung_id): + ausschreibung = get_object_or_404(Ausschreibung, pk=ausschreibung_id) + vollstaendigkeit = abgabe_vollstaendigkeit(ausschreibung) + dokumente = Dokument.objects.filter(ausschreibung=ausschreibung).order_by('kategorie', 'bezeichnung') + + punkte = [ + ('entscheidung_getroffen', 'Teilnahmeentscheidung getroffen'), + ('teilnahme_freigabe', 'Teilnahme-Freigabe erteilt'), + ('preis_freigabe', 'Preis-Freigabe erteilt'), + ('recht_freigabe', 'Rechtliche Freigabe erteilt'), + ('abgabe_freigabe', 'Abgabe-Freigabe erteilt'), + ] + erfuellt = sum(1 for k, _ in punkte if vollstaendigkeit.get(k)) + gesamt = len(punkte) + + ctx = { + 'ausschreibung': ausschreibung, + 'vollstaendigkeit': vollstaendigkeit, + 'punkte': [(label, vollstaendigkeit.get(key)) for key, label in punkte], + 'erfuellt': erfuellt, + 'gesamt': gesamt, + 'dokumente': dokumente, + } + return render(request, 'nachbetrachtung/abgabe.html', ctx) + + +class AbgabeForm(forms.Form): + abgabe_zeitpunkt = forms.DateTimeField( + widget=forms.DateTimeInput(attrs={'type': 'datetime-local', 'class': 'form-input'}), + label='Abgabezeitpunkt', + ) + abgabe_plattform = forms.CharField( + max_length=200, required=False, + widget=forms.TextInput(attrs={'class': 'form-input'}), + label='Abgabeplattform', + ) + verantwortlicher = forms.ModelChoiceField( + queryset=Mitarbeiter.objects.all(), + required=False, + widget=forms.Select(attrs={'class': 'form-select'}), + label='Verantwortlicher', + ) + abgabenachweis = forms.FileField( + required=False, + widget=forms.FileInput(attrs={'class': 'form-input'}), + label='Abgabenachweis (Eingangsbestätigung)', + ) + kommentar = forms.CharField( + required=False, + widget=forms.Textarea(attrs={'class': 'form-input', 'rows': 3}), + label='Kommentar', + ) + + +def abgabe_dokumentieren(request, ausschreibung_id): + ausschreibung = get_object_or_404(Ausschreibung, pk=ausschreibung_id) + if request.method == 'POST': + form = AbgabeForm(request.POST, request.FILES) + if form.is_valid(): + if form.cleaned_data.get('abgabenachweis'): + Dokument.objects.create( + ausschreibung=ausschreibung, + datei=form.cleaned_data['abgabenachweis'], + kategorie='abgabenachweis', + status='final_abgegeben', + finale_abgabeversion=True, + ) + ausschreibung.status = 9 + ausschreibung.save(update_fields=['status', 'geaendert_am']) + messages.success(request, 'Abgabe dokumentiert.') + return redirect('ausschreibungen:detail', pk=ausschreibung_id) + else: + form = AbgabeForm() + return render(request, 'nachbetrachtung/abgabe_formular.html', { + 'form': form, + 'ausschreibung': ausschreibung, + }) + + +def abgabe_problem(request, ausschreibung_id): + ausschreibung = get_object_or_404(Ausschreibung, pk=ausschreibung_id) + if request.method == 'POST': + kommentar = request.POST.get('kommentar', '') + ausschreibung.status = 7 + ausschreibung.save(update_fields=['status', 'geaendert_am']) + messages.warning(request, f'Problem bei Abgabe vermerkt. {kommentar}') + return redirect('ausschreibungen:nachbetrachtung:abgabe:checkliste', + ausschreibung_id=ausschreibung_id) + return render(request, 'nachbetrachtung/abgabe_problem.html', {'ausschreibung': ausschreibung}) diff --git a/vergabe_teilnahme/apps/nachbetrachtung/tests.py b/vergabe_teilnahme/apps/nachbetrachtung/tests.py index 7ce503c..54f9ba5 100644 --- a/vergabe_teilnahme/apps/nachbetrachtung/tests.py +++ b/vergabe_teilnahme/apps/nachbetrachtung/tests.py @@ -1,3 +1,74 @@ -from django.test import TestCase +import pytest +from django.contrib.contenttypes.models import ContentType +from django.urls import reverse -# Create your tests here. +from vergabe_teilnahme.apps.ausschreibungen.tests import AusschreibungFactory +from vergabe_teilnahme.apps.core.models import Freigabe + +from .abgabe_views import abgabe_vollstaendigkeit +from .models import Nachbetrachtung + + +@pytest.mark.django_db +def test_abgabe_vollstaendigkeit_ohne_freigaben(): + a = AusschreibungFactory(teilnahmeentscheidung='offen') + v = abgabe_vollstaendigkeit(a) + assert v['teilnahme_freigabe'] is False + assert v['preis_freigabe'] is False + assert v['recht_freigabe'] is False + assert v['abgabe_freigabe'] is False + assert v['entscheidung_getroffen'] is False + + +@pytest.mark.django_db +def test_abgabe_vollstaendigkeit_mit_freigabe(): + from vergabe_teilnahme.apps.accounts.models import Mitarbeiter + user = Mitarbeiter.objects.create_user(username='pruefer', password='x') + a = AusschreibungFactory(teilnahmeentscheidung='teilnahme') + ct = ContentType.objects.get_for_model(a) + Freigabe.objects.create( + content_type=ct, object_id=a.pk, + freigabe_typ='preis', status='erteilt', + freigebende_person=user, + ) + v = abgabe_vollstaendigkeit(a) + assert v['preis_freigabe'] is True + assert v['teilnahme_freigabe'] is False + assert v['entscheidung_getroffen'] is True + + +@pytest.mark.django_db +def test_ergebnis_gewonnen_erstellt_kickoff_aufgabe(client): + from vergabe_teilnahme.apps.aufgaben.models import Aufgabe + a = AusschreibungFactory(status=9) + url = reverse('ausschreibungen:nachbetrachtung:detail', kwargs={'ausschreibung_id': a.pk}) + response = client.post(url, { + 'ergebnis': 'gewonnen', + 'verlustgruende': '[]', + }) + assert response.status_code == 302 + a.refresh_from_db() + assert a.status == 10 + assert Aufgabe.objects.filter(ausschreibung=a, titel='Kickoff vorbereiten').exists() + + +@pytest.mark.django_db +def test_ergebnis_verloren_setzt_status_11(client): + a = AusschreibungFactory(status=9) + url = reverse('ausschreibungen:nachbetrachtung:detail', kwargs={'ausschreibung_id': a.pk}) + client.post(url, {'ergebnis': 'verloren', 'verlustgruende': '[]'}) + a.refresh_from_db() + assert a.status == 11 + + +@pytest.mark.django_db +def test_verlustgruende_json_gespeichert(client): + import json + a = AusschreibungFactory(status=9) + url = reverse('ausschreibungen:nachbetrachtung:detail', kwargs={'ausschreibung_id': a.pk}) + gruende = [{'grund': 'Zu teuer', 'kategorie': 'preis', 'verlaesslichkeit': 4}] + client.post(url, {'ergebnis': 'verloren', 'verlustgruende': json.dumps(gruende)}) + nb = Nachbetrachtung.objects.get(ausschreibung=a) + assert nb.verlustgruende[0]['grund'] == 'Zu teuer' + assert nb.verlustgruende[0]['kategorie'] == 'preis' + assert nb.verlustgruende[0]['verlaesslichkeit'] == 4 diff --git a/vergabe_teilnahme/apps/nachbetrachtung/urls.py b/vergabe_teilnahme/apps/nachbetrachtung/urls.py index eb89e3b..0bb0fde 100644 --- a/vergabe_teilnahme/apps/nachbetrachtung/urls.py +++ b/vergabe_teilnahme/apps/nachbetrachtung/urls.py @@ -1,2 +1,9 @@ from django.urls import path -urlpatterns = [] + +from . import views + +app_name = 'nachbetrachtung' + +urlpatterns = [ + path('', views.nachbetrachtung_detail, name='detail'), +] diff --git a/vergabe_teilnahme/apps/nachbetrachtung/views.py b/vergabe_teilnahme/apps/nachbetrachtung/views.py index 91ea44a..d8b5aed 100644 --- a/vergabe_teilnahme/apps/nachbetrachtung/views.py +++ b/vergabe_teilnahme/apps/nachbetrachtung/views.py @@ -1,3 +1,102 @@ -from django.shortcuts import render +import json -# Create your views here. +from django import forms +from django.contrib import messages +from django.shortcuts import get_object_or_404, redirect, render + +from vergabe_teilnahme.apps.accounts.models import Mitarbeiter +from vergabe_teilnahme.apps.aufgaben.models import Aufgabe +from vergabe_teilnahme.apps.ausschreibungen.models import Ausschreibung + +from .models import Nachbetrachtung + + +class NachbetrachtungForm(forms.ModelForm): + class Meta: + model = Nachbetrachtung + fields = [ + 'ergebnis', 'zuschlagsdatum', 'projektverantwortlicher', + 'abgegebene_unterlagen', 'abgegebene_preise', + 'ausschlaggebende_zuschlagsmerkmale', 'lessons_learned', + 'empfehlungen', 'wiederverwendbare_erkenntnisse_markiert', + ] + widgets = { + 'ergebnis': forms.RadioSelect(attrs={'class': 'mr-2'}), + 'zuschlagsdatum': forms.DateInput(attrs={'type': 'date', 'class': 'form-input'}), + 'projektverantwortlicher': forms.Select(attrs={'class': 'form-select'}), + 'abgegebene_unterlagen': forms.Textarea(attrs={'class': 'form-input', 'rows': 3}), + 'abgegebene_preise': forms.Textarea(attrs={'class': 'form-input', 'rows': 3}), + 'ausschlaggebende_zuschlagsmerkmale': forms.Textarea(attrs={'class': 'form-input', 'rows': 3}), + 'lessons_learned': forms.Textarea(attrs={'class': 'form-input', 'rows': 4}), + 'empfehlungen': forms.Textarea(attrs={'class': 'form-input', 'rows': 3}), + } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields['projektverantwortlicher'].queryset = Mitarbeiter.objects.all() + self.fields['projektverantwortlicher'].required = False + self.fields['zuschlagsdatum'].required = False + + +def nachbetrachtung_detail(request, ausschreibung_id): + ausschreibung = get_object_or_404(Ausschreibung, pk=ausschreibung_id) + nb, _ = Nachbetrachtung.objects.get_or_create(ausschreibung=ausschreibung) + + if request.method == 'POST': + form = NachbetrachtungForm(request.POST, instance=nb) + verlustgruende_raw = request.POST.get('verlustgruende', '[]') + try: + verlustgruende = json.loads(verlustgruende_raw) + except (json.JSONDecodeError, ValueError): + verlustgruende = [] + + if form.is_valid(): + nb = form.save(commit=False) + nb.verlustgruende = verlustgruende + nb.save() + + ergebnis = form.cleaned_data['ergebnis'] + if ergebnis == 'gewonnen': + ausschreibung.status = 10 + ausschreibung.save(update_fields=['status', 'geaendert_am']) + aufgabe, created = Aufgabe.objects.get_or_create( + ausschreibung=ausschreibung, + titel='Kickoff vorbereiten', + defaults={ + 'typ': 'fachlich', + 'prioritaet': 1, + 'verantwortlicher': form.cleaned_data.get('projektverantwortlicher'), + 'beschreibung': ( + f'Kickoff für {ausschreibung.titel}. ' + 'Angebotsumfang und Annahmen übergeben.' + ), + }, + ) + if created: + pv = form.cleaned_data.get('projektverantwortlicher') + pv_name = str(pv) if pv else '—' + messages.success(request, f'Kickoff-Aufgabe erstellt für {pv_name}.') + elif ergebnis == 'verloren': + ausschreibung.status = 11 + ausschreibung.save(update_fields=['status', 'geaendert_am']) + + messages.success(request, 'Nachbetrachtung gespeichert.') + return redirect('ausschreibungen:nachbetrachtung:detail', + ausschreibung_id=ausschreibung_id) + else: + form = NachbetrachtungForm(instance=nb) + + kickoff_aufgabe = None + if nb.ergebnis == 'gewonnen': + kickoff_aufgabe = Aufgabe.objects.filter( + ausschreibung=ausschreibung, titel='Kickoff vorbereiten' + ).first() + + ctx = { + 'ausschreibung': ausschreibung, + 'nachbetrachtung': nb, + 'form': form, + 'kickoff_aufgabe': kickoff_aufgabe, + 'verlustgruende_json': json.dumps(nb.verlustgruende), + } + return render(request, 'nachbetrachtung/detail.html', ctx) diff --git a/vergabe_teilnahme/templates/nachbetrachtung/abgabe.html b/vergabe_teilnahme/templates/nachbetrachtung/abgabe.html new file mode 100644 index 0000000..e0d11ef --- /dev/null +++ b/vergabe_teilnahme/templates/nachbetrachtung/abgabe.html @@ -0,0 +1,81 @@ +{% extends "base.html" %} +{% block title %}Abgabe-Checkliste — {{ ausschreibung.titel }}{% endblock %} +{% block content %} + +
Abgabe bis: {{ ausschreibung.abgabe_bis }}
+Fortschritt
+{{ erfuellt }} / {{ gesamt }}
+Freigaben & Entscheidungen
+Dokumente
+{{ vollstaendigkeit.dokumente_freigegeben }} / {{ vollstaendigkeit.dokumente_gesamt }} freigegeben
+| Bezeichnung | +Kategorie | +Status | +
|---|---|---|
| {{ dok.bezeichnung|default:dok.datei.name }} | +{{ dok.get_kategorie_display }} | ++ {% if dok.status == 'final_abgegeben' or dok.status == 'freigegeben' %} + {{ dok.get_status_display }} + {% else %} + {{ dok.get_status_display }} + {% endif %} + | +
Noch keine Dokumente hochgeladen.
+ {% endif %} +