Require invited login and private downloads for the company pilot

Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a07ff8-19d0-7820-b4d0-1353833cb7fc
This commit is contained in:
tegwick 2026-09-11 16:35:13 +02:00
parent 56bf193193
commit b7d7828f30
27 changed files with 584 additions and 111 deletions

View file

@ -41,7 +41,7 @@ It operates on three levels, per `wiki/ProductRequirementsDocument.md`:
Primary customer service/UI repository for the HelixForge factory, selected by Primary customer service/UI repository for the HelixForge factory, selected by
the user. Initial delivery preserves a single-company boundary; customer release the user. Initial delivery preserves a single-company boundary; customer release
requires explicit deployment, access, persistence, restore and support acceptance requires explicit deployment, access, persistence, restore and support acceptance
in VERGABE-WP-0018. The original internal collaboration workflows remain valid. in VERGABE-WP-0019 (invited pilot) and VERGABE-WP-0018 (factory delivery). The original internal collaboration workflows remain valid.
## In Scope ## In Scope
@ -60,7 +60,7 @@ in VERGABE-WP-0018. The original internal collaboration workflows remain valid.
- External user accounts for partners/subcontractors/service providers (data - External user accounts for partners/subcontractors/service providers (data
objects only in this build stage, no system access of their own) objects only in this build stage, no system access of their own)
- Multi-tenancy - Shared-application multi-tenancy; invited companies use isolated deployments
- Automated ingestion from tender platforms, SharePoint, Teams, CRM, ERP, - Automated ingestion from tender platforms, SharePoint, Teams, CRM, ERP,
email, or calendars (deliberately manual-entry-first for v1) email, or calendars (deliberately manual-entry-first for v1)
- Certification or legal validity of submissions — the system tracks - Certification or legal validity of submissions — the system tracks
@ -95,7 +95,7 @@ in VERGABE-WP-0018. The original internal collaboration workflows remain valid.
feedback, lose, marktbegleiter, nachbetrachtung, partner, preise); 17 feedback, lose, marktbegleiter, nachbetrachtung, partner, preise); 17
workplans (WP-0001WP-0017) implemented in sequence from project workplans (WP-0001WP-0017) implemented in sequence from project
scaffold through whynot-design token adoption scaffold through whynot-design token adoption
- Stability: evolving — manual-entry-first v1; no CI workflow configured yet - Stability: evolving — manual-entry-first v1; Forgejo gates images with application/container acceptance
(`.gitea/`/`.forgejo/` absent) (`.gitea/`/`.forgejo/` absent)
- Usage: internal collaboration tool, not yet published for external use - Usage: internal collaboration tool, not yet published for external use
@ -159,3 +159,12 @@ Product and architecture documentation (`wiki/`) is in German; this SCOPE.md
is in English per the reuse-surface registry's Markdown-first, agent-facing is in English per the reuse-surface registry's Markdown-first, agent-facing
convention. Refer to `wiki/ProductRequirementsDocument.md` for the convention. Refer to `wiki/ProductRequirementsDocument.md` for the
authoritative German-language product definition. authoritative German-language product definition.
## Invited-pilot milestone — 2026-09-11
One company, several manually onboarded users; pricing follows later by user
decision. Django login and protected downloads are source-tested. Native release,
data recovery, user acceptance and support remain in VERGABE-WP-0019 and
RAPPS-WP-0014. Automated source/container acceptance already exists in Forgejo;
older current-state statements about missing CI are historical.

View file

@ -8,3 +8,10 @@ def mitarbeiter(db):
return Mitarbeiter.objects.create_user( return Mitarbeiter.objects.create_user(
username='testuser', password='testpass', first_name='Test', last_name='User' username='testuser', password='testpass', first_name='Test', last_name='User'
) )
@pytest.fixture
def admitted_client(client, mitarbeiter):
"""Domain workflow tests act as a member; the standard client stays anonymous."""
client.force_login(mitarbeiter)
return client

View file

@ -58,3 +58,28 @@ digest and chart revision, tenant/host and login binding, database and media
ownership, backup/restore and rollback, UI smoke and named operating owner. ownership, backup/restore and rollback, UI smoke and named operating owner.
Existing production tender data is never disposable test data. A passing source Existing production tender data is never disposable test data. A passing source
build does not establish customer availability or the governed factory worker. build does not establish customer availability or the governed factory worker.
## Invited pilot — 2026-09-11
[VERGABE-WP-0019](../workplans/VERGABE-WP-0019-invited-company-pilot.md)
advances one manually onboarded company alongside factory admission. Billing
and a shared tenancy implementation are not pilot gates. All application routes
and media downloads require an active Django account; health and login remain
public. Members can change passwords; administrators create/reset/deactivate
accounts using the existing Django administration through the admitted operator
path. There is no public sign-up or email invitation delivery in this milestone.
Use the Railiance Apps pilot contract in `docs/vergabe-teilnahme-pilot.md` and
RAPPS-WP-0014. Record the newly tested image digest; the older published image
has no global access gate. Persist PostgreSQL, uploads and issue-facade SQLite
separately, and prove coherent restore before admitting customer data. Serve
`/media/` through Django, never directly through an unauthenticated ingress or
static file server. The image's `/app/.issue-facade` directory is operational
state and must never be placed below MEDIA_ROOT.
Application tests use the standard anonymous `client` for access tests and the
explicit `admitted_client` fixture for company workflows. Existing end-to-end
tests retain their explicit login. Container acceptance remains the release
gate; native two-user workflow, restored data, support and rollout evidence
remain open until performed on the admitted deployment.

View file

@ -0,0 +1,42 @@
{
"schema": "vergabe.invited-pilot-source-acceptance.v1",
"date": "2026-09-11",
"source_base_commit": "56bf193193a4db635692ddd9e534c233b13f904e",
"regression_baseline": {
"failed": 9,
"passed": 3,
"anonymous_read_and_create_demonstrated": true
},
"application_suite": {
"passed": 94,
"database": "disposable SQLite",
"local": true,
"container": true,
"container_image_id": "sha256:6f85d039115d5412b77a65740baa741f37e9ac2bf5f28a7a216aebf8f3c428cc",
"static_files_collected": 139,
"vite_build": "passed",
"migration_drift": false,
"warning": "One existing naive-datetime warning"
},
"ruff_new_access_modules": "passed",
"browser": {
"status": "passed",
"checks": [
"Anonymous tender page redirects to login",
"Password input has a visible border and usable height",
"Invited member logs in and reaches password-change UI",
"User menu submits CSRF-protected logout and returns to login",
"Login fits a 390-pixel viewport without horizontal overflow",
"No browser JavaScript errors"
],
"synthetic_users": true,
"disposable_sqlite": true,
"local_http_only": true,
"native_customer_admission": false
},
"native_customer_admission": false,
"live_customer_data_used": false,
"pricing_required_for_pilot": false,
"factory_attempts": 0,
"paid_model_calls": 0
}

View file

@ -0,0 +1,16 @@
from django.contrib.auth import forms
class _AccountFieldStyle:
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
for field in self.fields.values():
field.widget.attrs['class'] = 'form-input'
class AuthenticationForm(_AccountFieldStyle, forms.AuthenticationForm):
pass
class PasswordChangeForm(_AccountFieldStyle, forms.PasswordChangeForm):
pass

View file

@ -0,0 +1,21 @@
from django.contrib.auth.middleware import LoginRequiredMiddleware
from django.contrib.auth.views import redirect_to_login
from django.http import HttpResponse
from django.utils.cache import add_never_cache_headers
class PilotLoginRequiredMiddleware(LoginRequiredMiddleware):
"""Use Django's default-deny gate, including full-page HTMX reauthentication."""
def handle_no_permission(self, request, view_func):
if request.headers.get('HX-Request') == 'true':
# The original URL may render only a fragment or accept only POST.
login = redirect_to_login('/', self.get_login_url(view_func))
return HttpResponse(status=401, headers={'HX-Redirect': login.url})
return super().handle_no_permission(request, view_func)
def process_response(self, request, response):
# Company records and account pages must not survive in shared caches.
if request.path != '/health/':
add_never_cache_headers(response)
return response

View file

@ -1,3 +1,138 @@
from django.test import TestCase from urllib.parse import parse_qs, urlsplit
# Create your tests here. import pytest
from django.test import Client
from vergabe_teilnahme.apps.ausschreibungen.models import Ausschreibung
pytestmark = pytest.mark.django_db
def test_anonymous_cannot_read_tenders(client):
Ausschreibung.objects.create(titel='Confidential tender', ausschreiber='Pilot')
response = client.get('/ausschreibungen/')
assert response.status_code == 302
assert urlsplit(response.url).path == '/accounts/login/'
assert b'Confidential tender' not in response.content
def test_anonymous_cannot_create_tender(client):
response = client.post('/ausschreibungen/neu/', {
'titel': 'Unauthorised tender', 'ausschreiber': 'Pilot',
})
assert response.status_code == 302
assert not Ausschreibung.objects.exists()
def test_expired_htmx_session_requires_full_login(client):
response = client.get('/suche/?q=private', HTTP_HX_REQUEST='true')
assert response.status_code == 401
assert urlsplit(response['HX-Redirect']).path == '/accounts/login/'
# A fragment URL must never become the full page after login.
assert parse_qs(urlsplit(response['HX-Redirect']).query)['next'] == ['/']
def test_health_is_public_and_contains_no_tenant_data(client):
assert client.get('/health/').json() == {'status': 'ok'}
def test_invited_login_redirect_and_logout(mitarbeiter):
client = Client(enforce_csrf_checks=True)
response = client.get('/accounts/login/?next=/ausschreibungen/')
assert response.status_code == 200
assert b'Benutzername' in response.content
assert b'search-results' not in response.content
credentials = {'username': mitarbeiter.username, 'password': 'testpass',
'next': '/ausschreibungen/'}
assert client.post('/accounts/login/', credentials).status_code == 403
credentials['csrfmiddlewaretoken'] = client.cookies['csrftoken'].value
response = client.post('/accounts/login/', credentials)
assert response.status_code == 302
assert response.url == '/ausschreibungen/'
assert client.get(response.url).status_code == 200
assert client.get('/accounts/logout/').status_code == 405
assert client.post('/accounts/logout/').status_code == 403
response = client.post('/accounts/logout/', {
'csrfmiddlewaretoken': client.cookies['csrftoken'].value,
})
assert response.url == '/accounts/login/'
assert client.get('/ausschreibungen/').status_code == 302
def test_login_rejects_external_next(client, mitarbeiter):
response = client.post('/accounts/login/', {
'username': mitarbeiter.username, 'password': 'testpass',
'next': 'https://untrusted.example/private',
})
assert response.status_code == 302
assert response.url == '/'
def test_deactivated_member_loses_existing_session_and_cannot_login(client, mitarbeiter):
client.force_login(mitarbeiter)
mitarbeiter.is_active = False
mitarbeiter.save(update_fields=['is_active'])
assert client.get('/ausschreibungen/').status_code == 302
response = client.post('/accounts/login/', {
'username': mitarbeiter.username, 'password': 'testpass',
})
assert response.status_code == 200
assert response.context['form'].errors
assert client.get('/ausschreibungen/').status_code == 302
def test_member_password_change_keeps_session_and_replaces_password(client, mitarbeiter):
client.force_login(mitarbeiter)
assert client.get('/accounts/password-change/').status_code == 200
response = client.post('/accounts/password-change/', {
'old_password': 'testpass',
'new_password1': 'A-unique-pilot-password-914!',
'new_password2': 'A-unique-pilot-password-914!',
})
assert response.status_code == 302
assert client.get(response.url).status_code == 200
assert client.get('/ausschreibungen/').status_code == 200
mitarbeiter.refresh_from_db()
assert not mitarbeiter.check_password('testpass')
assert mitarbeiter.check_password('A-unique-pilot-password-914!')
def test_company_member_does_not_gain_django_admin_access(client, mitarbeiter):
client.force_login(mitarbeiter)
response = client.get('/admin/')
assert response.status_code == 302
assert urlsplit(response.url).path == '/admin/login/'
@pytest.mark.parametrize('debug', [False, True])
def test_media_requires_login_and_downloads_as_private_attachment(
client, mitarbeiter, tmp_path, settings, debug,
):
settings.DEBUG = debug
settings.MEDIA_ROOT = tmp_path / 'media'
settings.MEDIA_ROOT.mkdir()
(settings.MEDIA_ROOT / 'tender.pdf').write_bytes(b'%PDF-pilot')
assert client.get('/media/tender.pdf').status_code == 302
client.force_login(mitarbeiter)
response = client.get('/media/tender.pdf')
assert response.status_code == 200
assert b''.join(response.streaming_content) == b'%PDF-pilot'
assert response['Content-Disposition'] == 'attachment; filename="tender.pdf"'
assert response['X-Content-Type-Options'] == 'nosniff'
assert 'no-store' in response['Cache-Control']
def test_media_rejects_traversal_symlink_directory_and_missing_file(
client, mitarbeiter, tmp_path, settings,
):
settings.MEDIA_ROOT = tmp_path / 'media'
settings.MEDIA_ROOT.mkdir()
private = tmp_path / 'issues.db'
private.write_bytes(b'private issue state')
(settings.MEDIA_ROOT / 'escape.pdf').symlink_to(private)
(settings.MEDIA_ROOT / 'folder').mkdir()
client.force_login(mitarbeiter)
for path in ('../issues.db', '%2e%2e/issues.db', 'escape.pdf', 'folder', 'absent.pdf'):
response = client.get('/media/' + path)
assert response.status_code == 404
assert b'private issue state' not in response.content

View file

@ -0,0 +1,21 @@
from django.contrib.auth import views
from django.urls import path, reverse_lazy
from .forms import AuthenticationForm, PasswordChangeForm
app_name = 'accounts'
urlpatterns = [
path('login/', views.LoginView.as_view(
template_name='accounts/login.html', authentication_form=AuthenticationForm,
), name='login'),
path('logout/', views.LogoutView.as_view(), name='logout'),
path('password-change/', views.PasswordChangeView.as_view(
template_name='accounts/password_change.html',
form_class=PasswordChangeForm,
success_url=reverse_lazy('accounts:password_change_done'),
), name='password_change'),
path('password-change/done/', views.PasswordChangeDoneView.as_view(
template_name='accounts/password_change_done.html',
), name='password_change_done'),
]

View file

@ -33,40 +33,40 @@ class BieterfragenFactory(factory.django.DjangoModelFactory):
@pytest.mark.django_db @pytest.mark.django_db
def test_aufgaben_liste_get(client): def test_aufgaben_liste_get(admitted_client):
a = AusschreibungFactory() a = AusschreibungFactory()
url = reverse('ausschreibungen:aufgaben:liste', kwargs={'ausschreibung_id': a.pk}) url = reverse('ausschreibungen:aufgaben:liste', kwargs={'ausschreibung_id': a.pk})
response = client.get(url) response = admitted_client.get(url)
assert response.status_code == 200 assert response.status_code == 200
@pytest.mark.django_db @pytest.mark.django_db
def test_aufgabe_neu_post(client): def test_aufgabe_neu_post(admitted_client):
a = AusschreibungFactory() a = AusschreibungFactory()
url = reverse('ausschreibungen:aufgaben:neu', kwargs={'ausschreibung_id': a.pk}) url = reverse('ausschreibungen:aufgaben:neu', kwargs={'ausschreibung_id': a.pk})
response = client.post(url, {'titel': 'Neue Aufgabe', 'typ': 'fachlich', 'prioritaet': 2}) response = admitted_client.post(url, {'titel': 'Neue Aufgabe', 'typ': 'fachlich', 'prioritaet': 2})
assert response.status_code == 302 assert response.status_code == 302
assert Aufgabe.objects.filter(ausschreibung=a, titel='Neue Aufgabe').exists() assert Aufgabe.objects.filter(ausschreibung=a, titel='Neue Aufgabe').exists()
@pytest.mark.django_db @pytest.mark.django_db
def test_aufgabe_status_htmx(client): def test_aufgabe_status_htmx(admitted_client):
aufgabe = AufgabeFactory() aufgabe = AufgabeFactory()
url = reverse('ausschreibungen:aufgaben:status', url = reverse('ausschreibungen:aufgaben:status',
kwargs={'ausschreibung_id': aufgabe.ausschreibung_id, 'pk': aufgabe.pk}) kwargs={'ausschreibung_id': aufgabe.ausschreibung_id, 'pk': aufgabe.pk})
response = client.post(url, {'status': 'erledigt'}, HTTP_HX_REQUEST='true') response = admitted_client.post(url, {'status': 'erledigt'}, HTTP_HX_REQUEST='true')
assert response.status_code == 200 assert response.status_code == 200
aufgabe.refresh_from_db() aufgabe.refresh_from_db()
assert aufgabe.status == 'erledigt' assert aufgabe.status == 'erledigt'
@pytest.mark.django_db @pytest.mark.django_db
def test_ueberfaellige_aufgabe_auto_update(client): def test_ueberfaellige_aufgabe_auto_update(admitted_client):
from datetime import date, timedelta from datetime import date, timedelta
a = AusschreibungFactory() a = AusschreibungFactory()
aufgabe = AufgabeFactory(ausschreibung=a, frist=date.today() - timedelta(days=1), status='offen') aufgabe = AufgabeFactory(ausschreibung=a, frist=date.today() - timedelta(days=1), status='offen')
url = reverse('ausschreibungen:aufgaben:liste', kwargs={'ausschreibung_id': a.pk}) url = reverse('ausschreibungen:aufgaben:liste', kwargs={'ausschreibung_id': a.pk})
client.get(url) admitted_client.get(url)
aufgabe.refresh_from_db() aufgabe.refresh_from_db()
assert aufgabe.status == 'ueberfaellig' assert aufgabe.status == 'ueberfaellig'
@ -75,21 +75,21 @@ def test_ueberfaellige_aufgabe_auto_update(client):
@pytest.mark.django_db @pytest.mark.django_db
def test_bieterfrage_neu_prefill_anforderung(client): def test_bieterfrage_neu_prefill_anforderung(admitted_client):
a = AusschreibungFactory() a = AusschreibungFactory()
anf = AnforderungFactory(ausschreibung=a) anf = AnforderungFactory(ausschreibung=a)
url = reverse('ausschreibungen:bieterfragen:neu', kwargs={'ausschreibung_id': a.pk}) url = reverse('ausschreibungen:bieterfragen:neu', kwargs={'ausschreibung_id': a.pk})
response = client.get(url, {'anforderung_id': anf.pk}) response = admitted_client.get(url, {'anforderung_id': anf.pk})
assert response.status_code == 200 assert response.status_code == 200
assert str(anf.pk).encode() in response.content assert str(anf.pk).encode() in response.content
@pytest.mark.django_db @pytest.mark.django_db
def test_bieterfrage_antwort_speichern(client): def test_bieterfrage_antwort_speichern(admitted_client):
bf = BieterfragenFactory(status='eingereicht') bf = BieterfragenFactory(status='eingereicht')
url = reverse('ausschreibungen:bieterfragen:antwort', url = reverse('ausschreibungen:bieterfragen:antwort',
kwargs={'ausschreibung_id': bf.ausschreibung_id, 'pk': bf.pk}) kwargs={'ausschreibung_id': bf.ausschreibung_id, 'pk': bf.pk})
response = client.post(url, {'antwort': 'Die Antwort lautet 42.', 'auswirkung_angebot': ''}) response = admitted_client.post(url, {'antwort': 'Die Antwort lautet 42.', 'auswirkung_angebot': ''})
assert response.status_code == 302 assert response.status_code == 302
bf.refresh_from_db() bf.refresh_from_db()
assert bf.antwort == 'Die Antwort lautet 42.' assert bf.antwort == 'Die Antwort lautet 42.'
@ -116,7 +116,7 @@ def test_frist_effektiv_ohne_frist():
@pytest.mark.django_db @pytest.mark.django_db
def test_ueberfaellig_ohne_frist_nach_7_tagen(client): def test_ueberfaellig_ohne_frist_nach_7_tagen(admitted_client):
from datetime import timedelta from datetime import timedelta
from django.utils import timezone from django.utils import timezone
a = AusschreibungFactory() a = AusschreibungFactory()
@ -124,7 +124,7 @@ def test_ueberfaellig_ohne_frist_nach_7_tagen(client):
aufgabe = AufgabeFactory(ausschreibung=a, frist=None, status='offen') aufgabe = AufgabeFactory(ausschreibung=a, frist=None, status='offen')
Aufgabe.objects.filter(pk=aufgabe.pk).update(erstellt_am=alte_erstellung) Aufgabe.objects.filter(pk=aufgabe.pk).update(erstellt_am=alte_erstellung)
url = reverse('ausschreibungen:aufgaben:liste', kwargs={'ausschreibung_id': a.pk}) url = reverse('ausschreibungen:aufgaben:liste', kwargs={'ausschreibung_id': a.pk})
client.get(url) admitted_client.get(url)
aufgabe.refresh_from_db() aufgabe.refresh_from_db()
assert aufgabe.status == 'ueberfaellig' assert aufgabe.status == 'ueberfaellig'
@ -133,14 +133,14 @@ def test_ueberfaellig_ohne_frist_nach_7_tagen(client):
@pytest.mark.django_db @pytest.mark.django_db
def test_aufgaben_verknuepfung_erstellen(client): def test_aufgaben_verknuepfung_erstellen(admitted_client):
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
aufgabe = AufgabeFactory() aufgabe = AufgabeFactory()
anf = AnforderungFactory(ausschreibung=aufgabe.ausschreibung) anf = AnforderungFactory(ausschreibung=aufgabe.ausschreibung)
ct = ContentType.objects.get_for_model(anf) ct = ContentType.objects.get_for_model(anf)
url = reverse('ausschreibungen:aufgaben:verknuepfung_neu', url = reverse('ausschreibungen:aufgaben:verknuepfung_neu',
kwargs={'ausschreibung_id': aufgabe.ausschreibung_id, 'pk': aufgabe.pk}) kwargs={'ausschreibung_id': aufgabe.ausschreibung_id, 'pk': aufgabe.pk})
response = client.post(url, { response = admitted_client.post(url, {
'ziel_typ': ct.pk, 'ziel_typ': ct.pk,
'ziel_id': anf.pk, 'ziel_id': anf.pk,
'kommentar': 'Testverknüpfung', 'kommentar': 'Testverknüpfung',
@ -150,7 +150,7 @@ def test_aufgaben_verknuepfung_erstellen(client):
@pytest.mark.django_db @pytest.mark.django_db
def test_aufgaben_verknuepfung_loeschen(client): def test_aufgaben_verknuepfung_loeschen(admitted_client):
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
aufgabe = AufgabeFactory() aufgabe = AufgabeFactory()
anf = AnforderungFactory(ausschreibung=aufgabe.ausschreibung) anf = AnforderungFactory(ausschreibung=aufgabe.ausschreibung)
@ -160,7 +160,7 @@ def test_aufgaben_verknuepfung_loeschen(client):
) )
url = reverse('ausschreibungen:aufgaben:verknuepfung_loeschen', url = reverse('ausschreibungen:aufgaben:verknuepfung_loeschen',
kwargs={'ausschreibung_id': aufgabe.ausschreibung_id, 'pk': aufgabe.pk, 'vk_pk': vk.pk}) kwargs={'ausschreibung_id': aufgabe.ausschreibung_id, 'pk': aufgabe.pk, 'vk_pk': vk.pk})
response = client.post(url, {}, HTTP_HX_REQUEST='true') response = admitted_client.post(url, {}, HTTP_HX_REQUEST='true')
assert response.status_code == 200 assert response.status_code == 200
assert not AufgabenVerknuepfung.objects.filter(pk=vk.pk).exists() assert not AufgabenVerknuepfung.objects.filter(pk=vk.pk).exists()
@ -176,24 +176,24 @@ def tmp_issue_db(tmp_path, settings):
@pytest.mark.django_db @pytest.mark.django_db
def test_external_issue_erstellen(client, tmp_issue_db): def test_external_issue_erstellen(admitted_client, tmp_issue_db):
aufgabe = AufgabeFactory() aufgabe = AufgabeFactory()
url = reverse('ausschreibungen:aufgaben:external_issue', url = reverse('ausschreibungen:aufgaben:external_issue',
kwargs={'ausschreibung_id': aufgabe.ausschreibung_id, 'pk': aufgabe.pk}) kwargs={'ausschreibung_id': aufgabe.ausschreibung_id, 'pk': aufgabe.pk})
response = client.post(url, {'notizen': ''}, HTTP_HX_REQUEST='true') response = admitted_client.post(url, {'notizen': ''}, HTTP_HX_REQUEST='true')
assert response.status_code == 200 assert response.status_code == 200
assert ExternalIssue.objects.filter(aufgabe=aufgabe, issue_facade_backend='local').exists() assert ExternalIssue.objects.filter(aufgabe=aufgabe, issue_facade_backend='local').exists()
@pytest.mark.django_db @pytest.mark.django_db
def test_external_issue_loeschen(client): def test_external_issue_loeschen(admitted_client):
aufgabe = AufgabeFactory() aufgabe = AufgabeFactory()
ei = ExternalIssue.objects.create( ei = ExternalIssue.objects.create(
aufgabe=aufgabe, issue_facade_backend='local', issue_key='#1' aufgabe=aufgabe, issue_facade_backend='local', issue_key='#1'
) )
url = reverse('ausschreibungen:aufgaben:external_issue_loeschen', url = reverse('ausschreibungen:aufgaben:external_issue_loeschen',
kwargs={'ausschreibung_id': aufgabe.ausschreibung_id, 'pk': aufgabe.pk}) kwargs={'ausschreibung_id': aufgabe.ausschreibung_id, 'pk': aufgabe.pk})
response = client.post(url, {}, HTTP_HX_REQUEST='true') response = admitted_client.post(url, {}, HTTP_HX_REQUEST='true')
assert response.status_code == 200 assert response.status_code == 200
assert not ExternalIssue.objects.filter(pk=ei.pk).exists() assert not ExternalIssue.objects.filter(pk=ei.pk).exists()
@ -250,11 +250,11 @@ def test_status_synchronisieren(tmp_issue_db):
@pytest.mark.django_db @pytest.mark.django_db
def test_external_issue_bearbeiten_view_erstellt_issue(client, tmp_issue_db): def test_external_issue_bearbeiten_view_erstellt_issue(admitted_client, tmp_issue_db):
aufgabe = AufgabeFactory() aufgabe = AufgabeFactory()
url = reverse('ausschreibungen:aufgaben:external_issue', url = reverse('ausschreibungen:aufgaben:external_issue',
kwargs={'ausschreibung_id': aufgabe.ausschreibung_id, 'pk': aufgabe.pk}) kwargs={'ausschreibung_id': aufgabe.ausschreibung_id, 'pk': aufgabe.pk})
response = client.post(url, {'notizen': 'Test-Notiz'}, HTTP_HX_REQUEST='true') response = admitted_client.post(url, {'notizen': 'Test-Notiz'}, HTTP_HX_REQUEST='true')
assert response.status_code == 200 assert response.status_code == 200
ei = ExternalIssue.objects.get(aufgabe=aufgabe) ei = ExternalIssue.objects.get(aufgabe=aufgabe)
assert ei.issue_facade_backend == 'local' assert ei.issue_facade_backend == 'local'
@ -263,7 +263,7 @@ def test_external_issue_bearbeiten_view_erstellt_issue(client, tmp_issue_db):
@pytest.mark.django_db @pytest.mark.django_db
def test_external_issue_sync_view(client, tmp_issue_db): def test_external_issue_sync_view(admitted_client, tmp_issue_db):
from .issue_facade import lokales_issue_erstellen from .issue_facade import lokales_issue_erstellen
aufgabe = AufgabeFactory() aufgabe = AufgabeFactory()
daten = lokales_issue_erstellen(aufgabe) daten = lokales_issue_erstellen(aufgabe)
@ -276,7 +276,7 @@ def test_external_issue_sync_view(client, tmp_issue_db):
) )
url = reverse('ausschreibungen:aufgaben:external_issue_sync', url = reverse('ausschreibungen:aufgaben:external_issue_sync',
kwargs={'ausschreibung_id': aufgabe.ausschreibung_id, 'pk': aufgabe.pk}) kwargs={'ausschreibung_id': aufgabe.ausschreibung_id, 'pk': aufgabe.pk})
response = client.post(url, {}, HTTP_HX_REQUEST='true') response = admitted_client.post(url, {}, HTTP_HX_REQUEST='true')
assert response.status_code == 200 assert response.status_code == 200
ei.refresh_from_db() ei.refresh_from_db()
assert ei.sync_status == 'open' assert ei.sync_status == 'open'

View file

@ -54,47 +54,47 @@ def test_naechste_frist_none_when_past():
# --- View tests --- # --- View tests ---
@pytest.mark.django_db @pytest.mark.django_db
def test_liste_get(client): def test_liste_get(admitted_client):
response = client.get(reverse("ausschreibungen:liste")) response = admitted_client.get(reverse("ausschreibungen:liste"))
assert response.status_code == 200 assert response.status_code == 200
@pytest.mark.django_db @pytest.mark.django_db
def test_neu_get(client): def test_neu_get(admitted_client):
response = client.get(reverse("ausschreibungen:neu")) response = admitted_client.get(reverse("ausschreibungen:neu"))
assert response.status_code == 200 assert response.status_code == 200
@pytest.mark.django_db @pytest.mark.django_db
def test_neu_post_valid(client): def test_neu_post_valid(admitted_client):
data = {"titel": "Neue Ausschreibung", "ausschreiber": "Stadt XY"} data = {"titel": "Neue Ausschreibung", "ausschreiber": "Stadt XY"}
response = client.post(reverse("ausschreibungen:neu"), data) response = admitted_client.post(reverse("ausschreibungen:neu"), data)
assert response.status_code == 302 assert response.status_code == 302
a = Ausschreibung.objects.get(titel="Neue Ausschreibung") a = Ausschreibung.objects.get(titel="Neue Ausschreibung")
assert response.url == reverse("ausschreibungen:detail", kwargs={"pk": a.pk}) assert response.url == reverse("ausschreibungen:detail", kwargs={"pk": a.pk})
@pytest.mark.django_db @pytest.mark.django_db
def test_detail_get(client): def test_detail_get(admitted_client):
a = AusschreibungFactory() a = AusschreibungFactory()
response = client.get(reverse("ausschreibungen:detail", kwargs={"pk": a.pk})) response = admitted_client.get(reverse("ausschreibungen:detail", kwargs={"pk": a.pk}))
assert response.status_code == 200 assert response.status_code == 200
@pytest.mark.django_db @pytest.mark.django_db
def test_status_post(client): def test_status_post(admitted_client):
a = AusschreibungFactory(status=1) a = AusschreibungFactory(status=1)
url = reverse("ausschreibungen:status", kwargs={"pk": a.pk}) url = reverse("ausschreibungen:status", kwargs={"pk": a.pk})
response = client.post(url, {"status": "4"}) response = admitted_client.post(url, {"status": "4"})
assert response.status_code == 200 assert response.status_code == 200
a.refresh_from_db() a.refresh_from_db()
assert a.status == 4 assert a.status == 4
@pytest.mark.django_db @pytest.mark.django_db
def test_status_htmx_returns_partial(client): def test_status_htmx_returns_partial(admitted_client):
a = AusschreibungFactory(status=1) a = AusschreibungFactory(status=1)
url = reverse("ausschreibungen:status", kwargs={"pk": a.pk}) url = reverse("ausschreibungen:status", kwargs={"pk": a.pk})
response = client.post(url, {"status": "3"}, HTTP_HX_REQUEST="true") response = admitted_client.post(url, {"status": "3"}, HTTP_HX_REQUEST="true")
assert response.status_code == 200 assert response.status_code == 200
assert b"status-widget" in response.content assert b"status-widget" in response.content

View file

@ -19,12 +19,12 @@ def test_nachweis_ist_abgelaufen_false_without_date():
@pytest.mark.django_db @pytest.mark.django_db
def test_nachweis_liste_filter_abgelaufen(client): def test_nachweis_liste_filter_abgelaufen(admitted_client):
heute = date.today() heute = date.today()
Nachweis.objects.create(titel='AbgelaufenerNachweis', gueltig_bis=heute - timedelta(days=5)) Nachweis.objects.create(titel='AbgelaufenerNachweis', gueltig_bis=heute - timedelta(days=5))
Nachweis.objects.create(titel='NochAktuellerNachweis', gueltig_bis=heute + timedelta(days=100)) Nachweis.objects.create(titel='NochAktuellerNachweis', gueltig_bis=heute + timedelta(days=100))
url = reverse('bibliothek:nachweise_liste') url = reverse('bibliothek:nachweise_liste')
response = client.get(url + '?tab=abgelaufen') response = admitted_client.get(url + '?tab=abgelaufen')
assert response.status_code == 200 assert response.status_code == 200
content = response.content.decode() content = response.content.decode()
assert 'AbgelaufenerNachweis' in content assert 'AbgelaufenerNachweis' in content
@ -32,7 +32,7 @@ def test_nachweis_liste_filter_abgelaufen(client):
@pytest.mark.django_db @pytest.mark.django_db
def test_entscheidungsregel_inaktiv_nicht_in_liste(client): def test_entscheidungsregel_inaktiv_nicht_in_liste(admitted_client):
Entscheidungsregel.objects.create( Entscheidungsregel.objects.create(
regelname='Aktive Regel', kategorie='ausschlusskriterium', empfehlung='teilnehmen', aktiv=True regelname='Aktive Regel', kategorie='ausschlusskriterium', empfehlung='teilnehmen', aktiv=True
) )
@ -40,7 +40,7 @@ def test_entscheidungsregel_inaktiv_nicht_in_liste(client):
regelname='Inaktive Regel', kategorie='frist', empfehlung='pruefen', aktiv=False regelname='Inaktive Regel', kategorie='frist', empfehlung='pruefen', aktiv=False
) )
url = reverse('bibliothek:entscheidungsregeln_liste') url = reverse('bibliothek:entscheidungsregeln_liste')
response = client.get(url) response = admitted_client.get(url)
assert response.status_code == 200 assert response.status_code == 200
content = response.content.decode() content = response.content.decode()
assert 'Aktive Regel' in content assert 'Aktive Regel' in content
@ -48,21 +48,21 @@ def test_entscheidungsregel_inaktiv_nicht_in_liste(client):
@pytest.mark.django_db @pytest.mark.django_db
def test_entscheidungsregel_toggle(client): def test_entscheidungsregel_toggle(admitted_client):
r = Entscheidungsregel.objects.create( r = Entscheidungsregel.objects.create(
regelname='Toggle-Regel', kategorie='ausschlusskriterium', empfehlung='teilnehmen', aktiv=True regelname='Toggle-Regel', kategorie='ausschlusskriterium', empfehlung='teilnehmen', aktiv=True
) )
url = reverse('bibliothek:er_toggle', kwargs={'pk': r.pk}) url = reverse('bibliothek:er_toggle', kwargs={'pk': r.pk})
client.post(url) admitted_client.post(url)
r.refresh_from_db() r.refresh_from_db()
assert r.aktiv is False assert r.aktiv is False
@pytest.mark.django_db @pytest.mark.django_db
def test_nachweis_neue_version_ersetzt_alten(client): def test_nachweis_neue_version_ersetzt_alten(admitted_client):
alt = Nachweis.objects.create(titel='Zertifikat ISO', version='1.0') alt = Nachweis.objects.create(titel='Zertifikat ISO', version='1.0')
url = reverse('bibliothek:nachweis_version', kwargs={'pk': alt.pk}) url = reverse('bibliothek:nachweis_version', kwargs={'pk': alt.pk})
client.post(url, { admitted_client.post(url, {
'titel': 'Zertifikat ISO', 'titel': 'Zertifikat ISO',
'version': '1.0', 'version': '1.0',
'sprache': 'de', 'sprache': 'de',

View file

@ -0,0 +1,23 @@
from pathlib import Path
from django.conf import settings
from django.http import FileResponse, Http404
from django.views.decorators.http import require_safe
@require_safe
def protected_media(request, path):
"""Download company uploads through the application authentication gate.
The invited pilot has one company per deployment. MEDIA_ROOT must contain
uploads only; operational databases and credentials live outside this root.
"""
root = Path(settings.MEDIA_ROOT).resolve()
try:
target = (root / path).resolve()
if not target.is_relative_to(root) or not target.is_file():
raise Http404
file = target.open('rb')
except (OSError, RuntimeError, ValueError) as exc:
raise Http404 from exc
return FileResponse(file, as_attachment=True, filename=target.name)

View file

@ -26,46 +26,46 @@ def _pdf_file(name='test.pdf'):
@pytest.mark.django_db @pytest.mark.django_db
def test_dokument_upload_valid(client, tmp_path, settings): def test_dokument_upload_valid(admitted_client, tmp_path, settings):
settings.MEDIA_ROOT = tmp_path settings.MEDIA_ROOT = tmp_path
a = AusschreibungFactory() a = AusschreibungFactory()
url = reverse('ausschreibungen:dokumente:upload', kwargs={'ausschreibung_id': a.pk}) url = reverse('ausschreibungen:dokumente:upload', kwargs={'ausschreibung_id': a.pk})
response = client.post(url, {'datei': _pdf_file(), 'kategorie': 'intern', 'version': '1.0'}) response = admitted_client.post(url, {'datei': _pdf_file(), 'kategorie': 'intern', 'version': '1.0'})
assert response.status_code == 302 assert response.status_code == 302
assert Dokument.objects.filter(ausschreibung=a).exists() assert Dokument.objects.filter(ausschreibung=a).exists()
@pytest.mark.django_db @pytest.mark.django_db
def test_dokument_upload_invalid_extension(client, tmp_path, settings): def test_dokument_upload_invalid_extension(admitted_client, tmp_path, settings):
settings.MEDIA_ROOT = tmp_path settings.MEDIA_ROOT = tmp_path
a = AusschreibungFactory() a = AusschreibungFactory()
url = reverse('ausschreibungen:dokumente:upload', kwargs={'ausschreibung_id': a.pk}) url = reverse('ausschreibungen:dokumente:upload', kwargs={'ausschreibung_id': a.pk})
bad_file = SimpleUploadedFile('malware.exe', b'MZ bad', content_type='application/octet-stream') bad_file = SimpleUploadedFile('malware.exe', b'MZ bad', content_type='application/octet-stream')
response = client.post(url, {'datei': bad_file, 'kategorie': 'intern', 'version': '1.0'}) response = admitted_client.post(url, {'datei': bad_file, 'kategorie': 'intern', 'version': '1.0'})
assert response.status_code == 200 assert response.status_code == 200
assert not Dokument.objects.filter(ausschreibung=a).exists() assert not Dokument.objects.filter(ausschreibung=a).exists()
@pytest.mark.django_db @pytest.mark.django_db
def test_dokument_upload_too_large(client, tmp_path, settings): def test_dokument_upload_too_large(admitted_client, tmp_path, settings):
settings.MEDIA_ROOT = tmp_path settings.MEDIA_ROOT = tmp_path
settings.MAX_UPLOAD_SIZE = 10 settings.MAX_UPLOAD_SIZE = 10
a = AusschreibungFactory() a = AusschreibungFactory()
url = reverse('ausschreibungen:dokumente:upload', kwargs={'ausschreibung_id': a.pk}) url = reverse('ausschreibungen:dokumente:upload', kwargs={'ausschreibung_id': a.pk})
big_file = SimpleUploadedFile('big.pdf', b'%PDF' + b'x' * 100, content_type='application/pdf') big_file = SimpleUploadedFile('big.pdf', b'%PDF' + b'x' * 100, content_type='application/pdf')
response = client.post(url, {'datei': big_file, 'kategorie': 'intern', 'version': '1.0'}) response = admitted_client.post(url, {'datei': big_file, 'kategorie': 'intern', 'version': '1.0'})
assert response.status_code == 200 assert response.status_code == 200
assert not Dokument.objects.filter(ausschreibung=a).exists() assert not Dokument.objects.filter(ausschreibung=a).exists()
@pytest.mark.django_db @pytest.mark.django_db
def test_dokument_neue_version(client, tmp_path, settings): def test_dokument_neue_version(admitted_client, tmp_path, settings):
settings.MEDIA_ROOT = tmp_path settings.MEDIA_ROOT = tmp_path
altes_dok = DokumentFactory(version='1.0') altes_dok = DokumentFactory(version='1.0')
a = altes_dok.ausschreibung a = altes_dok.ausschreibung
url = reverse('ausschreibungen:dokumente:neue_version', url = reverse('ausschreibungen:dokumente:neue_version',
kwargs={'ausschreibung_id': a.pk, 'pk': altes_dok.pk}) kwargs={'ausschreibung_id': a.pk, 'pk': altes_dok.pk})
response = client.post(url, {'datei': _pdf_file('v2.pdf'), 'version': '2.0'}) response = admitted_client.post(url, {'datei': _pdf_file('v2.pdf'), 'version': '2.0'})
assert response.status_code == 302 assert response.status_code == 302
altes_dok.refresh_from_db() altes_dok.refresh_from_db()
assert altes_dok.status == 'ersetzt' assert altes_dok.status == 'ersetzt'
@ -73,12 +73,12 @@ def test_dokument_neue_version(client, tmp_path, settings):
@pytest.mark.django_db @pytest.mark.django_db
def test_dokument_finale_version(client): def test_dokument_finale_version(admitted_client):
dok = DokumentFactory(status='freigegeben') dok = DokumentFactory(status='freigegeben')
a = dok.ausschreibung a = dok.ausschreibung
url = reverse('ausschreibungen:dokumente:finale_version', url = reverse('ausschreibungen:dokumente:finale_version',
kwargs={'ausschreibung_id': a.pk, 'pk': dok.pk}) kwargs={'ausschreibung_id': a.pk, 'pk': dok.pk})
response = client.post(url) response = admitted_client.post(url)
assert response.status_code == 200 assert response.status_code == 200
dok.refresh_from_db() dok.refresh_from_db()
assert dok.finale_abgabeversion is True assert dok.finale_abgabeversion is True

View file

@ -39,28 +39,28 @@ class NachweisFactory(factory.django.DjangoModelFactory):
@pytest.mark.django_db @pytest.mark.django_db
def test_lose_liste_get(client): def test_lose_liste_get(admitted_client):
a = AusschreibungFactory() a = AusschreibungFactory()
url = reverse('ausschreibungen:lose:liste', kwargs={'ausschreibung_id': a.pk}) url = reverse('ausschreibungen:lose:liste', kwargs={'ausschreibung_id': a.pk})
response = client.get(url) response = admitted_client.get(url)
assert response.status_code == 200 assert response.status_code == 200
@pytest.mark.django_db @pytest.mark.django_db
def test_los_neu_post(client): def test_los_neu_post(admitted_client):
a = AusschreibungFactory() a = AusschreibungFactory()
url = reverse('ausschreibungen:lose:neu', kwargs={'ausschreibung_id': a.pk}) url = reverse('ausschreibungen:lose:neu', kwargs={'ausschreibung_id': a.pk})
response = client.post(url, {'losnummer': 'L01', 'lostitel': 'Testlos'}) response = admitted_client.post(url, {'losnummer': 'L01', 'lostitel': 'Testlos'})
assert response.status_code == 302 assert response.status_code == 302
assert Los.objects.filter(ausschreibung=a, losnummer='L01').exists() assert Los.objects.filter(ausschreibung=a, losnummer='L01').exists()
@pytest.mark.django_db @pytest.mark.django_db
def test_los_detail_get(client): def test_los_detail_get(admitted_client):
los = LosFactory() los = LosFactory()
url = reverse('ausschreibungen:lose:detail', url = reverse('ausschreibungen:lose:detail',
kwargs={'ausschreibung_id': los.ausschreibung_id, 'los_pk': los.pk}) kwargs={'ausschreibung_id': los.ausschreibung_id, 'los_pk': los.pk})
response = client.get(url) response = admitted_client.get(url)
assert response.status_code == 200 assert response.status_code == 200
@ -68,21 +68,21 @@ def test_los_detail_get(client):
@pytest.mark.django_db @pytest.mark.django_db
def test_anforderung_neu_post_muss(client): def test_anforderung_neu_post_muss(admitted_client):
a = AusschreibungFactory() a = AusschreibungFactory()
url = reverse('ausschreibungen:lose:anforderung_neu', kwargs={'ausschreibung_id': a.pk}) url = reverse('ausschreibungen:lose:anforderung_neu', kwargs={'ausschreibung_id': a.pk})
response = client.post(url, {'titel': 'Neue Anforderung', 'verbindlichkeit': 'muss', response = admitted_client.post(url, {'titel': 'Neue Anforderung', 'verbindlichkeit': 'muss',
'erfuellungsstatus': 'offen'}) 'erfuellungsstatus': 'offen'})
assert response.status_code == 302 assert response.status_code == 302
assert Anforderung.objects.filter(ausschreibung=a, titel='Neue Anforderung').exists() assert Anforderung.objects.filter(ausschreibung=a, titel='Neue Anforderung').exists()
@pytest.mark.django_db @pytest.mark.django_db
def test_anforderung_status_htmx(client): def test_anforderung_status_htmx(admitted_client):
anf = AnforderungFactory() anf = AnforderungFactory()
url = reverse('ausschreibungen:lose:anforderung_status', url = reverse('ausschreibungen:lose:anforderung_status',
kwargs={'ausschreibung_id': anf.ausschreibung_id, 'pk': anf.pk}) kwargs={'ausschreibung_id': anf.ausschreibung_id, 'pk': anf.pk})
response = client.post(url, {'erfuellungsstatus': 'nicht_erfuellbar'}, response = admitted_client.post(url, {'erfuellungsstatus': 'nicht_erfuellbar'},
HTTP_HX_REQUEST='true') HTTP_HX_REQUEST='true')
assert response.status_code == 200 assert response.status_code == 200
anf.refresh_from_db() anf.refresh_from_db()
@ -90,7 +90,7 @@ def test_anforderung_status_htmx(client):
@pytest.mark.django_db @pytest.mark.django_db
def test_ausschlusskriterium_banner_shown(client): def test_ausschlusskriterium_banner_shown(admitted_client):
a = AusschreibungFactory() a = AusschreibungFactory()
AnforderungFactory( AnforderungFactory(
ausschreibung=a, ausschreibung=a,
@ -98,17 +98,17 @@ def test_ausschlusskriterium_banner_shown(client):
erfuellungsstatus='nicht_erfuellbar', erfuellungsstatus='nicht_erfuellbar',
) )
url = reverse('ausschreibungen:entscheidung', kwargs={'pk': a.pk}) url = reverse('ausschreibungen:entscheidung', kwargs={'pk': a.pk})
response = client.get(url) response = admitted_client.get(url)
assert response.status_code == 200 assert response.status_code == 200
assert b'Nicht erf\xc3\xbcllbare Ausschlusskriterien' in response.content assert b'Nicht erf\xc3\xbcllbare Ausschlusskriterien' in response.content
@pytest.mark.django_db @pytest.mark.django_db
def test_nachweis_zuordnen(client): def test_nachweis_zuordnen(admitted_client):
anf = AnforderungFactory() anf = AnforderungFactory()
n = NachweisFactory() n = NachweisFactory()
url = reverse('ausschreibungen:lose:nachweis_zuordnen', url = reverse('ausschreibungen:lose:nachweis_zuordnen',
kwargs={'ausschreibung_id': anf.ausschreibung_id, 'pk': anf.pk}) kwargs={'ausschreibung_id': anf.ausschreibung_id, 'pk': anf.pk})
response = client.post(url, {'nachweis_pk': n.pk}) response = admitted_client.post(url, {'nachweis_pk': n.pk})
assert response.status_code == 200 assert response.status_code == 200
assert anf.nachweise.filter(pk=n.pk).exists() assert anf.nachweise.filter(pk=n.pk).exists()

View file

@ -12,11 +12,11 @@ def make_mb(name='TestBegleiter', **kwargs):
@pytest.mark.django_db @pytest.mark.django_db
def test_passage_anlegen_mit_score_10(client): def test_passage_anlegen_mit_score_10(admitted_client):
a = AusschreibungFactory() a = AusschreibungFactory()
mb = make_mb() mb = make_mb()
url = reverse('marktbegleiter:passagen:neu', kwargs={'ausschreibung_id': a.pk}) url = reverse('marktbegleiter:passagen:neu', kwargs={'ausschreibung_id': a.pk})
response = client.post(url, { response = admitted_client.post(url, {
'passage': 'Musterpassage aus dem Dokument', 'passage': 'Musterpassage aus dem Dokument',
'marktbegleiter': mb.pk, 'marktbegleiter': mb.pk,
'verlaesslichkeitsscore': 10, 'verlaesslichkeitsscore': 10,
@ -40,7 +40,7 @@ def test_passage_score_zu_hoch_validierungsfehler():
@pytest.mark.django_db @pytest.mark.django_db
def test_auswertung_score_durchschnitt(client): def test_auswertung_score_durchschnitt(admitted_client):
a = AusschreibungFactory() a = AusschreibungFactory()
mb = make_mb() mb = make_mb()
Ausschreibungspassage.objects.create( Ausschreibungspassage.objects.create(
@ -50,14 +50,14 @@ def test_auswertung_score_durchschnitt(client):
ausschreibung=a, marktbegleiter=mb, passage='P2', verlaesslichkeitsscore=6 ausschreibung=a, marktbegleiter=mb, passage='P2', verlaesslichkeitsscore=6
) )
url = reverse('marktbegleiter:auswertung', kwargs={'pk': mb.pk}) url = reverse('marktbegleiter:auswertung', kwargs={'pk': mb.pk})
response = client.get(url) response = admitted_client.get(url)
assert response.status_code == 200 assert response.status_code == 200
content = response.content.decode() content = response.content.decode()
assert '7' in content assert '7' in content
@pytest.mark.django_db @pytest.mark.django_db
def test_marktbegleiter_detail_zeigt_passagen(client): def test_marktbegleiter_detail_zeigt_passagen(admitted_client):
a = AusschreibungFactory() a = AusschreibungFactory()
mb = make_mb(name='DetailBegleiter') mb = make_mb(name='DetailBegleiter')
Ausschreibungspassage.objects.create( Ausschreibungspassage.objects.create(
@ -65,6 +65,6 @@ def test_marktbegleiter_detail_zeigt_passagen(client):
passage='Sichtbare Passage', verlaesslichkeitsscore=5 passage='Sichtbare Passage', verlaesslichkeitsscore=5
) )
url = reverse('marktbegleiter:detail', kwargs={'pk': mb.pk}) url = reverse('marktbegleiter:detail', kwargs={'pk': mb.pk})
response = client.get(url) response = admitted_client.get(url)
assert response.status_code == 200 assert response.status_code == 200
assert b'Sichtbare Passage' in response.content assert b'Sichtbare Passage' in response.content

View file

@ -38,11 +38,11 @@ def test_abgabe_vollstaendigkeit_mit_freigabe():
@pytest.mark.django_db @pytest.mark.django_db
def test_ergebnis_gewonnen_erstellt_kickoff_aufgabe(client): def test_ergebnis_gewonnen_erstellt_kickoff_aufgabe(admitted_client):
from vergabe_teilnahme.apps.aufgaben.models import Aufgabe from vergabe_teilnahme.apps.aufgaben.models import Aufgabe
a = AusschreibungFactory(status=9) a = AusschreibungFactory(status=9)
url = reverse('ausschreibungen:nachbetrachtung:detail', kwargs={'ausschreibung_id': a.pk}) url = reverse('ausschreibungen:nachbetrachtung:detail', kwargs={'ausschreibung_id': a.pk})
response = client.post(url, { response = admitted_client.post(url, {
'ergebnis': 'gewonnen', 'ergebnis': 'gewonnen',
'verlustgruende': '[]', 'verlustgruende': '[]',
}) })
@ -53,21 +53,21 @@ def test_ergebnis_gewonnen_erstellt_kickoff_aufgabe(client):
@pytest.mark.django_db @pytest.mark.django_db
def test_ergebnis_verloren_setzt_status_11(client): def test_ergebnis_verloren_setzt_status_11(admitted_client):
a = AusschreibungFactory(status=9) a = AusschreibungFactory(status=9)
url = reverse('ausschreibungen:nachbetrachtung:detail', kwargs={'ausschreibung_id': a.pk}) url = reverse('ausschreibungen:nachbetrachtung:detail', kwargs={'ausschreibung_id': a.pk})
client.post(url, {'ergebnis': 'verloren', 'verlustgruende': '[]'}) admitted_client.post(url, {'ergebnis': 'verloren', 'verlustgruende': '[]'})
a.refresh_from_db() a.refresh_from_db()
assert a.status == 11 assert a.status == 11
@pytest.mark.django_db @pytest.mark.django_db
def test_verlustgruende_json_gespeichert(client): def test_verlustgruende_json_gespeichert(admitted_client):
import json import json
a = AusschreibungFactory(status=9) a = AusschreibungFactory(status=9)
url = reverse('ausschreibungen:nachbetrachtung:detail', kwargs={'ausschreibung_id': a.pk}) url = reverse('ausschreibungen:nachbetrachtung:detail', kwargs={'ausschreibung_id': a.pk})
gruende = [{'grund': 'Zu teuer', 'kategorie': 'preis', 'verlaesslichkeit': 4}] gruende = [{'grund': 'Zu teuer', 'kategorie': 'preis', 'verlaesslichkeit': 4}]
client.post(url, {'ergebnis': 'verloren', 'verlustgruende': json.dumps(gruende)}) admitted_client.post(url, {'ergebnis': 'verloren', 'verlustgruende': json.dumps(gruende)})
nb = Nachbetrachtung.objects.get(ausschreibung=a) nb = Nachbetrachtung.objects.get(ausschreibung=a)
assert nb.verlustgruende[0]['grund'] == 'Zu teuer' assert nb.verlustgruende[0]['grund'] == 'Zu teuer'
assert nb.verlustgruende[0]['kategorie'] == 'preis' assert nb.verlustgruende[0]['kategorie'] == 'preis'

View file

@ -12,43 +12,43 @@ def make_sub(praeferenz='zugelassen', name='TestSub', **kwargs):
@pytest.mark.django_db @pytest.mark.django_db
def test_subunternehmer_zuordnung_zu_los(client): def test_subunternehmer_zuordnung_zu_los(admitted_client):
a = AusschreibungFactory() a = AusschreibungFactory()
los = Los.objects.create(ausschreibung=a, losnummer='1', lostitel='Los 1') los = Los.objects.create(ausschreibung=a, losnummer='1', lostitel='Los 1')
sub = make_sub() sub = make_sub()
url = reverse('partner:su_zuordnen', kwargs={'ausschreibung_id': a.pk, 'los_pk': los.pk}) url = reverse('partner:su_zuordnen', kwargs={'ausschreibung_id': a.pk, 'los_pk': los.pk})
client.post(url, {'subunternehmer_id': sub.pk, 'konkrete_leistung': 'IT-Support'}) admitted_client.post(url, {'subunternehmer_id': sub.pk, 'konkrete_leistung': 'IT-Support'})
assert SubunternehmerZuordnung.objects.filter(subunternehmer=sub, ausschreibung=a, los=los).exists() assert SubunternehmerZuordnung.objects.filter(subunternehmer=sub, ausschreibung=a, los=los).exists()
@pytest.mark.django_db @pytest.mark.django_db
def test_gesperrter_subunternehmer_im_suchmodal(client): def test_gesperrter_subunternehmer_im_suchmodal(admitted_client):
a = AusschreibungFactory() a = AusschreibungFactory()
los = Los.objects.create(ausschreibung=a, losnummer='1', lostitel='Los 1') los = Los.objects.create(ausschreibung=a, losnummer='1', lostitel='Los 1')
make_sub(praeferenz='gesperrt', name='GesperrterSub') make_sub(praeferenz='gesperrt', name='GesperrterSub')
url = reverse('partner:su_suche_modal', kwargs={'ausschreibung_id': a.pk, 'los_pk': los.pk}) url = reverse('partner:su_suche_modal', kwargs={'ausschreibung_id': a.pk, 'los_pk': los.pk})
response = client.get(url + '?q=GesperrterSub') response = admitted_client.get(url + '?q=GesperrterSub')
assert response.status_code == 200 assert response.status_code == 200
assert b'gesperrt' in response.content.lower() assert b'gesperrt' in response.content.lower()
@pytest.mark.django_db @pytest.mark.django_db
def test_subunternehmer_praeferenz_update(client): def test_subunternehmer_praeferenz_update(admitted_client):
sub = make_sub(praeferenz='zugelassen') sub = make_sub(praeferenz='zugelassen')
url = reverse('partner:su_praeferenz', kwargs={'pk': sub.pk}) url = reverse('partner:su_praeferenz', kwargs={'pk': sub.pk})
client.post(url, {'praeferenz': 'bevorzugt'}) admitted_client.post(url, {'praeferenz': 'bevorzugt'})
sub.refresh_from_db() sub.refresh_from_db()
assert sub.praeferenz == 'bevorzugt' assert sub.praeferenz == 'bevorzugt'
@pytest.mark.django_db @pytest.mark.django_db
def test_zuordnung_toggle_zusage(client): def test_zuordnung_toggle_zusage(admitted_client):
a = AusschreibungFactory() a = AusschreibungFactory()
los = Los.objects.create(ausschreibung=a, losnummer='1', lostitel='Los 1') los = Los.objects.create(ausschreibung=a, losnummer='1', lostitel='Los 1')
sub = make_sub() sub = make_sub()
z = SubunternehmerZuordnung.objects.create(subunternehmer=sub, ausschreibung=a, los=los) z = SubunternehmerZuordnung.objects.create(subunternehmer=sub, ausschreibung=a, los=los)
assert z.zusage_vorhanden is False assert z.zusage_vorhanden is False
url = reverse('partner:zuordnung_toggle', kwargs={'pk': z.pk}) url = reverse('partner:zuordnung_toggle', kwargs={'pk': z.pk})
client.post(url, {'feld': 'zusage_vorhanden'}) admitted_client.post(url, {'feld': 'zusage_vorhanden'})
z.refresh_from_db() z.refresh_from_db()
assert z.zusage_vorhanden is True assert z.zusage_vorhanden is True

View file

@ -23,10 +23,10 @@ class PreispunktFactory(factory.django.DjangoModelFactory):
@pytest.mark.django_db @pytest.mark.django_db
def test_vergleichsgewicht_null_gespeichert(client): def test_vergleichsgewicht_null_gespeichert(admitted_client):
a = AusschreibungFactory() a = AusschreibungFactory()
url = reverse('ausschreibungen:preise:neu', kwargs={'ausschreibung_id': a.pk}) url = reverse('ausschreibungen:preise:neu', kwargs={'ausschreibung_id': a.pk})
response = client.post(url, { response = admitted_client.post(url, {
'leistungstyp': 'Test', 'leistungstyp': 'Test',
'konkrete_leistung': 'Leistung mit Gewicht 0', 'konkrete_leistung': 'Leistung mit Gewicht 0',
'mengeneinheit': 'Stück', 'mengeneinheit': 'Stück',
@ -39,10 +39,10 @@ def test_vergleichsgewicht_null_gespeichert(client):
@pytest.mark.django_db @pytest.mark.django_db
def test_vergleichsgewicht_zu_gross_validation_error(client): def test_vergleichsgewicht_zu_gross_validation_error(admitted_client):
a = AusschreibungFactory() a = AusschreibungFactory()
url = reverse('ausschreibungen:preise:neu', kwargs={'ausschreibung_id': a.pk}) url = reverse('ausschreibungen:preise:neu', kwargs={'ausschreibung_id': a.pk})
response = client.post(url, { response = admitted_client.post(url, {
'leistungstyp': 'Test', 'leistungstyp': 'Test',
'konkrete_leistung': 'Leistung', 'konkrete_leistung': 'Leistung',
'mengeneinheit': 'Stück', 'mengeneinheit': 'Stück',
@ -71,10 +71,10 @@ def test_gewichteter_durchschnitt_berechnung():
@pytest.mark.django_db @pytest.mark.django_db
def test_auswertung_view_200(client): def test_auswertung_view_200(admitted_client):
a = AusschreibungFactory() a = AusschreibungFactory()
PreispunktFactory(ausschreibung=a, einzelpreis=Decimal('150.00')) PreispunktFactory(ausschreibung=a, einzelpreis=Decimal('150.00'))
url = reverse('ausschreibungen:preise:auswertung', kwargs={'ausschreibung_id': a.pk}) url = reverse('ausschreibungen:preise:auswertung', kwargs={'ausschreibung_id': a.pk})
response = client.get(url) response = admitted_client.get(url)
assert response.status_code == 200 assert response.status_code == 200
assert 'ergebnis' in response.context assert 'ergebnis' in response.context

View file

@ -35,6 +35,7 @@ MIDDLEWARE = [
'django.middleware.common.CommonMiddleware', 'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware', 'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware',
'vergabe_teilnahme.apps.accounts.middleware.PilotLoginRequiredMiddleware',
'django.contrib.messages.middleware.MessageMiddleware', 'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware',
] ]
@ -67,6 +68,9 @@ DATABASES = {
} }
AUTH_USER_MODEL = 'accounts.Mitarbeiter' AUTH_USER_MODEL = 'accounts.Mitarbeiter'
LOGIN_URL = 'accounts:login'
LOGIN_REDIRECT_URL = '/'
LOGOUT_REDIRECT_URL = 'accounts:login'
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'

View file

@ -0,0 +1,16 @@
{% load static %}
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Anmelden{% endblock %} · Vergabe Teilnahme</title>
<link rel="stylesheet" href="{% static 'dist/main.css' %}">
</head>
<body class="bg-paper-2 min-h-screen text-ink flex items-center justify-center p-6">
<main class="w-full max-w-md bg-white border border-slate-200 rounded-lg p-8 shadow-sm">
<p class="text-brand-700 font-semibold text-lg mb-6">Vergabe Teilnahme</p>
{% block content %}{% endblock %}
</main>
</body>
</html>

View file

@ -0,0 +1,13 @@
{% extends "accounts/base.html" %}
{% block content %}
<h1 class="text-xl font-semibold mb-2">Anmelden</h1>
<p class="text-sm text-slate-600 mb-6">Melden Sie sich mit Ihrem eingerichteten Benutzerkonto an.</p>
<form method="post" action="{% url 'accounts:login' %}"
class="space-y-4 [&_label]:block [&_label]:mb-1 [&_.errorlist]:text-red-700">
{% csrf_token %}
{{ form.as_p }}
<input type="hidden" name="next" value="{{ next }}">
<button type="submit" class="btn-primary w-full">Anmelden</button>
</form>
<p class="text-sm text-slate-500 mt-6">Für Zugang oder ein neues Passwort wenden Sie sich bitte an Ihre Ansprechperson für den Pilotbetrieb.</p>
{% endblock %}

View file

@ -0,0 +1,11 @@
{% extends "accounts/base.html" %}
{% block title %}Passwort ändern{% endblock %}
{% block content %}
<h1 class="text-xl font-semibold mb-6">Passwort ändern</h1>
<form method="post" class="space-y-4 [&_label]:block [&_label]:mb-1 [&_.errorlist]:text-red-700 [&_.helptext]:text-sm">
{% csrf_token %}
{{ form.as_p }}
<button type="submit" class="btn-primary w-full">Passwort speichern</button>
</form>
<a href="{% url 'home' %}" class="block text-brand-700 mt-6">Zurück zur Übersicht</a>
{% endblock %}

View file

@ -0,0 +1,7 @@
{% extends "accounts/base.html" %}
{% block title %}Passwort gespeichert{% endblock %}
{% block content %}
<h1 class="text-xl font-semibold mb-2">Passwort gespeichert</h1>
<p class="mb-6">Ihr neues Passwort ist jetzt aktiv.</p>
<a href="{% url 'home' %}" class="btn-primary">Zur Übersicht</a>
{% endblock %}

View file

@ -39,8 +39,12 @@
<div class="px-3 py-2 text-xs text-slate-500 border-b border-slate-100"> <div class="px-3 py-2 text-xs text-slate-500 border-b border-slate-100">
{{ request.user.get_rolle_display|default:"Mitarbeiter" }} {{ request.user.get_rolle_display|default:"Mitarbeiter" }}
</div> </div>
<a href="/accounts/logout/" <a href="{% url 'accounts:password_change' %}"
class="block px-3 py-2 text-sm text-slate-700 hover:bg-slate-50">Abmelden</a> class="block px-3 py-2 text-sm text-slate-700 hover:bg-slate-50">Passwort ändern</a>
<form method="post" action="{% url 'accounts:logout' %}">
{% csrf_token %}
<button type="submit" class="block w-full text-left px-3 py-2 text-sm text-slate-700 hover:bg-slate-50">Abmelden</button>
</form>
</div> </div>
</div> </div>
</header> </header>

View file

@ -1,14 +1,15 @@
from django.contrib import admin from django.contrib import admin
from django.conf import settings from django.contrib.auth.decorators import login_not_required
from django.conf.urls.static import static
from django.http import JsonResponse from django.http import JsonResponse
from django.shortcuts import redirect from django.shortcuts import redirect
from django.urls import include, path from django.urls import include, path
from vergabe_teilnahme.apps.core import views as core_views from vergabe_teilnahme.apps.core import views as core_views
from vergabe_teilnahme.apps.dokumente.downloads import protected_media
from vergabe_teilnahme.apps.preise import views as preise_views from vergabe_teilnahme.apps.preise import views as preise_views
@login_not_required
def health(request): def health(request):
return JsonResponse({'status': 'ok'}) return JsonResponse({'status': 'ok'})
@ -21,6 +22,8 @@ handler404 = 'vergabe_teilnahme.apps.core.views.custom_404'
handler500 = 'vergabe_teilnahme.apps.core.views.custom_500' handler500 = 'vergabe_teilnahme.apps.core.views.custom_500'
urlpatterns = [ urlpatterns = [
path('accounts/', include('vergabe_teilnahme.apps.accounts.urls')),
path('media/<path:path>', protected_media, name='protected_media'),
path('admin/', admin.site.urls), path('admin/', admin.site.urls),
path('health/', health), path('health/', health),
path('', home, name='home'), path('', home, name='home'),
@ -49,6 +52,3 @@ urlpatterns = [
path('core/attrs/<int:content_type_id>/<int:object_id>/<int:attr_pk>/loeschen/', core_views.custom_attribute_loeschen, name='custom_attribute_loeschen'), path('core/attrs/<int:content_type_id>/<int:object_id>/<int:attr_pk>/loeschen/', core_views.custom_attribute_loeschen, name='custom_attribute_loeschen'),
path('core/attrs/<int:content_type_id>/<int:object_id>/<int:attr_pk>/sort/', core_views.custom_attribute_sort, name='custom_attribute_sort'), path('core/attrs/<int:content_type_id>/<int:object_id>/<int:attr_pk>/sort/', core_views.custom_attribute_sort, name='custom_attribute_sort'),
] ]
if settings.DEBUG:
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

View file

@ -8,7 +8,7 @@ status: blocked
owner: the-custodian owner: the-custodian
topic_slug: vergabe-teilnahme topic_slug: vergabe-teilnahme
created: "2026-09-08" created: "2026-09-08"
updated: "2026-09-08" updated: "2026-09-11"
related: [HFACT-WP-0001, REUSE-WP-0022] related: [HFACT-WP-0001, REUSE-WP-0022]
state_hub_workstream_id: "27a95f7b-cec4-50ca-8383-c1c95d996217" state_hub_workstream_id: "27a95f7b-cec4-50ca-8383-c1c95d996217"
--- ---
@ -69,8 +69,7 @@ state_hub_task_id: "6e428152-aad6-5184-8b1d-8860ec0b9ae6"
Coordinator: the-custodian; product acceptance: Bernd Worsch; deployment owner: Coordinator: the-custodian; product acceptance: Bernd Worsch; deployment owner:
railiance-apps and admitted Railiance placement owner. Prepare exact chart/image, railiance-apps and admitted Railiance placement owner. Prepare exact chart/image,
host, tenant, credential binding, existing-data disposition, backup/restore, host, tenant, credential binding, existing-data disposition, backup/restore,
rollback and UI acceptance evidence before release. Use an isolated internal rollback and UI acceptance evidence before release. Use the invited single-company pilot in VERGABE-WP-0019; existing `vergabe_db` is not test data and must not be relocated or
pilot first; existing `vergabe_db` is not test data and must not be relocated or
overwritten implicitly. No customer-ready claim until admitted release passes. overwritten implicitly. No customer-ready claim until admitted release passes.
Return the release and consumer evidence to HFACT-WP-0001-T06/T07. Return the release and consumer evidence to HFACT-WP-0001-T06/T07.
@ -107,3 +106,14 @@ T01/T02 are done; the workplan is blocked on T03's admitted customer release.
HFACT-WP-0001 consumes the source and artifact receipts without claiming a HFACT-WP-0001 consumes the source and artifact receipts without claiming a
current governed worker run. Forgejo PR 1 metadata needs its authenticated current governed worker run. Forgejo PR 1 metadata needs its authenticated
`manually-merged` receipt; source integration itself is verified on main. `manually-merged` receipt; source integration itself is verified on main.
## Invited-pilot sequencing decision — 2026-09-11
The user selected an invited pilot with manual onboarding and deferred pricing.
VERGABE-WP-0019 owns concrete product readiness; RAPPS-WP-0014 owns deployment
and recovery. Their preparation can advance independently of T03's governed
worker dependency. This record continues to own factory-produced delivery
acceptance, so a manually prepared customer pilot cannot falsely close HFACT
worker proof. The old main-fa9f082 image predates the required login gate and
must not be used as the invited-pilot release merely because its CI passed.

View file

@ -0,0 +1,109 @@
---
id: VERGABE-WP-0019
type: workplan
title: "Admit the first invited company pilot with protected access and recoverable data"
domain: communication
repo: vergabe-teilnahme
status: active
owner: the-custodian
topic_slug: vergabe-teilnahme
created: "2026-09-11"
updated: "2026-09-11"
related: [VERGABE-WP-0018, RAPPS-WP-0014, HFACT-WP-0001]
---
# Invited company pilot
## Confirm the bounded customer milestone
```task
id: VERGABE-WP-0019-T01
status: done
priority: high
assignee: the-custodian
```
User decision on 2026-09-11: start with an invited pilot, one company and several
users, manually onboarded. Pricing is a later improvement and is not an entry
gate. Use the existing single-company product: each admitted company has an
isolated deployment, database and data volumes. All active company members can
collaborate on the company's tenders; the existing v1 domain approval roles
remain descriptive. Django staff/superuser administration remains restricted.
External partners remain data objects. No public registration or shared-app
multitenancy is introduced.
Product readiness proceeds alongside the governed factory runtime. It does not
wait for the fourteen-day factory value study. VERGABE-WP-0018-T03 retains the
separate claim that the factory can produce and deliver the customer release;
HFACT-WP-0001-T05 still needs a natural governed worker trace.
## Require invited access across the UI and uploaded documents
```task
id: VERGABE-WP-0019-T02
status: done
priority: high
assignee: the-custodian
```
Use Django's default-deny login middleware with explicit health/login exemptions,
German login and password-change forms, CSRF-protected POST logout, and full-page
reauthentication for expired HTMX sessions. Protect uploaded files in production
and development, serve private attachments only within MEDIA_ROOT, and keep
operational issue state outside that downloadable root. Do not cache company
responses. Inactive users lose existing session access; ordinary members do not
gain Django administration privileges.
The 12 access regressions initially produced 9 failures, including actual
anonymous tender read/create. After the fix all 94 application tests pass
locally and in the existing container target; Vite assets build, 139 static
files collect, and migration drift is absent. New/changed access modules pass
Ruff. Source acceptance is not native customer admission.
## Publish and prove the isolated deployment and recovery contract
```task
id: VERGABE-WP-0019-T03
status: progress
priority: high
assignee: the-custodian
depends_on: [VERGABE-WP-0019-T02, RAPPS-WP-0014-T02, RAPPS-WP-0014-T03]
```
RAPPS-WP-0014 owns exact image/chart/host/namespace/database/Secret binding,
persistent media plus distinct issue-facade state, isolated restore rehearsal,
and rollback. The customer app owns live CI and immutable release evidence.
Obtain the exact company, user count, hostname and empty-vs-existing data choice
from the user; no password or user list is required in public work records.
The historical vergabe_db is not disposable and must not be overwritten.
On 2026-09-11 the checked Railiance cluster contains neither the historical
vergabe-teilnahme namespace nor any Deployment whose name/image contains
vergabe/teilnahme. Treat the old runbook as historical intent, not a live return.
Native placement and existing data inventory remain required before cutover.
## Accept onboarding, collaboration, recovery and support with pilot users
```task
id: VERGABE-WP-0019-T04
status: wait
priority: high
assignee: the-custodian
depends_on: [VERGABE-WP-0019-T03]
blocking_reason: "Await admitted company deployment, immutable release and demonstrated restore; company/host/data selection is pending."
```
Product acceptance: Bernd Worsch and the named company contact. Verify two
separate ordinary-user accounts can sign in, change passwords, create a tender
and lot, collaborate on tasks, upload/download a document, record a v1 domain
approval and submission, and report feedback. Verify anonymous access and a
revoked user fail, health remains available, and pod replacement preserves all
three data stores. Restore a backup into an isolated destination and repeat the
workflow without touching the live database. Record operator/support contact,
incident route, backup cadence/retention and demonstrated recovery time. Use
manual account creation/reset/deactivation through the admitted operator path;
never seed development accounts or put credentials in logs/workplans/chat.
A successful invited pilot does not claim shared multitenancy, paid subscriptions,
HA or autonomous production release. Pricing is outside this milestone and will
need a later explicit product decision.