diff --git a/.custodian-brief.md b/.custodian-brief.md index 63d17cc..05958b6 100644 --- a/.custodian-brief.md +++ b/.custodian-brief.md @@ -2,26 +2,22 @@ # Custodian Brief — vergabe-teilnahme **Domain:** communication -**Last synced:** 2026-09-12 00:45 UTC +**Last synced:** 2026-08-26 18:23 UTC **State Hub:** http://127.0.0.1:8000 *(adjust if running on a remote machine)* ## Active Workstreams -### Admit the first invited company pilot with protected access and recoverable data -Progress: 3/6 done | workplan_id: `85b5f304-d497-5570-bebf-3a3669ef6a7d` +*(none — repo may need first-session setup)* -**Open tasks:** -- ! Accept onboarding, collaboration, recovery and support with pilot users `db807f96` - *(wait: Native tenant/user/password setup is verified. Await admitted application placement, tenant welcome/sign-in connection and demonstrated restore.)* -- ► Publish and prove the isolated deployment and recovery contract `9065d5cb` -- ► Connect identity setup to the company's welcome and sign-in page `455b33f9` +## Inbox Hygiene -### Establish verified delivery for the primary customer tender product -Progress: 2/3 done | workplan_id: `27a95f7b-cec4-50ca-8383-c1c95d996217` +**Stale unread:** 1 message(s) older than 3 day(s) — triage at session start. +**Missing thread_id:** 1 unread message(s) lack supersession chains. +- ! state-hub: [BREAKING] Task status canon: wait/todo/progress/done/cancel `d5ea5add` -**Open tasks:** -- ! Accept the first Railiance customer pilot release `6e428152` - *(wait: Image main-fa9f082 is tested and published. Await HFACT-WP-0001-T05 governed worker evidence plus exact Railiance tenant, deployment, database, storage, access and recovery acceptance.)* +## SCOPE Freshness + +- SCOPE.md says active but no open workplans remain — refresh Current State. --- ## MCP Orientation (when available) diff --git a/Dockerfile b/Dockerfile index 2c071ba..e741bbe 100644 --- a/Dockerfile +++ b/Dockerfile @@ -119,5 +119,4 @@ CMD ["gunicorn", "vergabe_teilnahme.wsgi:application", \ "--bind", "0.0.0.0:8000", \ "--workers", "3", \ "--access-logfile", "-", \ - "--access-logformat", "%(h)s %(m)s %(U)s %(s)s %(L)s", \ "--error-logfile", "-"] diff --git a/SCOPE.md b/SCOPE.md index 31c7c85..33739a6 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-0019 (invited pilot) and VERGABE-WP-0018 (factory delivery). The original internal collaboration workflows remain valid. +in VERGABE-WP-0018. The original internal collaboration workflows remain valid. ## In Scope @@ -60,7 +60,7 @@ in VERGABE-WP-0019 (invited pilot) and VERGABE-WP-0018 (factory delivery). The o - External user accounts for partners/subcontractors/service providers (data objects only in this build stage, no system access of their own) -- Shared-application multi-tenancy; invited companies use isolated deployments +- Multi-tenancy - 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-0019 (invited pilot) and VERGABE-WP-0018 (factory delivery). The o 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; Forgejo gates images with application/container acceptance +- Stability: evolving — manual-entry-first v1; no CI workflow configured yet (`.gitea/`/`.forgejo/` absent) - Usage: internal collaboration tool, not yet published for external use @@ -159,12 +159,3 @@ 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/WORK-RECORDS.md b/WORK-RECORDS.md index 670a241..f942cfb 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -8,8 +8,6 @@ | Kind | ID | Status | Lane | Source | | --- | --- | --- | --- | --- | -| workplan | VERGABE-WP-0018 | blocked | — | workplans/VERGABE-WP-0018-customer-factory-delivery.md | -| workplan | VERGABE-WP-0019 | active | — | workplans/VERGABE-WP-0019-invited-company-pilot.md | | workplan | VT-WP-0001 | finished | — | workplans/VT-WP-0001-projektgeruest.md | | workplan | VT-WP-0002 | finished | — | workplans/VT-WP-0002-fachmodelle.md | | workplan | VT-WP-0003 | finished | — | workplans/VT-WP-0003-basis-ui.md | @@ -27,15 +25,6 @@ | workplan | VT-WP-0015 | finished | — | workplans/VT-WP-0015-aufgaben-verknuepfungen-frist-issuefacade.md | | workplan | VT-WP-0016 | finished | — | workplans/VT-WP-0016-issue-facade-integration.md | | workplan | VT-WP-0017 | finished | — | workplans/VT-WP-0017-whynot-design-tokens.md | -| task | VERGABE-WP-0018-T01 | done | — | workplans/VERGABE-WP-0018-customer-factory-delivery.md | -| task | VERGABE-WP-0018-T02 | done | — | workplans/VERGABE-WP-0018-customer-factory-delivery.md | -| task | VERGABE-WP-0018-T03 | wait | — | workplans/VERGABE-WP-0018-customer-factory-delivery.md | -| task | VERGABE-WP-0019-T01 | done | — | workplans/VERGABE-WP-0019-invited-company-pilot.md | -| task | VERGABE-WP-0019-T02 | done | — | workplans/VERGABE-WP-0019-invited-company-pilot.md | -| task | VERGABE-WP-0019-T03 | progress | — | workplans/VERGABE-WP-0019-invited-company-pilot.md | -| task | VERGABE-WP-0019-T04 | wait | — | workplans/VERGABE-WP-0019-invited-company-pilot.md | -| task | VERGABE-WP-0019-T05 | done | — | workplans/VERGABE-WP-0019-invited-company-pilot.md | -| task | VERGABE-WP-0019-T06 | progress | — | workplans/VERGABE-WP-0019-invited-company-pilot.md | | task | VT-WP-0001-T03 | done | — | workplans/VT-WP-0001-projektgeruest.md | | task | VT-WP-0001-T05 | done | — | workplans/VT-WP-0001-projektgeruest.md | | task | VT-WP-0001-T07 | done | — | workplans/VT-WP-0001-projektgeruest.md | diff --git a/conftest.py b/conftest.py index 1c0f5cd..709e1cc 100644 --- a/conftest.py +++ b/conftest.py @@ -8,10 +8,3 @@ 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 c99ead0..d14a477 100644 --- a/docs/delivery.md +++ b/docs/delivery.md @@ -58,40 +58,3 @@ 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. - - -## Published invited-pilot candidate — 2026-09-11 - -Source `ccb6d1f8c7abd50f2ef654491bb1aa5861e795cb` (implementation `b7d7828`) -passed live application CI 29 and image publication 31. The registry resolves -`main-ccb6d1f` to -`sha256:963240ef4180a01e5b8af7fbeb88be27e56203a45da8ecd633326b4c6d262005`. -The published image contains the invited login/private-download gate. Exact -company/host/data binding, native deployment, coherent restore and two-user -acceptance remain open. See `docs/evidence/2026-09-11-invited-pilot-release.json` -for source, CI and artifact receipts; this candidate has not been deployed. diff --git a/docs/evidence/2026-09-11-invited-pilot-access.json b/docs/evidence/2026-09-11-invited-pilot-access.json deleted file mode 100644 index cd09e5f..0000000 --- a/docs/evidence/2026-09-11-invited-pilot-access.json +++ /dev/null @@ -1,42 +0,0 @@ -{ - "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/docs/evidence/2026-09-11-invited-pilot-release.json b/docs/evidence/2026-09-11-invited-pilot-release.json deleted file mode 100644 index 6e1657c..0000000 --- a/docs/evidence/2026-09-11-invited-pilot-release.json +++ /dev/null @@ -1,56 +0,0 @@ -{ - "schema": "vergabe.invited-pilot-release.v1", - "source_commit": "ccb6d1f8c7abd50f2ef654491bb1aa5861e795cb", - "implementation_commit": "b7d7828", - "image": "forgejo.coulomb.social/coulomb/vergabe-teilnahme@sha256:963240ef4180a01e5b8af7fbeb88be27e56203a45da8ecd633326b4c6d262005", - "tag": "main-ccb6d1f", - "registry_resolution": "docker buildx imagetools inspect; Docker distribution manifest v2", - "live_ci": [ - { - "id": 7511, - "name": "build-and-push", - "head_branch": "main", - "head_sha": "ccb6d1f8c7abd50f2ef654491bb1aa5861e795cb", - "run_number": 31, - "event": "push", - "display_title": "repo.work.assign_missing_identifiers", - "status": "success", - "workflow_id": "image.yaml", - "url": "https://forgejo.coulomb.social/coulomb/vergabe-teilnahme/actions/runs/31", - "created_at": "2026-09-11T14:36:48Z", - "updated_at": "2026-09-11T14:37:30Z", - "run_started_at": "2026-09-11T14:36:48Z" - }, - { - "id": 7508, - "name": "application-tests", - "head_branch": "main", - "head_sha": "ccb6d1f8c7abd50f2ef654491bb1aa5861e795cb", - "run_number": 29, - "event": "push", - "display_title": "repo.work.assign_missing_identifiers", - "status": "success", - "workflow_id": "application-tests.yaml", - "url": "https://forgejo.coulomb.social/coulomb/vergabe-teilnahme/actions/runs/29", - "created_at": "2026-09-11T14:35:34Z", - "updated_at": "2026-09-11T14:36:43Z", - "run_started_at": "2026-09-11T14:35:34Z" - } - ], - "candidate_status": "tested_and_published_pending_company_placement_and_recovery", - "native_deployment": false, - "customer_data_used": false, - "published_runtime_smoke": { - "passed": true, - "uid": 999, - "health": 200, - "login": 200, - "anonymous_tender": 302, - "anonymous_media": 302, - "built_css": 200, - "pytest_in_runtime": false, - "settings": "vergabe_teilnahme.settings.prod", - "database": "disposable memory", - "native_admission": false - } -} diff --git a/docs/evidence/2026-09-11-tenant-path-browser.json b/docs/evidence/2026-09-11-tenant-path-browser.json deleted file mode 100644 index 9c10003..0000000 --- a/docs/evidence/2026-09-11-tenant-path-browser.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "status": "passed", - "checks": [ - "Edge matches only the exact company prefix", - "Bare company path canonicalizes and anonymous access reaches prefixed login", - "Prefixed CSS and JavaScript load and render the login controls", - "Real CSRF login, cookie scope and dashboard navigation stay within the tenant path", - "Password-change navigation and CSRF logout preserve the prefix", - "Expired HTMX requests demand full prefixed login", - "Mobile login fits and browser has no JavaScript errors" - ], - "synthetic_users": true, - "disposable_sqlite": true, - "local_http_only": true, - "production_edge_admitted": false -} diff --git a/docs/netkingdom-sign-in.md b/docs/netkingdom-sign-in.md deleted file mode 100644 index c7e0dbf..0000000 --- a/docs/netkingdom-sign-in.md +++ /dev/null @@ -1,84 +0,0 @@ -# Company welcome and NetKingdom sign-in - -VERGABE-WP-0019-T06. The isolated company instance consumes Authorization Code -with S256 PKCE and a browser-bound state and nonce. It discovers endpoints from -the configured HTTPS issuer, pins RS256, verifies signature/issuer/audience, -time and nonce, and uses a unique `(issuer, subject)` record for the local user. -It never merges accounts by email or directory display name. - -Admission requires the exact configured tenant, `tenant_source: directory`, a -human principal and the signed `:users` membership group. Suspended, -platform and emergency identities fail. This is the existing invited-company -pilot rule: admitted members collaborate on all company tenders. Tenant-admin -claims do not grant Django staff or superuser rights. New local users have no -usable local password. Local deactivation immediately blocks existing sessions; -provider-side revocation is bounded by the product's absolute five-minute -session maximum (or the token expiry, whichever comes first). - -The welcome page never consumes password-setup tokens. A CSRF-protected sign-in -action clears the current product session and requests `prompt=login`. A valid -callback displays the verified account and requires another CSRF-protected -confirmation before creating a product session. External `next` and `return_to` -parameters are not used. The callback redirects to a clean URL before displaying -identity. Gunicorn access logging records the URL path without query strings. -Passwords and bearer tokens are not persisted in the product. - -## Exact demo binding - -Enable only with the registered client and the verified KeyCape fresh-login -release. The initial `dcebd46` issuer lacked `tenant_source` and fresh-login propagation. -The approved 2026-09-12 rollout replaced it with source 8d4336e; live forwarding -and the company entry are verified. Native recipient/MFA acceptance remains open. - -``` -NETKINGDOM_ENABLED=true -NETKINGDOM_ISSUER=https://kc.coulomb.social -NETKINGDOM_CLIENT_ID=vergabe-demo-company -NETKINGDOM_CALLBACK=https://vergabe-teilnahme.coulomb.social/demo-company/accounts/oidc/callback/ -NETKINGDOM_TENANT=tenant:trial:demo-company -COMPANY_DISPLAY_NAME=Demo Company -``` - -Use a public client with only `openid profile groups`, `authorization_code`, -and the exact callback above. Do not add a client-declared tenant or a weaker -MFA override. Keep the existing provider MFA policy. The application namespace -requires narrowly scoped HTTPS egress to the issuer; its existing policy only -allows database traffic and DNS. - -NetKingdom's password setup maps `tenant:trial:demo-company` to the fixed HTTPS -company entry in `PASSWORD_SETUP_TENANT_RETURNS`. That return is stored inside -the setup grant at issuance and released only after successful consumption. -There is no browser-supplied return address, recipient credential transfer, -automatic product login or modification to the operator's portal session. -Existing setup grants have no new destination; issue a fresh link if needed. - -## Release and acceptance - -Run application tests, assets build and migration drift checks, publish the -exact source, and pin its digest in the existing Railiance pilot values. -Migration 0003 adds only the unique identity mapping table. Keep the accepted -60m CPU / 256Mi memory request and both data claims. Disabling -NETKINGDOM_ENABLED restores the interim local login; OIDC-created users still -have unusable local passwords, so rollback does not manufacture credentials. -Keep the added table when reverting an application image. - -KeyCape's current main also contains previously accepted startup and token -issuance changes after deployed source dcebd46; validate the live registration -configuration before upgrading (key-cape/docs/operations.md). Existing login -attempts are process-local and are lost on its single-replica Recreate rollout. -Password-setup links are likewise process-local and expire on provisioner restart. - -Native acceptance still requires the actual invited user to complete setup, -fresh sign-in and any provider-required MFA, confirm their account, and enter -the company workflow. Do not substitute an operator session. Recovery and -two-user workflow acceptance remain the existing RAPPS-WP-0014-T03 and -VERGABE-WP-0019-T04 tasks. - -## Rejected-login recovery - -Rejected callbacks and unusable confirmations clear pending identity state and -redirect to `NETKINGDOM_ACCOUNT_PORTAL_URL` + `/access-recovery` (default canonical -users.coulomb.social). The response is no-store and no-referrer; callback codes, -state and unverified identity are never forwarded. The central portal offers -verified account details and confirmed shared sign-out. Admission, CSRF, tenant, -principal and MFA rules remain enforced before any product account is created. diff --git a/docs/tenant-path-deployment.md b/docs/tenant-path-deployment.md deleted file mode 100644 index d937914..0000000 --- a/docs/tenant-path-deployment.md +++ /dev/null @@ -1,31 +0,0 @@ -# Deploy an isolated company below the product hostname - -For the demo pilot, use `https://vergabe-teilnahme.coulomb.social/demo-company/`. -Configure the isolated application with `APP_BASE_PATH=/demo-company` and -`ALLOWED_HOSTS=vergabe-teilnahme.coulomb.social`. The external CSRF origin is -`https://vergabe-teilnahme.coulomb.social`, without a path. - -The edge matches exactly `/demo-company` or the `/demo-company/` path prefix, -redirects the bare path to its trailing-slash form, and strips `/demo-company` -before forwarding. Internal health remains `/health/`. Django's fixed -FORCE_SCRIPT_NAME generates the external URLs. Untrusted forwarded-prefix -headers never select a tenant or override this configuration. Keep static and -private media requests on this same application route. - -Each tenant keeps its own namespace, database, media volume and issue-state -volume. A URL path is a deployment selector, not an authentication claim. -Cookies use company-specific names and paths; cookies and paths are not separate -browser origins. Only this trusted product's instances share the product host. -Django accounts still provide product admission; native platform tenant creation -and membership do not by themselves implement product SSO. - -Omit APP_BASE_PATH for the existing root-path deployment behavior. Nonempty -values must be one lowercase tenant slug prefixed with `/`; traversal, nested -paths and external URLs are refused at startup. - -Validation: 94 existing application tests plus four tenant-path regressions; -Vite asset build; seven local Chromium checks through an exact-path/strip-prefix -edge fixture. Authentication/CSRF, password changes, navigation/HTMX, private -media, cookie scope, assets and mobile layout are covered. These checks use a -disposable SQLite database and synthetic users, and do not establish live DNS, -TLS, database custody or restore readiness. RAPPS-WP-0014 owns those live gates. diff --git a/pyproject.toml b/pyproject.toml index 189313a..687832b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,7 +12,6 @@ dependencies = [ "dj-database-url>=2.1", "issue-core>=0.2,<0.3", "gunicorn>=22.0", - "PyJWT[crypto]>=2.10,<3", ] [tool.uv.sources] diff --git a/uv.lock b/uv.lock index b4ad5e5..231296d 100644 --- a/uv.lock +++ b/uv.lock @@ -61,91 +61,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707 }, ] -[[package]] -name = "cffi" -version = "2.1.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pycparser", marker = "implementation_name != 'PyPy'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/10/69/43965eccfdead3b9220015fd1320e117be8c6ed01a62ffab76eeb752f5d5/cffi-2.1.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0", size = 184821 }, - { url = "https://files.pythonhosted.org/packages/54/7d/16e5a096677b5e313ca80cd5e5170efa3ea44624a82bb111925522da64b1/cffi-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf", size = 184719 }, - { url = "https://files.pythonhosted.org/packages/56/e6/8941622732edec876dd17d0453dce07317ae96db34f2ec1436c9d3785986/cffi-2.1.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a", size = 214799 }, - { url = "https://files.pythonhosted.org/packages/44/de/f98430906df1545ffde0d543dd124a7a439bc2cd32b36b9c53f805df7333/cffi-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890", size = 222389 }, - { url = "https://files.pythonhosted.org/packages/6a/5b/717f1526b9957b34456313c31645c5b82b8fb5c3fe9e4752999be7128bfc/cffi-2.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50", size = 210249 }, - { url = "https://files.pythonhosted.org/packages/64/b3/f8aa4f3e34986c7e4ec45072d1b1b9dd295b6b18007b45518d79726dd725/cffi-2.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e", size = 208775 }, - { url = "https://files.pythonhosted.org/packages/b1/db/dceb9dd5b231e1da801793f8acc9f3c52a7e1afe40bb1aae37e02b0faad5/cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf", size = 221822 }, - { url = "https://files.pythonhosted.org/packages/a0/d2/6cd24ae3be000a634109c247d1475d62e5616d0dc78c82770942ec384248/cffi-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517", size = 225232 }, - { url = "https://files.pythonhosted.org/packages/cb/52/3fa190537004dd7f0ab860a6dc7c0175b8667f68d1e618a46f5498d30250/cffi-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735", size = 223597 }, - { url = "https://files.pythonhosted.org/packages/80/fb/0bb75b7039588c074b37ae99f40d9bfddf990ecb2fbc346ebccd2e56b9be/cffi-2.1.1-cp312-cp312-win32.whl", hash = "sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e", size = 175292 }, - { url = "https://files.pythonhosted.org/packages/d9/79/615cc094e2fb508cade7de88d3b4f6c4ec2bab695c97bce9153dc65aadf5/cffi-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a", size = 185919 }, - { url = "https://files.pythonhosted.org/packages/70/c6/d0ea84713fe46b243a436a18fcd47d639732747e21635c8a27191b06dc30/cffi-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80", size = 180093 }, - { url = "https://files.pythonhosted.org/packages/9d/f4/035513d4117049066b4779dc3b7c0c0fdad175fa13731c9f4003f1cd1478/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e", size = 194248 }, - { url = "https://files.pythonhosted.org/packages/76/af/2aeb4dbb5fc41a04161ae9ff1518de7cec08e164f44a8ce6a4cf7fd2cd1d/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c", size = 196908 }, - { url = "https://files.pythonhosted.org/packages/a7/46/2e5fdde8555706dd98139a910ca11be02809f3f605ce956f655d0214e100/cffi-2.1.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6", size = 184805 }, - { url = "https://files.pythonhosted.org/packages/55/41/4c7042f317b9217502988f0873af87e16ad606dc20f84e546e3e6ce9764c/cffi-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971", size = 184764 }, - { url = "https://files.pythonhosted.org/packages/43/1f/1c3d90d91811c8f86ced9ed637956c54bfe5b79ca98fe976d7f8c8979f6b/cffi-2.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c", size = 214722 }, - { url = "https://files.pythonhosted.org/packages/37/6f/3b5ce4c3b2192d250f04908f2bfd91ef34552ec8f7716a5d4abdb8d67bb2/cffi-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125", size = 222369 }, - { url = "https://files.pythonhosted.org/packages/02/10/4b3c75dde3d9663c9e02ba05c2668b954f671d4bbe346413ca8c696b295a/cffi-2.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264", size = 210175 }, - { url = "https://files.pythonhosted.org/packages/df/62/14f74b9543e605d17701dc797b815958b8bb70b7624ce1b832ddad48ed6c/cffi-2.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3", size = 208670 }, - { url = "https://files.pythonhosted.org/packages/95/95/86342356ff5953b3fb06f7ef7c5bee212d45e770abc7218d451b9148313c/cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2", size = 221824 }, - { url = "https://files.pythonhosted.org/packages/eb/ff/7b3429ff53aafe931ed8a5fc69f481bbef7ba6de87ddcbb63d08f483f613/cffi-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b", size = 225148 }, - { url = "https://files.pythonhosted.org/packages/34/34/a95870b9221e09cf4f2ce3178b1a210abdfe63a1bd357da940418d7b8d15/cffi-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7", size = 223564 }, - { url = "https://files.pythonhosted.org/packages/70/ea/839b50531021a647fb5e929f72cf97bc1ff702b5472166164b5b6e76b851/cffi-2.1.1-cp313-cp313-win32.whl", hash = "sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac", size = 175263 }, - { url = "https://files.pythonhosted.org/packages/60/a6/8b149b2c3f2e11aaa1618ef64500b45f50f22c57a977a4dff1aff1f91042/cffi-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d", size = 185688 }, - { url = "https://files.pythonhosted.org/packages/01/9a/11f687cb39d6a3504060d5242f04f48c735afb4d3d533958a20594890cb2/cffi-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973", size = 180078 }, - { url = "https://files.pythonhosted.org/packages/d3/7b/d6bbf82b8b96e7391438898c42f5bd96dd02030fd5b64937d248220003e2/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c", size = 194064 }, - { url = "https://files.pythonhosted.org/packages/94/e6/bcc91b283be94735e268487a054004f0aa19947b6348fa367db53230abc8/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb", size = 196720 }, - { url = "https://files.pythonhosted.org/packages/d9/99/c4b0c17cacdc9c3b8f280026286a9826d6a208c0f047591a3c3ce99b91fd/cffi-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54", size = 184964 }, - { url = "https://files.pythonhosted.org/packages/b3/a9/9db617d05d7367c1ad0ab00b3aa6e6f9281edd689b4ee9ea0e5a84e89c97/cffi-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72", size = 184962 }, - { url = "https://files.pythonhosted.org/packages/67/b8/b42132ca113dc567d37684437b46ca1dafc885902b02a110a02d5b511857/cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1", size = 222328 }, - { url = "https://files.pythonhosted.org/packages/80/10/c5c0cbf0a657aecf59ef511409734230bf556f05a0d6c9eed7aa5c0a0166/cffi-2.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062", size = 209985 }, - { url = "https://files.pythonhosted.org/packages/d5/6c/bfa0b87b03b9238148beca990292843c9396ba069b54496596594173de7b/cffi-2.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03", size = 208530 }, - { url = "https://files.pythonhosted.org/packages/e9/02/4e7d553a7ac4b4238b38b3c1b80d486e9d4436f8d2acbf87a0997fe3f402/cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96", size = 221525 }, - { url = "https://files.pythonhosted.org/packages/82/1d/a4aaf9babd75acb4d5f223bff71533bee748dd770a382619a798960ee9ba/cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527", size = 225053 }, - { url = "https://files.pythonhosted.org/packages/81/10/5dc0e7bdd18e22107054288283380fc97a06ae3f1656a106908d666a3c88/cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13", size = 223213 }, - { url = "https://files.pythonhosted.org/packages/0b/e9/d0061c364cde06ee43168a0d076ac1da512cbc380d44767b844ba34fe2b6/cffi-2.1.1-cp314-cp314-win32.whl", hash = "sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c", size = 177682 }, - { url = "https://files.pythonhosted.org/packages/a7/06/1c3e01e3ba14c39f6d10bfbac52753b7e22259e38088e5cfe1d704918690/cffi-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48", size = 187949 }, - { url = "https://files.pythonhosted.org/packages/87/5b/da4e39efe18eeb89cf580ea9cfc66b6a7c3eadb808fc0cc1d3a295cb5a5d/cffi-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836", size = 182947 }, - { url = "https://files.pythonhosted.org/packages/23/59/40338bf421c5accea1d45158170c87006ef1cd371b05c077e76476949728/cffi-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3", size = 188504 }, - { url = "https://files.pythonhosted.org/packages/7d/47/5ecf1023850036e674c77ec4de86182d309ae344e39e7cba984b7df5d647/cffi-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2", size = 188259 }, - { url = "https://files.pythonhosted.org/packages/2a/9c/92934c3bea9f785b23eba304538c0b4d37a2a96d2431eb3a1bc87a11aa19/cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94", size = 223864 }, - { url = "https://files.pythonhosted.org/packages/4d/45/ba4c93527bc38616a8bd36488acb69a2212d60486794f0c1f318949bbb76/cffi-2.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc", size = 211538 }, - { url = "https://files.pythonhosted.org/packages/80/e9/b6ef565e452acb932fb0cb5443f44a78efbd1233e566f02b5a83855e9115/cffi-2.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29", size = 210688 }, - { url = "https://files.pythonhosted.org/packages/9a/95/eff5f0cee78d2eabc7eebffec40d3fc1876b5f3c95582e018bb4b99601f2/cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676", size = 223803 }, - { url = "https://files.pythonhosted.org/packages/fa/01/579d39fb8bef00a335a23d83757b44feb24cd6345a2c451b64cb67b9c362/cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e", size = 226763 }, - { url = "https://files.pythonhosted.org/packages/8d/b0/0b44f47c60b01b57b6e2bbd92343f13a85a1d93bc46ccf6e47e244acd99c/cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f", size = 225688 }, - { url = "https://files.pythonhosted.org/packages/eb/d2/3b7176cb570a1d3e27faf67b72f591af508036e0d8b2be2ef9af9e8c84bb/cffi-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4", size = 182868 }, - { url = "https://files.pythonhosted.org/packages/56/78/31f00c1bcd97c9bbf55f1bfdf5bc809a5de8887473e90bb9960dca825e80/cffi-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e", size = 194104 }, - { url = "https://files.pythonhosted.org/packages/7b/1b/58496f2ed0a35de575250c02a43ab3cc2c04d494a88fed31c1cabc0fd176/cffi-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5", size = 186402 }, - { url = "https://files.pythonhosted.org/packages/c1/8f/9ebe220eab48a093d1a5a5e339ab0dc7316eef3bb04d63c42f0251b61f50/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d", size = 194043 }, - { url = "https://files.pythonhosted.org/packages/ff/69/844bad3ece306c4782c2ecb93597035b6690d48704b803914c199da1e8b3/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b", size = 196737 }, - { url = "https://files.pythonhosted.org/packages/1b/8a/af668013284634733f02d683458a0728739c7d6ddb5e14cb0c20832266fe/cffi-2.1.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4", size = 184933 }, - { url = "https://files.pythonhosted.org/packages/0c/75/2f5207ff6d1a613133b23a5203cc0c2a628313b5eb3974d7956ae3c57950/cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8", size = 185002 }, - { url = "https://files.pythonhosted.org/packages/e2/31/9e1313b0a6e30e91b3b3d3fff51ae99c857c07738e3afcce1f7334e1b7ab/cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6", size = 222271 }, - { url = "https://files.pythonhosted.org/packages/50/e3/f6234a833e6e08c7007003074723c406559eecf9b48dfc97471e5a8eb7a0/cffi-2.1.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80", size = 209919 }, - { url = "https://files.pythonhosted.org/packages/0d/fc/5f74e293fced6edb51af3a46c4ccf6c23c9943774ecb375ddbd522c76add/cffi-2.1.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779", size = 208529 }, - { url = "https://files.pythonhosted.org/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399", size = 221630 }, - { url = "https://files.pythonhosted.org/packages/a4/18/fa7f1f6857d5eb88a4ca99ffcbfb7c387a287ccc154c64a73e86314745d7/cffi-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688", size = 225134 }, - { url = "https://files.pythonhosted.org/packages/e0/9f/e8e3dfa04a1b4c241f8c91faacad872b4d4efd051d49764ad4e2fd4b9fea/cffi-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7", size = 223197 }, - { url = "https://files.pythonhosted.org/packages/f8/7e/8debeb04f1ab9fe2a6963964cd6f1aaf7192627b83926586a6a4e089c9fa/cffi-2.1.1-cp315-cp315-win32.whl", hash = "sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac", size = 177683 }, - { url = "https://files.pythonhosted.org/packages/e0/31/5158704cc474ab65c1647932e88be78dc0873f47130e253be38bcaf13d01/cffi-2.1.1-cp315-cp315-win_amd64.whl", hash = "sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960", size = 187897 }, - { url = "https://files.pythonhosted.org/packages/cc/4b/b3a2da8570c704ffc0f9762cdc3ec0f02c8573798e0b5cf7f11c82bbb70f/cffi-2.1.1-cp315-cp315-win_arm64.whl", hash = "sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1", size = 182935 }, - { url = "https://files.pythonhosted.org/packages/d0/ef/5443574510a1207e6f6bc38ba6e1f1de36cb48fef07b2728bb896a21f430/cffi-2.1.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc", size = 188464 }, - { url = "https://files.pythonhosted.org/packages/7e/ae/a56fa8c4686ad50e148fcbc8d3ae0d03915ff5c30d795058988c24118cef/cffi-2.1.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab", size = 188262 }, - { url = "https://files.pythonhosted.org/packages/53/b2/6187f46f2912276a3ae284076109cc5c8680482f11f766ccf26db4a86427/cffi-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e", size = 223779 }, - { url = "https://files.pythonhosted.org/packages/8a/f6/c3ad28bd19f77047a03084424fbd4cbe997303267c14423737324be0385d/cffi-2.1.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358", size = 211520 }, - { url = "https://files.pythonhosted.org/packages/a0/cd/ccac9013a5bd9fd764de118674ab9c805b5ca10c19270d90ee273f8b2240/cffi-2.1.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231", size = 210673 }, - { url = "https://files.pythonhosted.org/packages/52/86/2976131c639aead931c5bee5aba67e4b09fbeb8018b6f282f70803f923a7/cffi-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6", size = 223835 }, - { url = "https://files.pythonhosted.org/packages/ac/0c/33a7aeab2f9c76918c52e084beb39c570db3588133412929e8ec06fab90b/cffi-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94", size = 226705 }, - { url = "https://files.pythonhosted.org/packages/e3/26/2cde30fdde421130bfc18f70395731a6e6b2053c6a1978a5258ff04e72fa/cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5", size = 225539 }, - { url = "https://files.pythonhosted.org/packages/6d/cd/a361394c94b2129d604bb846f624a8e88255a3ee33129c434a00d715e64f/cffi-2.1.1-cp315-cp315t-win32.whl", hash = "sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66", size = 182707 }, - { url = "https://files.pythonhosted.org/packages/9b/b5/ba2b299993c26577d529b6ae29841f9e15b9fcf004d65f423f4fcf94ade9/cffi-2.1.1-cp315-cp315t-win_amd64.whl", hash = "sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3", size = 193772 }, - { url = "https://files.pythonhosted.org/packages/aa/29/35e016098c814cd93de9cd320c66b5bfba14dc6ecedd3cb518fa7c408c69/cffi-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692", size = 186360 }, -] - [[package]] name = "charset-normalizer" version = "3.4.7" @@ -324,56 +239,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9e/ee/a4cf96b8ce1e566ed238f0659ac2d3f007ed1d14b181bcb684e19561a69a/coverage-7.13.5-py3-none-any.whl", hash = "sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61", size = 211346 }, ] -[[package]] -name = "cryptography" -version = "50.0.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/bb/ad/5d6702db60b1e40b41ef513b6967ff5848f307d50f8449baf1634f5908f1/cryptography-50.0.1.tar.gz", hash = "sha256:5dd9bda1c12b4162f6ff568eeb5e0ff956c28d14406e875cfe8a63a2d414ff20", size = 880381 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ba/19/797e2aaac9df6a66f1550f49979dc1b1e39ecd2077501c30efa81e8d5d67/cryptography-50.0.1-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:b8f852c65863251b9e3a1b8c150ce21e59b522dbb6a7d4bc80e680d38388e986", size = 4010153 }, - { url = "https://files.pythonhosted.org/packages/90/34/9ce9a62ed9dc82ca9fd6a34445b6904af56e5f38b3eae2ed32e49c36053d/cryptography-50.0.1-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:53e279950892dc102c6b4e52af03ae5ea92fac572a1ddab78ca73a997f62b69f", size = 4723133 }, - { url = "https://files.pythonhosted.org/packages/57/26/e6d4fc8512a51a5f9ee7bfdbfb853bce1197087df40c9ad993ad370b846f/cryptography-50.0.1-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ff838d62ec1bfce4f9ba7fa16f4a7b554cd8d0c299e6be37502161a660c84eef", size = 4712478 }, - { url = "https://files.pythonhosted.org/packages/e6/de/d3cdc2815697aae84126cbd6a030ca7b6b452e28a88b501b836bd3aa7a86/cryptography-50.0.1-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e74591e283fe6eb956416c929eb58262a719fe0311fd9054c62c3350ed8760d8", size = 4730726 }, - { url = "https://files.pythonhosted.org/packages/55/32/38c0d344b98c06d34b5df8946565a9c0d6dbf32c8e0730a7f05f0a3c6cab/cryptography-50.0.1-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5fe002589592ed749ce77fe0695fcbd3500dd61d7d6db5858a7544c612fa8e45", size = 5353524 }, - { url = "https://files.pythonhosted.org/packages/e1/1b/82f0f0d8858d4432be1af790477edf62aef90324041aa07c57e57bef1af7/cryptography-50.0.1-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:51593d180cf6d179bde5c5d065bed81386b1f381656ae7d042b7ffc87a9895ad", size = 4746720 }, - { url = "https://files.pythonhosted.org/packages/29/ba/042ca458b8c64348c768284b5d23e69b92ed53d057ab779fee628564676d/cryptography-50.0.1-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:359e62deae718bce96170e223fdcb6357e4fbd3bb7a3a75f4430763532560e49", size = 4361866 }, - { url = "https://files.pythonhosted.org/packages/39/3b/e96c1ef71edef71057c7e3c3d982ce8fda554e0c52d0cc19c18845cde3eb/cryptography-50.0.1-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e2ca8fd1b6b4b82a1c4cb02841d0837e3c12336c2e24b520ab8ab3b969733d8f", size = 4730028 }, - { url = "https://files.pythonhosted.org/packages/e3/38/45abd72ef63f2e7d0754a6cacf97bd8b69512ace7f6130d24c39ece65da2/cryptography-50.0.1-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:76de83fbd91ac49c0feaaa983d0748fd7a53176afac5fb3bf7478d244f0eb527", size = 5308405 }, - { url = "https://files.pythonhosted.org/packages/85/66/6ccca4722987ddedaa7fc9c3f4708af7431f5535666c174350830888c6b7/cryptography-50.0.1-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:51afcfceb15597cf2635068e4ac9a56b2abde622edde17f37d85fd7b5306497a", size = 4746230 }, - { url = "https://files.pythonhosted.org/packages/13/0e/b1f92e013228111413f2e6743948b80bc24dfd3c1b87ba98ceea16f5df89/cryptography-50.0.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:be224a65493ec5b74a158ff22a5522ce4a5ca1e543c647a3a4730d4a09e5f959", size = 4862596 }, - { url = "https://files.pythonhosted.org/packages/7e/22/c3654cccc856e9d682817b04ac3ee79731cb09ca6f95996a95c904de2883/cryptography-50.0.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9ebcdd5519be9b652a46f507817a74591774fc3d6923ac364e4dfa64e36b291b", size = 5014082 }, - { url = "https://files.pythonhosted.org/packages/42/8b/cb12b1b60c91b074ca6bf0fdd59aa8f10d8bc5f73af8faece86ef0421b37/cryptography-50.0.1-cp311-abi3-win_amd64.whl", hash = "sha256:aed8db4f6d71c51efb89530e12d9464e7bf2923d46c3205dc794a2a93f8c0648", size = 3842826 }, - { url = "https://files.pythonhosted.org/packages/5b/f0/424cb557d99aa86ac55da5e2add02e2882e44047b6264f93ade1b975a993/cryptography-50.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:30a125032e5642a21ff816e021152bd4e7e94f03eff3f4b7fca41cd22bc3110f", size = 3973525 }, - { url = "https://files.pythonhosted.org/packages/4d/72/3a2711d967977ab5fc80b782837c7e8d1ac7445e764c20c381a265c57ef3/cryptography-50.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a0b1a59e3a089064a0ec309e9428c8e3ae4e161419d20ac33600767e83fc658a", size = 4708817 }, - { url = "https://files.pythonhosted.org/packages/b4/f2/bb1f56e10815b789df0b409a69fa4992ff3d3fef9c72747f4a6b26fed38e/cryptography-50.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8921d58f426793c5f1b47f0b59575780de9a095214958d0eb37d909593db8367", size = 4697300 }, - { url = "https://files.pythonhosted.org/packages/08/bd/ed5396be499ffcf8807a585bfe38b71a1fbdd1c342b4f9b6d0ef5162a946/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a8f40ea47330e71b594a7e246898f93177c259490c63183dbaf9e571d71ed9a5", size = 4716039 }, - { url = "https://files.pythonhosted.org/packages/f6/6e/1cf405c5c8e8df7545378048e954792f00b7f2367af8863ce8b8f3e10607/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:a255449073358275b64b67d3f595f268bbef70e72b6edb65e0c70c735bf739c9", size = 5332388 }, - { url = "https://files.pythonhosted.org/packages/47/92/b4317e8c32c4f47b062f5398bd79106b220a124546f42be83bf32b761e2a/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:8df2de9102026855887e4587084f6eabd80ed0f345b8ad8a7ac27ab9bf4723e0", size = 4730293 }, - { url = "https://files.pythonhosted.org/packages/39/0d/a1e7633e2c744d0f2983320a27e924ef2264c79c56e1a58d5fb0a1cfd413/cryptography-50.0.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:ac02b07824d4d1001bd4367599f839c19cb171924c796e52c23508ac14c2c0cc", size = 4346031 }, - { url = "https://files.pythonhosted.org/packages/88/dd/b215616f9bab3fc18510c78a4e5c9f362d77838503c363dc747c7d4f5c6f/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:cbf74a81765ee67413503ca6e26dcc4f6f5a519822436cc0a1b97aab6c1b8a17", size = 4715344 }, - { url = "https://files.pythonhosted.org/packages/b1/1b/ec3ebd31741d0e963612c4fe43caa39341b9b1e031e469820e42e4c83918/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:16c5ecd954b3330ebfb6605eca4fd952da8bef376551d5cc264534e3770a9ee6", size = 5287201 }, - { url = "https://files.pythonhosted.org/packages/1a/01/0127d11a762b31a9ee0221894f540318761783f3fdc4bc5d057698caebd5/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:79bf008d1f9af6071c797ad133e39915dfee7614f18f18f4db9072eb715064a3", size = 4730023 }, - { url = "https://files.pythonhosted.org/packages/9e/b9/e7425ebfb599241a0c1d7000f1b466c3062da66c19d9525031315dff7213/cryptography-50.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:330fbb252391c596f1ae42c5754449dc924e6ad012dca8efe0d703f9f2d12ec6", size = 4847362 }, - { url = "https://files.pythonhosted.org/packages/2d/fd/60d0ddf4defa12e482c9d5e0f554384d6e8ab25341fd15f060028fd92e6a/cryptography-50.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:42be3bb70596b3abe4ac097b75be223e8b3ab614a0e5de068e3dcc54d71d6149", size = 4999247 }, - { url = "https://files.pythonhosted.org/packages/4d/56/bc4f2b209e766c93372cfcd59b781a0b2b59700f62a969580415b699c2b2/cryptography-50.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:f74455bb086a85d5e81246412602aaa97ed095e504cd40dd261ef50be42205bf", size = 3825806 }, - { url = "https://files.pythonhosted.org/packages/84/a9/ee16a903f13755e914d1eecc482fe64d1f10761c3960e5d8fa6837377aff/cryptography-50.0.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ca83d00d9e69cd5eb63f2e69c3a5a59e0cecae5ae14c6ae0b35830fe3b37bad0", size = 4035307 }, - { url = "https://files.pythonhosted.org/packages/5e/a5/9ec7e81e8526c0d7a387d73386b2daed3f39e10d81a85930bd1b6bfba65c/cryptography-50.0.1-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:05ba322c4da95b262a212c345af888ef2c37c88c0509756ea00a0e6d68850f23", size = 4751900 }, - { url = "https://files.pythonhosted.org/packages/7e/3c/0e77bd5ffcf078e9dd27d3074aad6c030d9b10d0bf69329d573c927a188c/cryptography-50.0.1-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e22dfed744bd4002e909464cb23d2f0b05c6f3113a79ef2e9864a53db737c733", size = 4738357 }, - { url = "https://files.pythonhosted.org/packages/27/3a/3c5f80daa4dcd47323c7af8a2fcb90de27a33564d4fcac69846c0972691a/cryptography-50.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4c4188f7c0cf655be5c06342b817ed0f9595b69ffa2b12026e5353eed29dea88", size = 4758474 }, - { url = "https://files.pythonhosted.org/packages/6e/2b/214cf0cf93db9628c3c20c896b229f327f6fb1b20e4b3743d8ad3f00af8b/cryptography-50.0.1-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2ebbfb0f1fed745e91796e3e1080a1440423fdae8ece1b995a1d80883a409054", size = 5375862 }, - { url = "https://files.pythonhosted.org/packages/d6/51/3f9701867a46b6c1740c9b52fc4d3bed6cbdcfedcc9b6e64305c07f39cff/cryptography-50.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:407fe2b6db00939c05c0e945e9914238f2f0a430974839429dafc82b1ee6bee5", size = 4772942 }, - { url = "https://files.pythonhosted.org/packages/0d/5c/13ea642e08e2544d0f5396122055f4820cfacb3203562197b5967125ea97/cryptography-50.0.1-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:2b34d76a652ea2b6faf777c35df230c5637842cd904e04f16230c3f9f03e4361", size = 4383347 }, - { url = "https://files.pythonhosted.org/packages/84/d5/7d1fe1cb93f91c428093ff234e128c89ba8ea61a6f26aab406081f9b996e/cryptography-50.0.1-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:01f41478cf33fc605a6a089cd56d28b45c6c0b45a1928b61797f2621a04bac71", size = 4758050 }, - { url = "https://files.pythonhosted.org/packages/dd/04/557fc5ead96a829e0bc812a3b9dc4a52a2f27e4f7f5950da7ff27653a805/cryptography-50.0.1-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:fc3ed7ebd2a8c96f5b166de0ab9b624996bef3b07bbeb19364dfb78222c22c80", size = 5332955 }, - { url = "https://files.pythonhosted.org/packages/8c/eb/5d7124083e8d8cda8f5b348f544b71ad6f707ad63193758ef4d8e569da02/cryptography-50.0.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9dde0a357190eb3b1da1bb9ab750e9c85cba82ca5977aa0836cbb94e92611239", size = 4772694 }, - { url = "https://files.pythonhosted.org/packages/63/8e/f1f955e0921dd2b6d22eae7e8d24a4c4b638d10735ffbf6a71f99eb0fcb8/cryptography-50.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd3718b960d0b5dd213cdf03f3bcb7000e69dda0de8b956061947ff6bcff5558", size = 4888413 }, - { url = "https://files.pythonhosted.org/packages/1f/ab/89e2b798d2c3925f82e2bb72d5979f3d2f6da2dd22ef4a8cd8b70d920039/cryptography-50.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2a93d05e34d5f67fba6f891fe85d929999baa7195e853923ea6d7576c9e68c5e", size = 5044355 }, - { url = "https://files.pythonhosted.org/packages/99/89/87ef49ffe383ef4e147d27b7bf2088fb0b54ea409dd87b5a89442e5828a5/cryptography-50.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:55d16b1ef3ee0958d893a977b19777887e546c9954ea81b200c3301a864013f2", size = 3875429 }, -] - [[package]] name = "dj-database-url" version = "3.1.2" @@ -706,15 +571,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/eb/e6/5fff07a70d1f945ed90ae131c3bd76cab32beff7c58c6db15ad5820b6d1f/psycopg_binary-3.3.4-cp314-cp314-win_amd64.whl", hash = "sha256:c37e024c07308cd06cf3ec51bfd0e7f6157585a4d84d1bce4a7f5f7913719bf8", size = 3666849 }, ] -[[package]] -name = "pycparser" -version = "3.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172 }, -] - [[package]] name = "pygments" version = "2.20.0" @@ -724,20 +580,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151 }, ] -[[package]] -name = "pyjwt" -version = "2.14.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/af/c3/8a3b59c25070cc61dc517fbdfa5dc0904670c96f605cc69759dc09166b99/pyjwt-2.14.0.tar.gz", hash = "sha256:77283c83fb56ecf566a886c757a714bc83668e38156de2cce8263302f42e0b86", size = 113177 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9c/97/672cb32ce0dfea44b740cb7b4f97038463b9cf7c0ead1aacf595572851d6/pyjwt-2.14.0-py3-none-any.whl", hash = "sha256:ad0cef71c756a56e74863c2919cf0985f72decbcfcb550ee2f422e7c62b5eedc", size = 32896 }, -] - -[package.optional-dependencies] -crypto = [ - { name = "cryptography" }, -] - [[package]] name = "pytest" version = "9.0.3" @@ -906,7 +748,6 @@ dependencies = [ { name = "gunicorn" }, { name = "issue-core" }, { name = "psycopg", extra = ["binary"] }, - { name = "pyjwt", extra = ["crypto"] }, { name = "python-decouple" }, { name = "whitenoise" }, ] @@ -929,7 +770,6 @@ requires-dist = [ { name = "gunicorn", specifier = ">=22.0" }, { name = "issue-core", specifier = ">=0.2,<0.3", index = "https://forgejo.coulomb.social/api/packages/coulomb/pypi/simple/" }, { name = "psycopg", extras = ["binary"], specifier = ">=3.2" }, - { name = "pyjwt", extras = ["crypto"], specifier = ">=2.10,<3" }, { name = "python-decouple", specifier = ">=3.8" }, { name = "whitenoise", specifier = ">=6.7" }, ] diff --git a/vergabe_teilnahme/apps/accounts/context_processors.py b/vergabe_teilnahme/apps/accounts/context_processors.py deleted file mode 100644 index 4dc9b55..0000000 --- a/vergabe_teilnahme/apps/accounts/context_processors.py +++ /dev/null @@ -1,7 +0,0 @@ -from django.conf import settings - - -def company_context(request): - if settings.NETKINGDOM_ENABLED: - return {'company_name': settings.COMPANY_DISPLAY_NAME} - return {} diff --git a/vergabe_teilnahme/apps/accounts/forms.py b/vergabe_teilnahme/apps/accounts/forms.py deleted file mode 100644 index 7a612bd..0000000 --- a/vergabe_teilnahme/apps/accounts/forms.py +++ /dev/null @@ -1,16 +0,0 @@ -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 deleted file mode 100644 index 46f7ba8..0000000 --- a/vergabe_teilnahme/apps/accounts/middleware.py +++ /dev/null @@ -1,42 +0,0 @@ -import time - -from django.conf import settings -from django.contrib.auth import logout -from django.contrib.auth.middleware import LoginRequiredMiddleware -from django.contrib.auth.views import redirect_to_login -from django.http import HttpResponse -from django.urls import reverse -from django.utils.cache import add_never_cache_headers - - -class PilotLoginRequiredMiddleware(LoginRequiredMiddleware): - """Use Django's default-deny gate, including full-page HTMX reauthentication.""" - - def process_request(self, request): - expiry = request.session.get("oidc_expires") - if expiry is not None and ( - expiry <= time.time() - or request.user.is_staff - or request.user.is_superuser - or not settings.NETKINGDOM_ENABLED - or request.session.get("oidc_binding") - != [ - settings.NETKINGDOM_ISSUER, - settings.NETKINGDOM_TENANT, - settings.NETKINGDOM_CLIENT_ID, - ] - ): - logout(request) - - 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(reverse("home"), 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_info != "/health/": - add_never_cache_headers(response) - return response diff --git a/vergabe_teilnahme/apps/accounts/migrations/0003_oidcidentity.py b/vergabe_teilnahme/apps/accounts/migrations/0003_oidcidentity.py deleted file mode 100644 index 25b781f..0000000 --- a/vergabe_teilnahme/apps/accounts/migrations/0003_oidcidentity.py +++ /dev/null @@ -1,45 +0,0 @@ -# Generated by Django 6.0.5 on 2026-09-12 00:31 - -import django.db.models.deletion -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("accounts", "0002_alter_mitarbeiter_mobilnummer_and_more"), - ] - - operations = [ - migrations.CreateModel( - name="OIDCIdentity", - fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ("issuer", models.CharField(max_length=512)), - ("subject", models.CharField(max_length=512)), - ( - "user", - models.OneToOneField( - on_delete=django.db.models.deletion.PROTECT, - to=settings.AUTH_USER_MODEL, - ), - ), - ], - options={ - "constraints": [ - models.UniqueConstraint( - fields=("issuer", "subject"), name="unique_oidc_identity" - ) - ], - }, - ), - ] diff --git a/vergabe_teilnahme/apps/accounts/models.py b/vergabe_teilnahme/apps/accounts/models.py index dfc8444..f452959 100644 --- a/vergabe_teilnahme/apps/accounts/models.py +++ b/vergabe_teilnahme/apps/accounts/models.py @@ -23,13 +23,3 @@ class Mitarbeiter(AbstractUser): class Meta: verbose_name = 'Mitarbeiter' verbose_name_plural = 'Mitarbeiter' - - -class OIDCIdentity(models.Model): - issuer = models.CharField(max_length=512) - subject = models.CharField(max_length=512) - user = models.OneToOneField(Mitarbeiter, on_delete=models.PROTECT) - - class Meta: - constraints = [models.UniqueConstraint(fields=['issuer', 'subject'], - name='unique_oidc_identity')] diff --git a/vergabe_teilnahme/apps/accounts/oidc.py b/vergabe_teilnahme/apps/accounts/oidc.py deleted file mode 100644 index 79c70de..0000000 --- a/vergabe_teilnahme/apps/accounts/oidc.py +++ /dev/null @@ -1,210 +0,0 @@ -"""Bounded NetKingdom OIDC relying party for one isolated company instance.""" - -import base64 -import hashlib -import json -import secrets -import time -from functools import lru_cache -from urllib.parse import urlencode, urlsplit -from urllib.request import HTTPRedirectHandler, Request, build_opener - -import jwt -from django.conf import settings -from django.core.exceptions import ImproperlyConfigured - - -class LoginRejectedError(ValueError): - pass - - -class NoRedirect(HTTPRedirectHandler): - def redirect_request(self, req, fp, code, msg, headers, newurl): - return None - - -def configuration(): - issuer = settings.NETKINGDOM_ISSUER - callback = settings.NETKINGDOM_CALLBACK - for value in (issuer, callback): - parts = urlsplit(value) - if ( - parts.scheme != "https" - or not parts.hostname - or parts.username - or parts.password - or parts.query - or parts.fragment - or parts.hostname in {"localhost", "127.0.0.1"} - ): - raise ImproperlyConfigured("NetKingdom requires fixed HTTPS issuer and callback URLs") - if not settings.NETKINGDOM_CLIENT_ID or not settings.NETKINGDOM_TENANT: - raise ImproperlyConfigured("NetKingdom client and company binding are required") - expected = settings.APP_BASE_PATH + "/accounts/oidc/callback/" - if urlsplit(callback).path != expected: - raise ImproperlyConfigured("NetKingdom callback must match the fixed company path") - if settings.SESSION_ENGINE == "django.contrib.sessions.backends.signed_cookies": - raise ImproperlyConfigured("OIDC requires server-side sessions") - return issuer, settings.NETKINGDOM_CLIENT_ID, callback - - -def read_json(url, data=None): - request = Request( - url, - data=data, - headers={ - "Accept": "application/json", - "Content-Type": "application/x-www-form-urlencoded", - }, - ) - with build_opener(NoRedirect).open(request, timeout=10) as response: - body = response.read(262145) - if len(body) > 262144: - raise LoginRejectedError("Oversized provider response") - result = json.loads(body) - if not isinstance(result, dict): - raise LoginRejectedError("Invalid provider response") - return result - - -@lru_cache(maxsize=8) -def discovery(issuer, period): - metadata = read_json(issuer.rstrip("/") + "/.well-known/openid-configuration") - if metadata.get("issuer") != issuer: - raise LoginRejectedError("Issuer mismatch") - for field in ("authorization_endpoint", "token_endpoint", "jwks_uri"): - parts = urlsplit(metadata.get(field, "")) - if ( - parts.scheme != "https" - or parts.netloc != urlsplit(issuer).netloc - or parts.username - or parts.password - or parts.fragment - or parts.query - ): - raise LoginRejectedError("Unapproved provider endpoint") - if "S256" not in metadata.get( - "code_challenge_methods_supported", [] - ) or "RS256" not in metadata.get("id_token_signing_alg_values_supported", []): - raise LoginRejectedError("Provider does not support the admitted flow") - return metadata - - -@lru_cache(maxsize=8) -def key_client(uri): - return jwt.PyJWKClient(uri, timeout=10, lifespan=300) - - -def begin(): - issuer, client, callback = configuration() - metadata = discovery(issuer, int(time.time() // 300)) - pending = { - "state": secrets.token_urlsafe(32), - "nonce": secrets.token_urlsafe(32), - "verifier": secrets.token_urlsafe(64), - "created": time.time(), - } - challenge = ( - base64.urlsafe_b64encode( - hashlib.sha256(pending["verifier"].encode("ascii")).digest(), - ) - .decode("ascii") - .rstrip("=") - ) - parameters = { - "response_type": "code", - "client_id": client, - "redirect_uri": callback, - "scope": "openid profile groups", - "state": pending["state"], - "nonce": pending["nonce"], - "code_challenge": challenge, - "code_challenge_method": "S256", - "prompt": "login", - "tenant_hint": settings.NETKINGDOM_TENANT, - } - return pending, metadata["authorization_endpoint"] + "?" + urlencode(parameters) - - -def verify_claims(claims): - """Identity evidence plus the invited pilot's explicit company admission rule.""" - tenant = settings.NETKINGDOM_TENANT - groups, roles = claims.get("groups"), claims.get("roles") - if ( - claims.get("tenant") != tenant - or claims.get("tenant_source") != "directory" - or claims.get("principal_type") != "human" - or not isinstance(groups, list) - or not all(isinstance(g, str) for g in groups) - or not isinstance(roles, list) - or not all(isinstance(r, str) for r in roles) - or tenant + ":users" not in groups - or {"netkingdom-suspended", "net-kingdom-admins"} & set(groups) - or {"platform-operator", "platform-root", "emergency"} & set(roles) - ): - raise LoginRejectedError("Company membership is not established") - assurance = claims.get("assurance") - if not isinstance(assurance, dict) or assurance.get("level") not in {"aal1", "aal2", "aal3"}: - raise LoginRejectedError("Missing authentication assurance") - if not isinstance(claims.get("sub"), str) or not 0 < len(claims["sub"]) <= 512: - raise LoginRejectedError("Invalid subject") - - -def complete(pending, state, code): - if ( - not isinstance(pending, dict) - or not state - or not code - or len(code) > 8192 - or not secrets.compare_digest(pending.get("state", ""), state) - or not 0 <= time.time() - pending.get("created", 0) <= 600 - ): - raise LoginRejectedError("Invalid or expired sign-in") - issuer, client, callback = configuration() - metadata = discovery(issuer, int(time.time() // 300)) - tokens = read_json( - metadata["token_endpoint"], - urlencode( - { - "grant_type": "authorization_code", - "client_id": client, - "redirect_uri": callback, - "code": code, - "code_verifier": pending["verifier"], - } - ).encode("ascii"), - ) - token = tokens.get("id_token") - if not isinstance(token, str) or len(token) > 32768: - raise LoginRejectedError("Missing ID token") - key = key_client(metadata["jwks_uri"]).get_signing_key_from_jwt(token) - claims = jwt.decode( - token, - key.key, - algorithms=["RS256"], - issuer=issuer, - audience=client, - options={"require": ["iss", "sub", "aud", "exp", "iat", "nonce"]}, - ) - audience = claims["aud"] - if ( - (isinstance(audience, list) and len(audience) > 1 and claims.get("azp") != client) - or ("azp" in claims and claims["azp"] != client) - or not isinstance(claims["nonce"], str) - or not secrets.compare_digest(claims["nonce"], pending["nonce"]) - ): - raise LoginRejectedError("Token binding mismatch") - if any(type(claims[field]) is not int for field in ("iat", "exp")): - raise LoginRejectedError("Invalid token timestamps") - if claims["iat"] < pending["created"] - 60 or claims["exp"] <= claims["iat"]: - raise LoginRejectedError("Token predates this sign-in") - verify_claims(claims) - # Store neither bearer tokens nor a provider password in the product session. - return { - "issuer": issuer, - "tenant": settings.NETKINGDOM_TENANT, - "client": client, - "subject": claims["sub"], - "label": str(claims.get("preferred_username") or "Ihr Benutzerkonto")[:150], - "expires": min(int(claims["exp"]), int(time.time()) + 300), - } diff --git a/vergabe_teilnahme/apps/accounts/test_oidc.py b/vergabe_teilnahme/apps/accounts/test_oidc.py deleted file mode 100644 index 08eba6a..0000000 --- a/vergabe_teilnahme/apps/accounts/test_oidc.py +++ /dev/null @@ -1,277 +0,0 @@ -"""Exercise the actual signed-token and browser-session admission boundaries.""" - -import time -from types import SimpleNamespace -from urllib.parse import parse_qs, urlsplit - -import jwt -import pytest -from cryptography.hazmat.primitives.asymmetric import rsa -from django.test import Client -from django.urls import get_script_prefix, set_script_prefix - -from . import oidc -from .models import Mitarbeiter, OIDCIdentity - -pytestmark = pytest.mark.django_db -ISSUER = "https://kc.example.test" -TENANT = "tenant:trial:demo-company" -CLIENT = "vergabe-demo-company" - - -@pytest.fixture -def configured(settings, monkeypatch): - settings.NETKINGDOM_ENABLED = True - settings.NETKINGDOM_ISSUER = ISSUER - settings.NETKINGDOM_CLIENT_ID = CLIENT - settings.NETKINGDOM_TENANT = TENANT - settings.NETKINGDOM_CALLBACK = "https://vergabe.example.test/accounts/oidc/callback/" - settings.COMPANY_DISPLAY_NAME = "Demo Company" - settings.APP_BASE_PATH = "" - metadata = { - "issuer": ISSUER, - "authorization_endpoint": ISSUER + "/authorize", - "token_endpoint": ISSUER + "/token", - "jwks_uri": ISSUER + "/jwks", - "code_challenge_methods_supported": ["S256"], - "id_token_signing_alg_values_supported": ["RS256"], - } - monkeypatch.setattr(oidc, "discovery", lambda *args: metadata) - return settings - - -@pytest.fixture -def signed_flow(configured, monkeypatch): - key = rsa.generate_private_key(public_exponent=65537, key_size=2048) - monkeypatch.setattr( - oidc, - "key_client", - lambda uri: SimpleNamespace( - get_signing_key_from_jwt=lambda token: SimpleNamespace(key=key.public_key()), - ), - ) - - def exchange(client, changes=None, token_override=None): - pending = client.session["oidc_pending"] - claims = { - "iss": ISSUER, - "sub": "subject-1", - "aud": CLIENT, - "iat": int(time.time()), - "exp": int(time.time()) + 600, - "nonce": pending["nonce"], - "tenant": TENANT, - "tenant_source": "directory", - "principal_type": "human", - "groups": [TENANT + ":users"], - "roles": ["user"], - "assurance": {"level": "aal1"}, - "preferred_username": "Demo member", - } - claims.update(changes or {}) - token = token_override or jwt.encode( - claims, key, algorithm="RS256", headers={"kid": "test"} - ) - monkeypatch.setattr(oidc, "read_json", lambda *args: {"id_token": token}) - return client.get( - "/accounts/oidc/callback/", {"code": "single-code", "state": pending["state"]} - ) - - return exchange - - -def test_welcome_does_not_log_in_and_start_requires_csrf(configured): - client = Client(enforce_csrf_checks=True) - response = client.get("/accounts/login/?next=https://evil.test&token=untrusted") - assert b"Demo Company" in response.content - assert b"Mit NetKingdom anmelden" in response.content - assert "_auth_user_id" not in client.session - assert client.get("/accounts/oidc/start/").status_code == 405 - assert client.post("/accounts/oidc/start/").status_code == 403 - response = client.post( - "/accounts/oidc/start/", - { - "csrfmiddlewaretoken": client.cookies["csrftoken"].value, - }, - ) - query = parse_qs(urlsplit(response.url).query) - assert query["prompt"] == ["login"] - assert query["code_challenge_method"] == ["S256"] - assert query["tenant_hint"] == [TENANT] - assert len(query["nonce"][0]) >= 32 - assert "evil.test" not in response.url - - -def test_verified_login_needs_explicit_confirmation_and_stable_mapping(client, signed_flow): - assert client.post("/accounts/oidc/start/").status_code == 302 - assert signed_flow(client).url == "/accounts/oidc/confirm/" - assert not Mitarbeiter.objects.exists() - assert client.get("/ausschreibungen/").status_code == 302 - assert b"Demo member" in client.get("/accounts/oidc/confirm/").content - assert client.post("/accounts/oidc/confirm/").url == "/" - user = Mitarbeiter.objects.get() - assert not user.is_staff and not user.is_superuser and not user.has_usable_password() - assert OIDCIdentity.objects.get().subject == "subject-1" - dashboard = client.get("/ausschreibungen/") - assert dashboard.status_code == 200 - assert b"Demo Company" in dashboard.content - assert "access_token" not in repr(dict(client.session)) - assert client.post("/accounts/oidc/start/").status_code == 302 - assert "_auth_user_id" not in client.session - assert signed_flow(client, {"preferred_username": "New display name"}).status_code == 302 - assert client.post("/accounts/oidc/confirm/").status_code == 302 - assert Mitarbeiter.objects.count() == 1 - - -@pytest.mark.parametrize( - "changes", - [ - {"iss": "https://evil.test"}, - {"aud": "other-client"}, - {"nonce": "wrong"}, - {"exp": 1}, - {"iat": int(time.time()) + 3600}, - {"nbf": int(time.time()) + 3600}, - {"tenant": "tenant:platform"}, - {"tenant_source": "registration"}, - {"principal_type": "service"}, - {"groups": []}, - {"groups": [TENANT + ":users", "netkingdom-suspended"]}, - {"roles": ["platform-operator"]}, - {"groups": [TENANT + ":users", "net-kingdom-admins"]}, - {"assurance": {"level": "aal0"}}, - {"sub": ""}, - {"roles": "user"}, - {"aud": [CLIENT, "other"], "azp": "other"}, - ], -) -def test_invalid_tokens_never_create_users(client, signed_flow, changes): - client.post("/accounts/oidc/start/") - response = signed_flow(client, changes) - assert response.status_code == 302 - assert response.url == "https://users.coulomb.social/access-recovery" - assert response["Referrer-Policy"] == "no-referrer" - assert "oidc_confirm" not in client.session - assert not Mitarbeiter.objects.exists() - assert "_auth_user_id" not in client.session - - -def test_wrong_algorithm_denied(client, signed_flow): - client.post("/accounts/oidc/start/") - assert ( - signed_flow( - client, token_override=jwt.encode({"sub": "forged"}, "x" * 32, algorithm="HS256") - ).status_code - == 302 - ) - - -def test_state_is_browser_bound_expired_and_one_use(client, signed_flow): - client.post("/accounts/oidc/start/") - original = client.session["oidc_pending"] - other = Client() - response = other.get("/accounts/oidc/callback/", {"state": original["state"], "code": "x"}) - assert response.status_code == 302 - assert signed_flow(client).status_code == 302 - assert ( - client.get( - "/accounts/oidc/callback/", {"state": original["state"], "code": "x"} - ).status_code - == 302 - ) - assert client.post("/accounts/oidc/confirm/").status_code == 302 - client.post("/accounts/oidc/start/") - session = client.session - session["oidc_pending"]["created"] -= 601 - session.save() - assert signed_flow(client).status_code == 302 - - -def test_confirmation_requires_csrf(client, signed_flow): - client.post("/accounts/oidc/start/") - signed_flow(client) - guarded = Client(enforce_csrf_checks=True) - guarded.cookies = client.cookies - assert guarded.post("/accounts/oidc/confirm/").status_code == 403 - assert not Mitarbeiter.objects.exists() - - -def test_no_email_merge_and_inactive_or_staff_accounts_stay_denied(client, signed_flow): - existing = Mitarbeiter.objects.create_user(username="Demo member", email="same@example.test") - client.post("/accounts/oidc/start/") - signed_flow(client, {"email": existing.email}) - client.post("/accounts/oidc/confirm/") - linked = OIDCIdentity.objects.get().user - assert linked.pk != existing.pk - linked.is_active = False - linked.save() - assert client.get("/ausschreibungen/").status_code == 302 - client.post("/accounts/oidc/start/") - signed_flow(client) - assert client.post("/accounts/oidc/confirm/").status_code == 302 - linked.is_active = True - linked.is_staff = True - linked.save() - client.post("/accounts/oidc/start/") - signed_flow(client) - assert client.post("/accounts/oidc/confirm/").status_code == 302 - - -def test_absolute_session_expiry_and_wrong_identity_do_not_reuse_operator(client, signed_flow): - operator = Mitarbeiter.objects.create_superuser(username="operator", password="test-only") - client.force_login(operator) - client.post("/accounts/oidc/start/") - assert "_auth_user_id" not in client.session - assert signed_flow(client, {"tenant": "tenant:platform"}).status_code == 302 - client.post("/accounts/oidc/start/") - signed_flow(client) - client.post("/accounts/oidc/confirm/") - session = client.session - session["oidc_expires"] = time.time() - 1 - session.save() - assert client.get("/ausschreibungen/").status_code == 302 - - -def test_exact_company_callback_and_prefixed_links(configured, client): - old = get_script_prefix() - configured.APP_BASE_PATH = "/demo-company" - configured.FORCE_SCRIPT_NAME = "/demo-company" - configured.NETKINGDOM_CALLBACK = ( - "https://vergabe.example.test/demo-company/accounts/oidc/callback/" - ) - set_script_prefix("/demo-company") - try: - page = client.get("/accounts/login/") - assert b"/demo-company/accounts/oidc/start/" in page.content - target = client.post("/accounts/oidc/start/").url - assert parse_qs(urlsplit(target).query)["redirect_uri"] == [configured.NETKINGDOM_CALLBACK] - configured.NETKINGDOM_CALLBACK = ( - "https://vergabe.example.test/other/accounts/oidc/callback/" - ) - assert client.post("/accounts/oidc/start/").status_code == 503 - finally: - set_script_prefix(old) - - -def test_disabled_oidc_preserves_existing_pilot_login(settings, client): - settings.NETKINGDOM_ENABLED = False - assert b"Benutzername" in client.get("/accounts/login/").content - assert client.post("/accounts/oidc/start/").status_code == 404 - assert client.get("/accounts/oidc/callback/").status_code == 404 - - -def test_wrong_signature_denied(client, signed_flow): - client.post("/accounts/oidc/start/") - another = rsa.generate_private_key(public_exponent=65537, key_size=2048) - token = jwt.encode({"sub": "forged"}, another, algorithm="RS256") - assert signed_flow(client, token_override=token).status_code == 302 - assert not Mitarbeiter.objects.exists() - - -def test_rejected_callback_uses_fixed_recovery_without_browser_credentials(client, configured): - response = client.get("/accounts/oidc/callback/", {"code":"private-code", "state":"private-state", "next":"https://evil.example"}) - assert response.url == "https://users.coulomb.social/access-recovery" - assert "no-store" in response["Cache-Control"] - assert response["Referrer-Policy"] == "no-referrer" - assert not Mitarbeiter.objects.exists() - assert "_auth_user_id" not in client.session diff --git a/vergabe_teilnahme/apps/accounts/test_tenant_path.py b/vergabe_teilnahme/apps/accounts/test_tenant_path.py deleted file mode 100644 index ab1524b..0000000 --- a/vergabe_teilnahme/apps/accounts/test_tenant_path.py +++ /dev/null @@ -1,120 +0,0 @@ -"""Regression for an isolated instance mounted below a fixed tenant path.""" -from html.parser import HTMLParser -from urllib.parse import parse_qs, urlsplit - -import pytest -from django.test import Client -from django.urls import get_script_prefix, set_script_prefix - -from vergabe_teilnahme.apps.ausschreibungen.models import Ausschreibung - -pytestmark = pytest.mark.django_db -PREFIX = '/demo-company' - - -@pytest.fixture -def tenant_path(settings): - previous = get_script_prefix() - settings.FORCE_SCRIPT_NAME = PREFIX - settings.APP_BASE_PATH = PREFIX - settings.STATIC_URL = PREFIX + '/static/' - settings.MEDIA_URL = PREFIX + '/media/' - settings.SESSION_COOKIE_PATH = settings.CSRF_COOKIE_PATH = PREFIX + '/' - settings.SESSION_COOKIE_NAME = 'vergabe_demo_company_sessionid' - settings.CSRF_COOKIE_NAME = 'vergabe_demo_company_csrftoken' - set_script_prefix(PREFIX) - yield settings - set_script_prefix(previous) - - -class LocalLinks(HTMLParser): - def __init__(self): - super().__init__() - self.links = [] - - def handle_starttag(self, tag, attrs): - for name, value in attrs: - if name in {'href', 'src', 'action', 'hx-get', 'hx-post', 'hx-delete'} and value: - if value.startswith('/'): - self.links.append(value) - - -def assert_prefixed(content): - parser = LocalLinks() - parser.feed(content.decode()) - assert parser.links - assert all(link.startswith(PREFIX + '/') for link in parser.links), parser.links - - -def test_anonymous_and_expired_fragments_stay_in_tenant_path(client, tenant_path): - response = client.get('/ausschreibungen/', HTTP_X_FORWARDED_PREFIX='/other-company') - assert response.status_code == 302 - assert urlsplit(response.url).path == PREFIX + '/accounts/login/' - assert parse_qs(urlsplit(response.url).query)['next'] == [PREFIX + '/ausschreibungen/'] - response = client.get('/suche/?q=private', HTTP_HX_REQUEST='true') - assert response.status_code == 401 - assert urlsplit(response['HX-Redirect']).path == PREFIX + '/accounts/login/' - assert parse_qs(urlsplit(response['HX-Redirect']).query)['next'] == [PREFIX + '/'] - assert client.get('/health/').json() == {'status': 'ok'} - - -def test_login_cookie_csrf_logout_and_external_redirect(client, mitarbeiter, tenant_path): - client = Client(enforce_csrf_checks=True) - login = client.get('/accounts/login/') - assert_prefixed(login.content) - csrf = tenant_path.CSRF_COOKIE_NAME - assert client.cookies[csrf]['path'] == PREFIX + '/' - credentials = {'username': mitarbeiter.username, 'password': 'testpass'} - assert client.post('/accounts/login/', credentials).status_code == 403 - credentials['csrfmiddlewaretoken'] = client.cookies[csrf].value - credentials['next'] = 'https://untrusted.example/private' - response = client.post('/accounts/login/', credentials) - assert response.status_code == 302 - assert response.url == PREFIX + '/' - assert client.cookies[tenant_path.SESSION_COOKIE_NAME]['path'] == PREFIX + '/' - assert client.get('/accounts/logout/').status_code == 405 - assert client.post('/accounts/logout/').status_code == 403 - response = client.post('/accounts/logout/', {'csrfmiddlewaretoken': client.cookies[csrf].value}) - assert response.url == PREFIX + '/accounts/login/' - assert client.cookies[tenant_path.SESSION_COOKIE_NAME]['path'] == PREFIX + '/' - assert client.get('/ausschreibungen/').status_code == 302 - - -def test_member_navigation_fragments_and_password_change(client, mitarbeiter, tenant_path): - tender = Ausschreibung.objects.create(titel='Path test', ausschreiber='Demo') - client.force_login(mitarbeiter) - for path in [ - '/ausschreibungen/', f'/ausschreibungen/{tender.pk}/', - '/aufgaben/', '/partner/subunternehmer/', '/bibliothek/nachweise/', - '/accounts/password-change/', - ]: - response = client.get(path) - assert response.status_code == 200, path - assert_prefixed(response.content) - fragment = client.get('/suche/?q=Path', HTTP_HX_REQUEST='true') - assert fragment.status_code == 200 - assert_prefixed(fragment.content) - response = client.post('/accounts/password-change/', { - 'old_password': 'testpass', - 'new_password1': 'Path-pilot-password-984!', - 'new_password2': 'Path-pilot-password-984!', - }) - assert response.status_code == 302 - assert response.url.startswith(PREFIX + '/') - assert client.get('/ausschreibungen/').status_code == 200 - - -def test_private_media_remains_authenticated_under_prefix( - client, mitarbeiter, tenant_path, tmp_path, -): - tenant_path.MEDIA_ROOT = tmp_path - (tmp_path / 'document.pdf').write_bytes(b'%PDF-private-demo') - anonymous = client.get('/media/document.pdf') - assert anonymous.status_code == 302 - assert urlsplit(anonymous.url).path == PREFIX + '/accounts/login/' - client.force_login(mitarbeiter) - response = client.get('/media/document.pdf') - assert response.status_code == 200 - assert b''.join(response.streaming_content) == b'%PDF-private-demo' - assert response['Content-Disposition'] == 'attachment; filename="document.pdf"' - assert 'no-store' in response['Cache-Control'] diff --git a/vergabe_teilnahme/apps/accounts/tests.py b/vergabe_teilnahme/apps/accounts/tests.py index 62416e2..7ce503c 100644 --- a/vergabe_teilnahme/apps/accounts/tests.py +++ b/vergabe_teilnahme/apps/accounts/tests.py @@ -1,138 +1,3 @@ -from urllib.parse import parse_qs, urlsplit +from django.test import TestCase -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 +# Create your tests here. diff --git a/vergabe_teilnahme/apps/accounts/urls.py b/vergabe_teilnahme/apps/accounts/urls.py deleted file mode 100644 index 45672c9..0000000 --- a/vergabe_teilnahme/apps/accounts/urls.py +++ /dev/null @@ -1,32 +0,0 @@ -from django.contrib.auth import views -from django.urls import path, reverse_lazy - -from . import views as company_views -from .forms import PasswordChangeForm - -app_name = "accounts" - -urlpatterns = [ - path("login/", company_views.company_login, name="login"), - path("welcome/", company_views.welcome, name="welcome"), - path("oidc/start/", company_views.oidc_start, name="oidc_start"), - path("oidc/callback/", company_views.oidc_callback, name="oidc_callback"), - path("oidc/confirm/", company_views.oidc_confirm, name="oidc_confirm"), - 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/accounts/views.py b/vergabe_teilnahme/apps/accounts/views.py index a6f0e67..91ea44a 100644 --- a/vergabe_teilnahme/apps/accounts/views.py +++ b/vergabe_teilnahme/apps/accounts/views.py @@ -1,144 +1,3 @@ -import hashlib -import time -from urllib.error import URLError +from django.shortcuts import render -import jwt -from django.conf import settings -from django.contrib.auth import login, logout -from django.contrib.auth.decorators import login_not_required -from django.contrib.auth.views import LoginView -from django.core.exceptions import ImproperlyConfigured -from django.db import IntegrityError, transaction -from django.http import Http404 -from django.shortcuts import redirect, render -from django.views.decorators.debug import sensitive_variables -from django.views.decorators.http import require_GET, require_POST - -from . import oidc -from .forms import AuthenticationForm -from .models import Mitarbeiter, OIDCIdentity - - -def account_recovery(): - response = redirect(settings.NETKINGDOM_ACCOUNT_PORTAL_URL.rstrip("/") + "/access-recovery") - response["Cache-Control"] = "no-store" - response["Referrer-Policy"] = "no-referrer" - return response - - -def context(**extra): - return {"company_name": settings.COMPANY_DISPLAY_NAME, **extra} - - -@login_not_required -def company_login(request): - if settings.NETKINGDOM_ENABLED: - return welcome(request) - return LoginView.as_view( - template_name="accounts/login.html", authentication_form=AuthenticationForm - )(request) - - -@login_not_required -@require_GET -def welcome(request): - if not settings.NETKINGDOM_ENABLED: - return redirect("accounts:login") - # A setup return never establishes or changes an application session. - return render(request, "accounts/welcome.html", context()) - - -@login_not_required -@require_POST -@sensitive_variables() -def oidc_start(request): - if not settings.NETKINGDOM_ENABLED: - raise Http404 - # Explicit CSRF-protected action clears only this product's previous login. - logout(request) - try: - pending, target = oidc.begin() - except (ValueError, URLError, TimeoutError, OSError, ImproperlyConfigured): - return render(request, "accounts/welcome.html", context(login_error=True), status=503) - request.session["oidc_pending"] = pending - request.session.set_expiry(600) - return redirect(target) - - -@login_not_required -@require_GET -@sensitive_variables() -def oidc_callback(request): - if not settings.NETKINGDOM_ENABLED: - raise Http404 - pending = request.session.pop("oidc_pending", None) - request.session.pop("oidc_confirm", None) - try: - if request.GET.get("error") or len(request.GET.getlist("state")) != 1: - raise oidc.LoginRejectedError("Authorization was not completed") - verified = oidc.complete(pending, request.GET.get("state", ""), request.GET.get("code", "")) - except (ValueError, jwt.PyJWTError, URLError, TimeoutError, OSError, ImproperlyConfigured): - return account_recovery() - request.session["oidc_confirm"] = verified - # Remove the authorization code from the address bar before displaying identity. - return redirect("accounts:oidc_confirm") - - -@login_not_required -@sensitive_variables() -def oidc_confirm(request): - if not settings.NETKINGDOM_ENABLED: - raise Http404 - verified = request.session.get("oidc_confirm") - if ( - not verified - or verified["expires"] <= time.time() - or verified.get("tenant") != settings.NETKINGDOM_TENANT - or verified.get("issuer") != settings.NETKINGDOM_ISSUER - or verified.get("client") != settings.NETKINGDOM_CLIENT_ID - ): - request.session.pop("oidc_confirm", None) - return account_recovery() - if request.method == "GET": - return render(request, "accounts/confirm.html", context(identity_label=verified["label"])) - if request.method != "POST": - from django.http import HttpResponseNotAllowed - - return HttpResponseNotAllowed(["GET", "POST"]) - request.session.pop("oidc_confirm", None) - try: - with transaction.atomic(): - identity = ( - OIDCIdentity.objects.select_related("user") - .filter( - issuer=verified["issuer"], - subject=verified["subject"], - ) - .first() - ) - if identity is None: - # Never merge by email, display name or directory username. - username = ( - "nk_" - + hashlib.sha256( - (verified["issuer"] + "\0" + verified["subject"]).encode(), - ).hexdigest() - ) - user = Mitarbeiter(username=username, first_name=verified["label"][:150]) - user.set_unusable_password() - user.save() - identity = OIDCIdentity.objects.create( - issuer=verified["issuer"], - subject=verified["subject"], - user=user, - ) - user = identity.user - if not user.is_active or user.is_staff or user.is_superuser: - raise oidc.LoginRejectedError("Product account is not admitted") - except (IntegrityError, oidc.LoginRejectedError): - return account_recovery() - login(request, user, backend="django.contrib.auth.backends.ModelBackend") - request.session["oidc_expires"] = verified["expires"] - request.session["oidc_binding"] = [verified["issuer"], verified["tenant"], verified["client"]] - request.session.set_expiry(max(1, int(verified["expires"] - time.time()))) - return redirect("home") +# Create your views here. diff --git a/vergabe_teilnahme/apps/aufgaben/tests.py b/vergabe_teilnahme/apps/aufgaben/tests.py index 62de51a..0a789e0 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(admitted_client): +def test_aufgaben_liste_get(client): a = AusschreibungFactory() url = reverse('ausschreibungen:aufgaben:liste', kwargs={'ausschreibung_id': a.pk}) - response = admitted_client.get(url) + response = client.get(url) assert response.status_code == 200 @pytest.mark.django_db -def test_aufgabe_neu_post(admitted_client): +def test_aufgabe_neu_post(client): a = AusschreibungFactory() url = reverse('ausschreibungen:aufgaben:neu', kwargs={'ausschreibung_id': a.pk}) - response = admitted_client.post(url, {'titel': 'Neue Aufgabe', 'typ': 'fachlich', 'prioritaet': 2}) + response = 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(admitted_client): +def test_aufgabe_status_htmx(client): aufgabe = AufgabeFactory() url = reverse('ausschreibungen:aufgaben:status', kwargs={'ausschreibung_id': aufgabe.ausschreibung_id, 'pk': aufgabe.pk}) - response = admitted_client.post(url, {'status': 'erledigt'}, HTTP_HX_REQUEST='true') + response = 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(admitted_client): +def test_ueberfaellige_aufgabe_auto_update(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}) - admitted_client.get(url) + client.get(url) aufgabe.refresh_from_db() assert aufgabe.status == 'ueberfaellig' @@ -75,21 +75,21 @@ def test_ueberfaellige_aufgabe_auto_update(admitted_client): @pytest.mark.django_db -def test_bieterfrage_neu_prefill_anforderung(admitted_client): +def test_bieterfrage_neu_prefill_anforderung(client): a = AusschreibungFactory() anf = AnforderungFactory(ausschreibung=a) url = reverse('ausschreibungen:bieterfragen:neu', kwargs={'ausschreibung_id': a.pk}) - response = admitted_client.get(url, {'anforderung_id': anf.pk}) + response = 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(admitted_client): +def test_bieterfrage_antwort_speichern(client): bf = BieterfragenFactory(status='eingereicht') url = reverse('ausschreibungen:bieterfragen:antwort', kwargs={'ausschreibung_id': bf.ausschreibung_id, 'pk': bf.pk}) - response = admitted_client.post(url, {'antwort': 'Die Antwort lautet 42.', 'auswirkung_angebot': ''}) + response = 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(admitted_client): +def test_ueberfaellig_ohne_frist_nach_7_tagen(client): from datetime import timedelta from django.utils import timezone a = AusschreibungFactory() @@ -124,7 +124,7 @@ def test_ueberfaellig_ohne_frist_nach_7_tagen(admitted_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}) - admitted_client.get(url) + client.get(url) aufgabe.refresh_from_db() assert aufgabe.status == 'ueberfaellig' @@ -133,14 +133,14 @@ def test_ueberfaellig_ohne_frist_nach_7_tagen(admitted_client): @pytest.mark.django_db -def test_aufgaben_verknuepfung_erstellen(admitted_client): +def test_aufgaben_verknuepfung_erstellen(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 = admitted_client.post(url, { + response = client.post(url, { 'ziel_typ': ct.pk, 'ziel_id': anf.pk, 'kommentar': 'Testverknüpfung', @@ -150,7 +150,7 @@ def test_aufgaben_verknuepfung_erstellen(admitted_client): @pytest.mark.django_db -def test_aufgaben_verknuepfung_loeschen(admitted_client): +def test_aufgaben_verknuepfung_loeschen(client): from django.contrib.contenttypes.models import ContentType aufgabe = AufgabeFactory() anf = AnforderungFactory(ausschreibung=aufgabe.ausschreibung) @@ -160,7 +160,7 @@ def test_aufgaben_verknuepfung_loeschen(admitted_client): ) url = reverse('ausschreibungen:aufgaben:verknuepfung_loeschen', kwargs={'ausschreibung_id': aufgabe.ausschreibung_id, 'pk': aufgabe.pk, 'vk_pk': vk.pk}) - response = admitted_client.post(url, {}, HTTP_HX_REQUEST='true') + response = 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(admitted_client, tmp_issue_db): +def test_external_issue_erstellen(client, tmp_issue_db): aufgabe = AufgabeFactory() url = reverse('ausschreibungen:aufgaben:external_issue', kwargs={'ausschreibung_id': aufgabe.ausschreibung_id, 'pk': aufgabe.pk}) - response = admitted_client.post(url, {'notizen': ''}, HTTP_HX_REQUEST='true') + response = 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(admitted_client): +def test_external_issue_loeschen(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 = admitted_client.post(url, {}, HTTP_HX_REQUEST='true') + response = 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(admitted_client, tmp_issue_db): +def test_external_issue_bearbeiten_view_erstellt_issue(client, tmp_issue_db): aufgabe = AufgabeFactory() url = reverse('ausschreibungen:aufgaben:external_issue', kwargs={'ausschreibung_id': aufgabe.ausschreibung_id, 'pk': aufgabe.pk}) - response = admitted_client.post(url, {'notizen': 'Test-Notiz'}, HTTP_HX_REQUEST='true') + response = 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(admitted_client, tmp_issu @pytest.mark.django_db -def test_external_issue_sync_view(admitted_client, tmp_issue_db): +def test_external_issue_sync_view(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(admitted_client, tmp_issue_db): ) url = reverse('ausschreibungen:aufgaben:external_issue_sync', kwargs={'ausschreibung_id': aufgabe.ausschreibung_id, 'pk': aufgabe.pk}) - response = admitted_client.post(url, {}, HTTP_HX_REQUEST='true') + response = 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/aufgaben/views.py b/vergabe_teilnahme/apps/aufgaben/views.py index 70de1f6..50804e9 100644 --- a/vergabe_teilnahme/apps/aufgaben/views.py +++ b/vergabe_teilnahme/apps/aufgaben/views.py @@ -1,12 +1,12 @@ from datetime import date, timedelta -from django.http import HttpResponse from django.shortcuts import get_object_or_404, redirect, render -from django.urls import reverse from django.utils import timezone from vergabe_teilnahme.apps.ausschreibungen.models import Ausschreibung +from django.http import HttpResponse + from .issue_backends import gitea_configured as _gitea_configured from .models import Aufgabe, AufgabenVerknuepfung, Bieterfrage, ExternalIssue @@ -65,8 +65,8 @@ def aufgaben_liste(request, ausschreibung_id=None): if ausschreibung: breadcrumbs = [ - {'label': 'Ausschreibungen', 'url': reverse('ausschreibungen:liste')}, - {'label': ausschreibung.titel, 'url': reverse('ausschreibungen:detail', kwargs={'pk': ausschreibung_id})}, + {'label': 'Ausschreibungen', 'url': '/ausschreibungen/'}, + {'label': ausschreibung.titel, 'url': f'/ausschreibungen/{ausschreibung_id}/'}, {'label': 'Aufgaben', 'url': None}, ] else: @@ -119,9 +119,9 @@ def aufgabe_neu(request, ausschreibung_id): 'ausschreibung': ausschreibung, 'titel': 'Aufgabe anlegen', 'breadcrumbs': [ - {'label': 'Ausschreibungen', 'url': reverse('ausschreibungen:liste')}, - {'label': ausschreibung.titel, 'url': reverse('ausschreibungen:detail', kwargs={'pk': ausschreibung_id})}, - {'label': 'Aufgaben', 'url': reverse('ausschreibungen:aufgaben:liste', kwargs={'ausschreibung_id': ausschreibung_id})}, + {'label': 'Ausschreibungen', 'url': '/ausschreibungen/'}, + {'label': ausschreibung.titel, 'url': f'/ausschreibungen/{ausschreibung_id}/'}, + {'label': 'Aufgaben', 'url': f'/ausschreibungen/{ausschreibung_id}/aufgaben/'}, {'label': 'Neu', 'url': None}, ], }) @@ -142,9 +142,9 @@ def aufgabe_bearbeiten(request, ausschreibung_id, pk): 'aufgabe': aufgabe, 'titel': 'Aufgabe bearbeiten', 'breadcrumbs': [ - {'label': 'Ausschreibungen', 'url': reverse('ausschreibungen:liste')}, - {'label': ausschreibung.titel, 'url': reverse('ausschreibungen:detail', kwargs={'pk': ausschreibung_id})}, - {'label': 'Aufgaben', 'url': reverse('ausschreibungen:aufgaben:liste', kwargs={'ausschreibung_id': ausschreibung_id})}, + {'label': 'Ausschreibungen', 'url': '/ausschreibungen/'}, + {'label': ausschreibung.titel, 'url': f'/ausschreibungen/{ausschreibung_id}/'}, + {'label': 'Aufgaben', 'url': f'/ausschreibungen/{ausschreibung_id}/aufgaben/'}, {'label': aufgabe.titel[:50], 'url': None}, ], }) @@ -161,9 +161,9 @@ def aufgabe_loeschen(request, ausschreibung_id, pk): 'aufgabe': aufgabe, 'ausschreibung': ausschreibung, 'breadcrumbs': [ - {'label': 'Ausschreibungen', 'url': reverse('ausschreibungen:liste')}, - {'label': ausschreibung.titel, 'url': reverse('ausschreibungen:detail', kwargs={'pk': ausschreibung_id})}, - {'label': 'Aufgaben', 'url': reverse('ausschreibungen:aufgaben:liste', kwargs={'ausschreibung_id': ausschreibung_id})}, + {'label': 'Ausschreibungen', 'url': '/ausschreibungen/'}, + {'label': ausschreibung.titel, 'url': f'/ausschreibungen/{ausschreibung_id}/'}, + {'label': 'Aufgaben', 'url': f'/ausschreibungen/{ausschreibung_id}/aufgaben/'}, {'label': 'Löschen', 'url': None}, ], }) @@ -176,9 +176,9 @@ def aufgabe_detail(request, ausschreibung_id, pk): 'aufgabe': aufgabe, 'ausschreibung': ausschreibung, 'breadcrumbs': [ - {'label': 'Ausschreibungen', 'url': reverse('ausschreibungen:liste')}, - {'label': ausschreibung.titel, 'url': reverse('ausschreibungen:detail', kwargs={'pk': ausschreibung_id})}, - {'label': 'Aufgaben', 'url': reverse('ausschreibungen:aufgaben:liste', kwargs={'ausschreibung_id': ausschreibung_id})}, + {'label': 'Ausschreibungen', 'url': '/ausschreibungen/'}, + {'label': ausschreibung.titel, 'url': f'/ausschreibungen/{ausschreibung_id}/'}, + {'label': 'Aufgaben', 'url': f'/ausschreibungen/{ausschreibung_id}/aufgaben/'}, {'label': aufgabe.titel[:50], 'url': None}, ], }) @@ -237,17 +237,16 @@ def bieterfragen_liste(request, ausschreibung_id): 'mitarbeiter': Mitarbeiter.objects.all(), 'current_status': status_filter or '', 'breadcrumbs': [ - {'label': 'Ausschreibungen', 'url': reverse('ausschreibungen:liste')}, - {'label': ausschreibung.titel, 'url': reverse('ausschreibungen:detail', kwargs={'pk': ausschreibung_id})}, + {'label': 'Ausschreibungen', 'url': '/ausschreibungen/'}, + {'label': ausschreibung.titel, 'url': f'/ausschreibungen/{ausschreibung_id}/'}, {'label': 'Bieterfragen', 'url': None}, ], }) def bieterfrage_neu(request, ausschreibung_id): - from vergabe_teilnahme.apps.lose.models import Anforderung - from .forms import BieterfragenForm + from vergabe_teilnahme.apps.lose.models import Anforderung ausschreibung = get_object_or_404(Ausschreibung, pk=ausschreibung_id) @@ -276,9 +275,9 @@ def bieterfrage_neu(request, ausschreibung_id): 'ausschreibung': ausschreibung, 'titel': 'Bieterfrage anlegen', 'breadcrumbs': [ - {'label': 'Ausschreibungen', 'url': reverse('ausschreibungen:liste')}, - {'label': ausschreibung.titel, 'url': reverse('ausschreibungen:detail', kwargs={'pk': ausschreibung_id})}, - {'label': 'Bieterfragen', 'url': reverse('ausschreibungen:bieterfragen:liste', kwargs={'ausschreibung_id': ausschreibung_id})}, + {'label': 'Ausschreibungen', 'url': '/ausschreibungen/'}, + {'label': ausschreibung.titel, 'url': f'/ausschreibungen/{ausschreibung_id}/'}, + {'label': 'Bieterfragen', 'url': f'/ausschreibungen/{ausschreibung_id}/bieterfragen/'}, {'label': 'Neu', 'url': None}, ], }) @@ -291,9 +290,9 @@ def bieterfrage_detail(request, ausschreibung_id, pk): 'bieterfrage': bieterfrage, 'ausschreibung': ausschreibung, 'breadcrumbs': [ - {'label': 'Ausschreibungen', 'url': reverse('ausschreibungen:liste')}, - {'label': ausschreibung.titel, 'url': reverse('ausschreibungen:detail', kwargs={'pk': ausschreibung_id})}, - {'label': 'Bieterfragen', 'url': reverse('ausschreibungen:bieterfragen:liste', kwargs={'ausschreibung_id': ausschreibung_id})}, + {'label': 'Ausschreibungen', 'url': '/ausschreibungen/'}, + {'label': ausschreibung.titel, 'url': f'/ausschreibungen/{ausschreibung_id}/'}, + {'label': 'Bieterfragen', 'url': f'/ausschreibungen/{ausschreibung_id}/bieterfragen/'}, {'label': str(bieterfrage)[:50], 'url': None}, ], }) diff --git a/vergabe_teilnahme/apps/ausschreibungen/tests.py b/vergabe_teilnahme/apps/ausschreibungen/tests.py index c3a7ad2..b4b2731 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(admitted_client): - response = admitted_client.get(reverse("ausschreibungen:liste")) +def test_liste_get(client): + response = client.get(reverse("ausschreibungen:liste")) assert response.status_code == 200 @pytest.mark.django_db -def test_neu_get(admitted_client): - response = admitted_client.get(reverse("ausschreibungen:neu")) +def test_neu_get(client): + response = client.get(reverse("ausschreibungen:neu")) assert response.status_code == 200 @pytest.mark.django_db -def test_neu_post_valid(admitted_client): +def test_neu_post_valid(client): data = {"titel": "Neue Ausschreibung", "ausschreiber": "Stadt XY"} - response = admitted_client.post(reverse("ausschreibungen:neu"), data) + response = 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(admitted_client): +def test_detail_get(client): a = AusschreibungFactory() - response = admitted_client.get(reverse("ausschreibungen:detail", kwargs={"pk": a.pk})) + response = client.get(reverse("ausschreibungen:detail", kwargs={"pk": a.pk})) assert response.status_code == 200 @pytest.mark.django_db -def test_status_post(admitted_client): +def test_status_post(client): a = AusschreibungFactory(status=1) url = reverse("ausschreibungen:status", kwargs={"pk": a.pk}) - response = admitted_client.post(url, {"status": "4"}) + response = 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(admitted_client): +def test_status_htmx_returns_partial(client): a = AusschreibungFactory(status=1) url = reverse("ausschreibungen:status", kwargs={"pk": a.pk}) - response = admitted_client.post(url, {"status": "3"}, HTTP_HX_REQUEST="true") + response = 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/ausschreibungen/views.py b/vergabe_teilnahme/apps/ausschreibungen/views.py index f6e74e8..a4c0d7c 100644 --- a/vergabe_teilnahme/apps/ausschreibungen/views.py +++ b/vergabe_teilnahme/apps/ausschreibungen/views.py @@ -1,7 +1,6 @@ from datetime import date, timedelta from django.shortcuts import get_object_or_404, redirect, render -from django.urls import reverse from django.utils import timezone from vergabe_teilnahme.apps.accounts.models import Mitarbeiter @@ -102,7 +101,7 @@ def ausschreibung_neu(request): 'historisch': historisch, 'titel': 'Neue Ausschreibung', 'breadcrumbs': [ - {'label': 'Ausschreibungen', 'url': reverse('ausschreibungen:liste')}, + {'label': 'Ausschreibungen', 'url': '/ausschreibungen/'}, {'label': 'Neu', 'url': None}, ], }) @@ -112,11 +111,7 @@ def ausschreibung_detail(request, pk): from django.db.models import Count, Q from vergabe_teilnahme.apps.aufgaben.models import Aufgabe - from vergabe_teilnahme.apps.core.services import ( - aufgaben_score, - build_phase_nav, - get_deadline_warnings, - ) + from vergabe_teilnahme.apps.core.services import aufgaben_score, build_phase_nav, get_deadline_warnings from vergabe_teilnahme.apps.lose.models import Los a = get_object_or_404(Ausschreibung, pk=pk) @@ -141,7 +136,7 @@ def ausschreibung_detail(request, pk): 'warnungen': get_deadline_warnings(a), 'aufgaben_score': aufgaben_score(Aufgabe.objects.filter(ausschreibung=a)), 'breadcrumbs': [ - {'label': 'Ausschreibungen', 'url': reverse('ausschreibungen:liste')}, + {'label': 'Ausschreibungen', 'url': '/ausschreibungen/'}, {'label': a.titel, 'url': None}, ], } @@ -161,8 +156,8 @@ def ausschreibung_bearbeiten(request, pk): 'titel': 'Ausschreibung bearbeiten', 'ausschreibung': a, 'breadcrumbs': [ - {'label': 'Ausschreibungen', 'url': reverse('ausschreibungen:liste')}, - {'label': a.titel, 'url': reverse('ausschreibungen:detail', kwargs={'pk': pk})}, + {'label': 'Ausschreibungen', 'url': '/ausschreibungen/'}, + {'label': a.titel, 'url': f'/ausschreibungen/{pk}/'}, {'label': 'Bearbeiten', 'url': None}, ], }) @@ -202,8 +197,8 @@ def ausschreibung_entscheidung(request, pk): 'regelergebnis': entscheidungsregel_auswertung(a), 'ausschlusskriterien_nicht_erfuellbar': ausschlusskriterien, 'breadcrumbs': [ - {'label': 'Ausschreibungen', 'url': reverse('ausschreibungen:liste')}, - {'label': a.titel, 'url': reverse('ausschreibungen:detail', kwargs={'pk': pk})}, + {'label': 'Ausschreibungen', 'url': '/ausschreibungen/'}, + {'label': a.titel, 'url': f'/ausschreibungen/{pk}/'}, {'label': 'Teilnahmeentscheidung', 'url': None}, ], } @@ -232,8 +227,8 @@ def freigaben_uebersicht(request, pk): 'ct_id': ct.pk, 'freigabe_typ_choices': Freigabe.TYP_CHOICES, 'breadcrumbs': [ - {'label': 'Ausschreibungen', 'url': reverse('ausschreibungen:liste')}, - {'label': a.titel, 'url': reverse('ausschreibungen:detail', kwargs={'pk': pk})}, + {'label': 'Ausschreibungen', 'url': '/ausschreibungen/'}, + {'label': a.titel, 'url': f'/ausschreibungen/{pk}/'}, {'label': 'Freigaben', 'url': None}, ], } @@ -250,8 +245,8 @@ def ausschreibung_archivieren(request, pk): return render(request, 'ausschreibungen/archivieren_confirm.html', { 'ausschreibung': a, 'breadcrumbs': [ - {'label': 'Ausschreibungen', 'url': reverse('ausschreibungen:liste')}, - {'label': a.titel, 'url': reverse('ausschreibungen:detail', kwargs={'pk': pk})}, + {'label': 'Ausschreibungen', 'url': '/ausschreibungen/'}, + {'label': a.titel, 'url': f'/ausschreibungen/{pk}/'}, {'label': 'Archivieren', 'url': None}, ], }) diff --git a/vergabe_teilnahme/apps/bibliothek/tests.py b/vergabe_teilnahme/apps/bibliothek/tests.py index 44465ee..740464f 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(admitted_client): +def test_nachweis_liste_filter_abgelaufen(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 = admitted_client.get(url + '?tab=abgelaufen') + response = 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(admitted_client): @pytest.mark.django_db -def test_entscheidungsregel_inaktiv_nicht_in_liste(admitted_client): +def test_entscheidungsregel_inaktiv_nicht_in_liste(client): Entscheidungsregel.objects.create( regelname='Aktive Regel', kategorie='ausschlusskriterium', empfehlung='teilnehmen', aktiv=True ) @@ -40,7 +40,7 @@ def test_entscheidungsregel_inaktiv_nicht_in_liste(admitted_client): regelname='Inaktive Regel', kategorie='frist', empfehlung='pruefen', aktiv=False ) url = reverse('bibliothek:entscheidungsregeln_liste') - response = admitted_client.get(url) + response = 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(admitted_client): @pytest.mark.django_db -def test_entscheidungsregel_toggle(admitted_client): +def test_entscheidungsregel_toggle(client): r = Entscheidungsregel.objects.create( regelname='Toggle-Regel', kategorie='ausschlusskriterium', empfehlung='teilnehmen', aktiv=True ) url = reverse('bibliothek:er_toggle', kwargs={'pk': r.pk}) - admitted_client.post(url) + client.post(url) r.refresh_from_db() assert r.aktiv is False @pytest.mark.django_db -def test_nachweis_neue_version_ersetzt_alten(admitted_client): +def test_nachweis_neue_version_ersetzt_alten(client): alt = Nachweis.objects.create(titel='Zertifikat ISO', version='1.0') url = reverse('bibliothek:nachweis_version', kwargs={'pk': alt.pk}) - admitted_client.post(url, { + client.post(url, { 'titel': 'Zertifikat ISO', 'version': '1.0', 'sprache': 'de', diff --git a/vergabe_teilnahme/apps/bibliothek/views.py b/vergabe_teilnahme/apps/bibliothek/views.py index 2db4666..dc96e77 100644 --- a/vergabe_teilnahme/apps/bibliothek/views.py +++ b/vergabe_teilnahme/apps/bibliothek/views.py @@ -3,12 +3,12 @@ from datetime import date, timedelta from django import forms from django.contrib import messages from django.shortcuts import get_object_or_404, redirect, render -from django.urls import reverse from vergabe_teilnahme.apps.accounts.models import Mitarbeiter from .models import Entscheidungsregel, Leistungsblatt, Nachweis, Referenz + # ── Forms ───────────────────────────────────────────────────────────────────── class NachweisForm(forms.ModelForm): @@ -173,7 +173,7 @@ def nachweis_neu(request): return render(request, 'bibliothek/nachweis_form.html', { 'form': form, 'breadcrumbs': [ - {'label': 'Nachweise', 'url': reverse('bibliothek:nachweise_liste')}, + {'label': 'Nachweise', 'url': '/bibliothek/nachweise/'}, {'label': 'Neu', 'url': None}, ], }) @@ -184,7 +184,7 @@ def nachweis_detail(request, pk): return render(request, 'bibliothek/nachweis_detail.html', { 'nachweis': nachweis, 'breadcrumbs': [ - {'label': 'Nachweise', 'url': reverse('bibliothek:nachweise_liste')}, + {'label': 'Nachweise', 'url': '/bibliothek/nachweise/'}, {'label': nachweis.titel, 'url': None}, ], }) @@ -205,8 +205,8 @@ def nachweis_bearbeiten(request, pk): 'form': form, 'nachweis': nachweis, 'breadcrumbs': [ - {'label': 'Nachweise', 'url': reverse('bibliothek:nachweise_liste')}, - {'label': nachweis.titel, 'url': reverse('bibliothek:nachweis_detail', kwargs={'pk': pk})}, + {'label': 'Nachweise', 'url': '/bibliothek/nachweise/'}, + {'label': nachweis.titel, 'url': f'/bibliothek/nachweise/{pk}/'}, {'label': 'Bearbeiten', 'url': None}, ], }) @@ -237,8 +237,8 @@ def nachweis_neue_version(request, pk): 'nachweis': alter_nachweis, 'neue_version': True, 'breadcrumbs': [ - {'label': 'Nachweise', 'url': reverse('bibliothek:nachweise_liste')}, - {'label': alter_nachweis.titel, 'url': reverse('bibliothek:nachweis_detail', kwargs={'pk': pk})}, + {'label': 'Nachweise', 'url': '/bibliothek/nachweise/'}, + {'label': alter_nachweis.titel, 'url': f'/bibliothek/nachweise/{pk}/'}, {'label': 'Neue Version', 'url': None}, ], }) @@ -279,7 +279,7 @@ def referenz_neu(request): return render(request, 'bibliothek/referenz_form.html', { 'form': form, 'breadcrumbs': [ - {'label': 'Referenzen', 'url': reverse('bibliothek:referenz_liste')}, + {'label': 'Referenzen', 'url': '/bibliothek/referenzen/'}, {'label': 'Neu', 'url': None}, ], }) @@ -290,7 +290,7 @@ def referenz_detail(request, pk): return render(request, 'bibliothek/referenz_detail.html', { 'ref': ref, 'breadcrumbs': [ - {'label': 'Referenzen', 'url': reverse('bibliothek:referenz_liste')}, + {'label': 'Referenzen', 'url': '/bibliothek/referenzen/'}, {'label': ref.referenztitel, 'url': None}, ], }) @@ -311,8 +311,8 @@ def referenz_bearbeiten(request, pk): 'form': form, 'ref': ref, 'breadcrumbs': [ - {'label': 'Referenzen', 'url': reverse('bibliothek:referenz_liste')}, - {'label': ref.referenztitel, 'url': reverse('bibliothek:referenz_detail', kwargs={'pk': pk})}, + {'label': 'Referenzen', 'url': '/bibliothek/referenzen/'}, + {'label': ref.referenztitel, 'url': f'/bibliothek/referenzen/{pk}/'}, {'label': 'Bearbeiten', 'url': None}, ], }) @@ -365,7 +365,7 @@ def leistungsblatt_neu(request): return render(request, 'bibliothek/leistungsblatt_form.html', { 'form': form, 'breadcrumbs': [ - {'label': 'Leistungsblätter', 'url': reverse('bibliothek:leistungsblaetter_liste')}, + {'label': 'Leistungsblätter', 'url': '/bibliothek/leistungsblaetter/'}, {'label': 'Neu', 'url': None}, ], }) @@ -386,7 +386,7 @@ def leistungsblatt_bearbeiten(request, pk): 'form': form, 'obj': obj, 'breadcrumbs': [ - {'label': 'Leistungsblätter', 'url': reverse('bibliothek:leistungsblaetter_liste')}, + {'label': 'Leistungsblätter', 'url': '/bibliothek/leistungsblaetter/'}, {'label': 'Bearbeiten', 'url': None}, ], }) @@ -415,7 +415,7 @@ def entscheidungsregel_neu(request): return render(request, 'bibliothek/entscheidungsregel_form.html', { 'form': form, 'breadcrumbs': [ - {'label': 'Entscheidungsregeln', 'url': reverse('bibliothek:entscheidungsregeln_liste')}, + {'label': 'Entscheidungsregeln', 'url': '/bibliothek/entscheidungsregeln/'}, {'label': 'Neu', 'url': None}, ], }) @@ -436,7 +436,7 @@ def entscheidungsregel_bearbeiten(request, pk): 'form': form, 'obj': obj, 'breadcrumbs': [ - {'label': 'Entscheidungsregeln', 'url': reverse('bibliothek:entscheidungsregeln_liste')}, + {'label': 'Entscheidungsregeln', 'url': '/bibliothek/entscheidungsregeln/'}, {'label': 'Bearbeiten', 'url': None}, ], }) diff --git a/vergabe_teilnahme/apps/core/services.py b/vergabe_teilnahme/apps/core/services.py index 481c8ee..a874e80 100644 --- a/vergabe_teilnahme/apps/core/services.py +++ b/vergabe_teilnahme/apps/core/services.py @@ -1,8 +1,6 @@ from datetime import date from decimal import Decimal -from django.urls import reverse - PHASEN = [ (1, 'Recherche & Unterlagen'), (2, 'Teilnahmeentscheidung'), @@ -37,7 +35,7 @@ def aufgaben_score(qs): def build_phase_nav(ausschreibung, current_url=''): from vergabe_teilnahme.apps.aufgaben.models import Aufgabe aktuelle_phase = STATUS_TO_PHASE.get(ausschreibung.status, 1) - base = reverse('ausschreibungen:detail', kwargs={'pk': ausschreibung.pk}).rstrip('/') + base = f'/ausschreibungen/{ausschreibung.pk}' phase_urls = { 1: f'{base}/', 2: f'{base}/entscheidung/', diff --git a/vergabe_teilnahme/apps/dokumente/downloads.py b/vergabe_teilnahme/apps/dokumente/downloads.py deleted file mode 100644 index e95b091..0000000 --- a/vergabe_teilnahme/apps/dokumente/downloads.py +++ /dev/null @@ -1,23 +0,0 @@ -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 97e4c29..2e6d010 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(admitted_client, tmp_path, settings): +def test_dokument_upload_valid(client, tmp_path, settings): settings.MEDIA_ROOT = tmp_path a = AusschreibungFactory() url = reverse('ausschreibungen:dokumente:upload', kwargs={'ausschreibung_id': a.pk}) - response = admitted_client.post(url, {'datei': _pdf_file(), 'kategorie': 'intern', 'version': '1.0'}) + response = 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(admitted_client, tmp_path, settings): +def test_dokument_upload_invalid_extension(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 = admitted_client.post(url, {'datei': bad_file, 'kategorie': 'intern', 'version': '1.0'}) + response = 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(admitted_client, tmp_path, settings): +def test_dokument_upload_too_large(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 = admitted_client.post(url, {'datei': big_file, 'kategorie': 'intern', 'version': '1.0'}) + response = 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(admitted_client, tmp_path, settings): +def test_dokument_neue_version(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 = admitted_client.post(url, {'datei': _pdf_file('v2.pdf'), 'version': '2.0'}) + response = 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(admitted_client, tmp_path, settings): @pytest.mark.django_db -def test_dokument_finale_version(admitted_client): +def test_dokument_finale_version(client): dok = DokumentFactory(status='freigegeben') a = dok.ausschreibung url = reverse('ausschreibungen:dokumente:finale_version', kwargs={'ausschreibung_id': a.pk, 'pk': dok.pk}) - response = admitted_client.post(url) + response = 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 f7a04bb..f10c3a1 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(admitted_client): +def test_lose_liste_get(client): a = AusschreibungFactory() url = reverse('ausschreibungen:lose:liste', kwargs={'ausschreibung_id': a.pk}) - response = admitted_client.get(url) + response = client.get(url) assert response.status_code == 200 @pytest.mark.django_db -def test_los_neu_post(admitted_client): +def test_los_neu_post(client): a = AusschreibungFactory() url = reverse('ausschreibungen:lose:neu', kwargs={'ausschreibung_id': a.pk}) - response = admitted_client.post(url, {'losnummer': 'L01', 'lostitel': 'Testlos'}) + response = 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(admitted_client): +def test_los_detail_get(client): los = LosFactory() url = reverse('ausschreibungen:lose:detail', kwargs={'ausschreibung_id': los.ausschreibung_id, 'los_pk': los.pk}) - response = admitted_client.get(url) + response = client.get(url) assert response.status_code == 200 @@ -68,21 +68,21 @@ def test_los_detail_get(admitted_client): @pytest.mark.django_db -def test_anforderung_neu_post_muss(admitted_client): +def test_anforderung_neu_post_muss(client): a = AusschreibungFactory() url = reverse('ausschreibungen:lose:anforderung_neu', kwargs={'ausschreibung_id': a.pk}) - response = admitted_client.post(url, {'titel': 'Neue Anforderung', 'verbindlichkeit': 'muss', + response = 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(admitted_client): +def test_anforderung_status_htmx(client): anf = AnforderungFactory() url = reverse('ausschreibungen:lose:anforderung_status', kwargs={'ausschreibung_id': anf.ausschreibung_id, 'pk': anf.pk}) - response = admitted_client.post(url, {'erfuellungsstatus': 'nicht_erfuellbar'}, + response = 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(admitted_client): @pytest.mark.django_db -def test_ausschlusskriterium_banner_shown(admitted_client): +def test_ausschlusskriterium_banner_shown(client): a = AusschreibungFactory() AnforderungFactory( ausschreibung=a, @@ -98,17 +98,17 @@ def test_ausschlusskriterium_banner_shown(admitted_client): erfuellungsstatus='nicht_erfuellbar', ) url = reverse('ausschreibungen:entscheidung', kwargs={'pk': a.pk}) - response = admitted_client.get(url) + response = 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(admitted_client): +def test_nachweis_zuordnen(client): anf = AnforderungFactory() n = NachweisFactory() url = reverse('ausschreibungen:lose:nachweis_zuordnen', kwargs={'ausschreibung_id': anf.ausschreibung_id, 'pk': anf.pk}) - response = admitted_client.post(url, {'nachweis_pk': n.pk}) + response = 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/lose/views.py b/vergabe_teilnahme/apps/lose/views.py index dfb0296..64cb308 100644 --- a/vergabe_teilnahme/apps/lose/views.py +++ b/vergabe_teilnahme/apps/lose/views.py @@ -1,5 +1,4 @@ from django.shortcuts import get_object_or_404, redirect, render -from django.urls import reverse from vergabe_teilnahme.apps.ausschreibungen.models import Ausschreibung from vergabe_teilnahme.apps.bibliothek.models import Nachweis @@ -14,8 +13,8 @@ def _is_htmx(request): def _ausschreibung_breadcrumbs(ausschreibung, *extra): crumbs = [ - {'label': 'Ausschreibungen', 'url': reverse('ausschreibungen:liste')}, - {'label': ausschreibung.titel, 'url': reverse('ausschreibungen:detail', kwargs={'pk': ausschreibung.pk})}, + {'label': 'Ausschreibungen', 'url': '/ausschreibungen/'}, + {'label': ausschreibung.titel, 'url': f'/ausschreibungen/{ausschreibung.pk}/'}, ] for label, url in extra: crumbs.append({'label': label, 'url': url}) @@ -66,7 +65,7 @@ def los_neu(request, ausschreibung_id): 'ausschreibung': ausschreibung, 'titel': 'Los hinzufügen', 'breadcrumbs': _ausschreibung_breadcrumbs(ausschreibung, - ('Lose', reverse('ausschreibungen:lose:liste', kwargs={'ausschreibung_id': ausschreibung_id})), + ('Lose', f'/ausschreibungen/{ausschreibung_id}/lose/'), ('Neu', None)), }) @@ -80,7 +79,7 @@ def los_detail(request, ausschreibung_id, los_pk): 'los': los, 'anforderungen': anforderungen, 'breadcrumbs': _ausschreibung_breadcrumbs(ausschreibung, - ('Lose', reverse('ausschreibungen:lose:liste', kwargs={'ausschreibung_id': ausschreibung_id})), + ('Lose', f'/ausschreibungen/{ausschreibung_id}/lose/'), (str(los), None)), }) @@ -98,8 +97,8 @@ def los_bearbeiten(request, ausschreibung_id, los_pk): 'los': los, 'titel': 'Los bearbeiten', 'breadcrumbs': _ausschreibung_breadcrumbs(ausschreibung, - ('Lose', reverse('ausschreibungen:lose:liste', kwargs={'ausschreibung_id': ausschreibung_id})), - (str(los), reverse('ausschreibungen:lose:detail', kwargs={'ausschreibung_id': ausschreibung_id, 'los_pk': los_pk})), + ('Lose', f'/ausschreibungen/{ausschreibung_id}/lose/'), + (str(los), f'/ausschreibungen/{ausschreibung_id}/lose/{los_pk}/'), ('Bearbeiten', None)), }) @@ -114,8 +113,8 @@ def los_loeschen(request, ausschreibung_id, los_pk): 'ausschreibung': ausschreibung, 'los': los, 'breadcrumbs': _ausschreibung_breadcrumbs(ausschreibung, - ('Lose', reverse('ausschreibungen:lose:liste', kwargs={'ausschreibung_id': ausschreibung_id})), - (str(los), reverse('ausschreibungen:lose:detail', kwargs={'ausschreibung_id': ausschreibung_id, 'los_pk': los_pk})), + ('Lose', f'/ausschreibungen/{ausschreibung_id}/lose/'), + (str(los), f'/ausschreibungen/{ausschreibung_id}/lose/{los_pk}/'), ('Löschen', None)), }) @@ -188,7 +187,7 @@ def anforderung_neu(request, ausschreibung_id): 'ausschreibung': ausschreibung, 'titel': 'Anforderung anlegen', 'breadcrumbs': _ausschreibung_breadcrumbs(ausschreibung, - ('Anforderungen', reverse('ausschreibungen:lose:anforderungen_liste', kwargs={'ausschreibung_id': ausschreibung_id})), + ('Anforderungen', f'/ausschreibungen/{ausschreibung_id}/lose/anforderungen/'), ('Neu', None)), }) @@ -202,7 +201,7 @@ def anforderung_detail(request, ausschreibung_id, pk): 'anforderung': anforderung, 'aufgaben': aufgaben, 'breadcrumbs': _ausschreibung_breadcrumbs(ausschreibung, - ('Anforderungen', reverse('ausschreibungen:lose:anforderungen_liste', kwargs={'ausschreibung_id': ausschreibung_id})), + ('Anforderungen', f'/ausschreibungen/{ausschreibung_id}/lose/anforderungen/'), (anforderung.titel[:50], None)), }) @@ -221,8 +220,8 @@ def anforderung_bearbeiten(request, ausschreibung_id, pk): 'anforderung': anforderung, 'titel': 'Anforderung bearbeiten', 'breadcrumbs': _ausschreibung_breadcrumbs(ausschreibung, - ('Anforderungen', reverse('ausschreibungen:lose:anforderungen_liste', kwargs={'ausschreibung_id': ausschreibung_id})), - (anforderung.titel[:50], reverse('ausschreibungen:lose:anforderung_detail', kwargs={'ausschreibung_id': ausschreibung_id, 'pk': pk})), + ('Anforderungen', f'/ausschreibungen/{ausschreibung_id}/lose/anforderungen/'), + (anforderung.titel[:50], f'/ausschreibungen/{ausschreibung_id}/lose/anforderungen/{pk}/'), ('Bearbeiten', None)), }) diff --git a/vergabe_teilnahme/apps/marktbegleiter/tests.py b/vergabe_teilnahme/apps/marktbegleiter/tests.py index 5237589..065935e 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(admitted_client): +def test_passage_anlegen_mit_score_10(client): a = AusschreibungFactory() mb = make_mb() url = reverse('marktbegleiter:passagen:neu', kwargs={'ausschreibung_id': a.pk}) - response = admitted_client.post(url, { + response = 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(admitted_client): +def test_auswertung_score_durchschnitt(client): a = AusschreibungFactory() mb = make_mb() Ausschreibungspassage.objects.create( @@ -50,14 +50,14 @@ def test_auswertung_score_durchschnitt(admitted_client): ausschreibung=a, marktbegleiter=mb, passage='P2', verlaesslichkeitsscore=6 ) url = reverse('marktbegleiter:auswertung', kwargs={'pk': mb.pk}) - response = admitted_client.get(url) + response = 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(admitted_client): +def test_marktbegleiter_detail_zeigt_passagen(client): a = AusschreibungFactory() mb = make_mb(name='DetailBegleiter') Ausschreibungspassage.objects.create( @@ -65,6 +65,6 @@ def test_marktbegleiter_detail_zeigt_passagen(admitted_client): passage='Sichtbare Passage', verlaesslichkeitsscore=5 ) url = reverse('marktbegleiter:detail', kwargs={'pk': mb.pk}) - response = admitted_client.get(url) + response = 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 6c41c89..54f9ba5 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(admitted_client): +def test_ergebnis_gewonnen_erstellt_kickoff_aufgabe(client): from vergabe_teilnahme.apps.aufgaben.models import Aufgabe a = AusschreibungFactory(status=9) url = reverse('ausschreibungen:nachbetrachtung:detail', kwargs={'ausschreibung_id': a.pk}) - response = admitted_client.post(url, { + response = client.post(url, { 'ergebnis': 'gewonnen', 'verlustgruende': '[]', }) @@ -53,21 +53,21 @@ def test_ergebnis_gewonnen_erstellt_kickoff_aufgabe(admitted_client): @pytest.mark.django_db -def test_ergebnis_verloren_setzt_status_11(admitted_client): +def test_ergebnis_verloren_setzt_status_11(client): a = AusschreibungFactory(status=9) url = reverse('ausschreibungen:nachbetrachtung:detail', kwargs={'ausschreibung_id': a.pk}) - admitted_client.post(url, {'ergebnis': 'verloren', 'verlustgruende': '[]'}) + client.post(url, {'ergebnis': 'verloren', 'verlustgruende': '[]'}) a.refresh_from_db() assert a.status == 11 @pytest.mark.django_db -def test_verlustgruende_json_gespeichert(admitted_client): +def test_verlustgruende_json_gespeichert(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}] - admitted_client.post(url, {'ergebnis': 'verloren', 'verlustgruende': json.dumps(gruende)}) + 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 1690c81..f0284e6 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(admitted_client): +def test_subunternehmer_zuordnung_zu_los(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}) - admitted_client.post(url, {'subunternehmer_id': sub.pk, 'konkrete_leistung': 'IT-Support'}) + 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(admitted_client): +def test_gesperrter_subunternehmer_im_suchmodal(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 = admitted_client.get(url + '?q=GesperrterSub') + response = 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(admitted_client): +def test_subunternehmer_praeferenz_update(client): sub = make_sub(praeferenz='zugelassen') url = reverse('partner:su_praeferenz', kwargs={'pk': sub.pk}) - admitted_client.post(url, {'praeferenz': 'bevorzugt'}) + client.post(url, {'praeferenz': 'bevorzugt'}) sub.refresh_from_db() assert sub.praeferenz == 'bevorzugt' @pytest.mark.django_db -def test_zuordnung_toggle_zusage(admitted_client): +def test_zuordnung_toggle_zusage(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}) - admitted_client.post(url, {'feld': 'zusage_vorhanden'}) + client.post(url, {'feld': 'zusage_vorhanden'}) z.refresh_from_db() assert z.zusage_vorhanden is True diff --git a/vergabe_teilnahme/apps/partner/views.py b/vergabe_teilnahme/apps/partner/views.py index 0dfefd1..fd65395 100644 --- a/vergabe_teilnahme/apps/partner/views.py +++ b/vergabe_teilnahme/apps/partner/views.py @@ -1,7 +1,6 @@ from django import forms from django.contrib import messages from django.shortcuts import get_object_or_404, redirect, render -from django.urls import reverse from vergabe_teilnahme.apps.ausschreibungen.models import Ausschreibung from vergabe_teilnahme.apps.lose.models import Los @@ -98,7 +97,7 @@ def subunternehmer_neu(request): return render(request, 'partner/subunternehmer_form.html', { 'form': form, 'breadcrumbs': [ - {'label': 'Subunternehmer', 'url': reverse('partner:su_liste')}, + {'label': 'Subunternehmer', 'url': '/partner/subunternehmer/'}, {'label': 'Neu', 'url': None}, ], }) @@ -114,7 +113,7 @@ def subunternehmer_detail(request, pk): 'sub': sub, 'zuordnungen': zuordnungen, 'breadcrumbs': [ - {'label': 'Subunternehmer', 'url': reverse('partner:su_liste')}, + {'label': 'Subunternehmer', 'url': '/partner/subunternehmer/'}, {'label': sub.name, 'url': None}, ], }) @@ -135,8 +134,8 @@ def subunternehmer_bearbeiten(request, pk): 'form': form, 'sub': sub, 'breadcrumbs': [ - {'label': 'Subunternehmer', 'url': reverse('partner:su_liste')}, - {'label': sub.name, 'url': reverse('partner:su_detail', kwargs={'pk': pk})}, + {'label': 'Subunternehmer', 'url': '/partner/subunternehmer/'}, + {'label': sub.name, 'url': f'/partner/subunternehmer/{pk}/'}, {'label': 'Bearbeiten', 'url': None}, ], }) @@ -210,7 +209,7 @@ def dienstleistertyp_neu(request): return render(request, 'partner/dienstleistertyp_form.html', { 'form': form, 'breadcrumbs': [ - {'label': 'Dienstleistertypen', 'url': reverse('partner:dt_liste')}, + {'label': 'Dienstleistertypen', 'url': '/partner/dienstleistertypen/'}, {'label': 'Neu', 'url': None}, ], }) @@ -231,7 +230,7 @@ def dienstleistertyp_bearbeiten(request, pk): 'form': form, 'obj': obj, 'breadcrumbs': [ - {'label': 'Dienstleistertypen', 'url': reverse('partner:dt_liste')}, + {'label': 'Dienstleistertypen', 'url': '/partner/dienstleistertypen/'}, {'label': 'Bearbeiten', 'url': None}, ], }) diff --git a/vergabe_teilnahme/apps/preise/tests.py b/vergabe_teilnahme/apps/preise/tests.py index 515468b..4b791d3 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(admitted_client): +def test_vergleichsgewicht_null_gespeichert(client): a = AusschreibungFactory() url = reverse('ausschreibungen:preise:neu', kwargs={'ausschreibung_id': a.pk}) - response = admitted_client.post(url, { + response = client.post(url, { 'leistungstyp': 'Test', 'konkrete_leistung': 'Leistung mit Gewicht 0', 'mengeneinheit': 'Stück', @@ -39,10 +39,10 @@ def test_vergleichsgewicht_null_gespeichert(admitted_client): @pytest.mark.django_db -def test_vergleichsgewicht_zu_gross_validation_error(admitted_client): +def test_vergleichsgewicht_zu_gross_validation_error(client): a = AusschreibungFactory() url = reverse('ausschreibungen:preise:neu', kwargs={'ausschreibung_id': a.pk}) - response = admitted_client.post(url, { + response = 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(admitted_client): +def test_auswertung_view_200(client): a = AusschreibungFactory() PreispunktFactory(ausschreibung=a, einzelpreis=Decimal('150.00')) url = reverse('ausschreibungen:preise:auswertung', kwargs={'ausschreibung_id': a.pk}) - response = admitted_client.get(url) + response = 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 bd7183f..f9fc320 100644 --- a/vergabe_teilnahme/settings/base.py +++ b/vergabe_teilnahme/settings/base.py @@ -3,8 +3,6 @@ from pathlib import Path import dj_database_url from decouple import config -from .paths import application_path - BASE_DIR = Path(__file__).resolve().parent.parent.parent SECRET_KEY = config('SECRET_KEY', default='django-insecure-change-me') @@ -37,7 +35,6 @@ 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', ] @@ -56,7 +53,6 @@ TEMPLATES = [ 'django.contrib.auth.context_processors.auth', 'django.contrib.messages.context_processors.messages', 'vergabe_teilnahme.apps.core.context_processors.vergabe_context', - 'vergabe_teilnahme.apps.accounts.context_processors.company_context', ], }, }, @@ -71,20 +67,6 @@ DATABASES = { } AUTH_USER_MODEL = 'accounts.Mitarbeiter' -LOGIN_URL = 'accounts:login' -LOGIN_REDIRECT_URL = 'home' -LOGOUT_REDIRECT_URL = 'accounts:login' - -# The edge strips this exact prefix before forwarding to this isolated instance. -# Django uses SCRIPT_NAME for URL generation; tenant selection stays with the -# admitted deployment/database binding, not arbitrary forwarded headers. -APP_BASE_PATH = application_path(config('APP_BASE_PATH', default='')) -FORCE_SCRIPT_NAME = APP_BASE_PATH or None -SESSION_COOKIE_PATH = CSRF_COOKIE_PATH = APP_BASE_PATH + '/' -if APP_BASE_PATH: - _cookie_prefix = 'vergabe_' + APP_BASE_PATH[1:].replace('-', '_') - SESSION_COOKIE_NAME = _cookie_prefix + '_sessionid' - CSRF_COOKIE_NAME = _cookie_prefix + '_csrftoken' DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' @@ -100,11 +82,11 @@ TIME_ZONE = 'Europe/Berlin' USE_I18N = True USE_TZ = True -STATIC_URL = APP_BASE_PATH + '/static/' +STATIC_URL = '/static/' STATIC_ROOT = BASE_DIR / 'staticfiles' STATICFILES_DIRS = [BASE_DIR / 'static'] -MEDIA_URL = APP_BASE_PATH + '/media/' +MEDIA_URL = '/media/' MEDIA_ROOT = BASE_DIR / 'media' MAX_UPLOAD_SIZE = config('MAX_UPLOAD_SIZE', default=52428800, cast=int) @@ -121,13 +103,3 @@ ISSUE_FACADE_GITEA: dict | None = None # 'owner': 'org', # 'repo': 'vergabe', # } - -# Enabled only after the exact client, company and provider release are admitted. -NETKINGDOM_ENABLED = config('NETKINGDOM_ENABLED', default=False, cast=bool) -NETKINGDOM_ISSUER = config('NETKINGDOM_ISSUER', default='https://kc.coulomb.social') -NETKINGDOM_CLIENT_ID = config('NETKINGDOM_CLIENT_ID', default='') -NETKINGDOM_CALLBACK = config('NETKINGDOM_CALLBACK', default='') -NETKINGDOM_TENANT = config('NETKINGDOM_TENANT', default='') -COMPANY_DISPLAY_NAME = config('COMPANY_DISPLAY_NAME', default='Ihrem Unternehmen') - -NETKINGDOM_ACCOUNT_PORTAL_URL = config('NETKINGDOM_ACCOUNT_PORTAL_URL', default='https://users.coulomb.social') diff --git a/vergabe_teilnahme/settings/paths.py b/vergabe_teilnahme/settings/paths.py deleted file mode 100644 index c05ce4b..0000000 --- a/vergabe_teilnahme/settings/paths.py +++ /dev/null @@ -1,12 +0,0 @@ -import re - -from django.core.exceptions import ImproperlyConfigured - - -def application_path(value): - """A deployment-owned tenant path, never a value taken from request headers.""" - if value in ('', '/'): - return '' - if not re.fullmatch(r'/[a-z0-9]+(?:-[a-z0-9]+)*', value): - raise ImproperlyConfigured('APP_BASE_PATH must be empty or /lowercase-tenant-slug') - return value diff --git a/vergabe_teilnahme/templates/accounts/base.html b/vergabe_teilnahme/templates/accounts/base.html deleted file mode 100644 index efcde65..0000000 --- a/vergabe_teilnahme/templates/accounts/base.html +++ /dev/null @@ -1,16 +0,0 @@ -{% load static %} - - - - - - {% block title %}Anmelden{% endblock %} · Vergabe Teilnahme - - - -
-

Vergabe Teilnahme

- {% block content %}{% endblock %} -
- - diff --git a/vergabe_teilnahme/templates/accounts/confirm.html b/vergabe_teilnahme/templates/accounts/confirm.html deleted file mode 100644 index 12bf99a..0000000 --- a/vergabe_teilnahme/templates/accounts/confirm.html +++ /dev/null @@ -1,12 +0,0 @@ -{% extends "accounts/base.html" %} -{% block title %}Anmeldung bestätigen{% endblock %} -{% block content %} -

Anmelden bei {{ company_name }}

-

Sie fahren mit {{ identity_label }} fort.

-
- {% csrf_token %} -
-
- {% csrf_token %} -
-{% endblock %} diff --git a/vergabe_teilnahme/templates/accounts/login.html b/vergabe_teilnahme/templates/accounts/login.html deleted file mode 100644 index 8d36c17..0000000 --- a/vergabe_teilnahme/templates/accounts/login.html +++ /dev/null @@ -1,13 +0,0 @@ -{% extends "accounts/base.html" %} -{% block content %} -

Anmelden

-

Melden Sie sich mit Ihrem eingerichteten Benutzerkonto an.

-
- {% csrf_token %} - {{ form.as_p }} - - -
-

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 deleted file mode 100644 index 834e3a9..0000000 --- a/vergabe_teilnahme/templates/accounts/password_change.html +++ /dev/null @@ -1,11 +0,0 @@ -{% extends "accounts/base.html" %} -{% block title %}Passwort ändern{% endblock %} -{% block content %} -

Passwort ändern

-
- {% csrf_token %} - {{ form.as_p }} - -
-Zurück zur Übersicht -{% endblock %} diff --git a/vergabe_teilnahme/templates/accounts/password_change_done.html b/vergabe_teilnahme/templates/accounts/password_change_done.html deleted file mode 100644 index 6062fb7..0000000 --- a/vergabe_teilnahme/templates/accounts/password_change_done.html +++ /dev/null @@ -1,7 +0,0 @@ -{% extends "accounts/base.html" %} -{% block title %}Passwort gespeichert{% endblock %} -{% block content %} -

Passwort gespeichert

-

Ihr neues Passwort ist jetzt aktiv.

-Zur Übersicht -{% endblock %} diff --git a/vergabe_teilnahme/templates/accounts/welcome.html b/vergabe_teilnahme/templates/accounts/welcome.html deleted file mode 100644 index c39891c..0000000 --- a/vergabe_teilnahme/templates/accounts/welcome.html +++ /dev/null @@ -1,12 +0,0 @@ -{% extends "accounts/base.html" %} -{% block title %}Willkommen bei {{ company_name }}{% endblock %} -{% block content %} -

Willkommen bei {{ company_name }}

-

Hier bearbeiten Sie die Ausschreibungen Ihres Unternehmens gemeinsam.

-{% if login_error %}{% endif %} -
- {% csrf_token %} - -
-

Verwenden Sie Ihr eigenes Benutzerkonto und das von Ihnen eingerichtete Passwort. Wenn Sie den Zugang für eine andere Person vorbereitet haben, geben Sie ihr den Unternehmenslink weiter.

-{% endblock %} diff --git a/vergabe_teilnahme/templates/ausschreibungen/dashboard.html b/vergabe_teilnahme/templates/ausschreibungen/dashboard.html index aaa2edc..44034be 100644 --- a/vergabe_teilnahme/templates/ausschreibungen/dashboard.html +++ b/vergabe_teilnahme/templates/ausschreibungen/dashboard.html @@ -18,7 +18,7 @@