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>
This commit is contained in:
tegwick 2026-05-11 17:54:38 +02:00
parent c5ccbd665d
commit 5a231223c0
23 changed files with 828 additions and 37 deletions

View file

@ -0,0 +1,53 @@
{% extends "base.html" %}
{% load vergabe_tags %}
{% block title %}Freigaben — {{ ausschreibung.titel }}{% endblock %}
{% block content %}
<div class="flex items-center justify-between mb-5">
<h1 class="page-title">Freigaben: {{ ausschreibung.titel }}</h1>
<button hx-get="/freigaben/modal/?ct={{ ct_id }}&oid={{ ausschreibung.pk }}"
hx-target="#modal-container"
class="btn-primary">Freigabe erteilen</button>
</div>
{% if fehlende_typen %}
<div class="bg-red-50 border border-red-200 rounded-lg p-4 mb-5 text-sm text-red-700">
<strong>Fehlende Pflichtfreigaben:</strong>
{% for typ in fehlende_typen %}
<span class="inline-block bg-red-100 rounded px-2 py-0.5 ml-1">{{ typ }}</span>
{% endfor %}
</div>
{% else %}
<div class="bg-green-50 border border-green-200 rounded-lg p-4 mb-5 text-sm text-green-700">
Alle Pflichtfreigaben erteilt.
</div>
{% endif %}
<div class="card">
{% if freigaben %}
<table class="w-full text-sm">
<thead>
<tr class="text-left border-b border-slate-200">
<th class="pb-2 font-medium text-slate-600">Typ</th>
<th class="pb-2 font-medium text-slate-600">Status</th>
<th class="pb-2 font-medium text-slate-600">Erteilt von</th>
<th class="pb-2 font-medium text-slate-600">Kommentar</th>
<th class="pb-2 font-medium text-slate-600">Datum</th>
</tr>
</thead>
<tbody>
{% for fg in freigaben %}
<tr class="border-b border-slate-100 hover:bg-slate-50">
<td class="py-2 font-medium">{{ fg.get_freigabe_typ_display }}</td>
<td class="py-2">{% status_badge fg.status fg.get_status_display %}</td>
<td class="py-2">{{ fg.freigebende_person.get_full_name|default:fg.freigebende_person.username }}</td>
<td class="py-2 text-slate-500 max-w-xs">{{ fg.kommentar|default:"—"|truncatechars:80 }}</td>
<td class="py-2 text-slate-500 text-xs">{{ fg.timestamp|date:"d.m.Y H:i" }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="text-slate-500 text-sm">Noch keine Freigaben erteilt.</p>
{% endif %}
</div>
{% endblock %}

View file

@ -0,0 +1,33 @@
{% extends "base.html" %}
{% block title %}Feldkonfiguration — {{ entity_type }}{% endblock %}
{% block content %}
<div class="flex items-center justify-between mb-5">
<h1 class="page-title">Feldkonfiguration: {{ entity_type }}</h1>
</div>
<div class="flex gap-2 mb-5 flex-wrap">
{% for et in entity_types %}
<a href="/felder/{{ et }}/"
class="px-3 py-1 rounded text-sm {% if et == entity_type %}bg-brand-600 text-white{% else %}bg-slate-100 text-slate-700 hover:bg-slate-200{% endif %}">
{{ et }}
</a>
{% endfor %}
</div>
<div class="card">
<table class="w-full text-sm">
<thead>
<tr class="text-left border-b border-slate-200">
<th class="pb-2 font-medium text-slate-600">Feldname</th>
<th class="pb-2 font-medium text-slate-600">Anzeige-Label</th>
<th class="pb-2 font-medium text-slate-600 text-center">Ausgeblendet</th>
</tr>
</thead>
<tbody>
{% for eintrag in felder %}
{% include "core/partials/feld_zeile.html" with cfg=eintrag.cfg field_name=eintrag.feld.name entity_type=entity_type %}
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}

View file

@ -0,0 +1,67 @@
<div x-data="{ formOpen: false }">
<div class="flex items-center justify-between mb-3">
<h3 class="text-sm font-medium text-slate-700">Zusatzfelder</h3>
<button @click="formOpen = !formOpen" type="button" class="text-xs text-brand-600 hover:underline">
+ Hinzufügen
</button>
</div>
<div x-show="formOpen" x-cloak class="mb-4 bg-slate-50 border border-slate-200 rounded-lg p-3">
<form hx-post="/core/attrs/{{ ct_id }}/{{ oid }}/neu/"
hx-target="closest div[x-data]"
hx-swap="outerHTML"
class="space-y-2">
{% csrf_token %}
<div class="grid grid-cols-3 gap-2">
<input type="text" name="label" placeholder="Bezeichnung" class="form-input text-sm" required>
<input type="text" name="value" placeholder="Wert" class="form-input text-sm">
<select name="data_type" class="form-input text-sm">
<option value="text">Text</option>
<option value="number">Zahl</option>
<option value="date">Datum</option>
<option value="boolean">Ja/Nein</option>
<option value="url">URL</option>
<option value="email">E-Mail</option>
</select>
</div>
<div class="flex gap-2">
<button type="submit" class="btn-primary text-xs py-1 px-3">Speichern</button>
<button type="button" @click="formOpen = false" class="btn-secondary text-xs py-1 px-3">Abbrechen</button>
</div>
</form>
</div>
{% if attrs %}
<div class="space-y-1">
{% for attr in attrs %}
<div class="flex items-center gap-2 text-sm border-b border-slate-100 py-1.5">
<span class="text-xs text-slate-500 w-32 shrink-0">{{ attr.label }}</span>
<span class="text-slate-700 flex-1">{{ attr.value|default:"—" }}</span>
<span class="text-xs text-slate-400 bg-slate-100 rounded px-1.5">{{ attr.data_type }}</span>
<div class="flex gap-1">
<form hx-post="/core/attrs/{{ ct_id }}/{{ oid }}/{{ attr.pk }}/sort/"
hx-target="closest div[x-data]" hx-swap="outerHTML" class="inline">
{% csrf_token %}
<input type="hidden" name="direction" value="up">
<button type="submit" class="text-slate-300 hover:text-slate-600 text-xs"></button>
</form>
<form hx-post="/core/attrs/{{ ct_id }}/{{ oid }}/{{ attr.pk }}/sort/"
hx-target="closest div[x-data]" hx-swap="outerHTML" class="inline">
{% csrf_token %}
<input type="hidden" name="direction" value="down">
<button type="submit" class="text-slate-300 hover:text-slate-600 text-xs"></button>
</form>
<form hx-post="/core/attrs/{{ ct_id }}/{{ oid }}/{{ attr.pk }}/loeschen/"
hx-target="closest div[x-data]" hx-swap="outerHTML" class="inline"
onsubmit="return confirm('Löschen?')">
{% csrf_token %}
<button type="submit" class="text-red-400 hover:text-red-600 text-xs">×</button>
</form>
</div>
</div>
{% endfor %}
</div>
{% else %}
<p class="text-xs text-slate-400">Keine Zusatzfelder vorhanden.</p>
{% endif %}
</div>

View file

@ -0,0 +1,29 @@
<tr id="feld-{{ entity_type }}-{{ field_name }}" class="border-b border-slate-100 hover:bg-slate-50">
<td class="py-2 font-mono text-xs text-slate-700">{{ field_name }}</td>
<td class="py-2">
<form hx-post="/felder/{{ entity_type }}/{{ field_name }}/toggle/"
hx-target="#feld-{{ entity_type }}-{{ field_name }}"
hx-swap="outerHTML"
class="flex items-center gap-2">
{% csrf_token %}
<input type="hidden" name="is_hidden" value="{% if cfg.is_hidden %}true{% else %}false{% endif %}">
<input type="text" name="display_label"
value="{{ cfg.display_label|default:'' }}"
class="form-input w-48 text-sm" placeholder="{{ field_name }}">
<button type="submit" class="text-xs text-brand-600 hover:underline">Speichern</button>
</form>
</td>
<td class="py-2 text-center">
<form hx-post="/felder/{{ entity_type }}/{{ field_name }}/toggle/"
hx-target="#feld-{{ entity_type }}-{{ field_name }}"
hx-swap="outerHTML">
{% csrf_token %}
<input type="hidden" name="display_label" value="{{ cfg.display_label|default:'' }}">
<input type="hidden" name="is_hidden" value="{% if cfg.is_hidden %}false{% else %}true{% endif %}">
<button type="submit"
class="text-sm {% if cfg.is_hidden %}text-red-600 font-medium{% else %}text-slate-400{% endif %}">
{% if cfg.is_hidden %}Ja{% else %}Nein{% endif %}
</button>
</form>
</td>
</tr>

