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-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-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-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-T03 | done | — | workplans/VT-WP-0001-projektgeruest.md |
| task | VT-WP-0001-T05 | 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 | | 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.middleware import LoginRequiredMiddleware
from django.contrib.auth.views import redirect_to_login from django.contrib.auth.views import redirect_to_login
from django.http import HttpResponse from django.http import HttpResponse
from django.urls import reverse
from django.utils.cache import add_never_cache_headers from django.utils.cache import add_never_cache_headers
@ -10,12 +11,12 @@ class PilotLoginRequiredMiddleware(LoginRequiredMiddleware):
def handle_no_permission(self, request, view_func): def handle_no_permission(self, request, view_func):
if request.headers.get('HX-Request') == 'true': if request.headers.get('HX-Request') == 'true':
# The original URL may render only a fragment or accept only POST. # 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 HttpResponse(status=401, headers={'HX-Redirect': login.url})
return super().handle_no_permission(request, view_func) return super().handle_no_permission(request, view_func)
def process_response(self, request, response): def process_response(self, request, response):
# Company records and account pages must not survive in shared caches. # 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) add_never_cache_headers(response)
return 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 datetime import date, timedelta
from django.http import HttpResponse
from django.shortcuts import get_object_or_404, redirect, render from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from vergabe_teilnahme.apps.ausschreibungen.models import Ausschreibung from vergabe_teilnahme.apps.ausschreibungen.models import Ausschreibung
from django.http import HttpResponse
from .issue_backends import gitea_configured as _gitea_configured from .issue_backends import gitea_configured as _gitea_configured
from .models import Aufgabe, AufgabenVerknuepfung, Bieterfrage, ExternalIssue from .models import Aufgabe, AufgabenVerknuepfung, Bieterfrage, ExternalIssue
@ -65,8 +65,8 @@ def aufgaben_liste(request, ausschreibung_id=None):
if ausschreibung: if ausschreibung:
breadcrumbs = [ breadcrumbs = [
{'label': 'Ausschreibungen', 'url': '/ausschreibungen/'}, {'label': 'Ausschreibungen', 'url': reverse('ausschreibungen:liste')},
{'label': ausschreibung.titel, 'url': f'/ausschreibungen/{ausschreibung_id}/'}, {'label': ausschreibung.titel, 'url': reverse('ausschreibungen:detail', kwargs={'pk': ausschreibung_id})},
{'label': 'Aufgaben', 'url': None}, {'label': 'Aufgaben', 'url': None},
] ]
else: else:
@ -119,9 +119,9 @@ def aufgabe_neu(request, ausschreibung_id):
'ausschreibung': ausschreibung, 'ausschreibung': ausschreibung,
'titel': 'Aufgabe anlegen', 'titel': 'Aufgabe anlegen',
'breadcrumbs': [ 'breadcrumbs': [
{'label': 'Ausschreibungen', 'url': '/ausschreibungen/'}, {'label': 'Ausschreibungen', 'url': reverse('ausschreibungen:liste')},
{'label': ausschreibung.titel, 'url': f'/ausschreibungen/{ausschreibung_id}/'}, {'label': ausschreibung.titel, 'url': reverse('ausschreibungen:detail', kwargs={'pk': ausschreibung_id})},
{'label': 'Aufgaben', 'url': f'/ausschreibungen/{ausschreibung_id}/aufgaben/'}, {'label': 'Aufgaben', 'url': reverse('ausschreibungen:aufgaben:liste', kwargs={'ausschreibung_id': ausschreibung_id})},
{'label': 'Neu', 'url': None}, {'label': 'Neu', 'url': None},
], ],
}) })
@ -142,9 +142,9 @@ def aufgabe_bearbeiten(request, ausschreibung_id, pk):
'aufgabe': aufgabe, 'aufgabe': aufgabe,
'titel': 'Aufgabe bearbeiten', 'titel': 'Aufgabe bearbeiten',
'breadcrumbs': [ 'breadcrumbs': [
{'label': 'Ausschreibungen', 'url': '/ausschreibungen/'}, {'label': 'Ausschreibungen', 'url': reverse('ausschreibungen:liste')},
{'label': ausschreibung.titel, 'url': f'/ausschreibungen/{ausschreibung_id}/'}, {'label': ausschreibung.titel, 'url': reverse('ausschreibungen:detail', kwargs={'pk': ausschreibung_id})},
{'label': 'Aufgaben', 'url': f'/ausschreibungen/{ausschreibung_id}/aufgaben/'}, {'label': 'Aufgaben', 'url': reverse('ausschreibungen:aufgaben:liste', kwargs={'ausschreibung_id': ausschreibung_id})},
{'label': aufgabe.titel[:50], 'url': None}, {'label': aufgabe.titel[:50], 'url': None},
], ],
}) })
@ -161,9 +161,9 @@ def aufgabe_loeschen(request, ausschreibung_id, pk):
'aufgabe': aufgabe, 'aufgabe': aufgabe,
'ausschreibung': ausschreibung, 'ausschreibung': ausschreibung,
'breadcrumbs': [ 'breadcrumbs': [
{'label': 'Ausschreibungen', 'url': '/ausschreibungen/'}, {'label': 'Ausschreibungen', 'url': reverse('ausschreibungen:liste')},
{'label': ausschreibung.titel, 'url': f'/ausschreibungen/{ausschreibung_id}/'}, {'label': ausschreibung.titel, 'url': reverse('ausschreibungen:detail', kwargs={'pk': ausschreibung_id})},
{'label': 'Aufgaben', 'url': f'/ausschreibungen/{ausschreibung_id}/aufgaben/'}, {'label': 'Aufgaben', 'url': reverse('ausschreibungen:aufgaben:liste', kwargs={'ausschreibung_id': ausschreibung_id})},
{'label': 'Löschen', 'url': None}, {'label': 'Löschen', 'url': None},
], ],
}) })
@ -176,9 +176,9 @@ def aufgabe_detail(request, ausschreibung_id, pk):
'aufgabe': aufgabe, 'aufgabe': aufgabe,
'ausschreibung': ausschreibung, 'ausschreibung': ausschreibung,
'breadcrumbs': [ 'breadcrumbs': [
{'label': 'Ausschreibungen', 'url': '/ausschreibungen/'}, {'label': 'Ausschreibungen', 'url': reverse('ausschreibungen:liste')},
{'label': ausschreibung.titel, 'url': f'/ausschreibungen/{ausschreibung_id}/'}, {'label': ausschreibung.titel, 'url': reverse('ausschreibungen:detail', kwargs={'pk': ausschreibung_id})},
{'label': 'Aufgaben', 'url': f'/ausschreibungen/{ausschreibung_id}/aufgaben/'}, {'label': 'Aufgaben', 'url': reverse('ausschreibungen:aufgaben:liste', kwargs={'ausschreibung_id': ausschreibung_id})},
{'label': aufgabe.titel[:50], 'url': None}, {'label': aufgabe.titel[:50], 'url': None},
], ],
}) })
@ -237,17 +237,18 @@ def bieterfragen_liste(request, ausschreibung_id):
'mitarbeiter': Mitarbeiter.objects.all(), 'mitarbeiter': Mitarbeiter.objects.all(),
'current_status': status_filter or '', 'current_status': status_filter or '',
'breadcrumbs': [ 'breadcrumbs': [
{'label': 'Ausschreibungen', 'url': '/ausschreibungen/'}, {'label': 'Ausschreibungen', 'url': reverse('ausschreibungen:liste')},
{'label': ausschreibung.titel, 'url': f'/ausschreibungen/{ausschreibung_id}/'}, {'label': ausschreibung.titel, 'url': reverse('ausschreibungen:detail', kwargs={'pk': ausschreibung_id})},
{'label': 'Bieterfragen', 'url': None}, {'label': 'Bieterfragen', 'url': None},
], ],
}) })
def bieterfrage_neu(request, ausschreibung_id): def bieterfrage_neu(request, ausschreibung_id):
from .forms import BieterfragenForm
from vergabe_teilnahme.apps.lose.models import Anforderung from vergabe_teilnahme.apps.lose.models import Anforderung
from .forms import BieterfragenForm
ausschreibung = get_object_or_404(Ausschreibung, pk=ausschreibung_id) ausschreibung = get_object_or_404(Ausschreibung, pk=ausschreibung_id)
initial = {} initial = {}
@ -275,9 +276,9 @@ def bieterfrage_neu(request, ausschreibung_id):
'ausschreibung': ausschreibung, 'ausschreibung': ausschreibung,
'titel': 'Bieterfrage anlegen', 'titel': 'Bieterfrage anlegen',
'breadcrumbs': [ 'breadcrumbs': [
{'label': 'Ausschreibungen', 'url': '/ausschreibungen/'}, {'label': 'Ausschreibungen', 'url': reverse('ausschreibungen:liste')},
{'label': ausschreibung.titel, 'url': f'/ausschreibungen/{ausschreibung_id}/'}, {'label': ausschreibung.titel, 'url': reverse('ausschreibungen:detail', kwargs={'pk': ausschreibung_id})},
{'label': 'Bieterfragen', 'url': f'/ausschreibungen/{ausschreibung_id}/bieterfragen/'}, {'label': 'Bieterfragen', 'url': reverse('ausschreibungen:bieterfragen:liste', kwargs={'ausschreibung_id': ausschreibung_id})},
{'label': 'Neu', 'url': None}, {'label': 'Neu', 'url': None},
], ],
}) })
@ -290,9 +291,9 @@ def bieterfrage_detail(request, ausschreibung_id, pk):
'bieterfrage': bieterfrage, 'bieterfrage': bieterfrage,
'ausschreibung': ausschreibung, 'ausschreibung': ausschreibung,
'breadcrumbs': [ 'breadcrumbs': [
{'label': 'Ausschreibungen', 'url': '/ausschreibungen/'}, {'label': 'Ausschreibungen', 'url': reverse('ausschreibungen:liste')},
{'label': ausschreibung.titel, 'url': f'/ausschreibungen/{ausschreibung_id}/'}, {'label': ausschreibung.titel, 'url': reverse('ausschreibungen:detail', kwargs={'pk': ausschreibung_id})},
{'label': 'Bieterfragen', 'url': f'/ausschreibungen/{ausschreibung_id}/bieterfragen/'}, {'label': 'Bieterfragen', 'url': reverse('ausschreibungen:bieterfragen:liste', kwargs={'ausschreibung_id': ausschreibung_id})},
{'label': str(bieterfrage)[:50], 'url': None}, {'label': str(bieterfrage)[:50], 'url': None},
], ],
}) })

View file

@ -1,6 +1,7 @@
from datetime import date, timedelta from datetime import date, timedelta
from django.shortcuts import get_object_or_404, redirect, render from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from vergabe_teilnahme.apps.accounts.models import Mitarbeiter from vergabe_teilnahme.apps.accounts.models import Mitarbeiter
@ -101,7 +102,7 @@ def ausschreibung_neu(request):
'historisch': historisch, 'historisch': historisch,
'titel': 'Neue Ausschreibung', 'titel': 'Neue Ausschreibung',
'breadcrumbs': [ 'breadcrumbs': [
{'label': 'Ausschreibungen', 'url': '/ausschreibungen/'}, {'label': 'Ausschreibungen', 'url': reverse('ausschreibungen:liste')},
{'label': 'Neu', 'url': None}, {'label': 'Neu', 'url': None},
], ],
}) })
@ -111,7 +112,11 @@ def ausschreibung_detail(request, pk):
from django.db.models import Count, Q from django.db.models import Count, Q
from vergabe_teilnahme.apps.aufgaben.models import Aufgabe 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 from vergabe_teilnahme.apps.lose.models import Los
a = get_object_or_404(Ausschreibung, pk=pk) a = get_object_or_404(Ausschreibung, pk=pk)
@ -136,7 +141,7 @@ def ausschreibung_detail(request, pk):
'warnungen': get_deadline_warnings(a), 'warnungen': get_deadline_warnings(a),
'aufgaben_score': aufgaben_score(Aufgabe.objects.filter(ausschreibung=a)), 'aufgaben_score': aufgaben_score(Aufgabe.objects.filter(ausschreibung=a)),
'breadcrumbs': [ 'breadcrumbs': [
{'label': 'Ausschreibungen', 'url': '/ausschreibungen/'}, {'label': 'Ausschreibungen', 'url': reverse('ausschreibungen:liste')},
{'label': a.titel, 'url': None}, {'label': a.titel, 'url': None},
], ],
} }
@ -156,8 +161,8 @@ def ausschreibung_bearbeiten(request, pk):
'titel': 'Ausschreibung bearbeiten', 'titel': 'Ausschreibung bearbeiten',
'ausschreibung': a, 'ausschreibung': a,
'breadcrumbs': [ 'breadcrumbs': [
{'label': 'Ausschreibungen', 'url': '/ausschreibungen/'}, {'label': 'Ausschreibungen', 'url': reverse('ausschreibungen:liste')},
{'label': a.titel, 'url': f'/ausschreibungen/{pk}/'}, {'label': a.titel, 'url': reverse('ausschreibungen:detail', kwargs={'pk': pk})},
{'label': 'Bearbeiten', 'url': None}, {'label': 'Bearbeiten', 'url': None},
], ],
}) })
@ -197,8 +202,8 @@ def ausschreibung_entscheidung(request, pk):
'regelergebnis': entscheidungsregel_auswertung(a), 'regelergebnis': entscheidungsregel_auswertung(a),
'ausschlusskriterien_nicht_erfuellbar': ausschlusskriterien, 'ausschlusskriterien_nicht_erfuellbar': ausschlusskriterien,
'breadcrumbs': [ 'breadcrumbs': [
{'label': 'Ausschreibungen', 'url': '/ausschreibungen/'}, {'label': 'Ausschreibungen', 'url': reverse('ausschreibungen:liste')},
{'label': a.titel, 'url': f'/ausschreibungen/{pk}/'}, {'label': a.titel, 'url': reverse('ausschreibungen:detail', kwargs={'pk': pk})},
{'label': 'Teilnahmeentscheidung', 'url': None}, {'label': 'Teilnahmeentscheidung', 'url': None},
], ],
} }
@ -227,8 +232,8 @@ def freigaben_uebersicht(request, pk):
'ct_id': ct.pk, 'ct_id': ct.pk,
'freigabe_typ_choices': Freigabe.TYP_CHOICES, 'freigabe_typ_choices': Freigabe.TYP_CHOICES,
'breadcrumbs': [ 'breadcrumbs': [
{'label': 'Ausschreibungen', 'url': '/ausschreibungen/'}, {'label': 'Ausschreibungen', 'url': reverse('ausschreibungen:liste')},
{'label': a.titel, 'url': f'/ausschreibungen/{pk}/'}, {'label': a.titel, 'url': reverse('ausschreibungen:detail', kwargs={'pk': pk})},
{'label': 'Freigaben', 'url': None}, {'label': 'Freigaben', 'url': None},
], ],
} }
@ -245,8 +250,8 @@ def ausschreibung_archivieren(request, pk):
return render(request, 'ausschreibungen/archivieren_confirm.html', { return render(request, 'ausschreibungen/archivieren_confirm.html', {
'ausschreibung': a, 'ausschreibung': a,
'breadcrumbs': [ 'breadcrumbs': [
{'label': 'Ausschreibungen', 'url': '/ausschreibungen/'}, {'label': 'Ausschreibungen', 'url': reverse('ausschreibungen:liste')},
{'label': a.titel, 'url': f'/ausschreibungen/{pk}/'}, {'label': a.titel, 'url': reverse('ausschreibungen:detail', kwargs={'pk': pk})},
{'label': 'Archivieren', 'url': None}, {'label': 'Archivieren', 'url': None},
], ],
}) })

View file

@ -3,12 +3,12 @@ from datetime import date, timedelta
from django import forms from django import forms
from django.contrib import messages from django.contrib import messages
from django.shortcuts import get_object_or_404, redirect, render from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from vergabe_teilnahme.apps.accounts.models import Mitarbeiter from vergabe_teilnahme.apps.accounts.models import Mitarbeiter
from .models import Entscheidungsregel, Leistungsblatt, Nachweis, Referenz from .models import Entscheidungsregel, Leistungsblatt, Nachweis, Referenz
# ── Forms ───────────────────────────────────────────────────────────────────── # ── Forms ─────────────────────────────────────────────────────────────────────
class NachweisForm(forms.ModelForm): class NachweisForm(forms.ModelForm):
@ -173,7 +173,7 @@ def nachweis_neu(request):
return render(request, 'bibliothek/nachweis_form.html', { return render(request, 'bibliothek/nachweis_form.html', {
'form': form, 'form': form,
'breadcrumbs': [ 'breadcrumbs': [
{'label': 'Nachweise', 'url': '/bibliothek/nachweise/'}, {'label': 'Nachweise', 'url': reverse('bibliothek:nachweise_liste')},
{'label': 'Neu', 'url': None}, {'label': 'Neu', 'url': None},
], ],
}) })
@ -184,7 +184,7 @@ def nachweis_detail(request, pk):
return render(request, 'bibliothek/nachweis_detail.html', { return render(request, 'bibliothek/nachweis_detail.html', {
'nachweis': nachweis, 'nachweis': nachweis,
'breadcrumbs': [ 'breadcrumbs': [
{'label': 'Nachweise', 'url': '/bibliothek/nachweise/'}, {'label': 'Nachweise', 'url': reverse('bibliothek:nachweise_liste')},
{'label': nachweis.titel, 'url': None}, {'label': nachweis.titel, 'url': None},
], ],
}) })
@ -205,8 +205,8 @@ def nachweis_bearbeiten(request, pk):
'form': form, 'form': form,
'nachweis': nachweis, 'nachweis': nachweis,
'breadcrumbs': [ 'breadcrumbs': [
{'label': 'Nachweise', 'url': '/bibliothek/nachweise/'}, {'label': 'Nachweise', 'url': reverse('bibliothek:nachweise_liste')},
{'label': nachweis.titel, 'url': f'/bibliothek/nachweise/{pk}/'}, {'label': nachweis.titel, 'url': reverse('bibliothek:nachweis_detail', kwargs={'pk': pk})},
{'label': 'Bearbeiten', 'url': None}, {'label': 'Bearbeiten', 'url': None},
], ],
}) })
@ -237,8 +237,8 @@ def nachweis_neue_version(request, pk):
'nachweis': alter_nachweis, 'nachweis': alter_nachweis,
'neue_version': True, 'neue_version': True,
'breadcrumbs': [ 'breadcrumbs': [
{'label': 'Nachweise', 'url': '/bibliothek/nachweise/'}, {'label': 'Nachweise', 'url': reverse('bibliothek:nachweise_liste')},
{'label': alter_nachweis.titel, 'url': f'/bibliothek/nachweise/{pk}/'}, {'label': alter_nachweis.titel, 'url': reverse('bibliothek:nachweis_detail', kwargs={'pk': pk})},
{'label': 'Neue Version', 'url': None}, {'label': 'Neue Version', 'url': None},
], ],
}) })
@ -279,7 +279,7 @@ def referenz_neu(request):
return render(request, 'bibliothek/referenz_form.html', { return render(request, 'bibliothek/referenz_form.html', {
'form': form, 'form': form,
'breadcrumbs': [ 'breadcrumbs': [
{'label': 'Referenzen', 'url': '/bibliothek/referenzen/'}, {'label': 'Referenzen', 'url': reverse('bibliothek:referenz_liste')},
{'label': 'Neu', 'url': None}, {'label': 'Neu', 'url': None},
], ],
}) })
@ -290,7 +290,7 @@ def referenz_detail(request, pk):
return render(request, 'bibliothek/referenz_detail.html', { return render(request, 'bibliothek/referenz_detail.html', {
'ref': ref, 'ref': ref,
'breadcrumbs': [ 'breadcrumbs': [
{'label': 'Referenzen', 'url': '/bibliothek/referenzen/'}, {'label': 'Referenzen', 'url': reverse('bibliothek:referenz_liste')},
{'label': ref.referenztitel, 'url': None}, {'label': ref.referenztitel, 'url': None},
], ],
}) })
@ -311,8 +311,8 @@ def referenz_bearbeiten(request, pk):
'form': form, 'form': form,
'ref': ref, 'ref': ref,
'breadcrumbs': [ 'breadcrumbs': [
{'label': 'Referenzen', 'url': '/bibliothek/referenzen/'}, {'label': 'Referenzen', 'url': reverse('bibliothek:referenz_liste')},
{'label': ref.referenztitel, 'url': f'/bibliothek/referenzen/{pk}/'}, {'label': ref.referenztitel, 'url': reverse('bibliothek:referenz_detail', kwargs={'pk': pk})},
{'label': 'Bearbeiten', 'url': None}, {'label': 'Bearbeiten', 'url': None},
], ],
}) })
@ -365,7 +365,7 @@ def leistungsblatt_neu(request):
return render(request, 'bibliothek/leistungsblatt_form.html', { return render(request, 'bibliothek/leistungsblatt_form.html', {
'form': form, 'form': form,
'breadcrumbs': [ 'breadcrumbs': [
{'label': 'Leistungsblätter', 'url': '/bibliothek/leistungsblaetter/'}, {'label': 'Leistungsblätter', 'url': reverse('bibliothek:leistungsblaetter_liste')},
{'label': 'Neu', 'url': None}, {'label': 'Neu', 'url': None},
], ],
}) })
@ -386,7 +386,7 @@ def leistungsblatt_bearbeiten(request, pk):
'form': form, 'form': form,
'obj': obj, 'obj': obj,
'breadcrumbs': [ 'breadcrumbs': [
{'label': 'Leistungsblätter', 'url': '/bibliothek/leistungsblaetter/'}, {'label': 'Leistungsblätter', 'url': reverse('bibliothek:leistungsblaetter_liste')},
{'label': 'Bearbeiten', 'url': None}, {'label': 'Bearbeiten', 'url': None},
], ],
}) })
@ -415,7 +415,7 @@ def entscheidungsregel_neu(request):
return render(request, 'bibliothek/entscheidungsregel_form.html', { return render(request, 'bibliothek/entscheidungsregel_form.html', {
'form': form, 'form': form,
'breadcrumbs': [ 'breadcrumbs': [
{'label': 'Entscheidungsregeln', 'url': '/bibliothek/entscheidungsregeln/'}, {'label': 'Entscheidungsregeln', 'url': reverse('bibliothek:entscheidungsregeln_liste')},
{'label': 'Neu', 'url': None}, {'label': 'Neu', 'url': None},
], ],
}) })
@ -436,7 +436,7 @@ def entscheidungsregel_bearbeiten(request, pk):
'form': form, 'form': form,
'obj': obj, 'obj': obj,
'breadcrumbs': [ 'breadcrumbs': [
{'label': 'Entscheidungsregeln', 'url': '/bibliothek/entscheidungsregeln/'}, {'label': 'Entscheidungsregeln', 'url': reverse('bibliothek:entscheidungsregeln_liste')},
{'label': 'Bearbeiten', 'url': None}, {'label': 'Bearbeiten', 'url': None},
], ],
}) })

