antragstracker/backend/src/tracker/core/ampel.py
Dotty Dotter f8bc893a54 feat: Strang-basierte Klassifikation + Explorer + Ampel (#16)
Neue Features:
- 4 Verfahrensstränge: Antrag, Anfrage, Beschlussvorlage, Mitteilung
- Ampel-Visualisierung pro Kette (Fortschrittsanzeige mit Abzweigungen)
- 3-Panel Explorer (/explorer): Liste | Kette+Ampel | Vorlage-Detail
- KI-Bewertungs-Versionierung (alte Versionen aufklappbar)
- Neubewertung triggert automatisch Umsetzungs-Score
- Bewertungs-Log (bewertungs_log Tabelle)
- Umsetzungsgrad an Kette (Score + Begründung)
- Antragsteller + Beratungsergebnis pro Kettenglied
- HAK und Hagen Aktiv als getrennte Fraktionen
- Status-Filter im Explorer
- Suche durchsucht Aktenzeichen + Betreff

Backend:
- tracker/core/ampel.py — Ampel-Definition + get_ampel()
- tracker/core/perioden.py — Shared Perioden-Filter
- Neues Feld: ketten.strang, ki_bewertungen.kette_id
- GET /api/ampel/definition, erweiterte Ketten/Vorlagen-APIs

Closes #16
2026-04-02 00:36:30 +02:00

237 lines
8.1 KiB
Python

"""Ampel-Darstellungsschicht: Strang-basierte Klassifikation mit Ampel-Visualisierung.
Dies ist eine reine Darstellungsschicht über der bestehenden Status-Engine (core/status.py).
Die Status-Engine bleibt unverändert — die Ampel mappt deren Ergebnisse auf visuelle Schritte.
"""
from __future__ import annotations
# Definiert die Zustände pro Strang in Reihenfolge
STRANG_ZUSTAENDE: dict[str, list[dict]] = {
"antrag": [
{"id": "eingereicht", "label": "Eingereicht", "endfarbe": None},
{"id": "in_beratung", "label": "In Beratung", "endfarbe": None},
{"id": "beschlossen", "label": "Beschlossen", "endfarbe": "gelb"},
{"id": "umgesetzt", "label": "Umgesetzt", "endfarbe": "gruen"},
],
"beschlussvorlage": [
{"id": "vorgelegt", "label": "Vorgelegt", "endfarbe": None},
{"id": "in_beratung", "label": "In Beratung", "endfarbe": None},
{"id": "beschlossen", "label": "Beschlossen", "endfarbe": "gelb"},
{"id": "umgesetzt", "label": "Umgesetzt", "endfarbe": "gruen"},
],
"anfrage": [
{"id": "angefragt", "label": "Angefragt", "endfarbe": "gelb"},
{"id": "beantwortet", "label": "Beantwortet", "endfarbe": "gruen"},
],
"mitteilung": [
{"id": "vorgelegt", "label": "Vorgelegt", "endfarbe": None},
{"id": "zur_kenntnis", "label": "Zur Kenntnis genommen", "endfarbe": "grau"},
],
}
# Endstatus die von der Hauptreihe abzweigen
ABZWEIGUNGEN: dict[str, dict] = {
"abgelehnt": {"label": "Abgelehnt", "farbe": "rot"},
"abgewiegelt": {"label": "Abgewiegelt", "farbe": "rot"},
"versandet": {"label": "Versandet", "farbe": "rot"},
"teilweise_umgesetzt": {"label": "Teilweise umgesetzt", "farbe": "amber"},
"verwiesen": {"label": "Verwiesen", "farbe": "gelb"},
"zurueckgezogen": {"label": "Zurückgezogen", "farbe": "grau"},
}
# Labels für Stränge
STRANG_LABELS: dict[str, str] = {
"antrag": "Antrag",
"beschlussvorlage": "Beschlussvorlage",
"anfrage": "Anfrage",
"mitteilung": "Mitteilung",
"sonstig": "Sonstig",
}
# Kontrollfragen pro Strang
KONTROLLFRAGEN: dict[str, str | None] = {
"antrag": "Hat die Verwaltung umgesetzt?",
"beschlussvorlage": "Wurde so umgesetzt wie beschlossen?",
"anfrage": "Wurde befriedigend geantwortet?",
"mitteilung": None,
}
# Mapping: DB-Status → (Schritt-ID der letzten erreichten Position, ist Abzweigung?)
# Für jeden Strang kann das Mapping unterschiedlich sein.
# Wir definieren ein generisches Mapping und strang-spezifische Overrides.
_STATUS_TO_SCHRITT: dict[str, dict[str, tuple[str, bool]]] = {
"antrag": {
"eingereicht": ("eingereicht", False),
"in_beratung": ("in_beratung", False),
"vertagt": ("in_beratung", False), # Vertagt = noch in Beratung
"beschlossen": ("beschlossen", False),
"umgesetzt": ("umgesetzt", False),
"versandet": ("beschlossen", True),
"abgelehnt": ("in_beratung", True),
"teilweise_umgesetzt": ("beschlossen", True),
"verwiesen": ("in_beratung", True),
"zurückgezogen": ("in_beratung", True),
"zurueckgezogen": ("in_beratung", True),
"abgewiegelt": ("beschlossen", True),
"offen": ("in_beratung", False),
},
"beschlussvorlage": {
"eingereicht": ("vorgelegt", False),
"vorgelegt": ("vorgelegt", False),
"in_beratung": ("in_beratung", False),
"vertagt": ("in_beratung", False),
"beschlossen": ("beschlossen", False),
"umgesetzt": ("umgesetzt", False),
"versandet": ("beschlossen", True),
"abgelehnt": ("in_beratung", True),
"teilweise_umgesetzt": ("beschlossen", True),
"verwiesen": ("in_beratung", True),
"zurückgezogen": ("in_beratung", True),
"zurueckgezogen": ("in_beratung", True),
"abgewiegelt": ("beschlossen", True),
"offen": ("in_beratung", False),
},
"anfrage": {
"angefragt": ("angefragt", False),
"beantwortet": ("beantwortet", False),
"offen": ("angefragt", False),
"abgewiegelt": ("angefragt", True),
"versandet": ("angefragt", True),
"zurückgezogen": ("angefragt", True),
"zurueckgezogen": ("angefragt", True),
},
"mitteilung": {
"vorgelegt": ("vorgelegt", False),
"zur_kenntnis": ("zur_kenntnis", False),
"eingereicht": ("vorgelegt", False),
"beantwortet": ("zur_kenntnis", False), # Mapped to equivalent
"beschlossen": ("zur_kenntnis", False),
},
}
def _normalize_abzweigung_id(status: str) -> str:
"""Normalize status string to ABZWEIGUNGEN key."""
mapping = {
"zurückgezogen": "zurueckgezogen",
}
return mapping.get(status, status)
def get_ampel(strang: str, aktueller_status: str) -> dict | None:
"""Gibt die Ampel-Daten für Frontend zurück.
Returns None if strang is unknown or 'sonstig'.
"""
if not strang or strang not in STRANG_ZUSTAENDE:
return None
schritte_def = STRANG_ZUSTAENDE[strang]
status_map = _STATUS_TO_SCHRITT.get(strang, {})
# Determine position and whether it's a branch-off
mapping = status_map.get(aktueller_status or "")
if mapping is None:
# Unknown status — show first step as active
aktiver_schritt_id = schritte_def[0]["id"]
ist_abzweigung = False
else:
aktiver_schritt_id, ist_abzweigung = mapping
# Find index of active step
schritt_ids = [s["id"] for s in schritte_def]
try:
aktiver_idx = schritt_ids.index(aktiver_schritt_id)
except ValueError:
aktiver_idx = 0
# Build schritte list
schritte = []
for i, s in enumerate(schritte_def):
if ist_abzweigung:
# Bei Abzweigung: alle bis aktiver_idx sind "erreicht", keiner ist "aktiv"
erreicht = i <= aktiver_idx
aktiv = False
else:
erreicht = i <= aktiver_idx
aktiv = i == aktiver_idx
# Farbe bestimmen
if aktiv and s["endfarbe"]:
farbe = s["endfarbe"]
elif aktiv:
farbe = "blau" # Aktiver Schritt ohne spezielle Endfarbe
elif erreicht:
farbe = "grau" # Bereits durchlaufen
else:
farbe = "grau" # Noch nicht erreicht
schritte.append({
"id": s["id"],
"label": s["label"],
"aktiv": aktiv,
"erreicht": erreicht,
"farbe": farbe,
})
# Abzweigung
abzweigung = None
if ist_abzweigung:
norm_status = _normalize_abzweigung_id(aktueller_status or "")
if norm_status in ABZWEIGUNGEN:
abzw = ABZWEIGUNGEN[norm_status]
abzweigung = {
"id": norm_status,
"label": abzw["label"],
"farbe": abzw["farbe"],
}
return {
"strang": strang,
"strang_label": STRANG_LABELS.get(strang, strang.capitalize()),
"kontrollfrage": KONTROLLFRAGEN.get(strang),
"schritte": schritte,
"abzweigung": abzweigung,
}
def get_ampel_kompakt(strang: str, aktueller_status: str) -> dict | None:
"""Kompakte Ampel-Version für Listen: nur aktueller Schritt + Farbe."""
ampel = get_ampel(strang, aktueller_status)
if not ampel:
return None
if ampel["abzweigung"]:
return {
"schritt": ampel["abzweigung"]["label"],
"farbe": ampel["abzweigung"]["farbe"],
"ist_abzweigung": True,
}
aktiver = next((s for s in ampel["schritte"] if s["aktiv"]), None)
if aktiver:
return {
"schritt": aktiver["label"],
"farbe": aktiver["farbe"],
"ist_abzweigung": False,
}
return None
def get_ampel_definition() -> dict:
"""Gibt die komplette Strang-Definition zurück (für Legende im Frontend)."""
return {
"straenge": {
strang: {
"label": STRANG_LABELS.get(strang, strang.capitalize()),
"kontrollfrage": KONTROLLFRAGEN.get(strang),
"schritte": schritte,
}
for strang, schritte in STRANG_ZUSTAENDE.items()
},
"abzweigungen": ABZWEIGUNGEN,
}