"""Initiative (Vorhaben) service — tenant-scoped CRUD.""" 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 InitiativeStatus = Literal["active", "paused", "completed", "archived"] Priority = Literal["low", "normal", "high"] INITIATIVE_STATUSES = frozenset({"active", "paused", "completed", "archived"}) PRIORITIES = frozenset({"low", "normal", "high"}) def _serialize_row(row: dict[str, Any]) -> dict[str, Any]: result = dict(row) for key in ("id", "tenant_id", "owner_actor_id"): if result.get(key): result[key] = str(result[key]) if result.get("created_at"): result["created_at"] = result["created_at"].isoformat() if result.get("updated_at"): result["updated_at"] = result["updated_at"].isoformat() return result def _validate_initiative_status(status: str) -> None: if status not in INITIATIVE_STATUSES: raise ValueError(f"Ungültiger Initiative-Status: {status}") def _validate_priority(priority: str) -> None: if priority not in PRIORITIES: raise ValueError(f"Ungültige Priorität: {priority}") 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 create_initiative( *, tenant_id: str, title: str, owner_actor_id: str, goal: str = "", status: InitiativeStatus = "active", priority: Priority = "normal", user_id: Optional[str] = None, ) -> dict[str, Any]: title = title.strip() if not title: raise ValueError("Titel ist erforderlich") _validate_initiative_status(status) _validate_priority(priority) if not _actor_in_tenant(tenant_id=tenant_id, actor_id=owner_actor_id): raise ValueError("Owner-Actor gehört nicht zum Tenant") conn = get_connection() try: with conn.cursor(cursor_factory=RealDictCursor) as cur: cur.execute( """ INSERT INTO initiatives ( tenant_id, title, goal, status, priority, owner_actor_id ) VALUES (%s, %s, %s, %s, %s, %s) RETURNING id, tenant_id, title, goal, status, priority, owner_actor_id, created_at, updated_at """, (tenant_id, title, goal, status, priority, owner_actor_id), ) row = _serialize_row(dict(cur.fetchone())) conn.commit() finally: conn.close() log_audit( "initiative.created", user_id=user_id, tenant_id=tenant_id, details={"initiative_id": row["id"], "title": title, "status": status}, ) return row def list_initiatives(*, tenant_id: str) -> list[dict[str, Any]]: conn = get_connection() try: with conn.cursor(cursor_factory=RealDictCursor) as cur: cur.execute( """ SELECT id, tenant_id, title, goal, status, priority, owner_actor_id, created_at, updated_at FROM initiatives WHERE tenant_id = %s ORDER BY updated_at DESC, title """, (tenant_id,), ) return [_serialize_row(dict(row)) for row in cur.fetchall()] finally: conn.close() def get_initiative(*, tenant_id: str, initiative_id: str) -> Optional[dict[str, Any]]: conn = get_connection() try: with conn.cursor(cursor_factory=RealDictCursor) as cur: cur.execute( """ SELECT id, tenant_id, title, goal, status, priority, owner_actor_id, created_at, updated_at FROM initiatives WHERE id = %s AND tenant_id = %s """, (initiative_id, tenant_id), ) row = cur.fetchone() return _serialize_row(dict(row)) if row else None finally: conn.close() def update_initiative( *, tenant_id: str, initiative_id: str, user_id: Optional[str] = None, title: Optional[str] = None, goal: Optional[str] = None, status: Optional[InitiativeStatus] = None, priority: Optional[Priority] = None, owner_actor_id: Optional[str] = None, ) -> Optional[dict[str, Any]]: existing = get_initiative(tenant_id=tenant_id, initiative_id=initiative_id) if not existing: return None updates: list[str] = [] params: list[Any] = [] old_status = existing["status"] if title is not None: title = title.strip() if not title: raise ValueError("Titel ist erforderlich") updates.append("title = %s") params.append(title) if goal is not None: updates.append("goal = %s") params.append(goal) if status is not None: _validate_initiative_status(status) updates.append("status = %s") params.append(status) if priority is not None: _validate_priority(priority) updates.append("priority = %s") params.append(priority) if owner_actor_id is not None: if not _actor_in_tenant(tenant_id=tenant_id, actor_id=owner_actor_id): raise ValueError("Owner-Actor gehört nicht zum Tenant") updates.append("owner_actor_id = %s") params.append(owner_actor_id) if not updates: return existing updates.append("updated_at = NOW()") params.extend([initiative_id, tenant_id]) conn = get_connection() try: with conn.cursor(cursor_factory=RealDictCursor) as cur: cur.execute( f""" UPDATE initiatives SET {", ".join(updates)} WHERE id = %s AND tenant_id = %s RETURNING id, tenant_id, title, goal, status, priority, owner_actor_id, created_at, updated_at """, params, ) row = cur.fetchone() if not row: return None result = _serialize_row(dict(row)) conn.commit() finally: conn.close() log_audit( "initiative.updated", user_id=user_id, tenant_id=tenant_id, details={"initiative_id": initiative_id, "fields": updates}, ) if status is not None and status != old_status: log_audit( "initiative.status_changed", user_id=user_id, tenant_id=tenant_id, details={ "initiative_id": initiative_id, "from_status": old_status, "to_status": status, }, ) return result def delete_initiative( *, tenant_id: str, initiative_id: str, user_id: Optional[str] = None, ) -> bool: conn = get_connection() try: with conn.cursor() as cur: cur.execute( "DELETE FROM initiatives WHERE id = %s AND tenant_id = %s RETURNING id", (initiative_id, tenant_id), ) deleted = cur.fetchone() is not None conn.commit() finally: conn.close() if deleted: log_audit( "initiative.deleted", user_id=user_id, tenant_id=tenant_id, details={"initiative_id": initiative_id}, ) return deleted