Compare commits

..

No commits in common. "main" and "factory/customer-delivery" have entirely different histories.

71 changed files with 283 additions and 2546 deletions

View file

@ -2,28 +2,22 @@
# Custodian Brief — vergabe-teilnahme
**Domain:** communication
**Last synced:** 2026-09-27 16:34 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: Database restore and app restart are proved. Await two ordinary users collaborating on a tender, task, and document.)*
- ! Publish and prove the isolated deployment and recovery contract `9065d5cb`
*(wait: Await RAPPS-WP-0014-T03 coherent PostgreSQL/media/issue-state recovery, populated document round-trip and off-host backup evidence; September 25 proves database restore and restart only.)*
- ! Connect identity setup to the company's welcome and sign-in page `455b33f9`
*(wait: Source and live SSO/recovery are delivered, and one founder sign-in passed September 24. Await invited recipient setup-to-welcome-to-workflow acceptance and a second ordinary user.)*
## 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: Await HFACT-WP-0001-T05 natural governed Railiance worker trace and VERGABE-WP-0019 complete invited-user/recovery acceptance. Pilot placement and one sign-in are already evidenced; main-fa9f082 is superseded.)*
## SCOPE Freshness
- SCOPE.md says active but no open workplans remain — refresh Current State.
---
## MCP Orientation (when available)

View file

@ -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", "-"]

View file

@ -41,7 +41,7 @@ It operates on three levels, per `wiki/ProductRequirementsDocument.md`:
Primary customer service/UI repository for the HelixForge factory, selected by
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.

View file

@ -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 | blocked | — | 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 | wait | — | workplans/VERGABE-WP-0019-invited-company-pilot.md |
| task | VERGABE-WP-0019-T04 | wait | — | workplans/VERGABE-WP-0019-invited-company-pilot.md |
| task | VERGABE-WP-0019-T05 | done | — | workplans/VERGABE-WP-0019-invited-company-pilot.md |
| task | VERGABE-WP-0019-T06 | wait | — | 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 |
@ -45,18 +34,6 @@
| task | VT-WP-0002-T13 | done | — | workplans/VT-WP-0002-fachmodelle.md |
| task | VT-WP-0003-T01 | done | — | workplans/VT-WP-0003-basis-ui.md |
| task | VT-WP-0003-T10 | done | — | workplans/VT-WP-0003-basis-ui.md |
| task | VT-WP-0004-T01 | done | — | workplans/VT-WP-0004-dashboard-ausschreibungen.md |
| task | VT-WP-0004-T02 | done | — | workplans/VT-WP-0004-dashboard-ausschreibungen.md |
| task | VT-WP-0004-T03 | done | — | workplans/VT-WP-0004-dashboard-ausschreibungen.md |
| task | VT-WP-0004-T04 | done | — | workplans/VT-WP-0004-dashboard-ausschreibungen.md |
| task | VT-WP-0004-T05 | done | — | workplans/VT-WP-0004-dashboard-ausschreibungen.md |
| task | VT-WP-0004-T06 | done | — | workplans/VT-WP-0004-dashboard-ausschreibungen.md |
| task | VT-WP-0004-T07 | done | — | workplans/VT-WP-0004-dashboard-ausschreibungen.md |
| task | VT-WP-0004-T08 | done | — | workplans/VT-WP-0004-dashboard-ausschreibungen.md |
| task | VT-WP-0004-T09 | done | — | workplans/VT-WP-0004-dashboard-ausschreibungen.md |
| task | VT-WP-0004-T10 | done | — | workplans/VT-WP-0004-dashboard-ausschreibungen.md |
| task | VT-WP-0004-T11 | done | — | workplans/VT-WP-0004-dashboard-ausschreibungen.md |
| task | VT-WP-0004-T12 | done | — | workplans/VT-WP-0004-dashboard-ausschreibungen.md |
| task | VT-WP-0005-T07 | done | — | workplans/VT-WP-0005-lose-anforderungen.md |
| task | VT-WP-0012-T06 | done | — | workplans/VT-WP-0012-querschnitt.md |
| task | VT-WP-0015-T03 | done | — | workplans/VT-WP-0015-aufgaben-verknuepfungen-frist-issuefacade.md |

View file

@ -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

View file

@ -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.

View file

@ -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
}

View file

@ -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
}
}

View file

@ -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
}

View file

