"""RecurringElement service — tenant-scoped CRUD (AP0.9e).""" from __future__ import annotations from datetime import datetime, timezone 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 RecurringStatus = Literal["active", "paused", "ended"] RECURRING_STATUSES = frozenset({"active", "paused", "ended"}) _RECURRING_COLUMNS = """ id, tenant_id, initiative_id, roadmap_item_id, project_id, action_id, schedule_id, title, description, status, interval_days, next_due_at, 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", "roadmap_item_id", "project_id", "action_id", "schedule_id"): if result.get(key): result[key] = str(result[key]) for ts_key in ("created_at", "updated_at", "next_due_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 RECURRING_STATUSES: raise ValueError(f"Ungültiger Recurring-Status: {status}") def create_recurring_element( *, tenant_id: str, initiative_id: str, title: str, description: str = "", status: RecurringStatus = "active", interval_days: Optional[int] = None, next_due_at: Optional[datetime] = None, roadmap_item_id: Optional[str] = None, project_id: Optional[str] = None, schedule_id: Optional[str] = None, schedule_kind: str = "interval", weekday_mask: int = 0, pause_until=None, user_id: Optional[str] = None, ) -> dict[str, Any]: title = title.strip() if not title: raise ValueError("Titel ist erforderlich") _validate_status(status) if interval_days is not None and interval_days <= 0: raise ValueError("interval_days muss positiv sein") if not get_initiative(tenant_id=tenant_id, initiative_id=initiative_id): raise ValueError("Initiative nicht gefunden") from services.schedule import create_schedule, initial_due_at schedule = None if schedule_id: from services.schedule import get_schedule schedule = get_schedule(tenant_id=tenant_id, schedule_id=schedule_id) if not schedule: raise ValueError("Schedule nicht gefunden") else: days = interval_days if interval_days is not None else 1 schedule = create_schedule( tenant_id=tenant_id, schedule_kind=schedule_kind, # type: ignore[arg-type] interval_days=days, weekday_mask=weekday_mask, pause_until=pause_until, ) schedule_id = schedule["id"] interval_days = schedule.get("interval_days") or days if next_due_at is None: next_due_at = initial_due_at(schedule=schedule) conn = get_connection() try: with conn.cursor(cursor_factory=RealDictCursor) as cur: cur.execute( f""" INSERT INTO recurring_elements ( tenant_id, initiative_id, roadmap_item_id, project_id, schedule_id, title, description, status, interval_days, next_due_at ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) RETURNING {_RECURRING_COLUMNS} """, ( tenant_id, initiative_id, roadmap_item_id, project_id, schedule_id, title, description, status, interval_days, next_due_at, ), ) row = _serialize_row(dict(cur.fetchone())) conn.commit() finally: conn.close() log_audit( "recurring.created", user_id=user_id, tenant_id=tenant_id, details={"recurring_id": row["id"], "initiative_id": initiative_id}, ) from services.cadence_instance import ensure_open_instance open_inst = ensure_open_instance( tenant_id=tenant_id, recurring_element_id=row["id"], due_at=next_due_at, ) row["open_cadence_instance"] = open_inst row["schedule"] = schedule return row def list_recurring_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 {_RECURRING_COLUMNS} FROM recurring_elements WHERE tenant_id = %s AND initiative_id = %s ORDER BY updated_at DESC, title """, (tenant_id, initiative_id), ) rows = [_serialize_row(dict(r)) for r in cur.fetchall()] finally: conn.close() from services.cadence_instance import attach_open_instances_to_recurring_list from services.schedule import attach_schedules_to_items rows = attach_schedules_to_items(rows, tenant_id=tenant_id) return attach_open_instances_to_recurring_list(rows, tenant_id=tenant_id) def list_recurring_for_gate( *, tenant_id: str, initiative_id: str, gate_id: str ) -> list[dict[str, Any]]: items = list_recurring_for_initiative( tenant_id=tenant_id, initiative_id=initiative_id ) return [item for item in items if str(item.get("roadmap_item_id") or "") == str(gate_id)] def get_recurring_element( *, tenant_id: str, recurring_id: str ) -> Optional[dict[str, Any]]: conn = get_connection() try: with conn.cursor(cursor_factory=RealDictCursor) as cur: cur.execute( f""" SELECT {_RECURRING_COLUMNS} FROM recurring_elements WHERE id = %s AND tenant_id = %s """, (recurring_id, tenant_id), ) row = cur.fetchone() if not row: return None result = _serialize_row(dict(row)) finally: conn.close() from services.schedule import attach_schedules_to_items enriched = attach_schedules_to_items([result], tenant_id=tenant_id) return enriched[0] if enriched else result def update_recurring_element( *, tenant_id: str, recurring_id: str, user_id: Optional[str] = None, title: Optional[str] = None, description: Optional[str] = None, status: Optional[RecurringStatus] = None, interval_days: Optional[int] = None, clear_interval_days: bool = False, next_due_at: Optional[datetime] = None, clear_next_due_at: bool = False, roadmap_item_id: Optional[str] = None, clear_roadmap_item_id: bool = False, project_id: Optional[str] = None, clear_project_id: bool = False, schedule_kind: Optional[str] = None, weekday_mask: Optional[int] = None, pause_until=None, clear_pause_until: bool = False, ) -> Optional[dict[str, Any]]: existing = get_recurring_element(tenant_id=tenant_id, recurring_id=recurring_id) if not existing: return None 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 clear_interval_days: updates.append("interval_days = NULL") elif interval_days is not None: if interval_days <= 0: raise ValueError("interval_days muss positiv sein") updates.append("interval_days = %s") params.append(interval_days) if clear_next_due_at: updates.append("next_due_at = NULL") elif next_due_at is not None: updates.append("next_due_at = %s") params.append(next_due_at) if clear_roadmap_item_id: updates.append("roadmap_item_id = NULL") elif roadmap_item_id is not None: updates.append("roadmap_item_id = %s") params.append(roadmap_item_id) if clear_project_id: updates.append("project_id = NULL") elif project_id is not None: updates.append("project_id = %s") params.append(project_id) if not updates and schedule_kind is None and weekday_mask is None and not clear_pause_until and pause_until is None: return existing schedule_updated = False if existing.get("schedule_id") and ( schedule_kind is not None or weekday_mask is not None or pause_until is not None or clear_pause_until or interval_days is not None ): from services.schedule import initial_due_at, update_schedule sched = update_schedule( tenant_id=tenant_id, schedule_id=str(existing["schedule_id"]), schedule_kind=schedule_kind, # type: ignore[arg-type] interval_days=interval_days, weekday_mask=weekday_mask, pause_until=pause_until, clear_pause_until=clear_pause_until, ) if sched: schedule_updated = True if interval_days is not None and sched.get("interval_days"): updates.append("interval_days = %s") params.append(sched["interval_days"]) if next_due_at is None: next_due_at = initial_due_at(schedule=sched) updates.append("next_due_at = %s") params.append(next_due_at) if not updates and not schedule_updated: return existing updates.append("updated_at = NOW()") params.extend([recurring_id, tenant_id]) conn = get_connection() try: with conn.cursor(cursor_factory=RealDictCursor) as cur: cur.execute( f""" UPDATE recurring_elements SET {", ".join(updates)} WHERE id = %s AND tenant_id = %s RETURNING {_RECURRING_COLUMNS} """, params, ) row = cur.fetchone() if not row: return None result = _serialize_row(dict(row)) conn.commit() finally: conn.close() log_audit( "recurring.updated", user_id=user_id, tenant_id=tenant_id, details={"recurring_id": recurring_id}, ) if status is not None and status != old_status: log_audit( "recurring.status_changed", user_id=user_id, tenant_id=tenant_id, details={ "recurring_id": recurring_id, "from_status": old_status, "to_status": status, }, ) return get_recurring_element(tenant_id=tenant_id, recurring_id=recurring_id) or result def delete_recurring_element( *, tenant_id: str, recurring_id: str, user_id: Optional[str] = None, ) -> bool: conn = get_connection() try: with conn.cursor() as cur: cur.execute( """ DELETE FROM recurring_elements WHERE id = %s AND tenant_id = %s RETURNING id """, (recurring_id, tenant_id), ) deleted = cur.fetchone() is not None conn.commit() finally: conn.close() if deleted: log_audit( "recurring.deleted", user_id=user_id, tenant_id=tenant_id, details={"recurring_id": recurring_id}, ) return deleted