"""BacklogItem service — tenant-scoped CRUD + convert (AP0.8c).""" from __future__ import annotations from typing import Any, Literal, Optional from psycopg2.extras import RealDictCursor from db import get_connection from services.actions import create_action from services.audit import log_audit from services.initiatives import PRIORITIES, get_initiative from services.plan_ist import validate_roadmap_item_in_initiative BacklogStatus = Literal["new", "triaged", "accepted", "rejected", "converted"] BacklogItemKind = Literal["story", "bug", "issue"] BACKLOG_STATUSES = frozenset({"new", "triaged", "accepted", "rejected", "converted"}) BACKLOG_ITEM_KINDS = frozenset({"story", "bug", "issue"}) _BACKLOG_COLUMNS = """ 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 """ def _serialize_row(row: dict[str, Any]) -> dict[str, Any]: result = dict(row) for key in ( "id", "tenant_id", "initiative_id", "converted_action_id", "roadmap_item_id", "parent_action_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_status(status: str) -> None: if status not in BACKLOG_STATUSES: raise ValueError(f"Ungültiger Backlog-Status: {status}") def _validate_priority(priority: str) -> None: if priority not in PRIORITIES: raise ValueError(f"Ungültige Priorität: {priority}") def _validate_item_kind(item_kind: str) -> None: if item_kind not in BACKLOG_ITEM_KINDS: raise ValueError(f"Ungültiger Backlog-Typ: {item_kind}") def _item_kind_to_action_kind(item_kind: str) -> str: if item_kind == "bug": return "bug" if item_kind == "issue": return "issue" return "delivery" def _validate_parent_action_in_initiative( *, cur, tenant_id: str, initiative_id: str, parent_action_id: Optional[str], ) -> None: if not parent_action_id: return cur.execute( """ SELECT action_kind, parent_action_id 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"): raise ValueError("Referenz muss ein Feature-Arbeitspaket sein, kein Bug/Issue") def create_backlog_item( *, tenant_id: str, initiative_id: str, title: str, description: str = "", status: BacklogStatus = "new", priority: str = "normal", roadmap_item_id: Optional[str] = None, sort_order: Optional[int] = None, item_kind: BacklogItemKind = "story", 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_status(status) _validate_priority(priority) _validate_item_kind(item_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_in_initiative( cur, tenant_id=tenant_id, initiative_id=initiative_id, roadmap_item_id=roadmap_item_id, ) _validate_parent_action_in_initiative( cur=cur, tenant_id=tenant_id, initiative_id=initiative_id, parent_action_id=parent_action_id, ) 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( f""" 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, %s, %s, %s, %s, %s, %s) RETURNING {_BACKLOG_COLUMNS} """, ( tenant_id, initiative_id, title, description, status, priority, roadmap_item_id, sort_order if sort_order is not None else next_order, item_kind, parent_action_id, ), ) row = _serialize_row(dict(cur.fetchone())) conn.commit() finally: conn.close() log_audit( "backlog.created", user_id=user_id, tenant_id=tenant_id, details={"backlog_item_id": row["id"], "initiative_id": initiative_id, "title": title}, ) return row def list_backlog_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 {_BACKLOG_COLUMNS} FROM backlog_items WHERE tenant_id = %s AND initiative_id = %s ORDER BY sort_order ASC, created_at ASC, title """, (tenant_id, initiative_id), ) return [_serialize_row(dict(r)) for r in cur.fetchall()] finally: conn.close() def get_backlog_item(*, tenant_id: str, backlog_item_id: str) -> Optional[dict[str, Any]]: conn = get_connection() try: with conn.cursor(cursor_factory=RealDictCursor) as cur: cur.execute( f""" SELECT {_BACKLOG_COLUMNS} FROM backlog_items WHERE id = %s AND tenant_id = %s """, (backlog_item_id, tenant_id), ) row = cur.fetchone() return _serialize_row(dict(row)) if row else None finally: conn.close() def update_backlog_item( *, tenant_id: str, backlog_item_id: str, user_id: Optional[str] = None, title: Optional[str] = None, description: Optional[str] = None, status: Optional[BacklogStatus] = None, priority: Optional[str] = None, roadmap_item_id: Optional[str] = None, clear_roadmap_item: bool = False, sort_order: Optional[int] = None, item_kind: Optional[BacklogItemKind] = None, parent_action_id: Optional[str] = None, clear_parent_action: bool = False, ) -> Optional[dict[str, Any]]: existing = get_backlog_item(tenant_id=tenant_id, backlog_item_id=backlog_item_id) if not existing: return None if existing["status"] == "converted": raise ValueError("Konvertiertes Backlog-Item kann nicht bearbeitet werden") old_status = existing["status"] 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 priority is not None: _validate_priority(priority) updates.append("priority = %s") params.append(priority) 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 sort_order is not None: updates.append("sort_order = %s") params.append(sort_order) if item_kind is not None: _validate_item_kind(item_kind) updates.append("item_kind = %s") params.append(item_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([backlog_item_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, ) cur.execute( f""" UPDATE backlog_items SET {", ".join(updates)} WHERE id = %s AND tenant_id = %s RETURNING {_BACKLOG_COLUMNS} """, params, ) row = cur.fetchone() if not row: return None result = _serialize_row(dict(row)) conn.commit() finally: conn.close() log_audit( "backlog.updated", user_id=user_id, tenant_id=tenant_id, details={"backlog_item_id": backlog_item_id}, ) if status is not None and status != old_status: log_audit( "backlog.status_changed", user_id=user_id, tenant_id=tenant_id, details={ "backlog_item_id": backlog_item_id, "from_status": old_status, "to_status": status, }, ) return result def delete_backlog_item( *, tenant_id: str, backlog_item_id: str, user_id: Optional[str] = None, ) -> bool: conn = get_connection() try: with conn.cursor() as cur: cur.execute( "DELETE FROM backlog_items WHERE id = %s AND tenant_id = %s RETURNING id", (backlog_item_id, tenant_id), ) deleted = cur.fetchone() is not None conn.commit() finally: conn.close() if deleted: log_audit( "backlog.deleted", user_id=user_id, tenant_id=tenant_id, details={"backlog_item_id": backlog_item_id}, ) return deleted def convert_backlog_to_action( *, tenant_id: str, backlog_item_id: str, user_id: Optional[str] = None, assigned_actor_ids: Optional[list[str]] = None, work_cycle_id: Optional[str] = None, assign_active_sprint: bool = True, ) -> dict[str, Any]: existing = get_backlog_item(tenant_id=tenant_id, backlog_item_id=backlog_item_id) if not existing: raise ValueError("Backlog-Item nicht gefunden") if existing["status"] == "converted": raise ValueError("Backlog-Item wurde bereits konvertiert") if existing["status"] not in ("accepted", "triaged", "new"): raise ValueError("Backlog-Item kann in diesem Status nicht konvertiert werden") resolved_cycle_id = work_cycle_id if not resolved_cycle_id and assign_active_sprint: from services.work_cycle import get_active_work_cycle active = get_active_work_cycle( tenant_id=tenant_id, initiative_id=existing["initiative_id"] ) if active: resolved_cycle_id = active["id"] item_kind = existing.get("item_kind") or "story" action = create_action( tenant_id=tenant_id, initiative_id=existing["initiative_id"], title=existing["title"], description=existing["description"] or "", priority=existing["priority"], roadmap_item_id=existing.get("roadmap_item_id"), work_cycle_id=resolved_cycle_id, assigned_actor_ids=assigned_actor_ids or [], action_kind=_item_kind_to_action_kind(item_kind), parent_action_id=existing.get("parent_action_id"), user_id=user_id, ) conn = get_connection() try: with conn.cursor(cursor_factory=RealDictCursor) as cur: cur.execute( f""" UPDATE backlog_items SET status = 'converted', converted_action_id = %s, updated_at = NOW() WHERE id = %s AND tenant_id = %s RETURNING {_BACKLOG_COLUMNS} """, (action["id"], backlog_item_id, tenant_id), ) row = _serialize_row(dict(cur.fetchone())) conn.commit() finally: conn.close() log_audit( "backlog.converted_to_action", user_id=user_id, tenant_id=tenant_id, details={ "backlog_item_id": backlog_item_id, "action_id": action["id"], "initiative_id": existing["initiative_id"], }, ) return {"backlog_item": row, "action": action}