"""Evidence service — tenant-scoped CRUD (AP0.9b).""" from __future__ import annotations from typing import Any, Literal, Optional from psycopg2.extras import RealDictCursor from db import get_connection from services.audit import log_audit from services.initiatives import get_initiative EvidenceStatus = Literal["submitted", "accepted", "rejected"] EVIDENCE_STATUSES = frozenset({"submitted", "accepted", "rejected"}) _EVIDENCE_COLUMNS = """ id, tenant_id, initiative_id, action_id, milestone_id, roadmap_item_id, title, description, status, submitted_by_actor_id, created_at, updated_at """ def _serialize_row(row: dict[str, Any]) -> dict[str, Any]: result = dict(row) for key in ( "id", "tenant_id", "initiative_id", "action_id", "milestone_id", "roadmap_item_id", "submitted_by_actor_id", ): if result.get(key): result[key] = str(result[key]) for ts_key in ("created_at", "updated_at"): if result.get(ts_key): result[ts_key] = result[ts_key].isoformat() return result def _validate_status(status: str) -> None: if status not in EVIDENCE_STATUSES: raise ValueError(f"Ungültiger Evidence-Status: {status}") def _actor_in_tenant(*, tenant_id: str, actor_id: str) -> bool: conn = get_connection() try: with conn.cursor() as cur: cur.execute( "SELECT 1 FROM actors WHERE id = %s AND tenant_id = %s AND is_active = TRUE", (actor_id, tenant_id), ) return cur.fetchone() is not None finally: conn.close() def _action_in_initiative(*, tenant_id: str, initiative_id: str, action_id: str) -> bool: conn = get_connection() try: with conn.cursor() as cur: cur.execute( """ SELECT 1 FROM actions WHERE id = %s AND tenant_id = %s AND initiative_id = %s """, (action_id, tenant_id, initiative_id), ) return cur.fetchone() is not None finally: conn.close() def _roadmap_item_in_initiative( *, tenant_id: str, initiative_id: str, roadmap_item_id: str ) -> Optional[dict[str, str]]: conn = get_connection() try: with conn.cursor(cursor_factory=RealDictCursor) as cur: cur.execute( """ SELECT ri.id, ri.item_type FROM roadmap_items ri JOIN roadmaps r ON r.id = ri.roadmap_id AND r.tenant_id = ri.tenant_id WHERE ri.id = %s AND ri.tenant_id = %s AND r.initiative_id = %s """, (roadmap_item_id, tenant_id, initiative_id), ) row = cur.fetchone() if not row: return None return {"id": str(row["id"]), "item_type": row["item_type"]} finally: conn.close() def _milestone_in_initiative( *, tenant_id: str, initiative_id: str, milestone_id: str ) -> bool: conn = get_connection() try: with conn.cursor() as cur: cur.execute( """ SELECT 1 FROM milestones WHERE id = %s AND tenant_id = %s AND initiative_id = %s """, (milestone_id, tenant_id, initiative_id), ) if cur.fetchone(): return True cur.execute( """ SELECT 1 FROM roadmap_items ri JOIN roadmaps r ON r.id = ri.roadmap_id AND r.tenant_id = ri.tenant_id WHERE ri.id = %s AND ri.tenant_id = %s AND r.initiative_id = %s """, (milestone_id, tenant_id, initiative_id), ) return cur.fetchone() is not None finally: conn.close() def _resolve_gate_links( *, tenant_id: str, initiative_id: str, milestone_id: Optional[str] = None, roadmap_item_id: Optional[str] = None, ) -> tuple[Optional[str], Optional[str]]: """Gibt (milestone_id, roadmap_item_id) für Evidence-Insert zurück.""" resolved_milestone = milestone_id resolved_roadmap = roadmap_item_id if roadmap_item_id: item = _roadmap_item_in_initiative( tenant_id=tenant_id, initiative_id=initiative_id, roadmap_item_id=roadmap_item_id, ) if not item: raise ValueError("Plan-Element gehört nicht zum Vorhaben") resolved_roadmap = item["id"] if item["item_type"] == "milestone": resolved_milestone = item["id"] elif milestone_id: if not _milestone_in_initiative( tenant_id=tenant_id, initiative_id=initiative_id, milestone_id=milestone_id, ): raise ValueError("Meilenstein gehört nicht zum Vorhaben") resolved_roadmap = milestone_id return resolved_milestone, resolved_roadmap def create_evidence( *, tenant_id: str, initiative_id: str, title: str, description: str = "", status: EvidenceStatus = "submitted", action_id: Optional[str] = None, milestone_id: Optional[str] = None, roadmap_item_id: Optional[str] = None, submitted_by_actor_id: Optional[str] = None, user_id: Optional[str] = None, ) -> dict[str, Any]: title = title.strip() if not title: raise ValueError("Titel ist erforderlich") _validate_status(status) if not get_initiative(tenant_id=tenant_id, initiative_id=initiative_id): raise ValueError("Initiative nicht gefunden") if action_id and not _action_in_initiative( tenant_id=tenant_id, initiative_id=initiative_id, action_id=action_id ): raise ValueError("Maßnahme gehört nicht zum Vorhaben") milestone_id, roadmap_item_id = _resolve_gate_links( tenant_id=tenant_id, initiative_id=initiative_id, milestone_id=milestone_id, roadmap_item_id=roadmap_item_id, ) if submitted_by_actor_id and not _actor_in_tenant( tenant_id=tenant_id, actor_id=submitted_by_actor_id ): raise ValueError("Actor gehört nicht zum Tenant") conn = get_connection() try: with conn.cursor(cursor_factory=RealDictCursor) as cur: cur.execute( f""" INSERT INTO evidence ( tenant_id, initiative_id, action_id, milestone_id, roadmap_item_id, title, description, status, submitted_by_actor_id ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s) RETURNING {_EVIDENCE_COLUMNS} """, ( tenant_id, initiative_id, action_id, milestone_id, roadmap_item_id, title, description, status, submitted_by_actor_id, ), ) row = _serialize_row(dict(cur.fetchone())) conn.commit() finally: conn.close() log_audit( "evidence.created", user_id=user_id, tenant_id=tenant_id, details={"evidence_id": row["id"], "initiative_id": initiative_id}, ) return row def list_evidence_for_initiative( *, tenant_id: str, initiative_id: str ) -> list[dict[str, Any]]: if not get_initiative(tenant_id=tenant_id, initiative_id=initiative_id): raise ValueError("Initiative nicht gefunden") conn = get_connection() try: with conn.cursor(cursor_factory=RealDictCursor) as cur: cur.execute( f""" SELECT {_EVIDENCE_COLUMNS} FROM evidence WHERE tenant_id = %s AND initiative_id = %s ORDER BY updated_at DESC, title """, (tenant_id, initiative_id), ) return [_serialize_row(dict(r)) for r in cur.fetchall()] finally: conn.close() def get_evidence(*, tenant_id: str, evidence_id: str) -> Optional[dict[str, Any]]: conn = get_connection() try: with conn.cursor(cursor_factory=RealDictCursor) as cur: cur.execute( f""" SELECT {_EVIDENCE_COLUMNS} FROM evidence WHERE id = %s AND tenant_id = %s """, (evidence_id, tenant_id), ) row = cur.fetchone() return _serialize_row(dict(row)) if row else None finally: conn.close() def update_evidence( *, tenant_id: str, evidence_id: str, user_id: Optional[str] = None, title: Optional[str] = None, description: Optional[str] = None, status: Optional[EvidenceStatus] = None, action_id: Optional[str] = None, milestone_id: Optional[str] = None, roadmap_item_id: Optional[str] = None, clear_action_id: bool = False, clear_milestone_id: bool = False, clear_roadmap_item_id: bool = False, ) -> Optional[dict[str, Any]]: existing = get_evidence(tenant_id=tenant_id, evidence_id=evidence_id) if not existing: return None updates: list[str] = [] params: list[Any] = [] if title is not None: title = title.strip() if not title: raise ValueError("Titel ist erforderlich") updates.append("title = %s") params.append(title) if description is not None: updates.append("description = %s") params.append(description) if status is not None: _validate_status(status) updates.append("status = %s") params.append(status) if clear_action_id: updates.append("action_id = NULL") elif action_id is not None: if not _action_in_initiative( tenant_id=tenant_id, initiative_id=existing["initiative_id"], action_id=action_id, ): raise ValueError("Maßnahme gehört nicht zum Vorhaben") updates.append("action_id = %s") params.append(action_id) if clear_milestone_id or clear_roadmap_item_id: updates.append("milestone_id = NULL") updates.append("roadmap_item_id = NULL") elif roadmap_item_id is not None or milestone_id is not None: link_milestone, link_roadmap = _resolve_gate_links( tenant_id=tenant_id, initiative_id=existing["initiative_id"], milestone_id=milestone_id, roadmap_item_id=roadmap_item_id, ) updates.append("milestone_id = %s") params.append(link_milestone) updates.append("roadmap_item_id = %s") params.append(link_roadmap) if not updates: return existing updates.append("updated_at = NOW()") params.extend([evidence_id, tenant_id]) conn = get_connection() try: with conn.cursor(cursor_factory=RealDictCursor) as cur: cur.execute( f""" UPDATE evidence SET {", ".join(updates)} WHERE id = %s AND tenant_id = %s RETURNING {_EVIDENCE_COLUMNS} """, params, ) row = cur.fetchone() if not row: return None result = _serialize_row(dict(row)) conn.commit() finally: conn.close() log_audit( "evidence.updated", user_id=user_id, tenant_id=tenant_id, details={"evidence_id": evidence_id}, ) return result def delete_evidence( *, tenant_id: str, evidence_id: str, user_id: Optional[str] = None, ) -> bool: conn = get_connection() try: with conn.cursor() as cur: cur.execute( "DELETE FROM evidence WHERE id = %s AND tenant_id = %s RETURNING id", (evidence_id, tenant_id), ) deleted = cur.fetchone() is not None conn.commit() finally: conn.close() if deleted: log_audit( "evidence.deleted", user_id=user_id, tenant_id=tenant_id, details={"evidence_id": evidence_id}, ) return deleted