Vergabe Teilnahme
+ {% block content %}{% endblock %} +diff --git a/SCOPE.md b/SCOPE.md index 33739a6..31c7c85 100644 --- a/SCOPE.md +++ b/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 the user. Initial delivery preserves a single-company boundary; customer release 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 @@ -60,7 +60,7 @@ in VERGABE-WP-0018. The original internal collaboration workflows remain valid. - External user accounts for partners/subcontractors/service providers (data 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, email, or calendars (deliberately manual-entry-first for v1) - 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 workplans (WP-0001–WP-0017) implemented in sequence from project 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) - 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 convention. Refer to `wiki/ProductRequirementsDocument.md` for the 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. diff --git a/conftest.py b/conftest.py index 709e1cc..1c0f5cd 100644 --- a/conftest.py +++ b/conftest.py @@ -8,3 +8,10 @@ def mitarbeiter(db): return Mitarbeiter.objects.create_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 diff --git a/docs/delivery.md b/docs/delivery.md index d14a477..6a47d16 100644 --- a/docs/delivery.md +++ b/docs/delivery.md @@ -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. Existing production tender data is never disposable test data. A passing source 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. diff --git a/docs/evidence/2026-09-11-invited-pilot-access.json b/docs/evidence/2026-09-11-invited-pilot-access.json new file mode 100644 index 0000000..cd09e5f --- /dev/null +++ b/docs/evidence/2026-09-11-invited-pilot-access.json @@ -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 +} diff --git a/vergabe_teilnahme/apps/accounts/forms.py b/vergabe_teilnahme/apps/accounts/forms.py new file mode 100644 index 0000000..7a612bd --- /dev/null +++ b/vergabe_teilnahme/apps/accounts/forms.py @@ -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 diff --git a/vergabe_teilnahme/apps/accounts/middleware.py b/vergabe_teilnahme/apps/accounts/middleware.py new file mode 100644 index 0000000..0aaa6ed --- /dev/null +++ b/vergabe_teilnahme/apps/accounts/middleware.py @@ -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 diff --git a/vergabe_teilnahme/apps/accounts/tests.py b/vergabe_teilnahme/apps/accounts/tests.py index 7ce503c..62416e2 100644 --- a/vergabe_teilnahme/apps/accounts/tests.py +++ b/vergabe_teilnahme/apps/accounts/tests.py @@ -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 diff --git a/vergabe_teilnahme/apps/accounts/urls.py b/vergabe_teilnahme/apps/accounts/urls.py new file mode 100644 index 0000000..946e92c --- /dev/null +++ b/vergabe_teilnahme/apps/accounts/urls.py @@ -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'), +] diff --git a/vergabe_teilnahme/apps/aufgaben/tests.py b/vergabe_teilnahme/apps/aufgaben/tests.py index 0a789e0..62de51a 100644 --- a/vergabe_teilnahme/apps/aufgaben/tests.py +++ b/vergabe_teilnahme/apps/aufgaben/tests.py @@ -33,40 +33,40 @@ class BieterfragenFactory(factory.django.DjangoModelFactory): @pytest.mark.django_db -def test_aufgaben_liste_get(client): +def test_aufgaben_liste_get(admitted_client): a = AusschreibungFactory() url = reverse('ausschreibungen:aufgaben:liste', kwargs={'ausschreibung_id': a.pk}) - response = client.get(url) + response = admitted_client.get(url) assert response.status_code == 200 @pytest.mark.django_db -def test_aufgabe_neu_post(client): +def test_aufgabe_neu_post(admitted_client): a = AusschreibungFactory() 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 Aufgabe.objects.filter(ausschreibung=a, titel='Neue Aufgabe').exists() @pytest.mark.django_db -def test_aufgabe_status_htmx(client): +def test_aufgabe_status_htmx(admitted_client): aufgabe = AufgabeFactory() url = reverse('ausschreibungen:aufgaben:status', 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 aufgabe.refresh_from_db() assert aufgabe.status == 'erledigt' @pytest.mark.django_db -def test_ueberfaellige_aufgabe_auto_update(client): +def test_ueberfaellige_aufgabe_auto_update(admitted_client): from datetime import date, timedelta a = AusschreibungFactory() aufgabe = AufgabeFactory(ausschreibung=a, frist=date.today() - timedelta(days=1), status='offen') url = reverse('ausschreibungen:aufgaben:liste', kwargs={'ausschreibung_id': a.pk}) - client.get(url) + admitted_client.get(url) aufgabe.refresh_from_db() assert aufgabe.status == 'ueberfaellig' @@ -75,21 +75,21 @@ def test_ueberfaellige_aufgabe_auto_update(client): @pytest.mark.django_db -def test_bieterfrage_neu_prefill_anforderung(client): +def test_bieterfrage_neu_prefill_anforderung(admitted_client): a = AusschreibungFactory() anf = AnforderungFactory(ausschreibung=a) 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 str(anf.pk).encode() in response.content @pytest.mark.django_db -def test_bieterfrage_antwort_speichern(client): +def test_bieterfrage_antwort_speichern(admitted_client): bf = BieterfragenFactory(status='eingereicht') url = reverse('ausschreibungen:bieterfragen:antwort', 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 bf.refresh_from_db() assert bf.antwort == 'Die Antwort lautet 42.' @@ -116,7 +116,7 @@ def test_frist_effektiv_ohne_frist(): @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 django.utils import timezone a = AusschreibungFactory() @@ -124,7 +124,7 @@ def test_ueberfaellig_ohne_frist_nach_7_tagen(client): aufgabe = AufgabeFactory(ausschreibung=a, frist=None, status='offen') Aufgabe.objects.filter(pk=aufgabe.pk).update(erstellt_am=alte_erstellung) url = reverse('ausschreibungen:aufgaben:liste', kwargs={'ausschreibung_id': a.pk}) - client.get(url) + admitted_client.get(url) aufgabe.refresh_from_db() assert aufgabe.status == 'ueberfaellig' @@ -133,14 +133,14 @@ def test_ueberfaellig_ohne_frist_nach_7_tagen(client): @pytest.mark.django_db -def test_aufgaben_verknuepfung_erstellen(client): +def test_aufgaben_verknuepfung_erstellen(admitted_client): from django.contrib.contenttypes.models import ContentType aufgabe = AufgabeFactory() anf = AnforderungFactory(ausschreibung=aufgabe.ausschreibung) ct = ContentType.objects.get_for_model(anf) url = reverse('ausschreibungen:aufgaben:verknuepfung_neu', kwargs={'ausschreibung_id': aufgabe.ausschreibung_id, 'pk': aufgabe.pk}) - response = client.post(url, { + response = admitted_client.post(url, { 'ziel_typ': ct.pk, 'ziel_id': anf.pk, 'kommentar': 'Testverknüpfung', @@ -150,7 +150,7 @@ def test_aufgaben_verknuepfung_erstellen(client): @pytest.mark.django_db -def test_aufgaben_verknuepfung_loeschen(client): +def test_aufgaben_verknuepfung_loeschen(admitted_client): from django.contrib.contenttypes.models import ContentType aufgabe = AufgabeFactory() anf = AnforderungFactory(ausschreibung=aufgabe.ausschreibung) @@ -160,7 +160,7 @@ def test_aufgaben_verknuepfung_loeschen(client): ) url = reverse('ausschreibungen:aufgaben:verknuepfung_loeschen', 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 not AufgabenVerknuepfung.objects.filter(pk=vk.pk).exists() @@ -176,24 +176,24 @@ def tmp_issue_db(tmp_path, settings): @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() url = reverse('ausschreibungen:aufgaben:external_issue', 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 ExternalIssue.objects.filter(aufgabe=aufgabe, issue_facade_backend='local').exists() @pytest.mark.django_db -def test_external_issue_loeschen(client): +def test_external_issue_loeschen(admitted_client): aufgabe = AufgabeFactory() ei = ExternalIssue.objects.create( aufgabe=aufgabe, issue_facade_backend='local', issue_key='#1' ) url = reverse('ausschreibungen:aufgaben:external_issue_loeschen', 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 not ExternalIssue.objects.filter(pk=ei.pk).exists() @@ -250,11 +250,11 @@ def test_status_synchronisieren(tmp_issue_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() url = reverse('ausschreibungen:aufgaben:external_issue', 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 ei = ExternalIssue.objects.get(aufgabe=aufgabe) 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 -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 aufgabe = AufgabeFactory() 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', 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 ei.refresh_from_db() assert ei.sync_status == 'open' diff --git a/vergabe_teilnahme/apps/ausschreibungen/tests.py b/vergabe_teilnahme/apps/ausschreibungen/tests.py index b4b2731..c3a7ad2 100644 --- a/vergabe_teilnahme/apps/ausschreibungen/tests.py +++ b/vergabe_teilnahme/apps/ausschreibungen/tests.py @@ -54,47 +54,47 @@ def test_naechste_frist_none_when_past(): # --- View tests --- @pytest.mark.django_db -def test_liste_get(client): - response = client.get(reverse("ausschreibungen:liste")) +def test_liste_get(admitted_client): + response = admitted_client.get(reverse("ausschreibungen:liste")) assert response.status_code == 200 @pytest.mark.django_db -def test_neu_get(client): - response = client.get(reverse("ausschreibungen:neu")) +def test_neu_get(admitted_client): + response = admitted_client.get(reverse("ausschreibungen:neu")) assert response.status_code == 200 @pytest.mark.django_db -def test_neu_post_valid(client): +def test_neu_post_valid(admitted_client): 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 a = Ausschreibung.objects.get(titel="Neue Ausschreibung") assert response.url == reverse("ausschreibungen:detail", kwargs={"pk": a.pk}) @pytest.mark.django_db -def test_detail_get(client): +def test_detail_get(admitted_client): 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 @pytest.mark.django_db -def test_status_post(client): +def test_status_post(admitted_client): a = AusschreibungFactory(status=1) 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 a.refresh_from_db() assert a.status == 4 @pytest.mark.django_db -def test_status_htmx_returns_partial(client): +def test_status_htmx_returns_partial(admitted_client): a = AusschreibungFactory(status=1) 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 b"status-widget" in response.content diff --git a/vergabe_teilnahme/apps/bibliothek/tests.py b/vergabe_teilnahme/apps/bibliothek/tests.py index 740464f..44465ee 100644 --- a/vergabe_teilnahme/apps/bibliothek/tests.py +++ b/vergabe_teilnahme/apps/bibliothek/tests.py @@ -19,12 +19,12 @@ def test_nachweis_ist_abgelaufen_false_without_date(): @pytest.mark.django_db -def test_nachweis_liste_filter_abgelaufen(client): +def test_nachweis_liste_filter_abgelaufen(admitted_client): heute = date.today() Nachweis.objects.create(titel='AbgelaufenerNachweis', gueltig_bis=heute - timedelta(days=5)) Nachweis.objects.create(titel='NochAktuellerNachweis', gueltig_bis=heute + timedelta(days=100)) url = reverse('bibliothek:nachweise_liste') - response = client.get(url + '?tab=abgelaufen') + response = admitted_client.get(url + '?tab=abgelaufen') assert response.status_code == 200 content = response.content.decode() assert 'AbgelaufenerNachweis' in content @@ -32,7 +32,7 @@ def test_nachweis_liste_filter_abgelaufen(client): @pytest.mark.django_db -def test_entscheidungsregel_inaktiv_nicht_in_liste(client): +def test_entscheidungsregel_inaktiv_nicht_in_liste(admitted_client): Entscheidungsregel.objects.create( 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 ) url = reverse('bibliothek:entscheidungsregeln_liste') - response = client.get(url) + response = admitted_client.get(url) assert response.status_code == 200 content = response.content.decode() assert 'Aktive Regel' in content @@ -48,21 +48,21 @@ def test_entscheidungsregel_inaktiv_nicht_in_liste(client): @pytest.mark.django_db -def test_entscheidungsregel_toggle(client): +def test_entscheidungsregel_toggle(admitted_client): r = Entscheidungsregel.objects.create( regelname='Toggle-Regel', kategorie='ausschlusskriterium', empfehlung='teilnehmen', aktiv=True ) url = reverse('bibliothek:er_toggle', kwargs={'pk': r.pk}) - client.post(url) + admitted_client.post(url) r.refresh_from_db() assert r.aktiv is False @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') url = reverse('bibliothek:nachweis_version', kwargs={'pk': alt.pk}) - client.post(url, { + admitted_client.post(url, { 'titel': 'Zertifikat ISO', 'version': '1.0', 'sprache': 'de', diff --git a/vergabe_teilnahme/apps/dokumente/downloads.py b/vergabe_teilnahme/apps/dokumente/downloads.py new file mode 100644 index 0000000..e95b091 --- /dev/null +++ b/vergabe_teilnahme/apps/dokumente/downloads.py @@ -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) diff --git a/vergabe_teilnahme/apps/dokumente/tests.py b/vergabe_teilnahme/apps/dokumente/tests.py index 2e6d010..97e4c29 100644 --- a/vergabe_teilnahme/apps/dokumente/tests.py +++ b/vergabe_teilnahme/apps/dokumente/tests.py @@ -26,46 +26,46 @@ def _pdf_file(name='test.pdf'): @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 a = AusschreibungFactory() 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 Dokument.objects.filter(ausschreibung=a).exists() @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 a = AusschreibungFactory() url = reverse('ausschreibungen:dokumente:upload', kwargs={'ausschreibung_id': a.pk}) 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 not Dokument.objects.filter(ausschreibung=a).exists() @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.MAX_UPLOAD_SIZE = 10 a = AusschreibungFactory() url = reverse('ausschreibungen:dokumente:upload', kwargs={'ausschreibung_id': a.pk}) 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 not Dokument.objects.filter(ausschreibung=a).exists() @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 altes_dok = DokumentFactory(version='1.0') a = altes_dok.ausschreibung url = reverse('ausschreibungen:dokumente:neue_version', 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 altes_dok.refresh_from_db() assert altes_dok.status == 'ersetzt' @@ -73,12 +73,12 @@ def test_dokument_neue_version(client, tmp_path, settings): @pytest.mark.django_db -def test_dokument_finale_version(client): +def test_dokument_finale_version(admitted_client): dok = DokumentFactory(status='freigegeben') a = dok.ausschreibung url = reverse('ausschreibungen:dokumente:finale_version', kwargs={'ausschreibung_id': a.pk, 'pk': dok.pk}) - response = client.post(url) + response = admitted_client.post(url) assert response.status_code == 200 dok.refresh_from_db() assert dok.finale_abgabeversion is True diff --git a/vergabe_teilnahme/apps/lose/tests.py b/vergabe_teilnahme/apps/lose/tests.py index f10c3a1..f7a04bb 100644 --- a/vergabe_teilnahme/apps/lose/tests.py +++ b/vergabe_teilnahme/apps/lose/tests.py @@ -39,28 +39,28 @@ class NachweisFactory(factory.django.DjangoModelFactory): @pytest.mark.django_db -def test_lose_liste_get(client): +def test_lose_liste_get(admitted_client): a = AusschreibungFactory() url = reverse('ausschreibungen:lose:liste', kwargs={'ausschreibung_id': a.pk}) - response = client.get(url) + response = admitted_client.get(url) assert response.status_code == 200 @pytest.mark.django_db -def test_los_neu_post(client): +def test_los_neu_post(admitted_client): a = AusschreibungFactory() 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 Los.objects.filter(ausschreibung=a, losnummer='L01').exists() @pytest.mark.django_db -def test_los_detail_get(client): +def test_los_detail_get(admitted_client): los = LosFactory() url = reverse('ausschreibungen:lose:detail', kwargs={'ausschreibung_id': los.ausschreibung_id, 'los_pk': los.pk}) - response = client.get(url) + response = admitted_client.get(url) assert response.status_code == 200 @@ -68,21 +68,21 @@ def test_los_detail_get(client): @pytest.mark.django_db -def test_anforderung_neu_post_muss(client): +def test_anforderung_neu_post_muss(admitted_client): a = AusschreibungFactory() 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'}) assert response.status_code == 302 assert Anforderung.objects.filter(ausschreibung=a, titel='Neue Anforderung').exists() @pytest.mark.django_db -def test_anforderung_status_htmx(client): +def test_anforderung_status_htmx(admitted_client): anf = AnforderungFactory() url = reverse('ausschreibungen:lose:anforderung_status', 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') assert response.status_code == 200 anf.refresh_from_db() @@ -90,7 +90,7 @@ def test_anforderung_status_htmx(client): @pytest.mark.django_db -def test_ausschlusskriterium_banner_shown(client): +def test_ausschlusskriterium_banner_shown(admitted_client): a = AusschreibungFactory() AnforderungFactory( ausschreibung=a, @@ -98,17 +98,17 @@ def test_ausschlusskriterium_banner_shown(client): erfuellungsstatus='nicht_erfuellbar', ) url = reverse('ausschreibungen:entscheidung', kwargs={'pk': a.pk}) - response = client.get(url) + response = admitted_client.get(url) assert response.status_code == 200 assert b'Nicht erf\xc3\xbcllbare Ausschlusskriterien' in response.content @pytest.mark.django_db -def test_nachweis_zuordnen(client): +def test_nachweis_zuordnen(admitted_client): anf = AnforderungFactory() n = NachweisFactory() url = reverse('ausschreibungen:lose:nachweis_zuordnen', 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 anf.nachweise.filter(pk=n.pk).exists() diff --git a/vergabe_teilnahme/apps/marktbegleiter/tests.py b/vergabe_teilnahme/apps/marktbegleiter/tests.py index 065935e..5237589 100644 --- a/vergabe_teilnahme/apps/marktbegleiter/tests.py +++ b/vergabe_teilnahme/apps/marktbegleiter/tests.py @@ -12,11 +12,11 @@ def make_mb(name='TestBegleiter', **kwargs): @pytest.mark.django_db -def test_passage_anlegen_mit_score_10(client): +def test_passage_anlegen_mit_score_10(admitted_client): a = AusschreibungFactory() mb = make_mb() url = reverse('marktbegleiter:passagen:neu', kwargs={'ausschreibung_id': a.pk}) - response = client.post(url, { + response = admitted_client.post(url, { 'passage': 'Musterpassage aus dem Dokument', 'marktbegleiter': mb.pk, 'verlaesslichkeitsscore': 10, @@ -40,7 +40,7 @@ def test_passage_score_zu_hoch_validierungsfehler(): @pytest.mark.django_db -def test_auswertung_score_durchschnitt(client): +def test_auswertung_score_durchschnitt(admitted_client): a = AusschreibungFactory() mb = make_mb() Ausschreibungspassage.objects.create( @@ -50,14 +50,14 @@ def test_auswertung_score_durchschnitt(client): ausschreibung=a, marktbegleiter=mb, passage='P2', verlaesslichkeitsscore=6 ) url = reverse('marktbegleiter:auswertung', kwargs={'pk': mb.pk}) - response = client.get(url) + response = admitted_client.get(url) assert response.status_code == 200 content = response.content.decode() assert '7' in content @pytest.mark.django_db -def test_marktbegleiter_detail_zeigt_passagen(client): +def test_marktbegleiter_detail_zeigt_passagen(admitted_client): a = AusschreibungFactory() mb = make_mb(name='DetailBegleiter') Ausschreibungspassage.objects.create( @@ -65,6 +65,6 @@ def test_marktbegleiter_detail_zeigt_passagen(client): passage='Sichtbare Passage', verlaesslichkeitsscore=5 ) url = reverse('marktbegleiter:detail', kwargs={'pk': mb.pk}) - response = client.get(url) + response = admitted_client.get(url) assert response.status_code == 200 assert b'Sichtbare Passage' in response.content diff --git a/vergabe_teilnahme/apps/nachbetrachtung/tests.py b/vergabe_teilnahme/apps/nachbetrachtung/tests.py index 54f9ba5..6c41c89 100644 --- a/vergabe_teilnahme/apps/nachbetrachtung/tests.py +++ b/vergabe_teilnahme/apps/nachbetrachtung/tests.py @@ -38,11 +38,11 @@ def test_abgabe_vollstaendigkeit_mit_freigabe(): @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 a = AusschreibungFactory(status=9) url = reverse('ausschreibungen:nachbetrachtung:detail', kwargs={'ausschreibung_id': a.pk}) - response = client.post(url, { + response = admitted_client.post(url, { 'ergebnis': 'gewonnen', 'verlustgruende': '[]', }) @@ -53,21 +53,21 @@ def test_ergebnis_gewonnen_erstellt_kickoff_aufgabe(client): @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) 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() assert a.status == 11 @pytest.mark.django_db -def test_verlustgruende_json_gespeichert(client): +def test_verlustgruende_json_gespeichert(admitted_client): import json a = AusschreibungFactory(status=9) url = reverse('ausschreibungen:nachbetrachtung:detail', kwargs={'ausschreibung_id': a.pk}) 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) assert nb.verlustgruende[0]['grund'] == 'Zu teuer' assert nb.verlustgruende[0]['kategorie'] == 'preis' diff --git a/vergabe_teilnahme/apps/partner/tests.py b/vergabe_teilnahme/apps/partner/tests.py index f0284e6..1690c81 100644 --- a/vergabe_teilnahme/apps/partner/tests.py +++ b/vergabe_teilnahme/apps/partner/tests.py @@ -12,43 +12,43 @@ def make_sub(praeferenz='zugelassen', name='TestSub', **kwargs): @pytest.mark.django_db -def test_subunternehmer_zuordnung_zu_los(client): +def test_subunternehmer_zuordnung_zu_los(admitted_client): a = AusschreibungFactory() los = Los.objects.create(ausschreibung=a, losnummer='1', lostitel='Los 1') sub = make_sub() 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() @pytest.mark.django_db -def test_gesperrter_subunternehmer_im_suchmodal(client): +def test_gesperrter_subunternehmer_im_suchmodal(admitted_client): a = AusschreibungFactory() los = Los.objects.create(ausschreibung=a, losnummer='1', lostitel='Los 1') make_sub(praeferenz='gesperrt', name='GesperrterSub') 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 b'gesperrt' in response.content.lower() @pytest.mark.django_db -def test_subunternehmer_praeferenz_update(client): +def test_subunternehmer_praeferenz_update(admitted_client): sub = make_sub(praeferenz='zugelassen') url = reverse('partner:su_praeferenz', kwargs={'pk': sub.pk}) - client.post(url, {'praeferenz': 'bevorzugt'}) + admitted_client.post(url, {'praeferenz': 'bevorzugt'}) sub.refresh_from_db() assert sub.praeferenz == 'bevorzugt' @pytest.mark.django_db -def test_zuordnung_toggle_zusage(client): +def test_zuordnung_toggle_zusage(admitted_client): a = AusschreibungFactory() los = Los.objects.create(ausschreibung=a, losnummer='1', lostitel='Los 1') sub = make_sub() z = SubunternehmerZuordnung.objects.create(subunternehmer=sub, ausschreibung=a, los=los) assert z.zusage_vorhanden is False 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() assert z.zusage_vorhanden is True diff --git a/vergabe_teilnahme/apps/preise/tests.py b/vergabe_teilnahme/apps/preise/tests.py index 4b791d3..515468b 100644 --- a/vergabe_teilnahme/apps/preise/tests.py +++ b/vergabe_teilnahme/apps/preise/tests.py @@ -23,10 +23,10 @@ class PreispunktFactory(factory.django.DjangoModelFactory): @pytest.mark.django_db -def test_vergleichsgewicht_null_gespeichert(client): +def test_vergleichsgewicht_null_gespeichert(admitted_client): a = AusschreibungFactory() url = reverse('ausschreibungen:preise:neu', kwargs={'ausschreibung_id': a.pk}) - response = client.post(url, { + response = admitted_client.post(url, { 'leistungstyp': 'Test', 'konkrete_leistung': 'Leistung mit Gewicht 0', 'mengeneinheit': 'Stück', @@ -39,10 +39,10 @@ def test_vergleichsgewicht_null_gespeichert(client): @pytest.mark.django_db -def test_vergleichsgewicht_zu_gross_validation_error(client): +def test_vergleichsgewicht_zu_gross_validation_error(admitted_client): a = AusschreibungFactory() url = reverse('ausschreibungen:preise:neu', kwargs={'ausschreibung_id': a.pk}) - response = client.post(url, { + response = admitted_client.post(url, { 'leistungstyp': 'Test', 'konkrete_leistung': 'Leistung', 'mengeneinheit': 'Stück', @@ -71,10 +71,10 @@ def test_gewichteter_durchschnitt_berechnung(): @pytest.mark.django_db -def test_auswertung_view_200(client): +def test_auswertung_view_200(admitted_client): a = AusschreibungFactory() PreispunktFactory(ausschreibung=a, einzelpreis=Decimal('150.00')) 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 'ergebnis' in response.context diff --git a/vergabe_teilnahme/settings/base.py b/vergabe_teilnahme/settings/base.py index f9fc320..29105aa 100644 --- a/vergabe_teilnahme/settings/base.py +++ b/vergabe_teilnahme/settings/base.py @@ -35,6 +35,7 @@ MIDDLEWARE = [ 'django.middleware.common.CommonMiddleware', 'django.middleware.csrf.CsrfViewMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'vergabe_teilnahme.apps.accounts.middleware.PilotLoginRequiredMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', ] @@ -67,6 +68,9 @@ DATABASES = { } AUTH_USER_MODEL = 'accounts.Mitarbeiter' +LOGIN_URL = 'accounts:login' +LOGIN_REDIRECT_URL = '/' +LOGOUT_REDIRECT_URL = 'accounts:login' DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' diff --git a/vergabe_teilnahme/templates/accounts/base.html b/vergabe_teilnahme/templates/accounts/base.html new file mode 100644 index 0000000..efcde65 --- /dev/null +++ b/vergabe_teilnahme/templates/accounts/base.html @@ -0,0 +1,16 @@ +{% load static %} + + +
+ + +Vergabe Teilnahme
+ {% block content %}{% endblock %} +Melden Sie sich mit Ihrem eingerichteten Benutzerkonto an.
+ +Für Zugang oder ein neues Passwort wenden Sie sich bitte an Ihre Ansprechperson für den Pilotbetrieb.
+{% endblock %} diff --git a/vergabe_teilnahme/templates/accounts/password_change.html b/vergabe_teilnahme/templates/accounts/password_change.html new file mode 100644 index 0000000..834e3a9 --- /dev/null +++ b/vergabe_teilnahme/templates/accounts/password_change.html @@ -0,0 +1,11 @@ +{% extends "accounts/base.html" %} +{% block title %}Passwort ändern{% endblock %} +{% block content %} +Ihr neues Passwort ist jetzt aktiv.
+Zur Übersicht +{% endblock %} diff --git a/vergabe_teilnahme/templates/partials/topbar.html b/vergabe_teilnahme/templates/partials/topbar.html index cb34d4f..6e3be79 100644 --- a/vergabe_teilnahme/templates/partials/topbar.html +++ b/vergabe_teilnahme/templates/partials/topbar.html @@ -39,8 +39,12 @@