@ -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 `<tenant>: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.

View file

@ -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.

View file

@ -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]

160
uv.lock generated
View file

@ -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" },
]

View file

@ -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 {}

View file

@ -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

View file

@ -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

View file

@ -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"
)
],
},
),
]

View file

@ -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')]

View file

@ -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),
}

View file

@ -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

View file

@ -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']

View file

@ -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.

View file

@ -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",
),
]

View file

@ -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.

View file

@ -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'

View file

@ -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},
],
})

View file

@ -38,13 +38,6 @@ class AusschreibungForm(forms.ModelForm):
'entscheidungsbegruendung': forms.Textarea(attrs={'class': 'form-input', 'rows': 3}),
}
def __init__(self, *args, historisch=False, **kwargs):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['teilnahmeentscheidung'].required = False
if historisch:
from vergabe_teilnahme.apps.nachbetrachtung.models import Nachbetrachtung
self.fields['ergebnis'] = forms.ChoiceField(
choices=Nachbetrachtung.ERGEBNIS_CHOICES, initial='offen',
widget=forms.Select(attrs={'class': 'form-input'}),
)

View file

@ -54,137 +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
@pytest.mark.django_db
def test_dashboard_expiry_boundary(admitted_client):
from datetime import date, timedelta
from vergabe_teilnahme.apps.bibliothek.models import Nachweis
for title, days in [('Expired', -1), ('Boundary', 60), ('Later', 61)]:
Nachweis.objects.create(titel=title, gueltig_bis=date.today() + timedelta(days=days))
Nachweis.objects.create(titel='Undated')
response = admitted_client.get(reverse('ausschreibungen:dashboard'))
assert response.status_code == 200
assert [n.titel for n in response.context['ablaufende_nachweise']] == ['Expired', 'Boundary']
assert 'Boundary' in response.content.decode()
@pytest.mark.django_db
def test_list_search_combines_filters_and_htmx(admitted_client):
matching = AusschreibungFactory(titel='Network upgrade', ausschreiber='City', status=4)
AusschreibungFactory(titel='Other', ausschreiber='City', status=3)
AusschreibungFactory(titel='Archived', ausschreiber='City', status=4, archiviert=True)
for query in ['Network', 'City']:
response = admitted_client.get(reverse('ausschreibungen:liste'),
{'q': query, 'status': '4'}, HTTP_HX_REQUEST='true')
assert list(response.context['ausschreibungen']) == [matching]
assert 'ausschreibungen/liste_partial.html' in [t.name for t in response.templates]
@pytest.mark.django_db
def test_historical_result_validation_and_save(admitted_client):
url = reverse('ausschreibungen:neu')
response = admitted_client.get(url, {'historisch': '1'})
assert 'name="ergebnis"' in response.content.decode()
data = {'titel': 'Historical award', 'ausschreiber': 'City',
'historisch_erfassen': '1', 'teilnahmeentscheidung': 'teilnahme',
'ergebnis': 'invalid'}
response = admitted_client.post(url, data)
assert response.status_code == 200
assert response.context['form'].errors['ergebnis']
assert not Ausschreibung.objects.filter(titel=data['titel']).exists()
data['ergebnis'] = 'gewonnen'
response = admitted_client.post(url, data)
assert response.status_code == 302
tender = Ausschreibung.objects.get(titel=data['titel'])
assert tender.nachbetrachtung.ergebnis == 'gewonnen'
assert tender.status == 10
assert tender.teilnahmeentscheidung == 'teilnahme'
@pytest.mark.django_db
def test_global_search_tasks_partners_and_issuer(admitted_client):
from vergabe_teilnahme.apps.aufgaben.models import Aufgabe
from vergabe_teilnahme.apps.partner.models import Subunternehmer
tender = AusschreibungFactory(titel='Tender', ausschreiber='UniqueIssuer')
task = Aufgabe.objects.create(titel='UniqueTask', ausschreibung=tender)
partner = Subunternehmer.objects.create(name='UniquePartner')
cases = [
('UniqueIssuer', reverse('ausschreibungen:detail', kwargs={'pk': tender.pk})),
('UniqueTask', reverse('ausschreibungen:aufgaben:detail',
kwargs={'ausschreibung_id': tender.pk, 'pk': task.pk})),
('UniquePartner', reverse('partner:su_detail', kwargs={'pk': partner.pk})),
]
for query, target in cases:
response = admitted_client.get('/suche/', {'q': query})
assert response.status_code == 200
assert f'href="{target}"' in response.content.decode()
@pytest.mark.django_db
def test_inline_status_with_rendered_csrf_header(mitarbeiter):
import json
import re
from django.test import Client
client = Client(enforce_csrf_checks=True)
client.force_login(mitarbeiter)
tender = AusschreibungFactory(status=1)
response = client.get(reverse('ausschreibungen:detail', kwargs={'pk': tender.pk}))
headers = json.loads(re.search(r"hx-headers='([^']+)'", response.content.decode())[1])
url = reverse('ausschreibungen:status', kwargs={'pk': tender.pk})
assert client.post(url, {'status': '4'}).status_code == 403
response = client.post(url, {'status': '4'}, HTTP_HX_REQUEST='true',
HTTP_X_CSRFTOKEN=headers['X-CSRFToken'])
assert response.status_code == 200
tender.refresh_from_db()
assert tender.status == 4

View file

@ -1,9 +1,6 @@
from datetime import date, timedelta
from django.db import transaction
from django.db.models import Q
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
@ -17,7 +14,6 @@ def _is_htmx(request):
def dashboard(request):
from vergabe_teilnahme.apps.aufgaben.models import Aufgabe
from vergabe_teilnahme.apps.bibliothek.models import Nachweis
heute = date.today()
in_14_tagen = heute + timedelta(days=14)
@ -44,10 +40,6 @@ def dashboard(request):
status__range=(1, 9),
).order_by('-geaendert_am')[:10],
'ablaufende_nachweise': Nachweis.objects.filter(
gueltig_bis__lte=heute + timedelta(days=60),
).order_by('gueltig_bis', 'pk'),
'today': heute,
'breadcrumbs': [{'label': 'Übersicht', 'url': None}],
}
return render(request, 'ausschreibungen/dashboard.html', ctx)
@ -56,10 +48,6 @@ def dashboard(request):
def ausschreibung_liste(request):
qs = Ausschreibung.objects.all()
q = request.GET.get('q', '').strip()
if q:
qs = qs.filter(Q(titel__icontains=q) | Q(ausschreiber__icontains=q))
status_filter = request.GET.get('status')
if status_filter:
qs = qs.filter(status=status_filter)
@ -71,7 +59,7 @@ def ausschreibung_liste(request):
if bid_manager_filter:
qs = qs.filter(bid_manager=bid_manager_filter)
from django.db.models import Count
from django.db.models import Count, Q
qs = qs.select_related('bid_manager').annotate(
aufgaben_total=Count('aufgaben', distinct=True),
aufgaben_erledigt=Count(
@ -86,7 +74,6 @@ def ausschreibung_liste(request):
'status_choices': Ausschreibung.STATUS_CHOICES,
'mitarbeiter': Mitarbeiter.objects.all(),
'archiviert': archiviert,
'q': q,
'current_status': status_filter or '',
'current_bid_manager': bid_manager_filter or '',
'breadcrumbs': [{'label': 'Ausschreibungen', 'url': None}],
@ -100,35 +87,21 @@ def ausschreibung_liste(request):
def ausschreibung_neu(request):
from .forms import AusschreibungForm
historisch = (request.GET.get('historisch') == '1'
or request.POST.get('historisch_erfassen') == '1')
historisch = request.GET.get('historisch') == '1'
if request.method == 'POST':
form = AusschreibungForm(request.POST, historisch=historisch)
form = AusschreibungForm(request.POST)
if form.is_valid():
with transaction.atomic():
a = form.save()
if historisch:
from vergabe_teilnahme.apps.nachbetrachtung.models import Nachbetrachtung
ergebnis = form.cleaned_data["ergebnis"]
Nachbetrachtung.objects.create(ausschreibung=a, ergebnis=ergebnis)
result_status = {
"gewonnen": 10, "verloren": 11,
"aufgehoben": 12, "zurueckgezogen": 13,
}
if ergebnis in result_status:
a.status = result_status[ergebnis]
a.save(update_fields=["status"])
a = form.save()
return redirect('ausschreibungen:detail', pk=a.pk)
else:
form = AusschreibungForm(historisch=historisch)
form = AusschreibungForm()
return render(request, 'ausschreibungen/form.html', {
'form': form,
'historisch': historisch,
'titel': 'Neue Ausschreibung',
'breadcrumbs': [
{'label': 'Ausschreibungen', 'url': reverse('ausschreibungen:liste')},
{'label': 'Ausschreibungen', 'url': '/ausschreibungen/'},
{'label': 'Neu', 'url': None},
],
})
@ -138,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)
@ -167,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},
],
}
@ -187,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},
],
})
@ -228,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},
],
}
@ -258,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},
],
}
@ -276,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},
],
})

View file

@ -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',

View file

@ -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},
],
})

View file

@ -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/',

View file

@ -200,35 +200,23 @@ def suche(request):
q = request.GET.get('q', '').strip()
if not q or len(q) < 2:
return HttpResponse('')
from django.db.models import Q
from vergabe_teilnahme.apps.aufgaben.models import Aufgabe
from vergabe_teilnahme.apps.ausschreibungen.models import Ausschreibung
from vergabe_teilnahme.apps.bibliothek.models import Nachweis, Referenz
from vergabe_teilnahme.apps.dokumente.models import Dokument
from vergabe_teilnahme.apps.marktbegleiter.models import Marktbegleiter
from vergabe_teilnahme.apps.partner.models import Subunternehmer
ausschreibungen = Ausschreibung.objects.filter(
Q(titel__icontains=q) | Q(ausschreiber__icontains=q)
)[:5]
aufgaben = Aufgabe.objects.filter(titel__icontains=q)[:5]
subunternehmer = Subunternehmer.objects.filter(name__icontains=q)[:5]
ausschreibungen = Ausschreibung.objects.filter(titel__icontains=q)[:5]
dokumente = Dokument.objects.filter(dateiname__icontains=q).select_related('ausschreibung')[:5]
nachweise = Nachweis.objects.filter(titel__icontains=q)[:5]
referenzen = Referenz.objects.filter(referenztitel__icontains=q)[:5]
marktbegleiter = Marktbegleiter.objects.filter(name__icontains=q)[:5]
has_results = any([
ausschreibungen, aufgaben, subunternehmer, dokumente, nachweise, referenzen, marktbegleiter,
])
has_results = any([ausschreibungen, dokumente, nachweise, referenzen, marktbegleiter])
if not has_results:
return render(request, 'partials/search_results.html', {'q': q, 'empty': True})
return render(request, 'partials/search_results.html', {
'q': q,
'aufgaben': aufgaben,
'subunternehmer': subunternehmer,
'ausschreibungen': ausschreibungen,
'dokumente': dokumente,
'nachweise': nachweise,

View file

@ -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)

View file

@ -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

View file

@ -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()

View file

@ -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)),
})

View file

@ -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

View file

@ -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'

View file

@ -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

View file

@ -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},
],
})

View file

@ -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

View file

@ -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')

View file

@ -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

View file

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

View file

@ -1,12 +0,0 @@
{% extends "accounts/base.html" %}
{% block title %}Anmeldung bestätigen{% endblock %}
{% block content %}
<h1 class="text-xl font-semibold mb-2">Anmelden bei {{ company_name }}</h1>
<p class="mb-6">Sie fahren mit <strong>{{ identity_label }}</strong> fort.</p>
<form method="post" action="{% url 'accounts:oidc_confirm' %}">
{% csrf_token %}<button type="submit" class="btn-primary w-full">Mit diesem Konto fortfahren</button>
</form>
<form method="post" action="{% url 'accounts:oidc_start' %}" class="mt-4">
{% csrf_token %}<button type="submit">Mit einem anderen Konto anmelden</button>
</form>
{% endblock %}

View file

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

View file

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

View file

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

View file

@ -1,12 +0,0 @@
{% extends "accounts/base.html" %}
{% block title %}Willkommen bei {{ company_name }}{% endblock %}
{% block content %}
<h1 class="text-xl font-semibold mb-2">Willkommen bei {{ company_name }}</h1>
<p class="text-sm text-slate-600 mb-6">Hier bearbeiten Sie die Ausschreibungen Ihres Unternehmens gemeinsam.</p>
{% if login_error %}<p role="alert" class="text-red-700 mb-4">Die Anmeldung konnte nicht abgeschlossen werden. Melden Sie sich mit Ihrem persönlichen Unternehmenskonto erneut an.</p>{% endif %}
<form method="post" action="{% url 'accounts:oidc_start' %}">
{% csrf_token %}
<button type="submit" class="btn-primary w-full">Mit NetKingdom anmelden</button>
</form>
<p class="text-sm text-slate-500 mt-6">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.</p>
{% endblock %}

View file

@ -18,7 +18,7 @@
<ul class="space-y-1">
{% for a in kritische_fristen %}
<li class="flex items-center justify-between py-1 text-sm">
<a href="{% url 'ausschreibungen:detail' pk=a.pk %}" class="text-brand-700 hover:underline truncate max-w-xs">{{ a.titel }}</a>
<a href="/ausschreibungen/{{ a.pk }}/" class="text-brand-700 hover:underline truncate max-w-xs">{{ a.titel }}</a>
<span class="ml-2 shrink-0 {% if a.abgabe_bis.date <= today %}text-red-600 font-medium{% else %}text-amber-600{% endif %}">
{{ a.abgabe_bis|date:"d.m.Y H:i" }}
</span>
@ -42,7 +42,7 @@
<ul class="space-y-1">
{% for a in ohne_entscheidung %}
<li class="py-1 text-sm">
<a href="{% url 'ausschreibungen:entscheidung' pk=a.pk %}" class="text-brand-700 hover:underline">{{ a.titel }}</a>
<a href="/ausschreibungen/{{ a.pk }}/entscheidung/" class="text-brand-700 hover:underline">{{ a.titel }}</a>
<span class="text-slate-400 text-xs ml-1">seit {{ a.erstellt_am|date:"d.m.Y" }}</span>
</li>
{% endfor %}
@ -64,7 +64,7 @@
<ul class="space-y-1">
{% for aufgabe in ueberfaellige_aufgaben %}
<li class="flex items-center justify-between py-1 text-sm">
<a href="{% url 'ausschreibungen:aufgaben:liste' ausschreibung_id=aufgabe.ausschreibung_id %}" class="text-brand-700 hover:underline truncate max-w-xs">
<a href="/ausschreibungen/{{ aufgabe.ausschreibung_id }}/aufgaben/" class="text-brand-700 hover:underline truncate max-w-xs">
{{ aufgabe.titel }}
</a>
<span class="text-red-600 text-xs ml-2 shrink-0">{{ aufgabe.frist|date:"d.m.Y" }}</span>
@ -88,7 +88,7 @@
<ul class="space-y-1">
{% for a in laufende_ausschreibungen %}
<li class="flex items-center justify-between py-1 text-sm">
<a href="{% url 'ausschreibungen:detail' pk=a.pk %}" class="text-brand-700 hover:underline truncate max-w-xs">{{ a.titel }}</a>
<a href="/ausschreibungen/{{ a.pk }}/" class="text-brand-700 hover:underline truncate max-w-xs">{{ a.titel }}</a>
{% status_badge a.get_status_display a.status %}
</li>
{% endfor %}
@ -98,19 +98,5 @@
{% endif %}
</div>
<div class="card">
<div class="flex items-center justify-between mb-3">
<h2 class="text-sm font-semibold text-slate-700">Ablaufende Nachweise (60 Tage)</h2>
<span class="text-sm">{{ ablaufende_nachweise|length }}</span>
</div>
{% for n in ablaufende_nachweise %}
<div class="flex items-center justify-between py-1 text-sm">
<a href="{% url 'bibliothek:nachweis_detail' pk=n.pk %}" class="text-brand-700 hover:underline">{{ n.titel }}</a>
<span class="{% if n.gueltig_bis < today %}text-red-600{% else %}text-amber-600{% endif %}">{{ n.gueltig_bis|date:"d.m.Y" }}</span>
</div>
{% empty %}
<p class="text-slate-400 text-sm">Keine ablaufenden Nachweise.</p>
{% endfor %}
</div>
</div>
{% endblock %}

View file

@ -119,11 +119,6 @@
<label class="form-label">Teilnahmeentscheidung</label>
{{ form.teilnahmeentscheidung }}
</div>
<div>
<label class="form-label" for="id_ergebnis">Ergebnis</label>
{{ form.ergebnis }}
{{ form.ergebnis.errors }}
</div>
<div>
<label class="form-label">Begründung</label>
{{ form.entscheidungsbegruendung }}

View file

@ -4,7 +4,7 @@
{% block content %}
<div class="flex items-center justify-between mb-5">
<h1 class="page-title">Freigaben: {{ ausschreibung.titel }}</h1>
<button hx-get="{% url 'freigabe_modal' %}?ct={{ ct_id }}&oid={{ ausschreibung.pk }}"
<button hx-get="/freigaben/modal/?ct={{ ct_id }}&oid={{ ausschreibung.pk }}"
hx-target="#modal-container"
class="btn-primary">Freigabe erteilen</button>
</div>

View file

@ -13,12 +13,8 @@
hx-get="{% url 'ausschreibungen:liste' %}"
hx-target="#ausschreibungen-table"
hx-push-url="true"
hx-trigger="change from:select, change from:input[type=checkbox], input changed delay:300ms from:input[type=search]"
hx-trigger="change from:select, change from:input[type=checkbox]"
class="flex flex-wrap gap-3 items-end">
<div>
<label for="tender-search" class="form-label">Suche</label>
<input id="tender-search" type="search" name="q" value="{{ q }}" class="form-input" placeholder="Titel oder Ausschreiber">
</div>
<div>
<label class="form-label">Status</label>
<select name="status" class="form-input">

View file

@ -8,7 +8,7 @@
<link rel="stylesheet" href="{% static 'dist/main.css' %}">
<script src="{% static 'vendor/alpinejs/alpine.min.js' %}" defer></script>
</head>
<body class="bg-paper-2 min-h-screen text-ink" hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'>
<body class="bg-paper-2 min-h-screen text-ink">
{% include "partials/topbar.html" %}
<div class="flex h-[calc(100vh-56px)]">

View file

@ -7,7 +7,7 @@
<div class="flex gap-2 mb-5 flex-wrap">
{% for et in entity_types %}
<a href="{% url 'feld_konfiguration_liste' entity_type=et %}"
<a href="/felder/{{ et }}/"
class="px-3 py-1 rounded text-sm {% if et == entity_type %}bg-brand-600 text-white{% else %}bg-slate-100 text-slate-700 hover:bg-slate-200{% endif %}">
{{ et }}
</a>

View file

@ -7,7 +7,7 @@
</div>
<div x-show="formOpen" x-cloak class="mb-4 bg-slate-50 border border-slate-200 rounded-lg p-3">
<form hx-post="{% url 'custom_attribute_neu' content_type_id=ct_id object_id=oid %}"
<form hx-post="/core/attrs/{{ ct_id }}/{{ oid }}/neu/"
hx-target="closest div[x-data]"
hx-swap="outerHTML"
class="space-y-2">
@ -39,19 +39,19 @@
<span class="text-slate-700 flex-1">{{ attr.value|default:"—" }}</span>
<span class="text-xs text-slate-400 bg-slate-100 rounded px-1.5">{{ attr.data_type }}</span>
<div class="flex gap-1">
<form hx-post="{% url 'custom_attribute_sort' content_type_id=ct_id object_id=oid attr_pk=attr.pk %}"
<form hx-post="/core/attrs/{{ ct_id }}/{{ oid }}/{{ attr.pk }}/sort/"
hx-target="closest div[x-data]" hx-swap="outerHTML" class="inline">
{% csrf_token %}
<input type="hidden" name="direction" value="up">
<button type="submit" class="text-slate-300 hover:text-slate-600 text-xs">↑</button>
</form>
<form hx-post="{% url 'custom_attribute_sort' content_type_id=ct_id object_id=oid attr_pk=attr.pk %}"
<form hx-post="/core/attrs/{{ ct_id }}/{{ oid }}/{{ attr.pk }}/sort/"
hx-target="closest div[x-data]" hx-swap="outerHTML" class="inline">
{% csrf_token %}
<input type="hidden" name="direction" value="down">
<button type="submit" class="text-slate-300 hover:text-slate-600 text-xs">↓</button>
</form>
<form hx-post="{% url 'custom_attribute_loeschen' content_type_id=ct_id object_id=oid attr_pk=attr.pk %}"
<form hx-post="/core/attrs/{{ ct_id }}/{{ oid }}/{{ attr.pk }}/loeschen/"
hx-target="closest div[x-data]" hx-swap="outerHTML" class="inline"
onsubmit="return confirm('Löschen?')">
{% csrf_token %}

View file

@ -1,7 +1,7 @@
<tr id="feld-{{ entity_type }}-{{ field_name }}" class="border-b border-slate-100 hover:bg-slate-50">
<td class="py-2 font-mono text-xs text-slate-700">{{ field_name }}</td>
<td class="py-2">
<form hx-post="{% url 'feld_konfiguration_toggle' entity_type=entity_type field_name=field_name %}"
<form hx-post="/felder/{{ entity_type }}/{{ field_name }}/toggle/"
hx-target="#feld-{{ entity_type }}-{{ field_name }}"
hx-swap="outerHTML"
class="flex items-center gap-2">
@ -14,7 +14,7 @@
</form>
</td>
<td class="py-2 text-center">
<form hx-post="{% url 'feld_konfiguration_toggle' entity_type=entity_type field_name=field_name %}"
<form hx-post="/felder/{{ entity_type }}/{{ field_name }}/toggle/"
hx-target="#feld-{{ entity_type }}-{{ field_name }}"
hx-swap="outerHTML">
{% csrf_token %}

View file

@ -5,6 +5,6 @@
<p class="text-6xl font-bold text-slate-200 mb-4">404</p>
<h1 class="page-title mb-2">Seite nicht gefunden</h1>
<p class="text-slate-500 mb-6">Die angeforderte Seite existiert nicht oder wurde verschoben.</p>
<a href="{% url 'home' %}" class="btn-primary">Zur Übersicht</a>
<a href="/" class="btn-primary">Zur Übersicht</a>
</div>
{% endblock %}

View file

@ -5,6 +5,6 @@
<p class="text-6xl font-bold text-slate-200 mb-4">500</p>
<h1 class="page-title mb-2">Interner Serverfehler</h1>
<p class="text-slate-500 mb-6">Ein unerwarteter Fehler ist aufgetreten. Bitte versuche es später erneut.</p>
<a href="{% url 'home' %}" class="btn-primary">Zur Übersicht</a>
<a href="/" class="btn-primary">Zur Übersicht</a>
</div>
{% endblock %}

View file

@ -33,7 +33,7 @@
</span>
</td>
<td class="py-2 pr-3" @click.stop>
<form hx-post="{% url 'feedback:eintrag_bearbeiten' pk=eintrag.pk %}"
<form hx-post="/feedback/backlog/{{ eintrag.pk }}/bearbeiten/"
hx-target="#eintrag-wrapper-{{ eintrag.pk }}"
hx-swap="outerHTML">
{% csrf_token %}
@ -51,7 +51,7 @@
{# edit row #}
<tr x-show="editing" x-cloak class="bg-slate-50 border-b border-slate-200">
<td colspan="6" class="px-6 pt-3 pb-4">
<form hx-post="{% url 'feedback:eintrag_bearbeiten' pk=eintrag.pk %}"
<form hx-post="/feedback/backlog/{{ eintrag.pk }}/bearbeiten/"
hx-target="#eintrag-wrapper-{{ eintrag.pk }}"
hx-swap="outerHTML">
{% csrf_token %}

View file

@ -1,4 +1,4 @@
<button hx-get="{% url 'feedback:modal' %}"
<button hx-get="/feedback/modal/"
hx-target="#modal-container"
hx-swap="innerHTML"
class="fixed bottom-6 right-6 bg-white border border-slate-300 shadow-lg

View file

@ -3,7 +3,7 @@
<div class="bg-white rounded-xl shadow-xl p-6 w-full max-w-md mx-4"
@click.outside="open = false">
<h2 class="page-title text-xl mb-4">Feedback</h2>
<form hx-post="{% url 'feedback:submit' %}" hx-target="#modal-container" hx-swap="innerHTML">
<form hx-post="/feedback/" hx-target="#modal-container" hx-swap="innerHTML">
{% csrf_token %}
<input type="hidden" name="seite_kontext" value="{{ request.path }}">
{% if current_ausschreibung %}

View file

@ -3,7 +3,7 @@
<div class="bg-white rounded-xl shadow-xl p-6 w-full max-w-md mx-4"
@click.outside="open = false">
<h2 class="page-title text-xl mb-4">Freigabe erteilen</h2>
<form hx-post="{% url 'freigabe_erteilen' %}" hx-target="#modal-container" hx-swap="innerHTML">
<form hx-post="/freigaben/erteilen/" hx-target="#modal-container" hx-swap="innerHTML">
{% csrf_token %}
<input type="hidden" name="content_type_id" value="{{ content_type_id }}">
<input type="hidden" name="object_id" value="{{ object_id }}">

View file

@ -7,7 +7,7 @@
{% if ausschreibungen %}
<div class="px-3 py-1 text-xs font-medium text-slate-400 uppercase tracking-wide">Ausschreibungen</div>
{% for a in ausschreibungen %}
<a href="{% url 'ausschreibungen:detail' pk=a.pk %}"
<a href="/ausschreibungen/{{ a.pk }}/"
class="flex items-center gap-2 px-4 py-2 text-sm text-slate-700 hover:bg-slate-50">
<span class="text-base">📋</span>
<span>{{ a.titel }}</span>
@ -15,23 +15,10 @@
{% endfor %}
{% endif %}
{% if aufgaben %}
<div class="px-3 py-1 text-xs font-medium text-slate-400 uppercase tracking-wide">Aufgaben</div>
{% for a in aufgaben %}
<a href="{% url 'ausschreibungen:aufgaben:detail' ausschreibung_id=a.ausschreibung_id pk=a.pk %}" class="block px-4 py-2 text-sm text-slate-700 hover:bg-slate-50">{{ a.titel }}</a>
{% endfor %}
{% endif %}
{% if subunternehmer %}
<div class="px-3 py-1 text-xs font-medium text-slate-400 uppercase tracking-wide">Subunternehmer</div>
{% for s in subunternehmer %}
<a href="{% url 'partner:su_detail' pk=s.pk %}" class="block px-4 py-2 text-sm text-slate-700 hover:bg-slate-50">{{ s.name }}</a>
{% endfor %}
{% endif %}
{% if dokumente %}
<div class="px-3 py-1 text-xs font-medium text-slate-400 uppercase tracking-wide mt-1">Dokumente</div>
{% for d in dokumente %}
<a href="{% url 'ausschreibungen:dokumente:liste' ausschreibung_id=d.ausschreibung.pk %}"
<a href="/ausschreibungen/{{ d.ausschreibung.pk }}/dokumente/"
class="flex items-center gap-2 px-4 py-2 text-sm text-slate-700 hover:bg-slate-50">
<span class="text-base">📄</span>
<span>{{ d.dateiname }}</span>
@ -43,7 +30,7 @@
{% if nachweise %}
<div class="px-3 py-1 text-xs font-medium text-slate-400 uppercase tracking-wide mt-1">Nachweise</div>
{% for n in nachweise %}
<a href="{% url 'bibliothek:nachweis_detail' pk=n.pk %}"
<a href="/bibliothek/nachweise/{{ n.pk }}/"
class="flex items-center gap-2 px-4 py-2 text-sm text-slate-700 hover:bg-slate-50">
<span class="text-base">🏆</span>
<span>{{ n.titel }}</span>
@ -54,7 +41,7 @@
{% if referenzen %}
<div class="px-3 py-1 text-xs font-medium text-slate-400 uppercase tracking-wide mt-1">Referenzen</div>
{% for r in referenzen %}
<a href="{% url 'bibliothek:referenz_detail' pk=r.pk %}"
<a href="/bibliothek/referenzen/{{ r.pk }}/"
class="flex items-center gap-2 px-4 py-2 text-sm text-slate-700 hover:bg-slate-50">
<span class="text-base">⭐</span>
<span>{{ r.referenztitel }}</span>
@ -66,7 +53,7 @@
{% if marktbegleiter %}
<div class="px-3 py-1 text-xs font-medium text-slate-400 uppercase tracking-wide mt-1">Marktbegleiter</div>
{% for m in marktbegleiter %}
<a href="{% url 'marktbegleiter:detail' pk=m.pk %}"
<a href="/marktbegleiter/{{ m.pk }}/"
class="flex items-center gap-2 px-4 py-2 text-sm text-slate-700 hover:bg-slate-50">
<span class="text-base">🔍</span>
<span>{{ m.name }}</span>

View file

@ -2,7 +2,7 @@
x-show="sidebarOpen"
x-cloak>
<nav class="p-3 space-y-1 flex-1">
<a href="{% url 'home' %}"
<a href="/"
class="sidebar-link {% if request.resolver_match.url_name == 'dashboard' %}sidebar-link-active{% endif %}">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"/>
@ -16,8 +16,8 @@
<span x-text="open ? '▾' : '▸'" class="text-slate-400"></span>
</button>
<div x-show="open" class="ml-3 space-y-1">
<a href="{% url 'ausschreibungen:liste' %}" class="sidebar-link">Alle Ausschreibungen</a>
<a href="{% url 'ausschreibungen:neu' %}" class="sidebar-link text-brand-600 font-medium">+ Neu</a>
<a href="/ausschreibungen/" class="sidebar-link">Alle Ausschreibungen</a>
<a href="/ausschreibungen/neu/" class="sidebar-link text-brand-600 font-medium">+ Neu</a>
</div>
</div>
@ -27,10 +27,10 @@
<span x-text="open ? '▾' : '▸'" class="text-slate-400"></span>
</button>
<div x-show="open" class="ml-3 space-y-1">
<a href="{% url 'bibliothek:nachweise_liste' %}" class="sidebar-link">Nachweise</a>
<a href="{% url 'bibliothek:referenz_liste' %}" class="sidebar-link">Referenzen</a>
<a href="{% url 'bibliothek:leistungsblaetter_liste' %}" class="sidebar-link">Leistungsblätter</a>
<a href="{% url 'bibliothek:entscheidungsregeln_liste' %}" class="sidebar-link">Entscheidungsregeln</a>
<a href="/bibliothek/nachweise/" class="sidebar-link">Nachweise</a>
<a href="/bibliothek/referenzen/" class="sidebar-link">Referenzen</a>
<a href="/bibliothek/leistungsblaetter/" class="sidebar-link">Leistungsblätter</a>
<a href="/bibliothek/entscheidungsregeln/" class="sidebar-link">Entscheidungsregeln</a>
</div>
</div>
@ -40,17 +40,17 @@
<span x-text="open ? '▾' : '▸'" class="text-slate-400"></span>
</button>
<div x-show="open" class="ml-3 space-y-1">
<a href="{% url 'partner:su_liste' %}" class="sidebar-link">Subunternehmer</a>
<a href="{% url 'partner:dt_liste' %}" class="sidebar-link">Dienstleistertypen</a>
<a href="/partner/subunternehmer/" class="sidebar-link">Subunternehmer</a>
<a href="/partner/dienstleistertypen/" class="sidebar-link">Dienstleistertypen</a>
</div>
</div>
<a href="{% url 'aufgaben_global' %}" class="sidebar-link">Aufgaben</a>
<a href="{% url 'marktbegleiter:liste' %}" class="sidebar-link">Marktbegleiter</a>
<a href="{% url 'feedback:backlog' %}" class="sidebar-link">Feedback-Backlog</a>
<a href="/aufgaben/" class="sidebar-link">Aufgaben</a>
<a href="/marktbegleiter/" class="sidebar-link">Marktbegleiter</a>
<a href="/feedback/backlog/" class="sidebar-link">Feedback-Backlog</a>
{% if request.user.is_staff %}
<a href="{% url 'admin:index' %}" class="sidebar-link text-slate-400">Administration</a>
<a href="/admin/" class="sidebar-link text-slate-400">Administration</a>
{% endif %}
</nav>

View file

@ -7,9 +7,9 @@
</svg>
</button>
<a href="{% url 'home' %}" class="text-brand-700 font-semibold text-lg shrink-0">Vergabe Teilnahme{% if company_name %}<span class="block text-xs text-slate-500">{{ company_name }}</span>{% endif %}</a>
<a href="/" class="text-brand-700 font-semibold text-lg shrink-0">Vergabe Teilnahme</a>
<form hx-get="{% url 'suche' %}" hx-target="#search-results" hx-trigger="input changed delay:300ms"
<form hx-get="/suche/" hx-target="#search-results" hx-trigger="input changed delay:300ms"
class="relative flex-1 max-w-lg mx-auto">
<input name="q" type="search" placeholder="Ausschreibung, Aufgabe, Dokument suchen..."
autocomplete="off"
@ -39,14 +39,8 @@
<div class="px-3 py-2 text-xs text-slate-500 border-b border-slate-100">
{{ request.user.get_rolle_display|default:"Mitarbeiter" }}
</div>
{% if request.user.has_usable_password %}
<a href="{% url 'accounts:password_change' %}"
class="block px-3 py-2 text-sm text-slate-700 hover:bg-slate-50">Passwort ändern</a>
{% endif %}
<form method="post" action="{% url 'accounts:logout' %}">
{% csrf_token %}
<button type="submit" class="block w-full text-left px-3 py-2 text-sm text-slate-700 hover:bg-slate-50">Abmelden</button>
</form>
<a href="/accounts/logout/"
class="block px-3 py-2 text-sm text-slate-700 hover:bg-slate-50">Abmelden</a>
</div>
</div>
</header>

View file

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

View file

@ -4,14 +4,12 @@ type: workplan
title: "Establish verified delivery for the primary customer tender product"
domain: communication
repo: vergabe-teilnahme
status: blocked
flavor: implementation
status: active
owner: the-custodian
topic_slug: vergabe-teilnahme
created: "2026-09-08"
updated: "2026-09-27"
updated: "2026-09-08"
related: [HFACT-WP-0001, REUSE-WP-0022]
state_hub_workstream_id: "27a95f7b-cec4-50ca-8383-c1c95d996217"
---
# Customer product delivery
@ -30,7 +28,6 @@ id: VERGABE-WP-0018-T01
status: done
priority: high
assignee: the-custodian
state_hub_task_id: "ec53efce-ba3c-5965-9c64-ea0480bfae68"
```
Update product entry points and classification to reflect the user's explicit
@ -43,10 +40,9 @@ score requires owner review; it does not authorize a duplicate capability.
```task
id: VERGABE-WP-0018-T02
status: done
status: progress
priority: high
assignee: the-custodian
state_hub_task_id: "dc889dba-3e10-5017-af9f-4843119a103a"
```
Run existing application tests against disposable data, including tender create,
@ -63,14 +59,14 @@ status: wait
priority: high
assignee: the-custodian
depends_on: [VERGABE-WP-0018-T02, HFACT-WP-0001-T05]
blocking_reason: "Await HFACT-WP-0001-T05 natural governed Railiance worker trace and VERGABE-WP-0019 complete invited-user/recovery acceptance. Pilot placement and one sign-in are already evidenced; main-fa9f082 is superseded."
state_hub_task_id: "6e428152-aad6-5184-8b1d-8860ec0b9ae6"
blocking_reason: "Await tested/reviewed image and exact Railiance tenant, deployment, database, storage, access and recovery owner acceptance."
```
Coordinator: the-custodian; product acceptance: Bernd Worsch; deployment owner:
railiance-apps and admitted Railiance placement owner. Prepare exact chart/image,
host, tenant, credential binding, existing-data disposition, backup/restore,
rollback and UI acceptance evidence before release. Use the invited single-company pilot in VERGABE-WP-0019; existing `vergabe_db` is not test data and must not be relocated or
rollback and UI acceptance evidence before release. Use an isolated internal
pilot first; existing `vergabe_db` is not test data and must not be relocated or
overwritten implicitly. No customer-ready claim until admitted release passes.
Return the release and consumer evidence to HFACT-WP-0001-T06/T07.
@ -88,7 +84,7 @@ Return the release and consumer evidence to HFACT-WP-0001-T06/T07.
65-capability provenance and review disposition are in
`docs/evidence/2026-09-08-hosted-plan-check.json`.
T02 is complete with the live CI and immutable image receipts below.
T02 remains in progress until the published exact revision has its CI return.
T03 retains the concrete deployment/data/access/recovery gates. Attended Codex
source work is not a natural governed worker claim/heartbeat/close trace.
@ -96,36 +92,3 @@ Live CI runs 15/16 exposed the runner's missing Buildx/BuildKit support, despite
local acceptance. Added pinned temporary CLI setup shared by application and
image workflows, and removed duplicate feature-push test scheduling. The next
exact PR revision must pass on the actual runner before T02 closes.
## Published return
PR 1 source at `fa9f08268a5669d3832753929bb5a2ad267bfa72` passed live
application CI 17 and was integrated on main. Main application CI 18 and image
publication 20 passed. Published image `main-fa9f082` has digest
`sha256:cb48658f5bfeeef91b8e16de94e8833be01a68bf72619567665c9f7fe927d062`.
T01/T02 are done; the workplan is blocked on T03's admitted customer release.
HFACT-WP-0001 consumes the source and artifact receipts without claiming a
current governed worker run. Forgejo PR 1 metadata needs its authenticated
`manually-merged` receipt; source integration itself is verified on main.
## Invited-pilot sequencing decision — 2026-09-11
The user selected an invited pilot with manual onboarding and deferred pricing.
VERGABE-WP-0019 owns concrete product readiness; RAPPS-WP-0014 owns deployment
and recovery. Their preparation can advance independently of T03's governed
worker dependency. This record continues to own factory-produced delivery
acceptance, so a manually prepared customer pilot cannot falsely close HFACT
worker proof. The old main-fa9f082 image predates the required login gate and
must not be used as the invited-pilot release merely because its CI passed.
## Loose-end review — 2026-09-27
HFACT-WP-0001-T05 remains wait in prj-helixforge-factory: protected runtime and
synthetic proof exist, but upstream admission and natural queue/model execution
are outstanding. The September 12 pilot deployment, September 24 founder login
and September 25 database restore supersede the old missing-placement summary.
They do not close governed-worker proof, two-user product acceptance, or coherent
off-host recovery. T03 and this workplan remain blocked on those existing owners;
no duplicate tasks were opened.

View file

@ -1,326 +0,0 @@
---
id: VERGABE-WP-0019
type: workplan
title: "Admit the first invited company pilot with protected access and recoverable data"
domain: communication
repo: vergabe-teilnahme
status: blocked
flavor: implementation
owner: the-custodian
topic_slug: vergabe-teilnahme
created: "2026-09-11"
updated: "2026-09-27"
related: [VERGABE-WP-0018, RAPPS-WP-0014, HFACT-WP-0001, CUST-WP-0071]
state_hub_workstream_id: "85b5f304-d497-5570-bebf-3a3669ef6a7d"
---
# Invited company pilot
## Confirm the bounded customer milestone
```task
id: VERGABE-WP-0019-T01
status: done
priority: high
assignee: the-custodian
state_hub_task_id: "5785d35a-6501-57a3-aedf-3885db269e83"
```
User decision on 2026-09-11: start with an invited pilot, one company and several
users, manually onboarded. Pricing is a later improvement and is not an entry
gate. Use the existing single-company product: each admitted company has an
isolated deployment, database and data volumes. All active company members can
collaborate on the company's tenders; the existing v1 domain approval roles
remain descriptive. Django staff/superuser administration remains restricted.
External partners remain data objects. No public registration or shared-app
multitenancy is introduced.
Product readiness proceeds alongside the governed factory runtime. It does not
wait for the fourteen-day factory value study. VERGABE-WP-0018-T03 retains the
separate claim that the factory can produce and deliver the customer release;
HFACT-WP-0001-T05 still needs a natural governed worker trace.
## Require invited access across the UI and uploaded documents
```task
id: VERGABE-WP-0019-T02
status: done
priority: high
assignee: the-custodian
state_hub_task_id: "11e67327-aae4-5c83-99eb-86a8c82dd0be"
```
Use Django's default-deny login middleware with explicit health/login exemptions,
German login and password-change forms, CSRF-protected POST logout, and full-page
reauthentication for expired HTMX sessions. Protect uploaded files in production
and development, serve private attachments only within MEDIA_ROOT, and keep
operational issue state outside that downloadable root. Do not cache company
responses. Inactive users lose existing session access; ordinary members do not
gain Django administration privileges.
The 12 access regressions initially produced 9 failures, including actual
anonymous tender read/create. After the fix all 94 application tests pass
locally and in the existing container target; Vite assets build, 139 static
files collect, and migration drift is absent. New/changed access modules pass
Ruff. Source acceptance is not native customer admission.
## Publish and prove the isolated deployment and recovery contract
```task
id: VERGABE-WP-0019-T03
status: wait
blocking_reason: "Await RAPPS-WP-0014-T03 coherent PostgreSQL/media/issue-state recovery, populated document round-trip and off-host backup evidence; September 25 proves database restore and restart only."
priority: high
assignee: the-custodian
depends_on: [VERGABE-WP-0019-T02, RAPPS-WP-0014-T02, RAPPS-WP-0014-T03]
state_hub_task_id: "9065d5cb-2c65-561e-a237-e39da4f3b0fd"
```
RAPPS-WP-0014 owns exact image/chart/host/namespace/database/Secret binding,
persistent media plus distinct issue-facade state, isolated restore rehearsal,
and rollback. The customer app owns live CI and immutable release evidence.
The user now selects a fresh `demo-company` demo tenant with `demo-user1`, etc.
Use `tenant:trial:demo-company` under NetKingdom ADR-0013 and two ordinary demo
accounts initially. Native tenant creation and memberships use the existing
User Engine operator portal; this does not implement Django SSO. The user selected
`https://vergabe-teilnahme.coulomb.social/demo-company` (one product host,
company path). The concrete
prepared binding lives at `railiance-apps/docs/vergabe-demo-company-binding.md`.
No password or personal user list belongs in public work records.
The historical vergabe_db is not disposable and must not be overwritten.
The user subsequently accepts a 60m application CPU request for one tenant with
very few users as a prototype of HelixForge app delivery and Railiance/NetKingdom
onboarding. RAPPS-WP-0014 owns the explicit pilot override; this is an accepted
low-allocation experiment, not measured production sizing. CUST-WP-0071 is
registered for later metrics-based sizing and a final weekly allocation-review
setup. That follow-up does not block this deployment. CPU limits, memory,
protected access and company/data isolation remain governed by the pilot packet.
On 2026-09-11 the checked Railiance cluster contains neither the historical
vergabe-teilnahme namespace nor any Deployment whose name/image contains
vergabe/teilnahme. Treat the old runbook as historical intent, not a live return.
Native placement and existing data inventory remain required before cutover.
2026-09-12 deployment evidence: the operator added A records for
vergabe-teilnahme.coulomb.social and users.coulomb.social. Authoritative IONOS
and recursive readback both return 92.205.62.239. Both cert-manager certificates
are Ready. The portal now uses https://users.coulomb.social/login; its legacy
nip.io address redirects to the canonical hostname. The exact new callback is
registered alongside the rollback callback; scopes, public client type and PKCE
remain unchanged. Canonical authorization succeeds; unapproved callback and
missing PKCE fail. This supersedes earlier DNS and portal-hostname blockers.
Helm release vergabe-teilnahme revision 1 is deployed in vergabe-demo-company,
chart 0.2.1, pinned product digest cebe8ca6218cc89f903e8a69e06ac50d3812526d4119cf4da485cc173f674b68.
Deployment UID 2152014d-a020-4c5e-a3b0-9575e9f21c44 is Ready 1/1. Its init
migration completed before the web process; both phases share the same 60m CPU /
256Mi memory request. Node requests now total 3965m of 4000m; the 35m remainder
matches the accepted prototype allocation. No unrelated resource requests changed.
RPF-WP-0039 delivered fresh vergabe_demo_company database/role on apps-pg and
runtime Secret vergabe-demo-company/vergabe-demo-env. The app's own connection
confirms that exact database/role. The role is non-superuser, cannot create roles
or databases, has a 20-connection ceiling and 15-second timeouts, and cannot
CONNECT to historical vergabe_db, coulomb_social_db or apps_meta. Both dedicated
PVCs are Bound: 5Gi media and 1Gi issue state. Historical data was not selected
or overwritten; no credentials are recorded here.
Thirteen live Chromium/HTTP checks pass: page and assets, secure tenant-scoped
CSRF cookie, anonymous login gate and media refusal, private operational path
refusal, neighboring/root path refusal, canonical slash, HTTPS redirect and
missing-CSRF POST denial. Migration/app initialization also proves consumer
connectivity. The empty product has zero accounts, including zero staff accounts.
The current login is still the interim Django login, not NetKingdom SSO. Native
recipient login, company welcome and account mapping remain VERGABE-WP-0019-T06.
2026-09-25: RAPPS-WP-0014-T03 restored `vergabe_demo_company` into a scratch
database in 2 seconds, 39 tables and 212 rows, checksum matched, scratch
dropped. The app pod was recreated and health returned 200. The backup artifact
is a workstation age file, not the Nextcloud upload. Media and issue-facade
claims are empty local-path volumes. Evidence:
`railiance-apps/docs/evidence/2026-09-25-vergabe-demo-company-restore.md`.
No second-user acceptance is claimed.
## Accept onboarding, collaboration, recovery and support with pilot users
```task
id: VERGABE-WP-0019-T04
status: wait
priority: high
assignee: the-custodian
depends_on: [VERGABE-WP-0019-T03]
blocking_reason: "Database restore and app restart are proved. Await two ordinary users collaborating on a tender, task, and document."
state_hub_task_id: "db807f96-f5b8-528d-bc33-75a33fbbf1e4"
```
Product acceptance: Bernd Worsch and the named company contact. Verify two
separate ordinary-user accounts (`demo-user1` and `demo-user2` initially) can sign
in, change passwords, create a tender
and lot, collaborate on tasks, upload/download a document, record a v1 domain
approval and submission, and report feedback. Verify anonymous access and a
revoked user fail, health remains available, and pod replacement preserves all
three data stores. Restore a backup into an isolated destination and repeat the
workflow without touching the live database. Record operator/support contact,
incident route, backup cadence/retention and demonstrated recovery time. Use
manual account creation/reset/deactivation through the admitted operator path;
never seed development accounts or put credentials in logs/workplans/chat.
A successful invited pilot does not claim shared multitenancy, paid subscriptions,
HA or autonomous production release. Pricing is outside this milestone and will
need a later explicit product decision.
## Support the selected product host and tenant path
```task
id: VERGABE-WP-0019-T05
status: done
priority: high
assignee: the-custodian
state_hub_task_id: "d00acb0b-6514-5ab5-9894-7fadb917feb7"
```
Implement fixed APP_BASE_PATH, prefix-aware Django URLs/assets/media, scoped and
uniquely named cookies, and prefixed login/logout/password-change/HTMX redirects.
Replace root URL literals in templates and breadcrumbs with named reversing.
The edge strips only the admitted company prefix; the application remains an
isolated per-company instance. Four prefix regressions and seven local Chromium
checks pass, alongside the existing 94 root-path tests. Vite builds correctly.
See docs/tenant-path-deployment.md. T03 retains CI publication, exact live pin,
DNS/TLS/namespace/database binding and recovery; source acceptance is not deployment.
On 2026-09-11 at 19:03:18 UTC, native operator creation succeeded. Authority
readback confirms tenant:trial:demo-company active, version 1; the chosen first
administrator exists in User Engine with invited status. A subsequent Create
login failed in identity-provisioner at LLDAP service authentication (HTTP 401).
Its existing credential was reloaded without rotation; the connection still
fails. NetKingdom tracks that owner-credential reconciliation before identity
provisioning retries. No passwords or private addresses enter this record.
2026-09-12 native follow-through: the credential repair is verified, the operator
reports successful user password setup, and live read-only User Engine evidence
shows three memberships with one linked directory identity. This supersedes the
preceding service-login failure. It does not create a Django account or session.
## Connect identity setup to the company's welcome and sign-in page
```task
id: VERGABE-WP-0019-T06
status: wait
blocking_reason: "Source and live SSO/recovery are delivered, and one founder sign-in passed September 24. Await invited recipient setup-to-welcome-to-workflow acceptance and a second ordinary user."
priority: high
assignee: the-custodian
state_hub_task_id: "455b33f9-163a-5389-86a3-04000c32d176"
```
The operator successfully sets a user's directory password, but the provider ends
on a generic Password set page. The user requests a welcome page for the tenant
on the application. Target the selected
https://vergabe-teilnahme.coulomb.social/demo-company/ entry point.
Finish RAPPS-WP-0014-T02's real application route/data/placement, then preserve
company and intended recipient through the invitation/password-setup handoff
using exact allowed HTTPS return locations. Show a tenant-specific welcome page
and a clear sign-in action. Connect application authentication to the existing
NetKingdom identity contract, with issuer, signature, audience, expiry and exact
tenant validation, and a stable subject-to-local-account mapping. Keep ordinary
users non-staff and reject wrong-tenant identities. No password copying or
operator-session impersonation is an acceptable bridge.
The administrator may create another user's setup link while signed in as
platform-root. Completing that link must not turn the operator's existing portal
or shared SSO session into the recipient's application session. Handle the
explicit fresh-user sign-in/account-switch boundary; USER-WP-0025-T03 retains
complete provider sign-out coordination. Preserve the single-company database,
media and issue-state isolation and the accepted 60m allocation.
Verify a fresh invited user follows setup → company welcome → authenticated
application, sees the correct company, and can enter the pilot workflow. Verify
invalid/expired setup returns, unapproved external return URLs, wrong-tenant
identities and operator-session confusion fail safely. Keep setup tokens,
credentials and personal addresses out of work records. The earlier manual
Django login gate remains an interim product capability, not SSO proof.
2026-09-12 continuation: implemented company welcome, OIDC code+PKCE/nonce
verification, explicit account confirmation and stable issuer/subject mapping.
New accounts are ordinary users with unusable local passwords; signed directory
membership, exact tenant and bounded session expiry are required. Added callback,
wrong-tenant/platform, signature, replay, CSRF, local deactivation and prefix tests.
Source/deployment contract: docs/netkingdom-sign-in.md. The current live issuer
predates tenant_source and drops prompt=login before Authelia, so enabling the
product depends on KEY-WP-0033 and NK-WP-0037's exact registration/provider rollout.
The password-setup company return is grant-bound in NK-WP-0037. Native recipient
login/MFA, product publication and live acceptance remain open; no completed SSO
or pilot acceptance is claimed from source tests.
2026-09-12 release readiness: source 8be2810 is published as
sha256:2cb393608a82be2851adfc27f2bf4d8ef5d709f1b0038be5d0999e38c68b039e
(Forgejo run 51). All 125 local app tests, build and migration checks pass.
Acceptance run 49 completed tests but failed at image export; the independent
publication gate passed, and acceptance run 52 plus smoke run 53 pass on
cf50cc5 with identical application source (only generated brief changed).
The exact release packet, rollback and server-dry-run manifests are in
railiance-apps/docs/vergabe-demo-company-sso-rollout.md. Runtime remains on the
interim gate pending the documented attended KeyCape upgrade and native user/MFA
acceptance. KEY-WP-0033 and NK-WP-0037 remain live receiving records.
2026-09-12 attended rollout executed after explicit operator approval. KeyCape
and password setup are Ready on the prepared digests; exact public client
registration was CAS-applied (config resourceVersion 60123977) with unrelated
config bytes/Secret data preserved. Existing portal and product client both
pass fresh-login forwarding, wrong-callback and missing-PKCE checks (6 checks).
Vergabe Helm revision 2 is Ready; identity migration completed, both PVCs remain,
and requests remain 60m CPU/256Mi memory. Eleven live product checks pass:
company welcome, anonymous gate, no-store, secure scoped CSRF, POST/CSRF-only
login start, native issuer redirect, private company/media protection and
invalid callback/confirmation rejection. Initial readback showed zero accounts,
identity mappings and staff accounts. Native invited-user sign-in/MFA and
confirmation are now requested from the operator; no user credential was used
by the agent. Recovery and two-user acceptance remain their existing tasks.
Evidence: railiance-apps/docs/evidence/2026-09-12-demo-company-sso-live.md.
### Rejected-login recovery follow-up — 2026-09-12
Operator reports a dead-end error after trying a non-customer identity. Recent
issuer evidence shows token exchange failure; the exact browser cause is not
yet confirmed. Rejected callbacks and unusable confirmation now redirect to the
central account recovery page without code, state or claimed identity. Existing
tenant, principal, signature, CSRF and account-admission boundaries remain.
Validation: 126 application tests passed using an isolated in-memory database.
KEY-WP-0034 owns provider recovery/sign-out; USER-WP-0026 owns account visibility.
Publication and attended live recovery verification are in progress.
2026-09-24 23:46 UTC the founder completed one Vergabe sign-in on the freshness
image. The issuer issued a token for `vergabe-demo-company` after one failed
code exchange. That closes the native-login wait owned by KEY-WP-0033. This
task still owns the rest of the pilot: a second user, the setup-to-welcome
handoff, and entry into the company workflow. Receipt:
`key-cape/docs/evidence/2026-09-24-fresh-login-and-account-switch.md`.
### Live recovery rollout — 2026-09-12
Recovery is deployed in KeyCape 4d8b8fe, User Engine e54b6ee and Vergabe c067993
(Helm revision 3). All three are Ready. Six provider checks, eleven product
checks and six fresh anonymous Chromium checks pass, including actual provider
logout POST and return to the portal without test overrides. Real-user identity
switching is still awaiting operator evidence; no authenticated/MFA acceptance
is inferred. Detailed receipt: railiance-apps/docs/evidence/2026-09-12-account-recovery-live.md.
## Loose-end review — 2026-09-27
No further product implementation is identified in T03/T04/T06. T03 awaits
railiance-apps recovery evidence for all three stores and an off-host copy;
the September 25 age artifact stayed on the workstation and the two file
claims were empty. A database restore does not close that contract. T04 needs
Bernd Worsch and the company contact to demonstrate two ordinary users,
document/task collaboration, revocation, restored workflow and support handoff.
T06's September 24 founder login resolves the old issuer-login blocker, but
cannot establish a fresh invited recipient's complete setup-to-company journey.
Both previously progressing tasks now wait; the workplan is blocked. No new
workplan or task was opened, and no live deployment or user impersonation was
performed by this review.

View file

@ -5,7 +5,6 @@ title: Dashboard und Ausschreibungen-CRUD
status: finished
phase: 4-of-12
created: "2026-05-08"
updated: "2026-09-27"
depends_on: WP-0003
domain: communication
repo: vergabe-teilnahme
@ -22,14 +21,10 @@ Entscheidungsregel-Auswertung, Archivierung und historische Erfassung.
---
## Dashboard-View mit Kacheln und Fristenliste
```task
id: VT-WP-0004-T01
status: done
priority: medium
state_hub_task_id: "fb60f22e-6a83-5408-ad2b-9fd4109a98b7"
```
title: Dashboard-View mit Kacheln und Fristenliste
status: todo
`ausschreibungen/views.py` — Dashboard-View:
```python
@ -72,15 +67,12 @@ Jede Kachel: Überschrift, Anzahl-Badge, Liste der Einträge mit Direktlinks.
Nutze `.card`-Klasse, `status_badge`-Tag und relative Fristangaben (z. B. "in 3 Tagen").
Ablaufende Nachweise: Nachweis-Modell aus Bibliothek mit `gueltig_bis ≤ heute + 60 Tage`.
## Ausschreibungsliste mit Filter und HTMX-Suche
```
```task
id: VT-WP-0004-T02
status: done
priority: medium
state_hub_task_id: "cbcfd055-cb11-5e9b-829a-99763d232af9"
```
title: Ausschreibungsliste mit Filter und HTMX-Suche
status: todo
`ausschreibungen/views.py` — ListView:
```python
@ -115,15 +107,12 @@ Alle Filter-Änderungen: `hx-get="/ausschreibungen/" hx-target="#ausschreibungen
Tabelle: Titel, Ausschreiber, Status (status_badge), Abgabefrist (farbig wenn < 14 Tage),
Verantwortlicher, Link zum Detail.
## Ausschreibung anlegen — Form und View (UC-AS-01)
```
```task
id: VT-WP-0004-T03
status: done
priority: medium
state_hub_task_id: "c43caf96-a3f9-55ec-b4df-8bc46535116c"
```
title: Ausschreibung anlegen — Form und View (UC-AS-01)
status: todo
`ausschreibungen/forms.py`:
```python
@ -169,15 +158,12 @@ def ausschreibung_neu(request):
`ausschreibungen/form.html` — einfaches, gut gelayoutetes Formular.
Sections: Stammdaten, Fristen. Alle Felder nutzen `form-input` und `form-label`.
Submit: "Speichern" (btn-primary), "Abbrechen" (btn-ghost, zurück zur Liste).
## Ausschreibung-Detailseite (Phase 1 — Stammdaten)
```
```task
id: VT-WP-0004-T04
status: done
priority: medium
state_hub_task_id: "3d96629f-2f48-529b-8639-e4268ea5b472"
```
title: Ausschreibung-Detailseite (Phase 1 — Stammdaten)
status: todo
`ausschreibungen/views.py` — Detailview:
```python
@ -206,15 +192,12 @@ def ausschreibung_detail(request, pk):
- Tab-Navigation zu Unterseiten (Lose, Anforderungen, Aufgaben, Bieterfragen, Preise, Abgabe, Nachbetrachtung)
als horizontale Link-Leiste unterhalb des Titels
- "Weitere Attribute" CustomAttribute-Panel (HTMX lazy-load, Implementierung in WP-0012)
## Ausschreibung bearbeiten (Edit-View) und Status inline wechseln
```
```task
id: VT-WP-0004-T05
status: done
priority: medium
state_hub_task_id: "57dbd06f-532f-5a7a-9c23-a300aabc6ee2"
```
title: Ausschreibung bearbeiten (Edit-View) und Status inline wechseln
status: todo
`ausschreibungen/views.py`:
@ -253,15 +236,12 @@ def ausschreibung_status(request, pk):
</select>
</div>
```
## Teilnahmeentscheidung-Seite (Phase 2, UC-AS-04)
```
```task
id: VT-WP-0004-T06
status: done
priority: medium
state_hub_task_id: "ee9ca628-d51b-5e4d-bb60-20cadba19bdb"
```
title: Teilnahmeentscheidung-Seite (Phase 2, UC-AS-04)
status: todo
`ausschreibungen/views.py` — Teilnahmeentscheidungs-View:
```python
@ -292,15 +272,12 @@ def ausschreibung_entscheidung(request, pk):
- Zeigt Regelergebnis aus dem Katalog als strukturierte Liste
- Formular: Radio-Buttons für Teilnahme/Nichtteilnahme/Weitere Prüfung, Begründungsfeld
- "Freigabe erteilen"-Button (öffnet Freigabe-Modal, Implementierung in WP-0012)
## Entscheidungsregel-Auswertungs-Service
```
```task
id: VT-WP-0004-T07
status: done
priority: medium
state_hub_task_id: "8c676062-f060-580b-8399-61570107f18e"
```
title: Entscheidungsregel-Auswertungs-Service
status: todo
`vergabe_teilnahme/apps/ausschreibungen/services.py`:
@ -344,15 +321,12 @@ def _wende_regel_an(regel, ausschreibung):
'begruendung': f'Restlaufzeit {delta} Tage unter Schwellenwert.'}
return {'empfehlung': 'pruefen', 'begruendung': regel.begruendung or '—'}
```
## Ausschreibung archivieren und historisch erfassen (UC-AS-06, UC-AS-07)
```
```task
id: VT-WP-0004-T08
status: done
priority: medium
state_hub_task_id: "4596cc38-6deb-5f9a-9172-7d703c68a3fe"
```
title: Ausschreibung archivieren und historisch erfassen (UC-AS-06, UC-AS-07)
status: todo
**Archivieren:**
```python
@ -377,15 +351,12 @@ zugänglich — keine Einschränkung.
URL für historische Erfassung: `/ausschreibungen/neu/?historisch=1`
Die View prüft diesen Parameter und setzt `historisch_erfassen` im initialen Form-Context.
## Globale Suchleiste — HTMX-Endpunkt und Ergebnis-Template
```
```task
id: VT-WP-0004-T09
status: done
priority: medium
state_hub_task_id: "c6ecc471-f90c-5da9-b4f6-c9a290702dcb"
```
title: Globale Suchleiste — HTMX-Endpunkt und Ergebnis-Template
status: todo
`core/views.py`:
```python
@ -426,15 +397,12 @@ def global_search(request):
URL: `path('suche/', core_views.global_search, name='global_search')`
Topbar-Formular (aus WP-0003-T02) zeigt Ergebnisse in `#search-results`.
## Ausschreibungen-URL-Verkabelung und App-Namespace
```
```task
id: VT-WP-0004-T10
status: done
priority: medium
state_hub_task_id: "41b748bc-31c5-5626-bb2d-59204efe3c33"
```
title: Ausschreibungen-URL-Verkabelung und App-Namespace
status: todo
`vergabe_teilnahme/apps/ausschreibungen/urls.py`:
```python
@ -468,15 +436,12 @@ Jede referenzierte App-URL-Datei wird hier als leere Stub-Datei angelegt
Prüfe: `uv run manage.py check --deploy` → keine URL-Fehler.
Smoke-Test: alle Hauptseiten (/ausschreibungen/, /ausschreibungen/neu/) laden ohne 500.
## Ausschreibungs-Tests (Models und Views)
```
```task
id: VT-WP-0004-T11
status: done
priority: medium
state_hub_task_id: "da39ab8d-e534-55e1-a186-25a8f6439db5"
```
title: Ausschreibungs-Tests (Models und Views)
status: todo
Erstelle `vergabe_teilnahme/apps/ausschreibungen/tests/`:
@ -503,15 +468,12 @@ class AusschreibungFactory(factory.django.DjangoModelFactory):
ausschreiber = "Testausschreiber GmbH"
status = 1
```
## Seed-Daten prüfen und Dashboard-Kacheln verifizieren
```
```task
id: VT-WP-0004-T12
status: done
priority: medium
state_hub_task_id: "4d3cf1c6-fc7f-57ec-94a4-732a2da7184c"
```
title: Seed-Daten prüfen und Dashboard-Kacheln verifizieren
status: todo
Führe die gesamte Integrations-Smoke-Test-Sequenz durch:
@ -531,37 +493,4 @@ Führe die gesamte Integrations-Smoke-Test-Sequenz durch:
10. `uv run pytest vergabe_teilnahme/apps/ausschreibungen/` → alle Tests grün
Erst wenn alle 10 Punkte erfüllt sind: Task als done markieren.
## Completion review — 2026-09-27
The finished plan retained twelve stale todo blocks with nested Markdown fences.
Reconciled the existing task IDs into one valid task block per section; no new
tasks were created. T03–T07 and T10–T11 already had application implementations
and model/view acceptance. T01 now includes the missing 60-day evidence-expiry
card (including expired evidence). T02 now searches title and issuing authority
alongside status/manager/archive filters with HTMX. T08 now validates and saves
a historical result atomically in the existing Nachbetrachtung model and sets
the corresponding terminal tender status. T09 now includes issuing authority,
tasks and partners alongside the already implemented search categories.
The old illustrative fields are superseded by the current model: bid_manager
replaces hauptverantwortung, and ergebnis belongs to Nachbetrachtung. Historical
entry uses those models without a schema change. Existing approval, decision,
archive, detail and URL implementations remain in place.
Validation: all 131 application tests pass on disposable SQLite; all 20 tender
model/view tests also pass on disposable PostgreSQL 16. Vite build, Django system
check and migration-drift check pass. Changed Python passes Ruff excluding the
pre-existing E501 long lines. Local tests retain the pre-existing naive-datetime
fixture and uncollected-static-directory warnings. T12 uses freshly migrated/seeded disposable PostgreSQL and local
Chromium for dashboard/navigation, tender list/detail, HTMX search/status and
historical creation. This is local source acceptance, not pilot-user evidence.
The final T12 server-log review exposed a missing CSRF header on inline status
POSTs. T05 now inherits the rendered Django CSRF token through base.html's
HTMX headers. A CSRF-enforcing regression proves missing-token rejection and
valid-token mutation. The Chromium check now changes to a different status,
requires HTTP 200 for the POST, and checks persistence after reload; an unchanged
seeded value is not accepted as proof. This supersedes the initial browser
script's false-positive status assertion.
```