diff --git a/vergabe_teilnahme/apps/ausschreibungen/urls.py b/vergabe_teilnahme/apps/ausschreibungen/urls.py index bafb01a..1353b05 100644 --- a/vergabe_teilnahme/apps/ausschreibungen/urls.py +++ b/vergabe_teilnahme/apps/ausschreibungen/urls.py @@ -13,6 +13,7 @@ urlpatterns = [ path('/status/', views.ausschreibung_status, name='status'), path('/entscheidung/', views.ausschreibung_entscheidung, name='entscheidung'), path('/archivieren/', views.ausschreibung_archivieren, name='archivieren'), + path('/freigaben/', views.freigaben_uebersicht, name='freigaben'), path('/lose/', include('vergabe_teilnahme.apps.lose.urls')), path('/aufgaben/', include('vergabe_teilnahme.apps.aufgaben.urls')), path('/bieterfragen/', include('vergabe_teilnahme.apps.aufgaben.bieterfragen_urls')), diff --git a/vergabe_teilnahme/apps/ausschreibungen/views.py b/vergabe_teilnahme/apps/ausschreibungen/views.py index d53b849..5022fca 100644 --- a/vergabe_teilnahme/apps/ausschreibungen/views.py +++ b/vergabe_teilnahme/apps/ausschreibungen/views.py @@ -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': diff --git a/vergabe_teilnahme/apps/core/templatetags/vergabe_tags.py b/vergabe_teilnahme/apps/core/templatetags/vergabe_tags.py index 1d8976b..35adf5b 100644 --- a/vergabe_teilnahme/apps/core/templatetags/vergabe_tags.py +++ b/vergabe_teilnahme/apps/core/templatetags/vergabe_tags.py @@ -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 diff --git a/vergabe_teilnahme/apps/core/views.py b/vergabe_teilnahme/apps/core/views.py index 11941a2..b880bba 100644 --- a/vergabe_teilnahme/apps/core/views.py +++ b/vergabe_teilnahme/apps/core/views.py @@ -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'' - f'{a.titel}' - 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, + }) diff --git a/vergabe_teilnahme/apps/feedback/urls.py b/vergabe_teilnahme/apps/feedback/urls.py index acdece3..fc10e38 100644 --- a/vergabe_teilnahme/apps/feedback/urls.py +++ b/vergabe_teilnahme/apps/feedback/urls.py @@ -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//status/', views.status_aendern, name='status_aendern'), ] diff --git a/vergabe_teilnahme/apps/feedback/views.py b/vergabe_teilnahme/apps/feedback/views.py index b94a828..34c3854 100644 --- a/vergabe_teilnahme/apps/feedback/views.py +++ b/vergabe_teilnahme/apps/feedback/views.py @@ -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( - '
' - '
' - '

' - '

Danke für dein Feedback!

' - '' - '
' - ) + 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}) diff --git a/vergabe_teilnahme/templates/ausschreibungen/freigaben.html b/vergabe_teilnahme/templates/ausschreibungen/freigaben.html new file mode 100644 index 0000000..9fa4e66 --- /dev/null +++ b/vergabe_teilnahme/templates/ausschreibungen/freigaben.html @@ -0,0 +1,53 @@ +{% extends "base.html" %} +{% load vergabe_tags %} +{% block title %}Freigaben — {{ ausschreibung.titel }}{% endblock %} +{% block content %} +
+

Freigaben: {{ ausschreibung.titel }}

+ +
+ +{% if fehlende_typen %} +
+ Fehlende Pflichtfreigaben: + {% for typ in fehlende_typen %} + {{ typ }} + {% endfor %} +
+{% else %} +
+ Alle Pflichtfreigaben erteilt. +
+{% endif %} + +
+ {% if freigaben %} + + + + + + + + + + + + {% for fg in freigaben %} + + + + + + + + {% endfor %} + +
TypStatusErteilt vonKommentarDatum
{{ fg.get_freigabe_typ_display }}{% status_badge fg.status fg.get_status_display %}{{ fg.freigebende_person.get_full_name|default:fg.freigebende_person.username }}{{ fg.kommentar|default:"—"|truncatechars:80 }}{{ fg.timestamp|date:"d.m.Y H:i" }}
+ {% else %} +

Noch keine Freigaben erteilt.

+ {% endif %} +
+{% endblock %} diff --git a/vergabe_teilnahme/templates/core/feld_konfiguration.html b/vergabe_teilnahme/templates/core/feld_konfiguration.html new file mode 100644 index 0000000..3a24ab6 --- /dev/null +++ b/vergabe_teilnahme/templates/core/feld_konfiguration.html @@ -0,0 +1,33 @@ +{% extends "base.html" %} +{% block title %}Feldkonfiguration — {{ entity_type }}{% endblock %} +{% block content %} +
+

Feldkonfiguration: {{ entity_type }}

+
+ +
+ {% for et in entity_types %} + + {{ et }} + + {% endfor %} +
+ +
+ + + + + + + + + + {% for eintrag in felder %} + {% include "core/partials/feld_zeile.html" with cfg=eintrag.cfg field_name=eintrag.feld.name entity_type=entity_type %} + {% endfor %} + +
FeldnameAnzeige-LabelAusgeblendet
+
+{% endblock %} diff --git a/vergabe_teilnahme/templates/core/partials/custom_attributes.html b/vergabe_teilnahme/templates/core/partials/custom_attributes.html new file mode 100644 index 0000000..b899bec --- /dev/null +++ b/vergabe_teilnahme/templates/core/partials/custom_attributes.html @@ -0,0 +1,67 @@ +
+
+

Zusatzfelder

+ +
+ +
+
+ {% csrf_token %} +
+ + + +
+
+ + +
+
+
+ + {% if attrs %} +
+ {% for attr in attrs %} +
+ {{ attr.label }} + {{ attr.value|default:"—" }} + {{ attr.data_type }} +
+
+ {% csrf_token %} + + +
+
+ {% csrf_token %} + + +
+
+ {% csrf_token %} + +
+
+
+ {% endfor %} +
+ {% else %} +

Keine Zusatzfelder vorhanden.

+ {% endif %} +
diff --git a/vergabe_teilnahme/templates/core/partials/feld_zeile.html b/vergabe_teilnahme/templates/core/partials/feld_zeile.html new file mode 100644 index 0000000..aef1a66 --- /dev/null +++ b/vergabe_teilnahme/templates/core/partials/feld_zeile.html @@ -0,0 +1,29 @@ + + {{ field_name }} + +
+ {% csrf_token %} + + + +
+ + +
+ {% csrf_token %} + + + +
+ + diff --git a/vergabe_teilnahme/templates/feedback/backlog.html b/vergabe_teilnahme/templates/feedback/backlog.html new file mode 100644 index 0000000..43899fc --- /dev/null +++ b/vergabe_teilnahme/templates/feedback/backlog.html @@ -0,0 +1,56 @@ +{% extends "base.html" %} +{% block title %}Feedback-Backlog{% endblock %} +{% block content %} +
+

Feedback-Backlog

+ {{ eintraege.count }} Einträge +
+ +
+ + + + {% if current_status or current_kategorie or current_dringlichkeit %} + Filter zurücksetzen + {% endif %} +
+ +
+ {% if eintraege %} + + + + + + + + + + + + + {% for eintrag in eintraege %} + {% include "feedback/partials/eintrag_zeile.html" with status_choices=status_choices %} + {% endfor %} + +
TitelBeschreibungKategorieDringlichkeitStatusDatum
+ {% else %} +

Keine Einträge gefunden.

+ {% endif %} +
+{% endblock %} diff --git a/vergabe_teilnahme/templates/feedback/partials/eintrag_zeile.html b/vergabe_teilnahme/templates/feedback/partials/eintrag_zeile.html new file mode 100644 index 0000000..b480ff3 --- /dev/null +++ b/vergabe_teilnahme/templates/feedback/partials/eintrag_zeile.html @@ -0,0 +1,39 @@ + + + {{ eintrag.titel|truncatechars:60 }} + {% if eintrag.ausschreibung %} + {{ eintrag.ausschreibung.titel|truncatechars:40 }} + {% endif %} + + {{ eintrag.beschreibung|truncatechars:100 }} + + + {{ eintrag.get_kategorie_display }} + + + + + {{ eintrag.get_dringlichkeit_display }} + + + +
+ {% csrf_token %} + +
+ + {{ eintrag.datum|date:"d.m.Y" }} + diff --git a/vergabe_teilnahme/templates/nachbetrachtung/abgabe.html b/vergabe_teilnahme/templates/nachbetrachtung/abgabe.html index e0d11ef..100da48 100644 --- a/vergabe_teilnahme/templates/nachbetrachtung/abgabe.html +++ b/vergabe_teilnahme/templates/nachbetrachtung/abgabe.html @@ -5,7 +5,7 @@ diff --git a/vergabe_teilnahme/templates/nachbetrachtung/abgabe_formular.html b/vergabe_teilnahme/templates/nachbetrachtung/abgabe_formular.html index 4bf65e5..ae66cc8 100644 --- a/vergabe_teilnahme/templates/nachbetrachtung/abgabe_formular.html +++ b/vergabe_teilnahme/templates/nachbetrachtung/abgabe_formular.html @@ -4,7 +4,7 @@

Abgabe dokumentieren

- ← Checkliste + ← Checkliste
@@ -34,7 +34,7 @@
- Abbrechen + Abbrechen
diff --git a/vergabe_teilnahme/templates/nachbetrachtung/abgabe_problem.html b/vergabe_teilnahme/templates/nachbetrachtung/abgabe_problem.html index 2292f40..9b291c7 100644 --- a/vergabe_teilnahme/templates/nachbetrachtung/abgabe_problem.html +++ b/vergabe_teilnahme/templates/nachbetrachtung/abgabe_problem.html @@ -4,7 +4,7 @@

Problem bei Abgabe vermerken

- ← Checkliste + ← Checkliste
@@ -16,7 +16,7 @@
- Abbrechen + Abbrechen
diff --git a/vergabe_teilnahme/templates/partials/feedback_success.html b/vergabe_teilnahme/templates/partials/feedback_success.html new file mode 100644 index 0000000..0c6824b --- /dev/null +++ b/vergabe_teilnahme/templates/partials/feedback_success.html @@ -0,0 +1,8 @@ +
+
+

+

Danke für dein Feedback!

+ +
+
diff --git a/vergabe_teilnahme/templates/partials/freigabe_modal.html b/vergabe_teilnahme/templates/partials/freigabe_modal.html new file mode 100644 index 0000000..bf251d6 --- /dev/null +++ b/vergabe_teilnahme/templates/partials/freigabe_modal.html @@ -0,0 +1,31 @@ +
+
+

Freigabe erteilen

+
+ {% csrf_token %} + + +
+
+ + +
+
+ + +
+
+
+ + +
+
+
+
diff --git a/vergabe_teilnahme/templates/partials/freigabe_success.html b/vergabe_teilnahme/templates/partials/freigabe_success.html new file mode 100644 index 0000000..5047e5c --- /dev/null +++ b/vergabe_teilnahme/templates/partials/freigabe_success.html @@ -0,0 +1,8 @@ +
+
+

+

Freigabe erteilt!

+ +
+
diff --git a/vergabe_teilnahme/templates/partials/search_results.html b/vergabe_teilnahme/templates/partials/search_results.html new file mode 100644 index 0000000..a845a8e --- /dev/null +++ b/vergabe_teilnahme/templates/partials/search_results.html @@ -0,0 +1,64 @@ +{% if empty %} +
+ Keine Ergebnisse für „{{ q }}" +
+{% else %} +
+ {% if ausschreibungen %} +
Ausschreibungen
+ {% for a in ausschreibungen %} + + 📋 + {{ a.titel }} + + {% endfor %} + {% endif %} + + {% if dokumente %} +
Dokumente
+ {% for d in dokumente %} + + 📄 + {{ d.dateiname }} + — {{ d.ausschreibung.titel|truncatechars:30 }} + + {% endfor %} + {% endif %} + + {% if nachweise %} +
Nachweise
+ {% for n in nachweise %} + + 🏆 + {{ n.titel }} + + {% endfor %} + {% endif %} + + {% if referenzen %} +
Referenzen
+ {% for r in referenzen %} + + + {{ r.referenztitel }} + — {{ r.kunde|truncatechars:25 }} + + {% endfor %} + {% endif %} + + {% if marktbegleiter %} +
Marktbegleiter
+ {% for m in marktbegleiter %} + + 🔍 + {{ m.name }} + + {% endfor %} + {% endif %} +
+{% endif %} diff --git a/vergabe_teilnahme/tests/__init__.py b/vergabe_teilnahme/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/vergabe_teilnahme/tests/test_e2e.py b/vergabe_teilnahme/tests/test_e2e.py new file mode 100644 index 0000000..081fc40 --- /dev/null +++ b/vergabe_teilnahme/tests/test_e2e.py @@ -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 diff --git a/vergabe_teilnahme/urls.py b/vergabe_teilnahme/urls.py index e9699b3..347b968 100644 --- a/vergabe_teilnahme/urls.py +++ b/vergabe_teilnahme/urls.py @@ -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//', core_views.feld_konfiguration_liste, name='feld_konfiguration_liste'), + path('felder///toggle/', core_views.feld_konfiguration_toggle, name='feld_konfiguration_toggle'), + # CustomAttributes + path('core/attrs///', core_views.custom_attributes_panel, name='custom_attributes_panel'), + path('core/attrs///neu/', core_views.custom_attribute_neu, name='custom_attribute_neu'), + path('core/attrs////bearbeiten/', core_views.custom_attribute_bearbeiten, name='custom_attribute_bearbeiten'), + path('core/attrs////loeschen/', core_views.custom_attribute_loeschen, name='custom_attribute_loeschen'), + path('core/attrs////sort/', core_views.custom_attribute_sort, name='custom_attribute_sort'), ] if settings.DEBUG: diff --git a/workplans/WP-0012-querschnitt.md b/workplans/WP-0012-querschnitt.md index eeb0f63..01103c5 100644 --- a/workplans/WP-0012-querschnitt.md +++ b/workplans/WP-0012-querschnitt.md @@ -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('/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////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: