--- 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 ``` 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('/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
Lade...
``` 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///', custom_attributes_panel), path('core/attrs///neu/', custom_attribute_neu), path('core/attrs////bearbeiten/', custom_attribute_bearbeiten), path('core/attrs////loeschen/', custom_attribute_loeschen), path('core/attrs////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 ''" 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. ```