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

13 KiB

id, title, status, phase, created, depends_on
id title status phase created depends_on
WP-0012 Querschnitt — Freigaben, Flexible Felder, Feedback, Suche, Tests done 12-of-12 2026-05-08 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.


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:

<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:

<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:

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).
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.