Established workplans

This commit is contained in:
2026-05-08 07:54:06 +02:00
parent 026f7c0db1
commit 315143a6fc
16 changed files with 5492 additions and 0 deletions

34
workplans/README.md Normal file
View File

@@ -0,0 +1,34 @@
# Workplans — Vergabe Teilnahme
Vollständiger Implementierungsplan in 12 Ralph-Loop-Workplans.
Jeder Workplan ist mit `/ralph-workplan workplans/WP-XXXX-slug.md` zu starten.
## Reihenfolge
| Workplan | Inhalt | Tasks | Abhängigkeit |
|---|---|---|---|
| WP-0001 | Projektgerüst (Django, uv, Tailwind, Docker) | 12 | — |
| WP-0002 | Alle Fachmodelle, Migrationen, Admin, Seed | 14 | WP-0001 |
| WP-0003 | Basis-UI: Shell, Templates, Template-Tags, Nav | 10 | WP-0002 |
| WP-0004 | Dashboard, Ausschreibungen CRUD | 12 | WP-0003 |
| WP-0005 | Lose und Anforderungen | 8 | WP-0004 |
| WP-0006 | Aufgaben und Bieterfragen | 6 | WP-0005 |
| WP-0007 | Dokumentenmanagement | 6 | WP-0006 |
| WP-0008 | Preise und Marktpreisauswertung | 5 | WP-0007 |
| WP-0009 | Abgabe und Nachbetrachtung | 5 | WP-0008 |
| WP-0010 | Subunternehmer, Partner, Bibliothek | 7 | WP-0009 |
| WP-0011 | Marktbegleiter-Analyse | 4 | WP-0010 |
| WP-0012 | Freigaben, Flexible Felder, Feedback, E2E-Tests | 8 | WP-0011 |
## Start
```bash
# Neue Session starten (Kontext muss frisch sein — /compact bei Bedarf)
/ralph-workplan workplans/WP-0001-projektgeruest.md --max-iterations 15
```
## Referenzdokumente
- `wiki/ProductRequirementsDocument.md` — fachliche Anforderungen
- `wiki/ArchitectureBlueprint.md` — technische Architektur, Modelle, URL-Struktur
- `wiki/UseCaseCatalog.md` — 56 Use Cases mit konkreten UI-Abläufen

View File

@@ -0,0 +1,405 @@
---
id: WP-0001
title: Projektgerüst — Django-Setup, Tailwind, Dev-Stack
status: todo
phase: 1-of-12
created: "2026-05-08"
---
# WP-0001 — Projektgerüst
Legt das vollständige Django-Projektgerüst an: uv, Projektstruktur, Settings,
alle App-Hüllen, Tailwind CSS v4 via Vite, HTMX + Alpine.js, Docker Compose
für PostgreSQL, pytest-django, Makefile.
**Referenzdokumente:** `wiki/ArchitectureBlueprint.md` Abschnitte 2 und 3.
**Arbeitsverzeichnis:** `/home/worsch/vergabe-teilnahme/`
---
```task
id: WP-0001-T01
title: pyproject.toml und uv-Projektstruktur anlegen
status: todo
Erstelle `pyproject.toml` mit uv als Package-Manager.
Abhängigkeiten (production):
django>=5.2, psycopg[binary]>=3.2, django-storages>=1.14,
whitenoise>=6.7, python-decouple>=3.8
Abhängigkeiten (dev):
pytest-django>=4.8, pytest-cov>=5.0, factory-boy>=3.3,
ruff>=0.4, mypy>=1.10, django-stubs>=5.0
Python: >=3.12
Erstelle außerdem `.python-version` mit `3.12`.
Führe `uv sync` aus und bestätige, dass die virtuelle Umgebung erstellt wird.
```
```task
id: WP-0001-T02
title: Django-Projekt initialisieren und Settings-Struktur anlegen
status: todo
Führe `uv run django-admin startproject vergabe_teilnahme .` aus
(Punkt am Ende — kein verschachteltes Projektverzeichnis).
Erstelle `vergabe_teilnahme/settings/` mit:
- `__init__.py` (leer)
- `base.py` — gemeinsame Settings (INSTALLED_APPS, TEMPLATES, STATIC, MEDIA,
AUTH_USER_MODEL = 'accounts.Mitarbeiter', DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField',
LANGUAGE_CODE = 'de-de', TIME_ZONE = 'Europe/Berlin', USE_I18N = True, USE_TZ = True)
- `dev.py` — importiert base, setzt DEBUG=True, ALLOWED_HOSTS=['*'],
DATABASE aus python-decouple .env
- `prod.py` — importiert base, DEBUG=False, ALLOWED_HOSTS aus Env,
WhiteNoise-Middleware, SECURE_* Flags
Passe `manage.py` und `vergabe_teilnahme/wsgi.py` auf
`DJANGO_SETTINGS_MODULE = 'vergabe_teilnahme.settings.dev'` an.
```
```task
id: WP-0001-T03
title: .env.example und PostgreSQL-Konfiguration
status: todo
Erstelle `.env.example`:
```
DATABASE_URL=postgres://vergabe:vergabe@localhost:5432/vergabe_db
SECRET_KEY=change-me-in-production
DEBUG=True
ALLOWED_HOSTS=localhost,127.0.0.1
MEDIA_ROOT=media/
MAX_UPLOAD_SIZE=52428800
```
In `settings/base.py` lese DATABASE_URL via `python-decouple` und parse mit
`dj-database-url` (füge `dj-database-url>=2.1` zu pyproject.toml hinzu).
Erstelle `.env` (nur lokal, in .gitignore) mit Entwicklungswerten.
Prüfe dass `.env` und `*.sqlite3` in `.gitignore` enthalten sind.
```
```task
id: WP-0001-T04
title: Alle Django-Apps anlegen
status: todo
Erstelle folgende Apps mit `uv run manage.py startapp <name>`:
core, accounts, ausschreibungen, lose, aufgaben,
dokumente, preise, partner, bibliothek, marktbegleiter,
nachbetrachtung, feedback
Verschiebe jede App in ein eigenes Unterverzeichnis:
`vergabe_teilnahme/apps/<app_name>/`
Passe in jeder App `apps.py` den `name` auf `vergabe_teilnahme.apps.<app_name>` an.
Füge alle Apps zu `INSTALLED_APPS` in `settings/base.py` hinzu.
Stelle sicher, dass `django.contrib.contenttypes` ebenfalls in INSTALLED_APPS ist
(wird für GenericForeignKey im core-Modell benötigt).
```
```task
id: WP-0001-T05
title: Tailwind CSS v4 via Vite integrieren
status: todo
Erstelle `package.json` im Projektwurzelverzeichnis:
```json
{
"scripts": {
"dev": "vite build --watch",
"build": "vite build"
},
"devDependencies": {
"vite": "^5.0",
"@tailwindcss/vite": "^4.0",
"tailwindcss": "^4.0"
}
}
```
Erstelle `vite.config.js`:
```js
import { defineConfig } from 'vite'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [tailwindcss()],
build: { outDir: 'static/dist', emptyOutDir: true,
rollupOptions: { input: 'static/src/main.css' } }
})
```
Erstelle `static/src/main.css`:
```css
@import "tailwindcss";
@layer base { /* German-app base resets */ }
@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 phase-badge bg-slate-200 text-slate-500; }
.phase-active { @apply phase-badge bg-brand-500 text-white; }
.phase-done { @apply phase-badge bg-green-500 text-white; }
.phase-warn { @apply phase-badge 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; }
}
```
Füge CSS-Theme-Token für `brand` in `main.css` hinzu:
```css
@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;
}
```
Konfiguriere Django STATICFILES_DIRS und STATIC_ROOT in settings/base.py.
Füge `STATICFILES_STORAGE = 'whitenoise.storage.CompressedManifestStaticFilesStorage'`
in settings/prod.py ein.
Führe `npm install` aus.
```
```task
id: WP-0001-T06
title: HTMX und Alpine.js einbinden
status: todo
Lade HTMX und Alpine.js als lokale Vendor-Dateien (keine CDN-Abhängigkeit):
- `static/vendor/htmx/htmx.min.js` — HTMX 2.x (von unpkg herunterladen)
- `static/vendor/alpinejs/alpine.min.js` — Alpine.js 3.x
Füge in `settings/base.py` hinzu:
```python
STATICFILES_DIRS = [BASE_DIR / 'static']
```
Die Einbindung im base.html-Template erfolgt in WP-0003.
Erstelle hier nur die Verzeichnisstruktur und die Dateien.
Prüfe: `ls static/vendor/htmx/` und `ls static/vendor/alpinejs/` sollten die Dateien zeigen.
```
```task
id: WP-0001-T07
title: Docker Compose für Entwicklungs-PostgreSQL
status: todo
Erstelle `docker-compose.dev.yml`:
```yaml
services:
db:
image: postgres:16-alpine
environment:
POSTGRES_DB: vergabe_db
POSTGRES_USER: vergabe
POSTGRES_PASSWORD: vergabe
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
volumes:
postgres_data:
```
Erstelle außerdem `docker-compose.test.yml` für CI (gleiche Konfiguration,
anderer DB-Name: `vergabe_test`).
Starte die DB: `docker compose -f docker-compose.dev.yml up -d`
Prüfe Verbindung: `uv run manage.py check --database default`
```
```task
id: WP-0001-T08
title: pytest-django konfigurieren
status: todo
Füge in `pyproject.toml` hinzu:
```toml
[tool.pytest.ini_options]
DJANGO_SETTINGS_MODULE = "vergabe_teilnahme.settings.dev"
python_files = ["test_*.py"]
python_classes = ["Test*"]
python_functions = ["test_*"]
addopts = "--tb=short -q"
[tool.ruff]
line-length = 100
target-version = "py312"
select = ["E", "F", "I", "N", "UP"]
```
Erstelle `conftest.py` im Projektwurzel:
```python
import pytest
@pytest.fixture
def mitarbeiter(db):
from vergabe_teilnahme.apps.accounts.models import Mitarbeiter
return Mitarbeiter.objects.create_user(
username='testuser', password='testpass', first_name='Test', last_name='User'
)
```
Prüfe: `uv run pytest --co -q` (keine Tests vorhanden, aber Konfiguration valide).
```
```task
id: WP-0001-T09
title: Makefile für häufige Dev-Commands
status: todo
Erstelle `Makefile` im Projektwurzel:
```makefile
.PHONY: dev db migrate shell test lint css
db:
docker compose -f docker-compose.dev.yml up -d
dev: db
uv run manage.py runserver 0.0.0.0:8000
css:
npm run dev
migrate:
uv run manage.py makemigrations
uv run manage.py migrate
shell:
uv run manage.py shell_plus 2>/dev/null || uv run manage.py shell
test:
uv run pytest
lint:
uv run ruff check .
uv run mypy vergabe_teilnahme/
createsuperuser:
uv run manage.py createsuperuser
collectstatic:
uv run manage.py collectstatic --noinput
```
Prüfe: `make db` startet PostgreSQL, `make migrate` läuft fehlerfrei durch
(zu diesem Zeitpunkt noch ohne Fachmodelle — nur Django-Default-Migrationen).
```
```task
id: WP-0001-T10
title: Django URL-Grundkonfiguration und Health-Check
status: todo
Editiere `vergabe_teilnahme/urls.py`:
```python
from django.contrib import admin
from django.urls import path, include
from django.conf import settings
from django.conf.urls.static import static
from django.http import JsonResponse
def health(request):
return JsonResponse({'status': 'ok'})
urlpatterns = [
path('admin/', admin.site.urls),
path('health/', health),
# Module-URLs werden in späteren Workplans ergänzt
]
if settings.DEBUG:
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
```
Füge in `settings/base.py` hinzu:
```python
MEDIA_URL = '/media/'
MEDIA_ROOT = BASE_DIR / 'media'
```
Prüfe: `make dev` startet ohne Fehler, `curl http://localhost:8000/health/`
gibt `{"status": "ok"}` zurück.
```
```task
id: WP-0001-T11
title: CLAUDE.md mit Build-Commands aktualisieren
status: todo
Aktualisiere `/home/worsch/vergabe-teilnahme/CLAUDE.md` um einen Abschnitt
"## Entwicklungs-Commands":
```markdown
## Entwicklungs-Commands
```bash
make db # PostgreSQL via Docker starten
make dev # Django-Dev-Server (Port 8000)
make css # Tailwind CSS im Watch-Modus
make migrate # Migrations generieren und ausführen
make test # pytest ausführen
make lint # ruff + mypy
uv run manage.py test <app> # Einzelne App testen
uv run pytest tests/<pfad>.py # 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
├── vendor/ # HTMX, Alpine.js
└── dist/ # Build-Output (gitignored)
workplans/ # Ralph-Loop-Workplans
wiki/ # PRD, Blueprint, Use-Case-Katalog
```
```
Füge außerdem `static/dist/` zu `.gitignore` hinzu.
```
```task
id: WP-0001-T12
title: Erstes `uv run manage.py migrate` und Smoke-Test
status: todo
Führe die gesamte initiale Setup-Sequenz durch und verifiziere:
1. `make db` → PostgreSQL läuft
2. `uv run manage.py migrate` → alle Django-Default-Migrationen laufen sauber durch
3. `uv run manage.py check` → keine Fehler
4. `uv run pytest` → 0 Tests gesammelt, kein Fehler
5. `npm run build` → `static/dist/` enthält die kompilierte CSS-Datei
6. `make dev` → Server startet, `/health/` antwortet mit 200
Notiere etwaige Fehler und behebe sie. Erst wenn alle 6 Checks bestanden sind
gilt dieser Task als erledigt.
```

View File

@@ -0,0 +1,503 @@
---
id: WP-0002
title: Fachmodelle — alle Django-Models, Migrationen, Admin
status: todo
phase: 2-of-12
created: "2026-05-08"
depends_on: WP-0001
---
# WP-0002 — Fachmodelle
Implementiert alle Django-Modelle gemäß `wiki/ArchitectureBlueprint.md` Abschnitt 5.
Alle Modelle erben von `FlexibleModel`. Am Ende: alle Migrationen, Admin-Registrierung
und ein Management-Command für Seed-Daten.
**Arbeitsverzeichnis:** `/home/worsch/vergabe-teilnahme/`
**Apps-Pfad:** `vergabe_teilnahme/apps/`
---
```task
id: WP-0002-T01
title: Accounts-App: Mitarbeiter-Modell (AbstractUser)
status: todo
Datei: `vergabe_teilnahme/apps/accounts/models.py`
```python
from django.contrib.auth.models import AbstractUser
from django.db import models
class Mitarbeiter(AbstractUser):
ROLLE_CHOICES = [
('bid_manager', 'Bid Manager'),
('fachexperte', 'Fachverantwortlicher'),
('vertrieb', 'Vertrieb / Account Management'),
('pricing', 'Pricing / Controlling'),
('recht', 'Recht / Compliance'),
('geschaeftsfuehrung', 'Geschäftsführung'),
('projektleitung','Projektleitung Umsetzung'),
('admin', 'Administrator'),
]
rolle = CharField(max_length=30, choices=ROLLE_CHOICES, blank=True)
mobilnummer = CharField(max_length=50, blank=True)
organisationseinheit = CharField(max_length=200, blank=True)
def __str__(self):
return self.get_full_name() or self.username
```
Registriere in `accounts/admin.py` mit `UserAdmin`.
Setze `AUTH_USER_MODEL = 'accounts.Mitarbeiter'` in `settings/base.py` (bereits in T02 von WP-0001 vorbereitet — prüfen).
```
```task
id: WP-0002-T02
title: Core-App: FlexibleModel-Mixin und Basis-Infrastruktur
status: todo
Datei: `vergabe_teilnahme/apps/core/models.py`
Implementiere:
1. `FlexibleModel(Model)` — abstract base:
- Hat `GenericRelation` auf `CustomAttribute`
- Methode `get_hidden_fields()` — liest `EntityFieldConfig` für `self._meta.model_name`
- Methode `get_visible_field_names()` — gibt Liste nicht-ausgeblendeter Felder zurück
- `class Meta: abstract = True`
2. `EntityFieldConfig(Model)` — globale Feldkonfiguration pro Entitätstyp:
- `entity_type` CharField(100) — model_name slug (z. B. 'subunternehmer')
- `field_name` CharField(100)
- `is_hidden` BooleanField(default=False)
- `display_label` CharField(200, blank=True) — Umbenennung
- `sort_order` PositiveSmallIntegerField(default=0)
- `Meta: unique_together = ('entity_type', 'field_name')`
3. `CustomAttribute(Model)` — generisches Key-Value-Attribut:
- `content_type` FK(ContentType, CASCADE)
- `object_id` PositiveIntegerField()
- `content_object` GenericForeignKey()
- `key` CharField(100) — slug
- `label` CharField(200) — Anzeigename
- `value` TextField(blank=True)
- `data_type` CharField(20, choices=[text/number/date/boolean/url/email], default='text')
- `sort_order` PositiveSmallIntegerField(default=0)
- `created_at` DateTimeField(auto_now_add=True)
- `Meta: ordering = ['sort_order', 'created_at']`
- `Index` auf `(content_type, object_id)`
4. `Freigabe(Model)` — generische Freigabe:
- `content_type` FK(ContentType, CASCADE)
- `object_id` PositiveIntegerField()
- `content_object` GenericForeignKey()
- `freigabe_typ` CharField(30, choices=[teilnahme/ablehnung/recht/preis/abgabe/standarddokument/referenz])
- `freigebende_person` FK(settings.AUTH_USER_MODEL, PROTECT)
- `status` CharField(20, choices=[erteilt/abgelehnt/ausstehend], default='erteilt')
- `kommentar` TextField(blank=True)
- `timestamp` DateTimeField(auto_now_add=True)
- `Meta: ordering = ['-timestamp']`
Alle vier in `core/admin.py` registrieren.
```
```task
id: WP-0002-T03
title: Core-App: services.py mit Utility-Funktionen
status: todo
Datei: `vergabe_teilnahme/apps/core/services.py`
Implementiere folgende Funktionen (ohne Django-Import-Fehler zur Importzeit):
```python
from datetime import date
from decimal import Decimal
def get_deadline_warnings(ausschreibung):
"""Gibt Liste von Warnungen für nahende Fristen zurück."""
warnings = []
heute = date.today()
if ausschreibung.bieterfragen_bis:
delta = (ausschreibung.bieterfragen_bis - heute).days
if delta <= 3:
warnings.append({'typ': 'bieterfragen', 'tage': delta, 'farbe': 'red' if delta <= 1 else 'amber'})
if ausschreibung.abgabe_bis:
delta = (ausschreibung.abgabe_bis.date() - heute).days
if delta <= 14:
warnings.append({'typ': 'abgabe', 'tage': delta, 'farbe': 'red' if delta <= 3 else 'amber'})
return warnings
def gewichteter_durchschnitt(preispunkte, feld='einzelpreis'):
"""Berechnet gewichteten Durchschnitt für Preispunkte. Gibt None zurück wenn keine verwertbaren Punkte."""
relevante = [p for p in preispunkte
if getattr(p, feld) is not None and p.vergleichsgewicht > 0]
if not relevante:
return None
summe_gewichte = sum(p.vergleichsgewicht for p in relevante)
if summe_gewichte == 0:
return None
summe = sum(getattr(p, feld) * p.vergleichsgewicht for p in relevante)
werte = [getattr(p, feld) for p in relevante]
return {
'wert': summe / summe_gewichte,
'summe_gewichte': summe_gewichte,
'anzahl': len(relevante),
'minimum': min(werte),
'maximum': max(werte),
'ungewichtet': sum(werte) / len(werte),
}
```
Schreibe Tests in `vergabe_teilnahme/apps/core/tests/test_services.py`:
- Test gewichteter_durchschnitt mit Beispiel aus Blueprint (100×1,0 + 80×0,2 + 110×1,2 = 103,33)
- Test mit leerem Input → None
- Test mit allen Gewichten 0 → None
```
```task
id: WP-0002-T04
title: Ausschreibungen-App: Ausschreibung-Modell
status: todo
Datei: `vergabe_teilnahme/apps/ausschreibungen/models.py`
Implementiere `Ausschreibung(FlexibleModel)` gemäß Blueprint Abschnitt 5.1.
Import von FlexibleModel: `from vergabe_teilnahme.apps.core.models import FlexibleModel`
Felder exakt wie im Blueprint definiert (STATUS_CHOICES 1-13, TEILNAHME_CHOICES,
alle Datums-/Fristen-Felder, erstellt_am/geaendert_am auto).
Methoden:
- `__str__` → `self.titel`
- `property ist_aktiv` → status in range(1, 10)
- `property naechste_frist` → das nächste nicht-vergangene Datum aus (bieterfragen_bis, abgabe_bis.date())
- `property phase_nummer` → min(8, max(1, self.status)) — Phasennummer für Navigator
`Meta: ordering = ['-erstellt_am']`
Admin-Registrierung mit list_display=['titel', 'ausschreiber', 'status', 'abgabe_bis'].
```
```task
id: WP-0002-T05
title: Lose-App: Los- und Anforderung-Modell
status: todo
Datei: `vergabe_teilnahme/apps/lose/models.py`
`Los(FlexibleModel)`:
- `ausschreibung` FK(Ausschreibung, CASCADE, related_name='lose')
- `losnummer` CharField(50)
- `lostitel` CharField(300)
- `beschreibung` TextField(blank=True)
- `abgrenzung` TextField(blank=True)
- `zustaendiger` FK('accounts.Mitarbeiter', null=True, blank=True, SET_NULL)
- `teilnahme` BooleanField(null=True) # None = offen
- `status` CharField(50, blank=True)
- `Meta: ordering = ['losnummer']`
- `__str__` → `f"Los {self.losnummer}: {self.lostitel}"`
`Anforderung(FlexibleModel)`:
- `ausschreibung` FK(Ausschreibung, CASCADE)
- `los` FK(Los, null=True, blank=True, SET_NULL, related_name='anforderungen')
- Alle Felder aus Blueprint 5.3 (titel, beschreibung, quelle_im_dokument, kategorie,
verbindlichkeit MUSS_CHOICES, ausschlusskriterium, bewertungskriterium,
zustaendiger FK, erfuellungsstatus CHOICES, nachweis_erforderlich)
- `dokumente` M2M('dokumente.Dokument', blank=True)
- `nachweise` M2M('bibliothek.Nachweis', blank=True)
- `__str__` → `self.titel`
Admin für beide Modelle.
```
```task
id: WP-0002-T06
title: Aufgaben-App: Aufgabe- und Bieterfrage-Modell
status: todo
Datei: `vergabe_teilnahme/apps/aufgaben/models.py`
`Aufgabe(FlexibleModel)` mit allen Feldern aus Blueprint 5.4:
TYP_CHOICES (fachlich/rechtlich/kaufmaennisch/technisch/subunternehmer/dokument/preis)
STATUS_CHOICES (offen/in_bearbeitung/wartend_intern/wartend_sub/wartend_ausschreiber/erledigt/verworfen/ueberfaellig)
FKs auf Ausschreibung (CASCADE), Los (null/blank/SET_NULL), Anforderung, Bieterfrage, Dokument.
prioritaet PositiveSmallIntegerField(default=2, choices=[(1,'Hoch'),(2,'Mittel'),(3,'Niedrig')])
frist DateField(null=True, blank=True)
verantwortlicher FK(Mitarbeiter, null=True, SET_NULL)
ergebnis TextField(blank=True)
Meta: ordering = ['prioritaet', 'frist']
property `ist_ueberfaellig`: frist < date.today() and status not in ['erledigt', 'verworfen']
`Bieterfrage(FlexibleModel)` mit allen Feldern aus Blueprint 5.5:
STATUS_CHOICES (entwurf/abgestimmt/eingereicht/beantwortet/eingearbeitet)
FKs auf Ausschreibung (CASCADE), Anforderung (null/blank), Dokument (null/blank)
prioritaet, einreichungsdatum, antwort, auswirkung_angebot, eingearbeitet
Admin für beide Modelle.
```
```task
id: WP-0002-T07
title: Dokumente-App: Dokument-Modell mit Datei-Upload
status: todo
Datei: `vergabe_teilnahme/apps/dokumente/models.py`
`Dokument(FlexibleModel)` mit allen Feldern aus Blueprint 5.6:
- `datei` FileField(upload_to='dokumente/%Y/%m/')
- `dateiname` CharField(300) — wird beim Upload automatisch aus datei.name befüllt
- Alle Status-/Kategorie-Choices
- `ausschreibung` FK(null=True, blank=True)
- `los` FK(Los, null=True, blank=True)
- `verantwortlicher`, `pruefer` FK(Mitarbeiter)
- `finale_abgabeversion` BooleanField(default=False)
- `upload_datum` DateTimeField(auto_now_add=True)
- `GenericRelation(Freigabe)` für direkte Freigaben-Abfrage
Signal `pre_save`: falls `finale_abgabeversion` von False auf True gesetzt wird,
setze `status='final_abgegeben'`.
Datei-Validierung als Modell-Methode `clean()`:
Erlaubte MIME-Typen: pdf, docx, xlsx, zip, png, jpg (prüfe gegen Dateiendung, keine MIME-Detection in v1)
Maximalgröße: settings.MAX_UPLOAD_SIZE (Default: 50 MB)
Admin-Registrierung.
```
```task
id: WP-0002-T08
title: Preise-App: Preispunkt-Modell
status: todo
Datei: `vergabe_teilnahme/apps/preise/models.py`
`Preispunkt(FlexibleModel)` gemäß Blueprint 5.7:
- `ausschreibung` FK(Ausschreibung, CASCADE)
- `los` FK(Los, null=True, blank=True, SET_NULL)
- `leistungstyp` CharField(200) — freier Text, z. B. "Schulung", "Lizenz", "Betrieb"
- `konkrete_leistung` CharField(400)
- `mengeneinheit`, `waehrung` CharFields mit Defaults
- `menge`, `einzelpreis`, `gesamtpreis` DecimalFields(14, 2 bzw. 4, null=True, blank=True)
- `preisstand` DateField(null=True, blank=True)
- `wiederkehrend` BooleanField(default=False)
- `laufzeitbezug` CharField(100, blank=True)
- `subunternehmeranteil` BooleanField(default=False)
- `subunternehmer` FK('partner.Subunternehmer', null=True, blank=True, SET_NULL)
- `vergleichsgewicht` DecimalField(3, 1, default=Decimal('1.0'))
- `gewichtungsbegruendung` TextField(blank=True)
- `kommentar` TextField(blank=True)
- `ausschreibung_gewonnen` BooleanField(null=True)
Modell-Validierung `clean()`:
- `vergleichsgewicht` muss zwischen Decimal('0.0') und Decimal('2.0') liegen,
sonst ValidationError("Vergleichsgewicht muss zwischen 0,0 und 2,0 liegen.")
`Meta: ordering = ['leistungstyp', 'konkrete_leistung']`
Admin mit list_display=['konkrete_leistung', 'leistungstyp', 'einzelpreis', 'vergleichsgewicht'].
```
```task
id: WP-0002-T09
title: Partner-App: Subunternehmer und Dienstleistertyp
status: todo
Datei: `vergabe_teilnahme/apps/partner/models.py`
`Dienstleistertyp(FlexibleModel)`:
- name CharField(200), beschreibung TextField(blank=True)
- typische_leistungen TextField(blank=True), typische_nachweise TextField(blank=True)
- relevante_standards TextField(blank=True), typische_preisbestandteile TextField(blank=True)
- bemerkungen TextField(blank=True)
- `__str__` → name
`Subunternehmer(FlexibleModel)`:
- Alle Felder aus Blueprint 5.9
- PRAEFERENZ_CHOICES: bevorzugt/zugelassen/gesperrt (default='zugelassen')
- `dienstleistertyp` FK(Dienstleistertyp, null=True, blank=True, SET_NULL)
- `bisherige_ausschreibungen` M2M(Ausschreibung, blank=True, through='SubunternehmerZuordnung')
`SubunternehmerZuordnung(Model)` — Durchgangstabelle (kein FlexibleModel):
- `subunternehmer` FK(Subunternehmer, CASCADE)
- `ausschreibung` FK(Ausschreibung, CASCADE)
- `los` FK(Los, null=True, blank=True, SET_NULL)
- `konkrete_leistung` CharField(300, blank=True)
- `zusage_vorhanden` BooleanField(default=False)
- `nachweis_eingegangen` BooleanField(default=False)
- `preis_vorhanden` BooleanField(default=False)
- `kommentar` TextField(blank=True)
Admin für alle drei Modelle.
```
```task
id: WP-0002-T10
title: Bibliothek-App: Nachweis, Referenz, Leistungsblatt, Entscheidungsregel
status: todo
Datei: `vergabe_teilnahme/apps/bibliothek/models.py`
`Nachweis(FlexibleModel)` — Compliance-Nachweise:
Alle Felder aus Blueprint 6.10 (titel, kurzbeschreibung, dokumenttyp, kategorie,
datei FileField, version, gueltig_ab/bis DateFields, eigentuemer FK(Mitarbeiter),
freigabestatus CharField, letzte_pruefung DateField, zugehoerige_standards TextField,
vertraulichkeit CharField, sprache CharField default='de',
fuer_oeffentliche BooleanField, fuer_privatwirtschaftliche BooleanField)
property `ist_abgelaufen`: gueltig_bis < date.today() if gueltig_bis else False
`Referenz(FlexibleModel)` — Projekt-Referenzen:
Alle Felder aus Blueprint 6.11 (referenztitel, kunde, branche, oeffentlich_oder_privat,
leistungsbeschreibung, eingesetzte_produkte, projektzeitraum CharField,
vertragsvolumen DecimalField(null=True), ansprechpartner_referenzkunde,
freigabestatus_verwendung, vertraulichkeit, whitepaper FileField(null=True),
kurzfassung TextField, langfassung TextField,
verwendbar_fuer_ausschreibungen BooleanField, einschraenkungen_verwendung TextField)
leistungsblaetter M2M('Leistungsblatt', blank=True)
`Leistungsblatt(FlexibleModel)`:
produktfunktion, beschreibung, leistungsumfang, grenzen_ausschluesse,
technische_voraussetzungen, typische_nachweise TextField(blank=True),
version CharField, eigentuemer FK(Mitarbeiter, null=True)
`Entscheidungsregel(FlexibleModel)`:
regelname, beschreibung, kategorie, gewichtung DecimalField(5,2, default=1.0),
bewertungslogik TextField, schwellenwert DecimalField(null=True),
empfehlung CharField(choices=[teilnehmen/nicht_teilnehmen/pruefen]),
begruendung TextField, gueltig_von/bis DateFields(null=True), aktiv BooleanField(default=True),
verantwortlicher FK(Mitarbeiter, null=True)
Admin für alle vier Modelle.
```
```task
id: WP-0002-T11
title: Marktbegleiter-App: Marktbegleiter und Ausschreibungspassage
status: todo
Datei: `vergabe_teilnahme/apps/marktbegleiter/models.py`
`Marktbegleiter(FlexibleModel)` — alle Felder aus Blueprint 6.15:
name CharField(300), kurzbeschreibung TextField,
produkt_leistungsportfolio TextField(blank=True),
relevante_branchen TextField(blank=True),
bekannte_staerken TextField(blank=True), bekannte_schwaechen TextField(blank=True),
typische_formulierungen TextField(blank=True),
typische_leistungsmerkmale TextField(blank=True),
bekannte_zertifizierungen TextField(blank=True),
bekannte_referenzen TextField(blank=True),
quellen_links TextField(blank=True), letzte_aktualisierung DateField(null=True),
aktualisierungsstatus CharField(50, blank=True),
interne_notizen TextField(blank=True),
vertraulichkeit CharField(20, choices=[intern/streng_vertraulich], default='intern')
`Ausschreibungspassage(FlexibleModel)` — alle Felder aus Blueprint 6.16:
ausschreibung FK(Ausschreibung, CASCADE, related_name='passagen')
dokument FK(Dokument, null=True, blank=True, SET_NULL)
fundstelle CharField(200, blank=True)
passage TextField()
kategorie CharField(100, blank=True)
marktbegleiter FK(Marktbegleiter, CASCADE, related_name='passagen')
begruendung_zuordnung TextField()
verlaesslichkeitsscore PositiveSmallIntegerField(
validators=[MinValueValidator(1), MaxValueValidator(10)])
auswirkung_entscheidung TextField(blank=True)
auswirkung_preisstrategie TextField(blank=True)
auswirkung_loesungskonzept TextField(blank=True)
erfasst_von FK(Mitarbeiter, null=True, SET_NULL)
erfassungsdatum DateField(auto_now_add=True)
Admin für beide Modelle.
```
```task
id: WP-0002-T12
title: Nachbetrachtung- und Feedback-Modelle
status: todo
Datei: `vergabe_teilnahme/apps/nachbetrachtung/models.py`
`Nachbetrachtung(FlexibleModel)`:
- `ausschreibung` OneToOneField(Ausschreibung, CASCADE, related_name='nachbetrachtung')
- `ergebnis` CharField(20, choices=[gewonnen/verloren/aufgehoben/offen/zurueckgezogen])
- `zuschlagsdatum` DateField(null=True, blank=True)
- `abgegebene_unterlagen` TextField(blank=True)
- `abgegebene_preise` TextField(blank=True)
- `verlustgruende` JSONField(default=list)
# Format: [{"grund": "...", "kategorie": "preis|referenz|...", "verlaesslichkeit": 1-5}]
- `ausschlaggebende_zuschlagsmerkmale` TextField(blank=True)
- `lessons_learned` TextField(blank=True)
- `empfehlungen` TextField(blank=True)
- `wiederverwendbare_erkenntnisse_markiert` BooleanField(default=False)
- `projektverantwortlicher` FK(Mitarbeiter, null=True, blank=True, SET_NULL)
Datei: `vergabe_teilnahme/apps/feedback/models.py`
`Feedbackeintrag(FlexibleModel)` — alle Felder aus Blueprint 6.18:
titel, beschreibung TextField, seite_kontext CharField(500),
ausschreibung FK(Ausschreibung, null=True, blank=True, SET_NULL),
kategorie CharField(choices=[fehler/verbesserung/hinweis]),
dringlichkeit CharField(choices=[niedrig/mittel/hoch/kritisch], default='mittel'),
prioritaet PositiveSmallIntegerField(default=2),
status CharField(choices=[neu/in_bearbeitung/umgesetzt/abgelehnt], default='neu'),
erfasst_von FK(Mitarbeiter, null=True, SET_NULL),
datum DateTimeField(auto_now_add=True),
bewertung TextField(blank=True), entscheidung TextField(blank=True),
umsetzungshinweis TextField(blank=True)
Admin für beide Modelle.
```
```task
id: WP-0002-T13
title: Alle Migrationen generieren und ausführen
status: todo
Führe aus (in dieser Reihenfolge, da Apps voneinander abhängen):
```bash
uv run manage.py makemigrations accounts
uv run manage.py makemigrations core
uv run manage.py makemigrations ausschreibungen
uv run manage.py makemigrations lose
uv run manage.py makemigrations aufgaben
uv run manage.py makemigrations dokumente
uv run manage.py makemigrations preise
uv run manage.py makemigrations partner
uv run manage.py makemigrations bibliothek
uv run manage.py makemigrations marktbegleiter
uv run manage.py makemigrations nachbetrachtung
uv run manage.py makemigrations feedback
uv run manage.py migrate
```
Behebe etwaige Zirkulär-Import-Fehler durch String-Referenzen (z. B.
`FK('ausschreibungen.Ausschreibung', ...)` statt direktem Import).
Prüfe: `uv run manage.py check` → keine Fehler.
Prüfe: `uv run manage.py showmigrations` → alle Migrationen als [X] markiert.
```
```task
id: WP-0002-T14
title: Management-Command für Entwicklungs-Seed-Daten
status: todo
Erstelle `vergabe_teilnahme/apps/core/management/commands/seed_dev.py`
Der Command erstellt:
1. 3 Mitarbeiter (BM Max Muster, FV Anna Fach, GF Georg Chef)
2. 2 Dienstleistertypen (IT-Dienstleister, Rechtsberatung)
3. 1 Subunternehmer mit Zuordnung zu Dienstleistertyp
4. 2 Nachweise (ISO-27001 gültig, DSGVO-Muster gültig)
5. 1 Marktbegleiter
6. 3 Entscheidungsregeln (Muss-Anforderung nicht erfüllbar, Frist zu kurz, fehlende Referenz)
7. 1 Ausschreibung (Status 4, mit 2 Losen, 3 Anforderungen, 2 Aufgaben, 2 Preispunkten)
Nutze `get_or_create` für alle Objekte damit der Command idempotent ist.
Ausgabe am Ende: Zusammenfassung der erstellten Objekte.
Prüfe: `uv run manage.py seed_dev` läuft fehlerfrei durch.
Prüfe anschließend: `uv run manage.py shell -c "from vergabe_teilnahme.apps.ausschreibungen.models import Ausschreibung; print(Ausschreibung.objects.count())"` → mindestens 1.
```

View File

@@ -0,0 +1,502 @@
---
id: WP-0003
title: Basis-UI — Shell-Layout, Templates, Template-Tags, Navigation
status: todo
phase: 3-of-12
created: "2026-05-08"
depends_on: WP-0002
---
# WP-0003 — Basis-UI
Implementiert das Basis-Template-System: Shell-Layout mit Topbar und Sidebar,
alle Template-Tags (status_badge, phase_badge, render_field, flex_fields),
die globale und kontextuelle Navigation, den Feedback-Button und Error-Pages.
**Arbeitsverzeichnis:** `/home/worsch/vergabe-teilnahme/`
**Templates-Pfad:** `vergabe_teilnahme/templates/`
**Template-Tags:** `vergabe_teilnahme/apps/core/templatetags/`
Tailwind-Klassen aus WP-0001 (`static/src/main.css`) werden hier genutzt.
---
```task
id: WP-0003-T01
title: Template-Verzeichnisstruktur und base.html Shell-Layout
status: todo
Erstelle Verzeichnisstruktur:
```
vergabe_teilnahme/templates/
├── base.html
├── partials/
│ ├── topbar.html
│ ├── sidebar.html
│ ├── breadcrumb.html
│ ├── feedback_button.html
│ ├── feedback_modal.html
│ └── phase_nav.html
├── errors/
│ ├── 404.html
│ └── 500.html
└── (app-spezifische Unterverzeichnisse folgen in späteren Workplans)
```
Füge in `settings/base.py` hinzu:
```python
TEMPLATES = [{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [BASE_DIR / 'vergabe_teilnahme' / 'templates'],
'APP_DIRS': True,
...
}]
```
`base.html` implementiert das Shell-Layout aus Blueprint Abschnitt 6.1:
```html
<!DOCTYPE html>
<html lang="de" x-data="{ sidebarOpen: true }">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Vergabe Teilnahme{% endblock %}</title>
<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">
<!-- Topbar -->
{% include "partials/topbar.html" %}
<div class="flex h-[calc(100vh-56px)]">
<!-- Sidebar -->
{% include "partials/sidebar.html" %}
<!-- Hauptinhalt -->
<main class="flex-1 overflow-y-auto p-6">
{% include "partials/breadcrumb.html" %}
{% block content %}{% endblock %}
</main>
</div>
<!-- Feedback-Button (persistent) -->
{% include "partials/feedback_button.html" %}
<div id="modal-container"></div>
<!-- HTMX -->
<script src="{% static 'vendor/htmx/htmx.min.js' %}"></script>
{% block extra_js %}{% endblock %}
</body>
</html>
```
```
```task
id: WP-0003-T02
title: Topbar-Partial
status: todo
`vergabe_teilnahme/templates/partials/topbar.html`:
Implementiere die Topbar mit:
- Logo / Appname "Vergabe Teilnahme" (links, Link zu /)
- Globale Suchleiste (Mitte):
```html
<form hx-get="/suche/" hx-target="#search-results" hx-trigger="input changed delay:300ms"
class="relative">
<input name="q" type="search" placeholder="Ausschreibung, Aufgabe, Dokument suchen..."
class="form-input w-96">
<div id="search-results" class="absolute top-full left-0 w-full bg-white rounded-b-lg
shadow-lg z-50 hidden [&:not(:empty)]:block">
</div>
</form>
```
- Avatar-Dropdown (rechts): Nutzername + Rolle, "Abmelden"-Link
(nutze Alpine.js x-show für Dropdown)
Topbar-Höhe: `h-14` (56px), `bg-white border-b border-slate-200`.
```
```task
id: WP-0003-T03
title: Sidebar globale Navigation
status: todo
`vergabe_teilnahme/templates/partials/sidebar.html`:
Implementiere die feste linke Sidebar (240px Breite) aus Blueprint Abschnitt 6.2.
Struktur mit Alpine.js für aufklappbare Unterabschnitte:
```html
<aside class="w-60 bg-white border-r border-slate-200 flex flex-col overflow-y-auto"
x-show="sidebarOpen">
<!-- Globale Navpunkte -->
<nav class="p-3 space-y-1">
<!-- Übersicht -->
<a href="/" class="sidebar-link {% if request.resolver_match.url_name == 'dashboard' %}sidebar-link-active{% endif %}">
Übersicht
</a>
<!-- Ausschreibungen -->
<div x-data="{ open: true }">
<button @click="open = !open" class="sidebar-section-btn">
Ausschreibungen <span x-text="open ? '▾' : '▸'"></span>
</button>
<div x-show="open" class="ml-3 space-y-1">
<a href="/ausschreibungen/" class="sidebar-link">Alle Ausschreibungen</a>
<a href="/ausschreibungen/neu/" class="sidebar-link text-brand-600">+ Neu</a>
</div>
</div>
<!-- Bibliothek (aufklappbar) -->
<!-- Partner (aufklappbar) -->
<!-- Marktbegleiter -->
<!-- Feedback-Backlog -->
<!-- Administration -->
</nav>
<!-- Kontextueller Phasen-Navigator (nur wenn Ausschreibung aktiv) -->
{% if current_ausschreibung %}
{% include "partials/phase_nav.html" %}
{% endif %}
</aside>
```
Füge CSS-Hilfsklassen in `static/src/main.css` hinzu:
```css
.sidebar-link { @apply flex items-center px-3 py-2 rounded-lg text-sm text-slate-700 hover:bg-slate-100; }
.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; }
```
`current_ausschreibung` wird via Context-Processor bereitgestellt (nächster Task).
```
```task
id: WP-0003-T04
title: Context-Processor und Phasen-Navigator-Partial
status: todo
**Context-Processor** `vergabe_teilnahme/apps/core/context_processors.py`:
```python
def vergabe_context(request):
context = {}
# Aktueller Ausschreibungs-Kontext aus URL
ausschreibung_id = None
if hasattr(request, 'resolver_match') and request.resolver_match:
kwargs = request.resolver_match.kwargs
ausschreibung_id = kwargs.get('ausschreibung_id') or kwargs.get('pk')
if ausschreibung_id:
try:
from vergabe_teilnahme.apps.ausschreibungen.models import Ausschreibung
context['current_ausschreibung'] = Ausschreibung.objects.get(pk=ausschreibung_id)
except (Ausschreibung.DoesNotExist, ValueError):
pass
return context
```
Registriere in `settings/base.py` unter `TEMPLATES[0]['OPTIONS']['context_processors']`.
**Phasen-Navigator** `partials/phase_nav.html`:
Zeigt die 8 Phasen als klickbare Links mit Statusindikator.
```html
<div class="border-t border-slate-200 p-3">
<p class="text-xs font-semibold text-slate-500 uppercase tracking-wide mb-2">
{{ current_ausschreibung.titel|truncatechars:30 }}
</p>
{% for phase in phases %}
<a href="{{ phase.url }}"
class="flex items-center gap-2 px-2 py-1.5 rounded text-sm hover:bg-slate-100
{% if phase.aktiv %}text-brand-700 font-medium{% else %}text-slate-600{% endif %}">
<span class="{% if phase.erledigt %}phase-done{% elif phase.aktiv %}phase-active{% elif phase.warnung %}phase-warn{% else %}phase-todo{% endif %}">
{{ phase.nummer }}
</span>
{{ phase.name }}
{% if phase.warnung %}<span class="ml-auto text-amber-500 text-xs"></span>{% endif %}
</a>
{% endfor %}
</div>
```
Die `phases`-Liste wird von einer View-Hilfsfunktion `build_phase_nav(ausschreibung, current_url)` befüllt.
Implementiere diese Funktion in `core/services.py`.
```
```task
id: WP-0003-T05
title: Breadcrumb-Partial
status: todo
`vergabe_teilnahme/templates/partials/breadcrumb.html`:
Breadcrumb rendert eine Liste von Links aus dem Template-Context-Variable `breadcrumbs`.
```html
{% if breadcrumbs %}
<nav class="flex items-center gap-2 text-sm text-slate-500 mb-4">
{% for crumb in breadcrumbs %}
{% if not forloop.last %}
<a href="{{ crumb.url }}" class="hover:text-slate-900">{{ crumb.label }}</a>
<span></span>
{% else %}
<span class="text-slate-900 font-medium">{{ crumb.label }}</span>
{% endif %}
{% endfor %}
</nav>
{% endif %}
```
`breadcrumbs` wird in jeder View-Funktion als Liste von Dicts übergeben:
`[{'label': 'Ausschreibungen', 'url': '/ausschreibungen/'}, {'label': 'Titel', 'url': None}]`
Erstelle eine Hilfsfunktion `core.views_helpers.make_breadcrumbs(*args)` die diese Liste baut.
```
```task
id: WP-0003-T06
title: Template-Tags: status_badge und phase_badge
status: todo
Erstelle `vergabe_teilnahme/apps/core/templatetags/__init__.py` (leer).
Erstelle `vergabe_teilnahme/apps/core/templatetags/vergabe_tags.py`.
```python
from django import template
register = template.Library()
STATUS_COLORS = {
# Ausschreibung / Aufgabe / Dokument Status
'offen': 'bg-slate-100 text-slate-700',
'in_bearbeitung': 'bg-blue-100 text-blue-700',
'erledigt': 'bg-green-100 text-green-700',
'freigegeben': 'bg-green-100 text-green-700',
'erteilt': 'bg-green-100 text-green-700',
'gewonnen': 'bg-green-100 text-green-700',
'ueberfaellig': 'bg-red-100 text-red-700',
'nicht_erfuellbar': 'bg-red-100 text-red-700',
'verloren': 'bg-red-100 text-red-700',
'abgelehnt': 'bg-red-100 text-red-700',
'ausstehend': 'bg-amber-100 text-amber-700',
'in_pruefung': 'bg-amber-100 text-amber-700',
'wartend_intern': 'bg-amber-100 text-amber-700',
'wartend_sub': 'bg-amber-100 text-amber-700',
'wartend_ausschreiber': 'bg-amber-100 text-amber-700',
'archiviert': 'bg-gray-100 text-gray-500',
'ersetzt': 'bg-gray-100 text-gray-500',
'verworfen': 'bg-gray-100 text-gray-500',
}
@register.inclusion_tag('partials/status_badge.html')
def status_badge(value, display_label=None):
css = STATUS_COLORS.get(value, 'bg-slate-100 text-slate-700')
return {'css': css, 'label': display_label or value.replace('_', ' ').capitalize()}
@register.simple_tag
def phase_badge(nummer, zustand='todo'):
css_map = {'todo': 'phase-todo', 'active': 'phase-active',
'done': 'phase-done', 'warn': 'phase-warn'}
return f'<span class="{css_map.get(zustand, \"phase-todo\")}">{nummer}</span>'
```
Erstelle `partials/status_badge.html`:
```html
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium {{ css }}">
{{ label }}
</span>
```
```
```task
id: WP-0003-T07
title: Template-Tag: render_field (EntityFieldConfig-aware)
status: todo
Ergänze `vergabe_tags.py`:
```python
from vergabe_teilnahme.apps.core.models import EntityFieldConfig
_HIDDEN_FIELDS_CACHE = {}
def _is_field_hidden(entity_type, field_name):
key = (entity_type, field_name)
if key not in _HIDDEN_FIELDS_CACHE:
_HIDDEN_FIELDS_CACHE[key] = EntityFieldConfig.objects.filter(
entity_type=entity_type, field_name=field_name, is_hidden=True
).exists()
return _HIDDEN_FIELDS_CACHE[key]
def _get_field_label(entity_type, field_name, default_label):
try:
cfg = EntityFieldConfig.objects.get(entity_type=entity_type, field_name=field_name)
return cfg.display_label or default_label
except EntityFieldConfig.DoesNotExist:
return default_label
@register.inclusion_tag('partials/field_row.html')
def render_field(obj, field_name, label=None, force_show=False):
entity_type = obj._meta.model_name
if not force_show and _is_field_hidden(entity_type, field_name):
return {'hidden': True}
value = getattr(obj, field_name, None)
display_label = _get_field_label(entity_type, field_name, label or field_name)
return {'hidden': False, 'label': display_label, 'value': value, 'field_name': field_name}
```
Erstelle `partials/field_row.html`:
```html
{% if not hidden %}
<div class="field-row">
<dt class="field-label">{{ label }}</dt>
<dd class="field-value">
{% if value %}{{ value }}{% else %}<span class="text-slate-400"></span>{% endif %}
</dd>
</div>
{% endif %}
```
Wichtig: `_HIDDEN_FIELDS_CACHE` per Request invalidieren (oder einfach kein Cache in v1,
da Admin-Änderungen sofort wirken sollen — entscheide dich für kein Caching in v1).
```
```task
id: WP-0003-T08
title: Feedback-Button und Feedback-Modal-Partial
status: todo
`partials/feedback_button.html`:
```html
<button hx-get="/feedback/modal/"
hx-target="#modal-container"
hx-swap="innerHTML"
class="fixed bottom-6 right-6 bg-white border border-slate-300 shadow-lg
rounded-full p-3 hover:bg-slate-50 z-40"
title="Feedback geben">
💬
</button>
```
`partials/feedback_modal.html` (wird vom HTMX-Endpunkt zurückgegeben):
```html
<div x-data="{ open: true }" x-show="open"
class="fixed inset-0 bg-black/30 z-50 flex items-center justify-center">
<div class="bg-white rounded-xl shadow-xl p-6 w-full max-w-md"
@click.outside="open = false">
<h2 class="page-title text-xl mb-4">Feedback</h2>
<form hx-post="/feedback/" hx-target="#modal-container" hx-swap="innerHTML">
{% csrf_token %}
<input type="hidden" name="seite_kontext" value="{{ request.path }}">
{% if current_ausschreibung %}
<input type="hidden" name="ausschreibung" value="{{ current_ausschreibung.pk }}">
{% endif %}
<div class="space-y-3">
<!-- Kategorie, Titel, Beschreibung, Dringlichkeit -->
<div>
<label class="form-label">Kategorie</label>
<select name="kategorie" class="form-input">
<option value="hinweis">Hinweis</option>
<option value="verbesserung">Verbesserungsvorschlag</option>
<option value="fehler">Fehler</option>
</select>
</div>
<div>
<label class="form-label">Beschreibung *</label>
<textarea name="beschreibung" rows="3" class="form-input" required></textarea>
</div>
</div>
<div class="flex justify-end gap-2 mt-4">
<button type="button" @click="open = false" class="btn-secondary">Abbrechen</button>
<button type="submit" class="btn-primary">Senden</button>
</div>
</form>
</div>
</div>
```
Erstelle in `feedback/views.py`:
- `GET /feedback/modal/` → rendert `partials/feedback_modal.html`
- `POST /feedback/` → speichert Feedbackeintrag, gibt Danke-Fragment zurück
Verkable URLs in `feedback/urls.py` und include in Haupt-URLs.
```
```task
id: WP-0003-T09
title: Error-Templates und Django-URL-Konfiguration
status: todo
`vergabe_teilnahme/templates/errors/404.html`:
```html
{% extends "base.html" %}
{% block title %}Seite nicht gefunden{% endblock %}
{% block content %}
<div class="card text-center py-16">
<p class="text-6xl font-bold text-slate-200 mb-4">404</p>
<h1 class="page-title mb-2">Seite nicht gefunden</h1>
<p class="text-slate-500 mb-6">Die angeforderte Seite existiert nicht oder wurde verschoben.</p>
<a href="/" class="btn-primary">Zur Übersicht</a>
</div>
{% endblock %}
```
Analog `500.html`.
In `settings/base.py`:
```python
handler404 = 'vergabe_teilnahme.apps.core.views.custom_404'
handler500 = 'vergabe_teilnahme.apps.core.views.custom_500'
```
In `core/views.py`:
```python
from django.shortcuts import render
def custom_404(request, exception=None):
return render(request, 'errors/404.html', status=404)
def custom_500(request):
return render(request, 'errors/500.html', status=500)
```
Füge alle App-URL-Dateien in `vergabe_teilnahme/urls.py` ein (auch wenn die Views
noch nicht existieren — mit leeren `urlpatterns = []` als Platzhalter):
```python
path('ausschreibungen/', include('vergabe_teilnahme.apps.ausschreibungen.urls')),
path('lose/', include('vergabe_teilnahme.apps.lose.urls')),
# ... alle Apps
```
```
```task
id: WP-0003-T10
title: Einfache Startseite mit Redirect und Smoke-Test
status: todo
Erstelle `core/views.py` mit einer einfachen Redirect-View auf das Dashboard:
```python
from django.shortcuts import redirect
def home(request):
return redirect('ausschreibungen:dashboard')
```
Füge URL hinzu: `path('', core_views.home, name='home')`
Erstelle eine minimale Dashboard-Placeholder-View in `ausschreibungen/views.py`:
```python
from django.shortcuts import render
def dashboard(request):
return render(request, 'ausschreibungen/dashboard.html', {
'breadcrumbs': [{'label': 'Übersicht', 'url': None}]
})
```
Erstelle `vergabe_teilnahme/templates/ausschreibungen/dashboard.html`:
```html
{% extends "base.html" %}
{% block title %}Übersicht{% endblock %}
{% block content %}
<h1 class="page-title mb-6">Übersicht</h1>
<p class="text-slate-500">Dashboard wird in WP-0004 implementiert.</p>
{% endblock %}
```
Verkable URL: `ausschreibungen/urls.py``path('', views.dashboard, name='dashboard')`
In Haupt-URLs: `path('', include('vergabe_teilnahme.apps.ausschreibungen.urls', namespace='ausschreibungen'))`
Prüfe: `make dev` startet, `http://localhost:8000/` leitet auf Dashboard weiter,
Seite rendert ohne Template-Fehler. Sidebar und Topbar sind sichtbar.
```

View File

@@ -0,0 +1,492 @@
---
id: WP-0004
title: Dashboard und Ausschreibungen-CRUD
status: todo
phase: 4-of-12
created: "2026-05-08"
depends_on: WP-0003
---
# WP-0004 — Dashboard und Ausschreibungen-CRUD
Vollständige Implementierung des Dashboards und aller Ausschreibungs-Views:
Liste, Suche/Filter, Anlegen, Detailseite, Status-Wechsel (HTMX), Teilnahmeentscheidung,
Entscheidungsregel-Auswertung, Archivierung und historische Erfassung.
**Referenz:** UseCaseCatalog UC-OV-01 bis UC-OV-03, UC-AS-01 bis UC-AS-07.
---
```task
id: WP-0004-T01
title: Dashboard-View mit Kacheln und Fristenliste
status: todo
`ausschreibungen/views.py` — Dashboard-View:
```python
def dashboard(request):
from vergabe_teilnahme.apps.core.services import get_deadline_warnings
from vergabe_teilnahme.apps.aufgaben.models import Aufgabe
from datetime import date, timedelta
heute = date.today()
in_14_tagen = heute + timedelta(days=14)
ctx = {
'kritische_fristen': Ausschreibung.objects.filter(
abgabe_bis__date__lte=in_14_tagen,
abgabe_bis__date__gte=heute,
status__lt=10
).order_by('abgabe_bis')[:10],
'ohne_entscheidung': Ausschreibung.objects.filter(
status__in=[1, 2],
erstellt_am__lte=timezone.now() - timedelta(days=3)
).order_by('erstellt_am')[:10],
'ueberfaellige_aufgaben': Aufgabe.objects.filter(
frist__lt=heute,
status__in=['offen', 'in_bearbeitung', 'wartend_intern', 'wartend_sub', 'wartend_ausschreiber']
).select_related('ausschreibung', 'verantwortlicher').order_by('frist')[:15],
'laufende_ausschreibungen': Ausschreibung.objects.filter(
status__range=(3, 9)
).order_by('-geaendert_am')[:10],
'breadcrumbs': [{'label': 'Übersicht', 'url': None}],
}
return render(request, 'ausschreibungen/dashboard.html', ctx)
```
`ausschreibungen/dashboard.html` zeigt vier Kacheln-Zeilen:
Jede Kachel: Überschrift, Anzahl-Badge, Liste der Einträge mit Direktlinks.
Nutze `.card`-Klasse, `status_badge`-Tag und relative Fristangaben (z. B. "in 3 Tagen").
Ablaufende Nachweise: Nachweis-Modell aus Bibliothek mit `gueltig_bis ≤ heute + 60 Tage`.
```
```task
id: WP-0004-T02
title: Ausschreibungsliste mit Filter und HTMX-Suche
status: todo
`ausschreibungen/views.py` — ListView:
```python
def ausschreibung_liste(request):
qs = Ausschreibung.objects.all()
# Filter-Parameter
status = request.GET.get('status')
if status:
qs = qs.filter(status=status)
archiviert = request.GET.get('archiviert', '0') == '1'
qs = qs.filter(archiviert=archiviert)
verantwortlicher = request.GET.get('verantwortlicher')
if verantwortlicher:
qs = qs.filter(hauptverantwortung=verantwortlicher)
qs = qs.select_related('hauptverantwortung').order_by('-geaendert_am')
ctx = {
'ausschreibungen': qs,
'status_choices': Ausschreibung.STATUS_CHOICES,
'mitarbeiter': Mitarbeiter.objects.all(),
'breadcrumbs': [{'label': 'Ausschreibungen', 'url': None}],
}
template = 'ausschreibungen/liste_partial.html' if request.htmx else 'ausschreibungen/liste.html'
return render(request, template, ctx)
```
`liste.html` — vollständige Seite mit Filterleiste oben und eingebetteter Tabelle.
`liste_partial.html` — nur die Tabellen-Rows (für HTMX-Filter-Update).
Filterleiste: Dropdowns für Status, Verantwortlicher, Checkbox "Archivierte anzeigen".
Alle Filter-Änderungen: `hx-get="/ausschreibungen/" hx-target="#ausschreibungen-table" hx-push-url="true"`.
Tabelle: Titel, Ausschreiber, Status (status_badge), Abgabefrist (farbig wenn < 14 Tage),
Verantwortlicher, Link zum Detail.
```
```task
id: WP-0004-T03
title: Ausschreibung anlegen — Form und View (UC-AS-01)
status: todo
`ausschreibungen/forms.py`:
```python
from django import forms
class AusschreibungForm(forms.ModelForm):
class Meta:
model = Ausschreibung
fields = ['titel', 'ausschreiber', 'plattform', 'plattform_link',
'ansprechpartner', 'hauptverantwortung', 'beschreibung',
'strategische_relevanz', 'bieterfragen_bis', 'abgabe_bis',
'zuschlag_bis', 'produktiv_bis']
widgets = {
'titel': forms.TextInput(attrs={'class': 'form-input', 'autofocus': True}),
'ausschreiber': forms.TextInput(attrs={'class': 'form-input'}),
'beschreibung': forms.Textarea(attrs={'class': 'form-input', 'rows': 3}),
'abgabe_bis': forms.DateTimeInput(attrs={'class': 'form-input', 'type': 'datetime-local'}),
'bieterfragen_bis': forms.DateInput(attrs={'class': 'form-input', 'type': 'date'}),
# alle Datums-Widgets als type="date"
}
```
`ausschreibungen/views.py` — CreateView (function-based):
```python
def ausschreibung_neu(request):
if request.method == 'POST':
form = AusschreibungForm(request.POST)
if form.is_valid():
a = form.save()
return redirect('ausschreibungen:detail', pk=a.pk)
else:
form = AusschreibungForm()
return render(request, 'ausschreibungen/form.html', {
'form': form,
'titel': 'Neue Ausschreibung',
'breadcrumbs': [
{'label': 'Ausschreibungen', 'url': '/ausschreibungen/'},
{'label': 'Neu', 'url': None}
],
})
```
`ausschreibungen/form.html` — einfaches, gut gelayoutetes Formular.
Sections: Stammdaten, Fristen. Alle Felder nutzen `form-input` und `form-label`.
Submit: "Speichern" (btn-primary), "Abbrechen" (btn-ghost, zurück zur Liste).
```
```task
id: WP-0004-T04
title: Ausschreibung-Detailseite (Phase 1 — Stammdaten)
status: todo
`ausschreibungen/views.py` — Detailview:
```python
def ausschreibung_detail(request, pk):
a = get_object_or_404(Ausschreibung, pk=pk)
from vergabe_teilnahme.apps.core.services import get_deadline_warnings, build_phase_nav
ctx = {
'ausschreibung': a,
'ausschreibung_id': pk, # für Context-Processor / Phase-Navigator
'warnungen': get_deadline_warnings(a),
'freigaben': a.freigabe_set.all() if hasattr(a, 'freigabe_set') else [],
'breadcrumbs': [
{'label': 'Ausschreibungen', 'url': '/ausschreibungen/'},
{'label': a.titel, 'url': None}
],
}
return render(request, 'ausschreibungen/detail.html', ctx)
```
`ausschreibungen/detail.html`:
- Seitentitel: Ausschreibungstitel + Status-Badge + Edit-Button
- Warnungs-Banner (gelb/rot) falls `warnungen` nicht leer
- Abschnitt "Stammdaten": nutze `{% render_field ausschreibung "ausschreiber" "Ausschreiber" %}` für alle Felder
- Abschnitt "Fristen": alle Datums-Felder mit Restlaufzeit-Anzeige
- Abschnitt "Freigaben": kompakte Liste (typ, person, datum)
- Tab-Navigation zu Unterseiten (Lose, Anforderungen, Aufgaben, Bieterfragen, Preise, Abgabe, Nachbetrachtung)
als horizontale Link-Leiste unterhalb des Titels
- "Weitere Attribute" CustomAttribute-Panel (HTMX lazy-load, Implementierung in WP-0012)
```
```task
id: WP-0004-T05
title: Ausschreibung bearbeiten (Edit-View) und Status inline wechseln
status: todo
`ausschreibungen/views.py`:
**Edit-View** (gleiche Form wie Neu, aber mit `instance=`):
```python
def ausschreibung_bearbeiten(request, pk):
a = get_object_or_404(Ausschreibung, pk=pk)
form = AusschreibungForm(request.POST or None, instance=a)
if request.method == 'POST' and form.is_valid():
form.save()
return redirect('ausschreibungen:detail', pk=pk)
return render(request, 'ausschreibungen/form.html', {'form': form, 'titel': 'Bearbeiten', ...})
```
**Status-Wechsel HTMX-Endpunkt:**
```python
def ausschreibung_status(request, pk):
a = get_object_or_404(Ausschreibung, pk=pk)
if request.method == 'POST':
neuer_status = int(request.POST.get('status', a.status))
a.status = neuer_status
a.save(update_fields=['status', 'geaendert_am'])
return render(request, 'ausschreibungen/partials/status_widget.html', {'ausschreibung': a})
```
`ausschreibungen/partials/status_widget.html`:
```html
<div id="status-widget-{{ ausschreibung.pk }}"
hx-target="#status-widget-{{ ausschreibung.pk }}" hx-swap="outerHTML">
{% status_badge ausschreibung.get_status_display ausschreibung.status %}
<select name="status" hx-post="{% url 'ausschreibungen:status' ausschreibung.pk %}"
hx-trigger="change" class="form-input ml-2 w-auto">
{% for val, label in ausschreibung.STATUS_CHOICES %}
<option value="{{ val }}" {% if val == ausschreibung.status %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</div>
```
```
```task
id: WP-0004-T06
title: Teilnahmeentscheidung-Seite (Phase 2, UC-AS-04)
status: todo
`ausschreibungen/views.py` — Teilnahmeentscheidungs-View:
```python
def ausschreibung_entscheidung(request, pk):
a = get_object_or_404(Ausschreibung, pk=pk)
if request.method == 'POST':
a.teilnahmeentscheidung = request.POST.get('teilnahmeentscheidung', 'offen')
a.beschreibung = request.POST.get('begruendung', a.beschreibung)
if a.teilnahmeentscheidung in ['teilnahme', 'ablehnung']:
a.status = max(a.status, 3)
a.save()
return redirect('ausschreibungen:detail', pk=pk)
from vergabe_teilnahme.apps.ausschreibungen.services import entscheidungsregel_auswertung
ctx = {
'ausschreibung': a,
'regelergebnis': entscheidungsregel_auswertung(a),
'ausschlusskriterien_nicht_erfuellbar': a.anforderung_set.filter(
ausschlusskriterium=True, erfuellungsstatus='nicht_erfuellbar'
) if hasattr(a, 'anforderung_set') else [],
'breadcrumbs': [...],
}
return render(request, 'ausschreibungen/entscheidung.html', ctx)
```
`ausschreibungen/entscheidung.html`:
- Zeigt offene Ausschlusskriterien als rote Warnmeldungen (wenn vorhanden)
- Zeigt Regelergebnis aus dem Katalog als strukturierte Liste
- Formular: Radio-Buttons für Teilnahme/Nichtteilnahme/Weitere Prüfung, Begründungsfeld
- "Freigabe erteilen"-Button (öffnet Freigabe-Modal, Implementierung in WP-0012)
```
```task
id: WP-0004-T07
title: Entscheidungsregel-Auswertungs-Service
status: todo
`vergabe_teilnahme/apps/ausschreibungen/services.py`:
```python
def entscheidungsregel_auswertung(ausschreibung):
"""
Wendet alle aktiven Entscheidungsregeln auf eine Ausschreibung an.
Gibt Liste von Ergebnis-Dicts zurück.
"""
from vergabe_teilnahme.apps.bibliothek.models import Entscheidungsregel
regeln = Entscheidungsregel.objects.filter(aktiv=True).order_by('-gewichtung')
ergebnisse = []
for regel in regeln:
ergebnis = _wende_regel_an(regel, ausschreibung)
ergebnisse.append({
'regel': regel,
'empfehlung': ergebnis['empfehlung'],
'begruendung': ergebnis['begruendung'],
'warnung': ergebnis['empfehlung'] == 'nicht_teilnehmen',
})
return ergebnisse
def _wende_regel_an(regel, ausschreibung):
"""
Einfache Heuristik für v1: Überprüft bekannte Regel-Kategorien.
Für unbekannte Kategorien: gibt neutrale Empfehlung zurück.
"""
kat = regel.kategorie
if kat == 'ausschlusskriterium' and hasattr(ausschreibung, 'anforderung_set'):
hat_ausschluss = ausschreibung.anforderung_set.filter(
ausschlusskriterium=True, erfuellungsstatus='nicht_erfuellbar'
).exists()
if hat_ausschluss:
return {'empfehlung': 'nicht_teilnehmen',
'begruendung': 'Nicht erfüllbares Ausschlusskriterium vorhanden.'}
if kat == 'frist' and ausschreibung.abgabe_bis:
from datetime import date
delta = (ausschreibung.abgabe_bis.date() - date.today()).days
if regel.schwellenwert and delta < regel.schwellenwert:
return {'empfehlung': 'nicht_teilnehmen',
'begruendung': f'Restlaufzeit {delta} Tage unter Schwellenwert.'}
return {'empfehlung': 'pruefen', 'begruendung': regel.begruendung or '—'}
```
```
```task
id: WP-0004-T08
title: Ausschreibung archivieren und historisch erfassen (UC-AS-06, UC-AS-07)
status: todo
**Archivieren:**
```python
def ausschreibung_archivieren(request, pk):
a = get_object_or_404(Ausschreibung, pk=pk)
if request.method == 'POST':
a.archiviert = True
a.status = 13
a.save(update_fields=['archiviert', 'status', 'geaendert_am'])
return redirect('ausschreibungen:liste')
return render(request, 'ausschreibungen/archivieren_confirm.html', {'ausschreibung': a})
```
`archivieren_confirm.html`: Einfacher Bestätigungsdialog (Alpine.js Modal oder eigene Seite).
**Historisch erfassen:**
Ergänze `AusschreibungForm` um ein BooleanField `historisch_erfassen` (Widget: HiddenInput).
Bei `historisch_erfassen=True` zeigt das Formular zusätzlich die Felder:
`ergebnis`, `teilnahmeentscheidung` — direkt befüllbar ohne Phasenreihenfolge.
Die Detailseite wird nach dem Speichern sofort mit allen Unterseiten (Preise, Nachbetrachtung etc.)
zugänglich — keine Einschränkung.
URL für historische Erfassung: `/ausschreibungen/neu/?historisch=1`
Die View prüft diesen Parameter und setzt `historisch_erfassen` im initialen Form-Context.
```
```task
id: WP-0004-T09
title: Globale Suchleiste — HTMX-Endpunkt und Ergebnis-Template
status: todo
`core/views.py`:
```python
def global_search(request):
q = request.GET.get('q', '').strip()
if len(q) < 2:
return HttpResponse('')
ctx = {
'q': q,
'ausschreibungen': Ausschreibung.objects.filter(
Q(titel__icontains=q) | Q(ausschreiber__icontains=q)
)[:5],
'aufgaben': Aufgabe.objects.filter(titel__icontains=q)[:5],
'subunternehmer': Subunternehmer.objects.filter(name__icontains=q)[:5],
'marktbegleiter': Marktbegleiter.objects.filter(name__icontains=q)[:3],
}
return render(request, 'partials/search_results.html', ctx)
```
`partials/search_results.html`:
```html
{% if ausschreibungen or aufgaben or subunternehmer %}
<div class="p-3 space-y-3">
{% if ausschreibungen %}
<div>
<p class="text-xs font-semibold text-slate-500 uppercase mb-1">Ausschreibungen</p>
{% for a in ausschreibungen %}
<a href="/ausschreibungen/{{ a.pk }}/" class="block px-2 py-1 rounded hover:bg-slate-50 text-sm">
{{ a.titel }} <span class="text-slate-400">— {{ a.ausschreiber }}</span>
</a>
{% endfor %}
</div>
{% endif %}
<!-- analog für andere Kategorien -->
</div>
{% endif %}
```
URL: `path('suche/', core_views.global_search, name='global_search')`
Topbar-Formular (aus WP-0003-T02) zeigt Ergebnisse in `#search-results`.
```
```task
id: WP-0004-T10
title: Ausschreibungen-URL-Verkabelung und App-Namespace
status: todo
`vergabe_teilnahme/apps/ausschreibungen/urls.py`:
```python
from django.urls import path
from . import views
app_name = 'ausschreibungen'
urlpatterns = [
path('', views.ausschreibung_liste, name='liste'),
path('neu/', views.ausschreibung_neu, name='neu'),
path('<int:pk>/', views.ausschreibung_detail, name='detail'),
path('<int:pk>/bearbeiten/', views.ausschreibung_bearbeiten, name='bearbeiten'),
path('<int:pk>/status/', views.ausschreibung_status, name='status'),
path('<int:pk>/entscheidung/', views.ausschreibung_entscheidung, name='entscheidung'),
path('<int:pk>/archivieren/', views.ausschreibung_archivieren, name='archivieren'),
# Unterseiten-URLs (Platzhalter für spätere Workplans):
path('<int:ausschreibung_id>/lose/', include('vergabe_teilnahme.apps.lose.urls')),
path('<int:ausschreibung_id>/aufgaben/', include('vergabe_teilnahme.apps.aufgaben.urls')),
path('<int:ausschreibung_id>/bieterfragen/', include('vergabe_teilnahme.apps.aufgaben.bieterfragen_urls')),
path('<int:ausschreibung_id>/dokumente/', include('vergabe_teilnahme.apps.dokumente.urls')),
path('<int:ausschreibung_id>/preise/', include('vergabe_teilnahme.apps.preise.urls')),
path('<int:ausschreibung_id>/abgabe/', include('vergabe_teilnahme.apps.nachbetrachtung.abgabe_urls')),
path('<int:ausschreibung_id>/nachbetrachtung/', include('vergabe_teilnahme.apps.nachbetrachtung.urls')),
path('<int:ausschreibung_id>/marktbegleiter/', include('vergabe_teilnahme.apps.marktbegleiter.passagen_urls')),
]
```
Jede referenzierte App-URL-Datei wird hier als leere Stub-Datei angelegt
(`urlpatterns = []`) damit die includes nicht zu ImportErrors führen.
Prüfe: `uv run manage.py check --deploy` → keine URL-Fehler.
Smoke-Test: alle Hauptseiten (/ausschreibungen/, /ausschreibungen/neu/) laden ohne 500.
```
```task
id: WP-0004-T11
title: Ausschreibungs-Tests (Models und Views)
status: todo
Erstelle `vergabe_teilnahme/apps/ausschreibungen/tests/`:
`test_models.py`:
- Test: Ausschreibung `__str__` gibt Titel zurück
- Test: `ist_aktiv` property für Status 1-9 (True) und 10-13 (False)
- Test: `naechste_frist` gibt das frühere von bieterfragen_bis/abgabe_bis zurück
`test_views.py` (nutze `pytest-django` + `client` fixture):
- Test: GET /ausschreibungen/ → 200
- Test: GET /ausschreibungen/neu/ → 200
- Test: POST /ausschreibungen/neu/ mit validen Daten → Redirect zur Detailseite
- Test: GET /ausschreibungen/<pk>/ → 200
- Test: POST /ausschreibungen/<pk>/status/ mit status=4 → 200, Ausschreibung hat status=4
- Test: Status-Wechsel mit HTMX-Header → partial template response
Nutze `factory_boy` für Factories:
```python
import factory
class AusschreibungFactory(factory.django.DjangoModelFactory):
class Meta:
model = Ausschreibung
titel = factory.Sequence(lambda n: f"Ausschreibung {n}")
ausschreiber = "Testausschreiber GmbH"
status = 1
```
```
```task
id: WP-0004-T12
title: Seed-Daten prüfen und Dashboard-Kacheln verifizieren
status: todo
Führe die gesamte Integrations-Smoke-Test-Sequenz durch:
1. `make db` → PostgreSQL läuft
2. `uv run manage.py migrate` → alle Migrationen sauber
3. `uv run manage.py seed_dev` → Seed-Daten angelegt
4. `make dev` → Server läuft
5. Browser öffnen: `http://localhost:8000/`
→ Dashboard zeigt Kacheln (auch wenn leer)
→ Sidebar zeigt alle globalen Navpunkte
→ Topbar mit Suchleiste sichtbar
6. `http://localhost:8000/ausschreibungen/`
→ Liste zeigt die Seed-Ausschreibung
7. Ausschreibung öffnen → Detail-Seite rendert mit Stammdaten
8. Status-Dropdown wechseln → HTMX aktualisiert Status inline
9. `http://localhost:8000/ausschreibungen/neu/` → Formular funktioniert
10. `uv run pytest vergabe_teilnahme/apps/ausschreibungen/` → alle Tests grün
Erst wenn alle 10 Punkte erfüllt sind: Task als done markieren.
```

View File

@@ -0,0 +1,211 @@
---
id: WP-0005
title: Lose und Anforderungen
status: todo
phase: 5-of-12
created: "2026-05-08"
depends_on: WP-0004
---
# WP-0005 — Lose und Anforderungen
Implementiert alle Views, Forms und Templates für Lose (UC-LA-01) und Anforderungen
(UC-LA-02 bis UC-LA-05) inklusive Nachweis-Verknüpfung und Ausschlusskriterium-Eskalation.
**URL-Präfix:** `/ausschreibungen/<ausschreibung_id>/lose/` und `.../anforderungen/`
---
```task
id: WP-0005-T01
title: Lose-Liste und Lose anlegen (UC-LA-01)
status: todo
`lose/views.py` — lose_liste und los_neu:
lose_liste: Zeigt alle Lose einer Ausschreibung geordnet nach Losnummer.
Template: `lose/liste.html` — Tabelle mit Losnummer, Lostitel, Zuständiger, Teilnahme (Badge), Status.
"+ Los hinzufügen"-Button öffnet Inline-Formular via HTMX.
`LosForm(ModelForm)`: Felder losnummer, lostitel, beschreibung, abgrenzung, zustaendiger, teilnahme.
Alle Inputs mit `form-input`, Textarea mit 3 rows.
los_neu (POST): Erstellt Los, gibt bei HTMX-Request nur neuen Tabellen-Row zurück.
los_bearbeiten (GET/POST): Edit in eigenem Template oder Inline.
los_loeschen (POST): Löscht Los nach Bestätigung.
URLs in `lose/urls.py`:
```python
path('', views.lose_liste, name='liste'),
path('neu/', views.los_neu, name='neu'),
path('<int:los_pk>/', views.los_detail, name='detail'),
path('<int:los_pk>/bearbeiten/', views.los_bearbeiten, name='bearbeiten'),
```
```
```task
id: WP-0005-T02
title: Los-Detail-Seite mit eingebetteten Anforderungen
status: todo
`lose/views.py` — los_detail:
```python
def los_detail(request, ausschreibung_id, los_pk):
los = get_object_or_404(Los, pk=los_pk, ausschreibung_id=ausschreibung_id)
ctx = {
'los': los,
'ausschreibung': los.ausschreibung,
'anforderungen': los.anforderungen.all().order_by('verbindlichkeit', 'titel'),
'subunternehmer': SubunternehmerZuordnung.objects.filter(los=los),
...
}
return render(request, 'lose/detail.html', ctx)
```
`lose/detail.html`:
- Los-Stammdaten (render_field Tags)
- Abschnitt "Anforderungen" — Tabelle mit Link zur Anforderungsdetailseite
- Abschnitt "Subunternehmer" — Liste zugeordneter Subunternehmer
- CustomAttribute-Panel (HTMX lazy, wie in WP-0012)
- Abschnitt "Teilnahme": Toggle Ja/Nein/Offen (HTMX POST)
```
```task
id: WP-0005-T03
title: Anforderungsliste nach Los gruppiert (UC-LA-02)
status: todo
`lose/views.py` — anforderungen_liste:
Lädt alle Anforderungen der Ausschreibung, gruppiert nach Los.
Anforderungen ohne Los-Zuordnung erscheinen in "Allgemein".
Template `lose/anforderungen_liste.html`:
- Filter-Leiste: Verbindlichkeit (Muss/Soll/Kann), Erfüllungsstatus, Los, Zuständiger
- Alle Filteränderungen via HTMX ohne Reload
- Pro Gruppe: aufklappbarer Akkordeon-Abschnitt (Alpine.js x-show)
- Jede Zeile: Titel, Verbindlichkeit-Badge, Erfüllungsstatus-Badge, Zuständiger
- Rote Hervorhebung bei Ausschlusskriterium + nicht_erfuellbar
- "+ Anforderung" Button oben rechts
```
```task
id: WP-0005-T04
title: Anforderung anlegen und Detailseite (UC-LA-02, UC-LA-03)
status: todo
`AnforderungForm(ModelForm)`: alle Felder aus Modell.
Besonderer Widget für verbindlichkeit: Radio-Buttons statt Dropdown.
anforderung_neu: Liest ausschreibung_id aus URL. Los-Dropdown zeigt nur Lose der aktuellen Ausschreibung.
anforderung_detail: Zeigt alle Felder (via render_field), Kommentarverlauf als Timeline
(für v1: einfache Textarea + gespeicherte Kommentare als JSONField auf Anforderung).
Verknüpfte Dokumente, Nachweise und Aufgaben als Listen.
Erfüllungsstatus inline ändern (UC-LA-03):
HTMX-Endpunkt `anforderung_status`:
```python
def anforderung_status(request, ausschreibung_id, pk):
a = get_object_or_404(Anforderung, pk=pk, ausschreibung_id=ausschreibung_id)
if request.method == 'POST':
a.erfuellungsstatus = request.POST['erfuellungsstatus']
a.save(update_fields=['erfuellungsstatus'])
return render(request, 'lose/partials/erfuellungsstatus_widget.html', {'anforderung': a})
```
```
```task
id: WP-0005-T05
title: Nachweis-Verknüpfung mit Bibliothek (UC-LA-04)
status: todo
`lose/views.py` — nachweis_suche_modal und nachweis_zuordnen:
Auf der Anforderungsdetailseite: Button "Nachweis zuordnen".
```html
<button hx-get="{% url 'lose:nachweis_suche' ausschreibung.pk anforderung.pk %}"
hx-target="#nachweis-modal"
class="btn-secondary">Nachweis zuordnen</button>
<div id="nachweis-modal"></div>
```
nachweis_suche_modal (GET): Gibt Such-Modal zurück mit Textfeld + Ergebnisliste (HTMX-Suche im Bibliothek-Bestand).
Jeder Treffer zeigt: Titel, Ablaufdatum, Freigabestatus. Ablaufende/abgelaufene Nachweise in Orange/Rot.
nachweis_zuordnen (POST): Fügt Nachweis via M2M hinzu.
nachweis_entfernen (DELETE/POST): Entfernt M2M-Verknüpfung.
Zeige zugeordnete Nachweise auf Anforderungsdetail als Liste mit Ablaufstatus-Badge.
```
```task
id: WP-0005-T06
title: Ausschlusskriterium-Eskalation auf Phase-2-Seite (UC-LA-05)
status: todo
Ergänze `ausschreibungen/views.py` — ausschreibung_entscheidung:
Vor dem Laden der Seite: Prüfe ob es Anforderungen mit
`ausschlusskriterium=True AND erfuellungsstatus='nicht_erfuellbar'` gibt.
Falls ja: Zeige oben auf der Entscheidungsseite einen roten Alert-Banner:
```html
{% if ausschlusskriterien_nicht_erfuellbar %}
<div class="bg-red-50 border border-red-300 rounded-lg p-4 mb-6">
<p class="font-semibold text-red-700">⚠ Nicht erfüllbare Ausschlusskriterien</p>
<ul class="mt-2 text-sm text-red-600 list-disc ml-4">
{% for a in ausschlusskriterien_nicht_erfuellbar %}
<li>{{ a.titel }} (Los: {{ a.los|default:"Allgemein" }})</li>
{% endfor %}
</ul>
<p class="text-sm text-red-500 mt-2">Empfehlung: Nichtteilnahme</p>
</div>
{% endif %}
```
Prüfe: Seed-Daten mit einer nicht erfüllbaren Muss-Anforderung + Ausschlusskriterium anlegen,
dann Phase-2-Seite öffnen → Banner erscheint.
```
```task
id: WP-0005-T07
title: Aufgabe aus Anforderung ableiten (UC-AU-02)
status: todo
Auf der Anforderungsdetailseite: Button "Aufgabe erstellen".
```python
def anforderung_aufgabe_erstellen(request, ausschreibung_id, pk):
anforderung = get_object_or_404(Anforderung, pk=pk)
if request.method == 'POST':
from vergabe_teilnahme.apps.aufgaben.models import Aufgabe
Aufgabe.objects.create(
ausschreibung_id=ausschreibung_id,
los=anforderung.los,
anforderung=anforderung,
titel=f"Klärung: {anforderung.titel[:200]}",
typ='fachlich',
verantwortlicher=anforderung.zustaendiger,
)
return redirect('ausschreibungen:lose:anforderung_detail',
ausschreibung_id=ausschreibung_id, pk=pk)
return render(request, 'lose/aufgabe_erstellen_confirm.html', {'anforderung': anforderung})
```
Nach Erstellen: Anforderungsdetail zeigt die neue Aufgabe im Abschnitt "Verbundene Aufgaben".
```
```task
id: WP-0005-T08
title: Tests für Lose und Anforderungen
status: todo
`lose/tests/test_views.py`:
- Test: Lose-Liste gibt 200 zurück
- Test: Los anlegen mit POST → Redirect, Los existiert in DB
- Test: Anforderung anlegen mit Muss-Verbindlichkeit
- Test: Erfüllungsstatus via HTMX-POST auf 'nicht_erfuellbar' setzen
- Test: Ausschlusskriterium + nicht_erfuellbar → Entscheidungsseite zeigt Alert-Banner
- Test: Nachweis-Verknüpfung über M2M
Nutze AusschreibungFactory und erstelle Factories für Los und Anforderung.
```

View File

@@ -0,0 +1,150 @@
---
id: WP-0006
title: Aufgaben und Bieterfragen
status: todo
phase: 6-of-12
created: "2026-05-08"
depends_on: WP-0005
---
# WP-0006 — Aufgaben und Bieterfragen
Implementiert alle Views für Aufgaben (UC-AU-01 bis UC-AU-04) und Bieterfragen
(UC-BF-01 bis UC-BF-03) inklusive globaler Aufgabenliste und Fristwarnung.
---
```task
id: WP-0006-T01
title: Aufgabenliste pro Ausschreibung und globale Liste (UC-OV-03, UC-AU-01)
status: todo
`aufgaben/views.py` — aufgaben_liste:
**Pro Ausschreibung** (`/ausschreibungen/<id>/aufgaben/`):
Zeigt alle Aufgaben dieser Ausschreibung, filterbar nach Status, Typ, Verantwortlicher.
Template `aufgaben/liste.html` — Tabelle mit: Titel, Typ-Badge, Priorität, Frist (rot wenn überfällig),
Verantwortlicher, Status-Badge, Inline-Status-Dropdown.
**Globale Liste** (`/aufgaben/`):
Gleiche View mit `ausschreibung_id=None`. Zusätzlicher Filter: "Nur meine Aufgaben"
(request.user als Verantwortlicher).
Zeigt zusätzliche Spalte "Ausschreibung" mit Link.
`Aufgabe.objects.filter(frist__lt=today, status__in=AKTIVE_STATUS)`:
Vor dem Rendering: Update alle überfälligen Aufgaben via `update()` auf status='ueberfaellig'.
(Kein Celery nötig — wird beim Seitenaufruf der Liste getriggert.)
URL in `aufgaben/urls.py`:
```python
path('', views.aufgaben_liste, name='liste'),
```
Globale URL in Haupt-urls.py: `path('aufgaben/', include('vergabe_teilnahme.apps.aufgaben.urls'))`
```
```task
id: WP-0006-T02
title: Aufgabe anlegen und zuweisen (UC-AU-01)
status: todo
`AufgabeForm(ModelForm)`:
- `typ` als Select, `prioritaet` als Radio (Hoch/Mittel/Niedrig)
- `frist` als DateInput type="date"
- `los`, `anforderung`, `bieterfrage` als optionale Selects (gefiltert auf aktuelle Ausschreibung)
`aufgabe_neu (POST)`: Bei HTMX-Request gibt es die neue Tabellenzeile zurück
(kein Full-Page-Reload). Sonst Redirect zu Aufgabenliste.
`aufgabe_bearbeiten`: Gleiche Form mit `instance`.
`aufgabe_loeschen (POST)`: Setzt status='verworfen' statt hartem Delete.
`aufgabe_detail`: Zeigt alle Felder, verknüpfte Anforderung/Bieterfrage, Ergebnisfeld.
```
```task
id: WP-0006-T03
title: Aufgabenstatus inline ändern und Ergebnis dokumentieren (UC-AU-03)
status: todo
**Status-Widget** (analog zum Ausschreibungs-Status-Widget):
Jede Zeile in der Aufgabenliste enthält ein Status-Dropdown:
```html
<select name="status"
hx-post="{% url 'aufgaben:status' ausschreibung.pk aufgabe.pk %}"
hx-target="closest tr"
hx-swap="outerHTML"
class="form-input text-xs">
{% for val, label in Aufgabe.STATUS_CHOICES %}
<option value="{{ val }}" {% if val == aufgabe.status %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
```
Bei Status-Wechsel auf 'erledigt': HTMX-Response zeigt zusätzlich ein Ergebnis-Eingabefeld inline.
Nutzer kann Ergebnis eintragen und separat abspeichern.
`aufgabe_status (POST)`: Aktualisiert Status, gibt einzelne Tabellenzeile zurück.
`aufgabe_ergebnis (POST)`: Speichert Ergebnistext.
```
```task
id: WP-0006-T04
title: Bieterfragen-Liste und Bieterfrage anlegen (UC-BF-01, UC-BF-02)
status: todo
`aufgaben/views.py` — bieterfragen_liste und bieterfrage_neu:
Template `aufgaben/bieterfragen_liste.html`:
- Fristwarnung oben: "Bieterfragen bis: <Datum> — noch X Tage" (roter/gelber Banner)
- Filter: Status, Priorität, Verantwortlicher
- Tabelle: Frage (gekürzt), Status-Badge, Priorität, Einreichungsdatum, Verantwortlicher
`BieterfragenForm(ModelForm)`:
`frage` als Textarea, `hintergrund` als Textarea,
`anforderung` + `dokument` als optionale Selects.
bieterfrage_neu: Kann aus Anforderung vorausgefüllt werden (GET-Parameter `anforderung_id`).
bieterfrage_detail: Zeigt Frage, Hintergrund, verknüpfte Anforderung, Status-Timeline.
URLs in `aufgaben/bieterfragen_urls.py`:
```python
path('', views.bieterfragen_liste, name='liste'),
path('neu/', views.bieterfrage_neu, name='neu'),
path('<int:pk>/', views.bieterfrage_detail, name='detail'),
path('<int:pk>/status/', views.bieterfrage_status, name='status'),
path('<int:pk>/antwort/', views.bieterfrage_antwort, name='antwort'),
```
```
```task
id: WP-0006-T05
title: Bieterfragen-Workflow und Antwort einarbeiten (UC-BF-03)
status: todo
`bieterfrage_status (POST)`: Ermöglicht Status-Wechsel über definierte Übergänge:
entwurf → abgestimmt → eingereicht → beantwortet → eingearbeitet.
Beim Wechsel auf 'eingereicht': Setzt einreichungsdatum=heute (wenn noch nicht gesetzt).
`bieterfrage_antwort (POST)`:
Speichert `antwort`, `auswirkung_angebot`.
Falls eine Anforderung verknüpft ist: Zeigt Button "Anforderungsstatus aktualisieren" →
Weiterleitung zur Anforderungsdetailseite mit vorausgefülltem Erfüllungsstatus.
Auf der Bieterfragen-Detailseite:
- Status-Timeline als vertikale Stepper-Darstellung (CSS-only mit Tailwind)
- Antwortformular (erscheint nur bei Status 'eingereicht'/'beantwortet')
- "Einarbeiten"-Checkbox setzt Status direkt auf 'eingearbeitet' und `eingearbeitet=True`
```
```task
id: WP-0006-T06
title: Aufgaben- und Bieterfragen-Tests
status: todo
`aufgaben/tests/test_views.py`:
- Test: Aufgabenliste gibt 200 zurück
- Test: Neue Aufgabe anlegen → erscheint in Liste
- Test: Status-Wechsel via HTMX → Zeile enthält neuen Status-Badge
- Test: Überfällige Aufgabe → status wird auf 'ueberfaellig' gesetzt bei Listenabruf
- Test: Bieterfrage aus Anforderung vorausgefüllt (GET mit anforderung_id)
- Test: Antwort speichern → antwort-Feld befüllt, Status auf 'beantwortet'
```

View File

@@ -0,0 +1,157 @@
---
id: WP-0007
title: Dokumentenmanagement
status: todo
phase: 7-of-12
created: "2026-05-08"
depends_on: WP-0006
---
# WP-0007 — Dokumentenmanagement
Upload, Kategorisierung, Versionierung, Statusworkflow und Standarddokument-Zuordnung
für alle Dokumente. Referenz: UC-DO-01 bis UC-DO-05.
---
```task
id: WP-0007-T01
title: Dokument-Upload und Kategorisierung (UC-DO-01)
status: todo
`dokumente/views.py` — dokument_upload:
`DokumentForm(ModelForm)`:
Felder: datei (FileInput), kategorie (Select), version (Text, default='1.0'),
quelle (Text, blank), verantwortlicher (Select), pruefer (Select, blank), los (Select, blank).
`clean_datei()` prüft Dateiendung (.pdf, .docx, .xlsx, .zip, .png, .jpg, .jpeg) und
Dateigröße ≤ settings.MAX_UPLOAD_SIZE. Fehler: ValidationError mit klarer Meldung.
Nach erfolgreichem Upload:
- `dateiname` wird aus `datei.name` befüllt (`os.path.basename(form.instance.datei.name)`)
- Redirect zur Dokumentenliste der Ausschreibung
Multi-Upload: Zeige Dropzone (`<input type="file" multiple>`) mit Alpine.js-Preview der
gewählten Dateien (Dateinamen-Liste). Für jede Datei eigenes Formular-Submit
(vereinfacht: ein File at a time in v1).
```
```task
id: WP-0007-T02
title: Dokumentenliste und Dokumentdetail
status: todo
`dokumente/views.py` — dokumente_liste:
Zeigt alle Dokumente einer Ausschreibung, gruppiert nach Kategorie.
Filter: Status, Kategorie, Verantwortlicher.
Template `dokumente/liste.html`:
- Akkordeon nach Kategorie (Alpine.js)
- Tabelle: Dateiname (Download-Link), Version, Status-Badge, Verantwortlicher, Prüfer, Datum
- Rote Markierung für `finale_abgabeversion=True`
`dokument_detail`:
- Alle Felder via render_field
- Download-Button für Datei
- Versionshistorie (alle Dokumente gleicher Kategorie + Name, geordnet nach Version)
- Freigaben-Liste via GenericRelation
- CustomAttribute-Panel
```
```task
id: WP-0007-T03
title: Neue Dokumentversion hochladen (UC-DO-02)
status: todo
`dokument_neue_version (POST)`:
```python
def dokument_neue_version(request, ausschreibung_id, pk):
altes_dokument = get_object_or_404(Dokument, pk=pk, ausschreibung_id=ausschreibung_id)
form = DokumentVersionForm(request.POST, request.FILES)
if form.is_valid():
neues_dok = form.save(commit=False)
neues_dok.ausschreibung = altes_dokument.ausschreibung
neues_dok.los = altes_dokument.los
neues_dok.kategorie = altes_dokument.kategorie
neues_dok.verantwortlicher = altes_dokument.verantwortlicher
neues_dok.save()
altes_dokument.status = 'ersetzt'
altes_dokument.save(update_fields=['status'])
return redirect('dokumente:detail', ausschreibung_id=ausschreibung_id, pk=neues_dok.pk)
return render(request, 'dokumente/neue_version.html', {'form': form, 'dokument': altes_dokument})
```
`DokumentVersionForm`: Nur datei + version (vorausgefüllt mit inkrementierter Versionsnummer).
Logik `naechste_version(alte_version_str)`: "1.0" → "2.0", "2.3" → "3.0" (Major-Inkrement für neue Versionen).
```
```task
id: WP-0007-T04
title: Dokumentstatus-Workflow und finale Abgabeversion (UC-DO-03, UC-DO-04)
status: todo
**Status-Workflow** — HTMX-Widget analog zum Aufgaben-Status.
Statusübergänge: hochgeladen → zu_pruefen → in_bearbeitung → geprueft → freigegeben → final_abgegeben.
`dokument_status (POST)`:
Bei Übergang auf 'final_abgegeben': Setze automatisch `finale_abgabeversion=True`.
Bei Übergang auf 'final_abgegeben': Sperre weitere Status-Änderungen
(Widget rendert dann nur readonly Status-Badge ohne Dropdown).
**Finale Abgabeversion kennzeichnen** (UC-DO-04):
Zusätzlicher Button "Als finale Abgabeversion kennzeichnen" (außerhalb des normalen Workflows):
`dokument_finale_version (POST)`:
```python
def dokument_finale_version(request, ausschreibung_id, pk):
dok = get_object_or_404(Dokument, pk=pk)
dok.finale_abgabeversion = True
dok.status = 'final_abgegeben'
dok.save(update_fields=['finale_abgabeversion', 'status'])
return render(request, 'dokumente/partials/finaler_status_badge.html', {'dokument': dok})
```
Nach Kennzeichnung erscheint grüner "Final" Badge; weitere Uploads zu dieser Version gesperrt.
```
```task
id: WP-0007-T05
title: Standarddokument aus Bibliothek zuordnen (UC-DO-05)
status: todo
`dokument_bibliothek_zuordnen`:
HTMX-Modal mit Suchfeld. Suche in `bibliothek.Nachweis` und Bibliothek-Dokumente.
Jeder Treffer zeigt: Titel, Kategorie, Version, Ablaufdatum, Freigabestatus.
Zuordnung erstellt **keinen** neuen Upload, sondern einen Dokument-Datensatz mit:
- `datei` = leer (null)
- `quelle` = "Bibliothek: <Nachweis-Titel>"
- `dateiname` = Nachweis-Titel
- Referenz auf Nachweis via FK (`bibliothek_nachweis` FK(Nachweis, null=True, SET_NULL) — ergänze Feld im Dokument-Modell + Migration)
Ablaufende/abgelaufene Nachweise: Zeige Warnung in orange/rot.
```
```task
id: WP-0007-T06
title: Dokument-URL-Verkabelung und Tests
status: todo
`dokumente/urls.py`:
```python
app_name = 'dokumente'
urlpatterns = [
path('', views.dokumente_liste, name='liste'),
path('hochladen/', views.dokument_upload, name='upload'),
path('<int:pk>/', views.dokument_detail, name='detail'),
path('<int:pk>/version/', views.dokument_neue_version, name='neue_version'),
path('<int:pk>/status/', views.dokument_status, name='status'),
path('<int:pk>/final/', views.dokument_finale_version, name='finale_version'),
path('<int:pk>/bibliothek/', views.dokument_bibliothek_zuordnen, name='bibliothek_zuordnen'),
]
```
Tests:
- Test: Upload mit gültigem PDF → Dokument in DB, Datei im Dateisystem
- Test: Upload mit ungültiger Dateierweiterung → ValidationError
- Test: Upload zu groß → ValidationError
- Test: Neue Version hochladen → altes Dokument hat status='ersetzt'
- Test: Finale Abgabeversion → finale_abgabeversion=True, Status gesperrt
```

160
workplans/WP-0008-preise.md Normal file
View File

@@ -0,0 +1,160 @@
---
id: WP-0008
title: Preise und Marktpreisauswertung
status: todo
phase: 8-of-12
created: "2026-05-08"
depends_on: WP-0007
---
# WP-0008 — Preise und Marktpreisauswertung
Preispunkt-CRUD, Vergleichsgewicht-Validierung, gewichtete Durchschnittsberechnung
und Leistungstyp-Auswertung. Referenz: UC-PR-01 bis UC-PR-04, FR-25 bis FR-36.
---
```task
id: WP-0008-T01
title: Preispunkt anlegen mit Vergleichsgewicht-Validierung (UC-PR-01, UC-PR-02)
status: todo
`preise/views.py` — preispunkt_neu:
`PreispunktForm(ModelForm)`:
- Felder: leistungstyp, konkrete_leistung, mengeneinheit, menge, einzelpreis, gesamtpreis,
waehrung (default EUR), preisstand, wiederkehrend, laufzeitbezug,
subunternehmeranteil, subunternehmer, vergleichsgewicht, gewichtungsbegruendung, kommentar, los
- `vergleichsgewicht` als DecimalField-Input mit Schritt 0.1, min=0.0, max=2.0
- Widget für `vergleichsgewicht`: `<input type="number" step="0.1" min="0.0" max="2.0">`
- `clean_vergleichsgewicht()`: prüft Decimal('0.0') ≤ wert ≤ Decimal('2.0'),
sonst ValidationError("Vergleichsgewicht muss zwischen 0,0 und 2,0 liegen.")
- `initial={'vergleichsgewicht': Decimal('1.0')}`
Template `preise/form.html`:
- Abschnitt "Leistung": leistungstyp (mit Datalist für Autovervollständigung bekannter Leistungstypen),
konkrete_leistung, Mengenfelder
- Abschnitt "Preis": einzelpreis, gesamtpreis, waehrung, preisstand
- Abschnitt "Vergleichsgewicht": Numerisches Input + Hilfetextlabel
"0,0 = nicht gewertet | 1,0 = Standard | 2,0 = doppelt gewichtet"
- Subunternehmer-Toggle (Alpine x-show)
```
```task
id: WP-0008-T02
title: Preispunkt-Liste pro Ausschreibung
status: todo
`preise/views.py` — preispunkte_liste:
Zeigt alle Preispunkte der Ausschreibung, grupierbar nach Leistungstyp oder Los.
Filter: Leistungstyp, Los, Subunternehmeranteil ja/nein.
Template `preise/liste.html`:
- Tabelle: Leistungstyp, konkrete Leistung, Menge/Einheit, Einzelpreis, Gesamtpreis, Gewicht, Los
- Gewicht < 1.0: Grauer Text; Gewicht > 1.0: fetter Text; Gewicht = 0.0: durchgestrichen
- Spaltensumme Gesamtpreis (ungewichtet) am Ende der Tabelle
- "+ Preispunkt" Button
Gesamtpreis-Auto-Berechnung:
```html
<input name="einzelpreis" x-model="einzelpreis" @input="gesamtpreis = einzelpreis * menge">
<input name="gesamtpreis" x-model="gesamtpreis">
```
```
```task
id: WP-0008-T03
title: Leistungstyp-Auswertung mit gewichtetem Durchschnitt (UC-PR-03)
status: todo
`preise/views.py` — leistungstyp_auswertung:
```python
def leistungstyp_auswertung(request, ausschreibung_id):
from vergabe_teilnahme.apps.core.services import gewichteter_durchschnitt
ausschreibung = get_object_or_404(Ausschreibung, pk=ausschreibung_id)
leistungstyp = request.GET.get('leistungstyp')
filter_gewonnen = request.GET.get('gewonnen') # 'ja'/'nein'/None
qs = Preispunkt.objects.filter(einzelpreis__isnull=False)
if leistungstyp:
qs = qs.filter(leistungstyp__icontains=leistungstyp)
if filter_gewonnen == 'ja':
qs = qs.filter(ausschreibung_gewonnen=True)
elif filter_gewonnen == 'nein':
qs = qs.filter(ausschreibung_gewonnen=False)
ergebnis = gewichteter_durchschnitt(list(qs))
alle_leistungstypen = Preispunkt.objects.values_list('leistungstyp', flat=True).distinct()
ctx = {
'ausschreibung': ausschreibung,
'leistungstyp': leistungstyp,
'ergebnis': ergebnis,
'preispunkte': qs.order_by('-ausschreibung__erstellt_am'),
'alle_leistungstypen': alle_leistungstypen,
}
return render(request, 'preise/auswertung.html', ctx)
```
Template `preise/auswertung.html`:
Zeigt Statistik-Kacheln: Gewichteter Durchschnitt, Ungewichteter Durchschnitt,
Anzahl Messpunkte, Summe Gewichte, Minimum, Maximum.
Darunter: Tabelle aller Einzelmesspunkte mit Ausschreibungstitel, Datum, Gewicht.
```
```task
id: WP-0008-T04
title: Globaler Preisvergleich (cross-Ausschreibung, UC-PR-03)
status: todo
URL: `/preise/vergleich/` (globaler Endpunkt, kein ausschreibung_id Präfix)
`preise/views.py` — globaler_preisvergleich:
Gleiche Logik wie leistungstyp_auswertung, aber über alle Ausschreibungen.
Zusätzliche Filter: Zeitraum (von/bis), Ausschreibungstyp (öffentlich/privat via Ausschreiber-Feld),
nur gewonnene / nur verlorene.
Template `preise/globaler_vergleich.html`:
- Filterleiste oben (HTMX-Update der Ergebnisse)
- Datalist für Leistungstyp-Autocomplete aus allen existierenden Leistungstypen
- Statistik-Kacheln
- Aufschlüsselung: Durchschnitt bei Gewinn vs. Verlust (falls Daten vorhanden)
URL: `path('preise/vergleich/', preise_views.globaler_preisvergleich, name='preisvergleich')`
in Haupt-URLs einbinden.
```
```task
id: WP-0008-T05
title: Preisfreigabe und URL-Verkabelung (UC-PR-04)
status: todo
`preise/views.py` — preisfreigabe:
Button "Preisfreigabe erteilen" auf der Preisliste öffnet Freigabe-Modal (aus WP-0012).
Freigabe-Typ: 'preis'.
Sobald eine Preisfreigabe mit `status='erteilt'` für die Ausschreibung vorliegt,
zeigt die Abgabe-Checkliste (WP-0009) den Preisfreigabe-Punkt als abgehakt.
`preise/urls.py`:
```python
app_name = 'preise'
urlpatterns = [
path('', views.preispunkte_liste, name='liste'),
path('neu/', views.preispunkt_neu, name='neu'),
path('<int:pk>/', views.preispunkt_detail, name='detail'),
path('<int:pk>/bearbeiten/', views.preispunkt_bearbeiten, name='bearbeiten'),
path('<int:pk>/loeschen/', views.preispunkt_loeschen, name='loeschen'),
path('auswertung/', views.leistungstyp_auswertung, name='auswertung'),
]
```
Tests:
- Test: Vergleichsgewicht 0.0 → gespeichert, nicht in Durchschnitt
- Test: Vergleichsgewicht 2.5 → ValidationError
- Test: gewichteter_durchschnitt mit Blueprint-Beispiel → 103.33 (auf 2 Stellen)
- Test: Auswertungs-View gibt 200 zurück, enthält 'ergebnis' in Context
```

View File

@@ -0,0 +1,201 @@
---
id: WP-0009
title: Abgabe und Nachbetrachtung
status: todo
phase: 9-of-12
created: "2026-05-08"
depends_on: WP-0008
---
# WP-0009 — Abgabe (Phase 6/7) und Nachbetrachtung (Phase 8)
Abgabe-Checkliste, Vollständigkeitsprüfung, Abgabe-Dokumentation mit Nachweis,
Ergebnis erfassen, Kickoff-Aufgabe erstellen, Verlustanalyse, Lessons Learned.
Referenz: UC-AB-01 bis UC-AB-03, UC-NB-01 bis UC-NB-03.
---
```task
id: WP-0009-T01
title: Abgabe-Checkliste mit Vollständigkeitsstatus (UC-AB-01)
status: todo
`nachbetrachtung/abgabe_views.py` — abgabe_checkliste:
Vollständigkeitsprüfung-Service `abgabe_vollstaendigkeit(ausschreibung)`:
```python
def abgabe_vollstaendigkeit(ausschreibung):
from vergabe_teilnahme.apps.dokumente.models import Dokument
from vergabe_teilnahme.apps.core.models import Freigabe
from django.contrib.contenttypes.models import ContentType
ct = ContentType.objects.get_for_model(ausschreibung)
freigaben = Freigabe.objects.filter(content_type=ct, object_id=ausschreibung.pk)
def hat_freigabe(typ):
return freigaben.filter(freigabe_typ=typ, status='erteilt').exists()
return {
'dokumente_gesamt': Dokument.objects.filter(ausschreibung=ausschreibung).count(),
'dokumente_freigegeben': Dokument.objects.filter(
ausschreibung=ausschreibung, status__in=['freigegeben', 'final_abgegeben']).count(),
'teilnahme_freigabe': hat_freigabe('teilnahme'),
'preis_freigabe': hat_freigabe('preis'),
'recht_freigabe': hat_freigabe('recht'),
'abgabe_freigabe': hat_freigabe('abgabe'),
'entscheidung_getroffen': ausschreibung.teilnahmeentscheidung == 'teilnahme',
}
```
Template `nachbetrachtung/abgabe.html`:
- Fortschrittsbalken oben (Anzahl erfüllter Checkpunkte / Gesamt)
- Checkliste mit Grün-/Rot-Badges für jeden Punkt
- Dokumente-Sektion: Liste aller Dokumente mit Status
- Freigaben-Sektion: Welche Freigaben vorliegen / fehlen
- Frist-Banner: "Abgabe bis: <Datum>" prominent oben
```
```task
id: WP-0009-T02
title: Abgabe dokumentieren mit Nachweis-Upload (UC-AB-02)
status: todo
`nachbetrachtung/abgabe_views.py` — abgabe_dokumentieren:
`AbgabeForm(Form)`:
- `abgabe_zeitpunkt` DateTimeInput(type='datetime-local')
- `abgabe_plattform` CharField
- `verantwortlicher` ModelChoiceField(Mitarbeiter)
- `abgabenachweis` FileField (Eingangsbestätigung, Screenshot etc.)
- `kommentar` Textarea
```python
def abgabe_dokumentieren(request, ausschreibung_id):
ausschreibung = get_object_or_404(Ausschreibung, pk=ausschreibung_id)
if request.method == 'POST':
form = AbgabeForm(request.POST, request.FILES)
if form.is_valid():
# Abgabenachweis als Dokument speichern
if form.cleaned_data.get('abgabenachweis'):
Dokument.objects.create(
ausschreibung=ausschreibung,
datei=form.cleaned_data['abgabenachweis'],
kategorie='abgabenachweis',
status='final_abgegeben',
finale_abgabeversion=True,
)
# Ausschreibungsstatus auf Abgegeben setzen
ausschreibung.status = 9
ausschreibung.save(update_fields=['status', 'geaendert_am'])
# Alle Dokumente mit finale_abgabeversion=True: gesperrt (kein weiterer Upload)
return redirect('ausschreibungen:detail', pk=ausschreibung_id)
else:
form = AbgabeForm()
return render(request, 'nachbetrachtung/abgabe_formular.html',
{'form': form, 'ausschreibung': ausschreibung})
```
```
```task
id: WP-0009-T03
title: Nachbetrachtung-View — Ergebnis und Kickoff (UC-NB-01)
status: todo
`nachbetrachtung/views.py` — nachbetrachtung_detail:
Erstellt oder lädt die OneToOne `Nachbetrachtung` für die Ausschreibung.
`NachbetrachtungForm(ModelForm)`:
Felder: ergebnis (Radio), zuschlagsdatum, projektverantwortlicher (Select Mitarbeiter).
Bei Ergebnis 'gewonnen' (POST):
1. Setze Ausschreibungsstatus auf 10
2. Erstelle automatisch Aufgabe:
```python
Aufgabe.objects.get_or_create(
ausschreibung=ausschreibung,
titel="Kickoff vorbereiten",
defaults={
'typ': 'fachlich',
'prioritaet': 1,
'verantwortlicher': form.cleaned_data.get('projektverantwortlicher'),
'beschreibung': f"Kickoff für {ausschreibung.titel}. Angebotsumfang und Annahmen übergeben.",
}
)
```
3. Flash-Meldung: "Kickoff-Aufgabe erstellt für <Projektverantwortlicher>"
Template `nachbetrachtung/detail.html`:
- Ergebnis-Formular oben
- Bei Ergebnis 'gewonnen': Übergabe-Abschnitt (Projektverantwortlicher, Kickoff-Aufgabe-Link)
- Bei Ergebnis 'verloren': Verlustanalyse-Abschnitt (aus UC-NB-02)
```
```task
id: WP-0009-T04
title: Verlustanalyse und Lessons Learned (UC-NB-02, UC-NB-03)
status: todo
**Verlustgründe** — dynamisches JSONField-Formular:
Alpine.js-gesteuertes Array:
```html
<div x-data="{ gruende: {{ nachbetrachtung.verlustgruende|json_script:'gruende'|safe }} }">
<template x-for="(g, i) in gruende" :key="i">
<div class="flex gap-2 mb-2">
<input x-model="g.grund" class="form-input flex-1" placeholder="Verlustgrund">
<select x-model="g.kategorie" class="form-input w-32">
<option value="preis">Preis</option>
<option value="referenz">Referenz</option>
<option value="anforderung">Anforderung</option>
<option value="subunternehmer">Subunternehmer</option>
<option value="sonstiges">Sonstiges</option>
</select>
<input type="number" x-model.number="g.verlaesslichkeit" min="1" max="5"
class="form-input w-20" placeholder="1-5">
<button @click="gruende.splice(i, 1)" class="btn-ghost text-red-500">✕</button>
</div>
</template>
<button @click="gruende.push({grund:'', kategorie:'sonstiges', verlaesslichkeit:3})"
class="btn-secondary">+ Verlustgrund</button>
<input type="hidden" name="verlustgruende" :value="JSON.stringify(gruende)">
</div>
```
Ausschlaggebende Merkmale, Lessons Learned, Empfehlungen: einfache Textareas.
Checkbox "Wiederverwendbare Erkenntnisse markieren".
Bei Speichern: Aktualisiere `Nachbetrachtung`-Objekt, Redirect zur Nachbetrachtungsseite.
```
```task
id: WP-0009-T05
title: URL-Verkabelung Abgabe/Nachbetrachtung und Tests
status: todo
`nachbetrachtung/abgabe_urls.py`:
```python
urlpatterns = [
path('', abgabe_views.abgabe_checkliste, name='checkliste'),
path('dokumentieren/', abgabe_views.abgabe_dokumentieren, name='dokumentieren'),
path('problem/', abgabe_views.abgabe_problem, name='problem'),
]
```
`nachbetrachtung/urls.py`:
```python
app_name = 'nachbetrachtung'
urlpatterns = [
path('', views.nachbetrachtung_detail, name='detail'),
]
```
`abgabe_problem (POST)`:
Setzt `ausschreibung.status` auf internen Marker "Problem bei Abgabe" (eigenes Status-Choice ergänzen wenn nötig, oder Kommentarfeld).
Tests:
- Test: abgabe_vollstaendigkeit ohne Freigaben → alle False
- Test: Freigabe erteilen → entsprechendes Feld True
- Test: Ergebnis 'gewonnen' → Kickoff-Aufgabe wird erstellt, Status 10
- Test: Ergebnis 'verloren' → Status 11
- Test: Verlustgründe JSONField gespeichert mit korrekter Struktur
```

View File

@@ -0,0 +1,219 @@
---
id: WP-0010
title: Subunternehmer, Partner und Bibliothek
status: todo
phase: 10-of-12
created: "2026-05-08"
depends_on: WP-0009
---
# WP-0010 — Subunternehmer, Partner und Bibliothek
Subunternehmer-Katalog, Dienstleistertypen, Nachweis-/Referenz-/Leistungsblatt-/
Entscheidungsregel-Verwaltung. Referenz: UC-SU-01 bis UC-SU-04, UC-BIB-01 bis UC-BIB-05.
---
```task
id: WP-0010-T01
title: Subunternehmer-Katalog: Liste, Suche, Anlegen (UC-SU-01, UC-SU-03)
status: todo
`partner/views.py` — subunternehmer_liste, subunternehmer_neu:
Liste: Filter nach Dienstleistertyp, Präferenz (bevorzugt/zugelassen/gesperrt), Freitext-Suche.
`Subunternehmer.objects.filter(name__icontains=q)` für Freitext.
Präferenz 'gesperrt': Rot-Badge, wird in Suchergebnissen mit Warnsymbol angezeigt.
`SubunternehmerForm(ModelForm)`: alle Felder, Präferenz als Radio-Buttons.
subunternehmer_detail: Stammdaten + verknüpfte Ausschreibungen (über SubunternehmerZuordnung):
```python
zuordnungen = SubunternehmerZuordnung.objects.filter(
subunternehmer=obj
).select_related('ausschreibung', 'los').order_by('-ausschreibung__erstellt_am')
```
Zeigt: Ausschreibung, Los, Leistung, Zusage/Nachweis/Preis-Status.
CustomAttribute-Panel.
```
```task
id: WP-0010-T02
title: Subunternehmer einer Ausschreibung/Los zuordnen (UC-SU-02)
status: todo
`partner/views.py` — subunternehmer_zuordnen:
HTMX-Modal auf Los-Detail-Seite:
```python
def subunternehmer_suche_modal(request, ausschreibung_id, los_pk):
q = request.GET.get('q', '')
subunternehmer = Subunternehmer.objects.filter(name__icontains=q)
return render(request, 'partner/partials/subunternehmer_suche.html',
{'subunternehmer': subunternehmer, 'los_pk': los_pk,
'ausschreibung_id': ausschreibung_id})
def subunternehmer_zuordnen(request, ausschreibung_id, los_pk):
if request.method == 'POST':
sub_id = request.POST['subunternehmer_id']
sub = get_object_or_404(Subunternehmer, pk=sub_id)
if sub.praeferenz == 'gesperrt':
# Warnung anzeigen aber nicht blockieren
pass
los = get_object_or_404(Los, pk=los_pk)
zuordnung, created = SubunternehmerZuordnung.objects.get_or_create(
subunternehmer=sub,
ausschreibung_id=ausschreibung_id,
los=los,
defaults={'konkrete_leistung': request.POST.get('konkrete_leistung', '')}
)
return render(request, 'partner/partials/zuordnung_zeile.html',
{'zuordnung': zuordnung})
```
Auf der Los-Detail-Seite zeigt jede Zuordnung: Name, Dienstleistertyp, Leistung,
drei Checkboxen (Zusage, Nachweis, Preis) — HTMX-togglebar.
```
```task
id: WP-0010-T03
title: Dienstleistertyp-Katalog und Subunternehmer als gesperrt markieren (UC-SU-04)
status: todo
`partner/views.py` — dienstleistertypen_liste, dienstleistertyp_neu/_bearbeiten:
Einfache CRUD-Views für Dienstleistertypen (Katalog-Daten).
`subunternehmer_praeferenz (POST)`:
```python
def subunternehmer_praeferenz(request, pk):
sub = get_object_or_404(Subunternehmer, pk=pk)
if request.method == 'POST':
sub.praeferenz = request.POST['praeferenz']
if sub.praeferenz == 'gesperrt':
sub.bewertung = request.POST.get('begruendung', sub.bewertung)
sub.save(update_fields=['praeferenz', 'bewertung'])
return render(request, 'partner/partials/praeferenz_badge.html', {'sub': sub})
```
Bei Präferenz 'gesperrt': Roter Warnhinweis wenn dieser Subunternehmer bei
Zuordnung gewählt wird (im Suchmodal).
`partner/urls.py`:
```python
app_name = 'partner'
urlpatterns = [
path('subunternehmer/', views.subunternehmer_liste, name='su_liste'),
path('subunternehmer/neu/', views.subunternehmer_neu, name='su_neu'),
path('subunternehmer/<int:pk>/', views.subunternehmer_detail, name='su_detail'),
path('subunternehmer/<int:pk>/bearbeiten/', views.subunternehmer_bearbeiten, name='su_bearbeiten'),
path('subunternehmer/<int:pk>/praeferenz/', views.subunternehmer_praeferenz, name='su_praeferenz'),
path('dienstleistertypen/', views.dienstleistertypen_liste, name='dt_liste'),
path('dienstleistertypen/neu/', views.dienstleistertyp_neu, name='dt_neu'),
]
```
```
```task
id: WP-0010-T04
title: Bibliothek: Nachweis-Katalog mit Ablaufwarnung (UC-BIB-01, UC-BIB-02)
status: todo
`bibliothek/views.py` — nachweise_liste, nachweis_neu/_bearbeiten:
Liste mit Ablauffilter:
```python
from datetime import date, timedelta
heute = date.today()
in_60_tagen = heute + timedelta(days=60)
```
Tabs: "Alle", "Bald ablaufend" (`gueltig_bis__lte=in_60_tagen`), "Abgelaufen" (`gueltig_bis__lt=heute`).
Abgelaufene Nachweise: Roter Badge. Bald ablaufende: Oranger Badge.
`NachweisForm(ModelForm)`:
`datei` als FileInput. `gueltig_ab`/`gueltig_bis` als DateInput type="date".
Checkboxen für fuer_oeffentliche/fuer_privatwirtschaftliche.
nachweis_neue_version: Analog zu Dokument-Versionierung (WP-0007-T03).
Alten Nachweis auf status='ersetzt', neuen anlegen mit höherer Version.
`bibliothek/urls.py` (Auszug):
```python
path('nachweise/', views.nachweise_liste, name='nachweise_liste'),
path('nachweise/neu/', views.nachweis_neu, name='nachweis_neu'),
path('nachweise/<int:pk>/', views.nachweis_detail, name='nachweis_detail'),
path('nachweise/<int:pk>/version/', views.nachweis_neue_version, name='nachweis_version'),
```
```
```task
id: WP-0010-T05
title: Bibliothek: Referenz anlegen und zuordnen (UC-BIB-03, UC-BIB-04)
status: todo
`bibliothek/views.py` — referenzen_liste, referenz_neu/_bearbeiten:
`ReferenzForm(ModelForm)`:
`whitepaper` als FileInput. `projektzeitraum` als CharField (freies Datum-Format: "2024-2025").
`leistungsblaetter` als CheckboxSelectMultiple.
referenz_detail: Zeigt alle Felder, Whitepaper-Download-Link, verknüpfte Leistungsblätter.
`referenz_zuordnen (POST)`:
Wird von Ausschreibungs-Abgabe-Seite aufgerufen.
Erstellt eine M2M-Verknüpfung zwischen Referenz und Ausschreibung.
(Ergänze `referenzen` M2M-Feld auf Ausschreibung-Modell + Migration)
HTMX-Suchmodal: Suche nach Branche, Leistungsbeschreibung, Titel.
Zeigt Freigabestatus und Nutzungseinschränkungen als Warnung.
```
```task
id: WP-0010-T06
title: Bibliothek: Leistungsblatt und Entscheidungsregel (UC-BIB-05)
status: todo
`bibliothek/views.py` — leistungsblaetter_liste, leistungsblatt_neu/_bearbeiten:
Einfache CRUD-Views. `LeistungsblattForm(ModelForm)` mit allen Textfeldern.
`bibliothek/views.py` — entscheidungsregeln_liste, entscheidungsregel_neu/_bearbeiten:
`EntscheidungsregelForm(ModelForm)`:
`kategorie` als Select mit Choices:
[(ausschlusskriterium, 'Ausschlusskriterium'), (frist, 'Fristlage'),
(referenz, 'Referenzanforderung'), (wirtschaftlichkeit, 'Wirtschaftlichkeit'),
(ressourcen, 'Ressourcenverfügbarkeit'), (sonstiges, 'Sonstiges')]
`empfehlung` als Radio-Buttons (teilnehmen/nicht_teilnehmen/pruefen).
`aktiv`-Toggle in der Liste (HTMX POST).
Auf der Entscheidungsseite (Phase 2) werden nur `aktiv=True` Regeln angezeigt.
```
```task
id: WP-0010-T07
title: Bibliothek URL-Verkabelung und Tests
status: todo
`bibliothek/urls.py` vollständig:
```python
app_name = 'bibliothek'
urlpatterns = [
path('nachweise/', ...),
path('referenzen/', ...),
path('leistungsblaetter/', ...),
path('entscheidungsregeln/', ...),
# Detail/Neu/Bearbeiten für jede Entität
]
```
Global in Haupt-URLs:
`path('bibliothek/', include('vergabe_teilnahme.apps.bibliothek.urls'))`
`path('partner/', include('vergabe_teilnahme.apps.partner.urls'))`
Tests:
- Test: Nachweis mit abgelaufenem gueltig_bis → `ist_abgelaufen` property True
- Test: Nachweis-Liste mit Filter "Abgelaufen" → nur abgelaufene sichtbar
- Test: Subunternehmer-Zuordnung zu Los → SubunternehmerZuordnung-Objekt in DB
- Test: Gesperrter Subunternehmer → Warnung im Modal sichtbar (Template enthält 'gesperrt')
- Test: Entscheidungsregel mit aktiv=False → erscheint nicht in Phase-2-Auswertung
```

View File

@@ -0,0 +1,131 @@
---
id: WP-0011
title: Marktbegleiter-Analyse
status: todo
phase: 11-of-12
created: "2026-05-08"
depends_on: WP-0010
---
# WP-0011 — Marktbegleiter-Analyse
Marktbegleiter-Katalog, Passagen-Erfassung mit Verlässlichkeitsscore,
Musterauswertung. Referenz: UC-MB-01 bis UC-MB-03.
---
```task
id: WP-0011-T01
title: Marktbegleiter-Katalog: Liste und Anlegen (UC-MB-01)
status: todo
`marktbegleiter/views.py` — marktbegleiter_liste, marktbegleiter_neu/_bearbeiten:
Liste: Tabellenansicht mit Name, Branchen (gekürzt), Aktualisierungsdatum.
Filter: Branche (Freitext-Match auf `relevante_branchen`).
`MarktbegleiterForm(ModelForm)`:
`typische_formulierungen` als Textarea mit Placeholder "Eine Formulierung pro Zeile".
`vertraulichkeit` als Select (intern/streng_vertraulich).
marktbegleiter_detail:
- Profil-Abschnitte: Portfolio, Stärken/Schwächen, typische Formulierungen (als Tags/Pilllen)
- Abschnitt "Verknüpfte Passagen": Liste aller Ausschreibungspassagen, sortiert nach Verlässlichkeit desc
- Aggregation: "Erscheint in X Ausschreibungen, Ø Verlässlichkeit: Y"
- CustomAttribute-Panel
```
```task
id: WP-0011-T02
title: Ausschreibungspassage erfassen (UC-MB-02, UC-MB-03)
status: todo
`marktbegleiter/passagen_views.py` — passagen_liste und passage_neu:
passagen_liste (pro Ausschreibung):
`Ausschreibungspassage.objects.filter(ausschreibung_id=ausschreibung_id).select_related('marktbegleiter', 'dokument')`
Template `marktbegleiter/passagen_liste.html`: Tabelle mit Textauszug (150 Zeichen), Marktbegleiter,
Score, Kategorie, Datum.
`AusschreibungspassageForm(ModelForm)`:
- `passage` als großes Textarea
- `verlaesslichkeitsscore` als Range-Input 1-10 (mit Alpine.js-Beschriftung: "1=sehr unsicher, 10=sehr sicher")
- `marktbegleiter` als Select + Link "Neuen Marktbegleiter anlegen" (öffnet Modal)
- `dokument` als Select (nur Dokumente dieser Ausschreibung)
- `kategorie` als Select: formulierung/leistungsmerkmal/zertifizierung/referenz/sonstiges
Nach Speichern: Passage erscheint auf Ausschreibungsdetail (Tab "Marktbegleiter")
und im Marktbegleiter-Profil.
```
```task
id: WP-0011-T03
title: Marktbegleiter-Musterauswertung (UC-MB-03)
status: todo
`marktbegleiter/views.py` — marktbegleiter_auswertung:
```python
def marktbegleiter_auswertung(request, pk):
mb = get_object_or_404(Marktbegleiter, pk=pk)
passagen = Ausschreibungspassage.objects.filter(marktbegleiter=mb).select_related(
'ausschreibung', 'dokument'
)
# Aggregationen
ausschreiber_haeufigkeit = passagen.values(
'ausschreibung__ausschreiber'
).annotate(count=Count('id')).order_by('-count')[:10]
score_durchschnitt = passagen.aggregate(Avg('verlaesslichkeitsscore'))
ctx = {
'marktbegleiter': mb,
'passagen': passagen,
'ausschreiber_haeufigkeit': ausschreiber_haeufigkeit,
'score_durchschnitt': score_durchschnitt['verlaesslichkeitsscore__avg'],
'anzahl_ausschreibungen': passagen.values('ausschreibung').distinct().count(),
}
return render(request, 'marktbegleiter/auswertung.html', ctx)
```
Template `marktbegleiter/auswertung.html`:
- Statistikkacheln: Anzahl Passagen, Anzahl Ausschreibungen, Ø Score
- Tabelle: Häufigste Ausschreiber
- Alle Passagen sortierbar nach Datum / Score / Ausschreibung
```
```task
id: WP-0011-T04
title: URL-Verkabelung und Tests
status: todo
`marktbegleiter/passagen_urls.py`:
```python
urlpatterns = [
path('', passagen_views.passagen_liste, name='liste'),
path('neu/', passagen_views.passage_neu, name='neu'),
path('<int:pk>/', passagen_views.passage_detail, name='detail'),
path('<int:pk>/bearbeiten/', passagen_views.passage_bearbeiten, name='bearbeiten'),
]
```
`marktbegleiter/urls.py`:
```python
app_name = 'marktbegleiter'
urlpatterns = [
path('', views.marktbegleiter_liste, name='liste'),
path('neu/', views.marktbegleiter_neu, name='neu'),
path('<int:pk>/', views.marktbegleiter_detail, name='detail'),
path('<int:pk>/auswertung/', views.marktbegleiter_auswertung, name='auswertung'),
path('<int:pk>/bearbeiten/', views.marktbegleiter_bearbeiten, name='bearbeiten'),
]
```
Global in Haupt-URLs: `path('marktbegleiter/', include('vergabe_teilnahme.apps.marktbegleiter.urls'))`
Tests:
- Test: Passage anlegen mit verlaesslichkeitsscore=10 → gespeichert
- Test: verlaesslichkeitsscore=11 → ValidationError (MinValueValidator/MaxValueValidator)
- Test: Auswertungs-View mit Passagen → score_durchschnitt korrekt berechnet
- Test: Marktbegleiter-Detail zeigt Passagen-Liste
```

View File

@@ -0,0 +1,346 @@
---
id: WP-0012
title: Querschnitt — Freigaben, Flexible Felder, Feedback, Suche, Tests
status: todo
phase: 12-of-12
created: "2026-05-08"
depends_on: WP-0011
---
# WP-0012 — Querschnitt
Generisches Freigabe-Modal, EntityFieldConfig Admin-UI, CustomAttribute-Panel,
Feedback vollständig, globale Suche fertigstellen, End-to-End-Tests.
Referenz: UC-FR-01, UC-FR-02, UC-FF-01 bis UC-FF-03, UC-FB-01, UC-FB-02.
---
```task
id: WP-0012-T01
title: Generisches Freigabe-Modal (UC-FR-01)
status: todo
`core/views.py` — freigabe_modal und freigabe_erteilen:
```python
def freigabe_modal(request):
"""Gibt das Freigabe-Formular-Modal als Fragment zurück."""
content_type_id = request.GET.get('ct')
object_id = request.GET.get('oid')
freigabe_typ = request.GET.get('typ')
ctx = {
'content_type_id': content_type_id,
'object_id': object_id,
'freigabe_typ': freigabe_typ,
'freigabe_typ_choices': Freigabe.TYP_CHOICES,
}
return render(request, 'partials/freigabe_modal.html', ctx)
def freigabe_erteilen(request):
"""Speichert eine Freigabe und gibt Success-Fragment zurück."""
if request.method == 'POST':
ct = ContentType.objects.get(pk=request.POST['content_type_id'])
Freigabe.objects.create(
content_type=ct,
object_id=request.POST['object_id'],
freigabe_typ=request.POST['freigabe_typ'],
freigebende_person=request.user,
status='erteilt',
kommentar=request.POST.get('kommentar', ''),
)
return render(request, 'partials/freigabe_success.html', {})
return HttpResponseBadRequest()
```
`partials/freigabe_modal.html`:
Modal mit Typ-Dropdown (vorausgefüllt wenn übergeben), Kommentarfeld, "Freigabe erteilen"-Button.
`hx-post="/freigaben/erteilen/" hx-target="#modal-container"`
Nutzung in anderen Templates:
```html
<button hx-get="/freigaben/modal/?ct={{ ct_id }}&oid={{ obj.pk }}&typ=preis"
hx-target="#modal-container">Preisfreigabe erteilen</button>
```
Hilfsfunktion `get_content_type_id(model_instance)` in core/templatetags.
URL: `path('freigaben/modal/', core_views.freigabe_modal, name='freigabe_modal')`
`path('freigaben/erteilen/', core_views.freigabe_erteilen, name='freigabe_erteilen')`
```
```task
id: WP-0012-T02
title: Freigaben-Übersicht pro Ausschreibung (UC-FR-02)
status: todo
`ausschreibungen/views.py` — freigaben_uebersicht:
```python
def freigaben_uebersicht(request, pk):
ausschreibung = get_object_or_404(Ausschreibung, pk=pk)
ct = ContentType.objects.get_for_model(ausschreibung)
freigaben = Freigabe.objects.filter(
content_type=ct, object_id=pk
).select_related('freigebende_person').order_by('-timestamp')
# Welche Freigabetypen fehlen noch?
erteilte_typen = set(freigaben.filter(status='erteilt').values_list('freigabe_typ', flat=True))
erforderliche_typen = {'teilnahme', 'preis', 'abgabe'}
fehlende_typen = erforderliche_typen - erteilte_typen
ctx = {
'ausschreibung': ausschreibung,
'freigaben': freigaben,
'fehlende_typen': fehlende_typen,
'breadcrumbs': [...],
}
return render(request, 'ausschreibungen/freigaben.html', ctx)
```
Template: Tabelle mit allen Freigaben + roter Banner für fehlende Pflichtfreigaben.
Auf Detailseite: Tab "Freigaben" → diese View.
URL: `path('<int:pk>/freigaben/', views.freigaben_uebersicht, name='freigaben')`
```
```task
id: WP-0012-T03
title: EntityFieldConfig Admin-Interface (UC-FF-01, UC-FF-02)
status: todo
`core/views.py` — feld_konfiguration_liste und feld_konfiguration_toggle:
Nicht der Django-Standard-Admin, sondern eine eigene Verwaltungsseite unter `/admin/felder/`.
```python
ENTITY_TYPES = [
('ausschreibung', Ausschreibung),
('los', Los),
('anforderung', Anforderung),
('aufgabe', Aufgabe),
('subunternehmer', Subunternehmer),
('nachweis', Nachweis),
('referenz', Referenz),
# alle FlexibleModel-Unterklassen
]
def feld_konfiguration_liste(request, entity_type):
model = dict(ENTITY_TYPES)[entity_type]
felder = [f for f in model._meta.get_fields()
if hasattr(f, 'column') and not f.name.startswith('_')]
konfigurationen = {
cfg.field_name: cfg
for cfg in EntityFieldConfig.objects.filter(entity_type=entity_type)
}
return render(request, 'core/feld_konfiguration.html', {
'entity_type': entity_type, 'felder': felder, 'konfigurationen': konfigurationen
})
def feld_konfiguration_toggle(request, entity_type, field_name):
cfg, _ = EntityFieldConfig.objects.get_or_create(
entity_type=entity_type, field_name=field_name)
if request.method == 'POST':
cfg.is_hidden = request.POST.get('is_hidden') == 'true'
cfg.display_label = request.POST.get('display_label', '')
cfg.save()
return render(request, 'core/partials/feld_zeile.html', {'cfg': cfg, 'field_name': field_name})
```
Template `core/feld_konfiguration.html`:
Pro Feld eine Zeile mit: Feldname, Anzeige-Label-Input, Ausblenden-Toggle (HTMX).
Änderungen sofort aktiv.
```
```task
id: WP-0012-T04
title: CustomAttribute-Panel für alle Detailseiten (UC-FF-03)
status: todo
`core/views.py` — custom_attributes_panel, custom_attribute_neu, custom_attribute_bearbeiten:
```python
def custom_attributes_panel(request, content_type_id, object_id):
ct = get_object_or_404(ContentType, pk=content_type_id)
attrs = CustomAttribute.objects.filter(content_type=ct, object_id=object_id)
return render(request, 'core/partials/custom_attributes.html',
{'attrs': attrs, 'ct_id': content_type_id, 'oid': object_id})
def custom_attribute_neu(request, content_type_id, object_id):
ct = get_object_or_404(ContentType, pk=content_type_id)
if request.method == 'POST':
CustomAttribute.objects.create(
content_type=ct,
object_id=object_id,
key=slugify(request.POST['label']),
label=request.POST['label'],
value=request.POST.get('value', ''),
data_type=request.POST.get('data_type', 'text'),
)
return redirect_to_panel(content_type_id, object_id)
```
`core/partials/custom_attributes.html`:
HTMX lazy-load aus Detailseiten:
```html
<section hx-get="/core/attrs/{{ ct_id }}/{{ obj.pk }}/"
hx-trigger="load" hx-swap="innerHTML">Lade...</section>
```
Formular für neues Attribut:
Alpine.js `x-show="form_open"` Toggle.
Felder: Label, Wert, Datentyp (Select: text/number/date/boolean/url/email).
Bestehende Attribute: inline bearbeitbar, löschbar via HTMX DELETE.
Sortierung via Up/Down-Buttons (HTMX POST auf `custom_attribute_sort`).
URLs:
```python
path('core/attrs/<int:ct_id>/<int:oid>/', custom_attributes_panel),
path('core/attrs/<int:ct_id>/<int:oid>/neu/', custom_attribute_neu),
path('core/attrs/<int:ct_id>/<int:oid>/<int:attr_pk>/bearbeiten/', custom_attribute_bearbeiten),
path('core/attrs/<int:ct_id>/<int:oid>/<int:attr_pk>/loeschen/', custom_attribute_loeschen),
path('core/attrs/<int:ct_id>/<int:oid>/<int:attr_pk>/sort/', custom_attribute_sort),
```
```
```task
id: WP-0012-T05
title: Feedback vollständig: Modal-POST und Backlog-View (UC-FB-01, UC-FB-02)
status: todo
`feedback/views.py` — feedback_modal, feedback_speichern, feedback_backlog:
feedback_speichern (POST):
```python
def feedback_speichern(request):
if request.method == 'POST':
Feedbackeintrag.objects.create(
titel=request.POST.get('titel', 'Ohne Titel'),
beschreibung=request.POST['beschreibung'],
kategorie=request.POST.get('kategorie', 'hinweis'),
dringlichkeit=request.POST.get('dringlichkeit', 'mittel'),
seite_kontext=request.POST.get('seite_kontext', ''),
ausschreibung_id=request.POST.get('ausschreibung') or None,
erfasst_von=request.user if request.user.is_authenticated else None,
)
return render(request, 'partials/feedback_success.html')
```
`partials/feedback_success.html`: Danke-Meldung die nach 3 Sekunden verschwindet
(Alpine.js `x-data="{ show: true }" x-show="show" x-init="setTimeout(() => show=false, 3000)"`).
feedback_backlog: Liste aller Einträge mit Filter nach Status, Kategorie, Dringlichkeit.
Admin kann Status ändern (neu/in_bearbeitung/umgesetzt/abgelehnt), Bewertung und Entscheidung eintragen.
```
```task
id: WP-0012-T06
title: End-to-End-Tests für kritische Use Cases
status: todo
Erstelle `vergabe_teilnahme/tests/test_e2e.py` mit vollständigen Prozess-Tests:
```python
import pytest
from django.test import Client
@pytest.mark.django_db
class TestVollstaendigerBieterprozess:
def test_ausschreibung_anlegen_bis_abgabe(self, client, mitarbeiter):
client.force_login(mitarbeiter)
# 1. Ausschreibung anlegen
r = client.post('/ausschreibungen/neu/', {'titel': 'Test', 'ausschreiber': 'ABC GmbH', ...})
assert r.status_code == 302
ausschreibung_id = Ausschreibung.objects.last().pk
# 2. Los anlegen
r = client.post(f'/ausschreibungen/{ausschreibung_id}/lose/neu/', {...})
assert Los.objects.filter(ausschreibung_id=ausschreibung_id).count() == 1
# 3. Anforderung anlegen
# 4. Aufgabe anlegen
# 5. Preispunkt mit Vergleichsgewicht 1.5 anlegen
p = Preispunkt.objects.last()
assert p.vergleichsgewicht == Decimal('1.5')
# 6. Freigabe erteilen
r = client.post('/freigaben/erteilen/', {
'content_type_id': ct_id, 'object_id': ausschreibung_id,
'freigabe_typ': 'teilnahme'
})
assert Freigabe.objects.filter(freigabe_typ='teilnahme').exists()
# 7. Abgabe dokumentieren
r = client.post(f'/ausschreibungen/{ausschreibung_id}/abgabe/dokumentieren/', {...})
ausschreibung.refresh_from_db()
assert ausschreibung.status == 9
@pytest.mark.django_db
class TestFlexibleFelder:
def test_feld_ausblenden_wirkt_im_template(self, client, mitarbeiter):
sub = Subunternehmer.objects.create(name='Test GmbH', mobilnummer='0123')
EntityFieldConfig.objects.create(
entity_type='subunternehmer', field_name='mobilnummer', is_hidden=True)
client.force_login(mitarbeiter)
r = client.get(f'/partner/subunternehmer/{sub.pk}/')
assert 'mobilnummer' not in r.content.decode().lower() or 'Mobilnummer' not in r.content.decode()
def test_custom_attribute_hinzufuegen(self, client, mitarbeiter):
sub = Subunternehmer.objects.create(name='Test GmbH')
ct = ContentType.objects.get_for_model(sub)
client.force_login(mitarbeiter)
r = client.post(f'/core/attrs/{ct.pk}/{sub.pk}/neu/',
{'label': 'Vertragsnummer', 'value': 'VN-2026-001', 'data_type': 'text'})
assert CustomAttribute.objects.filter(object_id=sub.pk, label='Vertragsnummer').exists()
```
```
```task
id: WP-0012-T07
title: Globale Suche vervollständigen und Performance-Prüfung
status: todo
Vervollständige `core/views.py` — global_search:
- Dokumente (dateiname__icontains)
- Nachweise (titel__icontains)
- Referenzen (referenztitel__icontains, kunde__icontains)
- Marktbegleiter (name__icontains)
Optimierung: Alle Queries in einem einzigen DB-Roundtrip via `select_related` und
Begrenzung auf 5 Treffer pro Kategorie.
`partials/search_results.html` vollständig:
Alle sechs Ergebniskategorien mit Icon und Link.
Leer-State: "Keine Ergebnisse für '<q>'" wenn alle leer.
Performance-Test: Prüfe mit `uv run manage.py shell -c "..."` und `django.test.utils.CaptureQueriesContext`
dass die Such-View ≤ 6 DB-Queries ausführt (eine pro Entitätstyp + ContentType).
```
```task
id: WP-0012-T08
title: Finaler Integrations-Smoke-Test und CLAUDE.md-Aktualisierung
status: todo
Führe den finalen Integrations-Test durch:
1. Alle Migrationen sauber: `uv run manage.py migrate` → 0 Fehler
2. Seed-Daten: `uv run manage.py seed_dev` → 0 Fehler
3. `uv run pytest` → alle Tests grün, Testabdeckung ≥ 60% für kritische Module
4. `uv run ruff check .` → 0 Fehler
5. Manueller Smoke-Test aller Hauptseiten:
- Dashboard, Ausschreibungsliste, Ausschreibung-Detail
- Lose-Liste, Anforderungs-Liste, Aufgaben-Liste
- Bieterfragen-Liste, Dokumente-Liste, Preisliste
- Abgabe-Checkliste, Nachbetrachtung
- Subunternehmer-Katalog, Bibliothek (Nachweise, Referenzen)
- Marktbegleiter-Liste
- Feedback-Modal (auf jeder getesteten Seite)
- Admin-Felder-Konfiguration
- Globale Suche mit Suchbegriff
6. Aktualisiere `CLAUDE.md`:
- Bestätige alle Build-Commands als korrekt
- Ergänze "Testabdeckung" und "Produktionsdeployment"-Hinweis
- Notiere bekannte v1-Limitierungen (z. B. kein Celery für Fristenbenachrichtigungen)
Erst wenn alle 6 Punkte erfüllt: Workplan als done markieren.
```