feat(partner,bibliothek): Subunternehmer-Katalog, Dienstleistertypen und Bibliothek (WP-0010)
Implementiert Subunternehmer-Katalog mit Suche/Filter, Zuordnung zu Losen via HTMX-Modal, Dienstleistertyp-CRUD und Präferenz-Badges. Bibliothek: Nachweis-Katalog mit Ablaufwarnung und Versionierung, Referenz-Katalog mit Ausschreibungszuordnung, Leistungsblatt-CRUD, Entscheidungsregel-CRUD mit Aktiv-Toggle. Migration für referenzen M2M auf Ausschreibung. 56 Tests grün. Tests-Discovery auf tests.py-Dateien ausgedehnt. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
ca8818254b
commit
278cc1014c
31 changed files with 1818 additions and 22 deletions
|
|
@ -1,3 +1,54 @@
|
|||
from django.test import TestCase
|
||||
import pytest
|
||||
from django.urls import reverse
|
||||
|
||||
# Create your tests here.
|
||||
from vergabe_teilnahme.apps.ausschreibungen.tests import AusschreibungFactory
|
||||
from vergabe_teilnahme.apps.lose.models import Los
|
||||
|
||||
from .models import Subunternehmer, SubunternehmerZuordnung
|
||||
|
||||
|
||||
def make_sub(praeferenz='zugelassen', name='TestSub', **kwargs):
|
||||
return Subunternehmer.objects.create(name=name, praeferenz=praeferenz, **kwargs)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_subunternehmer_zuordnung_zu_los(client):
|
||||
a = AusschreibungFactory()
|
||||
los = Los.objects.create(ausschreibung=a, losnummer='1', lostitel='Los 1')
|
||||
sub = make_sub()
|
||||
url = reverse('partner:su_zuordnen', kwargs={'ausschreibung_id': a.pk, 'los_pk': los.pk})
|
||||
client.post(url, {'subunternehmer_id': sub.pk, 'konkrete_leistung': 'IT-Support'})
|
||||
assert SubunternehmerZuordnung.objects.filter(subunternehmer=sub, ausschreibung=a, los=los).exists()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_gesperrter_subunternehmer_im_suchmodal(client):
|
||||
a = AusschreibungFactory()
|
||||
los = Los.objects.create(ausschreibung=a, losnummer='1', lostitel='Los 1')
|
||||
make_sub(praeferenz='gesperrt', name='GesperrterSub')
|
||||
url = reverse('partner:su_suche_modal', kwargs={'ausschreibung_id': a.pk, 'los_pk': los.pk})
|
||||
response = client.get(url + '?q=GesperrterSub')
|
||||
assert response.status_code == 200
|
||||
assert b'gesperrt' in response.content.lower()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_subunternehmer_praeferenz_update(client):
|
||||
sub = make_sub(praeferenz='zugelassen')
|
||||
url = reverse('partner:su_praeferenz', kwargs={'pk': sub.pk})
|
||||
client.post(url, {'praeferenz': 'bevorzugt'})
|
||||
sub.refresh_from_db()
|
||||
assert sub.praeferenz == 'bevorzugt'
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_zuordnung_toggle_zusage(client):
|
||||
a = AusschreibungFactory()
|
||||
los = Los.objects.create(ausschreibung=a, losnummer='1', lostitel='Los 1')
|
||||
sub = make_sub()
|
||||
z = SubunternehmerZuordnung.objects.create(subunternehmer=sub, ausschreibung=a, los=los)
|
||||
assert z.zusage_vorhanden is False
|
||||
url = reverse('partner:zuordnung_toggle', kwargs={'pk': z.pk})
|
||||
client.post(url, {'feld': 'zusage_vorhanden'})
|
||||
z.refresh_from_db()
|
||||
assert z.zusage_vorhanden is True
|
||||
|
|
|
|||
|
|
@ -1,2 +1,21 @@
|
|||
from django.urls import path
|
||||
urlpatterns = []
|
||||
|
||||
from . import views
|
||||
|
||||
app_name = 'partner'
|
||||
|
||||
urlpatterns = [
|
||||
path('subunternehmer/', views.subunternehmer_liste, name='su_liste'),
|
||||
path('subunternehmer/neu/', views.subunternehmer_neu, name='su_neu'),
|
||||
path('subunternehmer/<int:pk>/', views.subunternehmer_detail, name='su_detail'),
|
||||
path('subunternehmer/<int:pk>/bearbeiten/', views.subunternehmer_bearbeiten, name='su_bearbeiten'),
|
||||
path('subunternehmer/<int:pk>/praeferenz/', views.subunternehmer_praeferenz, name='su_praeferenz'),
|
||||
path('subunternehmer/<int:pk>/zuordnung-toggle/', views.zuordnung_toggle, name='zuordnung_toggle'),
|
||||
path('dienstleistertypen/', views.dienstleistertypen_liste, name='dt_liste'),
|
||||
path('dienstleistertypen/neu/', views.dienstleistertyp_neu, name='dt_neu'),
|
||||
path('dienstleistertypen/<int:pk>/bearbeiten/', views.dienstleistertyp_bearbeiten, name='dt_bearbeiten'),
|
||||
path('ausschreibungen/<int:ausschreibung_id>/lose/<int:los_pk>/subunternehmer/suche/',
|
||||
views.subunternehmer_suche_modal, name='su_suche_modal'),
|
||||
path('ausschreibungen/<int:ausschreibung_id>/lose/<int:los_pk>/subunternehmer/zuordnen/',
|
||||
views.subunternehmer_zuordnen, name='su_zuordnen'),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1,3 +1,236 @@
|
|||
from django.shortcuts import render
|
||||
from django import forms
|
||||
from django.contrib import messages
|
||||
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.lose.models import Los
|
||||
|
||||
from .models import Dienstleistertyp, Subunternehmer, SubunternehmerZuordnung
|
||||
|
||||
|
||||
class SubunternehmerForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = Subunternehmer
|
||||
fields = [
|
||||
'name', 'kurzname', 'dienstleistertyp', 'praeferenz',
|
||||
'strasse', 'plz', 'ort', 'land',
|
||||
'telefon', 'mobilnummer', 'email', 'website',
|
||||
'ansprechpartner', 'ansprechpartner_email', 'ansprechpartner_telefon',
|
||||
'leistungsprofil', 'bewertung',
|
||||
]
|
||||
widgets = {
|
||||
'name': forms.TextInput(attrs={'class': 'form-input'}),
|
||||
'kurzname': forms.TextInput(attrs={'class': 'form-input'}),
|
||||
'dienstleistertyp': forms.Select(attrs={'class': 'form-select'}),
|
||||
'praeferenz': forms.RadioSelect(),
|
||||
'strasse': forms.TextInput(attrs={'class': 'form-input'}),
|
||||
'plz': forms.TextInput(attrs={'class': 'form-input'}),
|
||||
'ort': forms.TextInput(attrs={'class': 'form-input'}),
|
||||
'land': forms.TextInput(attrs={'class': 'form-input'}),
|
||||
'telefon': forms.TextInput(attrs={'class': 'form-input'}),
|
||||
'mobilnummer': forms.TextInput(attrs={'class': 'form-input'}),
|
||||
'email': forms.EmailInput(attrs={'class': 'form-input'}),
|
||||
'website': forms.URLInput(attrs={'class': 'form-input'}),
|
||||
'ansprechpartner': forms.TextInput(attrs={'class': 'form-input'}),
|
||||
'ansprechpartner_email': forms.EmailInput(attrs={'class': 'form-input'}),
|
||||
'ansprechpartner_telefon': forms.TextInput(attrs={'class': 'form-input'}),
|
||||
'leistungsprofil': forms.Textarea(attrs={'class': 'form-input', 'rows': 4}),
|
||||
'bewertung': forms.Textarea(attrs={'class': 'form-input', 'rows': 3}),
|
||||
}
|
||||
|
||||
|
||||
class DienstleistertypForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = Dienstleistertyp
|
||||
fields = [
|
||||
'name', 'beschreibung', 'typische_leistungen', 'typische_nachweise',
|
||||
'relevante_standards', 'typische_preisbestandteile', 'bemerkungen',
|
||||
]
|
||||
widgets = {
|
||||
'name': forms.TextInput(attrs={'class': 'form-input'}),
|
||||
'beschreibung': forms.Textarea(attrs={'class': 'form-input', 'rows': 3}),
|
||||
'typische_leistungen': forms.Textarea(attrs={'class': 'form-input', 'rows': 3}),
|
||||
'typische_nachweise': forms.Textarea(attrs={'class': 'form-input', 'rows': 2}),
|
||||
'relevante_standards': forms.Textarea(attrs={'class': 'form-input', 'rows': 2}),
|
||||
'typische_preisbestandteile': forms.Textarea(attrs={'class': 'form-input', 'rows': 2}),
|
||||
'bemerkungen': forms.Textarea(attrs={'class': 'form-input', 'rows': 2}),
|
||||
}
|
||||
|
||||
|
||||
def subunternehmer_liste(request):
|
||||
qs = Subunternehmer.objects.select_related('dienstleistertyp')
|
||||
|
||||
q = request.GET.get('q', '').strip()
|
||||
if q:
|
||||
qs = qs.filter(name__icontains=q)
|
||||
|
||||
praeferenz = request.GET.get('praeferenz', '')
|
||||
if praeferenz:
|
||||
qs = qs.filter(praeferenz=praeferenz)
|
||||
|
||||
dt_id = request.GET.get('dienstleistertyp', '')
|
||||
if dt_id:
|
||||
qs = qs.filter(dienstleistertyp_id=dt_id)
|
||||
|
||||
ctx = {
|
||||
'subunternehmer': qs,
|
||||
'dienstleistertypen': Dienstleistertyp.objects.all(),
|
||||
'praeferenz_choices': Subunternehmer.PRAEFERENZ_CHOICES,
|
||||
'q': q,
|
||||
'current_praeferenz': praeferenz,
|
||||
'current_dt': dt_id,
|
||||
'breadcrumbs': [{'label': 'Subunternehmer', 'url': None}],
|
||||
}
|
||||
return render(request, 'partner/subunternehmer_liste.html', ctx)
|
||||
|
||||
|
||||
def subunternehmer_neu(request):
|
||||
if request.method == 'POST':
|
||||
form = SubunternehmerForm(request.POST)
|
||||
if form.is_valid():
|
||||
obj = form.save()
|
||||
messages.success(request, f'Subunternehmer „{obj.name}" angelegt.')
|
||||
return redirect('partner:su_detail', pk=obj.pk)
|
||||
else:
|
||||
form = SubunternehmerForm()
|
||||
|
||||
return render(request, 'partner/subunternehmer_form.html', {
|
||||
'form': form,
|
||||
'breadcrumbs': [
|
||||
{'label': 'Subunternehmer', 'url': '/partner/subunternehmer/'},
|
||||
{'label': 'Neu', 'url': None},
|
||||
],
|
||||
})
|
||||
|
||||
|
||||
def subunternehmer_detail(request, pk):
|
||||
sub = get_object_or_404(Subunternehmer, pk=pk)
|
||||
zuordnungen = SubunternehmerZuordnung.objects.filter(
|
||||
subunternehmer=sub
|
||||
).select_related('ausschreibung', 'los').order_by('-ausschreibung__erstellt_am')
|
||||
|
||||
return render(request, 'partner/subunternehmer_detail.html', {
|
||||
'sub': sub,
|
||||
'zuordnungen': zuordnungen,
|
||||
'breadcrumbs': [
|
||||
{'label': 'Subunternehmer', 'url': '/partner/subunternehmer/'},
|
||||
{'label': sub.name, 'url': None},
|
||||
],
|
||||
})
|
||||
|
||||
|
||||
def subunternehmer_bearbeiten(request, pk):
|
||||
sub = get_object_or_404(Subunternehmer, pk=pk)
|
||||
if request.method == 'POST':
|
||||
form = SubunternehmerForm(request.POST, instance=sub)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
messages.success(request, 'Gespeichert.')
|
||||
return redirect('partner:su_detail', pk=pk)
|
||||
else:
|
||||
form = SubunternehmerForm(instance=sub)
|
||||
|
||||
return render(request, 'partner/subunternehmer_form.html', {
|
||||
'form': form,
|
||||
'sub': sub,
|
||||
'breadcrumbs': [
|
||||
{'label': 'Subunternehmer', 'url': '/partner/subunternehmer/'},
|
||||
{'label': sub.name, 'url': f'/partner/subunternehmer/{pk}/'},
|
||||
{'label': 'Bearbeiten', 'url': None},
|
||||
],
|
||||
})
|
||||
|
||||
|
||||
def subunternehmer_praeferenz(request, pk):
|
||||
sub = get_object_or_404(Subunternehmer, pk=pk)
|
||||
if request.method == 'POST':
|
||||
sub.praeferenz = request.POST.get('praeferenz', sub.praeferenz)
|
||||
if sub.praeferenz == 'gesperrt':
|
||||
begruendung = request.POST.get('begruendung', '').strip()
|
||||
if begruendung:
|
||||
sub.bewertung = begruendung
|
||||
sub.save(update_fields=['praeferenz', 'bewertung'])
|
||||
return render(request, 'partner/partials/praeferenz_badge.html', {'sub': sub})
|
||||
|
||||
|
||||
def subunternehmer_suche_modal(request, ausschreibung_id, los_pk):
|
||||
q = request.GET.get('q', '').strip()
|
||||
subunternehmer = Subunternehmer.objects.filter(name__icontains=q).select_related('dienstleistertyp')
|
||||
return render(request, 'partner/partials/subunternehmer_suche.html', {
|
||||
'subunternehmer': subunternehmer,
|
||||
'los_pk': los_pk,
|
||||
'ausschreibung_id': ausschreibung_id,
|
||||
'q': q,
|
||||
})
|
||||
|
||||
|
||||
def subunternehmer_zuordnen(request, ausschreibung_id, los_pk):
|
||||
if request.method == 'POST':
|
||||
sub_id = request.POST.get('subunternehmer_id')
|
||||
sub = get_object_or_404(Subunternehmer, pk=sub_id)
|
||||
los = get_object_or_404(Los, pk=los_pk)
|
||||
zuordnung, _ = SubunternehmerZuordnung.objects.get_or_create(
|
||||
subunternehmer=sub,
|
||||
ausschreibung_id=ausschreibung_id,
|
||||
los=los,
|
||||
defaults={'konkrete_leistung': request.POST.get('konkrete_leistung', '')},
|
||||
)
|
||||
return render(request, 'partner/partials/zuordnung_zeile.html', {'zuordnung': zuordnung})
|
||||
return redirect('partner:su_liste')
|
||||
|
||||
|
||||
def zuordnung_toggle(request, pk):
|
||||
zuordnung = get_object_or_404(SubunternehmerZuordnung, pk=pk)
|
||||
feld = request.POST.get('feld')
|
||||
if feld in ('zusage_vorhanden', 'nachweis_eingegangen', 'preis_vorhanden'):
|
||||
setattr(zuordnung, feld, not getattr(zuordnung, feld))
|
||||
zuordnung.save(update_fields=[feld])
|
||||
return render(request, 'partner/partials/zuordnung_zeile.html', {'zuordnung': zuordnung})
|
||||
|
||||
|
||||
def dienstleistertypen_liste(request):
|
||||
typen = Dienstleistertyp.objects.all()
|
||||
return render(request, 'partner/dienstleistertyp_liste.html', {
|
||||
'typen': typen,
|
||||
'breadcrumbs': [{'label': 'Dienstleistertypen', 'url': None}],
|
||||
})
|
||||
|
||||
|
||||
def dienstleistertyp_neu(request):
|
||||
if request.method == 'POST':
|
||||
form = DienstleistertypForm(request.POST)
|
||||
if form.is_valid():
|
||||
obj = form.save()
|
||||
messages.success(request, f'Dienstleistertyp „{obj.name}" angelegt.')
|
||||
return redirect('partner:dt_liste')
|
||||
else:
|
||||
form = DienstleistertypForm()
|
||||
|
||||
return render(request, 'partner/dienstleistertyp_form.html', {
|
||||
'form': form,
|
||||
'breadcrumbs': [
|
||||
{'label': 'Dienstleistertypen', 'url': '/partner/dienstleistertypen/'},
|
||||
{'label': 'Neu', 'url': None},
|
||||
],
|
||||
})
|
||||
|
||||
|
||||
def dienstleistertyp_bearbeiten(request, pk):
|
||||
obj = get_object_or_404(Dienstleistertyp, pk=pk)
|
||||
if request.method == 'POST':
|
||||
form = DienstleistertypForm(request.POST, instance=obj)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
messages.success(request, 'Gespeichert.')
|
||||
return redirect('partner:dt_liste')
|
||||
else:
|
||||
form = DienstleistertypForm(instance=obj)
|
||||
|
||||
return render(request, 'partner/dienstleistertyp_form.html', {
|
||||
'form': form,
|
||||
'obj': obj,
|
||||
'breadcrumbs': [
|
||||
{'label': 'Dienstleistertypen', 'url': '/partner/dienstleistertypen/'},
|
||||
{'label': 'Bearbeiten', 'url': None},
|
||||
],
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue