Aufgaben und Bieterfragen

This commit is contained in:
tegwick 2026-05-08 17:43:23 +02:00
parent f202b71c75
commit 70ece97587
17 changed files with 1038 additions and 14 deletions

View file

@ -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('<int:pk>/', views.bieterfrage_detail, name='detail'),
path('<int:pk>/status/', views.bieterfrage_status, name='status'),
path('<int:pk>/antwort/', views.bieterfrage_antwort, name='antwort'),
]

View file

@ -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

View file

@ -0,0 +1,7 @@
from django.urls import path
from . import views
urlpatterns = [
path('', views.aufgaben_liste, name='aufgaben_global'),
]

View file

@ -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'

View file

@ -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('<int:pk>/', views.aufgabe_detail, name='detail'),
path('<int:pk>/bearbeiten/', views.aufgabe_bearbeiten, name='bearbeiten'),
path('<int:pk>/loeschen/', views.aufgabe_loeschen, name='loeschen'),
path('<int:pk>/status/', views.aufgabe_status, name='status'),
path('<int:pk>/ergebnis/', views.aufgabe_ergebnis, name='ergebnis'),
]

View file

@ -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': [],
})