feat: serve isolated companies below a fixed product path
All checks were successful
Application acceptance / application-tests (push) Successful in 1m12s
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Build and Publish Container Image / build-and-push (push) Successful in 25s

Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a07ff8-19d0-7820-b4d0-1353833cb7fc
This commit is contained in:
tegwick 2026-09-11 21:29:26 +02:00
parent 09e2239bc6
commit 9345a1bb1a
28 changed files with 350 additions and 117 deletions

View file

@ -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 |

View file

@ -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
}

View file

@ -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.

View file

@ -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

View file

@ -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']

View file

@ -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},
],
})

View file

@ -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},
],
})

View file

@ -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},
],
})

View file

@ -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/',

View file

@ -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)),
})

View file

@ -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},
],
})

View file

@ -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)

View file

@ -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

View file

@ -18,7 +18,7 @@
<ul class="space-y-1">
{% for a in kritische_fristen %}
<li class="flex items-center justify-between py-1 text-sm">
<a href="/ausschreibungen/{{ a.pk }}/" class="text-brand-700 hover:underline truncate max-w-xs">{{ a.titel }}</a>
<a href="{% url 'ausschreibungen:detail' pk=a.pk %}" class="text-brand-700 hover:underline truncate max-w-xs">{{ a.titel }}</a>
<span class="ml-2 shrink-0 {% if a.abgabe_bis.date <= today %}text-red-600 font-medium{% else %}text-amber-600{% endif %}">
{{ a.abgabe_bis|date:"d.m.Y H:i" }}
</span>
@ -42,7 +42,7 @@
<ul class="space-y-1">
{% for a in ohne_entscheidung %}
<li class="py-1 text-sm">
<a href="/ausschreibungen/{{ a.pk }}/entscheidung/" class="text-brand-700 hover:underline">{{ a.titel }}</a>
<a href="{% url 'ausschreibungen:entscheidung' pk=a.pk %}" class="text-brand-700 hover:underline">{{ a.titel }}</a>
<span class="text-slate-400 text-xs ml-1">seit {{ a.erstellt_am|date:"d.m.Y" }}</span>
</li>
{% endfor %}
@ -64,7 +64,7 @@
<ul class="space-y-1">
{% for aufgabe in ueberfaellige_aufgaben %}
<li class="flex items-center justify-between py-1 text-sm">
<a href="/ausschreibungen/{{ aufgabe.ausschreibung_id }}/aufgaben/" class="text-brand-700 hover:underline truncate max-w-xs">
<a href="{% url 'ausschreibungen:aufgaben:liste' ausschreibung_id=aufgabe.ausschreibung_id %}" class="text-brand-700 hover:underline truncate max-w-xs">
{{ aufgabe.titel }}
</a>
<span class="text-red-600 text-xs ml-2 shrink-0">{{ aufgabe.frist|date:"d.m.Y" }}</span>
@ -88,7 +88,7 @@
<ul class="space-y-1">
{% for a in laufende_ausschreibungen %}
<li class="flex items-center justify-between py-1 text-sm">
<a href="/ausschreibungen/{{ a.pk }}/" class="text-brand-700 hover:underline truncate max-w-xs">{{ a.titel }}</a>
<a href="{% url 'ausschreibungen:detail' pk=a.pk %}" class="text-brand-700 hover:underline truncate max-w-xs">{{ a.titel }}</a>
{% status_badge a.get_status_display a.status %}
</li>
{% endfor %}

View file

@ -4,7 +4,7 @@
{% 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 }}"
<button hx-get="{% url 'freigabe_modal' %}?ct={{ ct_id }}&oid={{ ausschreibung.pk }}"
hx-target="#modal-container"
class="btn-primary">Freigabe erteilen</button>
</div>

View file

@ -7,7 +7,7 @@
<div class="flex gap-2 mb-5 flex-wrap">
{% for et in entity_types %}
<a href="/felder/{{ et }}/"
<a href="{% url 'feld_konfiguration_liste' entity_type=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>

View file

@ -7,7 +7,7 @@
</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/"
<form hx-post="{% url 'custom_attribute_neu' content_type_id=ct_id object_id=oid %}"
hx-target="closest div[x-data]"
hx-swap="outerHTML"
class="space-y-2">
@ -39,19 +39,19 @@
<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/"
<form hx-post="{% url 'custom_attribute_sort' content_type_id=ct_id object_id=oid attr_pk=attr.pk %}"
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/"
<form hx-post="{% url 'custom_attribute_sort' content_type_id=ct_id object_id=oid attr_pk=attr.pk %}"
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/"
<form hx-post="{% url 'custom_attribute_loeschen' content_type_id=ct_id object_id=oid attr_pk=attr.pk %}"
hx-target="closest div[x-data]" hx-swap="outerHTML" class="inline"
onsubmit="return confirm('Löschen?')">
{% csrf_token %}

View file

@ -1,7 +1,7 @@
<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/"
<form hx-post="{% url 'feld_konfiguration_toggle' entity_type=entity_type field_name=field_name %}"
hx-target="#feld-{{ entity_type }}-{{ field_name }}"
hx-swap="outerHTML"
class="flex items-center gap-2">
@ -14,7 +14,7 @@
</form>
</td>
<td class="py-2 text-center">
<form hx-post="/felder/{{ entity_type }}/{{ field_name }}/toggle/"
<form hx-post="{% url 'feld_konfiguration_toggle' entity_type=entity_type field_name=field_name %}"
hx-target="#feld-{{ entity_type }}-{{ field_name }}"
hx-swap="outerHTML">
{% csrf_token %}

View file

@ -5,6 +5,6 @@
<p class="text-6xl font-bold text-slate-200 mb-4">404</p>
<h1 class="page-title mb-2">Seite nicht gefunden</h1>
<p class="text-slate-500 mb-6">Die angeforderte Seite existiert nicht oder wurde verschoben.</p>
<a href="/" class="btn-primary">Zur Übersicht</a>
<a href="{% url 'home' %}" class="btn-primary">Zur Übersicht</a>
</div>
{% endblock %}

View file

@ -5,6 +5,6 @@
<p class="text-6xl font-bold text-slate-200 mb-4">500</p>
<h1 class="page-title mb-2">Interner Serverfehler</h1>
<p class="text-slate-500 mb-6">Ein unerwarteter Fehler ist aufgetreten. Bitte versuche es später erneut.</p>
<a href="/" class="btn-primary">Zur Übersicht</a>
<a href="{% url 'home' %}" class="btn-primary">Zur Übersicht</a>
</div>
{% endblock %}

View file

@ -33,7 +33,7 @@
</span>
</td>
<td class="py-2 pr-3" @click.stop>
<form hx-post="/feedback/backlog/{{ eintrag.pk }}/bearbeiten/"
<form hx-post="{% url 'feedback:eintrag_bearbeiten' pk=eintrag.pk %}"
hx-target="#eintrag-wrapper-{{ eintrag.pk }}"
hx-swap="outerHTML">
{% csrf_token %}
@ -51,7 +51,7 @@
{# edit row #}
<tr x-show="editing" x-cloak class="bg-slate-50 border-b border-slate-200">
<td colspan="6" class="px-6 pt-3 pb-4">
<form hx-post="/feedback/backlog/{{ eintrag.pk }}/bearbeiten/"
<form hx-post="{% url 'feedback:eintrag_bearbeiten' pk=eintrag.pk %}"
hx-target="#eintrag-wrapper-{{ eintrag.pk }}"
hx-swap="outerHTML">
{% csrf_token %}

View file

@ -1,4 +1,4 @@
<button hx-get="/feedback/modal/"
<button hx-get="{% url 'feedback:modal' %}"
hx-target="#modal-container"
hx-swap="innerHTML"
class="fixed bottom-6 right-6 bg-white border border-slate-300 shadow-lg

View file

@ -3,7 +3,7 @@
<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">Feedback</h2>
<form hx-post="/feedback/" hx-target="#modal-container" hx-swap="innerHTML">
<form hx-post="{% url 'feedback:submit' %}" hx-target="#modal-container" hx-swap="innerHTML">
{% csrf_token %}
<input type="hidden" name="seite_kontext" value="{{ request.path }}">
{% if current_ausschreibung %}

View file

@ -3,7 +3,7 @@
<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">
<form hx-post="{% url 'freigabe_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 }}">

View file

@ -7,7 +7,7 @@
{% 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 }}/"
<a href="{% url 'ausschreibungen:detail' pk=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>
@ -18,7 +18,7 @@
{% 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/"
<a href="{% url 'ausschreibungen:dokumente:liste' ausschreibung_id=d.ausschreibung.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>{{ d.dateiname }}</span>
@ -30,7 +30,7 @@
{% 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 }}/"
<a href="{% url 'bibliothek:nachweis_detail' pk=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>
@ -41,7 +41,7 @@
{% 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 }}/"
<a href="{% url 'bibliothek:referenz_detail' pk=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>
@ -53,7 +53,7 @@
{% 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 }}/"
<a href="{% url 'marktbegleiter:detail' pk=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>

View file

@ -2,7 +2,7 @@
x-show="sidebarOpen"
x-cloak>
<nav class="p-3 space-y-1 flex-1">
<a href="/"
<a href="{% url 'home' %}"
class="sidebar-link {% if request.resolver_match.url_name == 'dashboard' %}sidebar-link-active{% endif %}">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"/>
@ -16,8 +16,8 @@
<span x-text="open ? '▾' : '▸'" class="text-slate-400"></span>
</button>
<div x-show="open" class="ml-3 space-y-1">
<a href="/ausschreibungen/" class="sidebar-link">Alle Ausschreibungen</a>
<a href="/ausschreibungen/neu/" class="sidebar-link text-brand-600 font-medium">+ Neu</a>
<a href="{% url 'ausschreibungen:liste' %}" class="sidebar-link">Alle Ausschreibungen</a>
<a href="{% url 'ausschreibungen:neu' %}" class="sidebar-link text-brand-600 font-medium">+ Neu</a>
</div>
</div>
@ -27,10 +27,10 @@
<span x-text="open ? '▾' : '▸'" class="text-slate-400"></span>
</button>
<div x-show="open" class="ml-3 space-y-1">
<a href="/bibliothek/nachweise/" class="sidebar-link">Nachweise</a>
<a href="/bibliothek/referenzen/" class="sidebar-link">Referenzen</a>
<a href="/bibliothek/leistungsblaetter/" class="sidebar-link">Leistungsblätter</a>
<a href="/bibliothek/entscheidungsregeln/" class="sidebar-link">Entscheidungsregeln</a>
<a href="{% url 'bibliothek:nachweise_liste' %}" class="sidebar-link">Nachweise</a>
<a href="{% url 'bibliothek:referenz_liste' %}" class="sidebar-link">Referenzen</a>
<a href="{% url 'bibliothek:leistungsblaetter_liste' %}" class="sidebar-link">Leistungsblätter</a>
<a href="{% url 'bibliothek:entscheidungsregeln_liste' %}" class="sidebar-link">Entscheidungsregeln</a>
</div>
</div>
@ -40,17 +40,17 @@
<span x-text="open ? '▾' : '▸'" class="text-slate-400"></span>
</button>
<div x-show="open" class="ml-3 space-y-1">
<a href="/partner/subunternehmer/" class="sidebar-link">Subunternehmer</a>
<a href="/partner/dienstleistertypen/" class="sidebar-link">Dienstleistertypen</a>
<a href="{% url 'partner:su_liste' %}" class="sidebar-link">Subunternehmer</a>
<a href="{% url 'partner:dt_liste' %}" class="sidebar-link">Dienstleistertypen</a>
</div>
</div>
<a href="/aufgaben/" class="sidebar-link">Aufgaben</a>
<a href="/marktbegleiter/" class="sidebar-link">Marktbegleiter</a>
<a href="/feedback/backlog/" class="sidebar-link">Feedback-Backlog</a>
<a href="{% url 'aufgaben_global' %}" class="sidebar-link">Aufgaben</a>
<a href="{% url 'marktbegleiter:liste' %}" class="sidebar-link">Marktbegleiter</a>
<a href="{% url 'feedback:backlog' %}" class="sidebar-link">Feedback-Backlog</a>
{% if request.user.is_staff %}
<a href="/admin/" class="sidebar-link text-slate-400">Administration</a>
<a href="{% url 'admin:index' %}" class="sidebar-link text-slate-400">Administration</a>
{% endif %}
</nav>

View file

@ -7,9 +7,9 @@
</svg>
</button>
<a href="/" class="text-brand-700 font-semibold text-lg shrink-0">Vergabe Teilnahme</a>
<a href="{% url 'home' %}" class="text-brand-700 font-semibold text-lg shrink-0">Vergabe Teilnahme</a>
<form hx-get="/suche/" hx-target="#search-results" hx-trigger="input changed delay:300ms"
<form hx-get="{% url 'suche' %}" hx-target="#search-results" hx-trigger="input changed delay:300ms"
class="relative flex-1 max-w-lg mx-auto">
<input name="q" type="search" placeholder="Ausschreibung, Aufgabe, Dokument suchen..."
autocomplete="off"

View file

@ -80,8 +80,9 @@ and rollback. The customer app owns live CI and immutable release evidence.
The user now selects a fresh `demo-company` demo tenant with `demo-user1`, etc.
Use `tenant:trial:demo-company` under NetKingdom ADR-0013 and two ordinary demo
accounts initially. Native tenant creation and memberships use the existing
User Engine operator portal; this does not implement Django SSO. Hostname
preference is pending (`demo-vergabe.coulomb.social` proposed). The concrete
User Engine operator portal; this does not implement Django SSO. The user selected
`https://vergabe-teilnahme.coulomb.social/demo-company` (one product host,
company path). The concrete
prepared binding lives at `railiance-apps/docs/vergabe-demo-company-binding.md`.
No password or personal user list belongs in public work records.
The historical vergabe_db is not disposable and must not be overwritten.
@ -107,7 +108,7 @@ status: wait
priority: high
assignee: the-custodian
depends_on: [VERGABE-WP-0019-T03]
blocking_reason: "Await native demo-company tenant, admitted deployment and demonstrated restore; hostname and operator login remain pending."
blocking_reason: "Native demo-company exists and hostname/path is selected. Await identity-provisioner service-login repair, admitted application deployment and demonstrated restore."
state_hub_task_id: "db807f96-f5b8-528d-bc33-75a33fbbf1e4"
```
@ -126,3 +127,31 @@ never seed development accounts or put credentials in logs/workplans/chat.
A successful invited pilot does not claim shared multitenancy, paid subscriptions,
HA or autonomous production release. Pricing is outside this milestone and will
need a later explicit product decision.
## Support the selected product host and tenant path
```task
id: VERGABE-WP-0019-T05
status: done
priority: high
assignee: the-custodian
state_hub_task_id: "d00acb0b-6514-5ab5-9894-7fadb917feb7"
```
Implement fixed APP_BASE_PATH, prefix-aware Django URLs/assets/media, scoped and
uniquely named cookies, and prefixed login/logout/password-change/HTMX redirects.
Replace root URL literals in templates and breadcrumbs with named reversing.
The edge strips only the admitted company prefix; the application remains an
isolated per-company instance. Four prefix regressions and seven local Chromium
checks pass, alongside the existing 94 root-path tests. Vite builds correctly.
See docs/tenant-path-deployment.md. T03 retains CI publication, exact live pin,
DNS/TLS/namespace/database binding and recovery; source acceptance is not deployment.
On 2026-09-11 at 19:03:18 UTC, native operator creation succeeded. Authority
readback confirms tenant:trial:demo-company active, version 1; the chosen first
administrator exists in User Engine with invited status. A subsequent Create
login failed in identity-provisioner at LLDAP service authentication (HTTP 401).
Its existing credential was reloaded without rotation; the connection still
fails. NetKingdom tracks that owner-credential reconciliation before identity
provisioning retries. No passwords or private addresses enter this record.