Files
vergabe-teilnahme/workplans/WP-0012-querschnitt.md
tegwick 5a231223c0 feat(WP-0012): Querschnitt — Freigaben, Felder, Feedback, Suche, Tests
Implements all 8 tasks of the final cross-cutting workplan:

- T01: Generisches Freigabe-Modal (freigabe_modal, freigabe_erteilen views + templates)
- T02: Freigaben-Übersicht pro Ausschreibung (freigaben_uebersicht view + template)
- T03: EntityFieldConfig Admin-Interface (/felder/<entity_type>/ with HTMX toggle)
- T04: CustomAttribute-Panel (full CRUD with sort, lazy HTMX load)
- T05: Feedback-Backlog mit Statusverwaltung + feedback_success.html template
- T06: End-to-End-Tests in vergabe_teilnahme/tests/test_e2e.py (8 tests)
- T07: Globale Suche erweitert (Dokumente, Nachweise, Referenzen, Marktbegleiter)
- T08: Alle Migrationen sauber, 68/68 Tests grün, Ruff-Fehler in neuem Code behoben

Bugfix: URL-Namespace-Fehler in Abgabe-Templates (ausschreibungen:nachbetrachtung:abgabe → ausschreibungen:abgabe)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 17:54:38 +02:00

347 lines
13 KiB
Markdown

