feat(WP-0011): Marktbegleiter-Analyse — Katalog, Passagen, Auswertung
Implementiert UC-MB-01 bis UC-MB-03: Marktbegleiter-Katalog (Liste, Detail, Anlegen/Bearbeiten), Ausschreibungspassagen mit Verlässlichkeitsscore (1–10, Validator), Musterauswertung mit Aggregationen (Ausschreiber-Häufigkeit, Ø-Score, Passagen-Anzahl). 4 Tests grün. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
f88e2e7562
commit
bde10f3a69
13 changed files with 737 additions and 12 deletions
|
|
@ -1,3 +1,10 @@
|
|||
from django.urls import path
|
||||
|
||||
urlpatterns = []
|
||||
from . import passagen_views
|
||||
|
||||
urlpatterns = [
|
||||
path('', passagen_views.passagen_liste, name='liste'),
|
||||
path('neu/', passagen_views.passage_neu, name='neu'),
|
||||
path('<int:pk>/', passagen_views.passage_detail, name='detail'),
|
||||
path('<int:pk>/bearbeiten/', passagen_views.passage_bearbeiten, name='bearbeiten'),
|
||||
]
|
||||
|
|
|
|||
110
vergabe_teilnahme/apps/marktbegleiter/passagen_views.py
Normal file
110
vergabe_teilnahme/apps/marktbegleiter/passagen_views.py
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
from django import forms
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
|
||||
from vergabe_teilnahme.apps.ausschreibungen.models import Ausschreibung
|
||||
from vergabe_teilnahme.apps.dokumente.models import Dokument
|
||||
|
||||
from .models import Ausschreibungspassage, Marktbegleiter
|
||||
|
||||
|
||||
KATEGORIE_CHOICES = [
|
||||
('formulierung', 'Formulierung'),
|
||||
('leistungsmerkmal', 'Leistungsmerkmal'),
|
||||
('zertifizierung', 'Zertifizierung'),
|
||||
('referenz', 'Referenz'),
|
||||
('sonstiges', 'Sonstiges'),
|
||||
]
|
||||
|
||||
|
||||
class AusschreibungspassageForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = Ausschreibungspassage
|
||||
fields = [
|
||||
'passage', 'marktbegleiter', 'dokument', 'fundstelle',
|
||||
'kategorie', 'verlaesslichkeitsscore',
|
||||
'begruendung_zuordnung', 'auswirkung_entscheidung',
|
||||
'auswirkung_preisstrategie', 'auswirkung_loesungskonzept',
|
||||
]
|
||||
widgets = {
|
||||
'passage': forms.Textarea(attrs={'class': 'form-input', 'rows': 6}),
|
||||
'marktbegleiter': forms.Select(attrs={'class': 'form-select'}),
|
||||
'dokument': forms.Select(attrs={'class': 'form-select'}),
|
||||
'fundstelle': forms.TextInput(attrs={'class': 'form-input'}),
|
||||
'kategorie': forms.Select(attrs={'class': 'form-select'}, choices=[('', '---------')] + KATEGORIE_CHOICES),
|
||||
'verlaesslichkeitsscore': forms.NumberInput(attrs={
|
||||
'class': 'form-input', 'type': 'range', 'min': 1, 'max': 10,
|
||||
'x-model': 'score',
|
||||
}),
|
||||
'begruendung_zuordnung': forms.Textarea(attrs={'class': 'form-input', 'rows': 2}),
|
||||
'auswirkung_entscheidung': forms.Textarea(attrs={'class': 'form-input', 'rows': 2}),
|
||||
'auswirkung_preisstrategie': forms.Textarea(attrs={'class': 'form-input', 'rows': 2}),
|
||||
'auswirkung_loesungskonzept': forms.Textarea(attrs={'class': 'form-input', 'rows': 2}),
|
||||
}
|
||||
|
||||
def __init__(self, *args, ausschreibung=None, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['marktbegleiter'].queryset = Marktbegleiter.objects.all()
|
||||
if ausschreibung:
|
||||
self.fields['dokument'].queryset = Dokument.objects.filter(ausschreibung=ausschreibung)
|
||||
else:
|
||||
self.fields['dokument'].queryset = Dokument.objects.none()
|
||||
self.fields['dokument'].required = False
|
||||
self.fields['fundstelle'].required = False
|
||||
self.fields['kategorie'].required = False
|
||||
self.fields['begruendung_zuordnung'].required = False
|
||||
self.fields['auswirkung_entscheidung'].required = False
|
||||
self.fields['auswirkung_preisstrategie'].required = False
|
||||
self.fields['auswirkung_loesungskonzept'].required = False
|
||||
|
||||
|
||||
def passagen_liste(request, ausschreibung_id):
|
||||
ausschreibung = get_object_or_404(Ausschreibung, pk=ausschreibung_id)
|
||||
passagen = Ausschreibungspassage.objects.filter(
|
||||
ausschreibung=ausschreibung
|
||||
).select_related('marktbegleiter', 'dokument').order_by('-verlaesslichkeitsscore')
|
||||
return render(request, 'marktbegleiter/passagen_liste.html', {
|
||||
'ausschreibung': ausschreibung,
|
||||
'passagen': passagen,
|
||||
})
|
||||
|
||||
|
||||
def passage_neu(request, ausschreibung_id):
|
||||
ausschreibung = get_object_or_404(Ausschreibung, pk=ausschreibung_id)
|
||||
form = AusschreibungspassageForm(
|
||||
request.POST or None, ausschreibung=ausschreibung
|
||||
)
|
||||
if form.is_valid():
|
||||
passage = form.save(commit=False)
|
||||
passage.ausschreibung = ausschreibung
|
||||
passage.save()
|
||||
return redirect('marktbegleiter:passagen:liste', ausschreibung_id=ausschreibung_id)
|
||||
return render(request, 'marktbegleiter/passage_form.html', {
|
||||
'form': form,
|
||||
'ausschreibung': ausschreibung,
|
||||
'neu': True,
|
||||
})
|
||||
|
||||
|
||||
def passage_detail(request, ausschreibung_id, pk):
|
||||
ausschreibung = get_object_or_404(Ausschreibung, pk=ausschreibung_id)
|
||||
passage = get_object_or_404(Ausschreibungspassage, pk=pk, ausschreibung=ausschreibung)
|
||||
return render(request, 'marktbegleiter/passage_detail.html', {
|
||||
'ausschreibung': ausschreibung,
|
||||
'passage': passage,
|
||||
})
|
||||
|
||||
|
||||
def passage_bearbeiten(request, ausschreibung_id, pk):
|
||||
ausschreibung = get_object_or_404(Ausschreibung, pk=ausschreibung_id)
|
||||
passage = get_object_or_404(Ausschreibungspassage, pk=pk, ausschreibung=ausschreibung)
|
||||
form = AusschreibungspassageForm(
|
||||
request.POST or None, instance=passage, ausschreibung=ausschreibung
|
||||
)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
return redirect('marktbegleiter:passagen:detail', ausschreibung_id=ausschreibung_id, pk=pk)
|
||||
return render(request, 'marktbegleiter/passage_form.html', {
|
||||
'form': form,
|
||||
'ausschreibung': ausschreibung,
|
||||
'passage': passage,
|
||||
})
|
||||
|
|
@ -1,3 +1,70 @@
|
|||
from django.test import TestCase
|
||||
import pytest
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.urls import reverse
|
||||
|
||||
# Create your tests here.
|
||||
from vergabe_teilnahme.apps.ausschreibungen.tests import AusschreibungFactory
|
||||
|
||||
from .models import Ausschreibungspassage, Marktbegleiter
|
||||
|
||||
|
||||
def make_mb(name='TestBegleiter', **kwargs):
|
||||
return Marktbegleiter.objects.create(name=name, **kwargs)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_passage_anlegen_mit_score_10(client):
|
||||
a = AusschreibungFactory()
|
||||
mb = make_mb()
|
||||
url = reverse('marktbegleiter:passagen:neu', kwargs={'ausschreibung_id': a.pk})
|
||||
response = client.post(url, {
|
||||
'passage': 'Musterpassage aus dem Dokument',
|
||||
'marktbegleiter': mb.pk,
|
||||
'verlaesslichkeitsscore': 10,
|
||||
})
|
||||
assert response.status_code == 302
|
||||
assert Ausschreibungspassage.objects.filter(marktbegleiter=mb, verlaesslichkeitsscore=10).exists()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_passage_score_zu_hoch_validierungsfehler():
|
||||
a = AusschreibungFactory()
|
||||
mb = make_mb()
|
||||
p = Ausschreibungspassage(
|
||||
ausschreibung=a,
|
||||
marktbegleiter=mb,
|
||||
passage='Test',
|
||||
verlaesslichkeitsscore=11,
|
||||
)
|
||||
with pytest.raises(ValidationError):
|
||||
p.full_clean()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_auswertung_score_durchschnitt(client):
|
||||
a = AusschreibungFactory()
|
||||
mb = make_mb()
|
||||
Ausschreibungspassage.objects.create(
|
||||
ausschreibung=a, marktbegleiter=mb, passage='P1', verlaesslichkeitsscore=8
|
||||
)
|
||||
Ausschreibungspassage.objects.create(
|
||||
ausschreibung=a, marktbegleiter=mb, passage='P2', verlaesslichkeitsscore=6
|
||||
)
|
||||
url = reverse('marktbegleiter:auswertung', kwargs={'pk': mb.pk})
|
||||
response = client.get(url)
|
||||
assert response.status_code == 200
|
||||
content = response.content.decode()
|
||||
assert '7' in content
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_marktbegleiter_detail_zeigt_passagen(client):
|
||||
a = AusschreibungFactory()
|
||||
mb = make_mb(name='DetailBegleiter')
|
||||
Ausschreibungspassage.objects.create(
|
||||
ausschreibung=a, marktbegleiter=mb,
|
||||
passage='Sichtbare Passage', verlaesslichkeitsscore=5
|
||||
)
|
||||
url = reverse('marktbegleiter:detail', kwargs={'pk': mb.pk})
|
||||
response = client.get(url)
|
||||
assert response.status_code == 200
|
||||
assert b'Sichtbare Passage' in response.content
|
||||
|
|
|
|||
|
|
@ -1,2 +1,16 @@
|
|||
from django.urls import path
|
||||
urlpatterns = []
|
||||
from django.urls import include, path
|
||||
|
||||
from . import views
|
||||
|
||||
app_name = 'marktbegleiter'
|
||||
|
||||
urlpatterns = [
|
||||
path('', views.marktbegleiter_liste, name='liste'),
|
||||
path('neu/', views.marktbegleiter_neu, name='neu'),
|
||||
path('<int:pk>/', views.marktbegleiter_detail, name='detail'),
|
||||
path('<int:pk>/auswertung/', views.marktbegleiter_auswertung, name='auswertung'),
|
||||
path('<int:pk>/bearbeiten/', views.marktbegleiter_bearbeiten, name='bearbeiten'),
|
||||
path('ausschreibungen/<int:ausschreibung_id>/passagen/', include(
|
||||
('vergabe_teilnahme.apps.marktbegleiter.passagen_urls', 'passagen')
|
||||
)),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1,3 +1,113 @@
|
|||
from django.shortcuts import render
|
||||
from django.db.models import Avg, Count
|
||||
from django import forms
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
|
||||
# Create your views here.
|
||||
from vergabe_teilnahme.apps.ausschreibungen.models import Ausschreibung
|
||||
|
||||
from .models import Ausschreibungspassage, Marktbegleiter
|
||||
|
||||
|
||||
# ── Forms ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
class MarktbegleiterForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = Marktbegleiter
|
||||
fields = [
|
||||
'name', 'kurzbeschreibung', 'produkt_leistungsportfolio',
|
||||
'relevante_branchen', 'bekannte_staerken', 'bekannte_schwaechen',
|
||||
'typische_formulierungen', 'typische_leistungsmerkmale',
|
||||
'bekannte_zertifizierungen', 'bekannte_referenzen',
|
||||
'quellen_links', 'letzte_aktualisierung', 'interne_notizen',
|
||||
'vertraulichkeit',
|
||||
]
|
||||
widgets = {
|
||||
'name': forms.TextInput(attrs={'class': 'form-input'}),
|
||||
'kurzbeschreibung': forms.Textarea(attrs={'class': 'form-input', 'rows': 3}),
|
||||
'produkt_leistungsportfolio': forms.Textarea(attrs={'class': 'form-input', 'rows': 4}),
|
||||
'relevante_branchen': forms.Textarea(attrs={'class': 'form-input', 'rows': 3}),
|
||||
'bekannte_staerken': forms.Textarea(attrs={'class': 'form-input', 'rows': 3}),
|
||||
'bekannte_schwaechen': forms.Textarea(attrs={'class': 'form-input', 'rows': 3}),
|
||||
'typische_formulierungen': forms.Textarea(attrs={
|
||||
'class': 'form-input', 'rows': 5,
|
||||
'placeholder': 'Eine Formulierung pro Zeile',
|
||||
}),
|
||||
'typische_leistungsmerkmale': forms.Textarea(attrs={'class': 'form-input', 'rows': 3}),
|
||||
'bekannte_zertifizierungen': forms.Textarea(attrs={'class': 'form-input', 'rows': 2}),
|
||||
'bekannte_referenzen': forms.Textarea(attrs={'class': 'form-input', 'rows': 2}),
|
||||
'quellen_links': forms.Textarea(attrs={'class': 'form-input', 'rows': 2}),
|
||||
'letzte_aktualisierung': forms.DateInput(attrs={'type': 'date', 'class': 'form-input'}),
|
||||
'interne_notizen': forms.Textarea(attrs={'class': 'form-input', 'rows': 3}),
|
||||
'vertraulichkeit': forms.Select(attrs={'class': 'form-select'}),
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['letzte_aktualisierung'].required = False
|
||||
|
||||
|
||||
# ── Views ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
def marktbegleiter_liste(request):
|
||||
qs = Marktbegleiter.objects.all()
|
||||
q = request.GET.get('q', '')
|
||||
branche = request.GET.get('branche', '')
|
||||
if q:
|
||||
qs = qs.filter(name__icontains=q)
|
||||
if branche:
|
||||
qs = qs.filter(relevante_branchen__icontains=branche)
|
||||
return render(request, 'marktbegleiter/marktbegleiter_liste.html', {
|
||||
'marktbegleiter_liste': qs,
|
||||
'q': q,
|
||||
'branche': branche,
|
||||
})
|
||||
|
||||
|
||||
def marktbegleiter_neu(request):
|
||||
form = MarktbegleiterForm(request.POST or None)
|
||||
if form.is_valid():
|
||||
obj = form.save()
|
||||
return redirect('marktbegleiter:detail', pk=obj.pk)
|
||||
return render(request, 'marktbegleiter/marktbegleiter_form.html', {'form': form, 'neu': True})
|
||||
|
||||
|
||||
def marktbegleiter_bearbeiten(request, pk):
|
||||
obj = get_object_or_404(Marktbegleiter, pk=pk)
|
||||
form = MarktbegleiterForm(request.POST or None, instance=obj)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
return redirect('marktbegleiter:detail', pk=obj.pk)
|
||||
return render(request, 'marktbegleiter/marktbegleiter_form.html', {'form': form, 'obj': obj})
|
||||
|
||||
|
||||
def marktbegleiter_detail(request, pk):
|
||||
obj = get_object_or_404(Marktbegleiter, pk=pk)
|
||||
passagen = Ausschreibungspassage.objects.filter(marktbegleiter=obj).select_related(
|
||||
'ausschreibung', 'dokument'
|
||||
).order_by('-verlaesslichkeitsscore')
|
||||
anzahl_ausschreibungen = passagen.values('ausschreibung').distinct().count()
|
||||
score_durchschnitt = passagen.aggregate(Avg('verlaesslichkeitsscore'))['verlaesslichkeitsscore__avg']
|
||||
return render(request, 'marktbegleiter/marktbegleiter_detail.html', {
|
||||
'obj': obj,
|
||||
'passagen': passagen,
|
||||
'anzahl_ausschreibungen': anzahl_ausschreibungen,
|
||||
'score_durchschnitt': score_durchschnitt,
|
||||
})
|
||||
|
||||
|
||||
def marktbegleiter_auswertung(request, pk):
|
||||
mb = get_object_or_404(Marktbegleiter, pk=pk)
|
||||
passagen = Ausschreibungspassage.objects.filter(marktbegleiter=mb).select_related(
|
||||
'ausschreibung', 'dokument'
|
||||
)
|
||||
ausschreiber_haeufigkeit = passagen.values(
|
||||
'ausschreibung__ausschreiber'
|
||||
).annotate(count=Count('id')).order_by('-count')[:10]
|
||||
score_durchschnitt = passagen.aggregate(Avg('verlaesslichkeitsscore'))['verlaesslichkeitsscore__avg']
|
||||
anzahl_ausschreibungen = passagen.values('ausschreibung').distinct().count()
|
||||
return render(request, 'marktbegleiter/auswertung.html', {
|
||||
'marktbegleiter': mb,
|
||||
'passagen': passagen,
|
||||
'ausschreiber_haeufigkeit': ausschreiber_haeufigkeit,
|
||||
'score_durchschnitt': score_durchschnitt,
|
||||
'anzahl_ausschreibungen': anzahl_ausschreibungen,
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue