diff --git a/vergabe_teilnahme/apps/aufgaben/bieterfragen_urls.py b/vergabe_teilnahme/apps/aufgaben/bieterfragen_urls.py index e39cb2c..751e8ae 100644 --- a/vergabe_teilnahme/apps/aufgaben/bieterfragen_urls.py +++ b/vergabe_teilnahme/apps/aufgaben/bieterfragen_urls.py @@ -1,3 +1,13 @@ from django.urls import path -urlpatterns = [] +from . import views + +app_name = 'bieterfragen' + +urlpatterns = [ + path('', views.bieterfragen_liste, name='liste'), + path('neu/', views.bieterfrage_neu, name='neu'), + path('/', views.bieterfrage_detail, name='detail'), + path('/status/', views.bieterfrage_status, name='status'), + path('/antwort/', views.bieterfrage_antwort, name='antwort'), +] diff --git a/vergabe_teilnahme/apps/aufgaben/forms.py b/vergabe_teilnahme/apps/aufgaben/forms.py new file mode 100644 index 0000000..a8c2e65 --- /dev/null +++ b/vergabe_teilnahme/apps/aufgaben/forms.py @@ -0,0 +1,63 @@ +from django import forms + +from .models import Aufgabe, Bieterfrage + + +class AufgabeForm(forms.ModelForm): + class Meta: + model = Aufgabe + fields = [ + 'titel', 'beschreibung', 'typ', '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'}), + 'prioritaet': forms.RadioSelect(), + 'frist': forms.DateInput(attrs={'class': 'form-input', 'type': 'date'}), + 'verantwortlicher': forms.Select(attrs={'class': 'form-input'}), + 'los': forms.Select(attrs={'class': 'form-input'}), + 'anforderung': forms.Select(attrs={'class': 'form-input'}), + 'bieterfrage': forms.Select(attrs={'class': 'form-input'}), + } + + def __init__(self, *args, ausschreibung=None, **kwargs): + super().__init__(*args, **kwargs) + if ausschreibung is not None: + from vergabe_teilnahme.apps.lose.models import Anforderung, Los + self.fields['los'].queryset = Los.objects.filter(ausschreibung=ausschreibung) + 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['frist'].required = False + self.fields['verantwortlicher'].required = False + self.fields['los'].required = False + self.fields['anforderung'].required = False + self.fields['bieterfrage'].required = False + + +class BieterfragenForm(forms.ModelForm): + class Meta: + model = Bieterfrage + fields = ['fragentext', 'begruendung', 'prioritaet', 'anforderung', 'dokument', 'verfasser'] + widgets = { + 'fragentext': forms.Textarea(attrs={'class': 'form-input', 'rows': 4, 'autofocus': True}), + 'begruendung': forms.Textarea(attrs={'class': 'form-input', 'rows': 3}), + 'prioritaet': forms.RadioSelect(), + 'anforderung': forms.Select(attrs={'class': 'form-input'}), + 'dokument': forms.Select(attrs={'class': 'form-input'}), + 'verfasser': forms.Select(attrs={'class': 'form-input'}), + } + + def __init__(self, *args, ausschreibung=None, **kwargs): + super().__init__(*args, **kwargs) + if ausschreibung is not None: + from vergabe_teilnahme.apps.lose.models import Anforderung + from vergabe_teilnahme.apps.dokumente.models import Dokument + self.fields['anforderung'].queryset = Anforderung.objects.filter(ausschreibung=ausschreibung) + self.fields['dokument'].queryset = Dokument.objects.filter(ausschreibung=ausschreibung) + self.fields['begruendung'].required = False + self.fields['anforderung'].required = False + self.fields['dokument'].required = False + self.fields['verfasser'].required = False diff --git a/vergabe_teilnahme/apps/aufgaben/global_urls.py b/vergabe_teilnahme/apps/aufgaben/global_urls.py new file mode 100644 index 0000000..4d520f8 --- /dev/null +++ b/vergabe_teilnahme/apps/aufgaben/global_urls.py @@ -0,0 +1,7 @@ +from django.urls import path + +from . import views + +urlpatterns = [ + path('', views.aufgaben_liste, name='aufgaben_global'), +] diff --git a/vergabe_teilnahme/apps/aufgaben/tests.py b/vergabe_teilnahme/apps/aufgaben/tests.py index 7ce503c..488baef 100644 --- a/vergabe_teilnahme/apps/aufgaben/tests.py +++ b/vergabe_teilnahme/apps/aufgaben/tests.py @@ -1,3 +1,96 @@ -from django.test import TestCase +import factory +import pytest +from django.urls import reverse -# Create your tests here. +from vergabe_teilnahme.apps.ausschreibungen.tests import AusschreibungFactory +from vergabe_teilnahme.apps.lose.tests import AnforderungFactory + +from .models import Aufgabe, Bieterfrage + + +class AufgabeFactory(factory.django.DjangoModelFactory): + class Meta: + model = Aufgabe + + ausschreibung = factory.SubFactory(AusschreibungFactory) + titel = factory.Sequence(lambda n: f"Aufgabe {n}") + typ = 'fachlich' + status = 'offen' + prioritaet = 2 + + +class BieterfragenFactory(factory.django.DjangoModelFactory): + class Meta: + model = Bieterfrage + + ausschreibung = factory.SubFactory(AusschreibungFactory) + fragentext = factory.Sequence(lambda n: f"Frage {n}: Bitte klären Sie...") + status = 'entwurf' + prioritaet = 2 + + +# ─── Aufgaben ────────────────────────────────────────────────────────────── + + +@pytest.mark.django_db +def test_aufgaben_liste_get(client): + a = AusschreibungFactory() + url = reverse('ausschreibungen:aufgaben:liste', kwargs={'ausschreibung_id': a.pk}) + response = client.get(url) + assert response.status_code == 200 + + +@pytest.mark.django_db +def test_aufgabe_neu_post(client): + a = AusschreibungFactory() + url = reverse('ausschreibungen:aufgaben:neu', kwargs={'ausschreibung_id': a.pk}) + response = client.post(url, {'titel': 'Neue Aufgabe', 'typ': 'fachlich', 'prioritaet': 2}) + assert response.status_code == 302 + assert Aufgabe.objects.filter(ausschreibung=a, titel='Neue Aufgabe').exists() + + +@pytest.mark.django_db +def test_aufgabe_status_htmx(client): + aufgabe = AufgabeFactory() + url = reverse('ausschreibungen:aufgaben:status', + kwargs={'ausschreibung_id': aufgabe.ausschreibung_id, 'pk': aufgabe.pk}) + response = client.post(url, {'status': 'erledigt'}, HTTP_HX_REQUEST='true') + assert response.status_code == 200 + aufgabe.refresh_from_db() + assert aufgabe.status == 'erledigt' + + +@pytest.mark.django_db +def test_ueberfaellige_aufgabe_auto_update(client): + from datetime import date, timedelta + a = AusschreibungFactory() + aufgabe = AufgabeFactory(ausschreibung=a, frist=date.today() - timedelta(days=1), status='offen') + url = reverse('ausschreibungen:aufgaben:liste', kwargs={'ausschreibung_id': a.pk}) + client.get(url) + aufgabe.refresh_from_db() + assert aufgabe.status == 'ueberfaellig' + + +# ─── Bieterfragen ───────────────────────────────────────────────────────── + + +@pytest.mark.django_db +def test_bieterfrage_neu_prefill_anforderung(client): + a = AusschreibungFactory() + anf = AnforderungFactory(ausschreibung=a) + url = reverse('ausschreibungen:bieterfragen:neu', kwargs={'ausschreibung_id': a.pk}) + response = client.get(url, {'anforderung_id': anf.pk}) + assert response.status_code == 200 + assert str(anf.pk).encode() in response.content + + +@pytest.mark.django_db +def test_bieterfrage_antwort_speichern(client): + bf = BieterfragenFactory(status='eingereicht') + url = reverse('ausschreibungen:bieterfragen:antwort', + kwargs={'ausschreibung_id': bf.ausschreibung_id, 'pk': bf.pk}) + response = client.post(url, {'antwort': 'Die Antwort lautet 42.', 'auswirkung_angebot': ''}) + assert response.status_code == 302 + bf.refresh_from_db() + assert bf.antwort == 'Die Antwort lautet 42.' + assert bf.status == 'beantwortet' diff --git a/vergabe_teilnahme/apps/aufgaben/urls.py b/vergabe_teilnahme/apps/aufgaben/urls.py index eb89e3b..772486f 100644 --- a/vergabe_teilnahme/apps/aufgaben/urls.py +++ b/vergabe_teilnahme/apps/aufgaben/urls.py @@ -1,2 +1,15 @@ from django.urls import path -urlpatterns = [] + +from . import views + +app_name = 'aufgaben' + +urlpatterns = [ + path('', views.aufgaben_liste, name='liste'), + path('neu/', views.aufgabe_neu, name='neu'), + path('/', views.aufgabe_detail, name='detail'), + path('/bearbeiten/', views.aufgabe_bearbeiten, name='bearbeiten'), + path('/loeschen/', views.aufgabe_loeschen, name='loeschen'), + path('/status/', views.aufgabe_status, name='status'), + path('/ergebnis/', views.aufgabe_ergebnis, name='ergebnis'), +] diff --git a/vergabe_teilnahme/apps/aufgaben/views.py b/vergabe_teilnahme/apps/aufgaben/views.py index 91ea44a..5e0fe7b 100644 --- a/vergabe_teilnahme/apps/aufgaben/views.py +++ b/vergabe_teilnahme/apps/aufgaben/views.py @@ -1,3 +1,312 @@ -from django.shortcuts import render +from datetime import date -# Create your views here. +from django.shortcuts import get_object_or_404, redirect, render + +from vergabe_teilnahme.apps.ausschreibungen.models import Ausschreibung + +from .models import Aufgabe, Bieterfrage + +AKTIVE_STATUS = [ + 'offen', 'in_bearbeitung', 'wartend_intern', 'wartend_sub', 'wartend_ausschreiber', +] + + +def _is_htmx(request): + return request.headers.get('HX-Request') == 'true' + + +# ─── Aufgaben ───────────────────────────────────────────────────────────────── + + +def aufgaben_liste(request, ausschreibung_id=None): + from vergabe_teilnahme.apps.accounts.models import Mitarbeiter + + heute = date.today() + + if ausschreibung_id: + ausschreibung = get_object_or_404(Ausschreibung, pk=ausschreibung_id) + qs = Aufgabe.objects.filter(ausschreibung=ausschreibung) + else: + ausschreibung = None + qs = Aufgabe.objects.select_related('ausschreibung').all() + + qs.filter(frist__lt=heute, status__in=AKTIVE_STATUS).update(status='ueberfaellig') + + status_filter = request.GET.get('status') + if status_filter: + qs = qs.filter(status=status_filter) + + typ_filter = request.GET.get('typ') + if typ_filter: + qs = qs.filter(typ=typ_filter) + + verantwortlicher_filter = request.GET.get('verantwortlicher') + if verantwortlicher_filter: + qs = qs.filter(verantwortlicher=verantwortlicher_filter) + + if not ausschreibung_id and request.GET.get('nur_meine') == '1' and request.user.is_authenticated: + qs = qs.filter(verantwortlicher=request.user) + + qs = qs.select_related('ausschreibung', 'verantwortlicher', 'los').order_by('prioritaet', 'frist') + + if ausschreibung: + breadcrumbs = [ + {'label': 'Ausschreibungen', 'url': '/ausschreibungen/'}, + {'label': ausschreibung.titel, 'url': f'/ausschreibungen/{ausschreibung_id}/'}, + {'label': 'Aufgaben', 'url': None}, + ] + else: + breadcrumbs = [{'label': 'Aufgaben', 'url': None}] + + ctx = { + 'aufgaben': qs, + 'ausschreibung': ausschreibung, + 'ausschreibung_id': ausschreibung_id, + 'status_choices': Aufgabe.STATUS_CHOICES, + 'typ_choices': Aufgabe.TYP_CHOICES, + 'mitarbeiter': Mitarbeiter.objects.all(), + 'current_status': status_filter or '', + 'current_typ': typ_filter or '', + 'current_verantwortlicher': verantwortlicher_filter or '', + 'breadcrumbs': breadcrumbs, + } + + if _is_htmx(request): + return render(request, 'aufgaben/liste_partial.html', ctx) + return render(request, 'aufgaben/liste.html', ctx) + + +def aufgabe_neu(request, ausschreibung_id): + from .forms import AufgabeForm + + ausschreibung = get_object_or_404(Ausschreibung, pk=ausschreibung_id) + if request.method == 'POST': + form = AufgabeForm(request.POST, ausschreibung=ausschreibung) + if form.is_valid(): + aufgabe = form.save(commit=False) + aufgabe.ausschreibung = ausschreibung + aufgabe.save() + if _is_htmx(request): + return render(request, 'aufgaben/partials/aufgabe_row.html', + {'aufgabe': aufgabe, 'ausschreibung': ausschreibung}) + return redirect('ausschreibungen:aufgaben:liste', ausschreibung_id=ausschreibung_id) + else: + form = AufgabeForm(ausschreibung=ausschreibung) + + return render(request, 'aufgaben/form.html', { + 'form': form, + 'ausschreibung': ausschreibung, + 'titel': 'Aufgabe anlegen', + 'breadcrumbs': [ + {'label': 'Ausschreibungen', 'url': '/ausschreibungen/'}, + {'label': ausschreibung.titel, 'url': f'/ausschreibungen/{ausschreibung_id}/'}, + {'label': 'Aufgaben', 'url': f'/ausschreibungen/{ausschreibung_id}/aufgaben/'}, + {'label': 'Neu', 'url': None}, + ], + }) + + +def aufgabe_bearbeiten(request, ausschreibung_id, pk): + from .forms import AufgabeForm + + ausschreibung = get_object_or_404(Ausschreibung, pk=ausschreibung_id) + aufgabe = get_object_or_404(Aufgabe, pk=pk, ausschreibung=ausschreibung) + form = AufgabeForm(request.POST or None, instance=aufgabe, ausschreibung=ausschreibung) + if request.method == 'POST' and form.is_valid(): + form.save() + return redirect('ausschreibungen:aufgaben:detail', ausschreibung_id=ausschreibung_id, pk=pk) + return render(request, 'aufgaben/form.html', { + 'form': form, + 'ausschreibung': ausschreibung, + 'aufgabe': aufgabe, + 'titel': 'Aufgabe bearbeiten', + 'breadcrumbs': [ + {'label': 'Ausschreibungen', 'url': '/ausschreibungen/'}, + {'label': ausschreibung.titel, 'url': f'/ausschreibungen/{ausschreibung_id}/'}, + {'label': 'Aufgaben', 'url': f'/ausschreibungen/{ausschreibung_id}/aufgaben/'}, + {'label': aufgabe.titel[:50], 'url': None}, + ], + }) + + +def aufgabe_loeschen(request, ausschreibung_id, pk): + ausschreibung = get_object_or_404(Ausschreibung, pk=ausschreibung_id) + aufgabe = get_object_or_404(Aufgabe, pk=pk, ausschreibung=ausschreibung) + if request.method == 'POST': + aufgabe.status = 'verworfen' + aufgabe.save(update_fields=['status']) + return redirect('ausschreibungen:aufgaben:liste', ausschreibung_id=ausschreibung_id) + return render(request, 'aufgaben/loeschen_confirm.html', { + 'aufgabe': aufgabe, + 'ausschreibung': ausschreibung, + 'breadcrumbs': [ + {'label': 'Ausschreibungen', 'url': '/ausschreibungen/'}, + {'label': ausschreibung.titel, 'url': f'/ausschreibungen/{ausschreibung_id}/'}, + {'label': 'Aufgaben', 'url': f'/ausschreibungen/{ausschreibung_id}/aufgaben/'}, + {'label': 'Löschen', 'url': None}, + ], + }) + + +def aufgabe_detail(request, ausschreibung_id, pk): + ausschreibung = get_object_or_404(Ausschreibung, pk=ausschreibung_id) + aufgabe = get_object_or_404(Aufgabe, pk=pk, ausschreibung=ausschreibung) + return render(request, 'aufgaben/detail.html', { + 'aufgabe': aufgabe, + 'ausschreibung': ausschreibung, + 'breadcrumbs': [ + {'label': 'Ausschreibungen', 'url': '/ausschreibungen/'}, + {'label': ausschreibung.titel, 'url': f'/ausschreibungen/{ausschreibung_id}/'}, + {'label': 'Aufgaben', 'url': f'/ausschreibungen/{ausschreibung_id}/aufgaben/'}, + {'label': aufgabe.titel[:50], 'url': None}, + ], + }) + + +def aufgabe_status(request, ausschreibung_id, pk): + ausschreibung = get_object_or_404(Ausschreibung, pk=ausschreibung_id) + aufgabe = get_object_or_404(Aufgabe, pk=pk, ausschreibung=ausschreibung) + if request.method == 'POST': + neuer_status = request.POST.get('status') + if neuer_status: + aufgabe.status = neuer_status + aufgabe.save(update_fields=['status']) + return render(request, 'aufgaben/partials/aufgabe_row.html', + {'aufgabe': aufgabe, 'ausschreibung': ausschreibung}) + + +def aufgabe_ergebnis(request, ausschreibung_id, pk): + ausschreibung = get_object_or_404(Ausschreibung, pk=ausschreibung_id) + aufgabe = get_object_or_404(Aufgabe, pk=pk, ausschreibung=ausschreibung) + if request.method == 'POST': + aufgabe.ergebnis = request.POST.get('ergebnis', aufgabe.ergebnis) + aufgabe.save(update_fields=['ergebnis']) + return render(request, 'aufgaben/partials/aufgabe_row.html', + {'aufgabe': aufgabe, 'ausschreibung': ausschreibung}) + + +# ─── Bieterfragen ───────────────────────────────────────────────────────────── + + +def bieterfragen_liste(request, ausschreibung_id): + from vergabe_teilnahme.apps.accounts.models import Mitarbeiter + + ausschreibung = get_object_or_404(Ausschreibung, pk=ausschreibung_id) + qs = Bieterfrage.objects.filter(ausschreibung=ausschreibung) + + status_filter = request.GET.get('status') + if status_filter: + qs = qs.filter(status=status_filter) + + prioritaet_filter = request.GET.get('prioritaet') + if prioritaet_filter: + qs = qs.filter(prioritaet=prioritaet_filter) + + verfasser_filter = request.GET.get('verfasser') + if verfasser_filter: + qs = qs.filter(verfasser=verfasser_filter) + + qs = qs.select_related('verfasser', 'anforderung').order_by('prioritaet', 'status') + + return render(request, 'aufgaben/bieterfragen_liste.html', { + 'bieterfragen': qs, + 'ausschreibung': ausschreibung, + 'status_choices': Bieterfrage.STATUS_CHOICES, + 'prioritaet_choices': Bieterfrage.PRIORITAET_CHOICES, + 'mitarbeiter': Mitarbeiter.objects.all(), + 'current_status': status_filter or '', + 'breadcrumbs': [ + {'label': 'Ausschreibungen', 'url': '/ausschreibungen/'}, + {'label': ausschreibung.titel, 'url': f'/ausschreibungen/{ausschreibung_id}/'}, + {'label': 'Bieterfragen', 'url': None}, + ], + }) + + +def bieterfrage_neu(request, ausschreibung_id): + from .forms import BieterfragenForm + from vergabe_teilnahme.apps.lose.models import Anforderung + + ausschreibung = get_object_or_404(Ausschreibung, pk=ausschreibung_id) + + initial = {} + anforderung_id = request.GET.get('anforderung_id') + if anforderung_id: + try: + anf = Anforderung.objects.get(pk=anforderung_id, ausschreibung=ausschreibung) + initial['anforderung'] = anf + except Anforderung.DoesNotExist: + pass + + if request.method == 'POST': + form = BieterfragenForm(request.POST, ausschreibung=ausschreibung) + if form.is_valid(): + bf = form.save(commit=False) + bf.ausschreibung = ausschreibung + bf.save() + return redirect('ausschreibungen:bieterfragen:detail', + ausschreibung_id=ausschreibung_id, pk=bf.pk) + else: + form = BieterfragenForm(initial=initial, ausschreibung=ausschreibung) + + return render(request, 'aufgaben/bieterfrage_form.html', { + 'form': form, + 'ausschreibung': ausschreibung, + 'titel': 'Bieterfrage anlegen', + 'breadcrumbs': [ + {'label': 'Ausschreibungen', 'url': '/ausschreibungen/'}, + {'label': ausschreibung.titel, 'url': f'/ausschreibungen/{ausschreibung_id}/'}, + {'label': 'Bieterfragen', 'url': f'/ausschreibungen/{ausschreibung_id}/bieterfragen/'}, + {'label': 'Neu', 'url': None}, + ], + }) + + +def bieterfrage_detail(request, ausschreibung_id, pk): + ausschreibung = get_object_or_404(Ausschreibung, pk=ausschreibung_id) + bieterfrage = get_object_or_404(Bieterfrage, pk=pk, ausschreibung=ausschreibung) + return render(request, 'aufgaben/bieterfrage_detail.html', { + 'bieterfrage': bieterfrage, + 'ausschreibung': ausschreibung, + 'breadcrumbs': [ + {'label': 'Ausschreibungen', 'url': '/ausschreibungen/'}, + {'label': ausschreibung.titel, 'url': f'/ausschreibungen/{ausschreibung_id}/'}, + {'label': 'Bieterfragen', 'url': f'/ausschreibungen/{ausschreibung_id}/bieterfragen/'}, + {'label': str(bieterfrage)[:50], 'url': None}, + ], + }) + + +def bieterfrage_status(request, ausschreibung_id, pk): + ausschreibung = get_object_or_404(Ausschreibung, pk=ausschreibung_id) + bieterfrage = get_object_or_404(Bieterfrage, pk=pk, ausschreibung=ausschreibung) + if request.method == 'POST': + neuer_status = request.POST.get('status') + if neuer_status: + bieterfrage.status = neuer_status + if neuer_status == 'eingereicht' and not bieterfrage.einreichungsdatum: + bieterfrage.einreichungsdatum = date.today() + if neuer_status == 'eingearbeitet': + bieterfrage.eingearbeitet = True + bieterfrage.save() + return redirect('ausschreibungen:bieterfragen:detail', + ausschreibung_id=ausschreibung_id, pk=pk) + + +def bieterfrage_antwort(request, ausschreibung_id, pk): + ausschreibung = get_object_or_404(Ausschreibung, pk=ausschreibung_id) + bieterfrage = get_object_or_404(Bieterfrage, pk=pk, ausschreibung=ausschreibung) + if request.method == 'POST': + bieterfrage.antwort = request.POST.get('antwort', bieterfrage.antwort) + bieterfrage.auswirkung_angebot = request.POST.get('auswirkung_angebot', bieterfrage.auswirkung_angebot) + if bieterfrage.status == 'eingereicht': + bieterfrage.status = 'beantwortet' + bieterfrage.save() + return redirect('ausschreibungen:bieterfragen:detail', + ausschreibung_id=ausschreibung_id, pk=pk) + return render(request, 'aufgaben/bieterfrage_detail.html', { + 'bieterfrage': bieterfrage, + 'ausschreibung': ausschreibung, + 'show_antwort_form': True, + 'breadcrumbs': [], + }) diff --git a/vergabe_teilnahme/templates/aufgaben/bieterfrage_detail.html b/vergabe_teilnahme/templates/aufgaben/bieterfrage_detail.html new file mode 100644 index 0000000..c0a9d4d --- /dev/null +++ b/vergabe_teilnahme/templates/aufgaben/bieterfrage_detail.html @@ -0,0 +1,126 @@ +{% extends "base.html" %} +{% load vergabe_tags %} +{% block title %}Bieterfrage{% endblock %} +{% block content %} + +
+

Bieterfrage

+ ← Übersicht +
+ +
+
+ +
+

{{ bieterfrage.fragentext }}

+ {% if bieterfrage.begruendung %} +
+

Begründung

+

{{ bieterfrage.begruendung }}

+
+ {% endif %} +
+ + {% if bieterfrage.antwort or bieterfrage.status == 'eingereicht' or bieterfrage.status == 'beantwortet' or bieterfrage.status == 'eingearbeitet' or show_antwort_form %} +
+

Antwort

+ {% if bieterfrage.antwort %} +

{{ bieterfrage.antwort }}

+ {% if bieterfrage.auswirkung_angebot %} +
+

Auswirkung auf Angebot

+

{{ bieterfrage.auswirkung_angebot }}

+
+ {% endif %} + {% endif %} + {% if bieterfrage.status == 'eingereicht' or bieterfrage.status == 'beantwortet' or show_antwort_form %} +
+ {% csrf_token %} +
+ + +
+
+ + +
+ +
+ {% endif %} +
+ {% endif %} + +
+ +
+ +
+

Status-Verlauf

+ +
    + {% for val, label in bieterfrage.STATUS_CHOICES %} +
  1. + + +

    + {{ label }} + {% if val == 'eingereicht' and bieterfrage.einreichungsdatum %} + ({{ bieterfrage.einreichungsdatum }}) + {% endif %} +

    +
  2. + {% endfor %} +
+ + {% if bieterfrage.status == 'entwurf' %} +
+ {% csrf_token %} + + +
+ {% elif bieterfrage.status == 'abgestimmt' %} +
+ {% csrf_token %} + + +
+ {% elif bieterfrage.status == 'eingereicht' %} +
+ {% csrf_token %} + + +
+ {% elif bieterfrage.status == 'beantwortet' %} +
+ {% csrf_token %} + + +
+ {% endif %} +
+ +
+

Details

+

Priorität: {{ bieterfrage.get_prioritaet_display }}

+ {% if bieterfrage.verfasser %} +

Verfasser: {{ bieterfrage.verfasser }}

+ {% endif %} + {% if bieterfrage.anforderung %} + + {% endif %} +
+ +
+
+ +{% endblock %} diff --git a/vergabe_teilnahme/templates/aufgaben/bieterfrage_form.html b/vergabe_teilnahme/templates/aufgaben/bieterfrage_form.html new file mode 100644 index 0000000..df8b232 --- /dev/null +++ b/vergabe_teilnahme/templates/aufgaben/bieterfrage_form.html @@ -0,0 +1,50 @@ +{% extends "base.html" %} +{% block title %}{{ titel }}{% endblock %} +{% block content %} +
+

{{ titel }}

+
+ {% csrf_token %} +
+
+ + {{ form.fragentext }} + {% if form.fragentext.errors %}

{{ form.fragentext.errors.0 }}

{% endif %} +
+
+ + {{ form.begruendung }} +
+
+ +
+ {% for radio in form.prioritaet %} + + {% endfor %} +
+
+
+
+

Verknüpfungen (optional)

+
+ + {{ form.anforderung }} +
+
+ + {{ form.dokument }} +
+
+ + {{ form.verfasser }} +
+
+
+ + Abbrechen +
+
+
+{% endblock %} diff --git a/vergabe_teilnahme/templates/aufgaben/bieterfragen_liste.html b/vergabe_teilnahme/templates/aufgaben/bieterfragen_liste.html new file mode 100644 index 0000000..4dd9449 --- /dev/null +++ b/vergabe_teilnahme/templates/aufgaben/bieterfragen_liste.html @@ -0,0 +1,80 @@ +{% extends "base.html" %} +{% load vergabe_tags %} +{% block title %}Bieterfragen — {{ ausschreibung.titel }}{% endblock %} +{% block content %} + +
+

Bieterfragen

+ + Bieterfrage anlegen +
+ +{% if ausschreibung.bieterfragen_frist %} +{% with tage=ausschreibung.bieterfragen_frist|timeuntil %} +
+ Bieterfragen bis: {{ ausschreibung.bieterfragen_frist|date:"d.m.Y" }} + {% if tage != '0 minutes' %}— noch {{ tage }}{% else %}— Frist abgelaufen{% endif %} +
+{% endwith %} +{% endif %} + +
+
+ + +
+
+ + +
+
+ +
+ {% if bieterfragen %} + + + + + + + + + + + + {% for bf in bieterfragen %} + + + + + + + + {% endfor %} + +
FrageStatusPrioritätEingereichtVerfasser
+ + {{ bf.fragentext|truncatechars:80 }} + + {% if bf.anforderung %} +

Anforderung: {{ bf.anforderung.titel|truncatechars:40 }}

+ {% endif %} +
{% status_badge bf.status bf.get_status_display %}{{ bf.get_prioritaet_display }}{{ bf.einreichungsdatum|default:"—" }}{{ bf.verfasser|default:"—" }}
+ {% else %} +

Noch keine Bieterfragen angelegt.

+ {% endif %} +
+ +{% endblock %} diff --git a/vergabe_teilnahme/templates/aufgaben/detail.html b/vergabe_teilnahme/templates/aufgaben/detail.html new file mode 100644 index 0000000..21392ab --- /dev/null +++ b/vergabe_teilnahme/templates/aufgaben/detail.html @@ -0,0 +1,67 @@ +{% extends "base.html" %} +{% load vergabe_tags %} +{% block title %}{{ aufgabe.titel }}{% endblock %} +{% block content %} + +
+

{{ aufgabe.titel }}

+ +
+ +
+
+
+ {% render_field aufgabe "typ" "Typ" %} + {% render_field aufgabe "prioritaet" "Priorität" %} + {% render_field aufgabe "status" "Status" %} + {% render_field aufgabe "frist" "Frist" %} + {% render_field aufgabe "verantwortlicher" "Verantwortlich" %} + {% if aufgabe.beschreibung %} +
+

Beschreibung

+

{{ aufgabe.beschreibung }}

+
+ {% endif %} +
+ + {% if aufgabe.status == 'erledigt' or aufgabe.ergebnis %} +
+

Ergebnis

+
+ {% csrf_token %} + + +
+
+ {% endif %} +
+ +
+ {% if aufgabe.anforderung %} + + {% endif %} + {% if aufgabe.bieterfrage %} +
+

Bieterfrage

+ {{ aufgabe.bieterfrage }} +
+ {% endif %} + {% if aufgabe.los %} + + {% endif %} +
+
+ +{% endblock %} diff --git a/vergabe_teilnahme/templates/aufgaben/form.html b/vergabe_teilnahme/templates/aufgaben/form.html new file mode 100644 index 0000000..c7018a3 --- /dev/null +++ b/vergabe_teilnahme/templates/aufgaben/form.html @@ -0,0 +1,64 @@ +{% extends "base.html" %} +{% block title %}{{ titel }}{% endblock %} +{% block content %} +
+

{{ titel }}

+
+ {% csrf_token %} +
+
+ + {{ form.titel }} + {% if form.titel.errors %}

{{ form.titel.errors.0 }}

{% endif %} +
+
+ + {{ form.beschreibung }} +
+
+
+ + {{ form.typ }} +
+
+ + {{ form.frist }} +
+
+
+ +
+ {% for radio in form.prioritaet %} + + {% endfor %} +
+
+
+ + {{ form.verantwortlicher }} +
+
+
+

Verknüpfungen (optional)

+
+ + {{ form.los }} +
+
+ + {{ form.anforderung }} +
+
+ + {{ form.bieterfrage }} +
+
+
+ + Abbrechen +
+
+
+{% endblock %} diff --git a/vergabe_teilnahme/templates/aufgaben/liste.html b/vergabe_teilnahme/templates/aufgaben/liste.html new file mode 100644 index 0000000..f09c93e --- /dev/null +++ b/vergabe_teilnahme/templates/aufgaben/liste.html @@ -0,0 +1,57 @@ +{% extends "base.html" %} +{% load vergabe_tags %} +{% block title %}Aufgaben{% if ausschreibung %} — {{ ausschreibung.titel }}{% endif %}{% endblock %} +{% block content %} + +
+

Aufgaben

+ {% if ausschreibung %} + + Aufgabe anlegen + {% endif %} +
+ +
+ {% csrf_token %} +
+ + +
+
+ + +
+
+ + +
+ {% if not ausschreibung %} + + {% endif %} +
+ +
+ {% include "aufgaben/liste_partial.html" %} +
+ +{% endblock %} diff --git a/vergabe_teilnahme/templates/aufgaben/liste_partial.html b/vergabe_teilnahme/templates/aufgaben/liste_partial.html new file mode 100644 index 0000000..aacd7ca --- /dev/null +++ b/vergabe_teilnahme/templates/aufgaben/liste_partial.html @@ -0,0 +1,22 @@ +{% load vergabe_tags %} +{% if aufgaben %} + + + + + + + + + + + + + {% for aufgabe in aufgaben %} + {% include "aufgaben/partials/aufgabe_row.html" %} + {% endfor %} + +
TitelTypFristVerantwortlichStatusErgebnis
+{% else %} +

Keine Aufgaben gefunden.

+{% endif %} diff --git a/vergabe_teilnahme/templates/aufgaben/loeschen_confirm.html b/vergabe_teilnahme/templates/aufgaben/loeschen_confirm.html new file mode 100644 index 0000000..ab536f1 --- /dev/null +++ b/vergabe_teilnahme/templates/aufgaben/loeschen_confirm.html @@ -0,0 +1,18 @@ +{% extends "base.html" %} +{% block title %}Aufgabe verwerfen{% endblock %} +{% block content %} +
+
+

Aufgabe verwerfen?

+

+ Die Aufgabe wird nicht gelöscht, sondern auf Status Verworfen gesetzt. +

+

{{ aufgabe.titel }}

+
+ {% csrf_token %} + + Abbrechen +
+
+
+{% endblock %} diff --git a/vergabe_teilnahme/templates/aufgaben/partials/aufgabe_row.html b/vergabe_teilnahme/templates/aufgaben/partials/aufgabe_row.html new file mode 100644 index 0000000..32c0f92 --- /dev/null +++ b/vergabe_teilnahme/templates/aufgaben/partials/aufgabe_row.html @@ -0,0 +1,45 @@ +{% load vergabe_tags %} + + + + {{ aufgabe.titel|truncatechars:60 }} + + + {% status_badge aufgabe.typ aufgabe.get_typ_display %} + + {% if aufgabe.frist %} + + {{ aufgabe.frist }} + + {% else %} + + {% endif %} + + {{ aufgabe.verantwortlicher|default:"—" }} + + + + {% if aufgabe.status == 'erledigt' %} + +
+ {% csrf_token %} + + +
+ + {% else %} + {{ aufgabe.ergebnis|truncatechars:30 }} + {% endif %} + diff --git a/vergabe_teilnahme/urls.py b/vergabe_teilnahme/urls.py index 02deb1f..1e3876e 100644 --- a/vergabe_teilnahme/urls.py +++ b/vergabe_teilnahme/urls.py @@ -25,7 +25,7 @@ urlpatterns = [ path('', home, name='home'), path('ausschreibungen/', include('vergabe_teilnahme.apps.ausschreibungen.urls', namespace='ausschreibungen')), path('lose/', include('vergabe_teilnahme.apps.lose.urls')), - path('aufgaben/', include('vergabe_teilnahme.apps.aufgaben.urls')), + path('aufgaben/', include('vergabe_teilnahme.apps.aufgaben.global_urls')), path('dokumente/', include('vergabe_teilnahme.apps.dokumente.urls')), path('preise/', include('vergabe_teilnahme.apps.preise.urls')), path('partner/', include('vergabe_teilnahme.apps.partner.urls')), diff --git a/workplans/WP-0006-aufgaben-bieterfragen.md b/workplans/WP-0006-aufgaben-bieterfragen.md index 75bf65e..dc3648f 100644 --- a/workplans/WP-0006-aufgaben-bieterfragen.md +++ b/workplans/WP-0006-aufgaben-bieterfragen.md @@ -1,7 +1,7 @@ --- id: WP-0006 title: Aufgaben und Bieterfragen -status: todo +status: done phase: 6-of-12 created: "2026-05-08" depends_on: WP-0005 @@ -17,7 +17,7 @@ Implementiert alle Views für Aufgaben (UC-AU-01 bis UC-AU-04) und Bieterfragen ```task id: WP-0006-T01 title: Aufgabenliste pro Ausschreibung und globale Liste (UC-OV-03, UC-AU-01) -status: todo +status: done `aufgaben/views.py` — aufgaben_liste: @@ -45,7 +45,7 @@ Globale URL in Haupt-urls.py: `path('aufgaben/', include('vergabe_teilnahme.apps ```task id: WP-0006-T02 title: Aufgabe anlegen und zuweisen (UC-AU-01) -status: todo +status: done `AufgabeForm(ModelForm)`: - `typ` als Select, `prioritaet` als Radio (Hoch/Mittel/Niedrig) @@ -63,7 +63,7 @@ status: todo ```task id: WP-0006-T03 title: Aufgabenstatus inline ändern und Ergebnis dokumentieren (UC-AU-03) -status: todo +status: done **Status-Widget** (analog zum Ausschreibungs-Status-Widget): Jede Zeile in der Aufgabenliste enthält ein Status-Dropdown: @@ -89,7 +89,7 @@ Nutzer kann Ergebnis eintragen und separat abspeichern. ```task id: WP-0006-T04 title: Bieterfragen-Liste und Bieterfrage anlegen (UC-BF-01, UC-BF-02) -status: todo +status: done `aufgaben/views.py` — bieterfragen_liste und bieterfrage_neu: @@ -118,7 +118,7 @@ path('/antwort/', views.bieterfrage_antwort, name='antwort'), ```task id: WP-0006-T05 title: Bieterfragen-Workflow und Antwort einarbeiten (UC-BF-03) -status: todo +status: done `bieterfrage_status (POST)`: Ermöglicht Status-Wechsel über definierte Übergänge: entwurf → abgestimmt → eingereicht → beantwortet → eingearbeitet. @@ -138,7 +138,7 @@ Auf der Bieterfragen-Detailseite: ```task id: WP-0006-T06 title: Aufgaben- und Bieterfragen-Tests -status: todo +status: done `aufgaben/tests/test_views.py`: - Test: Aufgabenliste gibt 200 zurück