Compare commits
No commits in common. "main" and "factory/customer-delivery" have entirely different histories.
main
...
factory/cu
71 changed files with 283 additions and 2546 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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", "-"]
|
||||
|
|
|
|||
15
SCOPE.md
15
SCOPE.md
|
|
@ -41,7 +41,7 @@ It operates on three levels, per `wiki/ProductRequirementsDocument.md`:
|
|||
Primary customer service/UI repository for the HelixForge factory, selected by
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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
160
uv.lock
generated
|
|
@ -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" },
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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 {}
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
)
|
||||
],
|
||||
},
|
||||
),
|
||||
]
|
||||
|
|
@ -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')]
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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']
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
),
|
||||
]
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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},
|
||||
],
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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'}),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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},
|
||||
],
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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},
|
||||
],
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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/',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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},
|
||||
],
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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>
|
||||
|
|
@ -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 %}
|
||||
|
|
@ -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 %}
|
||||
|
|
@ -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 %}
|
||||
|
|
@ -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 %}
|
||||
|
|
@ -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 %}
|
||||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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 }}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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)]">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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 }}">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
```
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue