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:
parent
56bf193193
commit
b7d7828f30
27 changed files with 584 additions and 111 deletions
15
SCOPE.md
15
SCOPE.md
|
|
@ -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-0001–WP-0017) implemented in sequence from project
|
workplans (WP-0001–WP-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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
42
docs/evidence/2026-09-11-invited-pilot-access.json
Normal file
42
docs/evidence/2026-09-11-invited-pilot-access.json
Normal 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
|
||||||
|
}
|
||||||
16
vergabe_teilnahme/apps/accounts/forms.py
Normal file
16
vergabe_teilnahme/apps/accounts/forms.py
Normal 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
|
||||||
21
vergabe_teilnahme/apps/accounts/middleware.py
Normal file
21
vergabe_teilnahme/apps/accounts/middleware.py
Normal 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
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
21
vergabe_teilnahme/apps/accounts/urls.py
Normal file
21
vergabe_teilnahme/apps/accounts/urls.py
Normal 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'),
|
||||||
|
]
|
||||||
|
|
@ -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'
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
23
vergabe_teilnahme/apps/dokumente/downloads.py
Normal file
23
vergabe_teilnahme/apps/dokumente/downloads.py
Normal 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)
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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'
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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'
|
||||||
|
|
||||||
|
|
|
||||||
16
vergabe_teilnahme/templates/accounts/base.html
Normal file
16
vergabe_teilnahme/templates/accounts/base.html
Normal 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>
|
||||||
13
vergabe_teilnahme/templates/accounts/login.html
Normal file
13
vergabe_teilnahme/templates/accounts/login.html
Normal 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 %}
|
||||||
11
vergabe_teilnahme/templates/accounts/password_change.html
Normal file
11
vergabe_teilnahme/templates/accounts/password_change.html
Normal 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 %}
|
||||||
|
|
@ -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 %}
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
109
workplans/VERGABE-WP-0019-invited-company-pilot.md
Normal file
109
workplans/VERGABE-WP-0019-invited-company-pilot.md
Normal 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.
|
||||||
Loading…
Add table
Add a link
Reference in a new issue