---
id: WP-0012
title: Querschnitt — Freigaben, Flexible Felder, Feedback, Suche, Tests
status: done
phase: 12-of-12
created: "2026-05-08"
depends_on: WP-0011
---
# WP-0012 — Querschnitt
Generisches Freigabe-Modal, EntityFieldConfig Admin-UI, CustomAttribute-Panel,
Feedback vollständig, globale Suche fertigstellen, End-to-End-Tests.
Referenz: UC-FR-01, UC-FR-02, UC-FF-01 bis UC-FF-03, UC-FB-01, UC-FB-02.
---
```task
id: WP-0012-T01
title: Generisches Freigabe-Modal (UC-FR-01)
status: done
`core/views.py` — freigabe_modal und freigabe_erteilen:
```python
def freigabe_modal(request):
"""Gibt das Freigabe-Formular-Modal als Fragment zurück."""
content_type_id = request.GET.get('ct')
object_id = request.GET.get('oid')
freigabe_typ = request.GET.get('typ')
ctx = {
'content_type_id': content_type_id,
'object_id': object_id,
'freigabe_typ': freigabe_typ,
'freigabe_typ_choices': Freigabe.TYP_CHOICES,
}
return render(request, 'partials/freigabe_modal.html', ctx)
def freigabe_erteilen(request):
"""Speichert eine Freigabe und gibt Success-Fragment zurück."""
if request.method == 'POST':
ct = ContentType.objects.get(pk=request.POST['content_type_id'])
Freigabe.objects.create(
content_type=ct,
object_id=request.POST['object_id'],
freigabe_typ=request.POST['freigabe_typ'],
freigebende_person=request.user,
status='erteilt',
kommentar=request.POST.get('kommentar', ''),
)
return render(request, 'partials/freigabe_success.html', {})
return HttpResponseBadRequest()
```
`partials/freigabe_modal.html`:
Modal mit Typ-Dropdown (vorausgefüllt wenn übergeben), Kommentarfeld, "Freigabe erteilen"-Button.
`hx-post="/freigaben/erteilen/" hx-target="#modal-container"`
Nutzung in anderen Templates:
```html
<button hx-get="/freigaben/modal/?ct={{ ct_id }}&oid={{ obj.pk }}&typ=preis"
hx-target="#modal-container">Preisfreigabe erteilen</button>
```
Hilfsfunktion `get_content_type_id(model_instance)` in core/templatetags.
URL: `path('freigaben/modal/', core_views.freigabe_modal, name='freigabe_modal')`
`path('freigaben/erteilen/', core_views.freigabe_erteilen, name='freigabe_erteilen')`
```
```task
id: WP-0012-T02
title: Freigaben-Übersicht pro Ausschreibung (UC-FR-02)
status: done
`ausschreibungen/views.py` — freigaben_uebersicht:
```python
def freigaben_uebersicht(request, pk):
ausschreibung = get_object_or_404(Ausschreibung, pk=pk)
ct = ContentType.objects.get_for_model(ausschreibung)
freigaben = Freigabe.objects.filter(
content_type=ct, object_id=pk
).select_related('freigebende_person').order_by('-timestamp')
# Welche Freigabetypen fehlen noch?
erteilte_typen = set(freigaben.filter(status='erteilt').values_list('freigabe_typ', flat=True))
erforderliche_typen = {'teilnahme', 'preis', 'abgabe'}
fehlende_typen = erforderliche_typen - erteilte_typen
ctx = {
'ausschreibung': ausschreibung,
'freigaben': freigaben,
'fehlende_typen': fehlende_typen,
'breadcrumbs': [...],
}
return render(request, 'ausschreibungen/freigaben.html', ctx)
```
Template: Tabelle mit allen Freigaben + roter Banner für fehlende Pflichtfreigaben.
Auf Detailseite: Tab "Freigaben" → diese View.
URL: `path('<int:pk>/freigaben/', views.freigaben_uebersicht, name='freigaben')`
```
```task
id: WP-0012-T03
title: EntityFieldConfig Admin-Interface (UC-FF-01, UC-FF-02)
status: done
`core/views.py` — feld_konfiguration_liste und feld_konfiguration_toggle:
Nicht der Django-Standard-Admin, sondern eine eigene Verwaltungsseite unter `/admin/felder/`.
```python
ENTITY_TYPES = [
('ausschreibung', Ausschreibung),
('los', Los),
('anforderung', Anforderung),
('aufgabe', Aufgabe),
('subunternehmer', Subunternehmer),
('nachweis', Nachweis),
('referenz', Referenz),
# alle FlexibleModel-Unterklassen
]
def feld_konfiguration_liste(request, entity_type):
model = dict(ENTITY_TYPES)[entity_type]
felder = [f for f in model._meta.get_fields()
if hasattr(f, 'column') and not f.name.startswith('_')]
konfigurationen = {
cfg.field_name: cfg
for cfg in EntityFieldConfig.objects.filter(entity_type=entity_type)
}
return render(request, 'core/feld_konfiguration.html', {
'entity_type': entity_type, 'felder': felder, 'konfigurationen': konfigurationen
})
def feld_konfiguration_toggle(request, entity_type, field_name):
cfg, _ = EntityFieldConfig.objects.get_or_create(
entity_type=entity_type, field_name=field_name)
if request.method == 'POST':
cfg.is_hidden = request.POST.get('is_hidden') == 'true'
cfg.display_label = request.POST.get('display_label', '')
cfg.save()
return render(request, 'core/partials/feld_zeile.html', {'cfg': cfg, 'field_name': field_name})
```
Template `core/feld_konfiguration.html`:
Pro Feld eine Zeile mit: Feldname, Anzeige-Label-Input, Ausblenden-Toggle (HTMX).
Änderungen sofort aktiv.
```
```task
id: WP-0012-T04
title: CustomAttribute-Panel für alle Detailseiten (UC-FF-03)
status: done
`core/views.py` — custom_attributes_panel, custom_attribute_neu, custom_attribute_bearbeiten:
```python
def custom_attributes_panel(request, content_type_id, object_id):
ct = get_object_or_404(ContentType, pk=content_type_id)
attrs = CustomAttribute.objects.filter(content_type=ct, object_id=object_id)
return render(request, 'core/partials/custom_attributes.html',
{'attrs': attrs, 'ct_id': content_type_id, 'oid': object_id})
def custom_attribute_neu(request, content_type_id, object_id):
ct = get_object_or_404(ContentType, pk=content_type_id)
if request.method == 'POST':
CustomAttribute.objects.create(
content_type=ct,
object_id=object_id,
key=slugify(request.POST['label']),
label=request.POST['label'],
value=request.POST.get('value', ''),
data_type=request.POST.get('data_type', 'text'),
)
return redirect_to_panel(content_type_id, object_id)
```
`core/partials/custom_attributes.html`:
HTMX lazy-load aus Detailseiten:
```html
<section hx-get="/core/attrs/{{ ct_id }}/{{ obj.pk }}/"
hx-trigger="load" hx-swap="innerHTML">Lade...</section>
```
Formular für neues Attribut:
Alpine.js `x-show="form_open"` Toggle.
Felder: Label, Wert, Datentyp (Select: text/number/date/boolean/url/email).
Bestehende Attribute: inline bearbeitbar, löschbar via HTMX DELETE.
Sortierung via Up/Down-Buttons (HTMX POST auf `custom_attribute_sort`).
URLs:
```python
path('core/attrs/<int:ct_id>/<int:oid>/', custom_attributes_panel),
path('core/attrs/<int:ct_id>/<int:oid>/neu/', custom_attribute_neu),
path('core/attrs/<int:ct_id>/<int:oid>/<int:attr_pk>/bearbeiten/', custom_attribute_bearbeiten),
path('core/attrs/<int:ct_id>/<int:oid>/<int:attr_pk>/loeschen/', custom_attribute_loeschen),
path('core/attrs/<int:ct_id>/<int:oid>/<int:attr_pk>/sort/', custom_attribute_sort),
```
```
```task
id: WP-0012-T05
title: Feedback vollständig: Modal-POST und Backlog-View (UC-FB-01, UC-FB-02)
status: done
`feedback/views.py` — feedback_modal, feedback_speichern, feedback_backlog:
feedback_speichern (POST):
```python
def feedback_speichern(request):
if request.method == 'POST':
Feedbackeintrag.objects.create(
titel=request.POST.get('titel', 'Ohne Titel'),
beschreibung=request.POST['beschreibung'],
kategorie=request.POST.get('kategorie', 'hinweis'),
dringlichkeit=request.POST.get('dringlichkeit', 'mittel'),
seite_kontext=request.POST.get('seite_kontext', ''),
ausschreibung_id=request.POST.get('ausschreibung') or None,
erfasst_von=request.user if request.user.is_authenticated else None,
)
return render(request, 'partials/feedback_success.html')
```
`partials/feedback_success.html`: Danke-Meldung die nach 3 Sekunden verschwindet
(Alpine.js `x-data="{ show: true }" x-show="show" x-init="setTimeout(() => show=false, 3000)"`).
feedback_backlog: Liste aller Einträge mit Filter nach Status, Kategorie, Dringlichkeit.
Admin kann Status ändern (neu/in_bearbeitung/umgesetzt/abgelehnt), Bewertung und Entscheidung eintragen.
```
```task
id: WP-0012-T06
title: End-to-End-Tests für kritische Use Cases
status: done
Erstelle `vergabe_teilnahme/tests/test_e2e.py` mit vollständigen Prozess-Tests:
```python
import pytest
from django.test import Client
@pytest.mark.django_db
class TestVollstaendigerBieterprozess:
def test_ausschreibung_anlegen_bis_abgabe(self, client, mitarbeiter):
client.force_login(mitarbeiter)
# 1. Ausschreibung anlegen
r = client.post('/ausschreibungen/neu/', {'titel': 'Test', 'ausschreiber': 'ABC GmbH', ...})
assert r.status_code == 302
ausschreibung_id = Ausschreibung.objects.last().pk
# 2. Los anlegen
r = client.post(f'/ausschreibungen/{ausschreibung_id}/lose/neu/', {...})
assert Los.objects.filter(ausschreibung_id=ausschreibung_id).count() == 1
# 3. Anforderung anlegen
# 4. Aufgabe anlegen
# 5. Preispunkt mit Vergleichsgewicht 1.5 anlegen
p = Preispunkt.objects.last()
assert p.vergleichsgewicht == Decimal('1.5')
# 6. Freigabe erteilen
r = client.post('/freigaben/erteilen/', {
'content_type_id': ct_id, 'object_id': ausschreibung_id,
'freigabe_typ': 'teilnahme'
})
assert Freigabe.objects.filter(freigabe_typ='teilnahme').exists()
# 7. Abgabe dokumentieren
r = client.post(f'/ausschreibungen/{ausschreibung_id}/abgabe/dokumentieren/', {...})
ausschreibung.refresh_from_db()
assert ausschreibung.status == 9
@pytest.mark.django_db
class TestFlexibleFelder:
def test_feld_ausblenden_wirkt_im_template(self, client, mitarbeiter):
sub = Subunternehmer.objects.create(name='Test GmbH', mobilnummer='0123')
EntityFieldConfig.objects.create(
entity_type='subunternehmer', field_name='mobilnummer', is_hidden=True)
client.force_login(mitarbeiter)
r = client.get(f'/partner/subunternehmer/{sub.pk}/')
assert 'mobilnummer' not in r.content.decode().lower() or 'Mobilnummer' not in r.content.decode()
def test_custom_attribute_hinzufuegen(self, client, mitarbeiter):
sub = Subunternehmer.objects.create(name='Test GmbH')
ct = ContentType.objects.get_for_model(sub)
client.force_login(mitarbeiter)
r = client.post(f'/core/attrs/{ct.pk}/{sub.pk}/neu/',
{'label': 'Vertragsnummer', 'value': 'VN-2026-001', 'data_type': 'text'})
assert CustomAttribute.objects.filter(object_id=sub.pk, label='Vertragsnummer').exists()
```
```
```task
id: WP-0012-T07
title: Globale Suche vervollständigen und Performance-Prüfung
status: done
Vervollständige `core/views.py` — global_search:
- Dokumente (dateiname__icontains)
- Nachweise (titel__icontains)
- Referenzen (referenztitel__icontains, kunde__icontains)
- Marktbegleiter (name__icontains)
Optimierung: Alle Queries in einem einzigen DB-Roundtrip via `select_related` und
Begrenzung auf 5 Treffer pro Kategorie.
`partials/search_results.html` vollständig:
Alle sechs Ergebniskategorien mit Icon und Link.
Leer-State: "Keine Ergebnisse für '<q>'" wenn alle leer.
Performance-Test: Prüfe mit `uv run manage.py shell -c "..."` und `django.test.utils.CaptureQueriesContext`
dass die Such-View ≤ 6 DB-Queries ausführt (eine pro Entitätstyp + ContentType).
```
```task
id: WP-0012-T08
title: Finaler Integrations-Smoke-Test und CLAUDE.md-Aktualisierung
status: done
Führe den finalen Integrations-Test durch:
1. Alle Migrationen sauber: `uv run manage.py migrate` → 0 Fehler
2. Seed-Daten: `uv run manage.py seed_dev` → 0 Fehler
3. `uv run pytest` → alle Tests grün, Testabdeckung ≥ 60% für kritische Module
4. `uv run ruff check .` → 0 Fehler
5. Manueller Smoke-Test aller Hauptseiten:
- Dashboard, Ausschreibungsliste, Ausschreibung-Detail
- Lose-Liste, Anforderungs-Liste, Aufgaben-Liste
- Bieterfragen-Liste, Dokumente-Liste, Preisliste
- Abgabe-Checkliste, Nachbetrachtung
- Subunternehmer-Katalog, Bibliothek (Nachweise, Referenzen)
- Marktbegleiter-Liste
- Feedback-Modal (auf jeder getesteten Seite)
- Admin-Felder-Konfiguration
- Globale Suche mit Suchbegriff
6. Aktualisiere `CLAUDE.md`:
- Bestätige alle Build-Commands als korrekt
- Ergänze "Testabdeckung" und "Produktionsdeployment"-Hinweis
- Notiere bekannte v1-Limitierungen (z. B. kein Celery für Fristenbenachrichtigungen)
Erst wenn alle 6 Punkte erfüllt: Workplan als done markieren.
```