View file

@ -1,6 +1,8 @@
from datetime import date from datetime import date
from decimal import Decimal from decimal import Decimal
from django.urls import reverse
PHASEN = [ PHASEN = [
(1, 'Recherche & Unterlagen'), (1, 'Recherche & Unterlagen'),
(2, 'Teilnahmeentscheidung'), (2, 'Teilnahmeentscheidung'),
@ -35,7 +37,7 @@ def aufgaben_score(qs):
def build_phase_nav(ausschreibung, current_url=''): def build_phase_nav(ausschreibung, current_url=''):
from vergabe_teilnahme.apps.aufgaben.models import Aufgabe from vergabe_teilnahme.apps.aufgaben.models import Aufgabe
aktuelle_phase = STATUS_TO_PHASE.get(ausschreibung.status, 1) 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 = { phase_urls = {
1: f'{base}/', 1: f'{base}/',
2: f'{base}/entscheidung/', 2: f'{base}/entscheidung/',

View file

@ -1,4 +1,5 @@
from django.shortcuts import get_object_or_404, redirect, render 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.ausschreibungen.models import Ausschreibung
from vergabe_teilnahme.apps.bibliothek.models import Nachweis from vergabe_teilnahme.apps.bibliothek.models import Nachweis
@ -13,8 +14,8 @@ def _is_htmx(request):
def _ausschreibung_breadcrumbs(ausschreibung, *extra): def _ausschreibung_breadcrumbs(ausschreibung, *extra):
crumbs = [ crumbs = [
{'label': 'Ausschreibungen', 'url': '/ausschreibungen/'}, {'label': 'Ausschreibungen', 'url': reverse('ausschreibungen:liste')},
{'label': ausschreibung.titel, 'url': f'/ausschreibungen/{ausschreibung.pk}/'}, {'label': ausschreibung.titel, 'url': reverse('ausschreibungen:detail', kwargs={'pk': ausschreibung.pk})},
] ]
for label, url in extra: for label, url in extra:
crumbs.append({'label': label, 'url': url}) crumbs.append({'label': label, 'url': url})
@ -65,7 +66,7 @@ def los_neu(request, ausschreibung_id):
'ausschreibung': ausschreibung, 'ausschreibung': ausschreibung,
'titel': 'Los hinzufügen', 'titel': 'Los hinzufügen',
'breadcrumbs': _ausschreibung_breadcrumbs(ausschreibung, 'breadcrumbs': _ausschreibung_breadcrumbs(ausschreibung,
('Lose', f'/ausschreibungen/{ausschreibung_id}/lose/'), ('Lose', reverse('ausschreibungen:lose:liste', kwargs={'ausschreibung_id': ausschreibung_id})),
('Neu', None)), ('Neu', None)),
}) })
@ -79,7 +80,7 @@ def los_detail(request, ausschreibung_id, los_pk):
'los': los, 'los': los,
'anforderungen': anforderungen, 'anforderungen': anforderungen,
'breadcrumbs': _ausschreibung_breadcrumbs(ausschreibung, 'breadcrumbs': _ausschreibung_breadcrumbs(ausschreibung,
('Lose', f'/ausschreibungen/{ausschreibung_id}/lose/'), ('Lose', reverse('ausschreibungen:lose:liste', kwargs={'ausschreibung_id': ausschreibung_id})),
(str(los), None)), (str(los), None)),
}) })
@ -97,8 +98,8 @@ def los_bearbeiten(request, ausschreibung_id, los_pk):
'los': los, 'los': los,
'titel': 'Los bearbeiten', 'titel': 'Los bearbeiten',
'breadcrumbs': _ausschreibung_breadcrumbs(ausschreibung, 'breadcrumbs': _ausschreibung_breadcrumbs(ausschreibung,
('Lose', f'/ausschreibungen/{ausschreibung_id}/lose/'), ('Lose', reverse('ausschreibungen:lose:liste', kwargs={'ausschreibung_id': ausschreibung_id})),
(str(los), f'/ausschreibungen/{ausschreibung_id}/lose/{los_pk}/'), (str(los), reverse('ausschreibungen:lose:detail', kwargs={'ausschreibung_id': ausschreibung_id, 'los_pk': los_pk})),
('Bearbeiten', None)), ('Bearbeiten', None)),
}) })
@ -113,8 +114,8 @@ def los_loeschen(request, ausschreibung_id, los_pk):
'ausschreibung': ausschreibung, 'ausschreibung': ausschreibung,
'los': los, 'los': los,
'breadcrumbs': _ausschreibung_breadcrumbs(ausschreibung, 'breadcrumbs': _ausschreibung_breadcrumbs(ausschreibung,
('Lose', f'/ausschreibungen/{ausschreibung_id}/lose/'), ('Lose', reverse('ausschreibungen:lose:liste', kwargs={'ausschreibung_id': ausschreibung_id})),
(str(los), f'/ausschreibungen/{ausschreibung_id}/lose/{los_pk}/'), (str(los), reverse('ausschreibungen:lose:detail', kwargs={'ausschreibung_id': ausschreibung_id, 'los_pk': los_pk})),
('Löschen', None)), ('Löschen', None)),
}) })
@ -187,7 +188,7 @@ def anforderung_neu(request, ausschreibung_id):
'ausschreibung': ausschreibung, 'ausschreibung': ausschreibung,
'titel': 'Anforderung anlegen', 'titel': 'Anforderung anlegen',
'breadcrumbs': _ausschreibung_breadcrumbs(ausschreibung, 'breadcrumbs': _ausschreibung_breadcrumbs(ausschreibung,
('Anforderungen', f'/ausschreibungen/{ausschreibung_id}/lose/anforderungen/'), ('Anforderungen', reverse('ausschreibungen:lose:anforderungen_liste', kwargs={'ausschreibung_id': ausschreibung_id})),
('Neu', None)), ('Neu', None)),
}) })
@ -201,7 +202,7 @@ def anforderung_detail(request, ausschreibung_id, pk):
'anforderung': anforderung, 'anforderung': anforderung,
'aufgaben': aufgaben, 'aufgaben': aufgaben,
'breadcrumbs': _ausschreibung_breadcrumbs(ausschreibung, '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)), (anforderung.titel[:50], None)),
}) })
@ -220,8 +221,8 @@ def anforderung_bearbeiten(request, ausschreibung_id, pk):
'anforderung': anforderung, 'anforderung': anforderung,
'titel': 'Anforderung bearbeiten', 'titel': 'Anforderung bearbeiten',
'breadcrumbs': _ausschreibung_breadcrumbs(ausschreibung, '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], f'/ausschreibungen/{ausschreibung_id}/lose/anforderungen/{pk}/'), (anforderung.titel[:50], reverse('ausschreibungen:lose:anforderung_detail', kwargs={'ausschreibung_id': ausschreibung_id, 'pk': pk})),
('Bearbeiten', None)), ('Bearbeiten', None)),
}) })

View file

@ -1,6 +1,7 @@
from django import forms from django import forms
from django.contrib import messages from django.contrib import messages
from django.shortcuts import get_object_or_404, redirect, render 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.ausschreibungen.models import Ausschreibung
from vergabe_teilnahme.apps.lose.models import Los from vergabe_teilnahme.apps.lose.models import Los
@ -97,7 +98,7 @@ def subunternehmer_neu(request):
return render(request, 'partner/subunternehmer_form.html', { return render(request, 'partner/subunternehmer_form.html', {
'form': form, 'form': form,
'breadcrumbs': [ 'breadcrumbs': [
{'label': 'Subunternehmer', 'url': '/partner/subunternehmer/'}, {'label': 'Subunternehmer', 'url': reverse('partner:su_liste')},
{'label': 'Neu', 'url': None}, {'label': 'Neu', 'url': None},
], ],
}) })
@ -113,7 +114,7 @@ def subunternehmer_detail(request, pk):
'sub': sub, 'sub': sub,
'zuordnungen': zuordnungen, 'zuordnungen': zuordnungen,
'breadcrumbs': [ 'breadcrumbs': [
{'label': 'Subunternehmer', 'url': '/partner/subunternehmer/'}, {'label': 'Subunternehmer', 'url': reverse('partner:su_liste')},
{'label': sub.name, 'url': None}, {'label': sub.name, 'url': None},
], ],
}) })
@ -134,8 +135,8 @@ def subunternehmer_bearbeiten(request, pk):
'form': form, 'form': form,
'sub': sub, 'sub': sub,
'breadcrumbs': [ 'breadcrumbs': [
{'label': 'Subunternehmer', 'url': '/partner/subunternehmer/'}, {'label': 'Subunternehmer', 'url': reverse('partner:su_liste')},
{'label': sub.name, 'url': f'/partner/subunternehmer/{pk}/'}, {'label': sub.name, 'url': reverse('partner:su_detail', kwargs={'pk': pk})},
{'label': 'Bearbeiten', 'url': None}, {'label': 'Bearbeiten', 'url': None},
], ],
}) })
@ -209,7 +210,7 @@ def dienstleistertyp_neu(request):
return render(request, 'partner/dienstleistertyp_form.html', { return render(request, 'partner/dienstleistertyp_form.html', {
'form': form, 'form': form,
'breadcrumbs': [ 'breadcrumbs': [
{'label': 'Dienstleistertypen', 'url': '/partner/dienstleistertypen/'}, {'label': 'Dienstleistertypen', 'url': reverse('partner:dt_liste')},
{'label': 'Neu', 'url': None}, {'label': 'Neu', 'url': None},
], ],
}) })
@ -230,7 +231,7 @@ def dienstleistertyp_bearbeiten(request, pk):
'form': form, 'form': form,
'obj': obj, 'obj': obj,
'breadcrumbs': [ 'breadcrumbs': [
{'label': 'Dienstleistertypen', 'url': '/partner/dienstleistertypen/'}, {'label': 'Dienstleistertypen', 'url': reverse('partner:dt_liste')},
{'label': 'Bearbeiten', 'url': None}, {'label': 'Bearbeiten', 'url': None},
], ],
}) })

View file

@ -3,6 +3,8 @@ from pathlib import Path
import dj_database_url import dj_database_url
from decouple import config from decouple import config
from .paths import application_path
BASE_DIR = Path(__file__).resolve().parent.parent.parent BASE_DIR = Path(__file__).resolve().parent.parent.parent
SECRET_KEY = config('SECRET_KEY', default='django-insecure-change-me') SECRET_KEY = config('SECRET_KEY', default='django-insecure-change-me')
@ -69,9 +71,20 @@ DATABASES = {
AUTH_USER_MODEL = 'accounts.Mitarbeiter' AUTH_USER_MODEL = 'accounts.Mitarbeiter'
LOGIN_URL = 'accounts:login' LOGIN_URL = 'accounts:login'
LOGIN_REDIRECT_URL = '/' LOGIN_REDIRECT_URL = 'home'
LOGOUT_REDIRECT_URL = 'accounts:login' 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' DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
AUTH_PASSWORD_VALIDATORS = [ AUTH_PASSWORD_VALIDATORS = [
@ -86,11 +99,11 @@ TIME_ZONE = 'Europe/Berlin'
USE_I18N = True USE_I18N = True
USE_TZ = True USE_TZ = True
STATIC_URL = '/static/' STATIC_URL = APP_BASE_PATH + '/static/'
STATIC_ROOT = BASE_DIR / 'staticfiles' STATIC_ROOT = BASE_DIR / 'staticfiles'
STATICFILES_DIRS = [BASE_DIR / 'static'] STATICFILES_DIRS = [BASE_DIR / 'static']
MEDIA_URL = '/media/' MEDIA_URL = APP_BASE_PATH + '/media/'
MEDIA_ROOT = BASE_DIR / 'media' MEDIA_ROOT = BASE_DIR / 'media'
MAX_UPLOAD_SIZE = config('MAX_UPLOAD_SIZE', default=52428800, cast=int) 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"> <ul class="space-y-1">
{% for a in kritische_fristen %} {% for a in kritische_fristen %}
<li class="flex items-center justify-between py-1 text-sm"> <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 %}"> <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" }} {{ a.abgabe_bis|date:"d.m.Y H:i" }}
</span> </span>
@ -42,7 +42,7 @@
<ul class="space-y-1"> <ul class="space-y-1">
{% for a in ohne_entscheidung %} {% for a in ohne_entscheidung %}
<li class="py-1 text-sm"> <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> <span class="text-slate-400 text-xs ml-1">seit {{ a.erstellt_am|date:"d.m.Y" }}</span>
</li> </li>
{% endfor %} {% endfor %}
@ -64,7 +64,7 @@
<ul class="space-y-1"> <ul class="space-y-1">
{% for aufgabe in ueberfaellige_aufgaben %} {% for aufgabe in ueberfaellige_aufgaben %}
<li class="flex items-center justify-between py-1 text-sm"> <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 }} {{ aufgabe.titel }}
</a> </a>
<span class="text-red-600 text-xs ml-2 shrink-0">{{ aufgabe.frist|date:"d.m.Y" }}</span> <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"> <ul class="space-y-1">
{% for a in laufende_ausschreibungen %} {% for a in laufende_ausschreibungen %}
<li class="flex items-center justify-between py-1 text-sm"> <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 %} {% status_badge a.get_status_display a.status %}
</li> </li>
{% endfor %} {% endfor %}

View file

@ -4,7 +4,7 @@
{% block content %} {% block content %}
<div class="flex items-center justify-between mb-5"> <div class="flex items-center justify-between mb-5">
<h1 class="page-title">Freigaben: {{ ausschreibung.titel }}</h1> <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" hx-target="#modal-container"
class="btn-primary">Freigabe erteilen</button> class="btn-primary">Freigabe erteilen</button>
</div> </div>

View file

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

View file

@ -7,7 +7,7 @@
</div> </div>
<div x-show="formOpen" x-cloak class="mb-4 bg-slate-50 border border-slate-200 rounded-lg p-3"> <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-target="closest div[x-data]"
hx-swap="outerHTML" hx-swap="outerHTML"
class="space-y-2"> class="space-y-2">
@ -39,19 +39,19 @@
<span class="text-slate-700 flex-1">{{ attr.value|default:"—" }}</span> <span class="text-slate-700 flex-1">{{ attr.value|default:"—" }}</span>
<span class="text-xs text-slate-400 bg-slate-100 rounded px-1.5">{{ attr.data_type }}</span> <span class="text-xs text-slate-400 bg-slate-100 rounded px-1.5">{{ attr.data_type }}</span>
<div class="flex gap-1"> <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"> hx-target="closest div[x-data]" hx-swap="outerHTML" class="inline">
{% csrf_token %} {% csrf_token %}
<input type="hidden" name="direction" value="up"> <input type="hidden" name="direction" value="up">
<button type="submit" class="text-slate-300 hover:text-slate-600 text-xs">↑</button> <button type="submit" class="text-slate-300 hover:text-slate-600 text-xs">↑</button>
</form> </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"> hx-target="closest div[x-data]" hx-swap="outerHTML" class="inline">
{% csrf_token %} {% csrf_token %}
<input type="hidden" name="direction" value="down"> <input type="hidden" name="direction" value="down">
<button type="submit" class="text-slate-300 hover:text-slate-600 text-xs">↓</button> <button type="submit" class="text-slate-300 hover:text-slate-600 text-xs">↓</button>
</form> </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" hx-target="closest div[x-data]" hx-swap="outerHTML" class="inline"
onsubmit="return confirm('Löschen?')"> onsubmit="return confirm('Löschen?')">
{% csrf_token %} {% 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"> <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 font-mono text-xs text-slate-700">{{ field_name }}</td>
<td class="py-2"> <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-target="#feld-{{ entity_type }}-{{ field_name }}"
hx-swap="outerHTML" hx-swap="outerHTML"
class="flex items-center gap-2"> class="flex items-center gap-2">
@ -14,7 +14,7 @@
</form> </form>
</td> </td>
<td class="py-2 text-center"> <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-target="#feld-{{ entity_type }}-{{ field_name }}"
hx-swap="outerHTML"> hx-swap="outerHTML">
{% csrf_token %} {% csrf_token %}

View file

@ -5,6 +5,6 @@
<p class="text-6xl font-bold text-slate-200 mb-4">404</p> <p class="text-6xl font-bold text-slate-200 mb-4">404</p>
<h1 class="page-title mb-2">Seite nicht gefunden</h1> <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> <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> </div>
{% endblock %} {% endblock %}

View file

@ -5,6 +5,6 @@
<p class="text-6xl font-bold text-slate-200 mb-4">500</p> <p class="text-6xl font-bold text-slate-200 mb-4">500</p>
<h1 class="page-title mb-2">Interner Serverfehler</h1> <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> <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> </div>
{% endblock %} {% endblock %}

View file

@ -33,7 +33,7 @@
</span> </span>
</td> </td>
<td class="py-2 pr-3" @click.stop> <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-target="#eintrag-wrapper-{{ eintrag.pk }}"
hx-swap="outerHTML"> hx-swap="outerHTML">
{% csrf_token %} {% csrf_token %}
@ -51,7 +51,7 @@
{# edit row #} {# edit row #}
<tr x-show="editing" x-cloak class="bg-slate-50 border-b border-slate-200"> <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"> <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-target="#eintrag-wrapper-{{ eintrag.pk }}"
hx-swap="outerHTML"> hx-swap="outerHTML">
{% csrf_token %} {% csrf_token %}

View file

@ -1,4 +1,4 @@
<button hx-get="/feedback/modal/" <button hx-get="{% url 'feedback:modal' %}"
hx-target="#modal-container" hx-target="#modal-container"
hx-swap="innerHTML" hx-swap="innerHTML"
class="fixed bottom-6 right-6 bg-white border border-slate-300 shadow-lg 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" <div class="bg-white rounded-xl shadow-xl p-6 w-full max-w-md mx-4"
@click.outside="open = false"> @click.outside="open = false">
<h2 class="page-title text-xl mb-4">Feedback</h2> <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 %} {% csrf_token %}
<input type="hidden" name="seite_kontext" value="{{ request.path }}"> <input type="hidden" name="seite_kontext" value="{{ request.path }}">
{% if current_ausschreibung %} {% 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" <div class="bg-white rounded-xl shadow-xl p-6 w-full max-w-md mx-4"
@click.outside="open = false"> @click.outside="open = false">
<h2 class="page-title text-xl mb-4">Freigabe erteilen</h2> <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 %} {% csrf_token %}
<input type="hidden" name="content_type_id" value="{{ content_type_id }}"> <input type="hidden" name="content_type_id" value="{{ content_type_id }}">
<input type="hidden" name="object_id" value="{{ object_id }}"> <input type="hidden" name="object_id" value="{{ object_id }}">

View file

@ -7,7 +7,7 @@
{% if ausschreibungen %} {% if ausschreibungen %}
<div class="px-3 py-1 text-xs font-medium text-slate-400 uppercase tracking-wide">Ausschreibungen</div> <div class="px-3 py-1 text-xs font-medium text-slate-400 uppercase tracking-wide">Ausschreibungen</div>
{% for a in ausschreibungen %} {% 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"> 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 class="text-base">📋</span>
<span>{{ a.titel }}</span> <span>{{ a.titel }}</span>
@ -18,7 +18,7 @@
{% if dokumente %} {% if dokumente %}
<div class="px-3 py-1 text-xs font-medium text-slate-400 uppercase tracking-wide mt-1">Dokumente</div> <div class="px-3 py-1 text-xs font-medium text-slate-400 uppercase tracking-wide mt-1">Dokumente</div>
{% for d in dokumente %} {% 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"> 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 class="text-base">📄</span>
<span>{{ d.dateiname }}</span> <span>{{ d.dateiname }}</span>
@ -30,7 +30,7 @@
{% if nachweise %} {% if nachweise %}
<div class="px-3 py-1 text-xs font-medium text-slate-400 uppercase tracking-wide mt-1">Nachweise</div> <div class="px-3 py-1 text-xs font-medium text-slate-400 uppercase tracking-wide mt-1">Nachweise</div>
{% for n in nachweise %} {% 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"> 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 class="text-base">🏆</span>
<span>{{ n.titel }}</span> <span>{{ n.titel }}</span>
@ -41,7 +41,7 @@
{% if referenzen %} {% if referenzen %}
<div class="px-3 py-1 text-xs font-medium text-slate-400 uppercase tracking-wide mt-1">Referenzen</div> <div class="px-3 py-1 text-xs font-medium text-slate-400 uppercase tracking-wide mt-1">Referenzen</div>
{% for r in referenzen %} {% 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"> 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 class="text-base">⭐</span>
<span>{{ r.referenztitel }}</span> <span>{{ r.referenztitel }}</span>
@ -53,7 +53,7 @@
{% if marktbegleiter %} {% if marktbegleiter %}
<div class="px-3 py-1 text-xs font-medium text-slate-400 uppercase tracking-wide mt-1">Marktbegleiter</div> <div class="px-3 py-1 text-xs font-medium text-slate-400 uppercase tracking-wide mt-1">Marktbegleiter</div>
{% for m in marktbegleiter %} {% 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"> 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 class="text-base">🔍</span>
<span>{{ m.name }}</span> <span>{{ m.name }}</span>

View file

@ -2,7 +2,7 @@
x-show="sidebarOpen" x-show="sidebarOpen"
x-cloak> x-cloak>
<nav class="p-3 space-y-1 flex-1"> <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 %}"> 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"> <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"/> <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> <span x-text="open ? '▾' : '▸'" class="text-slate-400"></span>
</button> </button>
<div x-show="open" class="ml-3 space-y-1"> <div x-show="open" class="ml-3 space-y-1">
<a href="/ausschreibungen/" class="sidebar-link">Alle Ausschreibungen</a> <a href="{% url 'ausschreibungen:liste' %}" class="sidebar-link">Alle Ausschreibungen</a>
<a href="/ausschreibungen/neu/" class="sidebar-link text-brand-600 font-medium">+ Neu</a> <a href="{% url 'ausschreibungen:neu' %}" class="sidebar-link text-brand-600 font-medium">+ Neu</a>
</div> </div>
</div> </div>
@ -27,10 +27,10 @@
<span x-text="open ? '▾' : '▸'" class="text-slate-400"></span> <span x-text="open ? '▾' : '▸'" class="text-slate-400"></span>
</button> </button>
<div x-show="open" class="ml-3 space-y-1"> <div x-show="open" class="ml-3 space-y-1">
<a href="/bibliothek/nachweise/" class="sidebar-link">Nachweise</a> <a href="{% url 'bibliothek:nachweise_liste' %}" class="sidebar-link">Nachweise</a>
<a href="/bibliothek/referenzen/" class="sidebar-link">Referenzen</a> <a href="{% url 'bibliothek:referenz_liste' %}" class="sidebar-link">Referenzen</a>
<a href="/bibliothek/leistungsblaetter/" class="sidebar-link">Leistungsblätter</a> <a href="{% url 'bibliothek:leistungsblaetter_liste' %}" class="sidebar-link">Leistungsblätter</a>
<a href="/bibliothek/entscheidungsregeln/" class="sidebar-link">Entscheidungsregeln</a> <a href="{% url 'bibliothek:entscheidungsregeln_liste' %}" class="sidebar-link">Entscheidungsregeln</a>
</div> </div>
</div> </div>
@ -40,17 +40,17 @@
<span x-text="open ? '▾' : '▸'" class="text-slate-400"></span> <span x-text="open ? '▾' : '▸'" class="text-slate-400"></span>
</button> </button>
<div x-show="open" class="ml-3 space-y-1"> <div x-show="open" class="ml-3 space-y-1">
<a href="/partner/subunternehmer/" class="sidebar-link">Subunternehmer</a> <a href="{% url 'partner:su_liste' %}" class="sidebar-link">Subunternehmer</a>
<a href="/partner/dienstleistertypen/" class="sidebar-link">Dienstleistertypen</a> <a href="{% url 'partner:dt_liste' %}" class="sidebar-link">Dienstleistertypen</a>
</div> </div>
</div> </div>
<a href="/aufgaben/" class="sidebar-link">Aufgaben</a> <a href="{% url 'aufgaben_global' %}" class="sidebar-link">Aufgaben</a>
<a href="/marktbegleiter/" class="sidebar-link">Marktbegleiter</a> <a href="{% url 'marktbegleiter:liste' %}" class="sidebar-link">Marktbegleiter</a>
<a href="/feedback/backlog/" class="sidebar-link">Feedback-Backlog</a> <a href="{% url 'feedback:backlog' %}" class="sidebar-link">Feedback-Backlog</a>
{% if request.user.is_staff %} {% 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 %} {% endif %}
</nav> </nav>

View file

@ -7,9 +7,9 @@
</svg> </svg>
</button> </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"> class="relative flex-1 max-w-lg mx-auto">
<input name="q" type="search" placeholder="Ausschreibung, Aufgabe, Dokument suchen..." <input name="q" type="search" placeholder="Ausschreibung, Aufgabe, Dokument suchen..."
autocomplete="off" 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. 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 Use `tenant:trial:demo-company` under NetKingdom ADR-0013 and two ordinary demo
accounts initially. Native tenant creation and memberships use the existing accounts initially. Native tenant creation and memberships use the existing
User Engine operator portal; this does not implement Django SSO. Hostname User Engine operator portal; this does not implement Django SSO. The user selected
preference is pending (`demo-vergabe.coulomb.social` proposed). The concrete `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`. prepared binding lives at `railiance-apps/docs/vergabe-demo-company-binding.md`.
No password or personal user list belongs in public work records. No password or personal user list belongs in public work records.
The historical vergabe_db is not disposable and must not be overwritten. The historical vergabe_db is not disposable and must not be overwritten.
@ -107,7 +108,7 @@ status: wait
priority: high priority: high
assignee: the-custodian assignee: the-custodian
depends_on: [VERGABE-WP-0019-T03] 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" 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, A successful invited pilot does not claim shared multitenancy, paid subscriptions,
HA or autonomous production release. Pricing is outside this milestone and will HA or autonomous production release. Pricing is outside this milestone and will
need a later explicit product decision. 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.