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:
parent
c5ccbd665d
commit
5a231223c0
23 changed files with 828 additions and 37 deletions
|
|
@ -1,6 +1,31 @@
|
|||
from django.http import HttpResponse
|
||||
from django.shortcuts import render
|
||||
from django.views.decorators.http import require_GET
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.http import HttpResponse, HttpResponseBadRequest
|
||||
from django.shortcuts import get_object_or_404, render
|
||||
from django.views.decorators.http import require_GET, require_POST
|
||||
|
||||
from .models import CustomAttribute, EntityFieldConfig, Freigabe
|
||||
|
||||
ENTITY_TYPES = None # lazy-loaded to avoid circular imports
|
||||
|
||||
|
||||
def _entity_types():
|
||||
global ENTITY_TYPES
|
||||
if ENTITY_TYPES is None:
|
||||
from vergabe_teilnahme.apps.aufgaben.models import Aufgabe
|
||||
from vergabe_teilnahme.apps.ausschreibungen.models import Ausschreibung
|
||||
from vergabe_teilnahme.apps.bibliothek.models import Nachweis, Referenz
|
||||
from vergabe_teilnahme.apps.lose.models import Anforderung, Los
|
||||
from vergabe_teilnahme.apps.partner.models import Subunternehmer
|
||||
ENTITY_TYPES = {
|
||||
'ausschreibung': Ausschreibung,
|
||||
'los': Los,
|
||||
'anforderung': Anforderung,
|
||||
'aufgabe': Aufgabe,
|
||||
'subunternehmer': Subunternehmer,
|
||||
'nachweis': Nachweis,
|
||||
'referenz': Referenz,
|
||||
}
|
||||
return ENTITY_TYPES
|
||||
|
||||
|
||||
def custom_404(request, exception=None):
|
||||
|
|
@ -11,19 +36,190 @@ def custom_500(request):
|
|||
return render(request, 'errors/500.html', status=500)
|
||||
|
||||
|
||||
# ── Freigaben ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@require_GET
|
||||
def freigabe_modal(request):
|
||||
ctx = {
|
||||
'content_type_id': request.GET.get('ct', ''),
|
||||
'object_id': request.GET.get('oid', ''),
|
||||
'freigabe_typ': request.GET.get('typ', ''),
|
||||
'freigabe_typ_choices': Freigabe.TYP_CHOICES,
|
||||
}
|
||||
return render(request, 'partials/freigabe_modal.html', ctx)
|
||||
|
||||
|
||||
@require_POST
|
||||
def freigabe_erteilen(request):
|
||||
ct_id = request.POST.get('content_type_id')
|
||||
object_id = request.POST.get('object_id')
|
||||
freigabe_typ = request.POST.get('freigabe_typ')
|
||||
if not (ct_id and object_id and freigabe_typ):
|
||||
return HttpResponseBadRequest()
|
||||
ct = get_object_or_404(ContentType, pk=ct_id)
|
||||
Freigabe.objects.create(
|
||||
content_type=ct,
|
||||
object_id=object_id,
|
||||
freigabe_typ=freigabe_typ,
|
||||
freigebende_person=request.user,
|
||||
status='erteilt',
|
||||
kommentar=request.POST.get('kommentar', ''),
|
||||
)
|
||||
return render(request, 'partials/freigabe_success.html', {})
|
||||
|
||||
|
||||
# ── EntityFieldConfig ─────────────────────────────────────────────────────────
|
||||
|
||||
def feld_konfiguration_liste(request, entity_type):
|
||||
types = _entity_types()
|
||||
if entity_type not in types:
|
||||
from django.http import Http404
|
||||
raise Http404
|
||||
model = types[entity_type]
|
||||
raw_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)
|
||||
}
|
||||
felder = [
|
||||
{'feld': f, 'cfg': konfigurationen.get(f.name)}
|
||||
for f in raw_felder
|
||||
]
|
||||
return render(request, 'core/feld_konfiguration.html', {
|
||||
'entity_type': entity_type,
|
||||
'entity_types': list(_entity_types().keys()),
|
||||
'felder': felder,
|
||||
})
|
||||
|
||||
|
||||
@require_POST
|
||||
def feld_konfiguration_toggle(request, entity_type, field_name):
|
||||
cfg, _ = EntityFieldConfig.objects.get_or_create(
|
||||
entity_type=entity_type, field_name=field_name
|
||||
)
|
||||
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,
|
||||
'entity_type': entity_type,
|
||||
})
|
||||
|
||||
|
||||
# ── CustomAttributes ──────────────────────────────────────────────────────────
|
||||
|
||||
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,
|
||||
})
|
||||
|
||||
|
||||
@require_POST
|
||||
def custom_attribute_neu(request, content_type_id, object_id):
|
||||
from django.utils.text import slugify
|
||||
ct = get_object_or_404(ContentType, pk=content_type_id)
|
||||
label = request.POST.get('label', '').strip()
|
||||
if label:
|
||||
CustomAttribute.objects.create(
|
||||
content_type=ct,
|
||||
object_id=object_id,
|
||||
key=slugify(label),
|
||||
label=label,
|
||||
value=request.POST.get('value', ''),
|
||||
data_type=request.POST.get('data_type', 'text'),
|
||||
)
|
||||
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,
|
||||
})
|
||||
|
||||
|
||||
@require_POST
|
||||
def custom_attribute_bearbeiten(request, content_type_id, object_id, attr_pk):
|
||||
attr = get_object_or_404(CustomAttribute, pk=attr_pk, object_id=object_id)
|
||||
attr.label = request.POST.get('label', attr.label).strip() or attr.label
|
||||
attr.value = request.POST.get('value', attr.value)
|
||||
attr.data_type = request.POST.get('data_type', attr.data_type)
|
||||
attr.save()
|
||||
ct = ContentType.objects.get(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,
|
||||
})
|
||||
|
||||
|
||||
@require_POST
|
||||
def custom_attribute_loeschen(request, content_type_id, object_id, attr_pk):
|
||||
attr = get_object_or_404(CustomAttribute, pk=attr_pk, object_id=object_id)
|
||||
attr.delete()
|
||||
ct = ContentType.objects.get(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,
|
||||
})
|
||||
|
||||
|
||||
@require_POST
|
||||
def custom_attribute_sort(request, content_type_id, object_id, attr_pk):
|
||||
direction = request.POST.get('direction', 'down')
|
||||
attr = get_object_or_404(CustomAttribute, pk=attr_pk, object_id=object_id)
|
||||
ct = ContentType.objects.get(pk=content_type_id)
|
||||
attrs = list(CustomAttribute.objects.filter(content_type=ct, object_id=object_id))
|
||||
idx = next((i for i, a in enumerate(attrs) if a.pk == attr.pk), None)
|
||||
if idx is not None:
|
||||
swap = idx - 1 if direction == 'up' else idx + 1
|
||||
if 0 <= swap < len(attrs):
|
||||
old, new = attrs[swap].sort_order, attrs[idx].sort_order
|
||||
attrs[idx].sort_order, attrs[swap].sort_order = old, new
|
||||
attrs[idx].save(update_fields=['sort_order'])
|
||||
attrs[swap].save(update_fields=['sort_order'])
|
||||
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,
|
||||
})
|
||||
|
||||
|
||||
# ── Globale Suche ─────────────────────────────────────────────────────────────
|
||||
|
||||
@require_GET
|
||||
def suche(request):
|
||||
q = request.GET.get('q', '').strip()
|
||||
if not q or len(q) < 2:
|
||||
return HttpResponse('')
|
||||
from vergabe_teilnahme.apps.ausschreibungen.models import Ausschreibung
|
||||
from vergabe_teilnahme.apps.bibliothek.models import Nachweis, Referenz
|
||||
from vergabe_teilnahme.apps.dokumente.models import Dokument
|
||||
from vergabe_teilnahme.apps.marktbegleiter.models import Marktbegleiter
|
||||
|
||||
ausschreibungen = Ausschreibung.objects.filter(titel__icontains=q)[:5]
|
||||
if not ausschreibungen:
|
||||
return HttpResponse('')
|
||||
items = ''.join(
|
||||
f'<a href="/ausschreibungen/{a.pk}/" '
|
||||
f'class="block px-4 py-2 text-sm text-slate-700 hover:bg-slate-50">'
|
||||
f'{a.titel}</a>'
|
||||
for a in ausschreibungen
|
||||
)
|
||||
return HttpResponse(items)
|
||||
dokumente = Dokument.objects.filter(dateiname__icontains=q).select_related('ausschreibung')[:5]
|
||||
nachweise = Nachweis.objects.filter(titel__icontains=q)[:5]
|
||||
referenzen = Referenz.objects.filter(referenztitel__icontains=q)[:5]
|
||||
marktbegleiter = Marktbegleiter.objects.filter(name__icontains=q)[:5]
|
||||
|
||||
has_results = any([ausschreibungen, dokumente, nachweise, referenzen, marktbegleiter])
|
||||
if not has_results:
|
||||
return render(request, 'partials/search_results.html', {'q': q, 'empty': True})
|
||||
|
||||
return render(request, 'partials/search_results.html', {
|
||||
'q': q,
|
||||
'ausschreibungen': ausschreibungen,
|
||||
'dokumente': dokumente,
|
||||
'nachweise': nachweise,
|
||||
'referenzen': referenzen,
|
||||
'marktbegleiter': marktbegleiter,
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue