Compare commits

..

102 Commits

Author SHA1 Message Date
498b7aa9a5 Promote vergabe-teilnahme capability after SCOPE.md fill-in (T05 entry 2).
SCOPE.md was already filled; update the registry entry to D3/A1/C1/R1,
document issue-core and whynot-design relations, and align the index vector
with the authored scope and wiki evidence.
2026-07-07 15:23:17 +02:00
ff95fb3971 Fill in SCOPE.md (was an unfilled template)
Written from direct inspection of the 12-app Django domain model,
wiki/ProductRequirementsDocument.md, and wiki/ArchitectureBlueprint.md.
Requested during reuse-surface REUSE-WP-0017-T05 review follow-up.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-07 01:26:36 +02:00
d20d21b21a Draft capability entry (reuse-surface REUSE-WP-0017-T04, cohort 3)
Honest first-pass maturity vector grounded in README/docs/tests present
in this repo; no invented evidence. Flagged for human review before
publish. See reuse-surface history/2026-07-06-coverage-classification.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-06 19:50:54 +02:00
a3cec3f221 Normalize agent instructions and workplan frontmatter (STATE-WP-0067)
- Align agent files with on-disk workplan prefixes (infer from workplan ids)
- Set workplan domain to registered domain_slug; add topic_slug where applicable
- Repair frontmatter delimiter formatting; migrate legacy task status literals
- Regenerate AGENTS.md, CLAUDE.md, and .claude/rules from State Hub templates
2026-06-22 23:16:29 +02:00
b31409649c Add .repo-classification.yaml (CUST-WP-0050 T11 agent first-pass) 2026-06-22 17:47:43 +02:00
7bd6730744 Add credential routing instructions for all agent runtimes
Propagate shared credential-routing section (Codex, Claude, Grok, llm-connect)
from state-hub template via scripts/propagate_credential_routing.py.
2026-06-18 22:48:40 +02:00
9cb8992486 Add capability registry scaffold (REUSE-WP-0014-T08 B06) 2026-06-16 02:02:54 +02:00
5ae4aba4a8 Lock issue-core from Gitea registry 2026-06-05 20:42:33 +02:00
00469c4cc0 Adopt whynot-design tokens (WP-0017 Phase 1)
Replace vergabe's blue brand-* palette with whynot's near-black/paper/yellow
visual language. Tokens vendored at static/src/vendor/whynot-design/ (synced
from commit 9419f16 via scripts/sync-whynot-design.sh / make sync-whynot-design).

main.css imports the vendored CSS first, exposes ink/paper/hi as Tailwind
@theme tokens (bg-paper, text-ink, border-line, etc.), and re-tones every
component class (.btn-*, .card, .field-row, .phase-*, .form-input, .table-*,
.sidebar-*). Border radii drop to whynot's 0-4px; .card loses its shadow.

Legacy text-brand-* / bg-brand-* / border-brand-* template references are
kept working via @theme aliases that map the old blue scale onto the whynot
ink ramp — Phase 1 is tokens-only, no template churn.

btn-danger keeps an off-spec red (#B22222) as a local --danger var until
upstream defines a canonical destructive color.

base.html body class swapped: bg-slate-50 → bg-paper-2 text-ink.

Phase 2 (component adoption) deferred until whynot-design ships Lit web
components + missing atoms (Card, Modal, Input, Table, Toast). See
wiki/DesignSystem.md and history/2026-05-23-whynot-design-cross-framework-analysis.md.

Verified: 8/8 e2e tests pass; dev server boots; static/dist/main.css contains
no #3b5bdb references. Visual pixel-level verification still pending Bernd's
browser walk.
2026-05-23 21:52:59 +02:00
fbb8def9ce Plan WP-0017: whynot-design adoption (tokens + CSS only)
Add cross-framework analysis to history/ and WP-0017 workplan for the
tokens+CSS phase of adopting ~/whynot-design. Component port deferred until
upstream ships Lit web components and missing atoms (Card, Modal, Input,
Table, Toast).

Decisions captured: vendor (not npm), big-bang swap (not pilot), keep
off-spec red for btn-danger until upstream defines one.
2026-05-23 19:09:18 +02:00
739ffafedd fixes 2026-05-23 06:42:44 +02:00
f95de1482d Fix CSRF 403 on all POSTs behind traefik
prod.py never read the CSRF_TRUSTED_ORIGINS env var the deployment already
injects, so Django's setting stayed empty. Behind traefik's TLS termination
Django saw requests as HTTP and rejected the browser's https:// Origin on
every POST with a CSRF failure (403) — forms could not be saved and the DB
stayed empty.

- Read CSRF_TRUSTED_ORIGINS from env (filtering empties).
- Set SECURE_PROXY_SSL_HEADER so Django recognizes HTTPS via X-Forwarded-Proto.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 02:02:42 +02:00
e28739f8f1 Fix Tailwind asset build dropping all utility classes in Docker
The Dockerfile asset stage copied only static/src + vite.config.js, never
the HTML templates. Tailwind v4 generates CSS by scanning source files for
class usage, so with no templates present it emitted a stripped CSS with
zero utility classes — breaking layout and ballooning SVG icons (w-5/h-5
no longer applied) on deployed builds.

- main.css: add explicit @source directive for the templates dir so content
  detection no longer depends on the build CWD.
- Dockerfile: copy vergabe_teilnahme/templates into the assets stage so the
  @source path resolves at build time.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 00:37:07 +02:00
483a4dfd3b Add multi-stage Dockerfile for container deployment
Three-stage build:
- assets: Node 22 + Vite + Tailwind CSS 4 → static/dist/main.css
- python-deps: uv sync --frozen --no-dev against pyproject + uv.lock,
  with the issue-core path dependency satisfied via a BuildKit
  named context (--build-context issue-core=...)
- runtime: python:3.12-slim-bookworm + libpq5 + curl, non-root 'app'
  user, collectstatic at build time, gunicorn on :8000,
  /health/ HEALTHCHECK every 30s

Adds gunicorn>=22 to project dependencies (was missing).

Build:
  docker build --build-context issue-core=/home/worsch/issue-core \
    -t gitea.coulomb.social/coulomb/vergabe-teilnahme:<tag> .

Smoke-verified: container reports (healthy) and /health/ returns
{"status": "ok"} without a database connection.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 23:32:51 +02:00
17f511fbcd Rewire issue tracker integration from issue-facade to issue-core
issue-facade was renamed to issue-core. Update the dependency
declaration and the three Python imports it touched. Model field
names (issue_facade_backend, issue_facade_id) and the Django setting
ISSUE_FACADE_LOCAL_DB stay as-is — they are persisted/semantic
identifiers, not part of the package wiring.

All 20 aufgaben tests pass after the rewire.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 22:33:28 +02:00
341c718511 Refresh agent instruction files 2026-05-18 16:55:54 +02:00
a32ebb9e12 chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-05-16:
  - update .custodian-brief.md for vergabe-teilnahme
2026-05-16 06:10:43 +02:00
746b035feb chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-05-16:
  - update .custodian-brief.md for vergabe_teilnahme
2026-05-16 05:53:23 +02:00
eaddaa1b2f chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-05-16:
  - update .custodian-brief.md for vergabe-teilnahme
2026-05-16 05:36:09 +02:00
c7c2e8472b chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-05-16:
  - update .custodian-brief.md for vergabe_teilnahme
2026-05-16 05:18:53 +02:00
6436fc5faf chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-05-16:
  - update .custodian-brief.md for vergabe-teilnahme
2026-05-16 05:01:42 +02:00
1ad7674532 chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-05-16:
  - update .custodian-brief.md for vergabe_teilnahme
2026-05-16 04:44:52 +02:00
3285026c0a chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-05-16:
  - update .custodian-brief.md for vergabe-teilnahme
2026-05-16 03:53:08 +02:00
bb70f36068 chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-05-16:
  - update .custodian-brief.md for vergabe_teilnahme
2026-05-16 03:53:02 +02:00
99b0974732 chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-05-16:
  - update .custodian-brief.md for vergabe-teilnahme
2026-05-16 03:36:06 +02:00
00e6a9ae6f chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-05-16:
  - update .custodian-brief.md for vergabe_teilnahme
2026-05-16 03:36:01 +02:00
1f39d6ad85 chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-05-16:
  - update .custodian-brief.md for vergabe-teilnahme
2026-05-16 03:18:52 +02:00
c9cf3bab87 chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-05-16:
  - update .custodian-brief.md for vergabe_teilnahme
2026-05-16 03:18:48 +02:00
07a831632b chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-05-16:
  - update .custodian-brief.md for vergabe-teilnahme
2026-05-16 02:26:59 +02:00
963997f410 chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-05-15:
  - update .custodian-brief.md for vergabe_teilnahme
2026-05-15 22:16:32 +02:00
19118dc534 chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-05-15:
  - update .custodian-brief.md for vergabe-teilnahme
2026-05-15 20:08:27 +02:00
0a7f1de5e4 chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-05-15:
  - update .custodian-brief.md for vergabe_teilnahme
2026-05-15 19:51:54 +02:00
91ab70710e chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-05-15:
  - update .custodian-brief.md for vergabe-teilnahme
2026-05-15 19:51:49 +02:00
33b6f2ecaf chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-05-15:
  - update .custodian-brief.md for vergabe_teilnahme
2026-05-15 19:35:42 +02:00
656276dda1 chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-05-15:
  - update .custodian-brief.md for vergabe-teilnahme
2026-05-15 19:35:36 +02:00
9677cb50fb chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-05-15:
  - update .custodian-brief.md for vergabe_teilnahme
2026-05-15 19:19:47 +02:00
67693d9f61 chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-05-15:
  - update .custodian-brief.md for vergabe-teilnahme
2026-05-15 19:19:42 +02:00
e55a619bce chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-05-15:
  - update .custodian-brief.md for vergabe_teilnahme
2026-05-15 19:03:35 +02:00
5d0d63fd89 chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-05-15:
  - update .custodian-brief.md for vergabe-teilnahme
2026-05-15 19:03:29 +02:00
ff79a4bd57 chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-05-15:
  - update .custodian-brief.md for vergabe_teilnahme
2026-05-15 17:58:48 +02:00
ec8aca19c1 chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-05-15:
  - update .custodian-brief.md for vergabe-teilnahme
2026-05-15 17:41:55 +02:00
07f8a2659d chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-05-15:
  - update .custodian-brief.md for vergabe_teilnahme
2026-05-15 17:41:51 +02:00
a9299e37b6 chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-05-15:
  - update .custodian-brief.md for vergabe-teilnahme
2026-05-15 16:53:20 +02:00
649f524979 chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-05-15:
  - update .custodian-brief.md for vergabe_teilnahme
2026-05-15 14:25:18 +02:00
c103c0d057 chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-05-15:
  - update .custodian-brief.md for vergabe-teilnahme
2026-05-15 14:25:13 +02:00
69c7f3eee1 chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-05-15:
  - update .custodian-brief.md for vergabe_teilnahme
2026-05-15 14:09:07 +02:00
b5b5c6c29b chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-05-15:
  - update .custodian-brief.md for vergabe-teilnahme
2026-05-15 13:52:38 +02:00
0ae841b362 chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-05-15:
  - update .custodian-brief.md for vergabe_teilnahme
2026-05-15 13:18:56 +02:00
ae32ba4ba6 chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-05-15:
  - update .custodian-brief.md for vergabe-teilnahme
2026-05-15 13:02:19 +02:00
b4451ea161 chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-05-15:
  - update .custodian-brief.md for vergabe_teilnahme
2026-05-15 10:48:29 +02:00
87e0a557b0 chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-05-15:
  - update .custodian-brief.md for vergabe-teilnahme
2026-05-15 10:48:15 +02:00
78a27793c1 chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-05-15:
  - update .custodian-brief.md for vergabe_teilnahme
2026-05-15 10:31:25 +02:00
34cf25483d chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-05-15:
  - update .custodian-brief.md for vergabe-teilnahme
2026-05-15 10:31:13 +02:00
aa940fafe5 chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-05-15:
  - update .custodian-brief.md for vergabe_teilnahme
2026-05-15 10:14:37 +02:00
fdccb41fcd chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-05-15:
  - update .custodian-brief.md for vergabe-teilnahme
2026-05-15 09:59:01 +02:00
30c44edb18 chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-05-15:
  - update .custodian-brief.md for vergabe_teilnahme
2026-05-15 03:36:23 +02:00
10c6252931 chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-05-15:
  - update .custodian-brief.md for vergabe-teilnahme
2026-05-15 03:36:08 +02:00
c1fe26c36b chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-05-15:
  - update .custodian-brief.md for vergabe_teilnahme
2026-05-15 03:21:32 +02:00
7568c85acc chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-05-15:
  - update .custodian-brief.md for vergabe-teilnahme
2026-05-15 03:06:43 +02:00
58ee79ef2f chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-05-15:
  - update .custodian-brief.md for vergabe_teilnahme
2026-05-15 02:51:58 +02:00
438d9a8d16 chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-05-15:
  - update .custodian-brief.md for vergabe-teilnahme
2026-05-15 02:37:05 +02:00
957bd7b77b chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-05-15:
  - update .custodian-brief.md for vergabe_teilnahme
2026-05-15 02:22:19 +02:00
f662fc42f8 chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-05-15:
  - update .custodian-brief.md for vergabe-teilnahme
2026-05-15 02:07:29 +02:00
c424b6ff90 chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-05-14:
  - update .custodian-brief.md for vergabe_teilnahme
2026-05-14 22:39:21 +02:00
3856d6d5d4 chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-05-14:
  - update .custodian-brief.md for vergabe-teilnahme
2026-05-14 19:38:42 +02:00
61f6c5f4eb chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-05-14:
  - update .custodian-brief.md for vergabe_teilnahme
2026-05-14 16:20:24 +02:00
7d6d2ebc2a chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-05-14:
  - update .custodian-brief.md for vergabe-teilnahme
2026-05-14 16:20:12 +02:00
4370be6160 chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-05-14:
  - update .custodian-brief.md for vergabe_teilnahme
2026-05-14 15:34:43 +02:00
e286290794 chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-05-14:
  - update .custodian-brief.md for vergabe-teilnahme
2026-05-14 15:34:29 +02:00
e08698ecdb chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-05-14:
  - update .custodian-brief.md for vergabe_teilnahme
2026-05-14 14:48:17 +02:00
37df677906 chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-05-14:
  - update .custodian-brief.md for vergabe-teilnahme
2026-05-14 14:48:04 +02:00
0903d2a8a4 chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-05-14:
  - update .custodian-brief.md for vergabe_teilnahme
2026-05-14 14:32:35 +02:00
ee5f530f5f chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-05-14:
  - update .custodian-brief.md for vergabe-teilnahme
2026-05-14 14:17:08 +02:00
b3377af439 chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-05-14:
  - update .custodian-brief.md for vergabe_teilnahme
2026-05-14 14:01:57 +02:00
8e91751528 chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-05-14:
  - update .custodian-brief.md for vergabe-teilnahme
2026-05-14 14:01:43 +02:00
dd6948f5ee chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-05-14:
  - update .custodian-brief.md for vergabe_teilnahme
2026-05-14 13:46:47 +02:00
819b928041 chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-05-14:
  - update .custodian-brief.md for vergabe-teilnahme
2026-05-14 13:46:19 +02:00
d7a4e4b07b chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-05-14:
  - update .custodian-brief.md for vergabe_teilnahme
2026-05-14 13:30:44 +02:00
3aca040ca7 chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-05-14:
  - update .custodian-brief.md for vergabe-teilnahme
2026-05-14 13:30:30 +02:00
2e0635501d chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-05-14:
  - update .custodian-brief.md for vergabe_teilnahme
2026-05-14 12:44:05 +02:00
8ff7fbc5bd chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-05-14:
  - update .custodian-brief.md for vergabe-teilnahme
2026-05-14 12:28:15 +02:00
b4aac0fd33 chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-05-14:
  - update .custodian-brief.md for vergabe_teilnahme
2026-05-14 12:28:03 +02:00
15ee4b3638 chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-05-14:
  - update .custodian-brief.md for vergabe-teilnahme
2026-05-14 12:12:42 +02:00
bdb9370c0b chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-05-14:
  - update .custodian-brief.md for vergabe_teilnahme
2026-05-14 11:57:09 +02:00
a907ed6f74 issue-facade integration 2026-05-14 11:30:30 +02:00
c2b7cc64d6 chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-05-14:
  - update .custodian-brief.md for vergabe-teilnahme
2026-05-14 06:13:39 +02:00
c022279038 chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-05-14:
  - update .custodian-brief.md for vergabe_teilnahme
2026-05-14 05:58:27 +02:00
cc499adc61 chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-05-14:
  - update .custodian-brief.md for vergabe-teilnahme
2026-05-14 05:43:16 +02:00
3d36baa1ce chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-05-14:
  - update .custodian-brief.md for vergabe_teilnahme
2026-05-14 05:28:04 +02:00
35a71c1cc1 chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-05-14:
  - update .custodian-brief.md for vergabe-teilnahme
2026-05-14 05:12:52 +02:00
69fda01d95 chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-05-14:
  - update .custodian-brief.md for vergabe_teilnahme
2026-05-14 04:57:33 +02:00
28e012201b chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-05-14:
  - update .custodian-brief.md for vergabe-teilnahme
2026-05-14 04:57:22 +02:00
2877898885 chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-05-14:
  - update .custodian-brief.md for vergabe_teilnahme
2026-05-14 04:42:27 +02:00
256fb4c75c chore(workplan): WP-0016 — Issue-Facade Integration
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 04:29:29 +02:00
d88aa9318c chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-05-14:
  - update .custodian-brief.md for vergabe-teilnahme
2026-05-14 04:27:27 +02:00
816c281f6a feat(aufgaben): Verknüpfungen, implizite Fälligkeit, Issue-Facade (WP-0015)
- Aufgabe.erstellt_am für implizite 7-Tage-Fälligkeit
- frist_effektiv property; ist_ueberfaellig nutzt sie
- AufgabenVerknuepfung (GenericForeignKey) mit HTMX-Panel
- ExternalIssue (OneToOne) mit IssueAdapter-ABC und HTMX-Panel
- link_registry.py und issue_facade.py als zentrale Registries
- 8 neue Tests, 76 gesamt grün

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 04:18:28 +02:00
a0c0ddf2eb chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-05-14:
  - update .custodian-brief.md for vergabe_teilnahme
2026-05-14 04:12:12 +02:00
b7863f60a5 chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-05-14:
  - update .custodian-brief.md for vergabe-teilnahme
2026-05-14 04:11:59 +02:00
a72360cc94 docs(workplan): WP-0015 — Aufgaben-Verknüpfungen, implizite Fälligkeit, Issue-Facade
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 03:36:43 +02:00
a9c230326d chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-05-14:
  - update .custodian-brief.md for vergabe_teilnahme
2026-05-14 03:31:59 +02:00
b054824862 fix(aufgaben): aufgabe_row.html nutzt aufgabe.ausschreibung_id statt ausschreibung.pk
Im globalen /aufgaben/-Kontext ist ausschreibung=None; die direkten FK-IDs
auf dem Aufgabe-Objekt sind immer vorhanden und erfordern kein Queryset-Join.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 03:17:14 +02:00
9ef6e0950a feat(aufgaben): Aufgaben-Panel auf Ausschreibungs-Detailseite + Sidebar-Link
Aufgaben sind jetzt direkt von der Detailseite erreichbar und anlegbar:
- Aufgaben-Panel mit HTMX-Inline-Formular auf ausschreibung_detail
- Globaler Sidebar-Link /aufgaben/ für ausschreibungsübergreifende Übersicht
- aufgaben-Queryset in ausschreibung_detail-Kontext ergänzt

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 03:12:23 +02:00
78 changed files with 4618 additions and 122 deletions

20
.claude/rules/agents.md Normal file
View File

@@ -0,0 +1,20 @@
## Kaizen Agents
Specialized agent personas available on demand via the state-hub MCP.
**Discover:** `list_kaizen_agents()` — returns all agents with name, description, category
**Load:** `get_kaizen_agent("tdd-workflow")` — returns full instructions; read and follow them
Common agents:
| Agent | Category | When to use |
|-------|----------|-------------|
| `tdd-workflow` | testing | Step-by-step TDD8 workflow for any feature |
| `code-refactoring` | quality | Code quality analysis and safe refactoring |
| `test-maintenance` | testing | Diagnose and fix failing tests |
| `requirements-engineering` | process | Prevent interface/mock mismatches upfront |
| `keepaTodofile` | process | Maintain TODO.md during work |
| `project-management` | process | Track status, determine next steps |
| `datamodel-optimization` | quality | Optimize dataclasses and data structures |
All 17 agents: call `list_kaizen_agents()` for the full list.

View File

@@ -0,0 +1,8 @@
## Architecture
<!-- TODO: Describe the key design decisions and component structure.
Key modules, data flows, external integrations, state machines, etc. -->
## Quick Reference
`~/state-hub/mcp_server/TOOLS.md` — MCP tool reference

View File

@@ -0,0 +1,50 @@
# Credential and access routing
**Audience:** Codex, Claude Code, Grok, and custodian agents that call **llm-connect**
for inference. Run this check **before** requesting secrets, API keys, SSH access,
login tokens, or database passwords — in any repo, not only `ops-warden`.
ops-warden **issues SSH certificates only** (`warden sign`, `cert_command`). Every
other credential need belongs to another subsystem. **Do not** message
`ops-warden` on State Hub expecting a secret value; the reply is a pointer, not a key.
### Lookup (do this first)
```bash
warden route find "<describe your need>" --json
warden route show <catalog-id> --json
```
Requires the `warden` CLI from `~/ops-warden` (`uv tool install .` or `uv run warden`).
| Agent runtime | How to orient |
| --- | --- |
| **Codex / Grok** (shell, HTTP State Hub) | `warden route` commands above; inbox `to_agent=vergabe-teilnahme` is for coordination, not secret vending |
| **Claude Code** (MCP when available) | `get_domain_summary("custodian")` for workstreams; **still** use `warden route` for credential ownership |
| **llm-connect** (inference service) | Never put secret retrieval in prompts; route custody to OpenBao/operator paths surfaced by `warden route` |
### Quick routing table
| I need… | Owner | ops-warden executes? |
| --- | --- | --- |
| SSH cert (`adm`/`agt`/`atm`) | ops-warden | **Yes**`warden sign` |
| API key, DB password, provider token | OpenBao (`railiance-platform`) | No — route only |
| Login / OIDC / MFA | key-cape / Keycloak | No — route only |
| Authorization decision | flex-auth | No — route only |
| activity-core → issue-core emission | activity-core + issue-core | No — `warden route show activity-core-issue-sink` |
| SSH tunnel | ops-bridge (+ `cert_command` from warden) | No — route only |
### Anti-patterns (do not do these)
- `POST /messages/` to `ops-warden` asking for `ISSUE_CORE_API_KEY`, `OPENROUTER_API_KEY`, etc.
- Inventing `warden secret`, `warden login`, `warden bao`, `warden tunnel` — they do not exist
- Pasting secrets into Git, State Hub, workplans, logs, or chat
### Other capabilities (reuse-surface)
Non-credential capabilities are usually discovered through **reuse-surface** federation
(`reuse-surface` registry / `capability.*` indexes). Credential routing is inlined in
every repo's agent instructions because it is high-frequency, high-risk, and easy to
get wrong.
**Canon:** `~/ops-warden/wiki/CredentialRouting.md` · catalog `~/ops-warden/registry/routing/catalog.yaml`

View File

@@ -0,0 +1,38 @@
## First Session Protocol
Triggered when `get_domain_summary("communication")` shows **no workstreams**.
The project is registered but work has not yet been structured.
**Step 1 — Read, don't write**
- `~/the-custodian/canon/projects/communication/project_charter_v0.1.md` — purpose, scope
- `~/the-custodian/canon/projects/communication/roadmap_v0.1.md` — planned phases
- Scan repo root: README, directory structure, existing code or docs
**Step 2 — Survey in-progress work**
Look for TODOs, open branches, half-finished files. Note done vs. started but incomplete.
**Step 3 — Propose workstreams to Bernd**
Propose 13 workstreams — each a coherent strand, weeks to months, anchored to a
roadmap phase. **Wait for approval before creating.**
**Step 4 — Create workplan file first, then DB record (ADR-001)**
```
workplans/WP-NNNN-<slug>.md ← write this first
```
Then register in the hub:
```
create_workstream(topic_id="36c7421b-c537-4723-bf75-42a3ebc6a1dc", title="...", owner="...", description="...")
create_task(workstream_id="<id>", title="...", priority="high|medium|low")
```
**Step 5 — Record the setup**
```
add_progress_event(
summary="First session: structured communication into N workstreams, M tasks",
event_type="milestone",
topic_id="36c7421b-c537-4723-bf75-42a3ebc6a1dc",
detail={"workstreams": [...], "tasks_created": M}
)
```
<!-- Delete or archive this file once past first session -->

View File

@@ -0,0 +1,8 @@
## Repo boundary
This repo owns **vergabe-teilnahme** only. It does not own:
<!-- TODO: List what belongs in adjacent repos, e.g.:
- SSH key management → railiance-infra/
- State hub code → state-hub/
-->

View File

@@ -0,0 +1,5 @@
**Purpose:** Coulomb project repository (bootstrapped from repo-seed template)
**Domain:** communication
**Repo slug:** vergabe-teilnahme
**Topic ID:** 36c7421b-c537-4723-bf75-42a3ebc6a1dc

View File

@@ -0,0 +1,85 @@
## Session Protocol
Dev Hub (State Hub API): http://127.0.0.1:8000
MCP server name in `~/.claude.json`: `dev-hub`
**Step 1 — Orient**
Read the offline-safe brief first — it works without a live hub connection:
```bash
cat .custodian-brief.md
```
Then call the MCP tool for richer cross-domain context when MCP tools are exposed:
```
get_domain_summary("communication")
```
If MCP tools are unavailable in the current agent session, use the REST API:
```bash
curl -s "http://127.0.0.1:8000/state/summary" | python3 -m json.tool
```
If the hub is offline: `cd ~/state-hub && make api`
**Step 2 — Check inbox**
With MCP tools:
```
get_messages(to_agent="vergabe-teilnahme", unread_only=True)
```
Mark read with `mark_message_read(message_id)`. Reply or act on coordination
requests before proceeding.
Without MCP tools:
```bash
curl -s "http://127.0.0.1:8000/messages/?to_agent=vergabe-teilnahme&unread_only=true" \
| python3 -m json.tool
curl -s -X PATCH "http://127.0.0.1:8000/messages/<id>/read" \
-H "Content-Type: application/json" -d '{}'
```
**Step 3 — Scan workplans**
```bash
ls workplans/
```
For each file with `status: ready`, `active`, or `blocked`, note pending
`wait`/`todo`/`progress` tasks.
**Step 4 — Present brief**
1. **Active workstreams** for `communication` — title, task counts, blocking decisions
2. **Pending tasks** from `workplans/` + any `[repo:vergabe-teilnahme]` hub tasks
3. **Goal guidance** — if `goal_guidance` in summary:
- `needs_workplan`: surface as top action — *"Repo goal '{title}' has no workplan yet"*
- `alignment_warnings`: flag if active work is not aligned with current goal
4. **Suggested next action** — highest-priority open item
5. **SBOM status** — flag if `last_sbom_at` is unset for this repo
If no workstreams: follow First Session Protocol (`first-session.md`).
**During work:** `record_decision()` · `add_progress_event()` · `resolve_decision()`
> State Hub is a *read model*. Bootstrap tools (`create_workstream`, `create_task`)
> are First Session Protocol only. Work structure belongs in repo files (ADR-001).
**Session close:**
With MCP tools:
```
add_progress_event(summary="...", topic_id="36c7421b-c537-4723-bf75-42a3ebc6a1dc", workstream_id="<uuid>")
```
Without MCP tools:
```bash
curl -s -X POST http://127.0.0.1:8000/progress/ \
-H "Content-Type: application/json" \
-d '{"topic_id":"36c7421b-c537-4723-bf75-42a3ebc6a1dc","workstream_id":"<uuid>","event_type":"note","summary":"what changed","author":"codex"}'
```
If workplan files were modified, ensure the local copy is up to date first:
```bash
git -C <repo_path> pull --ff-only
cd ~/state-hub && make fix-consistency REPO=vergabe-teilnahme
```
For repos where implementation runs on a remote machine (e.g. CoulombCore),
use the combined target which pulls before fixing:
```bash
cd ~/state-hub && make fix-consistency-remote REPO=vergabe-teilnahme
```
**C-15** (DB task ahead of file) is normal in multi-machine workflows — writeback
will sync the file to match DB. **C-16** (repo behind remote) blocks all writes
until you pull — intentional to prevent clobbering remote progress.

View File

@@ -0,0 +1,19 @@
## Stack
<!-- TODO: Fill in language, frameworks, and key dependencies -->
- **Language:**
- **Key deps:**
## Dev Commands
```bash
# TODO: Fill in the standard commands for this repo
# Install dependencies
# Run tests
# Lint / type check
# Build / package (if applicable)
```

View File

@@ -0,0 +1,40 @@
## Workplan Convention (ADR-001)
File location: `workplans/WP-NNNN-<slug>.md`
ID prefix: `WP-`
Work items originate as files in this repo **before** being registered in the hub.
Canonical workplan/workstream frontmatter statuses are:
`proposed`, `ready`, `active`, `blocked`, `backlog`, `finished`, `archived`.
Use `proposed` for a newly drafted plan, `ready` after review against current
repo state, and `finished` when implementation is complete. `stalled` and
`needs_review` are derived health labels, not stored statuses.
Closed workplans may be moved to `workplans/archived/` with a completion-date
prefix: `YYMMDD-WP-NNNN-<slug>.md`. The frontmatter id remains
unchanged; the prefix is only for quick visual reference.
Small opportunistic tasks discovered during another session use **Ad Hoc Tasks**:
`workplans/ADHOC-YYYY-MM-DD.md`, workstream slug `adhoc-YYYY-MM-DD`, and task ids
`ADHOC-YYYY-MM-DD-T01`, `T02`, etc. Use adhocs only for low-risk work completed
directly. Promote anything requiring analysis, design, approval, dependencies, or
multiple planned phases into a normal workplan.
Ecosystem todos from other agents arrive as `[repo:vergabe-teilnahme]` hub tasks —
visible at session start. Pick one up by creating the workplan file, then registering
the workstream.
Task blocks use this shape:
```task
id: WP-NNNN-T01
status: wait | todo | progress | done | cancel
priority: high | medium | low
state_hub_task_id: "<uuid>" # written by fix-consistency — do not edit
```
Status progression is `todo``progress``done`; use `wait` for waiting or
blocked work and `cancel` for stopped work.
<!-- Ralph Loop rules and HEUREKA sequence: ~/.claude/CLAUDE.md — do not duplicate here -->

View File

@@ -2,7 +2,7 @@
# Custodian Brief — vergabe-teilnahme
**Domain:** (unknown)
**Last synced:** 2026-05-13 23:09 UTC
**Last synced:** 2026-05-16 04:10 UTC
**State Hub:** http://127.0.0.1:8000 *(adjust if running on a remote machine)*
## Active Workstreams

33
.dockerignore Normal file
View File

@@ -0,0 +1,33 @@
.git
.gitignore
.dockerignore
Dockerfile
__pycache__
*.py[cod]
.venv
.pytest_cache
.ruff_cache
.mypy_cache
.coverage
htmlcov
node_modules
static/dist
staticfiles
media
*.log
*.sqlite3
.issue-facade
.env
.env.*
docker-compose*.yml
.claude
.custodian-brief.md
workplans
wiki
LICENSE
README.md

1
.gitignore vendored
View File

@@ -181,3 +181,4 @@ media/
# PyPI configuration file
.pypirc
.issue-facade/

19
.repo-classification.yaml Normal file
View File

@@ -0,0 +1,19 @@
repo_classification:
standard: Repo Classification Standard
version: '1.0'
classified_at: '2026-06-22'
classified_by: agent
category: experimental
domain: communication
secondary_domains: []
capability_tags:
- marketplace
- collaboration
- procurement
- governance
business_stake:
- product
- sales
business_mechanics:
- coordination
- operation

219
AGENTS.md Normal file
View File

@@ -0,0 +1,219 @@
# vergabe-teilnahme — Agent Instructions
## Repo Identity
**Purpose:** Coulomb project repository (bootstrapped from repo-seed template)
**Domain:** communication
**Repo slug:** vergabe-teilnahme
**Topic ID:** `36c7421b-c537-4723-bf75-42a3ebc6a1dc`
**Workplan prefix:** `WP-`
---
## State Hub Integration
The Custodian State Hub tracks work across all domains. Interact via HTTP REST —
there is no MCP server for Codex agents.
| Context | URL |
|---------|-----|
| Local workstation | `http://127.0.0.1:8000` |
| Remote via tunnel | `http://127.0.0.1:18000` |
### Orient at session start
```bash
# Offline brief — works without hub connection
cat .custodian-brief.md
# Active workstreams for this domain
curl -s "http://127.0.0.1:8000/workstreams/?topic_id=36c7421b-c537-4723-bf75-42a3ebc6a1dc&status=active" \
| python3 -m json.tool
# Check inbox
curl -s "http://127.0.0.1:8000/messages/?to_agent=vergabe-teilnahme&unread_only=true" \
| python3 -m json.tool
```
Mark a message read:
```bash
curl -s -X PATCH "http://127.0.0.1:8000/messages/<id>/read" \
-H "Content-Type: application/json" -d '{}'
```
### Log progress (required at session close)
```bash
curl -s -X POST http://127.0.0.1:8000/progress/ \
-H "Content-Type: application/json" \
-d '{
"summary": "what was done",
"event_type": "note",
"author": "codex",
"workstream_id": "<uuid>",
"task_id": "<uuid>"
}'
```
Omit `workstream_id` / `task_id` when not applicable.
### Update task status
```bash
curl -s -X PATCH "http://127.0.0.1:8000/tasks/<task_id>" \
-H "Content-Type: application/json" \
-d '{"status": "progress"}'
# values: wait | todo | progress | done | cancel
```
### Flag a task for human review
```bash
curl -s -X PATCH "http://127.0.0.1:8000/tasks/<task_id>" \
-H "Content-Type: application/json" \
-d '{"needs_human": true, "intervention_note": "reason"}'
```
---
## Session Protocol
**Start:**
1. `cat .custodian-brief.md` — domain goal and open workstreams (offline-safe)
2. Check inbox: `GET /messages/?to_agent=vergabe-teilnahme&unread_only=true`; mark read
3. Scan workplans: `ls workplans/` — note `status: ready`, `active`, or `blocked` files and open tasks
4. Check human-needed tasks: `GET /tasks/?needs_human=true`
**During work:**
- Update task statuses in workplan files as tasks progress
- Record significant decisions via `POST /decisions/`
**Close:**
1. Update workplan file task statuses to reflect progress
2. Log: `POST /progress/` with a summary of what changed
3. Note for the custodian operator: after workplan file changes, run from
`~/state-hub`:
```bash
make fix-consistency REPO=vergabe-teilnahme
```
This syncs task status from files into the hub DB.
---
## Credential and access routing
**Audience:** Codex, Claude Code, Grok, and custodian agents that call **llm-connect**
for inference. Run this check **before** requesting secrets, API keys, SSH access,
login tokens, or database passwords — in any repo, not only `ops-warden`.
ops-warden **issues SSH certificates only** (`warden sign`, `cert_command`). Every
other credential need belongs to another subsystem. **Do not** message
`ops-warden` on State Hub expecting a secret value; the reply is a pointer, not a key.
### Lookup (do this first)
```bash
warden route find "<describe your need>" --json
warden route show <catalog-id> --json
```
Requires the `warden` CLI from `~/ops-warden` (`uv tool install .` or `uv run warden`).
| Agent runtime | How to orient |
| --- | --- |
| **Codex / Grok** (shell, HTTP State Hub) | `warden route` commands above; inbox `to_agent=vergabe-teilnahme` is for coordination, not secret vending |
| **Claude Code** (MCP when available) | `get_domain_summary("custodian")` for workstreams; **still** use `warden route` for credential ownership |
| **llm-connect** (inference service) | Never put secret retrieval in prompts; route custody to OpenBao/operator paths surfaced by `warden route` |
### Quick routing table
| I need… | Owner | ops-warden executes? |
| --- | --- | --- |
| SSH cert (`adm`/`agt`/`atm`) | ops-warden | **Yes** — `warden sign` |
| API key, DB password, provider token | OpenBao (`railiance-platform`) | No — route only |
| Login / OIDC / MFA | key-cape / Keycloak | No — route only |
| Authorization decision | flex-auth | No — route only |
| activity-core → issue-core emission | activity-core + issue-core | No — `warden route show activity-core-issue-sink` |
| SSH tunnel | ops-bridge (+ `cert_command` from warden) | No — route only |
### Anti-patterns (do not do these)
- `POST /messages/` to `ops-warden` asking for `ISSUE_CORE_API_KEY`, `OPENROUTER_API_KEY`, etc.
- Inventing `warden secret`, `warden login`, `warden bao`, `warden tunnel` — they do not exist
- Pasting secrets into Git, State Hub, workplans, logs, or chat
### Other capabilities (reuse-surface)
Non-credential capabilities are usually discovered through **reuse-surface** federation
(`reuse-surface` registry / `capability.*` indexes). Credential routing is inlined in
every repo's agent instructions because it is high-frequency, high-risk, and easy to
get wrong.
**Canon:** `~/ops-warden/wiki/CredentialRouting.md` · catalog `~/ops-warden/registry/routing/catalog.yaml`
<!-- REPO-AGENTS-EXTENSIONS -->
<!-- Append repo-specific agent instructions below this marker.
The state-hub template sync preserves content after this line. -->
---
## Workplan Convention (ADR-001)
Work items originate as files in this repo — not in the hub. The hub is a
read/cache/index layer that rebuilds from files.
**File location:** `workplans/VERGABE-WP-NNNN-<slug>.md`
**Archived location:** finished workplans may move to
`workplans/archived/YYMMDD-VERGABE-WP-NNNN-<slug>.md`. The `YYMMDD` prefix is
the completion/archive date; the frontmatter `id` does not change.
**Ad Hoc Tasks:** small opportunistic fixes discovered during a session use
`workplans/ADHOC-YYYY-MM-DD.md` with task ids `ADHOC-YYYY-MM-DD-T01`, etc. Use
this only for low-risk work completed directly; create a normal workplan for
anything needing analysis, design, approval, dependencies, or multiple phases.
**Frontmatter:**
```yaml
---
id: VERGABE-WP-NNNN
type: workplan
title: "..."
domain: communication
repo: vergabe-teilnahme
status: proposed | ready | active | blocked | backlog | finished | archived
owner: codex
topic_slug: ...
created: "YYYY-MM-DD"
updated: "YYYY-MM-DD"
state_hub_workstream_id: "<uuid>" # written by fix-consistency — do not edit
---
```
Use `proposed` for a new draft, `ready` after review against current repo
state, and `finished` after implementation. `stalled` and `needs_review` are
derived health labels, not frontmatter statuses.
**Task block format** (one per `##` section):
```
## Task Title
` ` `task
id: VERGABE-WP-NNNN-T01
status: wait | todo | progress | done | cancel
priority: high | medium | low
state_hub_task_id: "<uuid>" # written by fix-consistency — do not edit
` ` `
Task description text.
```
Status progression: `todo` → `progress` → `done`; use `wait` for waiting/blocked work and `cancel` for stopped work.
To create a new workplan:
1. Write the file following the format above
2. Notify the custodian operator to run `make fix-consistency REPO=vergabe-teilnahme`
(or send a message to the hub agent via `POST /messages/`)

View File

@@ -1,76 +1,12 @@
# CLAUDE.md
# vergabe-teilnahme — Claude Code Instructions
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
**Vergabe Teilnahme** is a web-based tender/bid management system (internal collaboration tool) that supports a company through the full lifecycle of public and private procurement bids — from initial research through post-award retrospective. The language of the application and all domain documentation is **German**.
The authoritative requirements are in `wiki/ProductRequirementsDocument.md`. Technical architecture in `wiki/ArchitectureBlueprint.md`. Use cases in `wiki/UseCaseCatalog.md`.
## Tech Stack
**Django 6.x** · **uv** (package manager) · **Tailwind CSS v4** (via Vite) · **HTMX 2.x** · **Alpine.js 3.x** · **PostgreSQL 16+** (psycopg3)
## Entwicklungs-Commands
Run `make` without arguments for a full list with descriptions.
```bash
make db # PostgreSQL starten (Noop wenn :5432 bereits erreichbar)
make css # Tailwind CSS Watch-Modus — Terminal A, laufen lassen
make dev # Django-Dev-Server auf :9000 — Terminal B
make seed # Seed-Daten laden (idempotent)
make superuser # Superuser anlegen (einmalig für /admin/)
make migrate # Migrations generieren und ausführen
make test # pytest ausführen
make lint # ruff + mypy
uv run pytest vergabe_teilnahme/apps/<app>/tests/ # Einzelne Testdatei
```
## Projektstruktur
```
vergabe_teilnahme/
├── apps/ # Alle Django-Apps
│ ├── core/ # FlexibleModel, CustomAttribute, EntityFieldConfig, Freigabe
│ ├── accounts/ # Mitarbeiter (AbstractUser)
│ └── ... # je eine App pro Fachdomäne
├── settings/ # base.py, dev.py, prod.py
└── urls.py
static/
├── src/main.css # Tailwind-Quelldatei (mit @layer components und @theme brand tokens)
├── vendor/ # HTMX, Alpine.js (lokal, kein CDN)
└── dist/ # Build-Output (gitignored)
workplans/ # Ralph-Loop-Workplans (WP-0001 bis WP-0012)
wiki/ # PRD, Blueprint, Use-Case-Katalog
```
## Shared Infrastructure Note
Port 5432 is used by `infra-postgres-1` (the Custodian shared PostgreSQL container). The `vergabe_db` database and `vergabe` user are created there. `docker-compose.dev.yml` documents the intended standalone setup but is not started when infra container is active.
## Domain Model — Key Concepts
The system manages **Ausschreibungen** (tenders) through 8 phases:
| Phase | Name |
|-------|------|
| 1 | Recherche & Unterlagen bereitstellen |
| 2 | Teilnahmeentscheidung treffen |
| 3 | Detaillierte Durchsicht & offene Punkte |
| 4 | Bieterfragen, Subunternehmer, offene Punkte klären |
| 5 | Preismodell dokumentieren |
| 6 | Unterlagen finalisieren |
| 7 | Abgabe |
| 8 | Zuschlag / Nachbetrachtung |
Core entities: `Ausschreibung`, `Los` (lot), `Anforderung` (requirement), `Aufgabe` (task), `Bieterfrage` (bidder question), `Dokument`, `Subunternehmer`, `Preispunkt` (price point), `Marktbegleiter` (competitor), `Nachweis` (compliance certificate), `Referenz`, `Freigabe` (approval), `Nachbetrachtung` (retrospective).
**Vergleichsgewicht** (comparison weight): price points carry a weight in [0.0, 2.0] (default 1.0). Weighted averages use `Σ(value × weight) / Σ(weight)`; points with weight 0.0 are excluded from averages entirely.
## Custodian State Hub
This repo is tracked by the Custodian State Hub. At session start inside this repo, call `get_domain_summary("<domain_slug>")` via the `state-hub` MCP tool. End every non-trivial session with `add_progress_event()`.
@SCOPE.md
@.claude/rules/repo-identity.md
@.claude/rules/session-protocol.md
@.claude/rules/first-session.md
@.claude/rules/workplan-convention.md
@.claude/rules/stack-and-commands.md
@.claude/rules/architecture.md
@.claude/rules/repo-boundary.md
@.claude/rules/credential-routing.md
@.claude/rules/agents.md

98
Dockerfile Normal file
View File

@@ -0,0 +1,98 @@
# syntax=docker/dockerfile:1.7
#
# Build:
# docker build \
# --secret id=gitea_pypi_user,env=GITEA_PYPI_USER \
# --secret id=gitea_pypi_password,env=GITEA_PYPI_TOKEN \
# -t gitea.coulomb.social/coulomb/vergabe-teilnahme:<tag> .
#
# The optional secrets let uv authenticate to the Gitea Python package
# registry when resolving issue-core.
# ─── Stage 1 ─── Vite + Tailwind asset build ────────────────────────────────
FROM node:22-alpine AS assets
WORKDIR /build
COPY package.json package-lock.json ./
RUN npm ci --no-audit --no-fund
COPY vite.config.js ./
COPY static/src ./static/src
# Tailwind v4 scans these for utility-class usage at build time. They must be
# present or the generated CSS omits every utility class (broken layout, giant
# SVG icons). Paths mirror the @source directive in static/src/main.css.
COPY vergabe_teilnahme/templates ./vergabe_teilnahme/templates
RUN npm run build
# Output: /build/static/dist/main.css
# ─── Stage 2 ─── Python deps via uv ─────────────────────────────────────────
FROM python:3.12-slim-bookworm AS python-deps
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
UV_LINK_MODE=copy \
UV_PYTHON_DOWNLOADS=never
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential libpq-dev curl ca-certificates \
&& rm -rf /var/lib/apt/lists/*
COPY --from=ghcr.io/astral-sh/uv:0.5.11 /uv /usr/local/bin/uv
WORKDIR /app
COPY pyproject.toml uv.lock ./
RUN --mount=type=secret,id=gitea_pypi_user,required=false \
--mount=type=secret,id=gitea_pypi_password,required=false \
sh -eu -c '\
if [ -f /run/secrets/gitea_pypi_user ]; then \
export UV_INDEX_GITEA_USERNAME="$(cat /run/secrets/gitea_pypi_user)"; \
fi; \
if [ -f /run/secrets/gitea_pypi_password ]; then \
export UV_INDEX_GITEA_PASSWORD="$(cat /run/secrets/gitea_pypi_password)"; \
fi; \
uv sync --no-dev --no-install-project \
'
# ─── Stage 3 ─── Runtime image ──────────────────────────────────────────────
FROM python:3.12-slim-bookworm AS runtime
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
DJANGO_SETTINGS_MODULE=vergabe_teilnahme.settings.prod \
PATH=/app/.venv/bin:$PATH \
PORT=8000
RUN apt-get update && apt-get install -y --no-install-recommends \
libpq5 curl ca-certificates \
&& rm -rf /var/lib/apt/lists/* \
&& groupadd -r app && useradd -r -g app -d /app -s /sbin/nologin app
WORKDIR /app
COPY --from=python-deps /app/.venv ./.venv
COPY --chown=app:app manage.py pyproject.toml ./
COPY --chown=app:app vergabe_teilnahme ./vergabe_teilnahme
COPY --chown=app:app static/src ./static/src
COPY --chown=app:app static/vendor ./static/vendor
COPY --chown=app:app templates ./templates
COPY --from=assets --chown=app:app /build/static/dist ./static/dist
RUN mkdir -p ./media ./staticfiles ./.issue-facade && chown -R app:app /app
# collectstatic needs SECRET_KEY + ALLOWED_HOSTS but no DB; provide placeholders.
RUN SECRET_KEY=build-only ALLOWED_HOSTS=localhost \
python manage.py collectstatic --noinput
USER app
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD curl -fsS http://127.0.0.1:8000/health/ || exit 1
CMD ["gunicorn", "vergabe_teilnahme.wsgi:application", \
"--bind", "0.0.0.0:8000", \
"--workers", "3", \
"--access-logfile", "-", \
"--error-logfile", "-"]

View File

@@ -1,4 +1,4 @@
.PHONY: help db dev css seed migrate test lint shell superuser collectstatic
.PHONY: help db dev css seed migrate test lint shell superuser collectstatic sync-whynot-design
.DEFAULT_GOAL := help
@@ -41,3 +41,6 @@ shell: ## Open a Django shell (shell_plus if available)
collectstatic: ## Collect static files into staticfiles/ (production step)
uv run manage.py collectstatic --noinput
sync-whynot-design: ## Re-vendor whynot-design CSS+tokens from the pinned ref
./scripts/sync-whynot-design.sh

154
SCOPE.md Normal file
View File

@@ -0,0 +1,154 @@
# SCOPE
> This file helps you quickly understand what this repository is about,
> when it is relevant, and when it is not.
> It is intentionally lightweight and may be incomplete.
---
## One-liner
Web application that structures a company's participation in tenders
(Ausschreibungen) end-to-end, from research through submission to
post-award retrospective.
---
## Core Idea
**Vergabe Teilnahme** ("tender participation") guides a team through the
full lifecycle of bidding on a public or private tender: collecting tender
documents, analyzing requirements, deciding whether to participate, tracking
open items, finalizing pricing and documents, submitting on time, and
capturing reusable knowledge afterwards — win or lose. It is phase-guided,
never phase-locked: every element of a tender is reachable at any time, and
there is no forced completeness outside real submission-deadline
constraints. All data entry is manual in this first build stage — no
automated ingestion from tender platforms.
It operates on three levels, per `wiki/ProductRequirementsDocument.md`:
| Level | Content |
|---|---|
| Operational | deadlines, documents, tasks, lots (Lose), requirements, bidder questions, submission |
| Management | participate/no-bid decision, approvals, pricing level, subcontractor selection, outcome, retrospective |
| Strategic | reusable evidence/references, market price observation, loss reasons, win factors, competitor (Marktbegleiter) analysis |
---
## In Scope
- Tender (Ausschreibung) and lot (Los) tracking through 8 navigable phases
- Requirements analysis and bidder-question (Bieterfragen) handling
- Task management (Aufgaben) scoped to a tender or a lot
- Document management (Dokumente) for tender materials and submission artifacts
- Pricing (Preise) tracking per lot/tender
- Submission and post-award retrospective (Abgabe/Nachbetrachtung)
- Partner and reference library (Partner/Bibliothek) for reusable evidence
- Competitor/market observation (Marktbegleiter)
- Feedback/bug capture within the app (Feedback)
- Issue-tracking integration via `issue-core` (task facade, not a UI replacement)
## Out of Scope
- External user accounts for partners/subcontractors/service providers (data
objects only in this build stage, no system access of their own)
- 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
process state, it is not a legal compliance authority
---
## Relevant When
- Deciding whether to bid on a tender and needing structured deadline,
document, and requirement tracking across a team
- Needing a system of record for pricing decisions, submission evidence,
and post-award retrospectives on public/private tenders
- Wanting to reuse prior tender evidence, references, or competitor/pricing
observations when evaluating a new tender
## Not Relevant When
- Needing external bidder/partner portal access (not built yet)
- Needing automated tender discovery or platform integration (out of scope
for this build stage)
- Needing generic project management unrelated to the tender-participation
domain
---
## Current State
- Status: active
- Implementation: substantial — 12 Django apps covering the full phase
model (accounts, aufgaben, ausschreibungen, bibliothek, core, dokumente,
feedback, lose, marktbegleiter, nachbetrachtung, partner, preise); 17
workplans (WP-0001WP-0017) implemented in sequence from project
scaffold through whynot-design token adoption
- Stability: evolving — manual-entry-first v1; no CI workflow configured yet
(`.gitea/`/`.forgejo/` absent)
- Usage: internal collaboration tool, not yet published for external use
---
## How It Fits
- Upstream dependencies: `issue-core` (task-tracking facade, see
`vergabe_teilnahme/apps/aufgaben/issue_facade.py`), `whynot-design`
(visual language, vendored CSS/tokens)
- Downstream consumers: none known
- Often used with: `railiance-apps` (deployment target per its own SCOPE.md,
which names `vergabe-teilnahme` as a user-facing service it deploys)
---
## Terminology
- Preferred terms: Ausschreibung (tender), Los (lot), Aufgabe (task),
Marktbegleiter (competitor/market companion), Nachbetrachtung (post-award
retrospective)
- Also known as: "Vergabe Teilnahme" (product name), "tender participation
management system" (English gloss)
- Potentially confusing terms: "Partner" here means reference/subcontractor
data objects, not system users
---
## Related / Overlapping Repositories
- `issue-core` — task-tracking backend consumed via `issue_facade.py`
- `whynot-design` — visual language vendored into `static/src/vendor/`
- `railiance-apps` — deployment/workload layer for this service
---
## Getting Oriented
- Start with: `wiki/ProductRequirementsDocument.md` (product intent, in
German), `wiki/ArchitectureBlueprint.md` (technology stack and design
principles)
- Key files / directories: `vergabe_teilnahme/apps/` (12 domain apps),
`vergabe_teilnahme/urls.py` (route map), `workplans/` (WP-00010017
build history)
- Entry points: `manage.py runserver`; `vergabe_teilnahme/urls.py` maps
`/ausschreibungen/`, `/lose/`, `/aufgaben/`, `/dokumente/`, and more
---
## Provided Capabilities
Registered in `registry/capabilities/capability.procurement.vergabe-teilnahme.md`
(reuse-surface federation; vector **D3/A1/C1/R1** after SCOPE.md fill-in and
REUSE-WP-0017-T05 entry-2 review, 2026-07-07).
---
## Notes
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.

View File

@@ -0,0 +1,361 @@
---
date: 2026-05-23
topic: whynot-design adoption — cross-framework analysis
status: research / decision pending
author: claude (opus-4.7)
related:
- ~/whynot-design (the React DS in question)
- ~/whynot-control (the org control surface — explicitly out of scope)
- vergabe-teilnahme (the consuming Django app)
follow-ups:
- workplan: VERGABE_TEILNAHME-WP-0017 (whynot-design adoption) — not yet drafted, depends on strategy decision below
- possible new repo: whynot-design-django — see §5
---
# whynot-design — cross-framework adoption analysis
> Persisted from a research conversation on 2026-05-23. The conversation was triggered
> by the question "should vergabe-teilnahme adopt the whynot design system, and how?"
> The strategic question opened up because whynot-design ships React components
> while vergabe-teilnahme is Django + HTMX + Tailwind.
## 1. The headline insight
There is **no community** around "React → Django component porting." That pattern
doesn't exist as a tool, library, or codified practice. The reason: cross-framework
UI sharing has, since ~2020, converged on **Web Components** as the standard answer.
Every major design system that supports multiple frameworks (Shoelace / Web Awesome,
IBM Carbon, Adobe Spectrum, Salesforce Lightning, Material Web) either ships Web
Components or maintains parallel hand-rolled implementations per framework. There is
no third option that the industry has validated.
The W3C Design Tokens Community Group spec reached its **first stable version in
October 2025**, with Style Dictionary, Figma, Penpot, Tokens Studio, and others as
reference implementations. Tokens as a cross-framework contract are now genuinely
portable; *components* still aren't, except via web components.
The strategic question therefore reshapes itself: **do we accept the
parallel-implementations cost, or do we change what `whynot-design` actually ships?**
## 2. Research findings — existing approaches and their communities
| Approach | What it is | Community / activity | Fit for whynot |
|---|---|---|---|
| **Web Components** (Lit, Stencil) | Browser-standard custom elements; works in any framework | Very active; React 19 finally scores perfect on custom-elements-everywhere.com | **High** — best general answer if we're willing to refactor whynot-design |
| **Shoelace / Web Awesome** | Pre-built Web Components UI kit (Shoelace's successor) | Large, active OSS community | Not direct (competing DS), but proves the model. Has explicit HTMX integration guides |
| **Stencil JS** | Compiler that emits framework-specific bindings from one source | Mature; IBM/Apple use it; declining mindshare vs. Lit | **Medium** — heavier than Lit but generates per-framework wrappers automatically |
| **Style Dictionary + DTCG tokens** | One token file → CSS / Tailwind / iOS / Android / Flutter | W3C-backed, stable spec since 2025-10 | **High** — should be our token pipeline regardless of component choice |
| **Carbon / Spectrum / Lightning / Material pattern** | Single design source, hand-maintained parallel packages per framework | Active but they're 100+ engineer teams | **Low** at our scale — the "expensive but principled" reference path |
| **Single-spa / micro-frontends** | Multiple frameworks coexisting in one app | Niche, mostly enterprise integration | **Off-topic** — not about sharing components, about hosting heterogeneous apps |
| **django-cotton / django-components / django-bird** | Django-template-native component libraries; HTMX-friendly | Active, growing in 20242026; mature enough to depend on | **High** as the *Django-side runtime* — but doesn't solve cross-framework, just gives Django a real component model |
| **"Port React to Django" libraries** | (doesn't exist) | None | None |
**Read of the field:** the world has settled on "tokens are cross-framework;
components are framework-native." Most teams either pick web components (one
implementation, runs anywhere with some friction) or accept parallel implementations
from a shared design language.
## 3. Three viable strategies for whynot's situation
**Strategy A — Pivot whynot-design to Web Components.**
Rewrite `Atoms.jsx` + `Chrome.jsx` as Lit components. Ship one runtime artefact
(`@whynot/design`) that Django + HTMX templates can drop into a page via plain
`<whynot-button>` tags. No React dependency anywhere. Tailwind / CSS variables
continue to drive theming.
**Strategy B — Keep whynot-design React-canonical; add a parallel `whynot-design-django` repo.**
Treat the React JSX as the *visual + API specification*. Hand-port to Django
partials in a separate, reusable repo so vergabe-teilnahme and future Django
consumers share one implementation. Tokens stay in whynot-design (single source).
**Strategy C — Tokens only; components are framework-local forever.**
Don't promise component parity. Vergabe re-implements whynot's *look* using the
tokens but its components live and die in vergabe-teilnahme. Other Django apps
would do the same fork. Bernd implied this is unsatisfying — he wants a real
component library for cross-project consistency.
The real choice is **A vs. B**.
## 4. Pros / cons — A (Web Components) vs. B (parallel Django repo)
### Strategy A — Pivot to Web Components (Lit)
**Pros**
- *One* implementation. Zero divergence risk. Bug fixes ship once.
- Standards-backed; outlives any framework choice. Works in vanilla HTML, Django
templates, future Vue/Svelte/Solid apps, marketing sites, claude.ai artifacts.
- HTMX swaps work cleanly — web components are just DOM; HTMX doesn't care.
- The community is *here*. Tutorials, debugging tools, Storybook integration,
accessibility testing infrastructure all exist.
- The Claude atelier in claude.ai can still output JSX prototypes; Lit ports are
mechanical once the design is decided.
**Cons**
- One-time rewrite cost of the existing 11 React components. Real, but small
(~267 lines of JSX, not a library of 200 components).
- Web components have Shadow DOM trade-offs: form-association, deep style scoping,
and slotting have learning curves. Workable but real friction.
- React-shaped APIs (`<Button variant="primary">`) translate to attributes /
properties (`<wn-button variant="primary">`). Slightly less ergonomic in React,
equally ergonomic everywhere else.
- Lose the claude.ai → npm symmetry: claude.ai design tool outputs JSX; you'd
convert each new component.
- Whoever maintains whynot-design has to learn Lit (small surface area, but new
vocabulary).
### Strategy B — Parallel `whynot-design-django` repo
**Pros**
- Each implementation is idiomatic for its stack. Django templates feel like
Django; React feels like React.
- No new technology to learn. Both repos use mature, well-understood patterns.
- Django consumers get to use `django-cotton` or `django-bird` — real ergonomics,
not a half-port.
- claude.ai → JSX → Lit conversion step is avoided. The atelier output is
*directly* the React reference.
**Cons**
- *Two* implementations forever. Every component change needs both sides updated;
PRs need cross-repo review discipline.
- API drift is inevitable. By v0.5 the React `<Button>` and Django `{% button %}`
will have diverged in props/slots/behavior unless enforced with conformance tests.
- A *third* consumer stack (Vue, vanilla HTML, native mobile) means a *third* repo.
Cost scales linearly with the number of stacks; A's cost is roughly constant.
- The "community" of "people maintaining a React→Django twin DS" is exactly Bernd
and whoever he recruits. No prior art to lean on.
- Visual regression testing has to run twice (Playwright against examples in both
repos) to ensure parity.
### Recommendation
**Strategy A is the right long-term answer**; B is the right *interim* answer if
we don't want to halt vergabe-teilnahme to do a Lit rewrite first.
A real third path: **A scheduled, B today.** Build B as a reusable Django port now
to unblock vergabe-teilnahme; treat it as the *bridge* until whynot-design pivots
to Lit at, say, v0.5. The Django repo becomes obsolete when A lands — that's a
feature, not a bug; it forces a clear retirement decision instead of accumulating
tech debt.
If we don't want to commit to a future Lit pivot, then B is the right choice on its
own — just accept the linear-cost-per-stack reality.
## 5. Naming, establishing, workflow — if we go with B
### Naming proposals
| Candidate | Pros | Cons |
|---|---|---|
| **`whynot-design-django`** (recommended) | Mirrors `@whynot/design`. Unambiguous about stack. Discoverable. | Slightly long. |
| `whynot-django` | Short. | Ambiguous — sounds like a Django app, not a DS port. |
| `whynot-design-py` | Hedges for Jinja2/Flask later. | Vague — Python doesn't pick a template engine. |
| `whynot-dj` | Compact. | Cryptic. |
| `whynot-templates` | Honest about what it is. | Doesn't carry the "design" meaning. |
**Recommend `whynot-design-django`.** If a Jinja port appears, it gets its own repo
(`whynot-design-jinja`), not a confusing rename.
### Establishing the repo (concrete steps)
1. **Gitea org placement:** `gitea-remote:whynot/whynot-design-django.git` — same
org as `whynot-design`, signaling sibling status.
2. **Package shape:** a pip-installable Django app (`pyproject.toml`, importable as
`whynot_design`). Two install paths:
- `pip install git+ssh://...@v0.1.0` (matches whynot-design's tag-pinned discipline).
- For dev: editable install (`pip install -e ../whynot-design-django`).
3. **Layout** (matches Django conventions; mirrors whynot-design's structure):
```
whynot-design-django/
├── README.md ← restates the "match the React spec" contract
├── CONTRIBUTING.md ← parity rules: any change here must follow a whynot-design change
├── CHANGELOG.md
├── pyproject.toml
├── whynot_design/
│ ├── __init__.py
│ ├── apps.py
│ ├── templates/whynot_design/
│ │ ├── atoms/eyebrow.html, tag.html, button.html, stage_dot.html, stamp.html, icon.html
│ │ ├── chrome/top_nav.html, sidebar.html, page_header.html, pipeline_strip.html
│ │ └── _base.html ← imports the CSS once
│ ├── templatetags/
│ │ └── whynot.py ← {% wn_button variant="primary" %}…{% endwn_button %}, etc.
│ └── static/whynot_design/
│ └── colors_and_type.css ← vendored from @whynot/design (synced)
├── tests/
│ ├── test_components.py ← Django test client, asserts rendered markup matches expected shape
│ └── parity/ ← Playwright comparison of Django render vs. React render
└── examples/ ← a Django demo project rendering every component
```
4. **Token sync:** a `scripts/sync-from-whynot-design.py` that copies
`colors_and_type.css` and `tokens/*.json` from a pinned whynot-design ref into
the static dir + generates a Django settings constant for tokens. Run on every
whynot-design version bump.
5. **Underlying component mechanism:** plain `{% include %}` partials (simplest, no
new dep), `django-cotton` (HTML-like syntax, very ergonomic, active community),
or `django-components` (most powerful, heaviest). My lean: **`django-cotton`** —
its `<c-button variant="primary">…</c-button>` syntax parallels React JSX best,
so the parity contract is more obvious.
### What changes in the workflow
Before:
```
claude.ai atelier ──► whynot-design ──► consumer
```
After:
```
claude.ai atelier ──► whynot-design ──┬─► whynot-design-django ──► vergabe-teilnahme (+ future Django apps)
├─► future stacks: whynot-design-jinja / -svelte / -vue / …
└─► tokens flow into all child repos via a sync script
```
Concretely, this adds three new ongoing obligations:
1. **Token sync gate.** When whynot-design ships a token change, the Django repo's
sync script must run before its next release. CI check: the static CSS in
`whynot-design-django` must hash-match the CSS at the pinned upstream tag.
2. **Component parity gate.** When whynot-design adds or modifies a component, an
issue is opened automatically in `whynot-design-django` referencing the upstream
PR. A release is blocked until the issue closes. Encode in `CONTRIBUTING.md`
first; later, a CI cross-repo check.
3. **Conformance tests.** Each component's Django partial has a Playwright snapshot
that's compared against the same component rendered in whynot-design's
`examples/whynot-control/`. Drift = failing build.
Vergabe-teilnahme's workplan therefore depends on **whynot-design-django** existing
first. The vergabe workplan becomes: install the new package, replace partials with
cotton/include tags, retheme.
## 6. Component-level inventory — whynot-design vs. vergabe-teilnahme
| whynot-design (React) | vergabe-teilnahme (Django) | Match | Gap action |
|---|---|---|---|
| `Eyebrow` (mono uppercase label, fg-3) | — | Missing | New partial `atoms/eyebrow.html` |
| `Tag` (mono uppercase pill: default / active / draft) | `status_badge.html` (different semantics — domain statuses) | Partial | Replace `status_badge` content with `Tag` variants; keep status→variant mapping |
| `Button` (3 variants: primary / secondary / ghost; lucide icon support) | CSS classes `.btn-primary`, `.btn-secondary`, `.btn-danger`, `.btn-ghost` | Strong overlap; `btn-danger` not upstream | Adopt 3 base variants; propose `danger` upstream OR retire it (whynot's voice avoids red/destructive emphasis) |
| `StageDot` (S0S4 signal levels with grey-ramp + yellow S4) | `phase_nav.html` (numbered phase circles: todo/active/done/warn) | Semantically different — *whynot stages* ≠ *vergabe phases* | Keep both as distinct atoms. Propose `PhaseDot` upstream OR keep vergabe-local |
| `Stamp` (yellow rotated "DRAFT") | — | Missing | New partial; useful for unfreigegeben items |
| `Icon` (lucide via `data-lucide`) | inline SVGs / class-based | Missing as a component | New partial that wraps lucide; pull lucide JS into base.html |
| `TopNav` | `topbar.html` | Both exist; styling and density differ significantly | Reskin vergabe's topbar to whynot's structure (search box + primary action right-aligned) |
| `Sidebar` | `sidebar.html` | Both exist; nav model differs (vergabe has phase grouping; whynot has Work / Control docs grouping) | Adopt whynot's chrome (item style, eyebrow section headers) but keep vergabe's nav items |
| `PageHeader` (eyebrow + h1 + lede + actions) | — (inline per template) | Missing | New partial; refactor all page templates to use it |
| `PipelineStrip` (5-stage horizontal indicator) | `phase_nav.html` (vertical / different) | Semantically related, visually different | Decide: align on whynot's strip OR propose `PipelineStrip` variant upstream |
| — | `field_row.html` (vergabe-specific) | n/a | Propose upstream — it's a generic key/value display row |
| — | `breadcrumb.html` | n/a | Propose upstream as `Breadcrumb` |
| — | `feedback_button.html`, `feedback_modal.html`, `feedback_success.html` | n/a (vergabe-specific UX flow) | Keep vergabe-local; product UI, not DS atoms |
| — | `freigabe_modal.html`, `freigabe_success.html` | n/a (vergabe-specific) | Keep vergabe-local |
| — | `search_results.html` (HTMX results target) | n/a | Probably vergabe-local, but a `Listbox`/`Combobox` atom could live upstream — defer |
### Gap summary
- **5 net-new atoms** in vergabe (Eyebrow, Stamp, PageHeader, Icon wrapper, PipelineStrip).
- **4 atoms to align** (Tag ↔ status_badge, Button variants, TopNav, Sidebar).
- **2 atoms to propose upstream** (Breadcrumb, FieldRow).
- **1 semantic mismatch to resolve** (Stage vs Phase — they're different concepts).
- **All vergabe-specific flow UI** (feedback, freigabe, search_results) stays
vergabe-local — that's correct, those are product UI, not design system.
### Components missing from whynot-design — full gap list
These are components that are absent from `whynot-design` today and would need to
land there (or in `whynot-design-django`) before vergabe-teilnahme can fully adopt
the system. Grouped by origin.
#### Already exists in vergabe-teilnahme — candidates to promote upstream
1. **`Breadcrumb`** — vergabe's `breadcrumb.html`. Trivially generic; no
vergabe-specific semantics.
2. **`FieldRow`** — vergabe's `field_row.html` (label + value, 3-column grid). The
pattern recurs across detail pages; clearly a DS atom.
3. **`PhaseDot` / `PhaseNav`** *(name TBD)* — vergabe's `phase_nav.html`.
Semantically distinct from whynot's `StageDot` (numbered phases with
todo / active / done / warn states, not S0S4 signal levels). Either upstream
as a sibling component, or kept vergabe-local. **Open decision.**
#### Doesn't exist anywhere yet, but the DS clearly needs them
These are components that vergabe-teilnahme has as raw Tailwind classes or ad-hoc
markup, and whynot-design has no equivalent — but any non-trivial app needs them,
so they're real DS gaps:
4. **`Card`** — whynot's house rules reference cards ("no shadows on cards,"
"04px radii for cards/sheets") but there is no `Card` JSX component. Vergabe
has `.card` as a Tailwind class. **Surprising omission given the explicit
design rules.**
5. **`Input` / `Textarea` / `Select`** — whynot ships no form primitives at all.
Vergabe has `.form-input` and `.form-label` CSS classes. Required before any
form-based whynot artefact ships.
6. **`Modal` / `Dialog`** — whynot ships none. Vergabe has two (`freigabe_modal`,
`feedback_modal`). Whynot's house rules even prescribe modal radius (8px),
confirming the intent — just the component is missing.
7. **`Table`** — whynot ships none. Vergabe has `.table-base`, `.table-header`,
`.table-row` classes. Any data-heavy view (likely most of vergabe) needs it.
8. **`Toast` / inline success banner** *(name TBD)* — whynot ships none. Vergabe
has `freigabe_success.html` and `feedback_success.html` as ad-hoc partials.
Even with whynot's quiet voice, success / error states need a defined visual.
#### Worth flagging but defer until first real need
9. **`SearchInput`** — currently inlined inside whynot's `TopNav`. Pulling it out
as a standalone atom would be useful (vergabe's `search_results.html` is a
target for an HTMX-driven search box).
10. **`EmptyState`** — neither codebase has one; vergabe will need it for empty
list views. Worth designing once before either side fills the gap locally.
11. **`Tabs`, `Dropdown`, `Tooltip`, `Pagination`, `Avatar`** — common DS atoms;
neither side has them. Don't add speculatively; add when the first concrete
use case appears.
**Count:** 3 vergabe-existing to promote, 5 genuine DS-level gaps, 5 deferred.
The 5 "genuine DS-level gaps" — especially `Card`, `Input`, `Modal`, `Table` — are
**blockers for adoption**: vergabe can't replace its current chrome without them
existing somewhere shared.
### Stylistic gaps to address before "done"
- Vergabe currently uses Tailwind utility classes pervasively
(`.btn-primary { @apply … }`); whynot uses inline-style CSS-variable assignment.
The Django port should commit to one — recommend CSS variable + utility classes
(re-derive whynot's component styles as Tailwind `@apply` blocks that read from
CSS vars), so HTMX-injected fragments don't need their own style scaffolding.
- Lucide icons aren't wired into vergabe. Need a base.html script tag + an icon partial.
- Font stack mismatch: vergabe is `ui-sans-serif`; whynot is IBM Plex Sans / Mono /
Serif. Importing whynot's CSS handles this — but it pulls Google Fonts at runtime.
Decide self-host vs. CDN.
- Vergabe's brand blue (`#3b5bdb`) has to go entirely. No mid-state — the whynot
aesthetic forbids it.
## 7. Recommendation and open decision
**Recommendation:** Strategy B (parallel `whynot-design-django` repo) as the
immediate move, with the README explicitly framing it as a **bridge** that becomes
obsolete if whynot-design eventually pivots to Lit web components. Concretely this
means **three workplans**, not one:
1. **`whynot-design`**: small additions — tag v0.1.0, add upstream candidates
(`Breadcrumb`, `FieldRow`), document the parity contract for downstream Django port.
2. **`whynot-design-django`** (new repo): bootstrap, ship v0.1.0 with the 11
components + 2 vergabe-contributed atoms, set up token-sync + parity tests.
3. **`vergabe-teilnahme`** (`WP-0017`): consume `whynot-design-django`, retheme,
pilot on one page first, then full sweep.
**Open decision (blocks workplan drafting):**
Strategy A (pivot to Lit immediately) vs. Strategy B (parallel Django repo) vs.
"B now, A later" — Bernd to choose. If "B now, A later," add a workplan stub for
the Lit pivot in `whynot-design` itself so it doesn't get forgotten. If "A from the
start," the Django repo doesn't exist and the workplans collapse to two.
## Sources
- [Shoelace / Web Awesome](https://shoelace.style/) — framework-agnostic web components DS
- [Lit + Web Components for cross-framework UIs](https://thenewstack.io/how-to-build-framework-agnostic-uis-with-web-components/)
- [HTMX + Shoelace example](https://binaryigor.com/htmx-with-shoelace-framework-agnostic-components-in-an-example-app.html)
- [W3C Design Tokens spec first stable version (Oct 2025)](https://www.w3.org/community/design-tokens/2025/10/28/design-tokens-specification-reaches-first-stable-version/)
- [Style Dictionary cross-platform tokens](https://styledictionary.com/info/tokens/)
- [Django Cotton — HTML-like component syntax](https://django-cotton.com/)
- [django-bird](https://github.com/joshuadavidthomas/django-bird)
- [Salesforce — Beyond Components: multi-framework DS](https://medium.com/salesforce-ux/beyond-components-a-design-system-to-support-multiple-frameworks-cb1e4d511f66)
- [AgnosticUI post-mortem (rewrite to Lit)](https://frontendmasters.com/blog/post-mortem-rewriting-agnosticui-with-lit-web-components/)

View File

@@ -10,8 +10,18 @@ dependencies = [
"whitenoise>=6.7",
"python-decouple>=3.8",
"dj-database-url>=2.1",
"issue-core>=0.2,<0.3",
"gunicorn>=22.0",
]
[tool.uv.sources]
issue-core = { index = "gitea" }
[[tool.uv.index]]
name = "gitea"
url = "https://gitea.coulomb.social/api/packages/coulomb/pypi/simple/"
explicit = true
[dependency-groups]
dev = [
"pytest-django>=4.8",

12
registry/README.md Normal file
View File

@@ -0,0 +1,12 @@
# Capability Registry
Markdown-first capability index for federation and reuse planning.
## Authoring
1. Copy a capability entry template (see reuse-surface `templates/capability-entry.template.md`).
2. Add the row to `indexes/capabilities.yaml`.
3. Run `reuse-surface validate` from a checkout with the CLI installed.
4. Merge to `main` and verify publish with `reuse-surface establish --publish-check`.
Federation contract: reuse-surface `docs/RegistryFederation.md`.

View File

View File

@@ -0,0 +1,147 @@
---
id: capability.procurement.vergabe-teilnahme
name: Vergabe Teilnahme (Public Procurement Participation) Application
summary: Django application (with a Vite/Tailwind frontend) for managing German public-procurement (Vergabe)
tender participation — Ausschreibungs- und Teilnahme-Management-System.
owner: vergabe-teilnahme
status: draft
domain: communication
tags:
- procurement
- django
- vergabe
maturity:
discovery:
current: D3
target: D4
confidence: medium
rationale: SCOPE.md filled 2026-07-07 with in/out scope, current state, terminology, and orientation
pointers; supported by wiki/ProductRequirementsDocument.md (German product definition) and
wiki/ArchitectureBlueprint.md. README.md remains a stale repo-seed leftover — SCOPE and wiki
are authoritative for discovery.
availability:
current: A1
target: A3
confidence: medium
rationale: Substantial Django application (12 domain apps, WP-0001WP-0017 implemented) with
Vite/Tailwind frontend and docker-compose dev/test; SCOPE.md describes internal collaboration
use only — no hosted production deployment confirmed in this review.
external_evidence:
completeness:
level: C1
confidence: medium
basis: scope_vs_intent_and_consumer_expectations
satisfied_expectations:
- in-scope/out-of-scope boundaries documented in SCOPE.md
- 8-phase tender participation model described with operational/management/strategic levels
- manual-entry-first v1 constraint and external-portal exclusion explicit
broken_expectations: []
out_of_scope_expectations: []
reliability:
level: R1
confidence: low
basis: consumer_quality_signals
known_reliability_risks:
- no CI workflow configured yet (per SCOPE.md current state)
- no production consumer telemetry (REUSE-WP-0019)
- README.md still stale repo-seed text — fix separately
discovery:
intent: Guide a team through the full lifecycle of bidding on public or private tenders (Ausschreibungen)
— from research and participate/no-bid decisions through submission and post-award retrospective.
includes:
- tender (Ausschreibung) and lot (Los) tracking across 8 navigable phases
- requirements analysis and bidder-question (Bieterfragen) handling
- tasks (Aufgaben), documents (Dokumente), pricing (Preise), submission/retrospective (Abgabe/Nachbetrachtung)
- partner/reference library (Bibliothek) and competitor observation (Marktbegleiter)
- issue-core task facade integration (not a UI replacement)
excludes:
- external partner/subcontractor portal accounts (data objects only in v1)
- multi-tenancy
- automated ingestion from tender platforms, SharePoint, Teams, CRM, ERP, email, or calendars
assumptions: []
use_cases: []
research_memos: []
availability:
current_level: A1
target_level: A3
current_artifacts:
- Django application (`vergabe_teilnahme`, 12 apps)
- Vite/Tailwind frontend (`static/`)
- docker-compose.dev.yml and docker-compose.test.yml
target_artifacts: []
consumption_modes:
- application (Django + Vite, local dev via docker-compose)
relations:
depends_on:
- capability.infotech.issue-tracking
supports: []
related_to:
- capability.design.whynot-system
evidence:
documentation:
- SCOPE.md
- wiki/ProductRequirementsDocument.md
- wiki/ArchitectureBlueprint.md
- pyproject.toml
tests:
- vergabe_teilnahme/apps/*/tests.py (pytest-django suite across domain apps)
- docker-compose.test.yml
consumer_feedback: []
bug_reports: []
incidents: []
consumer_guidance:
recommended_for:
- teams needing structured German tender-participation workflow tracking (manual-entry-first v1)
- extending or deploying this specific Django application
not_recommended_for:
- automated tender-platform ingestion (out of scope for v1)
- external bidder/partner self-service portals (not built)
known_limitations:
- manual data entry only in v1 — no platform integration
- README.md is still repo-seed boilerplate; use SCOPE.md and wiki/ for orientation
promotion_history:
- date: "2026-07-07"
dimension: discovery
from: D1
to: D3
rationale: SCOPE.md filled with full in/out scope and current-state documentation (REUSE-WP-0017-T05 entry-2 review).
author: grok
- date: "2026-07-07"
dimension: completeness
from: C0
to: C1
rationale: Consumer scope expectations now documented in SCOPE.md and wiki PRD.
author: grok
---
# Vergabe Teilnahme (Public Procurement Participation) Application
## Overview
`vergabe-teilnahme` is a Django + Vite/Tailwind application for managing German public-procurement (Vergabe) tender participation end-to-end. SCOPE.md (filled 2026-07-07) documents the 8-phase, manual-entry-first v1 model across 12 Django apps.
## Assessment notes
### Discovery
SCOPE.md now provides authoritative in/out scope, current state, terminology, and pointers to the German-language wiki PRD and architecture blueprint. Discovery promoted from D1 to D3; README cleanup remains a separate hygiene item.
### Availability
Substantial implemented application (WP-0001WP-0017) with local docker-compose paths. Held at A1 until a hosted deployment path is confirmed (railiance-apps is named as deployment target in SCOPE.md).
### Completeness
Scope-vs-intent expectations are now documented (C1). Feature completeness against the full PRD is not independently verified in this review.
### Reliability
Pytest-django suite exists across domain apps (structural R1). No CI workflow or production telemetry yet.
## Promotion checklist
- [x] ID follows `capability.<domain>.<name>` pattern
- [x] Maturity enums match `specs/CapabilityMaturityStandard.md`
- [x] `external_evidence` is populated separately from `maturity`
- [x] Relations reference valid capability IDs
- [x] Index entry added in `registry/indexes/capabilities.yaml`

View File

@@ -0,0 +1,19 @@
version: 1
updated: '2026-07-07'
domain: helix_forge
capabilities:
- id: capability.procurement.vergabe-teilnahme
name: Vergabe Teilnahme (Public Procurement Participation) Application
summary: Django application (with a Vite/Tailwind frontend) for managing German public-procurement (Vergabe)
tender participation — Ausschreibungs- und Teilnahme-Management-System.
vector: D3 / A1 / C1 / R1
domain: communication
status: draft
owner: vergabe-teilnahme
path: registry/capabilities/capability.procurement.vergabe-teilnahme.md
tags:
- procurement
- django
- vergabe
consumption_modes:
- application (Django + Vite, local dev via docker-compose)

39
scripts/sync-whynot-design.sh Executable file
View File

@@ -0,0 +1,39 @@
#!/usr/bin/env bash
# Synchronises the vendored copy of the whynot-design system from a pinned
# upstream commit. Source: ~/whynot-design (worktree) or a clone from gitea.
#
# Usage: ./scripts/sync-whynot-design.sh [<commit-or-ref>]
# Default: reads .whynot-design-ref from the vendor directory.
#
# See workplans/WP-0017-whynot-design-tokens.md for the adoption strategy.
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
VENDOR_DIR="$ROOT/static/src/vendor/whynot-design"
REF_FILE="$VENDOR_DIR/.whynot-design-ref"
SRC_REPO="${WHYNOT_DESIGN_SRC:-$HOME/whynot-design}"
REF="${1:-}"
if [[ -z "$REF" && -f "$REF_FILE" ]]; then
REF="$(cat "$REF_FILE")"
fi
if [[ -z "$REF" ]]; then
echo "Usage: $0 <commit-or-ref> (or write a ref to $REF_FILE)" >&2
exit 2
fi
if [[ ! -d "$SRC_REPO/.git" ]]; then
echo "Source not found: $SRC_REPO" >&2
echo "Set WHYNOT_DESIGN_SRC or clone gitea:whynot/whynot-design there." >&2
exit 1
fi
mkdir -p "$VENDOR_DIR/tokens"
git -C "$SRC_REPO" show "$REF:src/styles/colors_and_type.css" \
> "$VENDOR_DIR/colors_and_type.css"
for f in colors.json type.json spacing.json index.json; do
git -C "$SRC_REPO" show "$REF:tokens/$f" > "$VENDOR_DIR/tokens/$f"
done
git -C "$SRC_REPO" rev-parse "$REF" > "$REF_FILE"
echo "Vendor synced → $VENDOR_DIR (ref: $(cat "$REF_FILE"))"

View File

@@ -1,43 +1,100 @@
/* whynot-design tokens & semantic element styles (pinned via
scripts/sync-whynot-design.sh; see .whynot-design-ref).
Must precede the Tailwind import so the @import url(...) for IBM Plex
ends up at the top of the generated bundle. */
@import "./vendor/whynot-design/colors_and_type.css";
@import "tailwindcss";
/* Explicit content sources. Without these, Tailwind's automatic detection
depends on the build CWD finding the templates — which fails in the Docker
asset stage (it only copies static/src), producing a CSS with no utility
classes and badly oversized SVG icons. Keep these paths in sync with the
template dirs copied in the Dockerfile `assets` stage. */
@source "../../vergabe_teilnahme/templates";
/* whynot tokens → Tailwind theme. Exposes utilities like bg-paper, text-ink,
border-line, bg-paper-2, text-ink-3, … */
@theme {
--color-brand-50: #f0f4ff;
--color-brand-100: #dce7ff;
--color-brand-500: #3b5bdb;
--color-brand-600: #2f4ac7;
--color-brand-700: #2541b2;
--color-brand-900: #152d99;
--color-ink: #0A0A0A;
--color-ink-2: #1F1F1F;
--color-ink-3: #5C5C5C;
--color-ink-4: #8A8A8A;
--color-ink-5: #B5B5B3;
--color-line: #E5E5E2;
--color-line-strong: #C9C9C5;
--color-line-soft: #F0F0EC;
--color-paper: #FFFFFF;
--color-paper-2: #FAFAF7;
--color-paper-3: #F4F4EF;
--color-hi: #FFE14A;
--color-hi-2: #FFD400;
--color-hi-ink: #1A1500;
/* Backwards-compat aliases for legacy `brand-*` utility usage in templates.
Keeps Phase 1 a tokens-only swap; templates can migrate to ink/paper at
leisure. Map blue-brand scale onto the whynot ink ramp. */
--color-brand-50: #FAFAF7;
--color-brand-100: #F4F4EF;
--color-brand-500: #0A0A0A;
--color-brand-600: #1F1F1F;
--color-brand-700: #0A0A0A;
--color-brand-900: #0A0A0A;
}
/* Off-spec — vergabe-local until whynot-design defines a canonical
destructive color. See history/2026-05-23-whynot-design-cross-framework-analysis.md
§4 for context. */
:root {
--danger: #B22222;
--danger-fg: #FFFFFF;
}
@layer base {
/* German-app base resets */
html {
font-family: ui-sans-serif, system-ui, sans-serif;
font-family: var(--ff-sans, ui-sans-serif), system-ui, sans-serif;
}
}
@layer components {
.card { @apply bg-white rounded-xl border border-slate-200 shadow-sm p-6; }
.btn-primary { @apply bg-brand-500 text-white px-4 py-2 rounded-lg hover:bg-brand-600 transition-colors; }
.btn-secondary { @apply bg-white text-slate-700 border border-slate-300 px-4 py-2 rounded-lg hover:bg-slate-50; }
.btn-danger { @apply bg-red-600 text-white px-4 py-2 rounded-lg hover:bg-red-700; }
.btn-ghost { @apply text-slate-600 px-3 py-2 rounded-lg hover:bg-slate-100; }
.field-row { @apply grid grid-cols-3 gap-4 py-3 border-b border-slate-100 last:border-0; }
.field-label { @apply text-sm font-medium text-slate-500 col-span-1; }
.field-value { @apply text-sm text-slate-900 col-span-2; }
.phase-badge { @apply inline-flex items-center justify-center w-7 h-7 rounded-full text-sm font-bold; }
.phase-todo { @apply inline-flex items-center justify-center w-7 h-7 rounded-full text-sm font-bold bg-slate-200 text-slate-500; }
.phase-active { @apply inline-flex items-center justify-center w-7 h-7 rounded-full text-sm font-bold bg-brand-500 text-white; }
.phase-done { @apply inline-flex items-center justify-center w-7 h-7 rounded-full text-sm font-bold bg-green-500 text-white; }
.phase-warn { @apply inline-flex items-center justify-center w-7 h-7 rounded-full text-sm font-bold bg-amber-400 text-amber-900; }
.section-title { @apply text-base font-semibold text-slate-900 mb-4; }
.page-title { @apply text-2xl font-bold text-slate-900; }
.form-input { @apply w-full rounded-lg border border-slate-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent; }
.form-label { @apply block text-sm font-medium text-slate-700 mb-1; }
.table-base { @apply w-full text-sm text-left; }
.table-header { @apply bg-slate-50 text-slate-500 font-medium text-xs uppercase tracking-wide; }
.table-row { @apply border-t border-slate-100 hover:bg-slate-50 transition-colors; }
.sidebar-link { @apply flex items-center px-3 py-2 rounded-lg text-sm text-slate-700 hover:bg-slate-100 transition-colors; }
.sidebar-link-active { @apply bg-brand-50 text-brand-700 font-medium; }
.sidebar-section-btn { @apply w-full flex items-center justify-between px-3 py-2 text-xs font-semibold text-slate-500 uppercase tracking-wide hover:text-slate-700; }
/* Cards / sheets — whynot: no shadow, hairline border */
.card { @apply bg-paper rounded border border-line p-6; }
/* Buttons — whynot: 3 variants + off-spec danger */
.btn-primary { @apply bg-ink text-paper px-4 py-2 rounded hover:bg-ink-2 transition-colors; }
.btn-secondary { @apply bg-paper text-ink border border-line px-4 py-2 rounded hover:bg-paper-2 transition-colors; }
.btn-ghost { @apply text-ink-3 px-3 py-2 rounded hover:bg-paper-2; }
.btn-danger { background: var(--danger); color: var(--danger-fg); @apply px-4 py-2 rounded transition-colors; }
.btn-danger:hover { filter: brightness(0.92); }
/* Field-row — label/value grid */
.field-row { @apply grid grid-cols-3 gap-4 py-3 border-b border-line-soft last:border-0; }
.field-label { @apply text-sm font-medium text-ink-3 col-span-1; }
.field-value { @apply text-sm text-ink col-span-2; }
/* Phase indicators — vergabe semantics (todo/active/done/warn), translated
into whynot palette. `phase-warn` uses --hi (annotation yellow). */
.phase-badge { @apply inline-flex items-center justify-center w-7 h-7 rounded-full text-sm font-bold; }
.phase-todo { @apply inline-flex items-center justify-center w-7 h-7 rounded-full text-sm font-bold bg-paper-3 text-ink-4; }
.phase-active { @apply inline-flex items-center justify-center w-7 h-7 rounded-full text-sm font-bold bg-ink text-paper; }
.phase-done { @apply inline-flex items-center justify-center w-7 h-7 rounded-full text-sm font-bold bg-ink-3 text-paper; }
.phase-warn { background: var(--hi); color: var(--hi-ink); @apply inline-flex items-center justify-center w-7 h-7 rounded-full text-sm font-bold; }
/* Titles / sections */
.section-title { @apply text-base font-semibold text-ink mb-4; }
.page-title { @apply text-2xl font-medium text-ink tracking-tight; }
/* Forms */
.form-input { @apply w-full rounded border border-line px-3 py-2 text-sm bg-paper focus:outline-none focus:border-ink transition-colors; }
.form-label { @apply block text-sm font-medium text-ink-2 mb-1; }
/* Tables */
.table-base { @apply w-full text-sm text-left; }
.table-header { @apply bg-paper-2 text-ink-3 font-medium text-xs uppercase tracking-wide; }
.table-row { @apply border-t border-line-soft hover:bg-paper-2 transition-colors; }
/* Sidebar */
.sidebar-link { @apply flex items-center px-3 py-2 rounded text-sm text-ink-2 hover:bg-paper-2 transition-colors; }
.sidebar-link-active { @apply bg-paper text-ink font-medium; box-shadow: inset 0 0 0 1px var(--line); }
.sidebar-section-btn { @apply w-full flex items-center justify-between px-3 py-2 text-xs font-semibold text-ink-4 uppercase tracking-wide hover:text-ink-2; }
}

View File

@@ -0,0 +1 @@
9419f166ce395858f55b10a5c72268a1fe9fc9d2

View File

@@ -0,0 +1,273 @@
/* ============================================================
WhyNot Design System — Colors & Type
------------------------------------------------------------
Neutral, mostly black/white. Color is used SPARINGLY — only
one warm accent (annotation yellow) borrowed from the LEGO
brick in the logo. The system favours light grey wireframe
artefacts over heavy fills.
============================================================ */
/* ---------- Webfonts (Google Fonts, see /fonts for offline) ---------- */
@import url("https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@300;400;500;600;700&family=IBM+Plex+Serif:ital,wght@0,400;0,500;1,400&display=swap");
:root {
/* ---------- Base palette: neutrals ---------- */
--ink: #0A0A0A; /* near-black, the only "fill" most of the time */
--ink-2: #1F1F1F;
--ink-3: #5C5C5C;
--ink-4: #8A8A8A;
--ink-5: #B5B5B3; /* placeholder text, wireframe labels */
--line: #E5E5E2; /* default 1px wireframe rule */
--line-strong: #C9C9C5; /* dividers between sections */
--line-soft: #F0F0EC; /* hairline within a card */
--paper: #FFFFFF; /* canvas */
--paper-2: #FAFAF7; /* sheet, dim canvas */
--paper-3: #F4F4EF; /* recessed surface, code block bg */
/* ---------- Foreground / background semantic ---------- */
--fg-1: var(--ink);
--fg-2: var(--ink-3);
--fg-3: var(--ink-4);
--fg-mute: var(--ink-5);
--fg-on-dark: #FAFAF7;
--bg-1: var(--paper);
--bg-2: var(--paper-2);
--bg-3: var(--paper-3);
--bg-invert: var(--ink);
--border: var(--line);
--border-strong: var(--line-strong);
--border-soft: var(--line-soft);
/* ---------- The single accent: annotation yellow ---------- */
/* Lifted from the LEGO brick. Used as highlighter, "draft"
stamp, signal-marker. Never as a button fill. */
--hi: #FFE14A;
--hi-2: #FFD400;
--hi-ink: #1A1500; /* text on yellow */
/* ---------- Status (for prototype lifecycle, signal strength) ---------- */
/* Kept deliberately desaturated so they read as labels, not UI. */
--status-raw: #B5B5B3; /* S0 — no signal */
--status-weak: #8A8A8A; /* S1 — weak signal */
--status-medium: #5C5C5C; /* S2 — medium signal */
--status-strong: #0A0A0A; /* S3 — strong signal */
--status-commercial: #FFD400; /* S4 — commercial */
/* ---------- Type families ---------- */
--ff-sans: "IBM Plex Sans", ui-sans-serif, system-ui, sans-serif;
--ff-mono: "IBM Plex Mono", ui-monospace, "SF Mono", Menlo, monospace;
--ff-serif: "IBM Plex Serif", "Iowan Old Style", Georgia, serif;
/* ---------- Type scale (modular, ~1.2) ---------- */
--fs-xs: 11px;
--fs-sm: 13px;
--fs-base: 15px;
--fs-md: 17px;
--fs-lg: 20px;
--fs-xl: 24px;
--fs-2xl: 32px;
--fs-3xl: 44px;
--fs-4xl: 64px;
--fs-5xl: 96px;
--lh-tight: 1.05;
--lh-snug: 1.25;
--lh-base: 1.5;
--lh-loose: 1.7;
--tr-tight: -0.02em;
--tr-snug: -0.01em;
--tr-base: 0em;
--tr-mono: 0.02em;
--tr-label: 0.08em; /* uppercase eyebrow labels */
/* ---------- Spacing (4px base) ---------- */
--sp-1: 4px;
--sp-2: 8px;
--sp-3: 12px;
--sp-4: 16px;
--sp-5: 24px;
--sp-6: 32px;
--sp-7: 48px;
--sp-8: 64px;
--sp-9: 96px;
--sp-10: 128px;
/* ---------- Radii — small, mostly square ---------- */
--r-0: 0px;
--r-1: 2px;
--r-2: 4px;
--r-3: 8px;
--r-pill: 999px;
/* ---------- Elevation — almost none. This is a wireframe system. ---------- */
--shadow-0: none;
--shadow-1: 0 1px 0 var(--line);
--shadow-2: 0 1px 0 var(--line-strong);
--shadow-3: 0 4px 12px -6px rgba(10,10,10,0.10);
}
/* ============================================================
Semantic element styles
============================================================ */
html {
font-family: var(--ff-sans);
font-size: var(--fs-base);
line-height: var(--lh-base);
color: var(--fg-1);
background: var(--bg-1);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
}
body {
margin: 0;
font-feature-settings: "ss01", "cv11";
text-wrap: pretty;
}
/* ---------- Headings ---------- */
h1, .h1 {
font: 600 var(--fs-3xl)/var(--lh-tight) var(--ff-sans);
letter-spacing: var(--tr-tight);
margin: 0 0 var(--sp-5);
color: var(--fg-1);
}
h2, .h2 {
font: 500 var(--fs-2xl)/var(--lh-snug) var(--ff-sans);
letter-spacing: var(--tr-snug);
margin: 0 0 var(--sp-4);
}
h3, .h3 {
font: 500 var(--fs-xl)/var(--lh-snug) var(--ff-sans);
letter-spacing: var(--tr-snug);
margin: 0 0 var(--sp-3);
}
h4, .h4 {
font: 500 var(--fs-lg)/var(--lh-snug) var(--ff-sans);
margin: 0 0 var(--sp-2);
}
h5, .h5 {
font: 500 var(--fs-md)/var(--lh-snug) var(--ff-sans);
margin: 0 0 var(--sp-2);
}
/* ---------- Display (for hero / title slides) ---------- */
.display-1 {
font: 300 var(--fs-5xl)/0.95 var(--ff-sans);
letter-spacing: -0.035em;
color: var(--fg-1);
}
.display-2 {
font: 400 var(--fs-4xl)/1.0 var(--ff-sans);
letter-spacing: var(--tr-tight);
}
/* ---------- Body ---------- */
p {
margin: 0 0 var(--sp-4);
line-height: var(--lh-base);
color: var(--fg-1);
}
.lead {
font-size: var(--fs-md);
line-height: 1.55;
color: var(--fg-2);
}
small, .small {
font-size: var(--fs-sm);
color: var(--fg-2);
}
/* ---------- Eyebrow / uppercase labels (very common in this system) ---------- */
.eyebrow,
.label {
font: 500 var(--fs-xs)/1.2 var(--ff-mono);
letter-spacing: var(--tr-label);
text-transform: uppercase;
color: var(--fg-3);
}
/* ---------- Code / mono ---------- */
code, kbd, samp, pre, .mono {
font-family: var(--ff-mono);
font-size: 0.92em;
letter-spacing: var(--tr-mono);
}
code {
background: var(--bg-3);
padding: 1px 6px;
border-radius: var(--r-1);
color: var(--ink-2);
}
pre {
background: var(--bg-3);
border: 1px solid var(--border);
padding: var(--sp-4);
overflow-x: auto;
border-radius: var(--r-2);
font-size: var(--fs-sm);
line-height: var(--lh-snug);
}
pre code { background: none; padding: 0; }
/* ---------- Editorial serif moments ---------- */
.serif { font-family: var(--ff-serif); }
.serif-quote {
font: 400 italic var(--fs-xl)/1.4 var(--ff-serif);
color: var(--fg-2);
}
/* ---------- Links ---------- */
a {
color: var(--fg-1);
text-decoration: underline;
text-decoration-color: var(--border-strong);
text-underline-offset: 3px;
text-decoration-thickness: 1px;
transition: text-decoration-color 120ms ease, color 120ms ease;
}
a:hover {
text-decoration-color: var(--fg-1);
}
/* ---------- HR ---------- */
hr {
border: 0;
border-top: 1px solid var(--border);
margin: var(--sp-5) 0;
}
/* ---------- Highlighter (the one place yellow appears in body copy) ---------- */
mark, .mark {
background: var(--hi);
color: var(--hi-ink);
padding: 0 2px;
}
/* ---------- Tables (used in templates) ---------- */
table {
width: 100%;
border-collapse: collapse;
font-size: var(--fs-sm);
}
th, td {
text-align: left;
padding: var(--sp-3) var(--sp-4);
border-bottom: 1px solid var(--border);
}
th {
font-weight: 500;
color: var(--fg-2);
font-family: var(--ff-mono);
font-size: var(--fs-xs);
letter-spacing: var(--tr-label);
text-transform: uppercase;
}
/* ---------- Selection ---------- */
::selection { background: var(--hi); color: var(--hi-ink); }

View File

@@ -0,0 +1,22 @@
{
"$schema": "https://design-tokens.github.io/community-group/format/",
"ink": { "value": "#0A0A0A", "type": "color", "comment": "Near-black. The only fill most of the time." },
"ink-2": { "value": "#1F1F1F", "type": "color" },
"ink-3": { "value": "#5C5C5C", "type": "color" },
"ink-4": { "value": "#8A8A8A", "type": "color" },
"ink-5": { "value": "#B5B5B3", "type": "color", "comment": "Placeholder text, wireframe labels." },
"line": { "value": "#E5E5E2", "type": "color", "comment": "Default 1px wireframe rule." },
"line-strong": { "value": "#C9C9C5", "type": "color" },
"line-soft": { "value": "#F0F0EC", "type": "color" },
"paper": { "value": "#FFFFFF", "type": "color" },
"paper-2": { "value": "#FAFAF7", "type": "color" },
"paper-3": { "value": "#F4F4EF", "type": "color" },
"hi": { "value": "#FFE14A", "type": "color", "comment": "Annotation yellow. Highlighter only, never a button fill." },
"hi-2": { "value": "#FFD400", "type": "color" },
"hi-ink": { "value": "#1A1500", "type": "color", "comment": "Text on yellow." },
"status-raw": { "value": "#B5B5B3", "type": "color", "comment": "S0 — no signal" },
"status-weak": { "value": "#8A8A8A", "type": "color", "comment": "S1 — weak signal" },
"status-medium": { "value": "#5C5C5C", "type": "color", "comment": "S2 — medium signal" },
"status-strong": { "value": "#0A0A0A", "type": "color", "comment": "S3 — strong signal" },
"status-commercial": { "value": "#FFD400", "type": "color", "comment": "S4 — commercial" }
}

View File

@@ -0,0 +1,6 @@
{
"comment": "Manifest pointing at the three token files. Source-of-truth for any future Style Dictionary build.",
"colors": "./colors.json",
"type": "./type.json",
"spacing": "./spacing.json"
}

View File

@@ -0,0 +1,28 @@
{
"$schema": "https://design-tokens.github.io/community-group/format/",
"spacing": {
"1": { "value": "4px", "type": "dimension" },
"2": { "value": "8px", "type": "dimension" },
"3": { "value": "12px", "type": "dimension" },
"4": { "value": "16px", "type": "dimension" },
"5": { "value": "24px", "type": "dimension" },
"6": { "value": "32px", "type": "dimension" },
"7": { "value": "48px", "type": "dimension" },
"8": { "value": "64px", "type": "dimension" },
"9": { "value": "96px", "type": "dimension" },
"10": { "value": "128px", "type": "dimension" }
},
"radius": {
"0": { "value": "0px", "type": "dimension" },
"1": { "value": "2px", "type": "dimension" },
"2": { "value": "4px", "type": "dimension" },
"3": { "value": "8px", "type": "dimension" },
"pill": { "value": "999px", "type": "dimension" }
},
"shadow": {
"0": { "value": "none", "type": "shadow" },
"1": { "value": "0 1px 0 #E5E5E2", "type": "shadow" },
"2": { "value": "0 1px 0 #C9C9C5", "type": "shadow" },
"3": { "value": "0 4px 12px -6px rgba(10,10,10,0.10)", "type": "shadow", "comment": "Floating elements only." }
}
}

View File

@@ -0,0 +1,33 @@
{
"$schema": "https://design-tokens.github.io/community-group/format/",
"family": {
"sans": { "value": "\"IBM Plex Sans\", ui-sans-serif, system-ui, sans-serif", "type": "fontFamily" },
"mono": { "value": "\"IBM Plex Mono\", ui-monospace, \"SF Mono\", Menlo, monospace", "type": "fontFamily" },
"serif": { "value": "\"IBM Plex Serif\", \"Iowan Old Style\", Georgia, serif", "type": "fontFamily" }
},
"size": {
"xs": { "value": "11px", "type": "dimension" },
"sm": { "value": "13px", "type": "dimension" },
"base": { "value": "15px", "type": "dimension" },
"md": { "value": "17px", "type": "dimension" },
"lg": { "value": "20px", "type": "dimension" },
"xl": { "value": "24px", "type": "dimension" },
"2xl": { "value": "32px", "type": "dimension" },
"3xl": { "value": "44px", "type": "dimension" },
"4xl": { "value": "64px", "type": "dimension" },
"5xl": { "value": "96px", "type": "dimension" }
},
"lineHeight": {
"tight": { "value": 1.05, "type": "number" },
"snug": { "value": 1.25, "type": "number" },
"base": { "value": 1.5, "type": "number" },
"loose": { "value": 1.7, "type": "number" }
},
"tracking": {
"tight": { "value": "-0.02em", "type": "dimension" },
"snug": { "value": "-0.01em", "type": "dimension" },
"base": { "value": "0em", "type": "dimension" },
"mono": { "value": "0.02em", "type": "dimension" },
"label": { "value": "0.08em", "type": "dimension", "comment": "Uppercase eyebrow labels." }
}
}

178
uv.lock generated
View File

@@ -52,6 +52,100 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/bd/46/d3ec57ad500f598d1554bd14ce4df615960549ab2844961bc4e1f5fbd174/ast_serialize-0.3.0-cp39-abi3-win_arm64.whl", hash = "sha256:0dd00da29985f15f50dc35728b7e1e7c84507bccfea1d9914738530f1c72238a", size = 1077165 },
]
[[package]]
name = "certifi"
version = "2026.4.22"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/25/ee/6caf7a40c36a1220410afe15a1cc64993a1f864871f698c0f93acb72842a/certifi-2026.4.22.tar.gz", hash = "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580", size = 137077 }
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 = "charset-normalizer"
version = "3.4.7"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328 },
{ url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061 },
{ url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031 },
{ url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239 },
{ url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589 },
{ url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733 },
{ url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652 },
{ url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229 },
{ url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552 },
{ url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806 },
{ url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316 },
{ url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274 },
{ url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468 },
{ url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460 },
{ url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330 },
{ url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828 },
{ url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627 },
{ url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008 },
{ url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303 },
{ url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282 },
{ url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595 },
{ url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986 },
{ url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711 },
{ url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036 },
{ url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998 },
{ url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056 },
{ url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537 },
{ url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176 },
{ url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723 },
{ url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085 },
{ url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819 },
{ url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915 },
{ url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234 },
{ url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042 },
{ url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706 },
{ url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727 },
{ url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882 },
{ url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860 },
{ url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564 },
{ url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276 },
{ url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238 },
{ url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189 },
{ url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352 },
{ url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024 },
{ url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869 },
{ url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541 },
{ url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634 },
{ url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384 },
{ url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133 },
{ url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257 },
{ url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851 },
{ url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393 },
{ url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251 },
{ url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609 },
{ url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014 },
{ url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979 },
{ url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238 },
{ url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110 },
{ url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824 },
{ url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103 },
{ url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194 },
{ url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827 },
{ url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168 },
{ url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018 },
{ url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958 },
]
[[package]]
name = "click"
version = "8.3.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "platform_system == 'Windows'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/bb/63/f9e1ea081ce35720d8b92acde70daaedace594dc93b693c869e0d5910718/click-8.3.3.tar.gz", hash = "sha256:398329ad4837b2ff7cbe1dd166a4c0f8900c3ca3a218de04466f38f6497f18a2", size = 328061 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ae/44/c1221527f6a71a01ec6fbad7fa78f1d50dfa02217385cf0fa3eec7087d59/click-8.3.3-py3-none-any.whl", hash = "sha256:a2bf429bb3033c89fa4936ffb35d5cb471e3719e1f3c8a7c3fff0b8314305613", size = 110502 },
]
[[package]]
name = "colorama"
version = "0.4.6"
@@ -235,6 +329,27 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a7/a7/a600f8f30d4505e89166de51dd121bd540ab8e560e8cf0901de00a81de8c/faker-40.15.0-py3-none-any.whl", hash = "sha256:71ab3c3370da9d2205ab74ffb0fd51273063ad562b3a3bb69d0026a20923e318", size = 2004447 },
]
[[package]]
name = "gunicorn"
version = "26.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "packaging" },
]
sdist = { url = "https://files.pythonhosted.org/packages/6d/b7/a4a3f632f823e432ce6bc65f62961b7980c898c77f075a2f7118cb3846fe/gunicorn-26.0.0.tar.gz", hash = "sha256:ca9346f85e3a4aeeb64d491045c16b9a35647abd37ea15efe53080eb8b090baf", size = 727286 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e6/40/9c2384fc2be4ad25dd4a49decd5ad9ea5a3639814c11bd40ab77cb9f0a14/gunicorn-26.0.0-py3-none-any.whl", hash = "sha256:40233d26a5f0d1872916188c276e21641155111c2853f0c2cd55260aec0d24fc", size = 212009 },
]
[[package]]
name = "idna"
version = "3.15"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340 },
]
[[package]]
name = "iniconfig"
version = "2.3.0"
@@ -244,6 +359,20 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484 },
]
[[package]]
name = "issue-core"
version = "0.2.0"
source = { registry = "https://gitea.coulomb.social/api/packages/coulomb/pypi/simple/" }
dependencies = [
{ name = "click" },
{ name = "python-dateutil" },
{ name = "requests" },
]
sdist = { url = "http://gitea.coulomb.social/api/packages/coulomb/pypi/files/issue-core/0.2.0/issue_core-0.2.0.tar.gz", hash = "sha256:cd456ccafdf540f02f7f8b1326e28f8acebcfa0476f3ea2245bd2a5230d85a8d" }
wheels = [
{ url = "http://gitea.coulomb.social/api/packages/coulomb/pypi/files/issue-core/0.2.0/issue_core-0.2.0-py3-none-any.whl", hash = "sha256:f98b90a13c787095834dbc7ff8b14057718884e01a51c6b13206e438ba1d5f90" },
]
[[package]]
name = "librt"
version = "0.10.0"
@@ -493,6 +622,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/83/a5/41d091f697c09609e7ef1d5d61925494e0454ebf51de7de05f0f0a728f1d/pytest_django-4.12.0-py3-none-any.whl", hash = "sha256:3ff300c49f8350ba2953b90297d23bf5f589db69545f56f1ec5f8cff5da83e85", size = 26123 },
]
[[package]]
name = "python-dateutil"
version = "2.9.0.post0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "six" },
]
sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892 },
]
[[package]]
name = "python-decouple"
version = "3.8"
@@ -502,6 +643,21 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a2/d4/9193206c4563ec771faf2ccf54815ca7918529fe81f6adb22ee6d0e06622/python_decouple-3.8-py3-none-any.whl", hash = "sha256:d0d45340815b25f4de59c974b855bb38d03151d81b037d9e3f463b0c9f8cbd66", size = 9947 },
]
[[package]]
name = "requests"
version = "2.34.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "charset-normalizer" },
{ name = "idna" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/24/36/7180e7f077c38108945dbbdf60fe04db681c3feb6e96419f8c6dc8723741/requests-2.34.1.tar.gz", hash = "sha256:0fc5669f2b69704449fe1552360bd2a73a54512dfd03e65529157f1513322beb", size = 142783 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/15/5a/4a949d170476de3c04ac036b5466422fbcbf348a917d8042eedf2cac7d1b/requests-2.34.1-py3-none-any.whl", hash = "sha256:bf38a3ff993960d3dd819c08862c40b3c703306eb7c744fcd9f4ddbb95b548f0", size = 73085 },
]
[[package]]
name = "ruff"
version = "0.15.12"
@@ -527,6 +683,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c0/98/6beb4b351e472e5f4c4613f7c35a5290b8be2497e183825310c4c3a3984b/ruff-0.15.12-py3-none-win_arm64.whl", hash = "sha256:a538f7a82d061cee7be55542aca1d86d1393d55d81d4fcc314370f4340930d4f", size = 11120821 },
]
[[package]]
name = "six"
version = "1.17.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050 },
]
[[package]]
name = "sqlparse"
version = "0.5.5"
@@ -563,6 +728,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ce/e4/dccd7f47c4b64213ac01ef921a1337ee6e30e8c6466046018326977efd95/tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7", size = 349321 },
]
[[package]]
name = "urllib3"
version = "2.7.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087 },
]
[[package]]
name = "vergabe-teilnahme"
version = "0.1.0"
@@ -571,6 +745,8 @@ dependencies = [
{ name = "dj-database-url" },
{ name = "django" },
{ name = "django-storages" },
{ name = "gunicorn" },
{ name = "issue-core" },
{ name = "psycopg", extra = ["binary"] },
{ name = "python-decouple" },
{ name = "whitenoise" },
@@ -591,6 +767,8 @@ requires-dist = [
{ name = "dj-database-url", specifier = ">=2.1" },
{ name = "django", specifier = ">=5.2" },
{ name = "django-storages", specifier = ">=1.14" },
{ name = "gunicorn", specifier = ">=22.0" },
{ name = "issue-core", specifier = ">=0.2,<0.3", index = "https://gitea.coulomb.social/api/packages/coulomb/pypi/simple/" },
{ name = "psycopg", extras = ["binary"], specifier = ">=3.2" },
{ name = "python-decouple", specifier = ">=3.8" },
{ name = "whitenoise", specifier = ">=6.7" },

View File

@@ -1,12 +1,34 @@
from django.contrib import admin
from .models import Aufgabe, Bieterfrage
from .models import Aufgabe, AufgabenVerknuepfung, Bieterfrage, ExternalIssue
class AufgabenVerknuepfungInline(admin.TabularInline):
model = AufgabenVerknuepfung
extra = 0
readonly_fields = ['ziel', 'erstellt_am']
@admin.register(Aufgabe)
class AufgabeAdmin(admin.ModelAdmin):
list_display = ['titel', 'typ', 'status', 'prioritaet', 'frist', 'verantwortlicher']
list_filter = ['typ', 'status', 'prioritaet']
inlines = [AufgabenVerknuepfungInline]
@admin.register(AufgabenVerknuepfung)
class AufgabenVerknuepfungAdmin(admin.ModelAdmin):
list_display = ['aufgabe', 'content_type', 'object_id', 'erstellt_am']
list_filter = ['content_type']
@admin.register(ExternalIssue)
class ExternalIssueAdmin(admin.ModelAdmin):
list_display = ['aufgabe', 'issue_facade_backend', 'issue_key',
'sync_status', 'letzter_sync']
list_filter = ['issue_facade_backend', 'sync_status']
readonly_fields = ['issue_facade_id', 'issue_key', 'issue_url',
'sync_status', 'letzter_sync', 'erstellt_am']
@admin.register(Bieterfrage)

View File

@@ -1,6 +1,6 @@
from django import forms
from .models import Aufgabe, Bieterfrage
from .models import Aufgabe, Bieterfrage, ExternalIssue
class AufgabeForm(forms.ModelForm):
@@ -63,3 +63,29 @@ class BieterfragenForm(forms.ModelForm):
self.fields['anforderung'].required = False
self.fields['dokument'].required = False
self.fields['verfasser'].required = False
class AufgabenVerknuepfungForm(forms.Form):
ziel_typ = forms.ChoiceField(choices=[], label='Typ')
ziel_id = forms.IntegerField(label='Objekt-ID', widget=forms.HiddenInput())
kommentar = forms.CharField(
widget=forms.Textarea(attrs={'class': 'form-input', 'rows': 2, 'placeholder': 'Kommentar (optional)'}),
required=False,
label='Kommentar',
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
from .link_registry import ziel_choices
self.fields['ziel_typ'].choices = [('', '— Typ wählen —')] + ziel_choices()
self.fields['ziel_typ'].widget.attrs.update({'class': 'form-input'})
class ExternalIssueForm(forms.ModelForm):
class Meta:
model = ExternalIssue
fields = ['notizen']
widgets = {
'notizen': forms.Textarea(attrs={'class': 'form-input', 'rows': 2,
'placeholder': 'Notizen (optional)'}),
}

View File

@@ -0,0 +1,43 @@
from contextlib import contextmanager
from pathlib import Path
@contextmanager
def local_backend():
from django.conf import settings
from issue_core.backends.local import LocalSQLiteBackend
db_path = str(getattr(settings, 'ISSUE_FACADE_LOCAL_DB', '.issue-facade/issues.db'))
Path(db_path).parent.mkdir(parents=True, exist_ok=True)
b = LocalSQLiteBackend()
b.connect({'db_path': db_path})
try:
yield b
finally:
b.disconnect()
@contextmanager
def remote_backend():
"""Yields GiteaBackend wenn konfiguriert, sonst None."""
from django.conf import settings
from issue_core.backends.gitea import GiteaBackend
cfg = getattr(settings, 'ISSUE_FACADE_GITEA', None)
if not cfg:
yield None
return
b = GiteaBackend()
b.connect(cfg)
try:
yield b
finally:
b.disconnect()
def gitea_configured() -> bool:
from django.conf import settings
cfg = getattr(settings, 'ISSUE_FACADE_GITEA', None)
return bool(cfg and cfg.get('token'))

View File

@@ -0,0 +1,111 @@
from datetime import datetime, timezone
from issue_core.core.models import Issue, IssueState, Label, Priority
from .issue_backends import gitea_configured, local_backend, remote_backend
def aufgabe_zu_issue(aufgabe) -> Issue:
"""Konvertiert eine Aufgabe in ein issue-facade Issue-Objekt."""
prioritaet_map = {1: Priority.HIGH, 2: Priority.MEDIUM, 3: Priority.LOW}
labels = [
Label(name='task'),
Label(name=f'priority:{prioritaet_map.get(aufgabe.prioritaet, Priority.MEDIUM).value}'),
]
if aufgabe.typ:
labels.append(Label(name=aufgabe.typ))
now = datetime.now(timezone.utc)
return Issue(
id='',
number=0,
title=aufgabe.titel,
description=aufgabe.beschreibung or '',
state=IssueState.OPEN,
created_at=now,
updated_at=now,
labels=labels,
)
def lokales_issue_erstellen(aufgabe) -> dict:
"""
Legt ein Issue im lokalen SQLite-Backend an.
Gibt {'issue_facade_id', 'issue_key', 'sync_status'} zurück.
"""
issue = aufgabe_zu_issue(aufgabe)
with local_backend() as b:
created = b.create_issue(issue)
return {
'issue_facade_id': str(created.id),
'issue_key': f'#{created.number}',
'sync_status': created.state.value,
}
def an_gitea_delegieren(aufgabe, external_issue) -> dict:
"""
Schiebt ein lokales Issue nach Gitea.
Gibt {'issue_facade_backend', 'issue_facade_id', 'issue_url', 'issue_key', 'sync_status'} zurück.
Wirft ValueError wenn Gitea nicht konfiguriert ist.
"""
with remote_backend() as b:
if b is None:
raise ValueError('Gitea nicht konfiguriert (ISSUE_FACADE_GITEA fehlt in settings)')
issue = aufgabe_zu_issue(aufgabe)
created = b.create_issue(issue)
url = ''
if created.sync_metadata:
url = created.sync_metadata.get('url', '')
return {
'issue_facade_backend': 'gitea',
'issue_facade_id': str(created.number),
'issue_url': url,
'issue_key': f'#{created.number}',
'sync_status': created.state.value,
}
def status_synchronisieren(external_issue) -> str:
"""
Liest den aktuellen Status aus dem konfigurierten Backend.
Gibt den neuen sync_status-String zurück.
"""
from django.utils import timezone as dj_tz
backend_ctx = (
remote_backend() if external_issue.issue_facade_backend == 'gitea'
else local_backend()
)
with backend_ctx as b:
if b is None:
raise ValueError('Backend nicht verfügbar')
issue = b.get_issue(external_issue.issue_facade_id)
neuer_status = issue.state.value if issue else 'error'
external_issue.sync_status = neuer_status
external_issue.letzter_sync = dj_tz.now()
external_issue.save(update_fields=['sync_status', 'letzter_sync'])
return neuer_status
def issue_schliessen(external_issue) -> None:
"""Setzt das Issue im Backend auf CLOSED."""
backend_ctx = (
remote_backend() if external_issue.issue_facade_backend == 'gitea'
else local_backend()
)
with backend_ctx as b:
if b is None:
return
issue = b.get_issue(external_issue.issue_facade_id)
if issue:
issue.state = IssueState.CLOSED
b.update_issue(issue)
external_issue.sync_status = 'closed'
external_issue.save(update_fields=['sync_status'])
def get_adapter(system: str):
"""Rückwärtskompatible Stub — gibt None zurück (kein manuelles Adapter-Dict mehr)."""
return None

View File

@@ -0,0 +1,27 @@
from django.contrib.contenttypes.models import ContentType
_VERKNUEPFBARE_MODELLE = [
('lose', 'Anforderung'),
('lose', 'Los'),
('aufgaben', 'Bieterfrage'),
('dokumente', 'Dokument'),
('preise', 'Preispunkt'),
]
def verknuepfbare_typen():
"""Gibt eine geordnete Liste (ContentType, label) der verknüpfbaren Typen zurück."""
result = []
for app_label, model_name in _VERKNUEPFBARE_MODELLE:
try:
ct = ContentType.objects.get(app_label=app_label, model=model_name.lower())
result.append((ct, f'{ct.app_label} / {model_name}'))
except ContentType.DoesNotExist:
pass
return result
def ziel_choices():
"""Gibt (content_type_id, label)-Tupel für ein ChoiceField zurück."""
return [(ct.pk, label) for ct, label in verknuepfbare_typen()]

View File

@@ -0,0 +1,18 @@
import django.utils.timezone
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('aufgaben', '0003_phase_feld'),
]
operations = [
migrations.AddField(
model_name='aufgabe',
name='erstellt_am',
field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now),
preserve_default=False,
),
]

View File

@@ -0,0 +1,49 @@
# Generated by Django 6.0.5 on 2026-05-14 02:11
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('aufgaben', '0004_erstellt_am'),
('contenttypes', '0002_remove_content_type_name'),
]
operations = [
migrations.CreateModel(
name='AufgabenVerknuepfung',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('object_id', models.PositiveIntegerField()),
('kommentar', models.TextField(blank=True)),
('erstellt_am', models.DateTimeField(auto_now_add=True)),
('aufgabe', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='verknuepfungen', to='aufgaben.aufgabe')),
('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype')),
],
options={
'verbose_name': 'Aufgaben-Verknüpfung',
'verbose_name_plural': 'Aufgaben-Verknüpfungen',
'ordering': ['erstellt_am'],
},
),
migrations.CreateModel(
name='ExternalIssue',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('system', models.CharField(choices=[('github', 'GitHub Issues'), ('jira', 'Jira'), ('linear', 'Linear'), ('azure', 'Azure DevOps'), ('sonstiges', 'Sonstiges')], max_length=20)),
('issue_url', models.URLField(blank=True)),
('issue_key', models.CharField(blank=True, help_text='z.B. "GH-42" oder "PROJ-1234"', max_length=100)),
('sync_status', models.CharField(choices=[('manuell', 'Manuell'), ('offen', 'Offen (extern)'), ('geschlossen', 'Geschlossen (extern)'), ('fehler', 'Sync-Fehler')], default='manuell', max_length=20)),
('letzter_sync', models.DateTimeField(blank=True, null=True)),
('notizen', models.TextField(blank=True)),
('erstellt_am', models.DateTimeField(auto_now_add=True)),
('aufgabe', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='external_issue', to='aufgaben.aufgabe')),
],
options={
'verbose_name': 'External Issue',
'verbose_name_plural': 'External Issues',
},
),
]

View File

@@ -0,0 +1,37 @@
# Generated by Django 6.0.5 on 2026-05-14 09:17
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('aufgaben', '0005_verknuepfung_externalissue'),
]
operations = [
migrations.RemoveField(
model_name='externalissue',
name='system',
),
migrations.AddField(
model_name='externalissue',
name='issue_facade_backend',
field=models.CharField(choices=[('local', 'Lokal (SQLite)'), ('gitea', 'Gitea')], default='local', max_length=20),
),
migrations.AddField(
model_name='externalissue',
name='issue_facade_id',
field=models.CharField(blank=True, help_text='UUID (lokal) oder Issue-Number (Gitea)', max_length=200),
),
migrations.AlterField(
model_name='externalissue',
name='issue_key',
field=models.CharField(blank=True, help_text='z.B. "#42" oder "PROJ-1234"', max_length=100),
),
migrations.AlterField(
model_name='externalissue',
name='sync_status',
field=models.CharField(choices=[('open', 'Offen'), ('in_progress', 'In Bearbeitung'), ('blocked', 'Blockiert'), ('closed', 'Geschlossen'), ('error', 'Sync-Fehler')], default='open', max_length=20),
),
]

View File

@@ -1,5 +1,7 @@
from datetime import date
from datetime import date, timedelta
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from django.db import models
from vergabe_teilnahme.apps.core.models import FlexibleModel
@@ -61,6 +63,7 @@ class Aufgabe(FlexibleModel):
status = models.CharField(max_length=25, choices=STATUS_CHOICES, default='offen')
prioritaet = models.PositiveSmallIntegerField(choices=PRIORITAET_CHOICES, default=2)
frist = models.DateField(null=True, blank=True)
erstellt_am = models.DateTimeField(auto_now_add=True)
verantwortlicher = models.ForeignKey(
'accounts.Mitarbeiter', on_delete=models.SET_NULL, null=True, blank=True
)
@@ -75,11 +78,18 @@ class Aufgabe(FlexibleModel):
def __str__(self):
return self.titel
@property
def frist_effektiv(self):
if self.frist:
return self.frist
return (self.erstellt_am + timedelta(days=7)).date()
@property
def ist_ueberfaellig(self):
if not self.frist:
return False
return self.frist < date.today() and self.status not in ['erledigt', 'verworfen']
return (
self.frist_effektiv < date.today()
and self.status not in ['erledigt', 'verworfen']
)
class Bieterfrage(FlexibleModel):
@@ -122,3 +132,64 @@ class Bieterfrage(FlexibleModel):
def __str__(self):
return self.fragentext[:80]
class AufgabenVerknuepfung(models.Model):
aufgabe = models.ForeignKey(
Aufgabe, on_delete=models.CASCADE, related_name='verknuepfungen'
)
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
object_id = models.PositiveIntegerField()
ziel = GenericForeignKey('content_type', 'object_id')
kommentar = models.TextField(blank=True)
erstellt_am = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ['erstellt_am']
verbose_name = 'Aufgaben-Verknüpfung'
verbose_name_plural = 'Aufgaben-Verknüpfungen'
def __str__(self):
return f'{self.aufgabe}{self.content_type} #{self.object_id}'
class ExternalIssue(models.Model):
BACKEND_CHOICES = [
('local', 'Lokal (SQLite)'),
('gitea', 'Gitea'),
]
SYNC_STATUS_CHOICES = [
('open', 'Offen'),
('in_progress', 'In Bearbeitung'),
('blocked', 'Blockiert'),
('closed', 'Geschlossen'),
('error', 'Sync-Fehler'),
]
aufgabe = models.OneToOneField(
Aufgabe, on_delete=models.CASCADE, related_name='external_issue'
)
issue_facade_backend = models.CharField(
max_length=20, choices=BACKEND_CHOICES, default='local'
)
issue_facade_id = models.CharField(
max_length=200, blank=True,
help_text='UUID (lokal) oder Issue-Number (Gitea)'
)
issue_url = models.URLField(blank=True)
issue_key = models.CharField(max_length=100, blank=True,
help_text='z.B. "#42" oder "PROJ-1234"')
sync_status = models.CharField(
max_length=20, choices=SYNC_STATUS_CHOICES, default='open'
)
letzter_sync = models.DateTimeField(null=True, blank=True)
notizen = models.TextField(blank=True)
erstellt_am = models.DateTimeField(auto_now_add=True)
class Meta:
verbose_name = 'External Issue'
verbose_name_plural = 'External Issues'
def __str__(self):
ident = self.issue_key or (self.issue_facade_id[:8] if self.issue_facade_id else '?')
return f'{self.get_issue_facade_backend_display()} #{ident}'

View File

@@ -5,7 +5,7 @@ from django.urls import reverse
from vergabe_teilnahme.apps.ausschreibungen.tests import AusschreibungFactory
from vergabe_teilnahme.apps.lose.tests import AnforderungFactory
from .models import Aufgabe, Bieterfrage
from .models import Aufgabe, AufgabenVerknuepfung, Bieterfrage, ExternalIssue
class AufgabeFactory(factory.django.DjangoModelFactory):
@@ -94,3 +94,196 @@ def test_bieterfrage_antwort_speichern(client):
bf.refresh_from_db()
assert bf.antwort == 'Die Antwort lautet 42.'
assert bf.status == 'beantwortet'
# ─── Implizite Fälligkeit ─────────────────────────────────────────────────────
@pytest.mark.django_db
def test_frist_effektiv_mit_frist():
from datetime import date
aufgabe = AufgabeFactory(frist=date(2026, 6, 1))
assert aufgabe.frist_effektiv == date(2026, 6, 1)
@pytest.mark.django_db
def test_frist_effektiv_ohne_frist():
from datetime import timedelta
from django.utils import timezone
aufgabe = AufgabeFactory(frist=None)
expected = (aufgabe.erstellt_am + timedelta(days=7)).date()
assert aufgabe.frist_effektiv == expected
@pytest.mark.django_db
def test_ueberfaellig_ohne_frist_nach_7_tagen(client):
from datetime import timedelta
from django.utils import timezone
a = AusschreibungFactory()
alte_erstellung = timezone.now() - timedelta(days=8)
aufgabe = AufgabeFactory(ausschreibung=a, frist=None, status='offen')
Aufgabe.objects.filter(pk=aufgabe.pk).update(erstellt_am=alte_erstellung)
url = reverse('ausschreibungen:aufgaben:liste', kwargs={'ausschreibung_id': a.pk})
client.get(url)
aufgabe.refresh_from_db()
assert aufgabe.status == 'ueberfaellig'
# ─── Aufgaben-Verknüpfungen ───────────────────────────────────────────────────
@pytest.mark.django_db
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 = client.post(url, {
'ziel_typ': ct.pk,
'ziel_id': anf.pk,
'kommentar': 'Testverknüpfung',
}, HTTP_HX_REQUEST='true')
assert response.status_code == 200
assert AufgabenVerknuepfung.objects.filter(aufgabe=aufgabe, object_id=anf.pk).exists()
@pytest.mark.django_db
def test_aufgaben_verknuepfung_loeschen(client):
from django.contrib.contenttypes.models import ContentType
aufgabe = AufgabeFactory()
anf = AnforderungFactory(ausschreibung=aufgabe.ausschreibung)
ct = ContentType.objects.get_for_model(anf)
vk = AufgabenVerknuepfung.objects.create(
aufgabe=aufgabe, content_type=ct, object_id=anf.pk
)
url = reverse('ausschreibungen:aufgaben:verknuepfung_loeschen',
kwargs={'ausschreibung_id': aufgabe.ausschreibung_id, 'pk': aufgabe.pk, 'vk_pk': vk.pk})
response = client.post(url, {}, HTTP_HX_REQUEST='true')
assert response.status_code == 200
assert not AufgabenVerknuepfung.objects.filter(pk=vk.pk).exists()
# ─── ExternalIssue ────────────────────────────────────────────────────────────
@pytest.fixture
def tmp_issue_db(tmp_path, settings):
settings.ISSUE_FACADE_LOCAL_DB = tmp_path / 'test_issues.db'
settings.ISSUE_FACADE_GITEA = None
return settings.ISSUE_FACADE_LOCAL_DB
@pytest.mark.django_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 = client.post(url, {'notizen': ''}, HTTP_HX_REQUEST='true')
assert response.status_code == 200
assert ExternalIssue.objects.filter(aufgabe=aufgabe, issue_facade_backend='local').exists()
@pytest.mark.django_db
def test_external_issue_loeschen(client):
aufgabe = AufgabeFactory()
ei = ExternalIssue.objects.create(
aufgabe=aufgabe, issue_facade_backend='local', issue_key='#1'
)
url = reverse('ausschreibungen:aufgaben:external_issue_loeschen',
kwargs={'ausschreibung_id': aufgabe.ausschreibung_id, 'pk': aufgabe.pk})
response = client.post(url, {}, HTTP_HX_REQUEST='true')
assert response.status_code == 200
assert not ExternalIssue.objects.filter(pk=ei.pk).exists()
@pytest.mark.django_db
def test_issue_adapter_interface():
from .issue_facade import get_adapter
assert get_adapter('github') is None
assert get_adapter('nichtexistent') is None
# ─── Issue-Facade Integration ─────────────────────────────────────────────────
@pytest.mark.django_db
def test_aufgabe_zu_issue_mapping(tmp_issue_db):
from .issue_facade import aufgabe_zu_issue
aufgabe = AufgabeFactory(titel='Test-Aufgabe', beschreibung='Beschreibung', prioritaet=1)
issue = aufgabe_zu_issue(aufgabe)
assert issue.title == 'Test-Aufgabe'
assert issue.description == 'Beschreibung'
label_names = [l.name for l in issue.labels]
assert 'task' in label_names
assert 'priority:high' in label_names
@pytest.mark.django_db
def test_lokales_issue_erstellen(tmp_issue_db):
from .issue_facade import lokales_issue_erstellen
aufgabe = AufgabeFactory()
daten = lokales_issue_erstellen(aufgabe)
assert daten['issue_facade_id']
assert daten['issue_key'].startswith('#')
assert daten['sync_status'] == 'open'
@pytest.mark.django_db
def test_status_synchronisieren(tmp_issue_db):
from .issue_facade import lokales_issue_erstellen, status_synchronisieren
aufgabe = AufgabeFactory()
daten = lokales_issue_erstellen(aufgabe)
ei = ExternalIssue.objects.create(
aufgabe=aufgabe,
issue_facade_backend='local',
issue_facade_id=daten['issue_facade_id'],
issue_key=daten['issue_key'],
sync_status=daten['sync_status'],
)
neuer_status = status_synchronisieren(ei)
assert neuer_status == 'open'
ei.refresh_from_db()
assert ei.sync_status == 'open'
assert ei.letzter_sync is not None
@pytest.mark.django_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 = 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'
assert ei.issue_facade_id
assert ei.notizen == 'Test-Notiz'
@pytest.mark.django_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)
ei = ExternalIssue.objects.create(
aufgabe=aufgabe,
issue_facade_backend='local',
issue_facade_id=daten['issue_facade_id'],
issue_key=daten['issue_key'],
sync_status=daten['sync_status'],
)
url = reverse('ausschreibungen:aufgaben:external_issue_sync',
kwargs={'ausschreibung_id': aufgabe.ausschreibung_id, 'pk': aufgabe.pk})
response = client.post(url, {}, HTTP_HX_REQUEST='true')
assert response.status_code == 200
ei.refresh_from_db()
assert ei.sync_status == 'open'
@pytest.mark.django_db
def test_get_adapter_stub():
from .issue_facade import get_adapter
assert get_adapter('github') is None
assert get_adapter('gitea') is None

View File

@@ -12,4 +12,12 @@ urlpatterns = [
path('<int:pk>/loeschen/', views.aufgabe_loeschen, name='loeschen'),
path('<int:pk>/status/', views.aufgabe_status, name='status'),
path('<int:pk>/ergebnis/', views.aufgabe_ergebnis, name='ergebnis'),
path('<int:pk>/verknuepfungen/neu/', views.verknuepfung_neu, name='verknuepfung_neu'),
path('<int:pk>/verknuepfungen/objekte/', views.verknuepfung_objekte, name='verknuepfung_objekte'),
path('<int:pk>/verknuepfungen/<int:vk_pk>/loeschen/', views.verknuepfung_loeschen, name='verknuepfung_loeschen'),
path('<int:pk>/issue/', views.external_issue_bearbeiten, name='external_issue'),
path('<int:pk>/issue/panel/', views.external_issue_panel, name='external_issue_panel'),
path('<int:pk>/issue/loeschen/', views.external_issue_loeschen, name='external_issue_loeschen'),
path('<int:pk>/issue/push/', views.external_issue_push_remote, name='external_issue_push'),
path('<int:pk>/issue/sync/', views.external_issue_sync, name='external_issue_sync'),
]

View File

@@ -1,10 +1,14 @@
from datetime import date
from datetime import date, timedelta
from django.shortcuts import get_object_or_404, redirect, render
from django.utils import timezone
from vergabe_teilnahme.apps.ausschreibungen.models import Ausschreibung
from .models import Aufgabe, Bieterfrage
from django.http import HttpResponse
from .issue_backends import gitea_configured as _gitea_configured
from .models import Aufgabe, AufgabenVerknuepfung, Bieterfrage, ExternalIssue
AKTIVE_STATUS = [
'offen', 'in_bearbeitung', 'wartend_intern', 'wartend_sub', 'wartend_ausschreiber',
@@ -31,6 +35,12 @@ def aufgaben_liste(request, ausschreibung_id=None):
qs = Aufgabe.objects.select_related('ausschreibung').all()
qs.filter(frist__lt=heute, status__in=AKTIVE_STATUS).update(status='ueberfaellig')
implizite_grenze = timezone.now() - timedelta(days=7)
qs.filter(
frist__isnull=True,
erstellt_am__lt=implizite_grenze,
status__in=AKTIVE_STATUS,
).update(status='ueberfaellig')
status_filter = request.GET.get('status')
if status_filter:
@@ -99,6 +109,11 @@ def aufgabe_neu(request, ausschreibung_id):
else:
form = AufgabeForm(ausschreibung=ausschreibung)
if _is_htmx(request):
return render(request, 'aufgaben/partials/aufgabe_form_inline.html', {
'form': form,
'ausschreibung': ausschreibung,
})
return render(request, 'aufgaben/form.html', {
'form': form,
'ausschreibung': ausschreibung,
@@ -316,3 +331,196 @@ def bieterfrage_antwort(request, ausschreibung_id, pk):
'show_antwort_form': True,
'breadcrumbs': [],
})
# ─── Aufgaben-Verknüpfungen ───────────────────────────────────────────────────
def verknuepfung_neu(request, ausschreibung_id, pk):
from django.contrib.contenttypes.models import ContentType
from .forms import AufgabenVerknuepfungForm
ausschreibung = get_object_or_404(Ausschreibung, pk=ausschreibung_id)
aufgabe = get_object_or_404(Aufgabe, pk=pk, ausschreibung=ausschreibung)
if request.method == 'POST':
form = AufgabenVerknuepfungForm(request.POST)
if form.is_valid():
ct_id = form.cleaned_data['ziel_typ']
obj_id = form.cleaned_data['ziel_id']
ct = get_object_or_404(ContentType, pk=ct_id)
vk = AufgabenVerknuepfung.objects.create(
aufgabe=aufgabe,
content_type=ct,
object_id=obj_id,
kommentar=form.cleaned_data['kommentar'],
)
if _is_htmx(request):
return render(request, 'aufgaben/partials/verknuepfung_row.html',
{'v': vk, 'aufgabe': aufgabe, 'ausschreibung': ausschreibung})
return redirect('ausschreibungen:aufgaben:detail',
ausschreibung_id=ausschreibung_id, pk=pk)
else:
form = AufgabenVerknuepfungForm()
if _is_htmx(request):
return render(request, 'aufgaben/partials/verknuepfung_form_inline.html', {
'form': form,
'aufgabe': aufgabe,
'ausschreibung': ausschreibung,
})
return redirect('ausschreibungen:aufgaben:detail', ausschreibung_id=ausschreibung_id, pk=pk)
def verknuepfung_objekte(request, ausschreibung_id, pk):
from django.contrib.contenttypes.models import ContentType
ct_id = request.GET.get('ziel_typ') or request.GET.get('ct_id')
objekte = []
if ct_id:
try:
ct = ContentType.objects.get(pk=ct_id)
model_class = ct.model_class()
if model_class is not None:
objekte = list(model_class.objects.all()[:200])
except ContentType.DoesNotExist:
pass
return render(request, 'aufgaben/partials/verknuepfung_objekte_select.html', {
'objekte': objekte,
'ct_id': ct_id,
})
def verknuepfung_loeschen(request, ausschreibung_id, pk, vk_pk):
ausschreibung = get_object_or_404(Ausschreibung, pk=ausschreibung_id)
aufgabe = get_object_or_404(Aufgabe, pk=pk, ausschreibung=ausschreibung)
vk = get_object_or_404(AufgabenVerknuepfung, pk=vk_pk, aufgabe=aufgabe)
if request.method == 'POST':
vk.delete()
if _is_htmx(request):
return HttpResponse('')
return redirect('ausschreibungen:aufgaben:detail',
ausschreibung_id=ausschreibung_id, pk=pk)
return render(request, 'aufgaben/partials/verknuepfung_loeschen_confirm.html', {
'v': vk,
'aufgabe': aufgabe,
'ausschreibung': ausschreibung,
})
# ─── External Issue ───────────────────────────────────────────────────────────
def external_issue_bearbeiten(request, ausschreibung_id, pk):
from .forms import ExternalIssueForm
from .issue_facade import lokales_issue_erstellen
ausschreibung = get_object_or_404(Ausschreibung, pk=ausschreibung_id)
aufgabe = get_object_or_404(Aufgabe, pk=pk, ausschreibung=ausschreibung)
issue = getattr(aufgabe, 'external_issue', None)
if request.method == 'POST':
form = ExternalIssueForm(request.POST, instance=issue)
if form.is_valid():
ei = form.save(commit=False)
ei.aufgabe = aufgabe
if not issue:
daten = lokales_issue_erstellen(aufgabe)
ei.issue_facade_backend = 'local'
ei.issue_facade_id = daten['issue_facade_id']
ei.issue_key = daten['issue_key']
ei.sync_status = daten['sync_status']
ei.save()
if _is_htmx(request):
return render(request, 'aufgaben/partials/external_issue_panel.html', {
'aufgabe': aufgabe,
'ausschreibung': ausschreibung,
'gitea_configured': _gitea_configured(),
})
return redirect('ausschreibungen:aufgaben:detail',
ausschreibung_id=ausschreibung_id, pk=pk)
else:
form = ExternalIssueForm(instance=issue)
if _is_htmx(request):
return render(request, 'aufgaben/partials/external_issue_form.html', {
'form': form,
'aufgabe': aufgabe,
'ausschreibung': ausschreibung,
})
return redirect('ausschreibungen:aufgaben:detail', ausschreibung_id=ausschreibung_id, pk=pk)
def external_issue_push_remote(request, ausschreibung_id, pk):
"""Schiebt ein lokales Issue nach Gitea."""
from .issue_facade import an_gitea_delegieren
ausschreibung = get_object_or_404(Ausschreibung, pk=ausschreibung_id)
aufgabe = get_object_or_404(Aufgabe, pk=pk, ausschreibung=ausschreibung)
ei = get_object_or_404(ExternalIssue, aufgabe=aufgabe)
push_error = None
if request.method == 'POST':
try:
daten = an_gitea_delegieren(aufgabe, ei)
for k, v in daten.items():
setattr(ei, k, v)
ei.save()
except ValueError as exc:
push_error = str(exc)
if _is_htmx(request):
return render(request, 'aufgaben/partials/external_issue_panel.html', {
'aufgabe': aufgabe,
'ausschreibung': ausschreibung,
'gitea_configured': _gitea_configured(),
'push_error': push_error,
})
return redirect('ausschreibungen:aufgaben:detail', ausschreibung_id=ausschreibung_id, pk=pk)
def external_issue_sync(request, ausschreibung_id, pk):
"""Aktualisiert sync_status aus dem Backend."""
from .issue_facade import status_synchronisieren
ausschreibung = get_object_or_404(Ausschreibung, pk=ausschreibung_id)
aufgabe = get_object_or_404(Aufgabe, pk=pk, ausschreibung=ausschreibung)
ei = get_object_or_404(ExternalIssue, aufgabe=aufgabe)
if request.method == 'POST':
try:
status_synchronisieren(ei)
except Exception:
ei.sync_status = 'error'
ei.save(update_fields=['sync_status'])
if _is_htmx(request):
return render(request, 'aufgaben/partials/external_issue_panel.html', {
'aufgabe': aufgabe,
'ausschreibung': ausschreibung,
'gitea_configured': _gitea_configured(),
})
return redirect('ausschreibungen:aufgaben:detail', ausschreibung_id=ausschreibung_id, pk=pk)
def external_issue_panel(request, ausschreibung_id, pk):
ausschreibung = get_object_or_404(Ausschreibung, pk=ausschreibung_id)
aufgabe = get_object_or_404(Aufgabe, pk=pk, ausschreibung=ausschreibung)
return render(request, 'aufgaben/partials/external_issue_panel.html', {
'aufgabe': aufgabe,
'ausschreibung': ausschreibung,
'gitea_configured': _gitea_configured(),
})
def external_issue_loeschen(request, ausschreibung_id, pk):
ausschreibung = get_object_or_404(Ausschreibung, pk=ausschreibung_id)
aufgabe = get_object_or_404(Aufgabe, pk=pk, ausschreibung=ausschreibung)
if request.method == 'POST':
ExternalIssue.objects.filter(aufgabe=aufgabe).delete()
if _is_htmx(request):
return render(request, 'aufgaben/partials/external_issue_panel.html', {
'aufgabe': aufgabe,
'ausschreibung': ausschreibung,
'gitea_configured': _gitea_configured(),
})
return redirect('ausschreibungen:aufgaben:detail',
ausschreibung_id=ausschreibung_id, pk=pk)
return redirect('ausschreibungen:aufgaben:detail', ausschreibung_id=ausschreibung_id, pk=pk)

View File

@@ -115,6 +115,10 @@ def ausschreibung_detail(request, pk):
from vergabe_teilnahme.apps.lose.models import Los
a = get_object_or_404(Ausschreibung, pk=pk)
from vergabe_teilnahme.apps.aufgaben.models import Aufgabe as AufgabeModel
aufgaben = AufgabeModel.objects.filter(ausschreibung=a).select_related(
'verantwortlicher', 'los'
).order_by('prioritaet', 'frist')
lose = Los.objects.filter(ausschreibung=a).annotate(
aufgaben_total=Count('aufgaben', distinct=True),
aufgaben_erledigt=Count(
@@ -127,6 +131,7 @@ def ausschreibung_detail(request, pk):
'ausschreibung': a,
'ausschreibung_id': pk,
'lose': lose,
'aufgaben': aufgaben,
'phases': build_phase_nav(a),
'warnungen': get_deadline_warnings(a),
'aufgaben_score': aufgaben_score(Aufgabe.objects.filter(ausschreibung=a)),

View File

@@ -90,3 +90,16 @@ MEDIA_URL = '/media/'
MEDIA_ROOT = BASE_DIR / 'media'
MAX_UPLOAD_SIZE = config('MAX_UPLOAD_SIZE', default=52428800, cast=int)
# Issue Facade — lokales SQLite-Backend (immer aktiv)
ISSUE_FACADE_LOCAL_DB = BASE_DIR / '.issue-facade' / 'issues.db'
# Issue Facade — Gitea-Remote (optional, None = deaktiviert)
ISSUE_FACADE_GITEA: dict | None = None
# Beispiel:
# ISSUE_FACADE_GITEA = {
# 'base_url': 'https://gitea.example.com',
# 'token': env('GITEA_TOKEN', default=''),
# 'owner': 'org',
# 'repo': 'vergabe',
# }

View File

@@ -5,6 +5,16 @@ from .base import * # noqa: F401, F403
DEBUG = False
ALLOWED_HOSTS = config('ALLOWED_HOSTS', default='').split(',')
# Behind traefik (TLS terminated at the proxy). Without these, Django sees the
# request as plain HTTP and rejects the browser's https:// Origin on every POST
# with a CSRF failure (403) — the request never reaches the view, so saves fail
# silently and the DB stays empty. The deployment already injects
# CSRF_TRUSTED_ORIGINS via env; this reads it.
CSRF_TRUSTED_ORIGINS = [
o for o in config('CSRF_TRUSTED_ORIGINS', default='').split(',') if o
]
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
STATICFILES_STORAGE = 'whitenoise.storage.CompressedManifestStaticFilesStorage'
SECURE_BROWSER_XSS_FILTER = True

View File

@@ -17,7 +17,17 @@
{% render_field aufgabe "typ" "Typ" %}
{% render_field aufgabe "prioritaet" "Priorität" %}
{% render_field aufgabe "status" "Status" %}
{% render_field aufgabe "frist" "Frist" %}
<div class="flex justify-between text-sm py-0.5">
<dt class="text-slate-500">Frist</dt>
<dd class="text-slate-800">
{% if aufgabe.frist %}
{{ aufgabe.frist }}
{% else %}
<span class="text-slate-400">keine</span>
<span class="text-xs text-amber-600 ml-1">(implizit fällig: {{ aufgabe.frist_effektiv|date:"d.m.Y" }})</span>
{% endif %}
</dd>
</div>
{% render_field aufgabe "verantwortlicher" "Verantwortlich" %}
{% if aufgabe.beschreibung %}
<div>
@@ -61,6 +71,28 @@
class="text-sm text-blue-600 hover:underline">{{ aufgabe.los.lostitel }}</a>
</div>
{% endif %}
<!-- Verknüpfungen -->
<div class="card">
<div class="flex items-center justify-between mb-2">
<p class="text-xs font-medium text-slate-500 uppercase tracking-wide">Verknüpfungen</p>
<button class="btn-ghost text-xs"
hx-get="{% url 'ausschreibungen:aufgaben:verknuepfung_neu' ausschreibung.pk aufgabe.pk %}"
hx-target="#verknuepfung-form-container"
hx-swap="innerHTML">+ Verknüpfen</button>
</div>
<div id="verknuepfung-form-container"></div>
<ul id="verknuepfungen-list" class="space-y-1 mt-1">
{% for v in aufgabe.verknuepfungen.all %}
{% include "aufgaben/partials/verknuepfung_row.html" %}
{% endfor %}
</ul>
</div>
<!-- Externes Issue -->
<div class="card" id="external-issue-panel">
{% include "aufgaben/partials/external_issue_panel.html" %}
</div>
</div>
</div>

View File

@@ -0,0 +1,29 @@
<form hx-post="{% url 'ausschreibungen:aufgaben:neu' ausschreibung.pk %}"
hx-target="#aufgaben-tbody"
hx-swap="afterbegin"
hx-on::after-request="if(event.detail.successful) this.reset(); document.getElementById('aufgaben-form-container').innerHTML = '';"
class="bg-slate-50 border border-slate-200 rounded p-3 mt-2">
{% csrf_token %}
<div class="flex gap-2 items-end flex-wrap">
<div class="flex-1 min-w-48">
<label class="form-label">Titel *</label>
{{ form.titel }}
</div>
<div class="w-32">
<label class="form-label">Typ</label>
{{ form.typ }}
</div>
<div class="w-36">
<label class="form-label">Phase</label>
{{ form.phase }}
</div>
<div class="flex gap-2 shrink-0">
<button type="submit" class="btn-primary text-xs">Speichern</button>
<button type="button" class="btn-ghost text-xs"
onclick="document.getElementById('aufgaben-form-container').innerHTML = ''">Abbrechen</button>
</div>
</div>
{% if form.titel.errors %}
<p class="text-red-600 text-xs mt-1">{{ form.titel.errors.0 }}</p>
{% endif %}
</form>

View File

@@ -1,7 +1,7 @@
{% load vergabe_tags %}
<tr class="hover:bg-slate-50{% if aufgabe.status == 'ueberfaellig' %} bg-red-50{% endif %}">
<td class="py-2 pr-4">
<a href="{% url 'ausschreibungen:aufgaben:detail' ausschreibung.pk aufgabe.pk %}"
<a href="{% url 'ausschreibungen:aufgaben:detail' aufgabe.ausschreibung_id aufgabe.pk %}"
class="font-medium text-slate-800 hover:text-blue-600 line-clamp-1">
{{ aufgabe.titel|truncatechars:60 }}
</a>
@@ -19,7 +19,7 @@
<td class="py-2 pr-4 text-xs text-slate-600">{{ aufgabe.verantwortlicher|default:"—" }}</td>
<td class="py-2 pr-4">
<select name="status"
hx-post="{% url 'ausschreibungen:aufgaben:status' ausschreibung.pk aufgabe.pk %}"
hx-post="{% url 'ausschreibungen:aufgaben:status' aufgabe.ausschreibung_id aufgabe.pk %}"
hx-target="closest tr"
hx-swap="outerHTML"
class="form-input text-xs py-1">
@@ -30,7 +30,7 @@
</td>
{% if aufgabe.status == 'erledigt' %}
<td class="py-2">
<form hx-post="{% url 'ausschreibungen:aufgaben:ergebnis' ausschreibung.pk aufgabe.pk %}"
<form hx-post="{% url 'ausschreibungen:aufgaben:ergebnis' aufgabe.ausschreibung_id aufgabe.pk %}"
hx-target="closest tr" hx-swap="outerHTML"
class="flex gap-1 items-center">
{% csrf_token %}

View File

@@ -0,0 +1,74 @@
{% with ei=aufgabe.external_issue %}
<div class="space-y-2 text-sm">
<div class="flex items-center gap-2 flex-wrap">
<!-- Backend-Badge -->
{% if ei.issue_facade_backend == 'local' %}
<span class="text-xs bg-slate-100 text-slate-600 rounded px-1.5 py-0.5 font-medium">LOKAL</span>
{% else %}
<span class="text-xs bg-blue-100 text-blue-700 rounded px-1.5 py-0.5 font-medium">GITEA</span>
{% endif %}
<!-- Issue-Key -->
{% if ei.issue_url %}
<a href="{{ ei.issue_url }}" target="_blank" rel="noopener"
class="text-xs font-mono text-blue-600 hover:underline">{{ ei.issue_key }}</a>
{% else %}
<span class="text-xs font-mono text-slate-600">{{ ei.issue_key }}</span>
{% endif %}
<!-- Status-Badge -->
<span class="text-xs rounded px-1.5 py-0.5
{% if ei.sync_status == 'closed' %}bg-green-100 text-green-700
{% elif ei.sync_status == 'in_progress' %}bg-amber-100 text-amber-700
{% elif ei.sync_status == 'blocked' %}bg-red-100 text-red-700
{% elif ei.sync_status == 'error' %}bg-red-200 text-red-800
{% else %}bg-slate-100 text-slate-600{% endif %}">
{{ ei.get_sync_status_display }}
</span>
<!-- Letzter Sync -->
{% if ei.letzter_sync %}
<span class="text-xs text-slate-400 ml-auto">Sync: {{ ei.letzter_sync|date:"d.m.Y H:i" }}</span>
{% endif %}
</div>
{% if ei.notizen %}
<p class="text-xs text-slate-500 whitespace-pre-wrap">{{ ei.notizen }}</p>
{% endif %}
{% if push_error %}
<p class="text-xs text-red-600">{{ push_error }}</p>
{% endif %}
<div class="flex gap-2 flex-wrap mt-1">
<!-- Bearbeiten -->
<button class="btn-ghost text-xs"
hx-get="{% url 'ausschreibungen:aufgaben:external_issue' ausschreibung.pk aufgabe.pk %}"
hx-target="#external-issue-panel" hx-swap="innerHTML">Bearbeiten</button>
<!-- Push to Gitea (nur wenn lokal und Gitea konfiguriert) -->
{% if ei.issue_facade_backend == 'local' and gitea_configured %}
<form hx-post="{% url 'ausschreibungen:aufgaben:external_issue_push' ausschreibung.pk aufgabe.pk %}"
hx-target="#external-issue-panel" hx-swap="innerHTML">
{% csrf_token %}
<button type="submit" class="btn-ghost text-xs text-blue-600">↑ Nach Gitea</button>
</form>
{% endif %}
<!-- Status syncen -->
<form hx-post="{% url 'ausschreibungen:aufgaben:external_issue_sync' ausschreibung.pk aufgabe.pk %}"
hx-target="#external-issue-panel" hx-swap="innerHTML">
{% csrf_token %}
<button type="submit" class="btn-ghost text-xs">⟳ Status syncen</button>
</form>
<!-- Entfernen -->
<form hx-post="{% url 'ausschreibungen:aufgaben:external_issue_loeschen' ausschreibung.pk aufgabe.pk %}"
hx-target="#external-issue-panel" hx-swap="innerHTML"
hx-confirm="Externe Verknüpfung entfernen?">
{% csrf_token %}
<button type="submit" class="btn-ghost text-xs text-red-500">Entfernen</button>
</form>
</div>
</div>
{% endwith %}

View File

@@ -0,0 +1,21 @@
<form hx-post="{% url 'ausschreibungen:aufgaben:external_issue' ausschreibung.pk aufgabe.pk %}"
hx-target="#external-issue-panel"
hx-swap="innerHTML"
class="space-y-2">
{% csrf_token %}
<p class="text-xs text-slate-500">Issue wird automatisch im lokalen Backend angelegt.</p>
<div>
<label class="form-label">Notizen</label>
{{ form.notizen }}
</div>
{% if form.errors %}
<p class="text-xs text-red-600">{{ form.errors }}</p>
{% endif %}
<div class="flex gap-2">
<button type="submit" class="btn-primary text-xs">Speichern</button>
<button type="button" class="btn-ghost text-xs"
hx-get="{% url 'ausschreibungen:aufgaben:external_issue_panel' ausschreibung.pk aufgabe.pk %}"
hx-target="#external-issue-panel"
hx-swap="innerHTML">Abbrechen</button>
</div>
</form>

View File

@@ -0,0 +1,10 @@
<p class="text-xs font-medium text-slate-500 uppercase tracking-wide mb-2">Externes Issue</p>
{% if aufgabe.external_issue %}
{% include "aufgaben/partials/external_issue_card.html" %}
{% else %}
<p class="text-sm text-slate-400">Kein externes Issue verknüpft.</p>
<button class="btn-ghost text-xs mt-2"
hx-get="{% url 'ausschreibungen:aufgaben:external_issue' ausschreibung.pk aufgabe.pk %}"
hx-target="#external-issue-panel"
hx-swap="innerHTML">+ Verknüpfen</button>
{% endif %}

View File

@@ -0,0 +1,34 @@
<form hx-post="{% url 'ausschreibungen:aufgaben:verknuepfung_neu' ausschreibung.pk aufgabe.pk %}"
hx-target="#verknuepfungen-list"
hx-swap="beforeend"
hx-on::after-request="if(event.detail.successful) this.reset(); document.getElementById('verknuepfung-form-container').innerHTML = '';"
class="space-y-2 border border-slate-200 rounded p-3 mt-2">
{% csrf_token %}
<div>
<label class="form-label">Typ</label>
<select name="ziel_typ"
class="form-input text-sm"
hx-get="{% url 'ausschreibungen:aufgaben:verknuepfung_objekte' ausschreibung.pk aufgabe.pk %}"
hx-target="#verknuepfung-obj-container"
hx-swap="innerHTML"
hx-trigger="change"
required>
<option value="">— Typ wählen —</option>
{% for ct_id, label in form.fields.ziel_typ.choices %}
{% if ct_id %}<option value="{{ ct_id }}">{{ label }}</option>{% endif %}
{% endfor %}
</select>
</div>
<div id="verknuepfung-obj-container">
<p class="text-xs text-slate-400">Bitte zuerst einen Typ wählen.</p>
</div>
<div>
<label class="form-label">Kommentar</label>
{{ form.kommentar }}
</div>
<div class="flex gap-2">
<button type="submit" class="btn-primary text-xs">Verknüpfen</button>
<button type="button" class="btn-ghost text-xs"
onclick="document.getElementById('verknuepfung-form-container').innerHTML = ''">Abbrechen</button>
</div>
</form>

View File

@@ -0,0 +1,10 @@
{% if objekte %}
<select name="ziel_id" class="form-input text-sm" required>
<option value="">— Objekt wählen —</option>
{% for obj in objekte %}
<option value="{{ obj.pk }}">{{ obj }}</option>
{% endfor %}
</select>
{% else %}
<p class="text-xs text-slate-400 py-1">Keine Objekte verfügbar.</p>
{% endif %}

View File

@@ -0,0 +1,22 @@
<li class="flex items-start gap-2 py-1 text-sm" id="vk-{{ v.pk }}">
<div class="flex-1">
<span class="inline-block text-xs bg-slate-100 text-slate-600 rounded px-1 py-0.5 mr-1">
{{ v.content_type.model }}
</span>
{% if v.ziel %}
<span class="text-slate-800">{{ v.ziel }}</span>
{% else %}
<span class="text-slate-400">Objekt nicht gefunden (ID {{ v.object_id }})</span>
{% endif %}
{% if v.kommentar %}
<p class="text-xs text-slate-500 mt-0.5 ml-1">{{ v.kommentar }}</p>
{% endif %}
</div>
<form hx-post="{% url 'ausschreibungen:aufgaben:verknuepfung_loeschen' ausschreibung.pk aufgabe.pk v.pk %}"
hx-target="closest li"
hx-swap="outerHTML"
hx-confirm="Verknüpfung entfernen?">
{% csrf_token %}
<button type="submit" class="btn-ghost text-xs text-red-500 shrink-0">×</button>
</form>
</li>

View File

@@ -135,6 +135,45 @@
{% endif %}
</div>
<!-- Aufgaben -->
<div class="card mt-6">
<div class="flex items-center justify-between mb-3">
<h2 class="text-sm font-semibold text-slate-700">Aufgaben</h2>
<div class="flex items-center gap-2">
<a href="{% url 'ausschreibungen:aufgaben:liste' ausschreibung.pk %}" class="btn-ghost text-xs">Alle anzeigen</a>
<button class="btn-ghost text-xs"
hx-get="{% url 'ausschreibungen:aufgaben:neu' ausschreibung.pk %}"
hx-target="#aufgaben-form-container"
hx-swap="innerHTML">
+ Aufgabe anlegen
</button>
</div>
</div>
<div id="aufgaben-form-container"></div>
{% if aufgaben %}
<table class="w-full text-sm mt-1">
<thead>
<tr class="border-b border-slate-200 text-left text-xs text-slate-500">
<th class="pb-2 pr-4">Titel</th>
<th class="pb-2 pr-4">Typ</th>
<th class="pb-2 pr-4">Frist</th>
<th class="pb-2 pr-4">Verantwortlicher</th>
<th class="pb-2 pr-4">Status</th>
<th class="pb-2"></th>
</tr>
</thead>
<tbody id="aufgaben-tbody" class="divide-y divide-slate-100">
{% for aufgabe in aufgaben %}
{% include "aufgaben/partials/aufgabe_row.html" %}
{% endfor %}
</tbody>
</table>
{% else %}
<div id="aufgaben-tbody"></div>
<p class="text-sm text-slate-400 mt-1">Noch keine Aufgaben angelegt.</p>
{% endif %}
</div>
<div class="flex gap-3 mt-6">
<a href="{% url 'ausschreibungen:entscheidung' ausschreibung.pk %}" class="btn-ghost text-xs">
Teilnahmeentscheidung

View File

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

View File

@@ -45,6 +45,7 @@
</div>
</div>
<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>

50
wiki/DesignSystem.md Normal file
View File

@@ -0,0 +1,50 @@
# Design System
vergabe-teilnahme nutzt das `whynot-design`-System (gitea
`whynot/whynot-design`) als visuelle Basis.
## Phase 1 — Tokens + CSS (aktiv, ab WP-0017)
- Vendored CSS unter `static/src/vendor/whynot-design/`.
- Sync via `make sync-whynot-design` (Skript: `scripts/sync-whynot-design.sh`).
- Gepinnter Commit steht in `static/src/vendor/whynot-design/.whynot-design-ref`.
- `static/src/main.css` importiert die Vendor-CSS und mappt die whynot-Tokens
in den Tailwind-`@theme`-Block: `bg-ink`, `bg-paper`, `text-ink-3`,
`border-line` usw. sind als Utility-Klassen verfügbar.
- Legacy `bg-brand-*` / `text-brand-*` Utilities sind weiterhin nutzbar; sie
sind als Aliasse auf die ink/paper-Skala gemappt, damit Page-Templates
nicht in einer großen Migration mitgezogen werden müssen.
## Phase 2 — Komponenten (offen)
Adoption der whynot-Komponenten erfolgt sobald upstream Lit Web Components
und die fehlenden Atome (`Card`, `Modal`, `Input`, `Table`, `Toast`)
ausliefert. Eigener Workplan wird zu diesem Zeitpunkt angelegt.
## Lokale Abweichungen vom whynot-System
Dokumentiert direkt in `static/src/main.css`:
- **`.btn-danger`** nutzt ein Off-Spec-Rot (`#B22222`, `--danger`-Variable).
whynot definiert aktuell keine destruktive Farbe; vergabe-Nutzung erfordert
sie für Löschen-Aktionen. Wird zurückgebaut, sobald upstream eine
kanonische Lösung definiert.
## Visuelle Hausregeln aus whynot übernommen
- Mostly Black & White; gelber Akzent (`--hi: #FFE14A`) nur als Highlighter /
Stamp / S4-Signal — nie als Button-Fill oder Hero-Hintergrund.
- 1px-Hairlines (`var(--line)` / `border-line`), großzügiger Weißraum,
Monospace-Eyebrow-Labels.
- Keine Schatten auf Cards; nur Popovers bekommen einen weichen 412px-Shadow.
- 04px Border-Radius für Cards/Sheets; 8px nur für große Modale; Pill nur
für Tag-Capsules.
- IBM Plex Sans / Mono / Serif via Google-Fonts (`@import url(...)` in der
Vendor-CSS). Build-Container und Browser brauchen Internet-Zugriff zu
Google Fonts. Air-gapped Deployment würde self-hosting erfordern.
## Hintergrund
- Strategie-Analyse + Komponenten-Lücken-Inventar:
`history/2026-05-23-whynot-design-cross-framework-analysis.md`.
- Adoption-Workplan: `workplans/WP-0017-whynot-design-tokens.md`.

View File

@@ -4,6 +4,7 @@ title: Projektgerüst — Django-Setup, Tailwind, Dev-Stack
status: done
phase: 1-of-12
created: "2026-05-08"
domain: communication
---
# WP-0001 — Projektgerüst

View File

@@ -5,6 +5,7 @@ status: done
phase: 2-of-12
created: "2026-05-08"
depends_on: WP-0001
domain: communication
---
# WP-0002 — Fachmodelle

View File

@@ -5,6 +5,7 @@ status: done
phase: 3-of-12
created: "2026-05-08"
depends_on: WP-0002
domain: communication
---
# WP-0003 — Basis-UI

View File

@@ -5,6 +5,7 @@ status: done
phase: 4-of-12
created: "2026-05-08"
depends_on: WP-0003
domain: communication
---
# WP-0004 — Dashboard und Ausschreibungen-CRUD

View File

@@ -5,6 +5,7 @@ status: done
phase: 5-of-12
created: "2026-05-08"
depends_on: WP-0004
domain: communication
---
# WP-0005 — Lose und Anforderungen

View File

@@ -5,6 +5,7 @@ status: done
phase: 6-of-12
created: "2026-05-08"
depends_on: WP-0005
domain: communication
---
# WP-0006 — Aufgaben und Bieterfragen

View File

@@ -5,6 +5,7 @@ status: done
phase: 7-of-12
created: "2026-05-08"
depends_on: WP-0006
domain: communication
---
# WP-0007 — Dokumentenmanagement

View File

@@ -5,6 +5,7 @@ status: done
phase: 8-of-12
created: "2026-05-08"
depends_on: WP-0007
domain: communication
---
# WP-0008 — Preise und Marktpreisauswertung

View File

@@ -5,6 +5,7 @@ status: done
phase: 9-of-12
created: "2026-05-08"
depends_on: WP-0008
domain: communication
---
# WP-0009 — Abgabe (Phase 6/7) und Nachbetrachtung (Phase 8)

View File

@@ -5,6 +5,7 @@ status: done
phase: 10-of-12
created: "2026-05-08"
depends_on: WP-0009
domain: communication
---
# WP-0010 — Subunternehmer, Partner und Bibliothek

View File

@@ -5,6 +5,7 @@ status: done
phase: 11-of-12
created: "2026-05-08"
depends_on: WP-0010
domain: communication
---
# WP-0011 — Marktbegleiter-Analyse

View File

@@ -5,6 +5,7 @@ status: done
phase: 12-of-12
created: "2026-05-08"
depends_on: WP-0011
domain: communication
---
# WP-0012 — Querschnitt

View File

@@ -5,6 +5,7 @@ status: done
phase: 13-of-13
created: "2026-05-14"
depends_on: WP-0012
domain: communication
---
# WP-0013 — Feedback-Bugs

View File

@@ -5,6 +5,7 @@ status: done
phase: 14-of-n
created: "2026-05-14"
depends_on: WP-0013
domain: communication
---
# WP-0014 — Aufgaben-Phasenzuordnung und Scores

View File

@@ -0,0 +1,423 @@
---
id: WP-0015
title: Aufgaben — Verknüpfungen, implizite Fälligkeit, Issue-Facade
status: done
phase: 15-of-n
created: "2026-05-14"
depends_on: WP-0014
domain: communication
---
# WP-0015 — Aufgaben: Verknüpfungen, implizite Fälligkeit, Issue-Facade
Drei eigenständige Erweiterungen des Aufgaben-Moduls:
1. **Verknüpfungen**: Jede Aufgabe kann mit beliebigen anderen Entitäten
(Anforderung, Los, Dokument, Bieterfrage, Preispunkt, …) verknüpft
werden — via ContentType/GenericForeignKey. Jede Verknüpfung trägt
einen Kommentar. Die Detailseite der Aufgabe zeigt alle Verknüpfungen
mit HTMX-Inline-Verwaltung (hinzufügen / entfernen).
2. **Implizite Fälligkeit**: Hat eine Aufgabe kein `frist`-Datum, gilt sie
nach 7 Tagen ab Erstellungsdatum als überfällig. Dazu wird `erstellt_am`
auf `Aufgabe` ergänzt und die Überfälligkeitsprüfung angepasst.
3. **Issue-Facade**: Eine optionale Schnittstelle, um eine Aufgabe mit einem
externen Issue-Tracker (GitHub Issues, Jira, Linear, …) zu verknüpfen.
Das Modell `ExternalIssue` hält System, URL/Key und Status. Ein
Service-Interface definiert die Adapter-API für spätere Implementierungen.
UI: Panel auf der Aufgaben-Detailseite zum Hinzufügen / Bearbeiten /
Entfernen der externen Verknüpfung.
---
```task
id: WP-0015-T01
title: Aufgabe.erstellt_am — Feld + Migration
status: done
`apps/aufgaben/models.py` — Feld ergänzen:
```python
erstellt_am = models.DateTimeField(auto_now_add=True)
```
Migration erstellen und ausführen:
```bash
uv run python manage.py makemigrations aufgaben --name erstellt_am
uv run python manage.py migrate
```
Bestehende Rows erhalten automatisch den NOW()-Zeitstempel (Django-Default
für auto_now_add bei ALTER TABLE).
```
```task
id: WP-0015-T02
title: Implizite Fälligkeit — Property + Überfälligkeitsprüfung
status: done
**Modell** (`apps/aufgaben/models.py`):
Property `frist_effektiv` auf `Aufgabe`:
```python
from datetime import timedelta
from django.utils import timezone
@property
def frist_effektiv(self):
if self.frist:
return self.frist
return (self.erstellt_am + timedelta(days=7)).date()
```
`ist_ueberfaellig` auf die neue Property umstellen:
```python
@property
def ist_ueberfaellig(self):
return (
self.frist_effektiv < date.today()
and self.status not in ['erledigt', 'verworfen']
)
```
**View** (`apps/aufgaben/views.py`), `aufgaben_liste`:
Die bestehende Update-Zeile filtert nur nach `frist__lt=heute`. Aufgaben
ohne Frist werden nie als überfällig markiert. Korrektur:
```python
# Aufgaben mit expliziter Frist markieren
qs.filter(
frist__lt=heute,
status__in=AKTIVE_STATUS,
).update(status='ueberfaellig')
# Aufgaben ohne Frist — implizite 7-Tage-Frist
from datetime import timedelta
from django.utils import timezone
implizite_grenze = timezone.now() - timedelta(days=7)
qs.filter(
frist__isnull=True,
erstellt_am__lt=implizite_grenze,
status__in=AKTIVE_STATUS,
).update(status='ueberfaellig')
```
**Template** (`templates/aufgaben/detail.html`): Frist-Zeile ergänzen,
sodass bei leerem `frist` die effektive Frist als "(implizit: TT.MM.JJJJ)"
angezeigt wird:
```html
{% if aufgabe.frist %}
{{ aufgabe.frist }}
{% else %}
<span class="text-slate-400">keine</span>
<span class="text-xs text-amber-600">
(implizit fällig: {{ aufgabe.frist_effektiv|date:"d.m.Y" }})
</span>
{% endif %}
```
```
```task
id: WP-0015-T03
title: AufgabenVerknuepfung — Modell + Migration + Admin
status: done
Neues Modell in `apps/aufgaben/models.py`:
```python
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
class AufgabenVerknuepfung(models.Model):
aufgabe = models.ForeignKey(
Aufgabe, on_delete=models.CASCADE, related_name='verknuepfungen'
)
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
object_id = models.PositiveIntegerField()
ziel = GenericForeignKey('content_type', 'object_id')
kommentar = models.TextField(blank=True)
erstellt_am = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ['erstellt_am']
verbose_name = 'Aufgaben-Verknüpfung'
verbose_name_plural = 'Aufgaben-Verknüpfungen'
def __str__(self):
return f'{self.aufgabe} → {self.content_type} #{self.object_id}'
```
Migration erstellen und ausführen.
`apps/aufgaben/admin.py` — Inline unter `AufgabeAdmin` registrieren.
Hilfsfunktion `verknuepfbare_typen()` in einem neuen Modul
`apps/aufgaben/link_registry.py` — gibt eine geordnete Liste der
zulässigen ContentTypes zurück (Anforderung, Los, Bieterfrage, Dokument,
Preispunkt). Wird in der Form als Auswahlfeld verwendet.
```
```task
id: WP-0015-T04
title: Verknüpfungen-View — Liste + Hinzufügen (HTMX)
status: done
**URLs** (`apps/aufgaben/urls.py`) ergänzen:
```python
path('<int:pk>/verknuepfungen/', views.verknuepfungen_liste, name='verknuepfungen'),
path('<int:pk>/verknuepfungen/neu/', views.verknuepfung_neu, name='verknuepfung_neu'),
```
**Form** `AufgabenVerknuepfungForm` in `apps/aufgaben/forms.py`:
```python
class AufgabenVerknuepfungForm(forms.ModelForm):
# Auswahlfeld für den Ziel-Typ (ContentType)
ziel_typ = forms.ChoiceField(choices=...) # befüllt aus link_registry
ziel_id = forms.IntegerField()
kommentar = forms.CharField(widget=forms.Textarea(...), required=False)
class Meta:
model = AufgabenVerknuepfung
fields = ['kommentar']
```
Nach Auswahl des `ziel_typ` lädt ein zweites HTMX-Request die passenden
Objekte dynamisch nach (zweistufige Auswahl: erst Typ, dann Objekt).
**Views** `verknuepfungen_liste` und `verknuepfung_neu`
analog zu den bestehenden HTMX-Inline-Views (Lose, Aufgaben):
- GET (HTMX) → `aufgaben/partials/verknuepfung_form_inline.html`
- POST (HTMX) → `aufgaben/partials/verknuepfung_row.html`
- Vollseite → Redirect zur Aufgaben-Detailseite
**Template** `templates/aufgaben/detail.html` — neues Panel in der
rechten Spalte unterhalb der bestehenden Sidebar-Cards:
```html
<!-- Verknüpfungen -->
<div class="card">
<div class="flex items-center justify-between mb-2">
<p class="text-xs font-medium text-slate-500 uppercase tracking-wide">Verknüpfungen</p>
<button class="btn-ghost text-xs"
hx-get="{% url 'ausschreibungen:aufgaben:verknuepfung_neu' ausschreibung.pk aufgabe.pk %}"
hx-target="#verknuepfung-form-container"
hx-swap="innerHTML">+ Verknüpfen</button>
</div>
<div id="verknuepfung-form-container"></div>
<ul id="verknuepfungen-list" class="space-y-1 mt-1">
{% for v in aufgabe.verknuepfungen.all %}
{% include "aufgaben/partials/verknuepfung_row.html" %}
{% endfor %}
</ul>
</div>
```
`verknuepfung_row.html` zeigt: Typ-Badge, Link zum Zielobjekt (wenn URL
ermittelbar), Kommentar, Löschen-Button.
```
```task
id: WP-0015-T05
title: Verknüpfungen-View — Entfernen (HTMX DELETE)
status: done
**URL** in `apps/aufgaben/urls.py`:
```python
path('<int:pk>/verknuepfungen/<int:vk_pk>/loeschen/',
views.verknuepfung_loeschen, name='verknuepfung_loeschen'),
```
**View** `verknuepfung_loeschen`:
```python
def verknuepfung_loeschen(request, ausschreibung_id, pk, vk_pk):
aufgabe = get_object_or_404(Aufgabe, pk=pk, ausschreibung_id=ausschreibung_id)
vk = get_object_or_404(AufgabenVerknuepfung, pk=vk_pk, aufgabe=aufgabe)
if request.method == 'POST':
vk.delete()
if _is_htmx(request):
return HttpResponse('') # leere Antwort → HTMX entfernt das Element
return redirect('ausschreibungen:aufgaben:detail', ...)
return render(request, 'aufgaben/verknuepfung_loeschen_confirm.html', {...})
```
Im `verknuepfung_row.html` Löschen-Button als HTMX-POST mit
`hx-confirm` und `hx-target="closest li"` + `hx-swap="outerHTML"`:
```html
<button hx-post="{% url '...verknuepfung_loeschen' ausschreibung.pk aufgabe.pk v.pk %}"
hx-confirm="Verknüpfung entfernen?"
hx-target="closest li"
hx-swap="outerHTML"
class="btn-ghost text-xs text-red-500">Entfernen</button>
```
```
```task
id: WP-0015-T06
title: ExternalIssue — Modell + Migration + Service-Interface
status: done
**Modell** in einem neuen Modul `apps/aufgaben/issue_models.py`
(oder direkt in `models.py`):
```python
class ExternalIssue(models.Model):
SYSTEM_CHOICES = [
('github', 'GitHub Issues'),
('jira', 'Jira'),
('linear', 'Linear'),
('azure', 'Azure DevOps'),
('sonstiges', 'Sonstiges'),
]
SYNC_STATUS_CHOICES = [
('manuell', 'Manuell'),
('offen', 'Offen (extern)'),
('geschlossen', 'Geschlossen (extern)'),
('fehler', 'Sync-Fehler'),
]
aufgabe = models.OneToOneField(
Aufgabe, on_delete=models.CASCADE, related_name='external_issue'
)
system = models.CharField(max_length=20, choices=SYSTEM_CHOICES)
issue_url = models.URLField(blank=True)
issue_key = models.CharField(max_length=100, blank=True,
help_text='z.B. "GH-42" oder "PROJ-1234"')
sync_status = models.CharField(max_length=20, choices=SYNC_STATUS_CHOICES,
default='manuell')
letzter_sync = models.DateTimeField(null=True, blank=True)
notizen = models.TextField(blank=True)
erstellt_am = models.DateTimeField(auto_now_add=True)
class Meta:
verbose_name = 'External Issue'
verbose_name_plural = 'External Issues'
def __str__(self):
return f'{self.get_system_display()} {self.issue_key or self.issue_url}'
```
**Service-Interface** `apps/aufgaben/issue_facade.py`:
```python
from abc import ABC, abstractmethod
class IssueAdapter(ABC):
"""
Adapter-Basisklasse. Jeder externe Issue-Tracker implementiert diese
Schnittstelle. Registrierung via ISSUE_ADAPTERS-Dict in settings.
"""
@abstractmethod
def create_issue(self, aufgabe) -> dict:
"""Legt ein Issue im externen System an. Gibt {'url', 'key'} zurück."""
@abstractmethod
def fetch_status(self, external_issue) -> str:
"""Liest den aktuellen Status aus dem externen System."""
@abstractmethod
def close_issue(self, external_issue) -> None:
"""Schließt das Issue im externen System."""
def get_adapter(system: str) -> IssueAdapter | None:
"""Gibt den registrierten Adapter für `system` zurück, oder None."""
from django.conf import settings
adapters = getattr(settings, 'ISSUE_ADAPTERS', {})
cls = adapters.get(system)
return cls() if cls else None
```
Migration erstellen und ausführen. Admin registrieren.
```
```task
id: WP-0015-T07
title: Issue-Facade UI — Panel auf Aufgaben-Detailseite
status: done
**URLs** in `apps/aufgaben/urls.py`:
```python
path('<int:pk>/issue/', views.external_issue_bearbeiten, name='external_issue'),
path('<int:pk>/issue/loeschen/', views.external_issue_loeschen, name='external_issue_loeschen'),
```
**Form** `ExternalIssueForm` in `forms.py`:
```python
class ExternalIssueForm(forms.ModelForm):
class Meta:
model = ExternalIssue
fields = ['system', 'issue_url', 'issue_key', 'notizen']
widgets = {f: forms.Select/Input(attrs={'class': 'form-input'}) ...}
```
**Views**:
`external_issue_bearbeiten` — GET/POST, erstellt oder aktualisiert das
`ExternalIssue`-Objekt zur Aufgabe (OneToOne: immer genau ein Objekt oder
keins). HTMX: gibt bei Erfolg das Panel-Fragment zurück.
`external_issue_loeschen` — POST, löscht das `ExternalIssue`-Objekt.
**Template** `templates/aufgaben/detail.html` — neues Card-Panel in der
rechten Spalte (unterhalb Verknüpfungen):
```html
<!-- Externes Issue -->
<div class="card" id="external-issue-panel">
<p class="text-xs font-medium text-slate-500 uppercase tracking-wide mb-2">Externes Issue</p>
{% if aufgabe.external_issue %}
{% include "aufgaben/partials/external_issue_card.html" %}
{% else %}
<p class="text-sm text-slate-400">Kein externes Issue verknüpft.</p>
<button class="btn-ghost text-xs mt-2"
hx-get="{% url 'ausschreibungen:aufgaben:external_issue' aufgabe.ausschreibung_id aufgabe.pk %}"
hx-target="#external-issue-panel"
hx-swap="innerHTML">+ Verknüpfen</button>
{% endif %}
</div>
```
Partial `external_issue_card.html` zeigt: System-Badge, Link (issue_url),
Key, Sync-Status, Notizen, Buttons "Bearbeiten" und "Entfernen".
```
```task
id: WP-0015-T08
title: Tests + Smoke-Check
status: done
Bestehende 68 Tests müssen grün bleiben.
Neue Tests in `apps/aufgaben/tests.py`:
- `test_frist_effektiv_mit_frist` — explizite Frist wird zurückgegeben
- `test_frist_effektiv_ohne_frist` — implizite Frist = erstellt_am + 7 Tage
- `test_ueberfaellig_ohne_frist_nach_7_tagen` — Aufgabe ohne Frist wird
nach 7 Tagen als überfällig eingestuft
- `test_aufgaben_verknuepfung_erstellen` — Verknüpfung anlegen via View
- `test_aufgaben_verknuepfung_loeschen` — Verknüpfung entfernen via View
- `test_external_issue_erstellen` — ExternalIssue via View anlegen
- `test_external_issue_loeschen` — ExternalIssue via View entfernen
- `test_issue_adapter_interface` — get_adapter gibt None zurück wenn kein
Adapter registriert ist
```bash
uv run pytest vergabe_teilnahme/apps/aufgaben/tests.py -v
uv run pytest vergabe_teilnahme/ -q # alle Tests
```
```

View File

@@ -0,0 +1,575 @@
---
id: WP-0016
title: Issue-Facade Integration — lokale Aufgabenverfolgung + Remote-Delegation
status: done
phase: 16-of-n
created: "2026-05-14"
depends_on: WP-0015
domain: communication
---
# WP-0016 — Issue-Facade Integration
Ersetzt den Platzhalter-`issue_facade.py` aus WP-0015 durch eine echte
Integration mit dem Sister-Projekt `issue-facade` (`/home/worsch/issue-facade`,
Package-Name `universal-issue-tracker`).
**Leitgedanke:** Aufgaben werden zunächst lokal im SQLite-Backend von
`issue-facade` verwaltet (offline-fähig, keine externe Abhängigkeit). Optional
können sie in ein entferntes System (zunächst Gitea) delegiert werden. Der
Status wird von dort zurückgelesen.
**Key interfaces aus dem Sister-Projekt:**
- `issue_tracker.core.models.Issue``id` (UUID), `number`, `title`,
`description`, `state` (IssueState: open/closed/in_progress/blocked)
- `issue_tracker.core.interfaces.IssueBackend` ABC — `create_issue()`,
`get_issue()`, `update_issue()`, `list_issues()`
- `issue_tracker.backends.local.LocalSQLiteBackend`
`connect({'db_path': ...})`
- `issue_tracker.backends.gitea.GiteaBackend`
`connect({'base_url', 'token', 'owner', 'repo'})`
---
```task
id: WP-0016-T01
title: Package-Installation + Django-Settings
status: done
**`pyproject.toml`** — Dependency ergänzen:
```toml
[project]
dependencies = [
...
"universal-issue-tracker @ file:///home/worsch/issue-facade",
]
```
Danach:
```bash
uv sync
```
**`vergabe_teilnahme/settings/base.py`** — neue Optionen am Ende:
```python
# Issue Facade — lokales SQLite-Backend (immer aktiv)
ISSUE_FACADE_LOCAL_DB = BASE_DIR / '.issue-facade' / 'issues.db'
# Issue Facade — Gitea-Remote (optional, None = deaktiviert)
ISSUE_FACADE_GITEA: dict | None = None
# Beispiel:
# ISSUE_FACADE_GITEA = {
# 'base_url': 'https://gitea.example.com',
# 'token': env('GITEA_TOKEN', default=''),
# 'owner': 'org',
# 'repo': 'vergabe',
# }
```
**`.gitignore`** — SQLite-DB ausschließen:
```
.issue-facade/
```
Smoke-Check:
```bash
uv run python -c "from issue_tracker.backends.local import LocalSQLiteBackend; print('ok')"
```
```
```task
id: WP-0016-T02
title: Backend-Utility — issue_backends.py
status: done
Neues Modul `vergabe_teilnahme/apps/aufgaben/issue_backends.py`:
```python
from contextlib import contextmanager
from pathlib import Path
@contextmanager
def local_backend():
from django.conf import settings
from issue_tracker.backends.local import LocalSQLiteBackend
db_path = str(getattr(settings, 'ISSUE_FACADE_LOCAL_DB', '.issue-facade/issues.db'))
Path(db_path).parent.mkdir(parents=True, exist_ok=True)
b = LocalSQLiteBackend()
b.connect({'db_path': db_path})
try:
yield b
finally:
b.disconnect()
@contextmanager
def remote_backend():
"""Yields GiteaBackend wenn konfiguriert, sonst None."""
from django.conf import settings
from issue_tracker.backends.gitea import GiteaBackend
cfg = getattr(settings, 'ISSUE_FACADE_GITEA', None)
if not cfg:
yield None
return
b = GiteaBackend()
b.connect(cfg)
try:
yield b
finally:
b.disconnect()
def gitea_configured() -> bool:
from django.conf import settings
cfg = getattr(settings, 'ISSUE_FACADE_GITEA', None)
return bool(cfg and cfg.get('token'))
```
Kontext-Manager-Pattern (kein Prozess-globaler Singleton, SQLite ist
pro Request cheap genug für eine Einzelbenutzer-App).
```
```task
id: WP-0016-T03
title: ExternalIssue-Modell erweitern + Migration
status: done
In `apps/aufgaben/models.py` das Modell `ExternalIssue` anpassen:
```python
class ExternalIssue(models.Model):
BACKEND_CHOICES = [
('local', 'Lokal (SQLite)'),
('gitea', 'Gitea'),
]
SYNC_STATUS_CHOICES = [
('open', 'Offen'),
('in_progress', 'In Bearbeitung'),
('blocked', 'Blockiert'),
('closed', 'Geschlossen'),
('error', 'Sync-Fehler'),
]
aufgabe = models.OneToOneField(
Aufgabe, on_delete=models.CASCADE, related_name='external_issue'
)
issue_facade_backend = models.CharField(
max_length=20, choices=BACKEND_CHOICES, default='local'
)
issue_facade_id = models.CharField(
max_length=200, blank=True,
help_text='UUID (lokal) oder Issue-Number (Gitea)'
)
issue_url = models.URLField(blank=True)
issue_key = models.CharField(max_length=100, blank=True,
help_text='z.B. "#42" oder "PROJ-1234"')
sync_status = models.CharField(
max_length=20, choices=SYNC_STATUS_CHOICES, default='open'
)
letzter_sync = models.DateTimeField(null=True, blank=True)
notizen = models.TextField(blank=True)
erstellt_am = models.DateTimeField(auto_now_add=True)
class Meta:
verbose_name = 'External Issue'
verbose_name_plural = 'External Issues'
def __str__(self):
return f'{self.get_issue_facade_backend_display()} #{self.issue_key or self.issue_facade_id[:8]}'
```
**Änderungen vs. WP-0015:**
- `system` (github/jira/linear/…) entfällt — ersetzt durch `issue_facade_backend`
- `issue_facade_id` neu — UUID für lokale Issues, Issue-Number-String für Gitea
- `sync_status`-Choices auf IssueState-Werte ausgerichtet
Migration erstellen und ausführen:
```bash
uv run python manage.py makemigrations aufgaben --name external_issue_redesign
uv run python manage.py migrate
```
```
```task
id: WP-0016-T04
title: Service-Schicht — issue_facade.py ersetzen
status: done
`vergabe_teilnahme/apps/aufgaben/issue_facade.py` **komplett ersetzen**:
```python
from datetime import datetime, timezone
from issue_tracker.core.models import (
Issue, IssueState, IssueType, Label, Priority
)
from .issue_backends import gitea_configured, local_backend, remote_backend
def aufgabe_zu_issue(aufgabe) -> Issue:
"""Konvertiert eine Aufgabe in ein issue-facade Issue-Objekt."""
prioritaet_map = {1: Priority.HIGH, 2: Priority.MEDIUM, 3: Priority.LOW}
labels = [
Label(name='task'),
Label(name=f'priority:{prioritaet_map.get(aufgabe.prioritaet, Priority.MEDIUM).value}'),
]
if aufgabe.typ:
labels.append(Label(name=aufgabe.typ))
now = datetime.now(timezone.utc)
return Issue(
id=None, number=0,
title=aufgabe.titel,
description=aufgabe.beschreibung or '',
state=IssueState.OPEN,
created_at=now,
updated_at=now,
labels=labels,
)
def lokales_issue_erstellen(aufgabe) -> dict:
"""
Legt ein Issue im lokalen SQLite-Backend an.
Gibt {'issue_facade_id', 'issue_key', 'sync_status'} zurück.
"""
issue = aufgabe_zu_issue(aufgabe)
with local_backend() as b:
created = b.create_issue(issue)
return {
'issue_facade_id': created.id,
'issue_key': f'#{created.number}',
'sync_status': created.state.value,
}
def an_gitea_delegieren(aufgabe, external_issue) -> dict:
"""
Schiebt ein lokales Issue nach Gitea.
Gibt {'issue_facade_id', 'issue_url', 'issue_key', 'sync_status'} zurück.
Wirft ValueError wenn Gitea nicht konfiguriert ist.
"""
with remote_backend() as b:
if b is None:
raise ValueError('Gitea nicht konfiguriert (ISSUE_FACADE_GITEA fehlt in settings)')
issue = aufgabe_zu_issue(aufgabe)
created = b.create_issue(issue)
return {
'issue_facade_backend': 'gitea',
'issue_facade_id': str(created.number),
'issue_url': created.sync_metadata.get('url', '') if created.sync_metadata else '',
'issue_key': f'#{created.number}',
'sync_status': created.state.value,
}
def status_synchronisieren(external_issue) -> str:
"""
Liest den aktuellen Status aus dem konfigurierten Backend.
Gibt den neuen sync_status-String zurück.
"""
from django.utils import timezone as dj_tz
backend_ctx = (
remote_backend() if external_issue.issue_facade_backend == 'gitea'
else local_backend()
)
with backend_ctx as b:
if b is None:
raise ValueError('Backend nicht verfügbar')
issue = b.get_issue(external_issue.issue_facade_id)
neuer_status = issue.state.value if issue else 'error'
external_issue.sync_status = neuer_status
external_issue.letzter_sync = dj_tz.now()
external_issue.save(update_fields=['sync_status', 'letzter_sync'])
return neuer_status
def issue_schliessen(external_issue) -> None:
"""Setzt das Issue im Backend auf CLOSED."""
backend_ctx = (
remote_backend() if external_issue.issue_facade_backend == 'gitea'
else local_backend()
)
with backend_ctx as b:
if b is None:
return
issue = b.get_issue(external_issue.issue_facade_id)
if issue:
issue.state = IssueState.CLOSED
b.update_issue(issue)
external_issue.sync_status = 'closed'
external_issue.save(update_fields=['sync_status'])
def get_adapter(system: str):
"""Rückwärtskompatible Stub — gibt None zurück (kein manuelles Adapter-Dict mehr)."""
return None
```
Der ABC `IssueAdapter` aus WP-0015 entfällt damit vollständig —
`issue-facade` übernimmt diese Rolle.
```
```task
id: WP-0016-T05
title: ExternalIssueForm anpassen + Views verkabeln
status: done
**Form** (`forms.py`) — vereinfacht, da `system` entfällt:
```python
class ExternalIssueForm(forms.ModelForm):
class Meta:
model = ExternalIssue
fields = ['notizen']
widgets = {
'notizen': forms.Textarea(attrs={'class': 'form-input', 'rows': 2,
'placeholder': 'Notizen (optional)'}),
}
```
(Backend und issue_facade_id werden vom Service gesetzt, nicht vom User.)
**`views.py`** — `external_issue_bearbeiten` anpassen:
```python
def external_issue_bearbeiten(request, ausschreibung_id, pk):
from .issue_facade import lokales_issue_erstellen
from .forms import ExternalIssueForm
ausschreibung = get_object_or_404(Ausschreibung, pk=ausschreibung_id)
aufgabe = get_object_or_404(Aufgabe, pk=pk, ausschreibung=ausschreibung)
issue = getattr(aufgabe, 'external_issue', None)
if request.method == 'POST':
form = ExternalIssueForm(request.POST, instance=issue)
if form.is_valid():
ei = form.save(commit=False)
ei.aufgabe = aufgabe
if not issue: # Neues Issue — im lokalen Backend anlegen
daten = lokales_issue_erstellen(aufgabe)
ei.issue_facade_backend = 'local'
ei.issue_facade_id = daten['issue_facade_id']
ei.issue_key = daten['issue_key']
ei.sync_status = daten['sync_status']
ei.save()
if _is_htmx(request):
return render(request,
'aufgaben/partials/external_issue_panel.html',
{'aufgabe': aufgabe, 'ausschreibung': ausschreibung})
return redirect('ausschreibungen:aufgaben:detail',
ausschreibung_id=ausschreibung_id, pk=pk)
else:
form = ExternalIssueForm(instance=issue)
if _is_htmx(request):
return render(request, 'aufgaben/partials/external_issue_form.html',
{'form': form, 'aufgabe': aufgabe, 'ausschreibung': ausschreibung})
return redirect('ausschreibungen:aufgaben:detail',
ausschreibung_id=ausschreibung_id, pk=pk)
```
Zwei neue Views ergänzen:
```python
def external_issue_push_remote(request, ausschreibung_id, pk):
"""Schiebt ein lokales Issue nach Gitea."""
from .issue_facade import an_gitea_delegieren
ausschreibung = get_object_or_404(Ausschreibung, pk=ausschreibung_id)
aufgabe = get_object_or_404(Aufgabe, pk=pk, ausschreibung=ausschreibung)
ei = get_object_or_404(ExternalIssue, aufgabe=aufgabe)
if request.method == 'POST':
try:
daten = an_gitea_delegieren(aufgabe, ei)
for k, v in daten.items():
setattr(ei, k, v)
ei.save()
except ValueError as exc:
# Gitea nicht konfiguriert — ignorieren, Fehlermeldung im Panel
pass
if _is_htmx(request):
return render(request,
'aufgaben/partials/external_issue_panel.html',
{'aufgabe': aufgabe, 'ausschreibung': ausschreibung,
'push_error': str(exc) if 'exc' in dir() else None})
return redirect('ausschreibungen:aufgaben:detail',
ausschreibung_id=ausschreibung_id, pk=pk)
def external_issue_sync(request, ausschreibung_id, pk):
"""Aktualisiert sync_status aus dem Backend."""
from .issue_facade import status_synchronisieren
ausschreibung = get_object_or_404(Ausschreibung, pk=ausschreibung_id)
aufgabe = get_object_or_404(Aufgabe, pk=pk, ausschreibung=ausschreibung)
ei = get_object_or_404(ExternalIssue, aufgabe=aufgabe)
if request.method == 'POST':
try:
status_synchronisieren(ei)
except Exception:
ei.sync_status = 'error'
ei.save(update_fields=['sync_status'])
if _is_htmx(request):
return render(request,
'aufgaben/partials/external_issue_panel.html',
{'aufgabe': aufgabe, 'ausschreibung': ausschreibung})
return redirect('ausschreibungen:aufgaben:detail',
ausschreibung_id=ausschreibung_id, pk=pk)
```
URLs in `urls.py` ergänzen:
```python
path('<int:pk>/issue/push/', views.external_issue_push_remote, name='external_issue_push'),
path('<int:pk>/issue/sync/', views.external_issue_sync, name='external_issue_sync'),
```
```
```task
id: WP-0016-T06
title: Admin + ExternalIssueAdmin aktualisieren
status: done
`apps/aufgaben/admin.py` — `ExternalIssueAdmin` an neue Felder anpassen:
```python
@admin.register(ExternalIssue)
class ExternalIssueAdmin(admin.ModelAdmin):
list_display = ['aufgabe', 'issue_facade_backend', 'issue_key',
'sync_status', 'letzter_sync']
list_filter = ['issue_facade_backend', 'sync_status']
readonly_fields = ['issue_facade_id', 'issue_key', 'issue_url',
'sync_status', 'letzter_sync', 'erstellt_am']
```
```
```task
id: WP-0016-T07
title: UI — ExternalIssue-Panel aktualisieren
status: done
`templates/aufgaben/partials/external_issue_card.html` überarbeiten:
```html
{% with ei=aufgabe.external_issue %}
<div class="space-y-2 text-sm">
<div class="flex items-center gap-2 flex-wrap">
<!-- Backend-Badge -->
{% if ei.issue_facade_backend == 'local' %}
<span class="text-xs bg-slate-100 text-slate-600 rounded px-1.5 py-0.5 font-medium">LOKAL</span>
{% else %}
<span class="text-xs bg-blue-100 text-blue-700 rounded px-1.5 py-0.5 font-medium">GITEA</span>
{% endif %}
<!-- Issue-Key -->
{% if ei.issue_url %}
<a href="{{ ei.issue_url }}" target="_blank" rel="noopener"
class="text-xs font-mono text-blue-600 hover:underline">{{ ei.issue_key }}</a>
{% else %}
<span class="text-xs font-mono text-slate-600">{{ ei.issue_key }}</span>
{% endif %}
<!-- Status-Badge -->
<span class="text-xs rounded px-1.5 py-0.5
{% if ei.sync_status == 'closed' %}bg-green-100 text-green-700
{% elif ei.sync_status == 'in_progress' %}bg-amber-100 text-amber-700
{% elif ei.sync_status == 'blocked' %}bg-red-100 text-red-700
{% elif ei.sync_status == 'error' %}bg-red-200 text-red-800
{% else %}bg-slate-100 text-slate-600{% endif %}">
{{ ei.get_sync_status_display }}
</span>
<!-- Letzter Sync -->
{% if ei.letzter_sync %}
<span class="text-xs text-slate-400 ml-auto">Sync: {{ ei.letzter_sync|date:"d.m.Y H:i" }}</span>
{% endif %}
</div>
{% if ei.notizen %}
<p class="text-xs text-slate-500 whitespace-pre-wrap">{{ ei.notizen }}</p>
{% endif %}
<div class="flex gap-2 flex-wrap mt-1">
<!-- Bearbeiten -->
<button class="btn-ghost text-xs"
hx-get="{% url 'ausschreibungen:aufgaben:external_issue' ausschreibung.pk aufgabe.pk %}"
hx-target="#external-issue-panel" hx-swap="innerHTML">Bearbeiten</button>
<!-- Push to Gitea (nur wenn lokal und Gitea konfiguriert) -->
{% if ei.issue_facade_backend == 'local' and gitea_configured %}
<form hx-post="{% url 'ausschreibungen:aufgaben:external_issue_push' ausschreibung.pk aufgabe.pk %}"
hx-target="#external-issue-panel" hx-swap="innerHTML">
{% csrf_token %}
<button type="submit" class="btn-ghost text-xs text-blue-600">↑ Nach Gitea</button>
</form>
{% endif %}
<!-- Status syncen -->
<form hx-post="{% url 'ausschreibungen:aufgaben:external_issue_sync' ausschreibung.pk aufgabe.pk %}"
hx-target="#external-issue-panel" hx-swap="innerHTML">
{% csrf_token %}
<button type="submit" class="btn-ghost text-xs">⟳ Status syncen</button>
</form>
<!-- Entfernen -->
<form hx-post="{% url 'ausschreibungen:aufgaben:external_issue_loeschen' ausschreibung.pk aufgabe.pk %}"
hx-target="#external-issue-panel" hx-swap="innerHTML"
hx-confirm="Externe Verknüpfung entfernen?">
{% csrf_token %}
<button type="submit" class="btn-ghost text-xs text-red-500">Entfernen</button>
</form>
</div>
</div>
{% endwith %}
```
`gitea_configured` im Template benötigt einen Context-Processor oder muss im
View als Kontext-Variable übergeben werden. Einfachste Lösung: in
`external_issue_panel.html` und den Views direkt via
`from .issue_backends import gitea_configured` hinzufügen.
`external_issue_form.html` vereinfachen — nur noch Notizen-Feld + Hinweis
"Issue wird automatisch im lokalen Backend angelegt".
```
```task
id: WP-0016-T08
title: Tests + Smoke-Check
status: done
Alle 76 bestehenden Tests müssen grün bleiben.
Neue Tests in `apps/aufgaben/tests.py` — lokales Backend per tmp-Datei:
```python
@pytest.fixture
def tmp_issue_db(tmp_path, settings):
settings.ISSUE_FACADE_LOCAL_DB = tmp_path / 'test_issues.db'
settings.ISSUE_FACADE_GITEA = None
return settings.ISSUE_FACADE_LOCAL_DB
```
- `test_aufgabe_zu_issue_mapping` — title, description, priority-Label korrekt
- `test_lokales_issue_erstellen` — ExternalIssue bekommt issue_facade_id + issue_key
- `test_status_synchronisieren` — sync_status wird aktualisiert
- `test_external_issue_bearbeiten_view_erstellt_issue` — POST → Issue in DB
- `test_external_issue_sync_view` — POST → sync_status geändert
- `test_get_adapter_stub` — gibt None zurück (Rückwärtskompatibilität)
```bash
uv run pytest vergabe_teilnahme/apps/aufgaben/tests.py -v
uv run pytest vergabe_teilnahme/ -q # alle Tests
```
```

View File

@@ -0,0 +1,399 @@
---
id: WP-0017
title: whynot-design Adoption — Phase 1 (Tokens + CSS)
status: finished
phase: 17-of-n
created: "2026-05-23"
depends_on: WP-0016
domain: communication
---
# WP-0017 — whynot-design Adoption · Phase 1 (Tokens + CSS)
Übernahme des `whynot-design`-Systems
(`~/whynot-design`, gitea `whynot/whynot-design`) in vergabe-teilnahme in
**Phase 1: Tokens + CSS-Variablen**. Keine Komponenten-Portierung. Bestehende
Django-Partials und Tailwind-Komponentenklassen bleiben markup-seitig unverändert
und werden durch den CSS-Variablen-Tausch automatisch retoniert.
**Strategiebeschluss vom 2026-05-23:** zweistufige Adoption.
- Phase 1 (dieser Workplan): nur Tokens + CSS. whynot-spezifische Komponenten
(Lit Web Components) sind upstream in Arbeit und werden separat adoptiert.
- Phase 2 (eigener Workplan, kommt später): Komponenten-Adoption sobald
whynot-design die fehlenden Atome (`Card`, `Modal`, `Input`, `Table`, `Toast`)
als Lit Web Components ausliefert.
**Hintergrund:** ausführliche Analyse der Strategie-Optionen, Komponenten-
Inventar und Lücken-Liste in
`history/2026-05-23-whynot-design-cross-framework-analysis.md`.
**Designentscheidungen für Phase 1:**
- Distribution: **Vendoring** über Sync-Skript (kein npm/gitea SSH im Docker-Build).
- Rollout: **Big-Bang** — eine PR ersetzt die komplette `brand-*`-Palette
vergabe-weit. Markup-Änderungen sind nicht erforderlich.
- `btn-danger`: **Off-Spec-Rot** behalten (lokale `--danger`-Variable), bis
whynot-design eine kanonische Lösung definiert.
**Pinned upstream:** commit `9419f166ce395858f55b10a5c72268a1fe9fc9d2`
(Stand 2026-05-23; einziger Commit im whynot-design-Repo).
---
```task
id: WP-0017-T01
title: Vendor-Sync-Skript + initiale Vendor-Übernahme
status: done
Ziel: deterministisches Pull der whynot-design CSS-/Token-Quellen aus einem
gepinnten Commit nach `static/src/vendor/whynot-design/`, ohne Docker-Build
SSH-Zugang zu gitea zu geben.
**`scripts/sync-whynot-design.sh`** — neu anlegen:
```bash
#!/usr/bin/env bash
# Synchronisiert die Vendor-Kopie des whynot-Design-Systems aus einem gepinnten
# Upstream-Commit. Quelle: ~/whynot-design (Worktree) oder Klone aus gitea.
#
# Aufruf: ./scripts/sync-whynot-design.sh [<commit-or-ref>]
# Default: liest .whynot-design-ref aus dem Vendor-Verzeichnis.
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
VENDOR_DIR="$ROOT/static/src/vendor/whynot-design"
REF_FILE="$VENDOR_DIR/.whynot-design-ref"
SRC_REPO="${WHYNOT_DESIGN_SRC:-$HOME/whynot-design}"
REF="${1:-}"
if [[ -z "$REF" && -f "$REF_FILE" ]]; then
REF="$(cat "$REF_FILE")"
fi
if [[ -z "$REF" ]]; then
echo "Usage: $0 <commit-or-ref> (or write a ref to $REF_FILE)" >&2
exit 2
fi
if [[ ! -d "$SRC_REPO/.git" ]]; then
echo "Quelle nicht gefunden: $SRC_REPO" >&2
echo "Setze WHYNOT_DESIGN_SRC oder klone gitea:whynot/whynot-design dorthin." >&2
exit 1
fi
mkdir -p "$VENDOR_DIR/tokens"
git -C "$SRC_REPO" show "$REF:src/styles/colors_and_type.css" \
> "$VENDOR_DIR/colors_and_type.css"
for f in colors.json type.json spacing.json index.json; do
git -C "$SRC_REPO" show "$REF:tokens/$f" > "$VENDOR_DIR/tokens/$f"
done
git -C "$SRC_REPO" rev-parse "$REF" > "$REF_FILE"
echo "Vendor synced → $VENDOR_DIR (ref: $(cat "$REF_FILE"))"
```
Ausführbar machen + initial syncen:
```bash
chmod +x scripts/sync-whynot-design.sh
./scripts/sync-whynot-design.sh 9419f166ce395858f55b10a5c72268a1fe9fc9d2
```
Erwartetes Resultat: `static/src/vendor/whynot-design/colors_and_type.css`,
`tokens/*.json`, `.whynot-design-ref` mit dem Commit-Hash.
**`Makefile`** — Target ergänzen:
```make
.PHONY: sync-whynot-design
sync-whynot-design:
./scripts/sync-whynot-design.sh
```
Commit-Hygiene: der Vendor-Inhalt wird **eingecheckt** (kein `.gitignore`).
Diffs gegen den Vendor sind Teil des Review-Surfaces beim nächsten Bump.
```
```task
id: WP-0017-T02
title: CSS-Integration in static/src/main.css
status: done
Ziel: whynot-Tokens werden global verfügbar, Tailwind-`@theme`-Mapping
exponiert sie als Utility-Klassen, vergabe-spezifisches Brand-Blau entfällt.
**`static/src/main.css`** — vollständig ersetzen (Reihenfolge ist wichtig:
whynot-CSS vor Tailwind, damit `@import url(...)` für IBM Plex am Anfang der
generierten Datei landet):
```css
/* whynot-design Tokens & semantische Element-Styles (gepinnt via
scripts/sync-whynot-design.sh; siehe .whynot-design-ref). */
@import "./vendor/whynot-design/colors_and_type.css";
@import "tailwindcss";
/* Tailwind v4 scannt diese Pfade für Utility-Klassen. Muss synchron mit
der Dockerfile `assets`-Stage bleiben. */
@source "../../vergabe_teilnahme/templates";
/* whynot-Tokens → Tailwind-Theme.
Erlaubt: bg-paper, text-ink, border-line, bg-paper-2, text-ink-3, … */
@theme {
--color-ink: var(--ink);
--color-ink-2: var(--ink-2);
--color-ink-3: var(--ink-3);
--color-ink-4: var(--ink-4);
--color-ink-5: var(--ink-5);
--color-line: var(--line);
--color-line-strong: var(--line-strong);
--color-line-soft: var(--line-soft);
--color-paper: var(--paper);
--color-paper-2: var(--paper-2);
--color-paper-3: var(--paper-3);
--color-hi: var(--hi);
--color-hi-2: var(--hi-2);
--color-hi-ink: var(--hi-ink);
}
/* Off-Spec — vergabe-lokal, bis whynot-design eine kanonische destruktive
Farbe definiert. Siehe history/2026-05-23-whynot-design-cross-framework-analysis.md
§4 "btn-danger". */
:root {
--danger: #B22222;
--danger-fg: #FFFFFF;
}
@layer base {
html {
font-family: var(--ff-sans, ui-sans-serif), system-ui, sans-serif;
}
}
@layer components {
/* Karten / Sheets — whynot: ohne Shadow, hairline border */
.card { @apply bg-paper rounded border border-line p-6; }
/* Buttons — whynot: 3 Varianten + off-spec danger */
.btn-primary { @apply bg-ink text-paper px-4 py-2 rounded hover:bg-ink-2 transition-colors; }
.btn-secondary { @apply bg-paper text-ink border border-line px-4 py-2 rounded hover:bg-paper-2 transition-colors; }
.btn-ghost { @apply text-ink-3 px-3 py-2 rounded hover:bg-paper-2; }
.btn-danger { background: var(--danger); color: var(--danger-fg); @apply px-4 py-2 rounded transition-colors; }
.btn-danger:hover { filter: brightness(0.92); }
/* Field-Row — Label/Value Grid */
.field-row { @apply grid grid-cols-3 gap-4 py-3 border-b border-line-soft last:border-0; }
.field-label { @apply text-sm font-medium text-ink-3 col-span-1; }
.field-value { @apply text-sm text-ink col-span-2; }
/* Phasen-Indikatoren — vergabe-Semantik (todo/active/done/warn), in
whynot-Palette übersetzt. `phase-warn` nutzt `--hi` (annotation yellow). */
.phase-badge { @apply inline-flex items-center justify-center w-7 h-7 rounded-full text-sm font-bold; }
.phase-todo { @apply inline-flex items-center justify-center w-7 h-7 rounded-full text-sm font-bold bg-paper-3 text-ink-4; }
.phase-active { @apply inline-flex items-center justify-center w-7 h-7 rounded-full text-sm font-bold bg-ink text-paper; }
.phase-done { @apply inline-flex items-center justify-center w-7 h-7 rounded-full text-sm font-bold bg-ink-3 text-paper; }
.phase-warn { background: var(--hi); color: var(--hi-ink); @apply inline-flex items-center justify-center w-7 h-7 rounded-full text-sm font-bold; }
/* Titel / Sektionen */
.section-title { @apply text-base font-semibold text-ink mb-4; }
.page-title { @apply text-2xl font-medium text-ink tracking-tight; }
/* Formulare */
.form-input { @apply w-full rounded border border-line px-3 py-2 text-sm bg-paper focus:outline-none focus:border-ink transition-colors; }
.form-label { @apply block text-sm font-medium text-ink-2 mb-1; }
/* Tabellen */
.table-base { @apply w-full text-sm text-left; }
.table-header { @apply bg-paper-2 text-ink-3 font-medium text-xs uppercase tracking-wide; }
.table-row { @apply border-t border-line-soft hover:bg-paper-2 transition-colors; }
/* Sidebar */
.sidebar-link { @apply flex items-center px-3 py-2 rounded text-sm text-ink-2 hover:bg-paper-2 transition-colors; }
.sidebar-link-active { @apply bg-paper text-ink font-medium; box-shadow: inset 0 0 0 1px var(--line); }
.sidebar-section-btn { @apply w-full flex items-center justify-between px-3 py-2 text-xs font-semibold text-ink-4 uppercase tracking-wide hover:text-ink-2; }
}
```
**Kommentar zu Border-Radii:** whynot-Hausregel verlangt 04px für Cards/Sheets,
8px nur für große Modale. Tailwind `rounded` (= 4px) erfüllt das. Keine
`rounded-xl`/`rounded-lg` mehr in den Komponenten-Klassen.
**Kommentar zu Shadows:** whynot-Regel "no shadows on cards". `shadow-sm` wurde
aus `.card` entfernt; Sidebar-Active nutzt `inset` border statt Schatten.
**Kommentar zu Fonts:** `colors_and_type.css` zieht IBM Plex via
`@import url("https://fonts.googleapis.com/...")`. Build-Container und Browser
brauchen Internet-Zugriff zu Google Fonts. Für air-gapped Deployment muss das
später durch self-hosting ersetzt werden — ist heute nicht relevant
(coulombcore k3s hat Internet-Egress).
```
```task
id: WP-0017-T03
title: Base-Template — Body-Hintergrund auf whynot-Palette
status: done
**`vergabe_teilnahme/templates/base.html`** — Body-Klasse anpassen:
```diff
- <body class="bg-slate-50 min-h-screen">
+ <body class="bg-paper-2 min-h-screen text-ink">
```
Damit der Default-Text vom whynot-Ink-Ton kommt und der Canvas auf Sheet-Beige
(`#FAFAF7`) liegt, nicht auf Slate-Blau-50.
Keine weiteren Markup-Änderungen in Phase 1. Alle `bg-slate-*`/`text-slate-*`
Utilities in den Page-Templates werden in T05 (Smoke-Test) gesichtet und nur
dann gepatcht, wenn sie visuell brechen — Tailwinds Slate-Skala existiert
weiterhin parallel zur whynot-Palette.
```
```task
id: WP-0017-T04
title: Build + Static-Asset-Prüfung
status: done
Lokaler Build:
```bash
npm ci
npm run build
ls -lh static/dist/main.css
```
Erwartet: `main.css` enthält die whynot-CSS-Variablen am Anfang, gefolgt von
generierten Tailwind-Utilities. Größe steigt moderat (whynot-CSS ≈ 8 KB
unkompressed).
**Sanity-Check** der generierten Utilities:
```bash
grep -c "bg-ink\|bg-paper\|text-ink\|border-line" static/dist/main.css
# Erwartet: > 0 (Beweis, dass @theme-Mapping aktiv ist)
grep -c "color-brand-500\|3b5bdb" static/dist/main.css
# Erwartet: 0 (Alt-Palette komplett raus)
```
Falls eine vergabe-Template-Datei noch `bg-brand-*` o.ä. nutzt: in T05
adressieren (visueller Bruch wird dort sichtbar).
```
```task
id: WP-0017-T05
title: Big-Bang Smoke-Test — visueller Durchlauf aller Hauptseiten
status: done
Dev-Server starten und durch die wichtigsten Views klicken. Bei jedem visuellen
Bruch (Kontrast, weiße Schrift auf weißem Grund, harte Farb-Fremdkörper) eine
kurze Notiz machen und im selben Task patchen.
```bash
docker-compose -f docker-compose.dev.yml up -d
# oder: uv run python manage.py runserver
```
Zu prüfende Views (Mindestliste — Anordnung folgt der Sidebar):
- [ ] Dashboard / Ausschreibungs-Übersicht
- [ ] Ausschreibung Detail (Phasen-Nav sichtbar)
- [ ] Lose-Liste + Detail
- [ ] Aufgaben-Liste
- [ ] Dokumente-Übersicht
- [ ] Preise-Auswertung
- [ ] Partner-Bibliothek
- [ ] Marktbegleiter
- [ ] Nachbetrachtung
- [ ] Feedback-Modal (Trigger via Bug-Button)
- [ ] Freigabe-Modal (auf einem freigabefähigen Objekt)
- [ ] Search-Results (HTMX-Suche in der Top-Bar)
**Typische Brüche, die zu erwarten sind:**
1. Direkt im Template inline gesetzte `bg-blue-*`, `text-blue-*`, `bg-brand-*`
in den whynot-Äquivalenten ersetzen (`bg-ink`, `text-ink`, `bg-hi`).
2. `shadow-sm`/`shadow-md` auf Cards — entfernen (whynot-Regel: keine Shadows
auf Cards, nur auf Popovers).
3. `rounded-lg`/`rounded-xl` auf nicht-Modal-Elementen — auf `rounded` (=4px)
reduzieren.
4. Fokus-Ringe in Brand-Blau — der `.form-input`-Style nutzt jetzt Border-Ink;
Tailwind-Default-Ring kann noch blau sein, ggf. globalen Ring-Reset
ergänzen.
Screenshots der Hauptseiten **vor und nach** dem Swap in
`history/2026-05-23-whynot-design-phase1-screenshots/` ablegen, als
Sichtprüfungs-Beleg.
```
```task
id: WP-0017-T06
title: Doku-Update und Phase-2-Pflock
status: done
**`wiki/`** — neue Datei `wiki/DesignSystem.md` mit knappem Inhalt:
```markdown
# Design System
vergabe-teilnahme nutzt das `whynot-design`-System
(gitea `whynot/whynot-design`) als visuelle Basis.
**Phase 1 (aktuell, ab WP-0017):** nur Tokens + CSS-Variablen, vendoring nach
`static/src/vendor/whynot-design/`. Sync via `make sync-whynot-design`. Aktuell
gepinnter Commit: siehe `static/src/vendor/whynot-design/.whynot-design-ref`.
**Phase 2 (offen):** Komponenten-Adoption sobald upstream Lit Web Components
und die fehlenden Atome (Card, Modal, Input, Table, Toast) ausliefert. Eigener
Workplan wird zu diesem Zeitpunkt angelegt.
**Lokale Abweichungen** vom whynot-System (dokumentiert in `main.css`):
- `btn-danger` mit Off-Spec-Rot (`#B22222`) — whynot definiert keine
destruktive Farbe; vergabe-Nutzung erfordert sie für Löschen-Aktionen.
Wird zurückgebaut, sobald upstream eine kanonische Lösung definiert.
**Hintergrund:** Strategie-Analyse + Komponenten-Lücken in
`history/2026-05-23-whynot-design-cross-framework-analysis.md`.
```
**`CLAUDE.md` / `.claude/rules/stack-and-commands.md`** — Stack-Eintrag
ergänzen um `whynot-design` und den Sync-Befehl:
```markdown
## Stack
- **Language:** Python 3.12 (Django 6), Node 22 (Vite/Tailwind v4)
- **Key deps:** Django, htmx, Alpine.js, Tailwind v4, whynot-design (vendored)
## Dev Commands
# Design-System-Vendor aktualisieren (whynot-design Pin bumpen)
make sync-whynot-design
```
**Workplan-Index** in `workplans/README.md` ist bereits 12-Workplan-zentriert
und veraltet (es gibt jetzt 17). Nicht in diesem WP anpassen — eigener
Aufräum-WP, falls der Index wieder verbindlich werden soll.
```
---
## Nicht in diesem Workplan
- Komponenten-Portierung (React-JSX → Django-Partials) — entfällt; wird durch
upstream Lit Web Components in Phase 2 obsolet.
- `whynot-design-django`-Repo — entfällt aus dem gleichen Grund.
- Neue Atome (Card, Modal, Input, Table, Toast als DS-Komponenten) — kommen
upstream; bis dahin vergabe-lokal über Tailwind-Klassen.
- Visuelle Regression-Tests (Playwright) — wäre sinnvoll, aber nicht
Voraussetzung für Phase 1.
- Markup-Änderungen in Page-Templates über das in T05 Notwendige hinaus.
## Definition of Done
- `static/src/vendor/whynot-design/colors_and_type.css` vorhanden,
`.whynot-design-ref` enthält gepinnten Commit-Hash.
- `npm run build` erfolgreich, `static/dist/main.css` ohne `#3b5bdb`
(Alt-Brand-Blau) und mit aktiven `bg-ink`/`bg-paper`-Utilities.
- Alle 12 Smoke-Test-Views in T05 visuell durchgegangen, Brüche gepatcht,
Screenshots in `history/` abgelegt.
- `wiki/DesignSystem.md` existiert und referenziert History-Artefakt +
Phase-2-Bedingung.