generated from coulomb/repo-seed
Established workplans
This commit is contained in:
34
workplans/README.md
Normal file
34
workplans/README.md
Normal 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
|
||||
405
workplans/WP-0001-projektgeruest.md
Normal file
405
workplans/WP-0001-projektgeruest.md
Normal 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.
|
||||
```
|
||||
503
workplans/WP-0002-fachmodelle.md
Normal file
503
workplans/WP-0002-fachmodelle.md
Normal 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.
|
||||
```
|
||||
502
workplans/WP-0003-basis-ui.md
Normal file
502
workplans/WP-0003-basis-ui.md
Normal 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.
|
||||
```
|
||||
492
workplans/WP-0004-dashboard-ausschreibungen.md
Normal file
492
workplans/WP-0004-dashboard-ausschreibungen.md
Normal 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.
|
||||
```
|
||||
211
workplans/WP-0005-lose-anforderungen.md
Normal file
211
workplans/WP-0005-lose-anforderungen.md
Normal 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.
|
||||
```
|
||||
150
workplans/WP-0006-aufgaben-bieterfragen.md
Normal file
150
workplans/WP-0006-aufgaben-bieterfragen.md
Normal 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'
|
||||
```
|
||||
157
workplans/WP-0007-dokumente.md
Normal file
157
workplans/WP-0007-dokumente.md
Normal 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
160
workplans/WP-0008-preise.md
Normal 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
|
||||
```
|
||||
201
workplans/WP-0009-abgabe-nachbetrachtung.md
Normal file
201
workplans/WP-0009-abgabe-nachbetrachtung.md
Normal 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
|
||||
```
|
||||
219
workplans/WP-0010-partner-bibliothek.md
Normal file
219
workplans/WP-0010-partner-bibliothek.md
Normal 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
|
||||
```
|
||||
131
workplans/WP-0011-marktbegleiter.md
Normal file
131
workplans/WP-0011-marktbegleiter.md
Normal 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
|
||||
```
|
||||
346
workplans/WP-0012-querschnitt.md
Normal file
346
workplans/WP-0012-querschnitt.md
Normal 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.
|
||||
```
|
||||
Reference in New Issue
Block a user