generated from coulomb/repo-seed
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>
347 lines
13 KiB
Markdown
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.
|
|
```
|