From 9345a1bb1a92ac0ee3b2dc6443a3d299e6754e70 Mon Sep 17 00:00:00 2001 From: tegwick Date: Fri, 11 Sep 2026 21:29:26 +0200 Subject: [PATCH] feat: serve isolated companies below a fixed product path Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a07ff8-19d0-7820-b4d0-1353833cb7fc --- WORK-RECORDS.md | 1 + .../2026-09-11-tenant-path-browser.json | 16 +++ docs/tenant-path-deployment.md | 31 +++++ vergabe_teilnahme/apps/accounts/middleware.py | 5 +- .../apps/accounts/test_tenant_path.py | 120 ++++++++++++++++++ vergabe_teilnahme/apps/aufgaben/views.py | 51 ++++---- .../apps/ausschreibungen/views.py | 27 ++-- vergabe_teilnahme/apps/bibliothek/views.py | 30 ++--- vergabe_teilnahme/apps/core/services.py | 4 +- vergabe_teilnahme/apps/lose/views.py | 25 ++-- vergabe_teilnahme/apps/partner/views.py | 13 +- vergabe_teilnahme/settings/base.py | 19 ++- vergabe_teilnahme/settings/paths.py | 12 ++ .../templates/ausschreibungen/dashboard.html | 8 +- .../templates/ausschreibungen/freigaben.html | 2 +- .../templates/core/feld_konfiguration.html | 2 +- .../core/partials/custom_attributes.html | 8 +- .../templates/core/partials/feld_zeile.html | 4 +- vergabe_teilnahme/templates/errors/404.html | 2 +- vergabe_teilnahme/templates/errors/500.html | 2 +- .../feedback/partials/eintrag_zeile.html | 4 +- .../templates/partials/feedback_button.html | 2 +- .../templates/partials/feedback_modal.html | 2 +- .../templates/partials/freigabe_modal.html | 2 +- .../templates/partials/search_results.html | 10 +- .../templates/partials/sidebar.html | 26 ++-- .../templates/partials/topbar.html | 4 +- .../VERGABE-WP-0019-invited-company-pilot.md | 35 ++++- 28 files changed, 350 insertions(+), 117 deletions(-) create mode 100644 docs/evidence/2026-09-11-tenant-path-browser.json create mode 100644 docs/tenant-path-deployment.md create mode 100644 vergabe_teilnahme/apps/accounts/test_tenant_path.py create mode 100644 vergabe_teilnahme/settings/paths.py diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index 2d43dd2..e7902a4 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -34,6 +34,7 @@ | task | VERGABE-WP-0019-T02 | done | — | workplans/VERGABE-WP-0019-invited-company-pilot.md | | task | VERGABE-WP-0019-T03 | progress | — | workplans/VERGABE-WP-0019-invited-company-pilot.md | | task | VERGABE-WP-0019-T04 | wait | — | workplans/VERGABE-WP-0019-invited-company-pilot.md | +| task | VERGABE-WP-0019-T05 | done | — | workplans/VERGABE-WP-0019-invited-company-pilot.md | | task | VT-WP-0001-T03 | done | — | workplans/VT-WP-0001-projektgeruest.md | | task | VT-WP-0001-T05 | done | — | workplans/VT-WP-0001-projektgeruest.md | | task | VT-WP-0001-T07 | done | — | workplans/VT-WP-0001-projektgeruest.md | diff --git a/docs/evidence/2026-09-11-tenant-path-browser.json b/docs/evidence/2026-09-11-tenant-path-browser.json new file mode 100644 index 0000000..9c10003 --- /dev/null +++ b/docs/evidence/2026-09-11-tenant-path-browser.json @@ -0,0 +1,16 @@ +{ + "status": "passed", + "checks": [ + "Edge matches only the exact company prefix", + "Bare company path canonicalizes and anonymous access reaches prefixed login", + "Prefixed CSS and JavaScript load and render the login controls", + "Real CSRF login, cookie scope and dashboard navigation stay within the tenant path", + "Password-change navigation and CSRF logout preserve the prefix", + "Expired HTMX requests demand full prefixed login", + "Mobile login fits and browser has no JavaScript errors" + ], + "synthetic_users": true, + "disposable_sqlite": true, + "local_http_only": true, + "production_edge_admitted": false +} diff --git a/docs/tenant-path-deployment.md b/docs/tenant-path-deployment.md new file mode 100644 index 0000000..d937914 --- /dev/null +++ b/docs/tenant-path-deployment.md @@ -0,0 +1,31 @@ +# Deploy an isolated company below the product hostname + +For the demo pilot, use `https://vergabe-teilnahme.coulomb.social/demo-company/`. +Configure the isolated application with `APP_BASE_PATH=/demo-company` and +`ALLOWED_HOSTS=vergabe-teilnahme.coulomb.social`. The external CSRF origin is +`https://vergabe-teilnahme.coulomb.social`, without a path. + +The edge matches exactly `/demo-company` or the `/demo-company/` path prefix, +redirects the bare path to its trailing-slash form, and strips `/demo-company` +before forwarding. Internal health remains `/health/`. Django's fixed +FORCE_SCRIPT_NAME generates the external URLs. Untrusted forwarded-prefix +headers never select a tenant or override this configuration. Keep static and +private media requests on this same application route. + +Each tenant keeps its own namespace, database, media volume and issue-state +volume. A URL path is a deployment selector, not an authentication claim. +Cookies use company-specific names and paths; cookies and paths are not separate +browser origins. Only this trusted product's instances share the product host. +Django accounts still provide product admission; native platform tenant creation +and membership do not by themselves implement product SSO. + +Omit APP_BASE_PATH for the existing root-path deployment behavior. Nonempty +values must be one lowercase tenant slug prefixed with `/`; traversal, nested +paths and external URLs are refused at startup. + +Validation: 94 existing application tests plus four tenant-path regressions; +Vite asset build; seven local Chromium checks through an exact-path/strip-prefix +edge fixture. Authentication/CSRF, password changes, navigation/HTMX, private +media, cookie scope, assets and mobile layout are covered. These checks use a +disposable SQLite database and synthetic users, and do not establish live DNS, +TLS, database custody or restore readiness. RAPPS-WP-0014 owns those live gates. diff --git a/vergabe_teilnahme/apps/accounts/middleware.py b/vergabe_teilnahme/apps/accounts/middleware.py index 0aaa6ed..8bbbddb 100644 --- a/vergabe_teilnahme/apps/accounts/middleware.py +++ b/vergabe_teilnahme/apps/accounts/middleware.py @@ -1,6 +1,7 @@ from django.contrib.auth.middleware import LoginRequiredMiddleware from django.contrib.auth.views import redirect_to_login from django.http import HttpResponse +from django.urls import reverse from django.utils.cache import add_never_cache_headers @@ -10,12 +11,12 @@ class PilotLoginRequiredMiddleware(LoginRequiredMiddleware): def handle_no_permission(self, request, view_func): if request.headers.get('HX-Request') == 'true': # The original URL may render only a fragment or accept only POST. - login = redirect_to_login('/', self.get_login_url(view_func)) + login = redirect_to_login(reverse('home'), self.get_login_url(view_func)) return HttpResponse(status=401, headers={'HX-Redirect': login.url}) return super().handle_no_permission(request, view_func) def process_response(self, request, response): # Company records and account pages must not survive in shared caches. - if request.path != '/health/': + if request.path_info != '/health/': add_never_cache_headers(response) return response diff --git a/vergabe_teilnahme/apps/accounts/test_tenant_path.py b/vergabe_teilnahme/apps/accounts/test_tenant_path.py new file mode 100644 index 0000000..ab1524b --- /dev/null +++ b/vergabe_teilnahme/apps/accounts/test_tenant_path.py @@ -0,0 +1,120 @@ +"""Regression for an isolated instance mounted below a fixed tenant path.""" +from html.parser import HTMLParser +from urllib.parse import parse_qs, urlsplit + +import pytest +from django.test import Client +from django.urls import get_script_prefix, set_script_prefix + +from vergabe_teilnahme.apps.ausschreibungen.models import Ausschreibung + +pytestmark = pytest.mark.django_db +PREFIX = '/demo-company' + + +@pytest.fixture +def tenant_path(settings): + previous = get_script_prefix() + settings.FORCE_SCRIPT_NAME = PREFIX + settings.APP_BASE_PATH = PREFIX + settings.STATIC_URL = PREFIX + '/static/' + settings.MEDIA_URL = PREFIX + '/media/' + settings.SESSION_COOKIE_PATH = settings.CSRF_COOKIE_PATH = PREFIX + '/' + settings.SESSION_COOKIE_NAME = 'vergabe_demo_company_sessionid' + settings.CSRF_COOKIE_NAME = 'vergabe_demo_company_csrftoken' + set_script_prefix(PREFIX) + yield settings + set_script_prefix(previous) + + +class LocalLinks(HTMLParser): + def __init__(self): + super().__init__() + self.links = [] + + def handle_starttag(self, tag, attrs): + for name, value in attrs: + if name in {'href', 'src', 'action', 'hx-get', 'hx-post', 'hx-delete'} and value: + if value.startswith('/'): + self.links.append(value) + + +def assert_prefixed(content): + parser = LocalLinks() + parser.feed(content.decode()) + assert parser.links + assert all(link.startswith(PREFIX + '/') for link in parser.links), parser.links + + +def test_anonymous_and_expired_fragments_stay_in_tenant_path(client, tenant_path): + response = client.get('/ausschreibungen/', HTTP_X_FORWARDED_PREFIX='/other-company') + assert response.status_code == 302 + assert urlsplit(response.url).path == PREFIX + '/accounts/login/' + assert parse_qs(urlsplit(response.url).query)['next'] == [PREFIX + '/ausschreibungen/'] + response = client.get('/suche/?q=private', HTTP_HX_REQUEST='true') + assert response.status_code == 401 + assert urlsplit(response['HX-Redirect']).path == PREFIX + '/accounts/login/' + assert parse_qs(urlsplit(response['HX-Redirect']).query)['next'] == [PREFIX + '/'] + assert client.get('/health/').json() == {'status': 'ok'} + + +def test_login_cookie_csrf_logout_and_external_redirect(client, mitarbeiter, tenant_path): + client = Client(enforce_csrf_checks=True) + login = client.get('/accounts/login/') + assert_prefixed(login.content) + csrf = tenant_path.CSRF_COOKIE_NAME + assert client.cookies[csrf]['path'] == PREFIX + '/' + credentials = {'username': mitarbeiter.username, 'password': 'testpass'} + assert client.post('/accounts/login/', credentials).status_code == 403 + credentials['csrfmiddlewaretoken'] = client.cookies[csrf].value + credentials['next'] = 'https://untrusted.example/private' + response = client.post('/accounts/login/', credentials) + assert response.status_code == 302 + assert response.url == PREFIX + '/' + assert client.cookies[tenant_path.SESSION_COOKIE_NAME]['path'] == PREFIX + '/' + assert client.get('/accounts/logout/').status_code == 405 + assert client.post('/accounts/logout/').status_code == 403 + response = client.post('/accounts/logout/', {'csrfmiddlewaretoken': client.cookies[csrf].value}) + assert response.url == PREFIX + '/accounts/login/' + assert client.cookies[tenant_path.SESSION_COOKIE_NAME]['path'] == PREFIX + '/' + assert client.get('/ausschreibungen/').status_code == 302 + + +def test_member_navigation_fragments_and_password_change(client, mitarbeiter, tenant_path): + tender = Ausschreibung.objects.create(titel='Path test', ausschreiber='Demo') + client.force_login(mitarbeiter) + for path in [ + '/ausschreibungen/', f'/ausschreibungen/{tender.pk}/', + '/aufgaben/', '/partner/subunternehmer/', '/bibliothek/nachweise/', + '/accounts/password-change/', + ]: + response = client.get(path) + assert response.status_code == 200, path + assert_prefixed(response.content) + fragment = client.get('/suche/?q=Path', HTTP_HX_REQUEST='true') + assert fragment.status_code == 200 + assert_prefixed(fragment.content) + response = client.post('/accounts/password-change/', { + 'old_password': 'testpass', + 'new_password1': 'Path-pilot-password-984!', + 'new_password2': 'Path-pilot-password-984!', + }) + assert response.status_code == 302 + assert response.url.startswith(PREFIX + '/') + assert client.get('/ausschreibungen/').status_code == 200 + + +def test_private_media_remains_authenticated_under_prefix( + client, mitarbeiter, tenant_path, tmp_path, +): + tenant_path.MEDIA_ROOT = tmp_path + (tmp_path / 'document.pdf').write_bytes(b'%PDF-private-demo') + anonymous = client.get('/media/document.pdf') + assert anonymous.status_code == 302 + assert urlsplit(anonymous.url).path == PREFIX + '/accounts/login/' + client.force_login(mitarbeiter) + response = client.get('/media/document.pdf') + assert response.status_code == 200 + assert b''.join(response.streaming_content) == b'%PDF-private-demo' + assert response['Content-Disposition'] == 'attachment; filename="document.pdf"' + assert 'no-store' in response['Cache-Control'] diff --git a/vergabe_teilnahme/apps/aufgaben/views.py b/vergabe_teilnahme/apps/aufgaben/views.py index 50804e9..70de1f6 100644 --- a/vergabe_teilnahme/apps/aufgaben/views.py +++ b/vergabe_teilnahme/apps/aufgaben/views.py @@ -1,12 +1,12 @@ from datetime import date, timedelta +from django.http import HttpResponse from django.shortcuts import get_object_or_404, redirect, render +from django.urls import reverse from django.utils import timezone from vergabe_teilnahme.apps.ausschreibungen.models import Ausschreibung -from django.http import HttpResponse - from .issue_backends import gitea_configured as _gitea_configured from .models import Aufgabe, AufgabenVerknuepfung, Bieterfrage, ExternalIssue @@ -65,8 +65,8 @@ def aufgaben_liste(request, ausschreibung_id=None): if ausschreibung: breadcrumbs = [ - {'label': 'Ausschreibungen', 'url': '/ausschreibungen/'}, - {'label': ausschreibung.titel, 'url': f'/ausschreibungen/{ausschreibung_id}/'}, + {'label': 'Ausschreibungen', 'url': reverse('ausschreibungen:liste')}, + {'label': ausschreibung.titel, 'url': reverse('ausschreibungen:detail', kwargs={'pk': ausschreibung_id})}, {'label': 'Aufgaben', 'url': None}, ] else: @@ -119,9 +119,9 @@ def aufgabe_neu(request, ausschreibung_id): 'ausschreibung': ausschreibung, 'titel': 'Aufgabe anlegen', 'breadcrumbs': [ - {'label': 'Ausschreibungen', 'url': '/ausschreibungen/'}, - {'label': ausschreibung.titel, 'url': f'/ausschreibungen/{ausschreibung_id}/'}, - {'label': 'Aufgaben', 'url': f'/ausschreibungen/{ausschreibung_id}/aufgaben/'}, + {'label': 'Ausschreibungen', 'url': reverse('ausschreibungen:liste')}, + {'label': ausschreibung.titel, 'url': reverse('ausschreibungen:detail', kwargs={'pk': ausschreibung_id})}, + {'label': 'Aufgaben', 'url': reverse('ausschreibungen:aufgaben:liste', kwargs={'ausschreibung_id': ausschreibung_id})}, {'label': 'Neu', 'url': None}, ], }) @@ -142,9 +142,9 @@ def aufgabe_bearbeiten(request, ausschreibung_id, pk): 'aufgabe': aufgabe, 'titel': 'Aufgabe bearbeiten', 'breadcrumbs': [ - {'label': 'Ausschreibungen', 'url': '/ausschreibungen/'}, - {'label': ausschreibung.titel, 'url': f'/ausschreibungen/{ausschreibung_id}/'}, - {'label': 'Aufgaben', 'url': f'/ausschreibungen/{ausschreibung_id}/aufgaben/'}, + {'label': 'Ausschreibungen', 'url': reverse('ausschreibungen:liste')}, + {'label': ausschreibung.titel, 'url': reverse('ausschreibungen:detail', kwargs={'pk': ausschreibung_id})}, + {'label': 'Aufgaben', 'url': reverse('ausschreibungen:aufgaben:liste', kwargs={'ausschreibung_id': ausschreibung_id})}, {'label': aufgabe.titel[:50], 'url': None}, ], }) @@ -161,9 +161,9 @@ def aufgabe_loeschen(request, ausschreibung_id, pk): 'aufgabe': aufgabe, 'ausschreibung': ausschreibung, 'breadcrumbs': [ - {'label': 'Ausschreibungen', 'url': '/ausschreibungen/'}, - {'label': ausschreibung.titel, 'url': f'/ausschreibungen/{ausschreibung_id}/'}, - {'label': 'Aufgaben', 'url': f'/ausschreibungen/{ausschreibung_id}/aufgaben/'}, + {'label': 'Ausschreibungen', 'url': reverse('ausschreibungen:liste')}, + {'label': ausschreibung.titel, 'url': reverse('ausschreibungen:detail', kwargs={'pk': ausschreibung_id})}, + {'label': 'Aufgaben', 'url': reverse('ausschreibungen:aufgaben:liste', kwargs={'ausschreibung_id': ausschreibung_id})}, {'label': 'Löschen', 'url': None}, ], }) @@ -176,9 +176,9 @@ def aufgabe_detail(request, ausschreibung_id, pk): 'aufgabe': aufgabe, 'ausschreibung': ausschreibung, 'breadcrumbs': [ - {'label': 'Ausschreibungen', 'url': '/ausschreibungen/'}, - {'label': ausschreibung.titel, 'url': f'/ausschreibungen/{ausschreibung_id}/'}, - {'label': 'Aufgaben', 'url': f'/ausschreibungen/{ausschreibung_id}/aufgaben/'}, + {'label': 'Ausschreibungen', 'url': reverse('ausschreibungen:liste')}, + {'label': ausschreibung.titel, 'url': reverse('ausschreibungen:detail', kwargs={'pk': ausschreibung_id})}, + {'label': 'Aufgaben', 'url': reverse('ausschreibungen:aufgaben:liste', kwargs={'ausschreibung_id': ausschreibung_id})}, {'label': aufgabe.titel[:50], 'url': None}, ], }) @@ -237,17 +237,18 @@ def bieterfragen_liste(request, ausschreibung_id): 'mitarbeiter': Mitarbeiter.objects.all(), 'current_status': status_filter or '', 'breadcrumbs': [ - {'label': 'Ausschreibungen', 'url': '/ausschreibungen/'}, - {'label': ausschreibung.titel, 'url': f'/ausschreibungen/{ausschreibung_id}/'}, + {'label': 'Ausschreibungen', 'url': reverse('ausschreibungen:liste')}, + {'label': ausschreibung.titel, 'url': reverse('ausschreibungen:detail', kwargs={'pk': ausschreibung_id})}, {'label': 'Bieterfragen', 'url': None}, ], }) def bieterfrage_neu(request, ausschreibung_id): - from .forms import BieterfragenForm from vergabe_teilnahme.apps.lose.models import Anforderung + from .forms import BieterfragenForm + ausschreibung = get_object_or_404(Ausschreibung, pk=ausschreibung_id) initial = {} @@ -275,9 +276,9 @@ def bieterfrage_neu(request, ausschreibung_id): 'ausschreibung': ausschreibung, 'titel': 'Bieterfrage anlegen', 'breadcrumbs': [ - {'label': 'Ausschreibungen', 'url': '/ausschreibungen/'}, - {'label': ausschreibung.titel, 'url': f'/ausschreibungen/{ausschreibung_id}/'}, - {'label': 'Bieterfragen', 'url': f'/ausschreibungen/{ausschreibung_id}/bieterfragen/'}, + {'label': 'Ausschreibungen', 'url': reverse('ausschreibungen:liste')}, + {'label': ausschreibung.titel, 'url': reverse('ausschreibungen:detail', kwargs={'pk': ausschreibung_id})}, + {'label': 'Bieterfragen', 'url': reverse('ausschreibungen:bieterfragen:liste', kwargs={'ausschreibung_id': ausschreibung_id})}, {'label': 'Neu', 'url': None}, ], }) @@ -290,9 +291,9 @@ def bieterfrage_detail(request, ausschreibung_id, pk): 'bieterfrage': bieterfrage, 'ausschreibung': ausschreibung, 'breadcrumbs': [ - {'label': 'Ausschreibungen', 'url': '/ausschreibungen/'}, - {'label': ausschreibung.titel, 'url': f'/ausschreibungen/{ausschreibung_id}/'}, - {'label': 'Bieterfragen', 'url': f'/ausschreibungen/{ausschreibung_id}/bieterfragen/'}, + {'label': 'Ausschreibungen', 'url': reverse('ausschreibungen:liste')}, + {'label': ausschreibung.titel, 'url': reverse('ausschreibungen:detail', kwargs={'pk': ausschreibung_id})}, + {'label': 'Bieterfragen', 'url': reverse('ausschreibungen:bieterfragen:liste', kwargs={'ausschreibung_id': ausschreibung_id})}, {'label': str(bieterfrage)[:50], 'url': None}, ], }) diff --git a/vergabe_teilnahme/apps/ausschreibungen/views.py b/vergabe_teilnahme/apps/ausschreibungen/views.py index a4c0d7c..f6e74e8 100644 --- a/vergabe_teilnahme/apps/ausschreibungen/views.py +++ b/vergabe_teilnahme/apps/ausschreibungen/views.py @@ -1,6 +1,7 @@ from datetime import date, timedelta from django.shortcuts import get_object_or_404, redirect, render +from django.urls import reverse from django.utils import timezone from vergabe_teilnahme.apps.accounts.models import Mitarbeiter @@ -101,7 +102,7 @@ def ausschreibung_neu(request): 'historisch': historisch, 'titel': 'Neue Ausschreibung', 'breadcrumbs': [ - {'label': 'Ausschreibungen', 'url': '/ausschreibungen/'}, + {'label': 'Ausschreibungen', 'url': reverse('ausschreibungen:liste')}, {'label': 'Neu', 'url': None}, ], }) @@ -111,7 +112,11 @@ def ausschreibung_detail(request, pk): from django.db.models import Count, Q from vergabe_teilnahme.apps.aufgaben.models import Aufgabe - from vergabe_teilnahme.apps.core.services import aufgaben_score, build_phase_nav, get_deadline_warnings + from vergabe_teilnahme.apps.core.services import ( + aufgaben_score, + build_phase_nav, + get_deadline_warnings, + ) from vergabe_teilnahme.apps.lose.models import Los a = get_object_or_404(Ausschreibung, pk=pk) @@ -136,7 +141,7 @@ def ausschreibung_detail(request, pk): 'warnungen': get_deadline_warnings(a), 'aufgaben_score': aufgaben_score(Aufgabe.objects.filter(ausschreibung=a)), 'breadcrumbs': [ - {'label': 'Ausschreibungen', 'url': '/ausschreibungen/'}, + {'label': 'Ausschreibungen', 'url': reverse('ausschreibungen:liste')}, {'label': a.titel, 'url': None}, ], } @@ -156,8 +161,8 @@ def ausschreibung_bearbeiten(request, pk): 'titel': 'Ausschreibung bearbeiten', 'ausschreibung': a, 'breadcrumbs': [ - {'label': 'Ausschreibungen', 'url': '/ausschreibungen/'}, - {'label': a.titel, 'url': f'/ausschreibungen/{pk}/'}, + {'label': 'Ausschreibungen', 'url': reverse('ausschreibungen:liste')}, + {'label': a.titel, 'url': reverse('ausschreibungen:detail', kwargs={'pk': pk})}, {'label': 'Bearbeiten', 'url': None}, ], }) @@ -197,8 +202,8 @@ def ausschreibung_entscheidung(request, pk): 'regelergebnis': entscheidungsregel_auswertung(a), 'ausschlusskriterien_nicht_erfuellbar': ausschlusskriterien, 'breadcrumbs': [ - {'label': 'Ausschreibungen', 'url': '/ausschreibungen/'}, - {'label': a.titel, 'url': f'/ausschreibungen/{pk}/'}, + {'label': 'Ausschreibungen', 'url': reverse('ausschreibungen:liste')}, + {'label': a.titel, 'url': reverse('ausschreibungen:detail', kwargs={'pk': pk})}, {'label': 'Teilnahmeentscheidung', 'url': None}, ], } @@ -227,8 +232,8 @@ def freigaben_uebersicht(request, pk): 'ct_id': ct.pk, 'freigabe_typ_choices': Freigabe.TYP_CHOICES, 'breadcrumbs': [ - {'label': 'Ausschreibungen', 'url': '/ausschreibungen/'}, - {'label': a.titel, 'url': f'/ausschreibungen/{pk}/'}, + {'label': 'Ausschreibungen', 'url': reverse('ausschreibungen:liste')}, + {'label': a.titel, 'url': reverse('ausschreibungen:detail', kwargs={'pk': pk})}, {'label': 'Freigaben', 'url': None}, ], } @@ -245,8 +250,8 @@ def ausschreibung_archivieren(request, pk): return render(request, 'ausschreibungen/archivieren_confirm.html', { 'ausschreibung': a, 'breadcrumbs': [ - {'label': 'Ausschreibungen', 'url': '/ausschreibungen/'}, - {'label': a.titel, 'url': f'/ausschreibungen/{pk}/'}, + {'label': 'Ausschreibungen', 'url': reverse('ausschreibungen:liste')}, + {'label': a.titel, 'url': reverse('ausschreibungen:detail', kwargs={'pk': pk})}, {'label': 'Archivieren', 'url': None}, ], }) diff --git a/vergabe_teilnahme/apps/bibliothek/views.py b/vergabe_teilnahme/apps/bibliothek/views.py index dc96e77..2db4666 100644 --- a/vergabe_teilnahme/apps/bibliothek/views.py +++ b/vergabe_teilnahme/apps/bibliothek/views.py @@ -3,12 +3,12 @@ from datetime import date, timedelta from django import forms from django.contrib import messages from django.shortcuts import get_object_or_404, redirect, render +from django.urls import reverse from vergabe_teilnahme.apps.accounts.models import Mitarbeiter from .models import Entscheidungsregel, Leistungsblatt, Nachweis, Referenz - # ── Forms ───────────────────────────────────────────────────────────────────── class NachweisForm(forms.ModelForm): @@ -173,7 +173,7 @@ def nachweis_neu(request): return render(request, 'bibliothek/nachweis_form.html', { 'form': form, 'breadcrumbs': [ - {'label': 'Nachweise', 'url': '/bibliothek/nachweise/'}, + {'label': 'Nachweise', 'url': reverse('bibliothek:nachweise_liste')}, {'label': 'Neu', 'url': None}, ], }) @@ -184,7 +184,7 @@ def nachweis_detail(request, pk): return render(request, 'bibliothek/nachweis_detail.html', { 'nachweis': nachweis, 'breadcrumbs': [ - {'label': 'Nachweise', 'url': '/bibliothek/nachweise/'}, + {'label': 'Nachweise', 'url': reverse('bibliothek:nachweise_liste')}, {'label': nachweis.titel, 'url': None}, ], }) @@ -205,8 +205,8 @@ def nachweis_bearbeiten(request, pk): 'form': form, 'nachweis': nachweis, 'breadcrumbs': [ - {'label': 'Nachweise', 'url': '/bibliothek/nachweise/'}, - {'label': nachweis.titel, 'url': f'/bibliothek/nachweise/{pk}/'}, + {'label': 'Nachweise', 'url': reverse('bibliothek:nachweise_liste')}, + {'label': nachweis.titel, 'url': reverse('bibliothek:nachweis_detail', kwargs={'pk': pk})}, {'label': 'Bearbeiten', 'url': None}, ], }) @@ -237,8 +237,8 @@ def nachweis_neue_version(request, pk): 'nachweis': alter_nachweis, 'neue_version': True, 'breadcrumbs': [ - {'label': 'Nachweise', 'url': '/bibliothek/nachweise/'}, - {'label': alter_nachweis.titel, 'url': f'/bibliothek/nachweise/{pk}/'}, + {'label': 'Nachweise', 'url': reverse('bibliothek:nachweise_liste')}, + {'label': alter_nachweis.titel, 'url': reverse('bibliothek:nachweis_detail', kwargs={'pk': pk})}, {'label': 'Neue Version', 'url': None}, ], }) @@ -279,7 +279,7 @@ def referenz_neu(request): return render(request, 'bibliothek/referenz_form.html', { 'form': form, 'breadcrumbs': [ - {'label': 'Referenzen', 'url': '/bibliothek/referenzen/'}, + {'label': 'Referenzen', 'url': reverse('bibliothek:referenz_liste')}, {'label': 'Neu', 'url': None}, ], }) @@ -290,7 +290,7 @@ def referenz_detail(request, pk): return render(request, 'bibliothek/referenz_detail.html', { 'ref': ref, 'breadcrumbs': [ - {'label': 'Referenzen', 'url': '/bibliothek/referenzen/'}, + {'label': 'Referenzen', 'url': reverse('bibliothek:referenz_liste')}, {'label': ref.referenztitel, 'url': None}, ], }) @@ -311,8 +311,8 @@ def referenz_bearbeiten(request, pk): 'form': form, 'ref': ref, 'breadcrumbs': [ - {'label': 'Referenzen', 'url': '/bibliothek/referenzen/'}, - {'label': ref.referenztitel, 'url': f'/bibliothek/referenzen/{pk}/'}, + {'label': 'Referenzen', 'url': reverse('bibliothek:referenz_liste')}, + {'label': ref.referenztitel, 'url': reverse('bibliothek:referenz_detail', kwargs={'pk': pk})}, {'label': 'Bearbeiten', 'url': None}, ], }) @@ -365,7 +365,7 @@ def leistungsblatt_neu(request): return render(request, 'bibliothek/leistungsblatt_form.html', { 'form': form, 'breadcrumbs': [ - {'label': 'Leistungsblätter', 'url': '/bibliothek/leistungsblaetter/'}, + {'label': 'Leistungsblätter', 'url': reverse('bibliothek:leistungsblaetter_liste')}, {'label': 'Neu', 'url': None}, ], }) @@ -386,7 +386,7 @@ def leistungsblatt_bearbeiten(request, pk): 'form': form, 'obj': obj, 'breadcrumbs': [ - {'label': 'Leistungsblätter', 'url': '/bibliothek/leistungsblaetter/'}, + {'label': 'Leistungsblätter', 'url': reverse('bibliothek:leistungsblaetter_liste')}, {'label': 'Bearbeiten', 'url': None}, ], }) @@ -415,7 +415,7 @@ def entscheidungsregel_neu(request): return render(request, 'bibliothek/entscheidungsregel_form.html', { 'form': form, 'breadcrumbs': [ - {'label': 'Entscheidungsregeln', 'url': '/bibliothek/entscheidungsregeln/'}, + {'label': 'Entscheidungsregeln', 'url': reverse('bibliothek:entscheidungsregeln_liste')}, {'label': 'Neu', 'url': None}, ], }) @@ -436,7 +436,7 @@ def entscheidungsregel_bearbeiten(request, pk): 'form': form, 'obj': obj, 'breadcrumbs': [ - {'label': 'Entscheidungsregeln', 'url': '/bibliothek/entscheidungsregeln/'}, + {'label': 'Entscheidungsregeln', 'url': reverse('bibliothek:entscheidungsregeln_liste')}, {'label': 'Bearbeiten', 'url': None}, ], }) diff --git a/vergabe_teilnahme/apps/core/services.py b/vergabe_teilnahme/apps/core/services.py index a874e80..481c8ee 100644 --- a/vergabe_teilnahme/apps/core/services.py +++ b/vergabe_teilnahme/apps/core/services.py @@ -1,6 +1,8 @@ from datetime import date from decimal import Decimal +from django.urls import reverse + PHASEN = [ (1, 'Recherche & Unterlagen'), (2, 'Teilnahmeentscheidung'), @@ -35,7 +37,7 @@ def aufgaben_score(qs): def build_phase_nav(ausschreibung, current_url=''): from vergabe_teilnahme.apps.aufgaben.models import Aufgabe aktuelle_phase = STATUS_TO_PHASE.get(ausschreibung.status, 1) - base = f'/ausschreibungen/{ausschreibung.pk}' + base = reverse('ausschreibungen:detail', kwargs={'pk': ausschreibung.pk}).rstrip('/') phase_urls = { 1: f'{base}/', 2: f'{base}/entscheidung/', diff --git a/vergabe_teilnahme/apps/lose/views.py b/vergabe_teilnahme/apps/lose/views.py index 64cb308..dfb0296 100644 --- a/vergabe_teilnahme/apps/lose/views.py +++ b/vergabe_teilnahme/apps/lose/views.py @@ -1,4 +1,5 @@ from django.shortcuts import get_object_or_404, redirect, render +from django.urls import reverse from vergabe_teilnahme.apps.ausschreibungen.models import Ausschreibung from vergabe_teilnahme.apps.bibliothek.models import Nachweis @@ -13,8 +14,8 @@ def _is_htmx(request): def _ausschreibung_breadcrumbs(ausschreibung, *extra): crumbs = [ - {'label': 'Ausschreibungen', 'url': '/ausschreibungen/'}, - {'label': ausschreibung.titel, 'url': f'/ausschreibungen/{ausschreibung.pk}/'}, + {'label': 'Ausschreibungen', 'url': reverse('ausschreibungen:liste')}, + {'label': ausschreibung.titel, 'url': reverse('ausschreibungen:detail', kwargs={'pk': ausschreibung.pk})}, ] for label, url in extra: crumbs.append({'label': label, 'url': url}) @@ -65,7 +66,7 @@ def los_neu(request, ausschreibung_id): 'ausschreibung': ausschreibung, 'titel': 'Los hinzufügen', 'breadcrumbs': _ausschreibung_breadcrumbs(ausschreibung, - ('Lose', f'/ausschreibungen/{ausschreibung_id}/lose/'), + ('Lose', reverse('ausschreibungen:lose:liste', kwargs={'ausschreibung_id': ausschreibung_id})), ('Neu', None)), }) @@ -79,7 +80,7 @@ def los_detail(request, ausschreibung_id, los_pk): 'los': los, 'anforderungen': anforderungen, 'breadcrumbs': _ausschreibung_breadcrumbs(ausschreibung, - ('Lose', f'/ausschreibungen/{ausschreibung_id}/lose/'), + ('Lose', reverse('ausschreibungen:lose:liste', kwargs={'ausschreibung_id': ausschreibung_id})), (str(los), None)), }) @@ -97,8 +98,8 @@ def los_bearbeiten(request, ausschreibung_id, los_pk): 'los': los, 'titel': 'Los bearbeiten', 'breadcrumbs': _ausschreibung_breadcrumbs(ausschreibung, - ('Lose', f'/ausschreibungen/{ausschreibung_id}/lose/'), - (str(los), f'/ausschreibungen/{ausschreibung_id}/lose/{los_pk}/'), + ('Lose', reverse('ausschreibungen:lose:liste', kwargs={'ausschreibung_id': ausschreibung_id})), + (str(los), reverse('ausschreibungen:lose:detail', kwargs={'ausschreibung_id': ausschreibung_id, 'los_pk': los_pk})), ('Bearbeiten', None)), }) @@ -113,8 +114,8 @@ def los_loeschen(request, ausschreibung_id, los_pk): 'ausschreibung': ausschreibung, 'los': los, 'breadcrumbs': _ausschreibung_breadcrumbs(ausschreibung, - ('Lose', f'/ausschreibungen/{ausschreibung_id}/lose/'), - (str(los), f'/ausschreibungen/{ausschreibung_id}/lose/{los_pk}/'), + ('Lose', reverse('ausschreibungen:lose:liste', kwargs={'ausschreibung_id': ausschreibung_id})), + (str(los), reverse('ausschreibungen:lose:detail', kwargs={'ausschreibung_id': ausschreibung_id, 'los_pk': los_pk})), ('Löschen', None)), }) @@ -187,7 +188,7 @@ def anforderung_neu(request, ausschreibung_id): 'ausschreibung': ausschreibung, 'titel': 'Anforderung anlegen', 'breadcrumbs': _ausschreibung_breadcrumbs(ausschreibung, - ('Anforderungen', f'/ausschreibungen/{ausschreibung_id}/lose/anforderungen/'), + ('Anforderungen', reverse('ausschreibungen:lose:anforderungen_liste', kwargs={'ausschreibung_id': ausschreibung_id})), ('Neu', None)), }) @@ -201,7 +202,7 @@ def anforderung_detail(request, ausschreibung_id, pk): 'anforderung': anforderung, 'aufgaben': aufgaben, 'breadcrumbs': _ausschreibung_breadcrumbs(ausschreibung, - ('Anforderungen', f'/ausschreibungen/{ausschreibung_id}/lose/anforderungen/'), + ('Anforderungen', reverse('ausschreibungen:lose:anforderungen_liste', kwargs={'ausschreibung_id': ausschreibung_id})), (anforderung.titel[:50], None)), }) @@ -220,8 +221,8 @@ def anforderung_bearbeiten(request, ausschreibung_id, pk): 'anforderung': anforderung, 'titel': 'Anforderung bearbeiten', 'breadcrumbs': _ausschreibung_breadcrumbs(ausschreibung, - ('Anforderungen', f'/ausschreibungen/{ausschreibung_id}/lose/anforderungen/'), - (anforderung.titel[:50], f'/ausschreibungen/{ausschreibung_id}/lose/anforderungen/{pk}/'), + ('Anforderungen', reverse('ausschreibungen:lose:anforderungen_liste', kwargs={'ausschreibung_id': ausschreibung_id})), + (anforderung.titel[:50], reverse('ausschreibungen:lose:anforderung_detail', kwargs={'ausschreibung_id': ausschreibung_id, 'pk': pk})), ('Bearbeiten', None)), }) diff --git a/vergabe_teilnahme/apps/partner/views.py b/vergabe_teilnahme/apps/partner/views.py index fd65395..0dfefd1 100644 --- a/vergabe_teilnahme/apps/partner/views.py +++ b/vergabe_teilnahme/apps/partner/views.py @@ -1,6 +1,7 @@ from django import forms from django.contrib import messages from django.shortcuts import get_object_or_404, redirect, render +from django.urls import reverse from vergabe_teilnahme.apps.ausschreibungen.models import Ausschreibung from vergabe_teilnahme.apps.lose.models import Los @@ -97,7 +98,7 @@ def subunternehmer_neu(request): return render(request, 'partner/subunternehmer_form.html', { 'form': form, 'breadcrumbs': [ - {'label': 'Subunternehmer', 'url': '/partner/subunternehmer/'}, + {'label': 'Subunternehmer', 'url': reverse('partner:su_liste')}, {'label': 'Neu', 'url': None}, ], }) @@ -113,7 +114,7 @@ def subunternehmer_detail(request, pk): 'sub': sub, 'zuordnungen': zuordnungen, 'breadcrumbs': [ - {'label': 'Subunternehmer', 'url': '/partner/subunternehmer/'}, + {'label': 'Subunternehmer', 'url': reverse('partner:su_liste')}, {'label': sub.name, 'url': None}, ], }) @@ -134,8 +135,8 @@ def subunternehmer_bearbeiten(request, pk): 'form': form, 'sub': sub, 'breadcrumbs': [ - {'label': 'Subunternehmer', 'url': '/partner/subunternehmer/'}, - {'label': sub.name, 'url': f'/partner/subunternehmer/{pk}/'}, + {'label': 'Subunternehmer', 'url': reverse('partner:su_liste')}, + {'label': sub.name, 'url': reverse('partner:su_detail', kwargs={'pk': pk})}, {'label': 'Bearbeiten', 'url': None}, ], }) @@ -209,7 +210,7 @@ def dienstleistertyp_neu(request): return render(request, 'partner/dienstleistertyp_form.html', { 'form': form, 'breadcrumbs': [ - {'label': 'Dienstleistertypen', 'url': '/partner/dienstleistertypen/'}, + {'label': 'Dienstleistertypen', 'url': reverse('partner:dt_liste')}, {'label': 'Neu', 'url': None}, ], }) @@ -230,7 +231,7 @@ def dienstleistertyp_bearbeiten(request, pk): 'form': form, 'obj': obj, 'breadcrumbs': [ - {'label': 'Dienstleistertypen', 'url': '/partner/dienstleistertypen/'}, + {'label': 'Dienstleistertypen', 'url': reverse('partner:dt_liste')}, {'label': 'Bearbeiten', 'url': None}, ], }) diff --git a/vergabe_teilnahme/settings/base.py b/vergabe_teilnahme/settings/base.py index 29105aa..dc588b5 100644 --- a/vergabe_teilnahme/settings/base.py +++ b/vergabe_teilnahme/settings/base.py @@ -3,6 +3,8 @@ from pathlib import Path import dj_database_url from decouple import config +from .paths import application_path + BASE_DIR = Path(__file__).resolve().parent.parent.parent SECRET_KEY = config('SECRET_KEY', default='django-insecure-change-me') @@ -69,9 +71,20 @@ DATABASES = { AUTH_USER_MODEL = 'accounts.Mitarbeiter' LOGIN_URL = 'accounts:login' -LOGIN_REDIRECT_URL = '/' +LOGIN_REDIRECT_URL = 'home' LOGOUT_REDIRECT_URL = 'accounts:login' +# The edge strips this exact prefix before forwarding to this isolated instance. +# Django uses SCRIPT_NAME for URL generation; tenant selection stays with the +# admitted deployment/database binding, not arbitrary forwarded headers. +APP_BASE_PATH = application_path(config('APP_BASE_PATH', default='')) +FORCE_SCRIPT_NAME = APP_BASE_PATH or None +SESSION_COOKIE_PATH = CSRF_COOKIE_PATH = APP_BASE_PATH + '/' +if APP_BASE_PATH: + _cookie_prefix = 'vergabe_' + APP_BASE_PATH[1:].replace('-', '_') + SESSION_COOKIE_NAME = _cookie_prefix + '_sessionid' + CSRF_COOKIE_NAME = _cookie_prefix + '_csrftoken' + DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' AUTH_PASSWORD_VALIDATORS = [ @@ -86,11 +99,11 @@ TIME_ZONE = 'Europe/Berlin' USE_I18N = True USE_TZ = True -STATIC_URL = '/static/' +STATIC_URL = APP_BASE_PATH + '/static/' STATIC_ROOT = BASE_DIR / 'staticfiles' STATICFILES_DIRS = [BASE_DIR / 'static'] -MEDIA_URL = '/media/' +MEDIA_URL = APP_BASE_PATH + '/media/' MEDIA_ROOT = BASE_DIR / 'media' MAX_UPLOAD_SIZE = config('MAX_UPLOAD_SIZE', default=52428800, cast=int) diff --git a/vergabe_teilnahme/settings/paths.py b/vergabe_teilnahme/settings/paths.py new file mode 100644 index 0000000..c05ce4b --- /dev/null +++ b/vergabe_teilnahme/settings/paths.py @@ -0,0 +1,12 @@ +import re + +from django.core.exceptions import ImproperlyConfigured + + +def application_path(value): + """A deployment-owned tenant path, never a value taken from request headers.""" + if value in ('', '/'): + return '' + if not re.fullmatch(r'/[a-z0-9]+(?:-[a-z0-9]+)*', value): + raise ImproperlyConfigured('APP_BASE_PATH must be empty or /lowercase-tenant-slug') + return value diff --git a/vergabe_teilnahme/templates/ausschreibungen/dashboard.html b/vergabe_teilnahme/templates/ausschreibungen/dashboard.html index 44034be..aaa2edc 100644 --- a/vergabe_teilnahme/templates/ausschreibungen/dashboard.html +++ b/vergabe_teilnahme/templates/ausschreibungen/dashboard.html @@ -18,7 +18,7 @@