feat: serve isolated companies below a fixed product path
All checks were successful
All checks were successful
Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a07ff8-19d0-7820-b4d0-1353833cb7fc
This commit is contained in:
parent
09e2239bc6
commit
9345a1bb1a
28 changed files with 350 additions and 117 deletions
|
|
@ -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 |
|
||||||
|
|
|
||||||
16
docs/evidence/2026-09-11-tenant-path-browser.json
Normal file
16
docs/evidence/2026-09-11-tenant-path-browser.json
Normal 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
|
||||||
|
}
|
||||||
31
docs/tenant-path-deployment.md
Normal file
31
docs/tenant-path-deployment.md
Normal 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.
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
120
vergabe_teilnahme/apps/accounts/test_tenant_path.py
Normal file
120
vergabe_teilnahme/apps/accounts/test_tenant_path.py
Normal 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']
|
||||||
|
|
@ -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},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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/',
|
||||||
|
|
|
||||||
|
|
@ -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)),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
12
vergabe_teilnahme/settings/paths.py
Normal file
12
vergabe_teilnahme/settings/paths.py
Normal 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
|
||||||
|
|
@ -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 %}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 %}
|
||||||
|
|
|
||||||
|
|
@ -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 %}
|
||||||
|
|
|
||||||
|
|
@ -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 %}
|
||||||
|
|
|
||||||
|
|
@ -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 %}
|
||||||
|
|
|
||||||
|
|
@ -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 %}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 %}
|
||||||
|
|
|
||||||
|
|
@ -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 }}">
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue