generated from coulomb/repo-seed
Compare commits
111 Commits
2ee2e4459f
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 498b7aa9a5 | |||
| ff95fb3971 | |||
| d20d21b21a | |||
| a3cec3f221 | |||
| b31409649c | |||
| 7bd6730744 | |||
| 9cb8992486 | |||
| 5ae4aba4a8 | |||
| 00469c4cc0 | |||
| fbb8def9ce | |||
| 739ffafedd | |||
| f95de1482d | |||
| e28739f8f1 | |||
| 483a4dfd3b | |||
| 17f511fbcd | |||
| 341c718511 | |||
| a32ebb9e12 | |||
| 746b035feb | |||
| eaddaa1b2f | |||
| c7c2e8472b | |||
| 6436fc5faf | |||
| 1ad7674532 | |||
| 3285026c0a | |||
| bb70f36068 | |||
| 99b0974732 | |||
| 00e6a9ae6f | |||
| 1f39d6ad85 | |||
| c9cf3bab87 | |||
| 07a831632b | |||
| 963997f410 | |||
| 19118dc534 | |||
| 0a7f1de5e4 | |||
| 91ab70710e | |||
| 33b6f2ecaf | |||
| 656276dda1 | |||
| 9677cb50fb | |||
| 67693d9f61 | |||
| e55a619bce | |||
| 5d0d63fd89 | |||
| ff79a4bd57 | |||
| ec8aca19c1 | |||
| 07f8a2659d | |||
| a9299e37b6 | |||
| 649f524979 | |||
| c103c0d057 | |||
| 69c7f3eee1 | |||
| b5b5c6c29b | |||
| 0ae841b362 | |||
| ae32ba4ba6 | |||
| b4451ea161 | |||
| 87e0a557b0 | |||
| 78a27793c1 | |||
| 34cf25483d | |||
| aa940fafe5 | |||
| fdccb41fcd | |||
| 30c44edb18 | |||
| 10c6252931 | |||
| c1fe26c36b | |||
| 7568c85acc | |||
| 58ee79ef2f | |||
| 438d9a8d16 | |||
| 957bd7b77b | |||
| f662fc42f8 | |||
| c424b6ff90 | |||
| 3856d6d5d4 | |||
| 61f6c5f4eb | |||
| 7d6d2ebc2a | |||
| 4370be6160 | |||
| e286290794 | |||
| e08698ecdb | |||
| 37df677906 | |||
| 0903d2a8a4 | |||
| ee5f530f5f | |||
| b3377af439 | |||
| 8e91751528 | |||
| dd6948f5ee | |||
| 819b928041 | |||
| d7a4e4b07b | |||
| 3aca040ca7 | |||
| 2e0635501d | |||
| 8ff7fbc5bd | |||
| b4aac0fd33 | |||
| 15ee4b3638 | |||
| bdb9370c0b | |||
| a907ed6f74 | |||
| c2b7cc64d6 | |||
| c022279038 | |||
| cc499adc61 | |||
| 3d36baa1ce | |||
| 35a71c1cc1 | |||
| 69fda01d95 | |||
| 28e012201b | |||
| 2877898885 | |||
| 256fb4c75c | |||
| d88aa9318c | |||
| 816c281f6a | |||
| a0c0ddf2eb | |||
| b7863f60a5 | |||
| a72360cc94 | |||
| a9c230326d | |||
| b054824862 | |||
| 9ef6e0950a | |||
| f6af101933 | |||
| e4eb5bc368 | |||
| f5950b6cbc | |||
| 58889a5a82 | |||
| 7903f59f85 | |||
| d6873d7b88 | |||
| 889aa6050c | |||
| 40e70e64f0 | |||
| 40739c1bfd |
20
.claude/rules/agents.md
Normal file
20
.claude/rules/agents.md
Normal 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.
|
||||
8
.claude/rules/architecture.md
Normal file
8
.claude/rules/architecture.md
Normal 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
|
||||
50
.claude/rules/credential-routing.md
Normal file
50
.claude/rules/credential-routing.md
Normal 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`
|
||||
38
.claude/rules/first-session.md
Normal file
38
.claude/rules/first-session.md
Normal 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 1–3 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 -->
|
||||
8
.claude/rules/repo-boundary.md
Normal file
8
.claude/rules/repo-boundary.md
Normal 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/
|
||||
-->
|
||||
5
.claude/rules/repo-identity.md
Normal file
5
.claude/rules/repo-identity.md
Normal 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
|
||||
85
.claude/rules/session-protocol.md
Normal file
85
.claude/rules/session-protocol.md
Normal 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.
|
||||
19
.claude/rules/stack-and-commands.md
Normal file
19
.claude/rules/stack-and-commands.md
Normal 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)
|
||||
```
|
||||
40
.claude/rules/workplan-convention.md
Normal file
40
.claude/rules/workplan-convention.md
Normal 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 -->
|
||||
@@ -2,7 +2,7 @@
|
||||
# Custodian Brief — vergabe-teilnahme
|
||||
|
||||
**Domain:** (unknown)
|
||||
**Last synced:** 2026-05-13 21:32 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
33
.dockerignore
Normal 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
1
.gitignore
vendored
@@ -181,3 +181,4 @@ media/
|
||||
# PyPI configuration file
|
||||
.pypirc
|
||||
|
||||
.issue-facade/
|
||||
|
||||
19
.repo-classification.yaml
Normal file
19
.repo-classification.yaml
Normal 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
219
AGENTS.md
Normal 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/`)
|
||||
86
CLAUDE.md
86
CLAUDE.md
@@ -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
98
Dockerfile
Normal 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", "-"]
|
||||
5
Makefile
5
Makefile
@@ -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
154
SCOPE.md
Normal 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-0001–WP-0017) implemented in sequence from project
|
||||
scaffold through whynot-design token adoption
|
||||
- Stability: evolving — manual-entry-first v1; no CI workflow configured yet
|
||||
(`.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-0001–0017
|
||||
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.
|
||||
361
history/2026-05-23-whynot-design-cross-framework-analysis.md
Normal file
361
history/2026-05-23-whynot-design-cross-framework-analysis.md
Normal 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 2024–2026; 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` (S0–S4 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 S0–S4 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,"
|
||||
"0–4px 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/)
|
||||
@@ -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
12
registry/README.md
Normal 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`.
|
||||
0
registry/capabilities/.gitkeep
Normal file
0
registry/capabilities/.gitkeep
Normal 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-0001–WP-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-0001–WP-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`
|
||||
19
registry/indexes/capabilities.yaml
Normal file
19
registry/indexes/capabilities.yaml
Normal 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
39
scripts/sync-whynot-design.sh
Executable 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"))"
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
1
static/src/vendor/whynot-design/.whynot-design-ref
vendored
Normal file
1
static/src/vendor/whynot-design/.whynot-design-ref
vendored
Normal file
@@ -0,0 +1 @@
|
||||
9419f166ce395858f55b10a5c72268a1fe9fc9d2
|
||||
273
static/src/vendor/whynot-design/colors_and_type.css
vendored
Normal file
273
static/src/vendor/whynot-design/colors_and_type.css
vendored
Normal 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); }
|
||||
22
static/src/vendor/whynot-design/tokens/colors.json
vendored
Normal file
22
static/src/vendor/whynot-design/tokens/colors.json
vendored
Normal 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" }
|
||||
}
|
||||
6
static/src/vendor/whynot-design/tokens/index.json
vendored
Normal file
6
static/src/vendor/whynot-design/tokens/index.json
vendored
Normal 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"
|
||||
}
|
||||
28
static/src/vendor/whynot-design/tokens/spacing.json
vendored
Normal file
28
static/src/vendor/whynot-design/tokens/spacing.json
vendored
Normal 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." }
|
||||
}
|
||||
}
|
||||
33
static/src/vendor/whynot-design/tokens/type.json
vendored
Normal file
33
static/src/vendor/whynot-design/tokens/type.json
vendored
Normal 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
178
uv.lock
generated
@@ -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" },
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
from django import forms
|
||||
|
||||
from .models import Aufgabe, Bieterfrage
|
||||
from .models import Aufgabe, Bieterfrage, ExternalIssue
|
||||
|
||||
|
||||
class AufgabeForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = Aufgabe
|
||||
fields = [
|
||||
'titel', 'beschreibung', 'typ', 'prioritaet', 'frist',
|
||||
'titel', 'beschreibung', 'typ', 'phase', 'prioritaet', 'frist',
|
||||
'verantwortlicher', 'los', 'anforderung', 'bieterfrage',
|
||||
]
|
||||
widgets = {
|
||||
'titel': forms.TextInput(attrs={'class': 'form-input', 'autofocus': True}),
|
||||
'beschreibung': forms.Textarea(attrs={'class': 'form-input', 'rows': 3}),
|
||||
'typ': forms.Select(attrs={'class': 'form-input'}),
|
||||
'phase': forms.Select(attrs={'class': 'form-input'}),
|
||||
'prioritaet': forms.RadioSelect(),
|
||||
'frist': forms.DateInput(attrs={'class': 'form-input', 'type': 'date'}),
|
||||
'verantwortlicher': forms.Select(attrs={'class': 'form-input'}),
|
||||
@@ -30,6 +31,7 @@ class AufgabeForm(forms.ModelForm):
|
||||
self.fields['anforderung'].queryset = Anforderung.objects.filter(ausschreibung=ausschreibung)
|
||||
self.fields['bieterfrage'].queryset = Bieterfrage.objects.filter(ausschreibung=ausschreibung)
|
||||
self.fields['beschreibung'].required = False
|
||||
self.fields['phase'].required = False
|
||||
self.fields['frist'].required = False
|
||||
self.fields['verantwortlicher'].required = False
|
||||
self.fields['los'].required = False
|
||||
@@ -61,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)'}),
|
||||
}
|
||||
|
||||
43
vergabe_teilnahme/apps/aufgaben/issue_backends.py
Normal file
43
vergabe_teilnahme/apps/aufgaben/issue_backends.py
Normal 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'))
|
||||
111
vergabe_teilnahme/apps/aufgaben/issue_facade.py
Normal file
111
vergabe_teilnahme/apps/aufgaben/issue_facade.py
Normal 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
|
||||
27
vergabe_teilnahme/apps/aufgaben/link_registry.py
Normal file
27
vergabe_teilnahme/apps/aufgaben/link_registry.py
Normal 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()]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 6.0.5 on 2026-05-13 23:39
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('aufgaben', '0002_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='aufgabe',
|
||||
name='phase',
|
||||
field=models.PositiveSmallIntegerField(blank=True, choices=[(1, 'Recherche & Unterlagen'), (2, 'Teilnahmeentscheidung'), (3, 'Detaillierte Durchsicht'), (4, 'Bieterfragen & Klärung'), (5, 'Preismodell'), (6, 'Unterlagen finalisieren'), (7, 'Abgabe'), (8, 'Zuschlag / Nachbetrachtung')], null=True),
|
||||
),
|
||||
]
|
||||
@@ -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,
|
||||
),
|
||||
]
|
||||
@@ -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',
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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
|
||||
@@ -26,6 +28,16 @@ class Aufgabe(FlexibleModel):
|
||||
('ueberfaellig', 'Überfällig'),
|
||||
]
|
||||
PRIORITAET_CHOICES = [(1, 'Hoch'), (2, 'Mittel'), (3, 'Niedrig')]
|
||||
PHASE_CHOICES = [
|
||||
(1, 'Recherche & Unterlagen'),
|
||||
(2, 'Teilnahmeentscheidung'),
|
||||
(3, 'Detaillierte Durchsicht'),
|
||||
(4, 'Bieterfragen & Klärung'),
|
||||
(5, 'Preismodell'),
|
||||
(6, 'Unterlagen finalisieren'),
|
||||
(7, 'Abgabe'),
|
||||
(8, 'Zuschlag / Nachbetrachtung'),
|
||||
]
|
||||
|
||||
ausschreibung = models.ForeignKey(
|
||||
'ausschreibungen.Ausschreibung', on_delete=models.CASCADE, related_name='aufgaben'
|
||||
@@ -51,10 +63,12 @@ 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
|
||||
)
|
||||
ergebnis = models.TextField(blank=True)
|
||||
phase = models.PositiveSmallIntegerField(choices=PHASE_CHOICES, null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['prioritaet', 'frist']
|
||||
@@ -64,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):
|
||||
@@ -111,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}'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'),
|
||||
]
|
||||
|
||||
@@ -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:
|
||||
@@ -40,6 +50,10 @@ def aufgaben_liste(request, ausschreibung_id=None):
|
||||
if typ_filter:
|
||||
qs = qs.filter(typ=typ_filter)
|
||||
|
||||
phase_filter = request.GET.get('phase')
|
||||
if phase_filter:
|
||||
qs = qs.filter(phase=phase_filter)
|
||||
|
||||
verantwortlicher_filter = request.GET.get('verantwortlicher')
|
||||
if verantwortlicher_filter:
|
||||
qs = qs.filter(verantwortlicher=verantwortlicher_filter)
|
||||
@@ -67,6 +81,8 @@ def aufgaben_liste(request, ausschreibung_id=None):
|
||||
'mitarbeiter': Mitarbeiter.objects.all(),
|
||||
'current_status': status_filter or '',
|
||||
'current_typ': typ_filter or '',
|
||||
'phase_choices': Aufgabe.PHASE_CHOICES,
|
||||
'current_phase': phase_filter or '',
|
||||
'current_verantwortlicher': verantwortlicher_filter or '',
|
||||
'breadcrumbs': breadcrumbs,
|
||||
}
|
||||
@@ -93,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,
|
||||
@@ -310,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)
|
||||
|
||||
@@ -7,10 +7,10 @@ class AusschreibungForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = Ausschreibung
|
||||
fields = [
|
||||
'titel', 'ausschreiber', 'vergabeplattform', 'vergabenummer', 'vergabeart',
|
||||
'titel', 'ausschreiber', 'vergabeplattform', 'vergabenummer', 'vergabeart', 'rechtsgrundlage', 'rechtsgrundlage_details',
|
||||
'fundstelle_url', 'bid_manager', 'leistungsbeschreibung',
|
||||
'branche', 'schlagwoerter', 'geschaetztes_volumen',
|
||||
'veroeffentlichungsdatum', 'bieterfragen_bis', 'abgabe_bis', 'bindefrist',
|
||||
'veroeffentlichungsdatum', 'bieterfragen_bis', 'abgabe_bis', 'bindefrist', 'bindefrist_tage',
|
||||
'unterlagen_erhalten', 'unterlagen_erhalten_am',
|
||||
'teilnahmeentscheidung', 'entscheidungsbegruendung',
|
||||
]
|
||||
@@ -20,17 +20,20 @@ class AusschreibungForm(forms.ModelForm):
|
||||
'vergabeplattform': forms.TextInput(attrs={'class': 'form-input'}),
|
||||
'vergabenummer': forms.TextInput(attrs={'class': 'form-input'}),
|
||||
'vergabeart': forms.Select(attrs={'class': 'form-input'}),
|
||||
'rechtsgrundlage': forms.Select(attrs={'class': 'form-input'}),
|
||||
'rechtsgrundlage_details': forms.Textarea(attrs={'class': 'form-input', 'rows': 2, 'placeholder': 'z.B. Verfahrensart, Schwellenwert, Besonderheiten…'}),
|
||||
'fundstelle_url': forms.URLInput(attrs={'class': 'form-input'}),
|
||||
'bid_manager': forms.Select(attrs={'class': 'form-input'}),
|
||||
'leistungsbeschreibung': forms.Textarea(attrs={'class': 'form-input', 'rows': 4}),
|
||||
'branche': forms.TextInput(attrs={'class': 'form-input'}),
|
||||
'schlagwoerter': forms.TextInput(attrs={'class': 'form-input'}),
|
||||
'geschaetztes_volumen': forms.NumberInput(attrs={'class': 'form-input'}),
|
||||
'veroeffentlichungsdatum': forms.DateInput(attrs={'class': 'form-input', 'type': 'date'}),
|
||||
'bieterfragen_bis': forms.DateInput(attrs={'class': 'form-input', 'type': 'date'}),
|
||||
'abgabe_bis': forms.DateTimeInput(attrs={'class': 'form-input', 'type': 'datetime-local'}),
|
||||
'bindefrist': forms.DateInput(attrs={'class': 'form-input', 'type': 'date'}),
|
||||
'unterlagen_erhalten_am': forms.DateInput(attrs={'class': 'form-input', 'type': 'date'}),
|
||||
'veroeffentlichungsdatum': forms.DateInput(attrs={'class': 'form-input', 'type': 'date'}, format='%Y-%m-%d'),
|
||||
'bieterfragen_bis': forms.DateInput(attrs={'class': 'form-input', 'type': 'date'}, format='%Y-%m-%d'),
|
||||
'abgabe_bis': forms.DateTimeInput(attrs={'class': 'form-input', 'type': 'datetime-local'}, format='%Y-%m-%dT%H:%M'),
|
||||
'bindefrist': forms.DateInput(attrs={'class': 'form-input', 'type': 'date'}, format='%Y-%m-%d'),
|
||||
'bindefrist_tage': forms.NumberInput(attrs={'class': 'form-input', 'min': '1', 'placeholder': 'Tage'}),
|
||||
'unterlagen_erhalten_am': forms.DateInput(attrs={'class': 'form-input', 'type': 'date'}, format='%Y-%m-%d'),
|
||||
'teilnahmeentscheidung': forms.Select(attrs={'class': 'form-input'}),
|
||||
'entscheidungsbegruendung': forms.Textarea(attrs={'class': 'form-input', 'rows': 3}),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 6.0.5 on 2026-05-13 22:25
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('ausschreibungen', '0003_referenzen_m2m'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='ausschreibung',
|
||||
name='rechtsgrundlage',
|
||||
field=models.CharField(blank=True, choices=[('vgv', 'VgV'), ('uvgo', 'UVgO'), ('vob_a', 'VOB/A'), ('sektvo', 'SektVO'), ('gwb', 'GWB'), ('sonstige', 'Sonstige')], max_length=20),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 6.0.5 on 2026-05-13 22:26
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('ausschreibungen', '0004_rechtsgrundlage'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='ausschreibung',
|
||||
name='bindefrist_tage',
|
||||
field=models.PositiveSmallIntegerField(blank=True, help_text='Bindefrist in Tagen ab Abgabe', null=True),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 6.0.5 on 2026-05-13 22:57
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('ausschreibungen', '0005_bindefrist_tage'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='ausschreibung',
|
||||
name='rechtsgrundlage_details',
|
||||
field=models.TextField(blank=True),
|
||||
),
|
||||
]
|
||||
@@ -1,4 +1,4 @@
|
||||
from datetime import date
|
||||
from datetime import date, timedelta
|
||||
|
||||
from django.db import models
|
||||
|
||||
@@ -34,12 +34,22 @@ class Ausschreibung(FlexibleModel):
|
||||
('rahmenvertrag', 'Rahmenvertrag'),
|
||||
('sonstige', 'Sonstige'),
|
||||
]
|
||||
RECHTSGRUNDLAGE_CHOICES = [
|
||||
('vgv', 'VgV'),
|
||||
('uvgo', 'UVgO'),
|
||||
('vob_a', 'VOB/A'),
|
||||
('sektvo', 'SektVO'),
|
||||
('gwb', 'GWB'),
|
||||
('sonstige', 'Sonstige'),
|
||||
]
|
||||
|
||||
titel = models.CharField(max_length=400)
|
||||
ausschreiber = models.CharField(max_length=300)
|
||||
vergabeplattform = models.CharField(max_length=200, blank=True)
|
||||
vergabenummer = models.CharField(max_length=100, blank=True)
|
||||
vergabeart = models.CharField(max_length=30, choices=VERGABEART_CHOICES, blank=True)
|
||||
rechtsgrundlage = models.CharField(max_length=20, choices=RECHTSGRUNDLAGE_CHOICES, blank=True)
|
||||
rechtsgrundlage_details = models.TextField(blank=True)
|
||||
status = models.PositiveSmallIntegerField(choices=STATUS_CHOICES, default=1)
|
||||
teilnahmeentscheidung = models.CharField(
|
||||
max_length=20, choices=TEILNAHME_CHOICES, default='offen'
|
||||
@@ -51,6 +61,7 @@ class Ausschreibung(FlexibleModel):
|
||||
bieterfragen_bis = models.DateField(null=True, blank=True)
|
||||
abgabe_bis = models.DateTimeField(null=True, blank=True)
|
||||
bindefrist = models.DateField(null=True, blank=True)
|
||||
bindefrist_tage = models.PositiveSmallIntegerField(null=True, blank=True, help_text='Bindefrist in Tagen ab Abgabe')
|
||||
|
||||
# Verantwortlichkeiten
|
||||
bid_manager = models.ForeignKey(
|
||||
@@ -92,6 +103,15 @@ class Ausschreibung(FlexibleModel):
|
||||
def __str__(self):
|
||||
return self.titel
|
||||
|
||||
@property
|
||||
def bindefrist_berechnet(self):
|
||||
"""Enddatum aus abgabe_bis + bindefrist_tage, falls kein festes bindefrist-Datum."""
|
||||
if self.bindefrist:
|
||||
return self.bindefrist
|
||||
if self.abgabe_bis and self.bindefrist_tage:
|
||||
return (self.abgabe_bis + timedelta(days=self.bindefrist_tage)).date()
|
||||
return None
|
||||
|
||||
@property
|
||||
def ist_aktiv(self):
|
||||
return 1 <= self.status <= 9
|
||||
|
||||
@@ -59,7 +59,15 @@ def ausschreibung_liste(request):
|
||||
if bid_manager_filter:
|
||||
qs = qs.filter(bid_manager=bid_manager_filter)
|
||||
|
||||
qs = qs.select_related('bid_manager').order_by('-geaendert_am')
|
||||
from django.db.models import Count, Q
|
||||
qs = qs.select_related('bid_manager').annotate(
|
||||
aufgaben_total=Count('aufgaben', distinct=True),
|
||||
aufgaben_erledigt=Count(
|
||||
'aufgaben',
|
||||
filter=Q(aufgaben__status__in=['erledigt', 'verworfen']),
|
||||
distinct=True,
|
||||
),
|
||||
).order_by('-geaendert_am')
|
||||
|
||||
ctx = {
|
||||
'ausschreibungen': qs,
|
||||
@@ -100,14 +108,33 @@ def ausschreibung_neu(request):
|
||||
|
||||
|
||||
def ausschreibung_detail(request, pk):
|
||||
from vergabe_teilnahme.apps.core.services import build_phase_nav, get_deadline_warnings
|
||||
from django.db.models import Count, Q
|
||||
|
||||
from vergabe_teilnahme.apps.aufgaben.models import Aufgabe
|
||||
from vergabe_teilnahme.apps.core.services import aufgaben_score, build_phase_nav, get_deadline_warnings
|
||||
from vergabe_teilnahme.apps.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(
|
||||
'aufgaben',
|
||||
filter=Q(aufgaben__status__in=['erledigt', 'verworfen']),
|
||||
distinct=True,
|
||||
),
|
||||
).order_by('losnummer')
|
||||
ctx = {
|
||||
'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)),
|
||||
'breadcrumbs': [
|
||||
{'label': 'Ausschreibungen', 'url': '/ausschreibungen/'},
|
||||
{'label': a.titel, 'url': None},
|
||||
|
||||
@@ -25,19 +25,32 @@ STATUS_TO_PHASE = {
|
||||
}
|
||||
|
||||
|
||||
def aufgaben_score(qs):
|
||||
total = qs.count()
|
||||
abgeschlossen = qs.filter(status__in=['erledigt', 'verworfen']).count()
|
||||
pct = round(abgeschlossen / total * 100) if total else 0
|
||||
return {'total': total, 'abgeschlossen': abgeschlossen, 'score_pct': pct}
|
||||
|
||||
|
||||
def build_phase_nav(ausschreibung, current_url=''):
|
||||
from vergabe_teilnahme.apps.aufgaben.models import Aufgabe
|
||||
aktuelle_phase = STATUS_TO_PHASE.get(ausschreibung.status, 1)
|
||||
base = f'/ausschreibungen/{ausschreibung.pk}'
|
||||
phase_urls = {
|
||||
1: f'{base}/',
|
||||
2: f'{base}/teilnahmeentscheidung/',
|
||||
3: f'{base}/anforderungen/',
|
||||
2: f'{base}/entscheidung/',
|
||||
3: f'{base}/lose/anforderungen/',
|
||||
4: f'{base}/bieterfragen/',
|
||||
5: f'{base}/preise/',
|
||||
6: f'{base}/dokumente/',
|
||||
7: f'{base}/abgabe/',
|
||||
8: f'{base}/nachbetrachtung/',
|
||||
}
|
||||
aufgaben_qs = Aufgabe.objects.filter(ausschreibung=ausschreibung)
|
||||
phasen_stats = {
|
||||
num: aufgaben_score(aufgaben_qs.filter(phase=num))
|
||||
for num, _ in PHASEN
|
||||
}
|
||||
return [
|
||||
{
|
||||
'nummer': num,
|
||||
@@ -46,6 +59,7 @@ def build_phase_nav(ausschreibung, current_url=''):
|
||||
'aktiv': num == aktuelle_phase,
|
||||
'erledigt': num < aktuelle_phase,
|
||||
'warnung': False,
|
||||
'aufgaben_score': phasen_stats[num],
|
||||
}
|
||||
for num, name in PHASEN
|
||||
]
|
||||
|
||||
@@ -8,5 +8,5 @@ urlpatterns = [
|
||||
path('modal/', views.modal, name='modal'),
|
||||
path('', views.submit, name='submit'),
|
||||
path('backlog/', views.backlog, name='backlog'),
|
||||
path('backlog/<int:pk>/status/', views.status_aendern, name='status_aendern'),
|
||||
path('backlog/<int:pk>/bearbeiten/', views.eintrag_bearbeiten, name='eintrag_bearbeiten'),
|
||||
]
|
||||
|
||||
@@ -65,17 +65,32 @@ def backlog(request):
|
||||
return render(request, 'feedback/backlog.html', ctx)
|
||||
|
||||
|
||||
def _eintrag_ctx(eintrag):
|
||||
return {
|
||||
'eintrag': eintrag,
|
||||
'status_choices': Feedbackeintrag.STATUS_CHOICES,
|
||||
'kategorie_choices': Feedbackeintrag.KATEGORIE_CHOICES,
|
||||
'dringlichkeit_choices': Feedbackeintrag.DRINGLICHKEIT_CHOICES,
|
||||
}
|
||||
|
||||
|
||||
@require_POST
|
||||
def status_aendern(request, pk):
|
||||
def eintrag_bearbeiten(request, pk):
|
||||
eintrag = get_object_or_404(Feedbackeintrag, pk=pk)
|
||||
neuer_status = request.POST.get('status')
|
||||
if neuer_status in dict(Feedbackeintrag.STATUS_CHOICES):
|
||||
eintrag.status = neuer_status
|
||||
bewertung = request.POST.get('bewertung')
|
||||
if bewertung is not None:
|
||||
eintrag.bewertung = bewertung
|
||||
entscheidung = request.POST.get('entscheidung')
|
||||
if entscheidung is not None:
|
||||
eintrag.entscheidung = entscheidung
|
||||
for field, choices in [
|
||||
('status', Feedbackeintrag.STATUS_CHOICES),
|
||||
('kategorie', Feedbackeintrag.KATEGORIE_CHOICES),
|
||||
('dringlichkeit', Feedbackeintrag.DRINGLICHKEIT_CHOICES),
|
||||
]:
|
||||
val = request.POST.get(field)
|
||||
if val and val in dict(choices):
|
||||
setattr(eintrag, field, val)
|
||||
for field in ('beschreibung', 'bewertung', 'entscheidung'):
|
||||
val = request.POST.get(field)
|
||||
if val is not None:
|
||||
setattr(eintrag, field, val)
|
||||
titel = request.POST.get('titel', '').strip()
|
||||
if titel:
|
||||
eintrag.titel = titel
|
||||
eintrag.save()
|
||||
return render(request, 'feedback/partials/eintrag_zeile.html', {'eintrag': eintrag})
|
||||
return render(request, 'feedback/partials/eintrag_zeile.html', _eintrag_ctx(eintrag))
|
||||
|
||||
@@ -24,8 +24,16 @@ def _ausschreibung_breadcrumbs(ausschreibung, *extra):
|
||||
# ─── Lose ────────────────────────────────────────────────────────────────────
|
||||
|
||||
def lose_liste(request, ausschreibung_id):
|
||||
from django.db.models import Count, Q
|
||||
ausschreibung = get_object_or_404(Ausschreibung, pk=ausschreibung_id)
|
||||
lose = Los.objects.filter(ausschreibung=ausschreibung).order_by('losnummer')
|
||||
lose = Los.objects.filter(ausschreibung=ausschreibung).annotate(
|
||||
aufgaben_total=Count('aufgaben', distinct=True),
|
||||
aufgaben_erledigt=Count(
|
||||
'aufgaben',
|
||||
filter=Q(aufgaben__status__in=['erledigt', 'verworfen']),
|
||||
distinct=True,
|
||||
),
|
||||
).order_by('losnummer')
|
||||
return render(request, 'lose/liste.html', {
|
||||
'ausschreibung': ausschreibung,
|
||||
'lose': lose,
|
||||
@@ -46,6 +54,12 @@ def los_neu(request, ausschreibung_id):
|
||||
return redirect('ausschreibungen:lose:liste', ausschreibung_id=ausschreibung_id)
|
||||
else:
|
||||
form = LosForm(ausschreibung=ausschreibung)
|
||||
|
||||
if _is_htmx(request):
|
||||
return render(request, 'lose/partials/los_form_inline.html', {
|
||||
'form': form,
|
||||
'ausschreibung': ausschreibung,
|
||||
})
|
||||
return render(request, 'lose/form.html', {
|
||||
'form': form,
|
||||
'ausschreibung': ausschreibung,
|
||||
|
||||
@@ -32,7 +32,7 @@ def abgabe_vollstaendigkeit(ausschreibung):
|
||||
def abgabe_checkliste(request, ausschreibung_id):
|
||||
ausschreibung = get_object_or_404(Ausschreibung, pk=ausschreibung_id)
|
||||
vollstaendigkeit = abgabe_vollstaendigkeit(ausschreibung)
|
||||
dokumente = Dokument.objects.filter(ausschreibung=ausschreibung).order_by('kategorie', 'bezeichnung')
|
||||
dokumente = Dokument.objects.filter(ausschreibung=ausschreibung).order_by('kategorie', 'beschreibung')
|
||||
|
||||
punkte = [
|
||||
('entscheidung_getroffen', 'Teilnahmeentscheidung getroffen'),
|
||||
|
||||
@@ -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',
|
||||
# }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -32,6 +32,15 @@
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label">Phase</label>
|
||||
<select name="phase" class="form-input text-xs">
|
||||
<option value="">Alle Phasen</option>
|
||||
{% for val, label in phase_choices %}
|
||||
<option value="{{ val }}"{% if current_phase == val|stringformat:"s" %} selected{% endif %}>{{ val }}. {{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label">Verantwortlich</label>
|
||||
<select name="verantwortlicher" class="form-input text-xs">
|
||||
|
||||
@@ -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>
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
@@ -10,6 +10,7 @@
|
||||
<p class="text-sm text-slate-500 mt-0.5">{{ ausschreibung.ausschreiber }}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
{% with s=aufgaben_score %}{% if s.total %}<span class="text-xs text-slate-500">Aufgaben: {{ s.abgeschlossen }}/{{ s.total }} ({{ s.score_pct }} %)</span>{% endif %}{% endwith %}
|
||||
{% include "ausschreibungen/partials/status_widget.html" %}
|
||||
<a href="{% url 'ausschreibungen:bearbeiten' ausschreibung.pk %}" class="btn-ghost text-xs">Bearbeiten</a>
|
||||
<a href="{% url 'ausschreibungen:archivieren' ausschreibung.pk %}" class="btn-ghost text-xs text-slate-400">Archivieren</a>
|
||||
@@ -59,6 +60,8 @@
|
||||
<dl class="space-y-1">
|
||||
{% render_field ausschreibung "ausschreiber" "Ausschreiber" %}
|
||||
{% render_field ausschreibung "vergabeart" "Vergabeart" %}
|
||||
{% render_field ausschreibung "rechtsgrundlage" "Rechtsgrundlage" %}
|
||||
{% render_field ausschreibung "rechtsgrundlage_details" "Rechtsgrundlage Details" %}
|
||||
{% render_field ausschreibung "vergabenummer" "Vergabenummer" %}
|
||||
{% render_field ausschreibung "vergabeplattform" "Plattform" %}
|
||||
{% render_field ausschreibung "branche" "Branche" %}
|
||||
@@ -73,7 +76,19 @@
|
||||
{% render_field ausschreibung "veroeffentlichungsdatum" "Veröffentlicht" %}
|
||||
{% render_field ausschreibung "bieterfragen_bis" "Bieterfragen bis" %}
|
||||
{% render_field ausschreibung "abgabe_bis" "Abgabe bis" %}
|
||||
{% if ausschreibung.bindefrist_tage %}
|
||||
<div class="flex justify-between text-sm py-0.5">
|
||||
<dt class="text-slate-500">Bindefrist</dt>
|
||||
<dd class="text-slate-800">
|
||||
{{ ausschreibung.bindefrist_tage }} Tage
|
||||
{% if ausschreibung.bindefrist_berechnet %}
|
||||
<span class="text-slate-400">(bis {{ ausschreibung.bindefrist_berechnet|date:"d.m.Y" }})</span>
|
||||
{% endif %}
|
||||
</dd>
|
||||
</div>
|
||||
{% else %}
|
||||
{% render_field ausschreibung "bindefrist" "Bindefrist" %}
|
||||
{% endif %}
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
@@ -85,6 +100,80 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Lose -->
|
||||
<div class="card mt-6">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h2 class="text-sm font-semibold text-slate-700">Lose</h2>
|
||||
<button class="btn-ghost text-xs"
|
||||
hx-get="{% url 'ausschreibungen:lose:neu' ausschreibung.pk %}"
|
||||
hx-target="#lose-form-container"
|
||||
hx-swap="innerHTML">
|
||||
+ Los hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
<div id="lose-form-container"></div>
|
||||
{% if lose %}
|
||||
<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">Nr.</th>
|
||||
<th class="pb-2 pr-4">Titel</th>
|
||||
<th class="pb-2 pr-4">Aufgaben</th>
|
||||
<th class="pb-2 pr-4">Teilnahme</th>
|
||||
<th class="pb-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="lose-tbody" class="divide-y divide-slate-100">
|
||||
{% for los in lose %}
|
||||
{% include "lose/partials/los_row.html" %}
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<div id="lose-tbody"></div>
|
||||
<p class="text-sm text-slate-400 mt-1">Noch keine Lose angelegt.</p>
|
||||
{% 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
|
||||
|
||||
@@ -26,6 +26,14 @@
|
||||
<label class="form-label">Vergabeart</label>
|
||||
{{ form.vergabeart }}
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label">Rechtsgrundlage</label>
|
||||
{{ form.rechtsgrundlage }}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label">Rechtsgrundlage — Details</label>
|
||||
{{ form.rechtsgrundlage_details }}
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
@@ -83,9 +91,14 @@
|
||||
{{ form.abgabe_bis }}
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label">Bindefrist</label>
|
||||
<label class="form-label">Bindefrist (Datum)</label>
|
||||
{{ form.bindefrist }}
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label">Bindefrist (Tage ab Abgabe)</label>
|
||||
{{ form.bindefrist_tage }}
|
||||
<p class="text-xs text-slate-400 mt-0.5">Alternativ zum Datum — Enddatum wird automatisch berechnet</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="flex items-center gap-2">
|
||||
|
||||
@@ -4,8 +4,10 @@
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-slate-50 border-b border-slate-200">
|
||||
<tr>
|
||||
<th class="text-left px-4 py-2 font-medium text-slate-600">Titel</th>
|
||||
<th class="text-left px-4 py-2 font-medium text-slate-600">Ausschreiber</th>
|
||||
<th class="text-left px-4 py-2 font-medium text-slate-600">Titel</th>
|
||||
<th class="text-left px-4 py-2 font-medium text-slate-600">Volumen (gesch.)</th>
|
||||
<th class="text-left px-4 py-2 font-medium text-slate-600">Aufgaben</th>
|
||||
<th class="text-left px-4 py-2 font-medium text-slate-600">Status</th>
|
||||
<th class="text-left px-4 py-2 font-medium text-slate-600">Abgabe</th>
|
||||
<th class="text-left px-4 py-2 font-medium text-slate-600">Bid Manager</th>
|
||||
@@ -14,12 +16,21 @@
|
||||
<tbody class="divide-y divide-slate-100">
|
||||
{% for a in ausschreibungen %}
|
||||
<tr class="hover:bg-slate-50">
|
||||
<td class="px-4 py-2 text-slate-600 font-medium">{{ a.ausschreiber|default:"—" }}</td>
|
||||
<td class="px-4 py-2">
|
||||
<a href="{% url 'ausschreibungen:detail' a.pk %}" class="text-brand-700 hover:underline font-medium">
|
||||
{{ a.titel }}
|
||||
</a>
|
||||
</td>
|
||||
<td class="px-4 py-2 text-slate-600">{{ a.ausschreiber }}</td>
|
||||
<td class="px-4 py-2 text-slate-600 text-right whitespace-nowrap">
|
||||
{% if a.geschaetztes_volumen %}{{ a.geschaetztes_volumen|floatformat:0 }} €{% else %}—{% endif %}
|
||||
</td>
|
||||
<td class="px-4 py-2 text-slate-600 whitespace-nowrap">
|
||||
{% if a.aufgaben_total %}
|
||||
{{ a.aufgaben_erledigt }}/{{ a.aufgaben_total }}
|
||||
({% widthratio a.aufgaben_erledigt a.aufgaben_total 100 %} %)
|
||||
{% else %}—{% endif %}
|
||||
</td>
|
||||
<td class="px-4 py-2">
|
||||
{% status_badge a.status a.get_status_display %}
|
||||
</td>
|
||||
|
||||
@@ -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)]">
|
||||
|
||||
@@ -43,11 +43,9 @@
|
||||
<th class="pb-2 font-medium text-slate-600">Datum</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for eintrag in eintraege %}
|
||||
{% include "feedback/partials/eintrag_zeile.html" with status_choices=status_choices %}
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
{% for eintrag in eintraege %}
|
||||
{% include "feedback/partials/eintrag_zeile.html" %}
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="text-slate-500 text-sm">Keine Einträge gefunden.</p>
|
||||
|
||||
@@ -1,39 +1,115 @@
|
||||
<tr id="eintrag-{{ eintrag.pk }}" class="border-b border-slate-100 hover:bg-slate-50 text-sm">
|
||||
<td class="py-2 font-medium text-slate-800 max-w-xs">
|
||||
{{ eintrag.titel|truncatechars:60 }}
|
||||
{% if eintrag.ausschreibung %}
|
||||
<span class="text-xs text-slate-400 block">{{ eintrag.ausschreibung.titel|truncatechars:40 }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="py-2 text-slate-500 max-w-xs text-xs">{{ eintrag.beschreibung|truncatechars:100 }}</td>
|
||||
<td class="py-2">
|
||||
<span class="inline-block rounded-full px-2 py-0.5 text-xs
|
||||
{% if eintrag.kategorie == 'fehler' %}bg-red-100 text-red-700
|
||||
{% elif eintrag.kategorie == 'verbesserung' %}bg-blue-100 text-blue-700
|
||||
{% else %}bg-slate-100 text-slate-700{% endif %}">
|
||||
{{ eintrag.get_kategorie_display }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="py-2">
|
||||
<span class="inline-block rounded-full px-2 py-0.5 text-xs
|
||||
{% if eintrag.dringlichkeit == 'kritisch' %}bg-red-100 text-red-700
|
||||
{% elif eintrag.dringlichkeit == 'hoch' %}bg-orange-100 text-orange-700
|
||||
{% elif eintrag.dringlichkeit == 'mittel' %}bg-amber-100 text-amber-700
|
||||
{% else %}bg-slate-100 text-slate-600{% endif %}">
|
||||
{{ eintrag.get_dringlichkeit_display }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="py-2">
|
||||
<form hx-post="/feedback/backlog/{{ eintrag.pk }}/status/"
|
||||
hx-target="#eintrag-{{ eintrag.pk }}"
|
||||
hx-swap="outerHTML">
|
||||
{% csrf_token %}
|
||||
<select name="status" onchange="this.form.requestSubmit()" class="text-xs border-slate-200 rounded p-1">
|
||||
{% for val, label in status_choices %}
|
||||
<option value="{{ val }}" {% if val == eintrag.status %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</form>
|
||||
</td>
|
||||
<td class="py-2 text-xs text-slate-500">{{ eintrag.datum|date:"d.m.Y" }}</td>
|
||||
</tr>
|
||||
<tbody id="eintrag-wrapper-{{ eintrag.pk }}" x-data="{ editing: false }">
|
||||
|
||||
{# display row #}
|
||||
<tr class="border-b border-slate-100 hover:bg-slate-50 text-sm cursor-pointer select-none"
|
||||
@click="editing = !editing">
|
||||
<td class="py-2 pr-3 font-medium text-slate-800 max-w-xs">
|
||||
<span class="flex items-start gap-1.5">
|
||||
<span class="mt-0.5 text-slate-300 text-xs shrink-0" x-text="editing ? '▾' : '▸'"></span>
|
||||
<span>
|
||||
{{ eintrag.titel }}
|
||||
{% if eintrag.ausschreibung %}
|
||||
<span class="text-xs text-slate-400 block font-normal">{{ eintrag.ausschreibung.titel|truncatechars:40 }}</span>
|
||||
{% endif %}
|
||||
</span>
|
||||
</span>
|
||||
</td>
|
||||
<td class="py-2 pr-3 text-slate-500 text-xs max-w-xs">{{ eintrag.beschreibung|truncatechars:120 }}</td>
|
||||
<td class="py-2 pr-3">
|
||||
<span class="inline-block rounded-full px-2 py-0.5 text-xs
|
||||
{% if eintrag.kategorie == 'fehler' %}bg-red-100 text-red-700
|
||||
{% elif eintrag.kategorie == 'verbesserung' %}bg-blue-100 text-blue-700
|
||||
{% else %}bg-slate-100 text-slate-700{% endif %}">
|
||||
{{ eintrag.get_kategorie_display }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="py-2 pr-3">
|
||||
<span class="inline-block rounded-full px-2 py-0.5 text-xs
|
||||
{% if eintrag.dringlichkeit == 'kritisch' %}bg-red-100 text-red-700
|
||||
{% elif eintrag.dringlichkeit == 'hoch' %}bg-orange-100 text-orange-700
|
||||
{% elif eintrag.dringlichkeit == 'mittel' %}bg-amber-100 text-amber-700
|
||||
{% else %}bg-slate-100 text-slate-600{% endif %}">
|
||||
{{ eintrag.get_dringlichkeit_display }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="py-2 pr-3" @click.stop>
|
||||
<form hx-post="/feedback/backlog/{{ eintrag.pk }}/bearbeiten/"
|
||||
hx-target="#eintrag-wrapper-{{ eintrag.pk }}"
|
||||
hx-swap="outerHTML">
|
||||
{% csrf_token %}
|
||||
<select name="status" onchange="this.form.requestSubmit()"
|
||||
class="text-xs border border-slate-200 rounded p-1 bg-white">
|
||||
{% for val, label in status_choices %}
|
||||
<option value="{{ val }}" {% if val == eintrag.status %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</form>
|
||||
</td>
|
||||
<td class="py-2 text-xs text-slate-500 whitespace-nowrap">{{ eintrag.datum|date:"d.m.Y" }}</td>
|
||||
</tr>
|
||||
|
||||
{# edit row #}
|
||||
<tr x-show="editing" x-cloak class="bg-slate-50 border-b border-slate-200">
|
||||
<td colspan="6" class="px-6 pt-3 pb-4">
|
||||
<form hx-post="/feedback/backlog/{{ eintrag.pk }}/bearbeiten/"
|
||||
hx-target="#eintrag-wrapper-{{ eintrag.pk }}"
|
||||
hx-swap="outerHTML">
|
||||
{% csrf_token %}
|
||||
<div class="grid grid-cols-3 gap-3 mb-3">
|
||||
<div class="col-span-3">
|
||||
<label class="form-label">Titel</label>
|
||||
<input type="text" name="titel" value="{{ eintrag.titel }}"
|
||||
class="form-input w-full text-sm" required>
|
||||
</div>
|
||||
<div class="col-span-3">
|
||||
<label class="form-label">Beschreibung</label>
|
||||
<textarea name="beschreibung" rows="3"
|
||||
class="form-input w-full text-sm">{{ eintrag.beschreibung }}</textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label">Kategorie</label>
|
||||
<select name="kategorie" class="form-input text-sm">
|
||||
{% for val, label in kategorie_choices %}
|
||||
<option value="{{ val }}" {% if val == eintrag.kategorie %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label">Dringlichkeit</label>
|
||||
<select name="dringlichkeit" class="form-input text-sm">
|
||||
{% for val, label in dringlichkeit_choices %}
|
||||
<option value="{{ val }}" {% if val == eintrag.dringlichkeit %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label">Status</label>
|
||||
<select name="status" class="form-input text-sm">
|
||||
{% for val, label in status_choices %}
|
||||
<option value="{{ val }}" {% if val == eintrag.status %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-span-3">
|
||||
<label class="form-label">Bewertung / Notizen</label>
|
||||
<textarea name="bewertung" rows="2"
|
||||
class="form-input w-full text-sm"
|
||||
placeholder="Interne Einschätzung...">{{ eintrag.bewertung }}</textarea>
|
||||
</div>
|
||||
<div class="col-span-3">
|
||||
<label class="form-label">Entscheidung</label>
|
||||
<textarea name="entscheidung" rows="2"
|
||||
class="form-input w-full text-sm"
|
||||
placeholder="Wie wird damit umgegangen?">{{ eintrag.entscheidung }}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button type="submit" class="btn-primary text-sm py-1.5 px-4">Speichern</button>
|
||||
<button type="button" @click="editing = false"
|
||||
class="btn-secondary text-sm py-1.5 px-4">Abbrechen</button>
|
||||
</div>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
<th class="pb-2 pr-4">Nr.</th>
|
||||
<th class="pb-2 pr-4">Titel</th>
|
||||
<th class="pb-2 pr-4">Zuständig</th>
|
||||
<th class="pb-2 pr-4">Aufgaben</th>
|
||||
<th class="pb-2 pr-4">Teilnahme</th>
|
||||
<th class="pb-2"></th>
|
||||
</tr>
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
<form hx-post="{% url 'ausschreibungen:lose:neu' ausschreibung.pk %}"
|
||||
hx-target="#lose-tbody"
|
||||
hx-swap="afterbegin"
|
||||
hx-on::after-request="if(event.detail.successful) this.reset(); document.getElementById('lose-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-none w-20">
|
||||
<label class="form-label">Nr.</label>
|
||||
{{ form.losnummer }}
|
||||
</div>
|
||||
<div class="flex-1 min-w-40">
|
||||
<label class="form-label">Titel *</label>
|
||||
{{ form.lostitel }}
|
||||
</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('lose-form-container').innerHTML = ''">Abbrechen</button>
|
||||
</div>
|
||||
</div>
|
||||
{% if form.losnummer.errors or form.lostitel.errors %}
|
||||
<p class="text-red-600 text-xs mt-1">
|
||||
{{ form.losnummer.errors.0 }}{{ form.lostitel.errors.0 }}
|
||||
</p>
|
||||
{% endif %}
|
||||
</form>
|
||||
@@ -6,6 +6,12 @@
|
||||
class="text-brand-600 hover:underline font-medium">{{ los.lostitel }}</a>
|
||||
</td>
|
||||
<td class="py-2 pr-4 text-slate-600">{{ los.zustaendiger|default:"—" }}</td>
|
||||
<td class="py-2 pr-4 text-slate-600 whitespace-nowrap">
|
||||
{% if los.aufgaben_total %}
|
||||
{{ los.aufgaben_erledigt }}/{{ los.aufgaben_total }}
|
||||
({% widthratio los.aufgaben_erledigt los.aufgaben_total 100 %} %)
|
||||
{% else %}—{% endif %}
|
||||
</td>
|
||||
<td class="py-2 pr-4">
|
||||
{% if los.teilnahme is None %}
|
||||
<span class="text-slate-400 text-xs">Offen</span>
|
||||
|
||||
@@ -13,7 +13,9 @@
|
||||
{{ phase.nummer }}
|
||||
</span>
|
||||
<span class="truncate">{{ phase.name }}</span>
|
||||
{% if phase.warnung %}
|
||||
{% if phase.aufgaben_score.total %}
|
||||
<span class="ml-auto text-slate-400 text-xs shrink-0">{{ phase.aufgaben_score.abgeschlossen }}/{{ phase.aufgaben_score.total }}</span>
|
||||
{% elif phase.warnung %}
|
||||
<span class="ml-auto text-amber-500 text-xs shrink-0">⚠</span>
|
||||
{% endif %}
|
||||
</a>
|
||||
|
||||
@@ -45,8 +45,9 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a href="/aufgaben/" class="sidebar-link">Aufgaben</a>
|
||||
<a href="/marktbegleiter/" class="sidebar-link">Marktbegleiter</a>
|
||||
<a href="/feedback/" class="sidebar-link">Feedback-Backlog</a>
|
||||
<a href="/feedback/backlog/" class="sidebar-link">Feedback-Backlog</a>
|
||||
|
||||
{% if request.user.is_staff %}
|
||||
<a href="/admin/" class="sidebar-link text-slate-400">Administration</a>
|
||||
|
||||
50
wiki/DesignSystem.md
Normal file
50
wiki/DesignSystem.md
Normal 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 4–12px-Shadow.
|
||||
- 0–4px 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`.
|
||||
@@ -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
|
||||
|
||||
@@ -5,6 +5,7 @@ status: done
|
||||
phase: 2-of-12
|
||||
created: "2026-05-08"
|
||||
depends_on: WP-0001
|
||||
domain: communication
|
||||
---
|
||||
|
||||
# WP-0002 — Fachmodelle
|
||||
|
||||
@@ -5,6 +5,7 @@ status: done
|
||||
phase: 3-of-12
|
||||
created: "2026-05-08"
|
||||
depends_on: WP-0002
|
||||
domain: communication
|
||||
---
|
||||
|
||||
# WP-0003 — Basis-UI
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -5,6 +5,7 @@ status: done
|
||||
phase: 7-of-12
|
||||
created: "2026-05-08"
|
||||
depends_on: WP-0006
|
||||
domain: communication
|
||||
---
|
||||
|
||||
# WP-0007 — Dokumentenmanagement
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -5,6 +5,7 @@ status: done
|
||||
phase: 11-of-12
|
||||
created: "2026-05-08"
|
||||
depends_on: WP-0010
|
||||
domain: communication
|
||||
---
|
||||
|
||||
# WP-0011 — Marktbegleiter-Analyse
|
||||
|
||||
@@ -5,6 +5,7 @@ status: done
|
||||
phase: 12-of-12
|
||||
created: "2026-05-08"
|
||||
depends_on: WP-0011
|
||||
domain: communication
|
||||
---
|
||||
|
||||
# WP-0012 — Querschnitt
|
||||
|
||||
144
workplans/WP-0013-feedback-bugs.md
Normal file
144
workplans/WP-0013-feedback-bugs.md
Normal file
@@ -0,0 +1,144 @@
|
||||
---
|
||||
id: WP-0013
|
||||
title: Feedback-Bugs — Fehlerbehebungen und Verbesserungen aus dem Backlog
|
||||
status: done
|
||||
phase: 13-of-13
|
||||
created: "2026-05-14"
|
||||
depends_on: WP-0012
|
||||
domain: communication
|
||||
---
|
||||
|
||||
# WP-0013 — Feedback-Bugs
|
||||
|
||||
Fehlerbehebungen und Verbesserungen, die während der ersten realen Nutzung
|
||||
des Systems im Feedback-Backlog erfasst wurden. Priorisiert nach Dringlichkeit:
|
||||
zuerst die kritischen Fehler (Fristen, Teilnahmeentscheidung), dann mittlere
|
||||
(Phase 3, Abgabe, Listen-Darstellung), zuletzt kosmetische Hinweise.
|
||||
|
||||
---
|
||||
|
||||
```task
|
||||
id: WP-0013-T01
|
||||
title: Fehler — Fristen verschwinden nach Speichern (Feedback #4, Hoch)
|
||||
status: done
|
||||
|
||||
Nach dem Speichern einer Ausschreibung sind beim erneuten Bearbeiten die Fristen
|
||||
nicht mehr vorausgefüllt. Ursache vermutlich: DateTimeField-Konvertierung oder
|
||||
fehlendes `initial`-Argument im Bearbeitungsformular.
|
||||
|
||||
Schritte:
|
||||
1. `apps/ausschreibungen/views.py` — Bearbeitungs-View debuggen: prüfen, ob
|
||||
die Fristen-Felder korrekt aus der Instanz gelesen und ans Template übergeben
|
||||
werden.
|
||||
2. Formular-Template prüfen: `value="{{ form.frist_angebot.value|date:'Y-m-d\\TH:i' }}"` o.ä.
|
||||
3. Reproduzieren: Ausschreibung anlegen, Fristen setzen, speichern, erneut öffnen.
|
||||
4. Fix und Regression-Test.
|
||||
```
|
||||
|
||||
```task
|
||||
id: WP-0013-T02
|
||||
title: Fehler — Phase Teilnahmeentscheidung liefert Fehler (Feedback #6, Hoch)
|
||||
status: done
|
||||
|
||||
Die Seite für Phase 2 "Teilnahmeentscheidung" gibt einen Fehler aus oder fehlt.
|
||||
|
||||
Schritte:
|
||||
1. URL `/ausschreibungen/<pk>/teilnahmeentscheidung/` aufrufen und Traceback lesen.
|
||||
2. View und Template für Phase 2 identifizieren.
|
||||
3. Fehlenden Import, fehlende Migration oder fehlendes Template beheben.
|
||||
4. Manuell testen: Teilnahmeentscheidung für eine Ausschreibung setzen.
|
||||
```
|
||||
|
||||
```task
|
||||
id: WP-0013-T03
|
||||
title: Fehler — Phase "Detaillierte Durchsicht" fehlerhaft (Feedback #7, Mittel)
|
||||
status: done
|
||||
|
||||
Die Seite für Phase 3 "Detaillierte Durchsicht & offene Punkte" ist fehlerhaft
|
||||
oder fehlt.
|
||||
|
||||
Schritte:
|
||||
1. URL aufrufen, Traceback/Fehlermeldung dokumentieren.
|
||||
2. View, URLs und Template der Phase prüfen.
|
||||
3. Ursache beheben (fehlender Import, Query-Fehler, Template-Variable).
|
||||
4. Manuell testen.
|
||||
```
|
||||
|
||||
```task
|
||||
id: WP-0013-T04
|
||||
title: Fehler — Abgabe-Seite defekt (Feedback #8, Mittel)
|
||||
status: done
|
||||
|
||||
Die Seite für Phase 7 "Abgabe" funktioniert nicht korrekt.
|
||||
|
||||
Schritte:
|
||||
1. URL `/ausschreibungen/<pk>/abgabe/` aufrufen, Fehler dokumentieren.
|
||||
2. `apps/nachbetrachtung/views.py` und `abgabe_views.py` prüfen.
|
||||
3. Namespace-Fehler in Templates ausschließen (wurden in WP-0012 teilweise bereits
|
||||
gefixt — prüfen ob noch weitere URL-Referenzen falsch sind).
|
||||
4. Fix und manuell testen.
|
||||
```
|
||||
|
||||
```task
|
||||
id: WP-0013-T05
|
||||
title: Hinweis — Ausschreibungen-Liste: Ausschreiber zuerst, Volumen ergänzen (Feedback #5, Mittel)
|
||||
status: done
|
||||
|
||||
In der Liste "Alle Ausschreibungen" soll der Ausschreiber in die erste Spalte,
|
||||
der Titel in die zweite. Das geschätzte Auftragsvolumen soll als weitere Spalte
|
||||
erscheinen.
|
||||
|
||||
Schritte:
|
||||
1. `templates/ausschreibungen/liste.html` — Spaltenreihenfolge anpassen.
|
||||
2. Sicherstellen, dass `ausschreiber` und `auftragsvolumen` im Queryset und
|
||||
Template-Kontext vorhanden sind.
|
||||
3. `auftragsvolumen` ggf. formatiert darstellen (Tausendertrennzeichen, EUR).
|
||||
```
|
||||
|
||||
```task
|
||||
id: WP-0013-T06
|
||||
title: Fehler — Feedback-Backlog bei leerem Bestand (Feedback #1, Niedrig)
|
||||
status: done
|
||||
|
||||
Die Feedback-Backlog-Seite liefert einen Fehler, wenn noch kein Eintrag vorhanden
|
||||
ist. Vermutlich ein Template-Fehler beim Iterieren über ein leeres QuerySet.
|
||||
|
||||
Schritte:
|
||||
1. Alle Einträge temporär deaktivieren oder neuen User testen.
|
||||
2. `templates/feedback/backlog.html` — `{% if eintraege %}` Guard prüfen
|
||||
(ist vorhanden, aber ggf. greift `.count` oder ein anderer Ausdruck vorher).
|
||||
3. Fix, ggf. auch den `{{ eintraege.count }}` im Header absichern.
|
||||
```
|
||||
|
||||
```task
|
||||
id: WP-0013-T07
|
||||
title: Hinweis — Rechtsgrundlage in Ausschreibungs-Stammdaten (Feedback #2, Mittel)
|
||||
status: done
|
||||
|
||||
In den Stammdaten der Ausschreibung fehlt das Feld "Rechtsgrundlage" (z.B. VgV,
|
||||
UVgO, VOB/A, SektVO).
|
||||
|
||||
Schritte:
|
||||
1. `apps/ausschreibungen/models.py` — Feld `rechtsgrundlage` ergänzen
|
||||
(CharField mit choices: VgV, UVgO, VOB/A, SektVO, GWB, Sonstige).
|
||||
2. Migration erstellen und anwenden.
|
||||
3. Formular und Template für Stammdaten erweitern.
|
||||
4. In der Detailansicht darstellen.
|
||||
```
|
||||
|
||||
```task
|
||||
id: WP-0013-T08
|
||||
title: Hinweis — Bindefrist: Tage erfassen, Datum berechnen (Feedback #3, Niedrig)
|
||||
status: done
|
||||
|
||||
Bindefrist soll als Anzahl Tage erfasst werden. Das konkrete Enddatum ergibt
|
||||
sich dann aus "Abgabe bis" + Bindefrist-Tage und soll ergänzend dargestellt werden.
|
||||
|
||||
Schritte:
|
||||
1. `apps/ausschreibungen/models.py` — Feld `bindefrist_tage` (PositiveIntegerField,
|
||||
optional) ergänzen neben dem bestehenden Datums-Feld (falls vorhanden).
|
||||
2. Migration erstellen.
|
||||
3. Template: berechnetes Datum `{{ ausschreibung.abgabe_bis|add_days:ausschreibung.bindefrist_tage }}`
|
||||
oder Berechnung im View/Model-Property.
|
||||
4. Formular anpassen.
|
||||
```
|
||||
243
workplans/WP-0014-aufgaben-lose-scores.md
Normal file
243
workplans/WP-0014-aufgaben-lose-scores.md
Normal file
@@ -0,0 +1,243 @@
|
||||
---
|
||||
id: WP-0014
|
||||
title: Aufgaben-Phasenzuordnung und Fertigstellungs-Scores in Übersichten
|
||||
status: done
|
||||
phase: 14-of-n
|
||||
created: "2026-05-14"
|
||||
depends_on: WP-0013
|
||||
domain: communication
|
||||
---
|
||||
|
||||
# WP-0014 — Aufgaben-Phasenzuordnung und Scores
|
||||
|
||||
Aufgaben bekommen ein Phasenfeld (1–8). In den Übersichten von
|
||||
Ausschreibungen, Losen und der Phasennavigation wird jeweils die Anzahl
|
||||
der verknüpften Aufgaben sowie ein Fertigstellungs-Score angezeigt
|
||||
(z.B. "8/10 (80%)"). Status "erledigt" und "verworfen" gelten als
|
||||
abgeschlossen.
|
||||
|
||||
---
|
||||
|
||||
```task
|
||||
id: WP-0014-T01
|
||||
title: Aufgabe.phase — Feld + Migration
|
||||
status: done
|
||||
|
||||
`apps/aufgaben/models.py` — Feld ergänzen:
|
||||
|
||||
```python
|
||||
PHASE_CHOICES = [
|
||||
(1, 'Recherche & Unterlagen'),
|
||||
(2, 'Teilnahmeentscheidung'),
|
||||
(3, 'Detaillierte Durchsicht'),
|
||||
(4, 'Bieterfragen & Klärung'),
|
||||
(5, 'Preismodell'),
|
||||
(6, 'Unterlagen finalisieren'),
|
||||
(7, 'Abgabe'),
|
||||
(8, 'Zuschlag / Nachbetrachtung'),
|
||||
]
|
||||
phase = models.PositiveSmallIntegerField(
|
||||
choices=PHASE_CHOICES, null=True, blank=True
|
||||
)
|
||||
```
|
||||
|
||||
`apps/aufgaben/forms.py` — `phase` zu den Feldern hinzufügen,
|
||||
Widget: `forms.Select(attrs={'class': 'form-input'})`.
|
||||
|
||||
Migration erstellen und ausführen.
|
||||
```
|
||||
|
||||
```task
|
||||
id: WP-0014-T02
|
||||
title: aufgaben_score() Helper in core/services.py
|
||||
status: done
|
||||
|
||||
Wiederverwendbare Hilfsfunktion, die für ein beliebiges Aufgaben-QuerySet
|
||||
den Score berechnet:
|
||||
|
||||
```python
|
||||
def aufgaben_score(qs):
|
||||
"""
|
||||
Gibt dict zurück: total, abgeschlossen, score_pct
|
||||
Abgeschlossen = status in ['erledigt', 'verworfen']
|
||||
"""
|
||||
total = qs.count()
|
||||
abgeschlossen = qs.filter(status__in=['erledigt', 'verworfen']).count()
|
||||
pct = round(abgeschlossen / total * 100) if total else 0
|
||||
return {'total': total, 'abgeschlossen': abgeschlossen, 'score_pct': pct}
|
||||
```
|
||||
|
||||
Kein Template-Tag — die Views rufen die Funktion auf und übergeben
|
||||
das Ergebnis direkt im Kontext.
|
||||
```
|
||||
|
||||
```task
|
||||
id: WP-0014-T03
|
||||
title: Aufgaben-Score in Ausschreibungen-Liste
|
||||
status: done
|
||||
|
||||
`apps/ausschreibungen/views.py` — in der Liste-View das QuerySet mit
|
||||
`annotate` ergänzen:
|
||||
|
||||
```python
|
||||
from django.db.models import Count, Q
|
||||
qs = qs.annotate(
|
||||
aufgaben_total=Count('aufgaben', distinct=True),
|
||||
aufgaben_erledigt=Count(
|
||||
'aufgaben',
|
||||
filter=Q(aufgaben__status__in=['erledigt', 'verworfen']),
|
||||
distinct=True,
|
||||
),
|
||||
)
|
||||
```
|
||||
|
||||
`templates/ausschreibungen/liste_partial.html` — neue Spalte "Aufgaben"
|
||||
nach der Volumen-Spalte:
|
||||
|
||||
```html
|
||||
<th>Aufgaben</th>
|
||||
...
|
||||
<td>
|
||||
{% if a.aufgaben_total %}
|
||||
{{ a.aufgaben_erledigt }}/{{ a.aufgaben_total }}
|
||||
({% widthratio a.aufgaben_erledigt a.aufgaben_total 100 %} %)
|
||||
{% else %}—{% endif %}
|
||||
</td>
|
||||
```
|
||||
```
|
||||
|
||||
```task
|
||||
id: WP-0014-T04
|
||||
title: Aufgaben-Score in Ausschreibungs-Detail
|
||||
status: done
|
||||
|
||||
`apps/ausschreibungen/views.py` — in `ausschreibung_detail` den Score
|
||||
berechnen und übergeben:
|
||||
|
||||
```python
|
||||
from vergabe_teilnahme.apps.core.services import aufgaben_score
|
||||
from vergabe_teilnahme.apps.aufgaben.models import Aufgabe
|
||||
ctx['aufgaben_score'] = aufgaben_score(
|
||||
Aufgabe.objects.filter(ausschreibung=a)
|
||||
)
|
||||
```
|
||||
|
||||
`templates/ausschreibungen/detail.html` — Aufgaben-Score-Badge im
|
||||
Kopfbereich (neben Phase-Status), z.B.:
|
||||
|
||||
```html
|
||||
{% with s=aufgaben_score %}
|
||||
{% if s.total %}
|
||||
<span class="text-xs text-slate-500">
|
||||
Aufgaben: {{ s.abgeschlossen }}/{{ s.total }} ({{ s.score_pct }} %)
|
||||
</span>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
```
|
||||
```
|
||||
|
||||
```task
|
||||
id: WP-0014-T05
|
||||
title: Aufgaben-Score in Lose-Liste
|
||||
status: done
|
||||
|
||||
`apps/lose/views.py` — in `lose_liste` das Lose-QuerySet annotieren:
|
||||
|
||||
```python
|
||||
from django.db.models import Count, Q
|
||||
lose = Los.objects.filter(ausschreibung=a).annotate(
|
||||
aufgaben_total=Count('aufgaben', distinct=True),
|
||||
aufgaben_erledigt=Count(
|
||||
'aufgaben',
|
||||
filter=Q(aufgaben__status__in=['erledigt', 'verworfen']),
|
||||
distinct=True,
|
||||
),
|
||||
)
|
||||
```
|
||||
|
||||
`templates/lose/partials/los_row.html` und `templates/lose/liste.html`
|
||||
— Spalte "Aufgaben" mit Score ergänzen.
|
||||
```
|
||||
|
||||
```task
|
||||
id: WP-0014-T06
|
||||
title: Aufgaben-Score in Phase-Navigation
|
||||
status: done
|
||||
|
||||
`apps/core/services.py` — `build_phase_nav` erweitern: für jede Phase
|
||||
die zugehörigen Aufgaben der Ausschreibung zählen und den Score
|
||||
berechnen. Nur sinnvoll wenn Aufgaben vorhanden sind.
|
||||
|
||||
```python
|
||||
from vergabe_teilnahme.apps.aufgaben.models import Aufgabe
|
||||
from django.db.models import Count, Q
|
||||
|
||||
aufgaben_qs = Aufgabe.objects.filter(ausschreibung=ausschreibung)
|
||||
phasen_stats = {
|
||||
num: aufgaben_score(aufgaben_qs.filter(phase=num))
|
||||
for num, _ in PHASEN
|
||||
}
|
||||
```
|
||||
|
||||
Jeden Phase-Dict um `aufgaben_score`-Werte erweitern.
|
||||
|
||||
`templates/partials/phase_nav.html` — pro Phaseneintrag einen
|
||||
Fortschrittsbalken oder Score-Text anzeigen wenn `aufgaben_total > 0`:
|
||||
|
||||
```html
|
||||
{% if phase.aufgaben_score.total %}
|
||||
<span class="text-xs text-slate-400">
|
||||
{{ phase.aufgaben_score.abgeschlossen }}/{{ phase.aufgaben_score.total }}
|
||||
</span>
|
||||
{% endif %}
|
||||
```
|
||||
```
|
||||
|
||||
```task
|
||||
id: WP-0014-T07
|
||||
title: Phasen-Filter in Aufgaben-Liste
|
||||
status: done
|
||||
|
||||
`apps/aufgaben/views.py` — Phasenfilter ergänzen (analog zum
|
||||
bestehenden Status- und Typ-Filter):
|
||||
|
||||
```python
|
||||
phase_filter = request.GET.get('phase')
|
||||
if phase_filter:
|
||||
qs = qs.filter(phase=phase_filter)
|
||||
```
|
||||
|
||||
`templates/aufgaben/liste.html` — Phasen-Dropdown im Filterbereich:
|
||||
|
||||
```html
|
||||
<div>
|
||||
<label class="form-label">Phase</label>
|
||||
<select name="phase" class="form-input text-xs">
|
||||
<option value="">Alle Phasen</option>
|
||||
{% for val, label in phase_choices %}
|
||||
<option value="{{ val }}"{% if current_phase == val|stringformat:"s" %} selected{% endif %}>
|
||||
{{ val }}. {{ label }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
```
|
||||
|
||||
Kontext: `phase_choices=Aufgabe.PHASE_CHOICES`, `current_phase=phase_filter or ''`.
|
||||
```
|
||||
|
||||
```task
|
||||
id: WP-0014-T08
|
||||
title: Tests + Smoke-Check aller geänderten Seiten
|
||||
status: done
|
||||
|
||||
pytest ausführen — alle 68 bestehenden Tests müssen grün bleiben.
|
||||
|
||||
Zusätzlich per Django-Test-Client prüfen:
|
||||
- Ausschreibungen-Liste: Status 200, enthält Score-Spalte
|
||||
- Lose-Liste: Status 200
|
||||
- Aufgaben-Liste mit ?phase=1: Status 200
|
||||
- Ausschreibungs-Detail: Status 200
|
||||
- Aufgabe anlegen mit phase=3: wird korrekt gespeichert
|
||||
```
|
||||
```
|
||||
423
workplans/WP-0015-aufgaben-verknuepfungen-frist-issuefacade.md
Normal file
423
workplans/WP-0015-aufgaben-verknuepfungen-frist-issuefacade.md
Normal 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
|
||||
```
|
||||
```
|
||||
575
workplans/WP-0016-issue-facade-integration.md
Normal file
575
workplans/WP-0016-issue-facade-integration.md
Normal 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
|
||||
```
|
||||
```
|
||||
399
workplans/WP-0017-whynot-design-tokens.md
Normal file
399
workplans/WP-0017-whynot-design-tokens.md
Normal 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 0–4px 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.
|
||||
Reference in New Issue
Block a user