"""Project service — operative Struktur unter Initiative (AP1.5, AP1.5c).""" from __future__ import annotations from datetime import date 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 ProjectStatus = Literal["active", "paused", "completed", "archived"] PROJECT_STATUSES = frozenset({"active", "paused", "completed", "archived"}) ContainerKind = Literal["project", "stream", "phase", "release"] CONTAINER_KINDS = frozenset({"project", "stream", "phase", "release"}) MAX_PROJECT_DEPTH = 5 _PROJECT_COLUMNS = """ id, tenant_id, initiative_id, parent_project_id, container_kind, title, description, status, roadmap_item_id, sort_order, target_date, 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", "parent_project_id", "roadmap_item_id"): if result.get(key): result[key] = str(result[key]) if result.get("target_date"): result["target_date"] = ( result["target_date"].isoformat() if hasattr(result["target_date"], "isoformat") else str(result["target_date"]) ) for ts in ("created_at", "updated_at"): if result.get(ts): result[ts] = result[ts].isoformat() return result def _validate_status(status: str) -> None: if status not in PROJECT_STATUSES: raise ValueError(f"Ungültiger Project-Status: {status}") def _validate_container_kind(container_kind: Optional[str]) -> None: if container_kind is not None and container_kind not in CONTAINER_KINDS: raise ValueError(f"Ungültiger container_kind: {container_kind}") def _validate_roadmap_item( cur, *, tenant_id: str, initiative_id: str, roadmap_item_id: Optional[str] ) -> None: if not roadmap_item_id: return 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 """, (roadmap_item_id, tenant_id, initiative_id), ) if not cur.fetchone(): raise ValueError("Gate/Plan-Element gehört nicht zu diesem Vorhaben") def _fetch_project_row( cur, *, tenant_id: str, project_id: str, initiative_id: Optional[str] = None ) -> Optional[dict[str, Any]]: query = f""" SELECT {_PROJECT_COLUMNS} FROM projects WHERE id = %s AND tenant_id = %s """ params: list[Any] = [project_id, tenant_id] if initiative_id is not None: query += " AND initiative_id = %s" params.append(initiative_id) cur.execute(query, params) row = cur.fetchone() return dict(row) if row else None def _project_depth_from_map( project_id: str, by_id: dict[str, dict[str, Any]], memo: dict[str, int] ) -> int: if project_id in memo: return memo[project_id] project = by_id.get(project_id) if not project or not project.get("parent_project_id"): memo[project_id] = 1 return 1 parent_id = str(project["parent_project_id"]) if parent_id == project_id: raise ValueError("Zyklus in Project-Hierarchie") depth = _project_depth_from_map(parent_id, by_id, memo) + 1 memo[project_id] = depth return depth def _collect_descendant_ids( project_id: str, by_parent: dict[str, list[str]] ) -> set[str]: result: set[str] = set() stack = list(by_parent.get(project_id, [])) while stack: child_id = stack.pop() if child_id in result: continue result.add(child_id) stack.extend(by_parent.get(child_id, [])) return result def _subtree_height(project_id: str, by_parent: dict[str, list[str]]) -> int: children = by_parent.get(project_id, []) if not children: return 1 return 1 + max(_subtree_height(child_id, by_parent) for child_id in children) def _load_initiative_project_maps( cur, *, tenant_id: str, initiative_id: str ) -> tuple[dict[str, dict[str, Any]], dict[str, list[str]]]: cur.execute( f""" SELECT {_PROJECT_COLUMNS} FROM projects WHERE tenant_id = %s AND initiative_id = %s """, (tenant_id, initiative_id), ) rows = [dict(r) for r in cur.fetchall()] by_id = {str(r["id"]): r for r in rows} by_parent: dict[str, list[str]] = {} for row in rows: parent_id = row.get("parent_project_id") if parent_id: by_parent.setdefault(str(parent_id), []).append(str(row["id"])) return by_id, by_parent def _validate_parent_project( cur, *, tenant_id: str, initiative_id: str, project_id: Optional[str], parent_project_id: Optional[str], ) -> None: if not parent_project_id: return if project_id and parent_project_id == project_id: raise ValueError("Projekt kann nicht sein eigener Parent sein") parent = _fetch_project_row( cur, tenant_id=tenant_id, project_id=parent_project_id, initiative_id=initiative_id ) if not parent: raise ValueError("Parent-Projekt gehört nicht zu diesem Vorhaben") by_id, by_parent = _load_initiative_project_maps( cur, tenant_id=tenant_id, initiative_id=initiative_id ) if project_id: descendants = _collect_descendant_ids(project_id, by_parent) if parent_project_id in descendants: raise ValueError("Zyklus: Parent darf kein Nachfahr sein") memo: dict[str, int] = {} parent_depth = _project_depth_from_map(parent_project_id, by_id, memo) subtree_height = ( _subtree_height(project_id, by_parent) if project_id else 1 ) if parent_depth + subtree_height > MAX_PROJECT_DEPTH: raise ValueError( f"Maximale Project-Tiefe ({MAX_PROJECT_DEPTH}) würde überschritten" ) def _attach_depths(projects: list[dict[str, Any]]) -> list[dict[str, Any]]: if not projects: return projects by_id = {p["id"]: p for p in projects} memo: dict[str, int] = {} for project in projects: project["depth"] = _project_depth_from_map(project["id"], by_id, memo) project["has_children"] = any( other.get("parent_project_id") == project["id"] for other in projects ) project["is_leaf"] = not project["has_children"] return projects def project_is_leaf(*, tenant_id: str, project_id: str) -> bool: conn = get_connection() try: with conn.cursor() as cur: cur.execute( """ SELECT 1 FROM projects WHERE parent_project_id = %s AND tenant_id = %s LIMIT 1 """, (project_id, tenant_id), ) return cur.fetchone() is None finally: conn.close() def create_project( *, tenant_id: str, initiative_id: str, title: str, description: str = "", status: ProjectStatus = "active", parent_project_id: Optional[str] = None, container_kind: Optional[ContainerKind] = None, roadmap_item_id: Optional[str] = None, sort_order: int = 0, target_date: Optional[date] = None, user_id: Optional[str] = None, ) -> dict[str, Any]: title = title.strip() if not title: raise ValueError("Titel ist erforderlich") _validate_status(status) _validate_container_kind(container_kind) 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: _validate_roadmap_item( cur, tenant_id=tenant_id, initiative_id=initiative_id, roadmap_item_id=roadmap_item_id, ) _validate_parent_project( cur, tenant_id=tenant_id, initiative_id=initiative_id, project_id=None, parent_project_id=parent_project_id, ) cur.execute( f""" INSERT INTO projects ( tenant_id, initiative_id, parent_project_id, container_kind, title, description, status, roadmap_item_id, sort_order, target_date ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) RETURNING {_PROJECT_COLUMNS} """, ( tenant_id, initiative_id, parent_project_id, container_kind, title, description, status, roadmap_item_id, sort_order, target_date, ), ) row = _serialize_row(dict(cur.fetchone())) conn.commit() finally: conn.close() log_audit( "project.created", user_id=user_id, tenant_id=tenant_id, details={ "project_id": row["id"], "initiative_id": initiative_id, "title": title, "parent_project_id": parent_project_id, }, ) return row def list_projects_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 {_PROJECT_COLUMNS} FROM projects WHERE tenant_id = %s AND initiative_id = %s ORDER BY sort_order ASC, updated_at DESC, title """, (tenant_id, initiative_id), ) rows = [_serialize_row(dict(r)) for r in cur.fetchall()] return _attach_depths(rows) finally: conn.close() def get_project(*, tenant_id: str, project_id: str) -> Optional[dict[str, Any]]: conn = get_connection() try: with conn.cursor(cursor_factory=RealDictCursor) as cur: cur.execute( f""" SELECT initiative_id FROM projects WHERE id = %s AND tenant_id = %s """, (project_id, tenant_id), ) row = cur.fetchone() if not row: return None initiative_id = str(row["initiative_id"]) finally: conn.close() for project in list_projects_for_initiative( tenant_id=tenant_id, initiative_id=initiative_id ): if project["id"] == project_id: return project return None def update_project( *, tenant_id: str, project_id: str, user_id: Optional[str] = None, title: Optional[str] = None, description: Optional[str] = None, status: Optional[ProjectStatus] = None, parent_project_id: Optional[str] = None, clear_parent_project: bool = False, container_kind: Optional[ContainerKind] = None, clear_container_kind: bool = False, roadmap_item_id: Optional[str] = None, clear_roadmap_item: bool = False, sort_order: Optional[int] = None, target_date: Optional[date] = None, clear_target_date: bool = False, ) -> Optional[dict[str, Any]]: existing = get_project(tenant_id=tenant_id, project_id=project_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_parent_project: updates.append("parent_project_id = NULL") elif parent_project_id is not None: conn = get_connection() try: with conn.cursor() as cur: _validate_parent_project( cur, tenant_id=tenant_id, initiative_id=existing["initiative_id"], project_id=project_id, parent_project_id=parent_project_id, ) finally: conn.close() updates.append("parent_project_id = %s") params.append(parent_project_id) if clear_container_kind: updates.append("container_kind = NULL") elif container_kind is not None: _validate_container_kind(container_kind) updates.append("container_kind = %s") params.append(container_kind) 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( 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 sort_order is not None: updates.append("sort_order = %s") params.append(sort_order) if clear_target_date: updates.append("target_date = NULL") elif target_date is not None: updates.append("target_date = %s") params.append(target_date) if not updates: return existing updates.append("updated_at = NOW()") params.extend([project_id, tenant_id]) conn = get_connection() try: with conn.cursor(cursor_factory=RealDictCursor) as cur: cur.execute( f""" UPDATE projects SET {", ".join(updates)} WHERE id = %s AND tenant_id = %s RETURNING {_PROJECT_COLUMNS} """, params, ) row = cur.fetchone() if not row: return None result = _serialize_row(dict(row)) conn.commit() finally: conn.close() log_audit( "project.updated", user_id=user_id, tenant_id=tenant_id, details={"project_id": project_id}, ) return get_project(tenant_id=tenant_id, project_id=project_id) def delete_project( *, tenant_id: str, project_id: str, user_id: Optional[str] = None ) -> bool: conn = get_connection() try: with conn.cursor() as cur: cur.execute( """ SELECT 1 FROM projects WHERE parent_project_id = %s AND tenant_id = %s LIMIT 1 """, (project_id, tenant_id), ) if cur.fetchone(): raise ValueError( "Projekt hat Unterprojekte — zuerst löschen oder verschieben" ) cur.execute( "UPDATE actions SET project_id = NULL WHERE project_id = %s AND tenant_id = %s", (project_id, tenant_id), ) cur.execute( "DELETE FROM projects WHERE id = %s AND tenant_id = %s RETURNING id", (project_id, tenant_id), ) deleted = cur.fetchone() is not None conn.commit() finally: conn.close() if deleted: log_audit( "project.deleted", user_id=user_id, tenant_id=tenant_id, details={"project_id": project_id}, ) return deleted