We are building, workplan now registered with statehub
This commit is contained in:
parent
14b0bc6d01
commit
f202b71c75
37 changed files with 2036 additions and 22 deletions
40
vergabe_teilnahme/apps/ausschreibungen/forms.py
Normal file
40
vergabe_teilnahme/apps/ausschreibungen/forms.py
Normal 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
|
||||
|
|
@ -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),
|
||||
),
|
||||
]
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
49
vergabe_teilnahme/apps/ausschreibungen/services.py
Normal file
49
vergabe_teilnahme/apps/ausschreibungen/services.py
Normal 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,
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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')),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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},
|
||||
],
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue