generated from coulomb/repo-seed
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:
@@ -13,6 +13,7 @@ urlpatterns = [
|
||||
path('<int:pk>/status/', views.ausschreibung_status, name='status'),
|
||||
path('<int:pk>/entscheidung/', views.ausschreibung_entscheidung, name='entscheidung'),
|
||||
path('<int:pk>/archivieren/', views.ausschreibung_archivieren, name='archivieren'),
|
||||
path('<int:pk>/freigaben/', views.freigaben_uebersicht, name='freigaben'),
|
||||
path('<int:ausschreibung_id>/lose/', include('vergabe_teilnahme.apps.lose.urls')),
|
||||
path('<int:ausschreibung_id>/aufgaben/', include('vergabe_teilnahme.apps.aufgaben.urls')),
|
||||
path('<int:ausschreibung_id>/bieterfragen/', include('vergabe_teilnahme.apps.aufgaben.bieterfragen_urls')),
|
||||
|
||||
@@ -178,6 +178,36 @@ def ausschreibung_entscheidung(request, pk):
|
||||
return render(request, 'ausschreibungen/entscheidung.html', ctx)
|
||||
|
||||
|
||||
def freigaben_uebersicht(request, pk):
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
|
||||
from vergabe_teilnahme.apps.core.models import Freigabe
|
||||
|
||||
a = get_object_or_404(Ausschreibung, pk=pk)
|
||||
ct = ContentType.objects.get_for_model(a)
|
||||
freigaben = Freigabe.objects.filter(
|
||||
content_type=ct, object_id=pk
|
||||
).select_related('freigebende_person').order_by('-timestamp')
|
||||
|
||||
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': a,
|
||||
'freigaben': freigaben,
|
||||
'fehlende_typen': fehlende_typen,
|
||||
'ct_id': ct.pk,
|
||||
'freigabe_typ_choices': Freigabe.TYP_CHOICES,
|
||||
'breadcrumbs': [
|
||||
{'label': 'Ausschreibungen', 'url': '/ausschreibungen/'},
|
||||
{'label': a.titel, 'url': f'/ausschreibungen/{pk}/'},
|
||||
{'label': 'Freigaben', 'url': None},
|
||||
],
|
||||
}
|
||||
return render(request, 'ausschreibungen/freigaben.html', ctx)
|
||||
|
||||
|
||||
def ausschreibung_archivieren(request, pk):
|
||||
a = get_object_or_404(Ausschreibung, pk=pk)
|
||||
if request.method == 'POST':
|
||||
|
||||
@@ -61,6 +61,12 @@ def _get_field_label(entity_type, field_name, default_label):
|
||||
return default_label
|
||||
|
||||
|
||||
@register.simple_tag
|
||||
def content_type_id(obj):
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
return ContentType.objects.get_for_model(obj).pk
|
||||
|
||||
|
||||
@register.inclusion_tag('partials/field_row.html')
|
||||
def render_field(obj, field_name, label=None, force_show=False):
|
||||
entity_type = obj._meta.model_name
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -7,4 +7,6 @@ app_name = 'feedback'
|
||||
urlpatterns = [
|
||||
path('modal/', views.modal, name='modal'),
|
||||
path('', views.submit, name='submit'),
|
||||
path('backlog/', views.backlog, name='backlog'),
|
||||
path('backlog/<int:pk>/status/', views.status_aendern, name='status_aendern'),
|
||||
]
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
from django.http import HttpResponse
|
||||
from django.shortcuts import render
|
||||
from django.shortcuts import get_object_or_404, render
|
||||
from django.views.decorators.http import require_GET, require_POST
|
||||
|
||||
from .models import Feedbackeintrag
|
||||
@@ -35,12 +34,48 @@ def submit(request):
|
||||
entry.erfasst_von = request.user
|
||||
entry.save()
|
||||
|
||||
return HttpResponse(
|
||||
'<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-8 text-center max-w-sm mx-4">'
|
||||
'<p class="text-2xl mb-2">✅</p>'
|
||||
'<p class="font-medium text-slate-900">Danke für dein Feedback!</p>'
|
||||
'<button @click="open=false" class="btn-secondary mt-4">Schließen</button>'
|
||||
'</div></div>'
|
||||
)
|
||||
return render(request, 'partials/feedback_success.html', {})
|
||||
|
||||
|
||||
def backlog(request):
|
||||
qs = Feedbackeintrag.objects.select_related('erfasst_von', 'ausschreibung').all()
|
||||
|
||||
status_filter = request.GET.get('status')
|
||||
if status_filter:
|
||||
qs = qs.filter(status=status_filter)
|
||||
|
||||
kategorie_filter = request.GET.get('kategorie')
|
||||
if kategorie_filter:
|
||||
qs = qs.filter(kategorie=kategorie_filter)
|
||||
|
||||
dringlichkeit_filter = request.GET.get('dringlichkeit')
|
||||
if dringlichkeit_filter:
|
||||
qs = qs.filter(dringlichkeit=dringlichkeit_filter)
|
||||
|
||||
ctx = {
|
||||
'eintraege': qs,
|
||||
'status_choices': Feedbackeintrag.STATUS_CHOICES,
|
||||
'kategorie_choices': Feedbackeintrag.KATEGORIE_CHOICES,
|
||||
'dringlichkeit_choices': Feedbackeintrag.DRINGLICHKEIT_CHOICES,
|
||||
'current_status': status_filter or '',
|
||||
'current_kategorie': kategorie_filter or '',
|
||||
'current_dringlichkeit': dringlichkeit_filter or '',
|
||||
'breadcrumbs': [{'label': 'Feedback-Backlog', 'url': None}],
|
||||
}
|
||||
return render(request, 'feedback/backlog.html', ctx)
|
||||
|
||||
|
||||
@require_POST
|
||||
def status_aendern(request, pk):
|
||||
eintrag = get_object_or_404(Feedbackeintrag, pk=pk)
|
||||
neuer_status = request.POST.get('status')
|
||||
if neuer_status in dict(Feedbackeintrag.STATUS_CHOICES):
|
||||
eintrag.status = neuer_status
|
||||
bewertung = request.POST.get('bewertung')
|
||||
if bewertung is not None:
|
||||
eintrag.bewertung = bewertung
|
||||
entscheidung = request.POST.get('entscheidung')
|
||||
if entscheidung is not None:
|
||||
eintrag.entscheidung = entscheidung
|
||||
eintrag.save()
|
||||
return render(request, 'feedback/partials/eintrag_zeile.html', {'eintrag': eintrag})
|
||||
|
||||
53
vergabe_teilnahme/templates/ausschreibungen/freigaben.html
Normal file
53
vergabe_teilnahme/templates/ausschreibungen/freigaben.html
Normal 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 %}
|
||||
33
vergabe_teilnahme/templates/core/feld_konfiguration.html
Normal file
33
vergabe_teilnahme/templates/core/feld_konfiguration.html
Normal 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 %}
|
||||
@@ -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>
|
||||
29
vergabe_teilnahme/templates/core/partials/feld_zeile.html
Normal file
29
vergabe_teilnahme/templates/core/partials/feld_zeile.html
Normal 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>
|
||||
56
vergabe_teilnahme/templates/feedback/backlog.html
Normal file
56
vergabe_teilnahme/templates/feedback/backlog.html
Normal 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 %}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
31
vergabe_teilnahme/templates/partials/freigabe_modal.html
Normal file
31
vergabe_teilnahme/templates/partials/freigabe_modal.html
Normal 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>
|
||||
@@ -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>
|
||||
64
vergabe_teilnahme/templates/partials/search_results.html
Normal file
64
vergabe_teilnahme/templates/partials/search_results.html
Normal 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 %}
|
||||
0
vergabe_teilnahme/tests/__init__.py
Normal file
0
vergabe_teilnahme/tests/__init__.py
Normal file
121
vergabe_teilnahme/tests/test_e2e.py
Normal file
121
vergabe_teilnahme/tests/test_e2e.py
Normal file
@@ -0,0 +1,121 @@
|
||||
import pytest
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
|
||||
from vergabe_teilnahme.apps.accounts.models import Mitarbeiter
|
||||
from vergabe_teilnahme.apps.ausschreibungen.models import Ausschreibung
|
||||
from vergabe_teilnahme.apps.core.models import CustomAttribute, EntityFieldConfig, Freigabe
|
||||
from vergabe_teilnahme.apps.lose.models import Los
|
||||
from vergabe_teilnahme.apps.partner.models import Subunternehmer
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mitarbeiter(db):
|
||||
return Mitarbeiter.objects.create_user(username='testuser', password='x')
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestVollstaendigerBieterprozess:
|
||||
def test_ausschreibung_anlegen_bis_freigabe(self, client, mitarbeiter):
|
||||
client.force_login(mitarbeiter)
|
||||
|
||||
# 1. Ausschreibung anlegen
|
||||
r = client.post('/ausschreibungen/neu/', {
|
||||
'titel': 'E2E Test Ausschreibung',
|
||||
'ausschreiber': 'Testamt',
|
||||
})
|
||||
assert r.status_code == 302
|
||||
a = Ausschreibung.objects.get(titel='E2E Test Ausschreibung')
|
||||
|
||||
# 2. Los anlegen
|
||||
r = client.post(f'/ausschreibungen/{a.pk}/lose/neu/', {
|
||||
'losnummer': '1',
|
||||
'lostitel': 'Hauptlos',
|
||||
})
|
||||
assert r.status_code == 302
|
||||
assert Los.objects.filter(ausschreibung=a).count() == 1
|
||||
|
||||
# 3. Freigabe erteilen via API
|
||||
ct = ContentType.objects.get_for_model(a)
|
||||
r = client.post('/freigaben/erteilen/', {
|
||||
'content_type_id': ct.pk,
|
||||
'object_id': a.pk,
|
||||
'freigabe_typ': 'teilnahme',
|
||||
})
|
||||
assert r.status_code == 200
|
||||
assert Freigabe.objects.filter(
|
||||
content_type=ct, object_id=a.pk, freigabe_typ='teilnahme'
|
||||
).exists()
|
||||
|
||||
# 4. Freigaben-Übersicht abrufbar
|
||||
r = client.get(f'/ausschreibungen/{a.pk}/freigaben/')
|
||||
assert r.status_code == 200
|
||||
assert 'teilnahme' in r.content.decode().lower()
|
||||
|
||||
def test_freigabe_modal_liefert_fragment(self, client, mitarbeiter):
|
||||
client.force_login(mitarbeiter)
|
||||
a = Ausschreibung.objects.create(titel='Modal Test', ausschreiber='X', status=1)
|
||||
ct = ContentType.objects.get_for_model(a)
|
||||
r = client.get(f'/freigaben/modal/?ct={ct.pk}&oid={a.pk}&typ=preis')
|
||||
assert r.status_code == 200
|
||||
content = r.content.decode()
|
||||
assert 'freigabe_typ' in content.lower() or 'freigabe' in content.lower()
|
||||
|
||||
def test_abgabe_dokumentieren(self, client, mitarbeiter):
|
||||
client.force_login(mitarbeiter)
|
||||
a = Ausschreibung.objects.create(titel='Abgabe Test', ausschreiber='X', status=6)
|
||||
r = client.post(f'/ausschreibungen/{a.pk}/abgabe/dokumentieren/', {
|
||||
'abgabe_zeitpunkt': '2026-05-11T12:00',
|
||||
'abgabe_plattform': 'Vergabeplattform',
|
||||
})
|
||||
assert r.status_code == 302
|
||||
a.refresh_from_db()
|
||||
assert a.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')
|
||||
EntityFieldConfig.objects.create(
|
||||
entity_type='subunternehmer', field_name='webseite', is_hidden=True
|
||||
)
|
||||
client.force_login(mitarbeiter)
|
||||
hidden_fields = sub.get_hidden_fields()
|
||||
assert 'webseite' in hidden_fields
|
||||
|
||||
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 r.status_code == 200
|
||||
assert CustomAttribute.objects.filter(
|
||||
content_type=ct, object_id=sub.pk, label='Vertragsnummer'
|
||||
).exists()
|
||||
|
||||
def test_custom_attribute_panel_zeigt_attribute(self, client, mitarbeiter):
|
||||
sub = Subunternehmer.objects.create(name='Panel Test GmbH')
|
||||
ct = ContentType.objects.get_for_model(sub)
|
||||
CustomAttribute.objects.create(
|
||||
content_type=ct, object_id=sub.pk,
|
||||
key='kundennr', label='Kundennummer', value='KD-999', data_type='text'
|
||||
)
|
||||
client.force_login(mitarbeiter)
|
||||
r = client.get(f'/core/attrs/{ct.pk}/{sub.pk}/')
|
||||
assert r.status_code == 200
|
||||
assert b'Kundennummer' in r.content
|
||||
|
||||
def test_feld_konfiguration_seite_erreichbar(self, client, mitarbeiter):
|
||||
client.force_login(mitarbeiter)
|
||||
r = client.get('/felder/ausschreibung/')
|
||||
assert r.status_code == 200
|
||||
assert b'titel' in r.content
|
||||
|
||||
def test_feedback_backlog_erreichbar(self, client, mitarbeiter):
|
||||
client.force_login(mitarbeiter)
|
||||
r = client.get('/feedback/backlog/')
|
||||
assert r.status_code == 200
|
||||
@@ -36,6 +36,18 @@ urlpatterns = [
|
||||
path('nachbetrachtung/', include('vergabe_teilnahme.apps.nachbetrachtung.urls')),
|
||||
path('feedback/', include('vergabe_teilnahme.apps.feedback.urls', namespace='feedback')),
|
||||
path('suche/', core_views.suche, name='suche'),
|
||||
# Freigaben
|
||||
path('freigaben/modal/', core_views.freigabe_modal, name='freigabe_modal'),
|
||||
path('freigaben/erteilen/', core_views.freigabe_erteilen, name='freigabe_erteilen'),
|
||||
# Admin — Feldkonfiguration
|
||||
path('felder/<str:entity_type>/', core_views.feld_konfiguration_liste, name='feld_konfiguration_liste'),
|
||||
path('felder/<str:entity_type>/<str:field_name>/toggle/', core_views.feld_konfiguration_toggle, name='feld_konfiguration_toggle'),
|
||||
# CustomAttributes
|
||||
path('core/attrs/<int:content_type_id>/<int:object_id>/', core_views.custom_attributes_panel, name='custom_attributes_panel'),
|
||||
path('core/attrs/<int:content_type_id>/<int:object_id>/neu/', core_views.custom_attribute_neu, name='custom_attribute_neu'),
|
||||
path('core/attrs/<int:content_type_id>/<int:object_id>/<int:attr_pk>/bearbeiten/', core_views.custom_attribute_bearbeiten, name='custom_attribute_bearbeiten'),
|
||||
path('core/attrs/<int:content_type_id>/<int:object_id>/<int:attr_pk>/loeschen/', core_views.custom_attribute_loeschen, name='custom_attribute_loeschen'),
|
||||
path('core/attrs/<int:content_type_id>/<int:object_id>/<int:attr_pk>/sort/', core_views.custom_attribute_sort, name='custom_attribute_sort'),
|
||||
]
|
||||
|
||||
if settings.DEBUG:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
id: WP-0012
|
||||
title: Querschnitt — Freigaben, Flexible Felder, Feedback, Suche, Tests
|
||||
status: todo
|
||||
status: done
|
||||
phase: 12-of-12
|
||||
created: "2026-05-08"
|
||||
depends_on: WP-0011
|
||||
@@ -18,7 +18,7 @@ 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: todo
|
||||
status: done
|
||||
|
||||
`core/views.py` — freigabe_modal und freigabe_erteilen:
|
||||
|
||||
@@ -71,7 +71,7 @@ URL: `path('freigaben/modal/', core_views.freigabe_modal, name='freigabe_modal')
|
||||
```task
|
||||
id: WP-0012-T02
|
||||
title: Freigaben-Übersicht pro Ausschreibung (UC-FR-02)
|
||||
status: todo
|
||||
status: done
|
||||
|
||||
`ausschreibungen/views.py` — freigaben_uebersicht:
|
||||
```python
|
||||
@@ -105,7 +105,7 @@ 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: todo
|
||||
status: done
|
||||
|
||||
`core/views.py` — feld_konfiguration_liste und feld_konfiguration_toggle:
|
||||
|
||||
@@ -153,7 +153,7 @@ Pro Feld eine Zeile mit: Feldname, Anzeige-Label-Input, Ausblenden-Toggle (HTMX)
|
||||
```task
|
||||
id: WP-0012-T04
|
||||
title: CustomAttribute-Panel für alle Detailseiten (UC-FF-03)
|
||||
status: todo
|
||||
status: done
|
||||
|
||||
`core/views.py` — custom_attributes_panel, custom_attribute_neu, custom_attribute_bearbeiten:
|
||||
|
||||
@@ -204,7 +204,7 @@ path('core/attrs/<int:ct_id>/<int:oid>/<int:attr_pk>/sort/', custom_attribute_so
|
||||
```task
|
||||
id: WP-0012-T05
|
||||
title: Feedback vollständig: Modal-POST und Backlog-View (UC-FB-01, UC-FB-02)
|
||||
status: todo
|
||||
status: done
|
||||
|
||||
`feedback/views.py` — feedback_modal, feedback_speichern, feedback_backlog:
|
||||
|
||||
@@ -234,7 +234,7 @@ Admin kann Status ändern (neu/in_bearbeitung/umgesetzt/abgelehnt), Bewertung un
|
||||
```task
|
||||
id: WP-0012-T06
|
||||
title: End-to-End-Tests für kritische Use Cases
|
||||
status: todo
|
||||
status: done
|
||||
|
||||
Erstelle `vergabe_teilnahme/tests/test_e2e.py` mit vollständigen Prozess-Tests:
|
||||
|
||||
@@ -296,7 +296,7 @@ class TestFlexibleFelder:
|
||||
```task
|
||||
id: WP-0012-T07
|
||||
title: Globale Suche vervollständigen und Performance-Prüfung
|
||||
status: todo
|
||||
status: done
|
||||
|
||||
Vervollständige `core/views.py` — global_search:
|
||||
- Dokumente (dateiname__icontains)
|
||||
@@ -318,7 +318,7 @@ dass die Such-View ≤ 6 DB-Queries ausführt (eine pro Entitätstyp + ContentTy
|
||||
```task
|
||||
id: WP-0012-T08
|
||||
title: Finaler Integrations-Smoke-Test und CLAUDE.md-Aktualisierung
|
||||
status: todo
|
||||
status: done
|
||||
|
||||
Führe den finalen Integrations-Test durch:
|
||||
|
||||
|
||||
Reference in New Issue
Block a user