"""Action (Maßnahme) service — tenant-scoped CRUD, assignments, open actions.""" 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 PRIORITIES, get_initiative from services.plan_ist import validate_roadmap_item_in_initiative from services.projects import project_is_leaf from services.work_cycle import validate_work_cycle_in_initiative ActionStatus = Literal[ "open", "ready", "in_progress", "blocked", "review_required", "done", "discarded" ] ACTION_STATUSES = frozenset( {"open", "ready", "in_progress", "blocked", "review_required", "done", "discarded"} ) OPEN_ACTION_STATUSES = frozenset( {"open", "ready", "in_progress", "blocked", "review_required"} ) _ACTION_COLUMNS = """ id, tenant_id, initiative_id, project_id, roadmap_item_id, work_cycle_id, title, description, status, priority, due_at, sort_order, action_kind, parent_action_id, created_at, updated_at """ ACTION_KINDS = frozenset({"delivery", "planning", "review", "bug", "issue", "tech_debt"}) UNPLAN_ALLOWED_STATUSES = frozenset({"open", "ready"}) CARRYOVER_UNPLAN_STATUSES = frozenset( {"open", "ready", "in_progress", "blocked", "review_required"} ) def _serialize_row(row: dict[str, Any]) -> dict[str, Any]: result = dict(row) for key in ( "id", "tenant_id", "initiative_id", "project_id", "roadmap_item_id", "work_cycle_id", "parent_action_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() if result.get("due_at"): result["due_at"] = result["due_at"].isoformat() return result def _validate_parent_action_in_initiative( *, cur, tenant_id: str, initiative_id: str, parent_action_id: Optional[str], action_id: Optional[str] = None, ) -> None: if not parent_action_id: return if action_id and parent_action_id == action_id: raise ValueError("Arbeitspaket kann nicht auf sich selbst verweisen") cur.execute( """ SELECT action_kind FROM actions WHERE id = %s AND tenant_id = %s AND initiative_id = %s """, (parent_action_id, tenant_id, initiative_id), ) row = cur.fetchone() if not row: raise ValueError("Referenz-Arbeitspaket (Feature) nicht gefunden") action_kind = row["action_kind"] if isinstance(row, dict) else row[0] if action_kind in ("bug", "issue", "tech_debt"): raise ValueError("Referenz muss ein Feature-Arbeitspaket sein, kein Bug/Issue/Schuld") def _action_kind_to_item_kind(action_kind: str) -> str: if action_kind == "bug": return "bug" if action_kind == "issue": return "issue" if action_kind == "tech_debt": return "tech_debt" return "story" def _validate_action_kind(action_kind: str) -> None: if action_kind not in ACTION_KINDS: raise ValueError(f"Ungültiger action_kind: {action_kind}") def _validate_action_status(status: str) -> None: if status not in ACTION_STATUSES: raise ValueError(f"Ungültiger Action-Status: {status}") def _validate_priority(priority: str) -> None: if priority not in PRIORITIES: raise ValueError(f"Ungültige Priorität: {priority}") def _load_assignments(*, tenant_id: str, action_ids: list[str]) -> dict[str, list[str]]: if not action_ids: return {} conn = get_connection() try: with conn.cursor(cursor_factory=RealDictCursor) as cur: cur.execute( """ SELECT action_id, actor_id FROM action_assignments WHERE tenant_id = %s AND action_id = ANY(%s::uuid[]) ORDER BY created_at """, (tenant_id, action_ids), ) result: dict[str, list[str]] = {aid: [] for aid in action_ids} for row in cur.fetchall(): result[str(row["action_id"])].append(str(row["actor_id"])) return result finally: conn.close() def _attach_assignments( actions: list[dict[str, Any]], *, tenant_id: str ) -> list[dict[str, Any]]: if not actions: return actions action_ids = [a["id"] for a in actions] assignments = _load_assignments(tenant_id=tenant_id, action_ids=action_ids) for action in actions: action["assigned_actor_ids"] = assignments.get(action["id"], []) return actions 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 _validate_project_in_initiative( *, tenant_id: str, initiative_id: str, project_id: Optional[str] ) -> None: if not project_id: return conn = get_connection() try: with conn.cursor() as cur: cur.execute( """ SELECT 1 FROM projects WHERE id = %s AND tenant_id = %s AND initiative_id = %s """, (project_id, tenant_id, initiative_id), ) if not cur.fetchone(): raise ValueError("Projekt gehört nicht zum Vorhaben") if not project_is_leaf(tenant_id=tenant_id, project_id=project_id): raise ValueError( "Arbeitspakete nur an Blatt-Projekten (ohne Unterprojekte)" ) finally: conn.close() def create_action( *, tenant_id: str, initiative_id: str, title: str, description: str = "", status: ActionStatus = "open", priority: str = "normal", due_at: Optional[Any] = None, project_id: Optional[str] = None, roadmap_item_id: Optional[str] = None, work_cycle_id: Optional[str] = None, assigned_actor_ids: Optional[list[str]] = None, sort_order: int = 0, action_kind: str = "delivery", parent_action_id: Optional[str] = None, user_id: Optional[str] = None, ) -> dict[str, Any]: title = title.strip() if not title: raise ValueError("Titel ist erforderlich") _validate_action_status(status) _validate_priority(priority) _validate_action_kind(action_kind) if not get_initiative(tenant_id=tenant_id, initiative_id=initiative_id): raise ValueError("Initiative nicht gefunden") _validate_project_in_initiative( tenant_id=tenant_id, initiative_id=initiative_id, project_id=project_id ) assigned_actor_ids = assigned_actor_ids or [] for actor_id in assigned_actor_ids: if not _actor_in_tenant(tenant_id=tenant_id, actor_id=actor_id): raise ValueError(f"Actor {actor_id} gehört nicht zum Tenant") conn = get_connection() try: with conn.cursor(cursor_factory=RealDictCursor) as cur: validate_roadmap_item_in_initiative( cur, tenant_id=tenant_id, initiative_id=initiative_id, roadmap_item_id=roadmap_item_id, ) validate_work_cycle_in_initiative( tenant_id=tenant_id, initiative_id=initiative_id, work_cycle_id=work_cycle_id, cur=cur, ) _validate_parent_action_in_initiative( cur=cur, tenant_id=tenant_id, initiative_id=initiative_id, parent_action_id=parent_action_id, ) cur.execute( f""" INSERT INTO actions ( tenant_id, initiative_id, project_id, roadmap_item_id, work_cycle_id, title, description, status, priority, due_at, sort_order, action_kind, parent_action_id ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) RETURNING {_ACTION_COLUMNS} """, ( tenant_id, initiative_id, project_id, roadmap_item_id, work_cycle_id, title, description, status, priority, due_at, sort_order, action_kind, parent_action_id, ), ) row = _serialize_row(dict(cur.fetchone())) for actor_id in assigned_actor_ids: cur.execute( """ INSERT INTO action_assignments (tenant_id, action_id, actor_id) VALUES (%s, %s, %s) """, (tenant_id, row["id"], actor_id), ) conn.commit() finally: conn.close() row["assigned_actor_ids"] = assigned_actor_ids log_audit( "action.created", user_id=user_id, tenant_id=tenant_id, details={ "action_id": row["id"], "initiative_id": initiative_id, "title": title, "status": status, "assigned_actor_ids": assigned_actor_ids, }, ) return row def list_actions_for_initiative( *, tenant_id: str, initiative_id: str ) -> list[dict[str, Any]]: if not get_initiative(tenant_id=tenant_id, initiative_id=initiative_id): return [] conn = get_connection() try: with conn.cursor(cursor_factory=RealDictCursor) as cur: cur.execute( f""" SELECT {_ACTION_COLUMNS} FROM actions WHERE tenant_id = %s AND initiative_id = %s ORDER BY sort_order ASC, title ASC, created_at DESC """, (tenant_id, initiative_id), ) actions = [_serialize_row(dict(row)) for row in cur.fetchall()] finally: conn.close() return _attach_assignments(actions, tenant_id=tenant_id) def get_action(*, tenant_id: str, action_id: str) -> Optional[dict[str, Any]]: conn = get_connection() try: with conn.cursor(cursor_factory=RealDictCursor) as cur: cur.execute( f""" SELECT {_ACTION_COLUMNS} FROM actions WHERE id = %s AND tenant_id = %s """, (action_id, tenant_id), ) row = cur.fetchone() if not row: return None action = _serialize_row(dict(row)) finally: conn.close() return _attach_assignments([action], tenant_id=tenant_id)[0] def update_action( *, tenant_id: str, action_id: str, user_id: Optional[str] = None, title: Optional[str] = None, description: Optional[str] = None, status: Optional[ActionStatus] = None, priority: Optional[str] = None, due_at: Optional[Any] = None, clear_due_at: bool = False, project_id: Optional[str] = None, clear_project: bool = False, roadmap_item_id: Optional[str] = None, clear_roadmap_item: bool = False, work_cycle_id: Optional[str] = None, clear_work_cycle: bool = False, sort_order: Optional[int] = None, action_kind: Optional[str] = None, parent_action_id: Optional[str] = None, clear_parent_action: bool = False, ) -> Optional[dict[str, Any]]: existing = get_action(tenant_id=tenant_id, action_id=action_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 description is not None: updates.append("description = %s") params.append(description) if status is not None: _validate_action_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 clear_due_at: updates.append("due_at = NULL") elif due_at is not None: updates.append("due_at = %s") params.append(due_at) if clear_project: updates.append("project_id = NULL") elif project_id is not None: _validate_project_in_initiative( tenant_id=tenant_id, initiative_id=existing["initiative_id"], project_id=project_id, ) updates.append("project_id = %s") params.append(project_id) if clear_roadmap_item: updates.append("roadmap_item_id = NULL") elif roadmap_item_id is not None: conn = get_connection() try: with conn.cursor() as cur: validate_roadmap_item_in_initiative( cur, tenant_id=tenant_id, initiative_id=existing["initiative_id"], roadmap_item_id=roadmap_item_id, ) finally: conn.close() updates.append("roadmap_item_id = %s") params.append(roadmap_item_id) if clear_work_cycle: updates.append("work_cycle_id = NULL") elif work_cycle_id is not None: validate_work_cycle_in_initiative( tenant_id=tenant_id, initiative_id=existing["initiative_id"], work_cycle_id=work_cycle_id, ) updates.append("work_cycle_id = %s") params.append(work_cycle_id) if sort_order is not None: updates.append("sort_order = %s") params.append(sort_order) if action_kind is not None: _validate_action_kind(action_kind) updates.append("action_kind = %s") params.append(action_kind) if clear_parent_action: updates.append("parent_action_id = NULL") elif parent_action_id is not None: updates.append("parent_action_id = %s") params.append(parent_action_id) if not updates: return existing updates.append("updated_at = NOW()") params.extend([action_id, tenant_id]) conn = get_connection() try: with conn.cursor(cursor_factory=RealDictCursor) as cur: if parent_action_id is not None and not clear_parent_action: _validate_parent_action_in_initiative( cur=cur, tenant_id=tenant_id, initiative_id=existing["initiative_id"], parent_action_id=parent_action_id, action_id=action_id, ) cur.execute( f""" UPDATE actions SET {", ".join(updates)} WHERE id = %s AND tenant_id = %s RETURNING {_ACTION_COLUMNS} """, params, ) row = cur.fetchone() if not row: return None conn.commit() finally: conn.close() result = get_action(tenant_id=tenant_id, action_id=action_id) assert result is not None log_audit( "action.updated", user_id=user_id, tenant_id=tenant_id, details={"action_id": action_id, "fields": updates}, ) if status is not None and status != old_status: log_audit( "action.status_changed", user_id=user_id, tenant_id=tenant_id, details={ "action_id": action_id, "from_status": old_status, "to_status": status, }, ) if status == "done": from services.steering_events import emit_steering_event emit_steering_event( tenant_id=tenant_id, initiative_id=str(existing["initiative_id"]), event_kind="actor_result", payload={ "action_id": action_id, "from_status": old_status, "to_status": status, }, user_id=user_id, ) return result def set_action_assignments( *, tenant_id: str, action_id: str, actor_ids: list[str], user_id: Optional[str] = None, ) -> Optional[dict[str, Any]]: existing = get_action(tenant_id=tenant_id, action_id=action_id) if not existing: return None unique_actor_ids = list(dict.fromkeys(actor_ids)) for actor_id in unique_actor_ids: if not _actor_in_tenant(tenant_id=tenant_id, actor_id=actor_id): raise ValueError(f"Actor {actor_id} gehört nicht zum Tenant") conn = get_connection() try: with conn.cursor() as cur: cur.execute( "DELETE FROM action_assignments WHERE action_id = %s AND tenant_id = %s", (action_id, tenant_id), ) for actor_id in unique_actor_ids: cur.execute( """ INSERT INTO action_assignments (tenant_id, action_id, actor_id) VALUES (%s, %s, %s) """, (tenant_id, action_id, actor_id), ) cur.execute( "UPDATE actions SET updated_at = NOW() WHERE id = %s AND tenant_id = %s", (action_id, tenant_id), ) conn.commit() finally: conn.close() log_audit( "action.assigned", user_id=user_id, tenant_id=tenant_id, details={ "action_id": action_id, "actor_ids": unique_actor_ids, "previous_actor_ids": existing.get("assigned_actor_ids", []), }, ) return get_action(tenant_id=tenant_id, action_id=action_id) def delete_action( *, tenant_id: str, action_id: str, user_id: Optional[str] = None, ) -> bool: conn = get_connection() try: with conn.cursor() as cur: cur.execute( "DELETE FROM actions WHERE id = %s AND tenant_id = %s RETURNING id", (action_id, tenant_id), ) deleted = cur.fetchone() is not None conn.commit() finally: conn.close() if deleted: log_audit( "action.deleted", user_id=user_id, tenant_id=tenant_id, details={"action_id": action_id}, ) return deleted def list_open_actions_for_actor( *, tenant_id: str, actor_id: str ) -> list[dict[str, Any]]: conn = get_connection() try: with conn.cursor(cursor_factory=RealDictCursor) as cur: cur.execute( """ SELECT a.id, a.tenant_id, a.initiative_id, a.title, a.description, a.status, a.priority, a.due_at, a.created_at, a.updated_at, i.title AS initiative_title FROM actions a JOIN action_assignments aa ON aa.action_id = a.id AND aa.tenant_id = a.tenant_id JOIN initiatives i ON i.id = a.initiative_id AND i.tenant_id = a.tenant_id WHERE a.tenant_id = %s AND aa.actor_id = %s AND a.status = ANY(%s) ORDER BY CASE a.priority WHEN 'high' THEN 0 WHEN 'normal' THEN 1 WHEN 'low' THEN 2 END, a.updated_at DESC """, (tenant_id, actor_id, list(OPEN_ACTION_STATUSES)), ) actions = [_serialize_row(dict(row)) for row in cur.fetchall()] finally: conn.close() return _attach_assignments(actions, tenant_id=tenant_id) def unplan_action_to_backlog( *, tenant_id: str, action_id: str, user_id: Optional[str] = None, carryover: bool = False, ) -> dict[str, Any]: """Entfernt ein Arbeitspaket aus dem Sprint und stellt es im Eingang wieder her.""" existing = get_action(tenant_id=tenant_id, action_id=action_id) if not existing: raise ValueError("Arbeitspaket nicht gefunden") allowed = CARRYOVER_UNPLAN_STATUSES if carryover else UNPLAN_ALLOWED_STATUSES if existing["status"] not in allowed: raise ValueError( "Arbeitspaket kann in diesem Status nicht zurück in den Eingang geschoben werden" if carryover else "Nur offene oder bereite Arbeitspakete können zurück in den Eingang" ) initiative_id = existing["initiative_id"] item_kind = _action_kind_to_item_kind(existing.get("action_kind") or "delivery") conn = get_connection() try: with conn.cursor(cursor_factory=RealDictCursor) as cur: cur.execute( """ SELECT id FROM backlog_items WHERE tenant_id = %s AND converted_action_id = %s """, (tenant_id, action_id), ) linked = cur.fetchone() if linked: backlog_id = str(linked["id"]) cur.execute( """ UPDATE backlog_items SET status = 'accepted', converted_action_id = NULL, title = %s, description = %s, priority = %s, roadmap_item_id = %s, item_kind = %s, parent_action_id = %s, updated_at = NOW() WHERE id = %s AND tenant_id = %s RETURNING id, tenant_id, initiative_id, title, description, status, priority, roadmap_item_id, converted_action_id, sort_order, item_kind, parent_action_id, created_at, updated_at """, ( existing["title"], existing.get("description") or "", existing["priority"], existing.get("roadmap_item_id"), item_kind, existing.get("parent_action_id"), backlog_id, tenant_id, ), ) backlog_row = _serialize_row(dict(cur.fetchone())) else: cur.execute( """ SELECT COALESCE(MAX(sort_order), -10) + 10 AS next_order FROM backlog_items WHERE tenant_id = %s AND initiative_id = %s """, (tenant_id, initiative_id), ) next_order = int(cur.fetchone()["next_order"]) cur.execute( """ INSERT INTO backlog_items ( tenant_id, initiative_id, title, description, status, priority, roadmap_item_id, sort_order, item_kind, parent_action_id ) VALUES (%s, %s, %s, %s, 'accepted', %s, %s, %s, %s, %s) RETURNING id, tenant_id, initiative_id, title, description, status, priority, roadmap_item_id, converted_action_id, sort_order, item_kind, parent_action_id, created_at, updated_at """, ( tenant_id, initiative_id, existing["title"], existing.get("description") or "", existing["priority"], existing.get("roadmap_item_id"), next_order, item_kind, existing.get("parent_action_id"), ), ) backlog_row = _serialize_row(dict(cur.fetchone())) cur.execute( """ UPDATE actions SET status = 'discarded', work_cycle_id = NULL, updated_at = NOW() WHERE id = %s AND tenant_id = %s RETURNING id, tenant_id, initiative_id, project_id, roadmap_item_id, work_cycle_id, title, description, status, priority, due_at, sort_order, action_kind, parent_action_id, created_at, updated_at """, (action_id, tenant_id), ) action_row = _serialize_row(dict(cur.fetchone())) conn.commit() finally: conn.close() action_result = get_action(tenant_id=tenant_id, action_id=action_id) log_audit( "action.unplanned_to_backlog", user_id=user_id, tenant_id=tenant_id, details={ "action_id": action_id, "backlog_item_id": backlog_row["id"], "initiative_id": initiative_id, }, ) return {"backlog_item": backlog_row, "action": action_result or action_row}