View file

@ -0,0 +1,56 @@
{% extends "base.html" %}
{% block title %}Feedback-Backlog{% endblock %}
{% block content %}
<div class="flex items-center justify-between mb-5">
<h1 class="page-title">Feedback-Backlog</h1>
<span class="text-sm text-slate-500">{{ eintraege.count }} Einträge</span>
</div>
<form method="get" class="flex gap-3 mb-5 flex-wrap">
<select name="status" class="form-input w-auto text-sm" onchange="this.form.submit()">
<option value="">Alle Status</option>
{% for val, label in status_choices %}
<option value="{{ val }}" {% if val == current_status %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
<select name="kategorie" class="form-input w-auto text-sm" onchange="this.form.submit()">
<option value="">Alle Kategorien</option>
{% for val, label in kategorie_choices %}
<option value="{{ val }}" {% if val == current_kategorie %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
<select name="dringlichkeit" class="form-input w-auto text-sm" onchange="this.form.submit()">
<option value="">Alle Dringlichkeiten</option>
{% for val, label in dringlichkeit_choices %}
<option value="{{ val }}" {% if val == current_dringlichkeit %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
{% if current_status or current_kategorie or current_dringlichkeit %}
<a href="?" class="text-sm text-slate-500 self-center hover:underline">Filter zurücksetzen</a>
{% endif %}
</form>
<div class="card overflow-x-auto">
{% if eintraege %}
<table class="w-full text-sm">
<thead>
<tr class="text-left border-b border-slate-200">
<th class="pb-2 font-medium text-slate-600">Titel</th>
<th class="pb-2 font-medium text-slate-600">Beschreibung</th>
<th class="pb-2 font-medium text-slate-600">Kategorie</th>
<th class="pb-2 font-medium text-slate-600">Dringlichkeit</th>
<th class="pb-2 font-medium text-slate-600">Status</th>
<th class="pb-2 font-medium text-slate-600">Datum</th>
</tr>
</thead>
<tbody>
{% for eintrag in eintraege %}
{% include "feedback/partials/eintrag_zeile.html" with status_choices=status_choices %}
{% endfor %}
</tbody>
</table>
{% else %}
<p class="text-slate-500 text-sm">Keine Einträge gefunden.</p>
{% endif %}
</div>
{% endblock %}

View file

@ -0,0 +1,39 @@
<tr id="eintrag-{{ eintrag.pk }}" class="border-b border-slate-100 hover:bg-slate-50 text-sm">
<td class="py-2 font-medium text-slate-800 max-w-xs">
{{ eintrag.titel|truncatechars:60 }}
{% if eintrag.ausschreibung %}
<span class="text-xs text-slate-400 block">{{ eintrag.ausschreibung.titel|truncatechars:40 }}</span>
{% endif %}
</td>
<td class="py-2 text-slate-500 max-w-xs text-xs">{{ eintrag.beschreibung|truncatechars:100 }}</td>
<td class="py-2">
<span class="inline-block rounded-full px-2 py-0.5 text-xs
{% if eintrag.kategorie == 'fehler' %}bg-red-100 text-red-700
{% elif eintrag.kategorie == 'verbesserung' %}bg-blue-100 text-blue-700
{% else %}bg-slate-100 text-slate-700{% endif %}">
{{ eintrag.get_kategorie_display }}
</span>
</td>
<td class="py-2">
<span class="inline-block rounded-full px-2 py-0.5 text-xs
{% if eintrag.dringlichkeit == 'kritisch' %}bg-red-100 text-red-700
{% elif eintrag.dringlichkeit == 'hoch' %}bg-orange-100 text-orange-700
{% elif eintrag.dringlichkeit == 'mittel' %}bg-amber-100 text-amber-700
{% else %}bg-slate-100 text-slate-600{% endif %}">
{{ eintrag.get_dringlichkeit_display }}
</span>
</td>
<td class="py-2">
<form hx-post="/feedback/backlog/{{ eintrag.pk }}/status/"
hx-target="#eintrag-{{ eintrag.pk }}"
hx-swap="outerHTML">
{% csrf_token %}
<select name="status" onchange="this.form.requestSubmit()" class="text-xs border-slate-200 rounded p-1">
{% for val, label in status_choices %}
<option value="{{ val }}" {% if val == eintrag.status %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</form>
</td>
<td class="py-2 text-xs text-slate-500">{{ eintrag.datum|date:"d.m.Y" }}</td>
</tr>

View file

@ -5,7 +5,7 @@
<div class="flex items-center justify-between mb-4">
<h1 class="page-title">Abgabe-Checkliste</h1>
<div class="flex gap-2">
<a href="{% url 'ausschreibungen:nachbetrachtung:abgabe:dokumentieren' ausschreibung.pk %}" class="btn-primary text-xs">Abgabe dokumentieren</a>
<a href="{% url 'ausschreibungen:abgabe:dokumentieren' ausschreibung.pk %}" class="btn-primary text-xs">Abgabe dokumentieren</a>
<a href="{% url 'ausschreibungen:detail' ausschreibung.pk %}" class="btn-ghost text-xs">← Ausschreibung</a>
</div>
</div>

View file

@ -4,7 +4,7 @@
<div class="flex items-center justify-between mb-4">
<h1 class="page-title">Abgabe dokumentieren</h1>
<a href="{% url 'ausschreibungen:nachbetrachtung:abgabe:checkliste' ausschreibung.pk %}" class="btn-ghost text-xs">← Checkliste</a>
<a href="{% url 'ausschreibungen:abgabe:checkliste' ausschreibung.pk %}" class="btn-ghost text-xs">← Checkliste</a>
</div>
<div class="max-w-xl">
@ -34,7 +34,7 @@
</div>
<div class="flex gap-3 pt-2">
<button type="submit" class="btn-primary">Abgabe bestätigen</button>
<a href="{% url 'ausschreibungen:nachbetrachtung:abgabe:checkliste' ausschreibung.pk %}" class="btn-ghost">Abbrechen</a>
<a href="{% url 'ausschreibungen:abgabe:checkliste' ausschreibung.pk %}" class="btn-ghost">Abbrechen</a>
</div>
</form>
</div>

View file

@ -4,7 +4,7 @@
<div class="flex items-center justify-between mb-4">
<h1 class="page-title">Problem bei Abgabe vermerken</h1>
<a href="{% url 'ausschreibungen:nachbetrachtung:abgabe:checkliste' ausschreibung.pk %}" class="btn-ghost text-xs">← Checkliste</a>
<a href="{% url 'ausschreibungen:abgabe:checkliste' ausschreibung.pk %}" class="btn-ghost text-xs">← Checkliste</a>
</div>
<div class="max-w-md">
@ -16,7 +16,7 @@
</div>
<div class="flex gap-3">
<button type="submit" class="btn-primary bg-amber-600 hover:bg-amber-700">Problem vermerken</button>
<a href="{% url 'ausschreibungen:nachbetrachtung:abgabe:checkliste' ausschreibung.pk %}" class="btn-ghost">Abbrechen</a>
<a href="{% url 'ausschreibungen:abgabe:checkliste' ausschreibung.pk %}" class="btn-ghost">Abbrechen</a>
</div>
</form>
</div>

View file

@ -0,0 +1,8 @@
<div x-data="{ show: true }" x-show="show" x-cloak x-init="setTimeout(() => show = false, 3000)"
class="fixed inset-0 bg-black/30 z-50 flex items-center justify-center">
<div class="bg-white rounded-xl shadow-xl p-8 text-center max-w-sm mx-4">
<p class="text-3xl mb-2"></p>
<p class="font-medium text-slate-900">Danke für dein Feedback!</p>
<button @click="show = false" class="btn-secondary mt-4">Schließen</button>
</div>
</div>

View file

@ -0,0 +1,31 @@
<div x-data="{ open: true }" x-show="open" x-cloak
class="fixed inset-0 bg-black/30 z-50 flex items-center justify-center">
<div class="bg-white rounded-xl shadow-xl p-6 w-full max-w-md mx-4"
@click.outside="open = false">
<h2 class="page-title text-xl mb-4">Freigabe erteilen</h2>
<form hx-post="/freigaben/erteilen/" hx-target="#modal-container" hx-swap="innerHTML">
{% csrf_token %}
<input type="hidden" name="content_type_id" value="{{ content_type_id }}">
<input type="hidden" name="object_id" value="{{ object_id }}">
<div class="space-y-3">
<div>
<label class="form-label">Freigabe-Typ</label>
<select name="freigabe_typ" class="form-input">
{% for value, label in freigabe_typ_choices %}
<option value="{{ value }}" {% if value == freigabe_typ %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</div>
<div>
<label class="form-label">Kommentar (optional)</label>
<textarea name="kommentar" rows="3" class="form-input"
placeholder="Begründung oder Anmerkungen..."></textarea>
</div>
</div>
<div class="flex justify-end gap-2 mt-4">
<button type="button" @click="open = false" class="btn-secondary">Abbrechen</button>
<button type="submit" class="btn-primary">Freigabe erteilen</button>
</div>
</form>
</div>
</div>

View file

@ -0,0 +1,8 @@
<div x-data="{ show: true }" x-show="show" x-cloak x-init="setTimeout(() => show = false, 3000)"
class="fixed inset-0 bg-black/30 z-50 flex items-center justify-center">
<div class="bg-white rounded-xl shadow-xl p-8 text-center max-w-sm mx-4">
<p class="text-3xl mb-2"></p>
<p class="font-medium text-slate-900">Freigabe erteilt!</p>
<button @click="show = false" class="btn-secondary mt-4">Schließen</button>
</div>
</div>

View file

@ -0,0 +1,64 @@
{% if empty %}
<div class="px-4 py-3 text-sm text-slate-500">
Keine Ergebnisse für „{{ q }}"
</div>
{% else %}
<div class="py-1">
{% if ausschreibungen %}
<div class="px-3 py-1 text-xs font-medium text-slate-400 uppercase tracking-wide">Ausschreibungen</div>
{% for a in ausschreibungen %}
<a href="/ausschreibungen/{{ a.pk }}/"
class="flex items-center gap-2 px-4 py-2 text-sm text-slate-700 hover:bg-slate-50">
<span class="text-base">📋</span>
<span>{{ a.titel }}</span>
</a>
{% endfor %}
{% endif %}
{% if dokumente %}
<div class="px-3 py-1 text-xs font-medium text-slate-400 uppercase tracking-wide mt-1">Dokumente</div>
{% for d in dokumente %}
<a href="/ausschreibungen/{{ d.ausschreibung.pk }}/dokumente/"
class="flex items-center gap-2 px-4 py-2 text-sm text-slate-700 hover:bg-slate-50">
<span class="text-base">📄</span>
<span>{{ d.dateiname }}</span>
<span class="text-xs text-slate-400">— {{ d.ausschreibung.titel|truncatechars:30 }}</span>
</a>
{% endfor %}
{% endif %}
{% if nachweise %}
<div class="px-3 py-1 text-xs font-medium text-slate-400 uppercase tracking-wide mt-1">Nachweise</div>
{% for n in nachweise %}
<a href="/bibliothek/nachweise/{{ n.pk }}/"
class="flex items-center gap-2 px-4 py-2 text-sm text-slate-700 hover:bg-slate-50">
<span class="text-base">🏆</span>
<span>{{ n.titel }}</span>
</a>
{% endfor %}
{% endif %}
{% if referenzen %}
<div class="px-3 py-1 text-xs font-medium text-slate-400 uppercase tracking-wide mt-1">Referenzen</div>
{% for r in referenzen %}
<a href="/bibliothek/referenzen/{{ r.pk }}/"
class="flex items-center gap-2 px-4 py-2 text-sm text-slate-700 hover:bg-slate-50">
<span class="text-base"></span>
<span>{{ r.referenztitel }}</span>
<span class="text-xs text-slate-400">— {{ r.kunde|truncatechars:25 }}</span>
</a>
{% endfor %}
{% endif %}
{% if marktbegleiter %}
<div class="px-3 py-1 text-xs font-medium text-slate-400 uppercase tracking-wide mt-1">Marktbegleiter</div>
{% for m in marktbegleiter %}
<a href="/marktbegleiter/{{ m.pk }}/"
class="flex items-center gap-2 px-4 py-2 text-sm text-slate-700 hover:bg-slate-50">
<span class="text-base">🔍</span>
<span>{{ m.name }}</span>
</a>
{% endfor %}
{% endif %}
</div>
{% endif %}