We are building, workplan now registered with statehub

This commit is contained in:
tegwick 2026-05-08 17:03:11 +02:00
parent 14b0bc6d01
commit f202b71c75
37 changed files with 2036 additions and 22 deletions

View file

@ -0,0 +1,3 @@
from django.urls import path
urlpatterns = []

View file

@ -0,0 +1,40 @@
from django import forms
from .models import Ausschreibung
class AusschreibungForm(forms.ModelForm):
class Meta:
model = Ausschreibung
fields = [
'titel', 'ausschreiber', 'vergabeplattform', 'vergabenummer', 'vergabeart',
'fundstelle_url', 'bid_manager', 'leistungsbeschreibung',
'branche', 'schlagwoerter', 'geschaetztes_volumen',
'veroeffentlichungsdatum', 'bieterfragen_bis', 'abgabe_bis', 'bindefrist',
'unterlagen_erhalten', 'unterlagen_erhalten_am',
'teilnahmeentscheidung', 'entscheidungsbegruendung',
]
widgets = {
'titel': forms.TextInput(attrs={'class': 'form-input', 'autofocus': True}),
'ausschreiber': forms.TextInput(attrs={'class': 'form-input'}),
'vergabeplattform': forms.TextInput(attrs={'class': 'form-input'}),
'vergabenummer': forms.TextInput(attrs={'class': 'form-input'}),
'vergabeart': forms.Select(attrs={'class': 'form-input'}),
'fundstelle_url': forms.URLInput(attrs={'class': 'form-input'}),
'bid_manager': forms.Select(attrs={'class': 'form-input'}),
'leistungsbeschreibung': forms.Textarea(attrs={'class': 'form-input', 'rows': 4}),
'branche': forms.TextInput(attrs={'class': 'form-input'}),
'schlagwoerter': forms.TextInput(attrs={'class': 'form-input'}),
'geschaetztes_volumen': forms.NumberInput(attrs={'class': 'form-input'}),
'veroeffentlichungsdatum': forms.DateInput(attrs={'class': 'form-input', 'type': 'date'}),
'bieterfragen_bis': forms.DateInput(attrs={'class': 'form-input', 'type': 'date'}),
'abgabe_bis': forms.DateTimeInput(attrs={'class': 'form-input', 'type': 'datetime-local'}),
'bindefrist': forms.DateInput(attrs={'class': 'form-input', 'type': 'date'}),
'unterlagen_erhalten_am': forms.DateInput(attrs={'class': 'form-input', 'type': 'date'}),
'teilnahmeentscheidung': forms.Select(attrs={'class': 'form-input'}),
'entscheidungsbegruendung': forms.Textarea(attrs={'class': 'form-input', 'rows': 3}),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['teilnahmeentscheidung'].required = False

View file

@ -0,0 +1,18 @@
# Generated by Django 6.0.5 on 2026-05-08 12:39
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('ausschreibungen', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='ausschreibung',
name='archiviert',
field=models.BooleanField(db_index=True, default=False),
),
]

View file

@ -74,6 +74,8 @@ class Ausschreibung(FlexibleModel):
unterlagen_erhalten = models.BooleanField(default=False)
unterlagen_erhalten_am = models.DateField(null=True, blank=True)
archiviert = models.BooleanField(default=False, db_index=True)
# Timestamps
erstellt_am = models.DateTimeField(auto_now_add=True)
geaendert_am = models.DateTimeField(auto_now=True)

View file

@ -0,0 +1,49 @@
from datetime import date
def entscheidungsregel_auswertung(ausschreibung):
from vergabe_teilnahme.apps.bibliothek.models import Entscheidungsregel
regeln = Entscheidungsregel.objects.filter(aktiv=True).order_by('-gewichtung')
return [
{
'regel': regel,
**_wende_regel_an(regel, ausschreibung),
}
for regel in regeln
]
def _wende_regel_an(regel, ausschreibung):
kat = regel.kategorie
if kat == 'ausschlusskriterium' and hasattr(ausschreibung, 'anforderungen'):
hat_ausschluss = ausschreibung.anforderungen.filter(
ausschlusskriterium=True, erfuellungsstatus='nicht_erfuellbar'
).exists()
if hat_ausschluss:
return {
'empfehlung': 'nicht_teilnehmen',
'begruendung': 'Nicht erfüllbares Ausschlusskriterium vorhanden.',
'warnung': True,
}
if kat == 'frist' and ausschreibung.abgabe_bis:
abgabe_date = (
ausschreibung.abgabe_bis.date()
if hasattr(ausschreibung.abgabe_bis, 'date')
else ausschreibung.abgabe_bis
)
delta = (abgabe_date - date.today()).days
if regel.schwellenwert and delta < int(regel.schwellenwert):
return {
'empfehlung': 'nicht_teilnehmen',
'begruendung': f'Restlaufzeit {delta} Tage liegt unter Schwellenwert {int(regel.schwellenwert)} Tage.',
'warnung': True,
}
return {
'empfehlung': 'pruefen',
'begruendung': regel.begruendung or '',
'warnung': False,
}

View file

@ -1,3 +1,100 @@
from django.test import TestCase
import factory
import pytest
from django.urls import reverse
# Create your tests here.
from .models import Ausschreibung
class AusschreibungFactory(factory.django.DjangoModelFactory):
class Meta:
model = Ausschreibung
titel = factory.Sequence(lambda n: f"Ausschreibung {n}")
ausschreiber = "Testausschreiber GmbH"
status = 1
# --- Model tests ---
@pytest.mark.django_db
def test_ausschreibung_str():
a = AusschreibungFactory(titel="Test Ausschreibung")
assert str(a) == "Test Ausschreibung"
@pytest.mark.django_db
@pytest.mark.parametrize("status,expected", [
(1, True), (5, True), (9, True),
(10, False), (11, False), (13, False),
])
def test_ist_aktiv(status, expected):
a = AusschreibungFactory(status=status)
assert a.ist_aktiv == expected
@pytest.mark.django_db
def test_naechste_frist_returns_earlier():
from datetime import date, timedelta
heute = date.today()
a = AusschreibungFactory(
bieterfragen_bis=heute + timedelta(days=5),
abgabe_bis=heute + timedelta(days=10),
)
assert a.naechste_frist == heute + timedelta(days=5)
@pytest.mark.django_db
def test_naechste_frist_none_when_past():
from datetime import date, timedelta
gestern = date.today() - timedelta(days=1)
a = AusschreibungFactory(bieterfragen_bis=gestern, abgabe_bis=None)
assert a.naechste_frist is None
# --- View tests ---
@pytest.mark.django_db
def test_liste_get(client):
response = client.get(reverse("ausschreibungen:liste"))
assert response.status_code == 200
@pytest.mark.django_db
def test_neu_get(client):
response = client.get(reverse("ausschreibungen:neu"))
assert response.status_code == 200
@pytest.mark.django_db
def test_neu_post_valid(client):
data = {"titel": "Neue Ausschreibung", "ausschreiber": "Stadt XY"}
response = client.post(reverse("ausschreibungen:neu"), data)
assert response.status_code == 302
a = Ausschreibung.objects.get(titel="Neue Ausschreibung")
assert response.url == reverse("ausschreibungen:detail", kwargs={"pk": a.pk})
@pytest.mark.django_db
def test_detail_get(client):
a = AusschreibungFactory()
response = client.get(reverse("ausschreibungen:detail", kwargs={"pk": a.pk}))
assert response.status_code == 200
@pytest.mark.django_db
def test_status_post(client):
a = AusschreibungFactory(status=1)
url = reverse("ausschreibungen:status", kwargs={"pk": a.pk})
response = client.post(url, {"status": "4"})
assert response.status_code == 200
a.refresh_from_db()
assert a.status == 4
@pytest.mark.django_db
def test_status_htmx_returns_partial(client):
a = AusschreibungFactory(status=1)
url = reverse("ausschreibungen:status", kwargs={"pk": a.pk})
response = client.post(url, {"status": "3"}, HTTP_HX_REQUEST="true")
assert response.status_code == 200
assert b"status-widget" in response.content

View file

@ -1,9 +1,24 @@
from django.urls import path
from django.urls import include, path
from . import views
app_name = 'ausschreibungen'
urlpatterns = [
path('', views.dashboard, name='dashboard'),
path('', views.ausschreibung_liste, name='liste'),
path('dashboard/', views.dashboard, name='dashboard'),
path('neu/', views.ausschreibung_neu, name='neu'),
path('<int:pk>/', views.ausschreibung_detail, name='detail'),
path('<int:pk>/bearbeiten/', views.ausschreibung_bearbeiten, name='bearbeiten'),
path('<int:pk>/status/', views.ausschreibung_status, name='status'),
path('<int:pk>/entscheidung/', views.ausschreibung_entscheidung, name='entscheidung'),
path('<int:pk>/archivieren/', views.ausschreibung_archivieren, name='archivieren'),
path('<int:ausschreibung_id>/lose/', include('vergabe_teilnahme.apps.lose.urls')),
path('<int:ausschreibung_id>/aufgaben/', include('vergabe_teilnahme.apps.aufgaben.urls')),
path('<int:ausschreibung_id>/bieterfragen/', include('vergabe_teilnahme.apps.aufgaben.bieterfragen_urls')),
path('<int:ausschreibung_id>/dokumente/', include('vergabe_teilnahme.apps.dokumente.urls')),
path('<int:ausschreibung_id>/preise/', include('vergabe_teilnahme.apps.preise.urls')),
path('<int:ausschreibung_id>/abgabe/', include('vergabe_teilnahme.apps.nachbetrachtung.abgabe_urls')),
path('<int:ausschreibung_id>/nachbetrachtung/', include('vergabe_teilnahme.apps.nachbetrachtung.urls')),
path('<int:ausschreibung_id>/marktbegleiter/', include('vergabe_teilnahme.apps.marktbegleiter.passagen_urls')),
]

View file

@ -1,7 +1,195 @@
from django.shortcuts import render
from datetime import date, timedelta
from django.shortcuts import get_object_or_404, redirect, render
from django.utils import timezone
from vergabe_teilnahme.apps.accounts.models import Mitarbeiter
from .models import Ausschreibung
def _is_htmx(request):
return request.headers.get('HX-Request') == 'true'
def dashboard(request):
return render(request, 'ausschreibungen/dashboard.html', {
from vergabe_teilnahme.apps.aufgaben.models import Aufgabe
heute = date.today()
in_14_tagen = heute + timedelta(days=14)
ctx = {
'kritische_fristen': Ausschreibung.objects.filter(
abgabe_bis__date__lte=in_14_tagen,
abgabe_bis__date__gte=heute,
status__lt=10,
).order_by('abgabe_bis')[:10],
'ohne_entscheidung': Ausschreibung.objects.filter(
teilnahmeentscheidung='offen',
erstellt_am__lte=timezone.now() - timedelta(days=3),
status__lt=10,
).order_by('erstellt_am')[:10],
'ueberfaellige_aufgaben': Aufgabe.objects.filter(
frist__lt=heute,
status__in=['offen', 'in_bearbeitung', 'wartend_intern', 'wartend_sub', 'wartend_ausschreiber'],
).select_related('ausschreibung').order_by('frist')[:15],
'laufende_ausschreibungen': Ausschreibung.objects.filter(
status__range=(1, 9),
).order_by('-geaendert_am')[:10],
'breadcrumbs': [{'label': 'Übersicht', 'url': None}],
}
return render(request, 'ausschreibungen/dashboard.html', ctx)
def ausschreibung_liste(request):
qs = Ausschreibung.objects.all()
status_filter = request.GET.get('status')
if status_filter:
qs = qs.filter(status=status_filter)
archiviert = request.GET.get('archiviert', '0') == '1'
qs = qs.filter(archiviert=archiviert)
bid_manager_filter = request.GET.get('bid_manager')
if bid_manager_filter:
qs = qs.filter(bid_manager=bid_manager_filter)
qs = qs.select_related('bid_manager').order_by('-geaendert_am')
ctx = {
'ausschreibungen': qs,
'status_choices': Ausschreibung.STATUS_CHOICES,
'mitarbeiter': Mitarbeiter.objects.all(),
'archiviert': archiviert,
'current_status': status_filter or '',
'current_bid_manager': bid_manager_filter or '',
'breadcrumbs': [{'label': 'Ausschreibungen', 'url': None}],
}
if _is_htmx(request):
return render(request, 'ausschreibungen/liste_partial.html', ctx)
return render(request, 'ausschreibungen/liste.html', ctx)
def ausschreibung_neu(request):
from .forms import AusschreibungForm
historisch = request.GET.get('historisch') == '1'
if request.method == 'POST':
form = AusschreibungForm(request.POST)
if form.is_valid():
a = form.save()
return redirect('ausschreibungen:detail', pk=a.pk)
else:
form = AusschreibungForm()
return render(request, 'ausschreibungen/form.html', {
'form': form,
'historisch': historisch,
'titel': 'Neue Ausschreibung',
'breadcrumbs': [
{'label': 'Ausschreibungen', 'url': '/ausschreibungen/'},
{'label': 'Neu', 'url': None},
],
})
def ausschreibung_detail(request, pk):
from vergabe_teilnahme.apps.core.services import build_phase_nav, get_deadline_warnings
a = get_object_or_404(Ausschreibung, pk=pk)
ctx = {
'ausschreibung': a,
'ausschreibung_id': pk,
'phases': build_phase_nav(a),
'warnungen': get_deadline_warnings(a),
'breadcrumbs': [
{'label': 'Ausschreibungen', 'url': '/ausschreibungen/'},
{'label': a.titel, 'url': None},
],
}
return render(request, 'ausschreibungen/detail.html', ctx)
def ausschreibung_bearbeiten(request, pk):
from .forms import AusschreibungForm
a = get_object_or_404(Ausschreibung, pk=pk)
form = AusschreibungForm(request.POST or None, instance=a)
if request.method == 'POST' and form.is_valid():
form.save()
return redirect('ausschreibungen:detail', pk=pk)
return render(request, 'ausschreibungen/form.html', {
'form': form,
'titel': 'Ausschreibung bearbeiten',
'ausschreibung': a,
'breadcrumbs': [
{'label': 'Ausschreibungen', 'url': '/ausschreibungen/'},
{'label': a.titel, 'url': f'/ausschreibungen/{pk}/'},
{'label': 'Bearbeiten', 'url': None},
],
})
def ausschreibung_status(request, pk):
a = get_object_or_404(Ausschreibung, pk=pk)
if request.method == 'POST':
neuer_status = request.POST.get('status')
if neuer_status and neuer_status.isdigit():
a.status = int(neuer_status)
a.save(update_fields=['status', 'geaendert_am'])
return render(request, 'ausschreibungen/partials/status_widget.html', {'ausschreibung': a})
def ausschreibung_entscheidung(request, pk):
from .services import entscheidungsregel_auswertung
a = get_object_or_404(Ausschreibung, pk=pk)
if request.method == 'POST':
a.teilnahmeentscheidung = request.POST.get('teilnahmeentscheidung', 'offen')
a.entscheidungsbegruendung = request.POST.get('begruendung', a.entscheidungsbegruendung)
if a.teilnahmeentscheidung in ['teilnahme', 'ablehnung']:
a.status = max(a.status, 3)
a.save()
return redirect('ausschreibungen:detail', pk=pk)
from vergabe_teilnahme.apps.lose.models import Anforderung
ausschlusskriterien = Anforderung.objects.filter(
ausschreibung=a,
ausschlusskriterium=True,
erfuellungsstatus='nicht_erfuellbar',
).select_related('los')
ctx = {
'ausschreibung': a,
'regelergebnis': entscheidungsregel_auswertung(a),
'ausschlusskriterien_nicht_erfuellbar': ausschlusskriterien,
'breadcrumbs': [
{'label': 'Ausschreibungen', 'url': '/ausschreibungen/'},
{'label': a.titel, 'url': f'/ausschreibungen/{pk}/'},
{'label': 'Teilnahmeentscheidung', 'url': None},
],
}
return render(request, 'ausschreibungen/entscheidung.html', ctx)
def ausschreibung_archivieren(request, pk):
a = get_object_or_404(Ausschreibung, pk=pk)
if request.method == 'POST':
a.archiviert = True
a.status = 13
a.save(update_fields=['archiviert', 'status', 'geaendert_am'])
return redirect('ausschreibungen:liste')
return render(request, 'ausschreibungen/archivieren_confirm.html', {
'ausschreibung': a,
'breadcrumbs': [
{'label': 'Ausschreibungen', 'url': '/ausschreibungen/'},
{'label': a.titel, 'url': f'/ausschreibungen/{pk}/'},
{'label': 'Archivieren', 'url': None},
],
})

View file

@ -0,0 +1,52 @@
from django import forms
from vergabe_teilnahme.apps.accounts.models import Mitarbeiter
from .models import Anforderung, Los
class LosForm(forms.ModelForm):
class Meta:
model = Los
fields = ['losnummer', 'lostitel', 'beschreibung', 'abgrenzung', 'zustaendiger', 'teilnahme']
widgets = {
'losnummer': forms.TextInput(attrs={'class': 'form-input', 'autofocus': True}),
'lostitel': forms.TextInput(attrs={'class': 'form-input'}),
'beschreibung': forms.Textarea(attrs={'class': 'form-input', 'rows': 3}),
'abgrenzung': forms.Textarea(attrs={'class': 'form-input', 'rows': 3}),
'zustaendiger': forms.Select(attrs={'class': 'form-input'}),
'teilnahme': forms.NullBooleanSelect(attrs={'class': 'form-input'}),
}
def __init__(self, *args, **kwargs):
ausschreibung = kwargs.pop('ausschreibung', None)
super().__init__(*args, **kwargs)
self.fields['teilnahme'].required = False
class AnforderungForm(forms.ModelForm):
class Meta:
model = Anforderung
fields = [
'titel', 'beschreibung', 'quelle_im_dokument', 'kategorie', 'verbindlichkeit',
'ausschlusskriterium', 'bewertungskriterium', 'zustaendiger',
'erfuellungsstatus', 'nachweis_erforderlich', 'los',
]
widgets = {
'titel': forms.TextInput(attrs={'class': 'form-input', 'autofocus': True}),
'beschreibung': forms.Textarea(attrs={'class': 'form-input', 'rows': 3}),
'quelle_im_dokument': forms.TextInput(attrs={'class': 'form-input'}),
'kategorie': forms.Select(attrs={'class': 'form-input'}),
'verbindlichkeit': forms.RadioSelect(),
'zustaendiger': forms.Select(attrs={'class': 'form-input'}),
'erfuellungsstatus': forms.Select(attrs={'class': 'form-input'}),
'los': forms.Select(attrs={'class': 'form-input'}),
}
def __init__(self, *args, ausschreibung=None, **kwargs):
super().__init__(*args, **kwargs)
if ausschreibung is not None:
self.fields['los'].queryset = Los.objects.filter(ausschreibung=ausschreibung)
self.fields['los'].required = False
self.fields['zustaendiger'].required = False
self.fields['kategorie'].required = False

View file

@ -1,3 +1,114 @@
from django.test import TestCase
import factory
import pytest
from django.urls import reverse
# Create your tests here.
from vergabe_teilnahme.apps.ausschreibungen.models import Ausschreibung
from vergabe_teilnahme.apps.ausschreibungen.tests import AusschreibungFactory
from vergabe_teilnahme.apps.bibliothek.models import Nachweis
from .models import Anforderung, Los
class LosFactory(factory.django.DjangoModelFactory):
class Meta:
model = Los
ausschreibung = factory.SubFactory(AusschreibungFactory)
losnummer = factory.Sequence(lambda n: f"L{n:02d}")
lostitel = factory.Sequence(lambda n: f"Los {n}")
class AnforderungFactory(factory.django.DjangoModelFactory):
class Meta:
model = Anforderung
ausschreibung = factory.SubFactory(AusschreibungFactory)
titel = factory.Sequence(lambda n: f"Anforderung {n}")
verbindlichkeit = 'muss'
erfuellungsstatus = 'offen'
class NachweisFactory(factory.django.DjangoModelFactory):
class Meta:
model = Nachweis
titel = factory.Sequence(lambda n: f"Nachweis {n}")
# ─── Lose ──────────────────────────────────────────────────────────────────
@pytest.mark.django_db
def test_lose_liste_get(client):
a = AusschreibungFactory()
url = reverse('ausschreibungen:lose:liste', kwargs={'ausschreibung_id': a.pk})
response = client.get(url)
assert response.status_code == 200
@pytest.mark.django_db
def test_los_neu_post(client):
a = AusschreibungFactory()
url = reverse('ausschreibungen:lose:neu', kwargs={'ausschreibung_id': a.pk})
response = client.post(url, {'losnummer': 'L01', 'lostitel': 'Testlos'})
assert response.status_code == 302
assert Los.objects.filter(ausschreibung=a, losnummer='L01').exists()
@pytest.mark.django_db
def test_los_detail_get(client):
los = LosFactory()
url = reverse('ausschreibungen:lose:detail',
kwargs={'ausschreibung_id': los.ausschreibung_id, 'los_pk': los.pk})
response = client.get(url)
assert response.status_code == 200
# ─── Anforderungen ─────────────────────────────────────────────────────────
@pytest.mark.django_db
def test_anforderung_neu_post_muss(client):
a = AusschreibungFactory()
url = reverse('ausschreibungen:lose:anforderung_neu', kwargs={'ausschreibung_id': a.pk})
response = client.post(url, {'titel': 'Neue Anforderung', 'verbindlichkeit': 'muss',
'erfuellungsstatus': 'offen'})
assert response.status_code == 302
assert Anforderung.objects.filter(ausschreibung=a, titel='Neue Anforderung').exists()
@pytest.mark.django_db
def test_anforderung_status_htmx(client):
anf = AnforderungFactory()
url = reverse('ausschreibungen:lose:anforderung_status',
kwargs={'ausschreibung_id': anf.ausschreibung_id, 'pk': anf.pk})
response = client.post(url, {'erfuellungsstatus': 'nicht_erfuellbar'},
HTTP_HX_REQUEST='true')
assert response.status_code == 200
anf.refresh_from_db()
assert anf.erfuellungsstatus == 'nicht_erfuellbar'
@pytest.mark.django_db
def test_ausschlusskriterium_banner_shown(client):
a = AusschreibungFactory()
AnforderungFactory(
ausschreibung=a,
ausschlusskriterium=True,
erfuellungsstatus='nicht_erfuellbar',
)
url = reverse('ausschreibungen:entscheidung', kwargs={'pk': a.pk})
response = client.get(url)
assert response.status_code == 200
assert b'Nicht erf\xc3\xbcllbare Ausschlusskriterien' in response.content
@pytest.mark.django_db
def test_nachweis_zuordnen(client):
anf = AnforderungFactory()
n = NachweisFactory()
url = reverse('ausschreibungen:lose:nachweis_zuordnen',
kwargs={'ausschreibung_id': anf.ausschreibung_id, 'pk': anf.pk})
response = client.post(url, {'nachweis_pk': n.pk})
assert response.status_code == 200
assert anf.nachweise.filter(pk=n.pk).exists()

View file

@ -1,2 +1,22 @@
from django.urls import path
urlpatterns = []
from . import views
app_name = 'lose'
urlpatterns = [
path('', views.lose_liste, name='liste'),
path('neu/', views.los_neu, name='neu'),
path('<int:los_pk>/', views.los_detail, name='detail'),
path('<int:los_pk>/bearbeiten/', views.los_bearbeiten, name='bearbeiten'),
path('<int:los_pk>/loeschen/', views.los_loeschen, name='loeschen'),
path('anforderungen/', views.anforderungen_liste, name='anforderungen_liste'),
path('anforderungen/neu/', views.anforderung_neu, name='anforderung_neu'),
path('anforderungen/<int:pk>/', views.anforderung_detail, name='anforderung_detail'),
path('anforderungen/<int:pk>/bearbeiten/', views.anforderung_bearbeiten, name='anforderung_bearbeiten'),
path('anforderungen/<int:pk>/status/', views.anforderung_status, name='anforderung_status'),
path('anforderungen/<int:pk>/nachweis/', views.nachweis_suche_modal, name='nachweis_suche'),
path('anforderungen/<int:pk>/nachweis/zuordnen/', views.nachweis_zuordnen, name='nachweis_zuordnen'),
path('anforderungen/<int:pk>/nachweis/<int:nachweis_pk>/entfernen/', views.nachweis_entfernen, name='nachweis_entfernen'),
path('anforderungen/<int:pk>/aufgabe/', views.anforderung_aufgabe_erstellen, name='anforderung_aufgabe'),
]

View file

@ -1,3 +1,287 @@
from django.shortcuts import render
from django.shortcuts import get_object_or_404, redirect, render
# Create your views here.
from vergabe_teilnahme.apps.ausschreibungen.models import Ausschreibung
from vergabe_teilnahme.apps.bibliothek.models import Nachweis
from .forms import AnforderungForm, LosForm
from .models import Anforderung, Los
def _is_htmx(request):
return request.headers.get('HX-Request') == 'true'
def _ausschreibung_breadcrumbs(ausschreibung, *extra):
crumbs = [
{'label': 'Ausschreibungen', 'url': '/ausschreibungen/'},
{'label': ausschreibung.titel, 'url': f'/ausschreibungen/{ausschreibung.pk}/'},
]
for label, url in extra:
crumbs.append({'label': label, 'url': url})
return crumbs
# ─── Lose ────────────────────────────────────────────────────────────────────
def lose_liste(request, ausschreibung_id):
ausschreibung = get_object_or_404(Ausschreibung, pk=ausschreibung_id)
lose = Los.objects.filter(ausschreibung=ausschreibung).order_by('losnummer')
return render(request, 'lose/liste.html', {
'ausschreibung': ausschreibung,
'lose': lose,
'breadcrumbs': _ausschreibung_breadcrumbs(ausschreibung, ('Lose', None)),
})
def los_neu(request, ausschreibung_id):
ausschreibung = get_object_or_404(Ausschreibung, pk=ausschreibung_id)
if request.method == 'POST':
form = LosForm(request.POST, ausschreibung=ausschreibung)
if form.is_valid():
los = form.save(commit=False)
los.ausschreibung = ausschreibung
los.save()
if _is_htmx(request):
return render(request, 'lose/partials/los_row.html', {'los': los, 'ausschreibung': ausschreibung})
return redirect('ausschreibungen:lose:liste', ausschreibung_id=ausschreibung_id)
else:
form = LosForm(ausschreibung=ausschreibung)
return render(request, 'lose/form.html', {
'form': form,
'ausschreibung': ausschreibung,
'titel': 'Los hinzufügen',
'breadcrumbs': _ausschreibung_breadcrumbs(ausschreibung,
('Lose', f'/ausschreibungen/{ausschreibung_id}/lose/'),
('Neu', None)),
})
def los_detail(request, ausschreibung_id, los_pk):
ausschreibung = get_object_or_404(Ausschreibung, pk=ausschreibung_id)
los = get_object_or_404(Los, pk=los_pk, ausschreibung=ausschreibung)
anforderungen = los.anforderungen.all().order_by('verbindlichkeit', 'titel')
return render(request, 'lose/detail.html', {
'ausschreibung': ausschreibung,
'los': los,
'anforderungen': anforderungen,
'breadcrumbs': _ausschreibung_breadcrumbs(ausschreibung,
('Lose', f'/ausschreibungen/{ausschreibung_id}/lose/'),
(str(los), None)),
})
def los_bearbeiten(request, ausschreibung_id, los_pk):
ausschreibung = get_object_or_404(Ausschreibung, pk=ausschreibung_id)
los = get_object_or_404(Los, pk=los_pk, ausschreibung=ausschreibung)
form = LosForm(request.POST or None, instance=los, ausschreibung=ausschreibung)
if request.method == 'POST' and form.is_valid():
form.save()
return redirect('ausschreibungen:lose:detail', ausschreibung_id=ausschreibung_id, los_pk=los_pk)
return render(request, 'lose/form.html', {
'form': form,
'ausschreibung': ausschreibung,
'los': los,
'titel': 'Los bearbeiten',
'breadcrumbs': _ausschreibung_breadcrumbs(ausschreibung,
('Lose', f'/ausschreibungen/{ausschreibung_id}/lose/'),
(str(los), f'/ausschreibungen/{ausschreibung_id}/lose/{los_pk}/'),
('Bearbeiten', None)),
})
def los_loeschen(request, ausschreibung_id, los_pk):
ausschreibung = get_object_or_404(Ausschreibung, pk=ausschreibung_id)
los = get_object_or_404(Los, pk=los_pk, ausschreibung=ausschreibung)
if request.method == 'POST':
los.delete()
return redirect('ausschreibungen:lose:liste', ausschreibung_id=ausschreibung_id)
return render(request, 'lose/loeschen_confirm.html', {
'ausschreibung': ausschreibung,
'los': los,
'breadcrumbs': _ausschreibung_breadcrumbs(ausschreibung,
('Lose', f'/ausschreibungen/{ausschreibung_id}/lose/'),
(str(los), f'/ausschreibungen/{ausschreibung_id}/lose/{los_pk}/'),
('Löschen', None)),
})
# ─── Anforderungen ───────────────────────────────────────────────────────────
def anforderungen_liste(request, ausschreibung_id):
ausschreibung = get_object_or_404(Ausschreibung, pk=ausschreibung_id)
qs = Anforderung.objects.filter(ausschreibung=ausschreibung).select_related('los', 'zustaendiger')
verbindlichkeit = request.GET.get('verbindlichkeit', '')
if verbindlichkeit:
qs = qs.filter(verbindlichkeit=verbindlichkeit)
status_filter = request.GET.get('erfuellungsstatus', '')
if status_filter:
qs = qs.filter(erfuellungsstatus=status_filter)
los_filter = request.GET.get('los', '')
if los_filter == 'allgemein':
qs = qs.filter(los__isnull=True)
elif los_filter:
qs = qs.filter(los_id=los_filter)
lose = Los.objects.filter(ausschreibung=ausschreibung).order_by('losnummer')
grouped = {}
for los in lose:
grouped[los] = []
grouped[None] = []
for a in qs.order_by('verbindlichkeit', 'titel'):
grouped[a.los].append(a)
grouped = {k: v for k, v in grouped.items() if v}
ctx = {
'ausschreibung': ausschreibung,
'grouped': grouped,
'lose': lose,
'verbindlichkeit_choices': Anforderung.VERBINDLICHKEIT_CHOICES,
'erfuellung_choices': Anforderung.ERFUELLUNG_CHOICES,
'current_verbindlichkeit': verbindlichkeit,
'current_status': status_filter,
'current_los': los_filter,
'breadcrumbs': _ausschreibung_breadcrumbs(ausschreibung, ('Anforderungen', None)),
}
if _is_htmx(request):
return render(request, 'lose/anforderungen_liste_partial.html', ctx)
return render(request, 'lose/anforderungen_liste.html', ctx)
def anforderung_neu(request, ausschreibung_id):
ausschreibung = get_object_or_404(Ausschreibung, pk=ausschreibung_id)
if request.method == 'POST':
form = AnforderungForm(request.POST, ausschreibung=ausschreibung)
if form.is_valid():
a = form.save(commit=False)
a.ausschreibung = ausschreibung
a.save()
return redirect('ausschreibungen:lose:anforderung_detail',
ausschreibung_id=ausschreibung_id, pk=a.pk)
else:
los_pk = request.GET.get('los')
initial = {'los': los_pk} if los_pk else {}
form = AnforderungForm(ausschreibung=ausschreibung, initial=initial)
return render(request, 'lose/anforderung_form.html', {
'form': form,
'ausschreibung': ausschreibung,
'titel': 'Anforderung anlegen',
'breadcrumbs': _ausschreibung_breadcrumbs(ausschreibung,
('Anforderungen', f'/ausschreibungen/{ausschreibung_id}/lose/anforderungen/'),
('Neu', None)),
})
def anforderung_detail(request, ausschreibung_id, pk):
ausschreibung = get_object_or_404(Ausschreibung, pk=ausschreibung_id)
anforderung = get_object_or_404(Anforderung, pk=pk, ausschreibung=ausschreibung)
aufgaben = anforderung.aufgaben.all().order_by('prioritaet', 'frist')
return render(request, 'lose/anforderung_detail.html', {
'ausschreibung': ausschreibung,
'anforderung': anforderung,
'aufgaben': aufgaben,
'breadcrumbs': _ausschreibung_breadcrumbs(ausschreibung,
('Anforderungen', f'/ausschreibungen/{ausschreibung_id}/lose/anforderungen/'),
(anforderung.titel[:50], None)),
})
def anforderung_bearbeiten(request, ausschreibung_id, pk):
ausschreibung = get_object_or_404(Ausschreibung, pk=ausschreibung_id)
anforderung = get_object_or_404(Anforderung, pk=pk, ausschreibung=ausschreibung)
form = AnforderungForm(request.POST or None, instance=anforderung, ausschreibung=ausschreibung)
if request.method == 'POST' and form.is_valid():
form.save()
return redirect('ausschreibungen:lose:anforderung_detail',
ausschreibung_id=ausschreibung_id, pk=pk)
return render(request, 'lose/anforderung_form.html', {
'form': form,
'ausschreibung': ausschreibung,
'anforderung': anforderung,
'titel': 'Anforderung bearbeiten',
'breadcrumbs': _ausschreibung_breadcrumbs(ausschreibung,
('Anforderungen', f'/ausschreibungen/{ausschreibung_id}/lose/anforderungen/'),
(anforderung.titel[:50], f'/ausschreibungen/{ausschreibung_id}/lose/anforderungen/{pk}/'),
('Bearbeiten', None)),
})
def anforderung_status(request, ausschreibung_id, pk):
anforderung = get_object_or_404(Anforderung, pk=pk, ausschreibung_id=ausschreibung_id)
if request.method == 'POST':
neuer_status = request.POST.get('erfuellungsstatus')
if neuer_status:
anforderung.erfuellungsstatus = neuer_status
anforderung.save(update_fields=['erfuellungsstatus'])
return render(request, 'lose/partials/erfuellungsstatus_widget.html', {'anforderung': anforderung})
# ─── Nachweise ───────────────────────────────────────────────────────────────
def nachweis_suche_modal(request, ausschreibung_id, pk):
anforderung = get_object_or_404(Anforderung, pk=pk, ausschreibung_id=ausschreibung_id)
q = request.GET.get('q', '')
nachweise = Nachweis.objects.all()
if q:
nachweise = nachweise.filter(titel__icontains=q)
bereits_zugeordnet = anforderung.nachweise.values_list('pk', flat=True)
return render(request, 'lose/partials/nachweis_modal.html', {
'anforderung': anforderung,
'nachweise': nachweise[:20],
'bereits_zugeordnet': list(bereits_zugeordnet),
'q': q,
'ausschreibung_id': ausschreibung_id,
})
def nachweis_zuordnen(request, ausschreibung_id, pk):
anforderung = get_object_or_404(Anforderung, pk=pk, ausschreibung_id=ausschreibung_id)
if request.method == 'POST':
nachweis_pk = request.POST.get('nachweis_pk')
if nachweis_pk:
nachweis = get_object_or_404(Nachweis, pk=nachweis_pk)
anforderung.nachweise.add(nachweis)
return render(request, 'lose/partials/nachweise_liste.html', {
'anforderung': anforderung,
'ausschreibung_id': ausschreibung_id,
})
def nachweis_entfernen(request, ausschreibung_id, pk, nachweis_pk):
anforderung = get_object_or_404(Anforderung, pk=pk, ausschreibung_id=ausschreibung_id)
if request.method == 'POST':
nachweis = get_object_or_404(Nachweis, pk=nachweis_pk)
anforderung.nachweise.remove(nachweis)
return render(request, 'lose/partials/nachweise_liste.html', {
'anforderung': anforderung,
'ausschreibung_id': ausschreibung_id,
})
# ─── Aufgabe aus Anforderung ─────────────────────────────────────────────────
def anforderung_aufgabe_erstellen(request, ausschreibung_id, pk):
ausschreibung = get_object_or_404(Ausschreibung, pk=ausschreibung_id)
anforderung = get_object_or_404(Anforderung, pk=pk, ausschreibung=ausschreibung)
if request.method == 'POST':
from vergabe_teilnahme.apps.aufgaben.models import Aufgabe
Aufgabe.objects.create(
ausschreibung=ausschreibung,
los=anforderung.los,
anforderung=anforderung,
titel=f'Klärung: {anforderung.titel[:200]}',
typ='fachlich',
verantwortlicher=anforderung.zustaendiger,
)
return redirect('ausschreibungen:lose:anforderung_detail',
ausschreibung_id=ausschreibung_id, pk=pk)
return render(request, 'lose/aufgabe_erstellen_confirm.html', {
'ausschreibung': ausschreibung,
'anforderung': anforderung,
})

View file

@ -0,0 +1,3 @@
from django.urls import path
urlpatterns = []

View file

@ -0,0 +1,3 @@
from django.urls import path
urlpatterns = []