"""Action ↔ Recurring bridge for unified schedules (ADP v0.1 Slice 2).""" from __future__ import annotations from typing import Any, Optional from psycopg2.extras import RealDictCursor from db import get_connection from services.audit import log_audit from services.cadence_instance import ensure_open_instance from services.recurring import ( create_recurring_element, get_recurring_element, update_recurring_element, ) from services.schedule import ( create_schedule, get_schedule, initial_due_at, update_schedule, ) def _get_action_row(*, tenant_id: str, action_id: str) -> dict[str, Any]: conn = get_connection() try: with conn.cursor(cursor_factory=RealDictCursor) as cur: cur.execute( """ SELECT id, tenant_id, initiative_id, project_id, roadmap_item_id, schedule_id, title, description FROM actions WHERE id = %s AND tenant_id = %s """, (action_id, tenant_id), ) row = cur.fetchone() if not row: raise ValueError("Arbeitspaket nicht gefunden") result = dict(row) for key in ( "id", "tenant_id", "initiative_id", "project_id", "roadmap_item_id", "schedule_id", ): if result.get(key): result[key] = str(result[key]) return result finally: conn.close() def _find_recurring_for_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( """ SELECT id FROM recurring_elements WHERE tenant_id = %s AND action_id = %s LIMIT 1 """, (tenant_id, action_id), ) row = cur.fetchone() if not row: return None return get_recurring_element(tenant_id=tenant_id, recurring_id=str(row["id"])) finally: conn.close() def attach_action_schedule_fields( action: dict[str, Any], *, tenant_id: str ) -> dict[str, Any]: schedule = None if action.get("schedule_id"): schedule = get_schedule(tenant_id=tenant_id, schedule_id=str(action["schedule_id"])) bridge = _find_recurring_for_action(tenant_id=tenant_id, action_id=str(action["id"])) copy = dict(action) copy["schedule"] = schedule copy["recurring_id"] = bridge["id"] if bridge else None return copy def get_action_schedule_bundle(*, tenant_id: str, action_id: str) -> dict[str, Any]: action = _get_action_row(tenant_id=tenant_id, action_id=action_id) enriched = attach_action_schedule_fields(action, tenant_id=tenant_id) bridge = _find_recurring_for_action(tenant_id=tenant_id, action_id=action_id) return { "action_id": action_id, "schedule": enriched.get("schedule"), "recurring_id": enriched.get("recurring_id"), "recurring": bridge, } def upsert_action_schedule( *, tenant_id: str, action_id: str, user_id: Optional[str] = None, schedule_kind: str = "interval", interval_days: Optional[int] = 1, weekday_mask: int = 0, pause_until=None, ) -> dict[str, Any]: action = _get_action_row(tenant_id=tenant_id, action_id=action_id) existing_schedule_id = action.get("schedule_id") if existing_schedule_id: schedule = 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=pause_until is None, ) else: schedule = create_schedule( tenant_id=tenant_id, schedule_kind=schedule_kind, # type: ignore[arg-type] interval_days=interval_days, weekday_mask=weekday_mask, pause_until=pause_until, ) if not schedule: raise ValueError("Schedule konnte nicht gespeichert werden") next_due = initial_due_at(schedule=schedule) interval = schedule.get("interval_days") or 1 conn = get_connection() try: with conn.cursor() as cur: cur.execute( """ UPDATE actions SET schedule_id = %s, updated_at = NOW() WHERE id = %s AND tenant_id = %s """, (schedule["id"], action_id, tenant_id), ) conn.commit() finally: conn.close() bridge = _find_recurring_for_action(tenant_id=tenant_id, action_id=action_id) if bridge: update_recurring_element( tenant_id=tenant_id, recurring_id=bridge["id"], user_id=user_id, status="active", interval_days=int(interval), next_due_at=next_due, ) conn = get_connection() try: with conn.cursor() as cur: cur.execute( """ UPDATE recurring_elements SET schedule_id = %s, updated_at = NOW() WHERE id = %s AND tenant_id = %s """, (schedule["id"], bridge["id"], tenant_id), ) conn.commit() finally: conn.close() recurring = get_recurring_element(tenant_id=tenant_id, recurring_id=bridge["id"]) else: recurring = create_recurring_element( tenant_id=tenant_id, initiative_id=str(action["initiative_id"]), title=action["title"], description=action.get("description") or "", status="active", interval_days=int(interval), next_due_at=next_due, roadmap_item_id=action.get("roadmap_item_id"), project_id=action.get("project_id"), schedule_id=str(schedule["id"]), user_id=user_id, ) conn = get_connection() try: with conn.cursor() as cur: cur.execute( """ UPDATE recurring_elements SET action_id = %s WHERE id = %s AND tenant_id = %s """, (action_id, recurring["id"], tenant_id), ) conn.commit() finally: conn.close() ensure_open_instance( tenant_id=tenant_id, recurring_element_id=recurring["id"], due_at=next_due, ) log_audit( "action.schedule_set", user_id=user_id, tenant_id=tenant_id, details={"action_id": action_id, "schedule_id": schedule["id"]}, ) return get_action_schedule_bundle(tenant_id=tenant_id, action_id=action_id) def clear_action_schedule( *, tenant_id: str, action_id: str, user_id: Optional[str] = None, ) -> dict[str, Any]: bundle = get_action_schedule_bundle(tenant_id=tenant_id, action_id=action_id) bridge = bundle.get("recurring") if bridge: update_recurring_element( tenant_id=tenant_id, recurring_id=bridge["id"], user_id=user_id, status="ended", ) conn = get_connection() try: with conn.cursor() as cur: cur.execute( """ UPDATE actions SET schedule_id = NULL, updated_at = NOW() WHERE id = %s AND tenant_id = %s """, (action_id, tenant_id), ) conn.commit() finally: conn.close() log_audit( "action.schedule_cleared", user_id=user_id, tenant_id=tenant_id, details={"action_id": action_id}, ) return {"action_id": action_id, "schedule": None, "recurring_id": None} def create_scheduled_action_at_gate( *, tenant_id: str, initiative_id: str, gate_id: str, title: str, description: str = "", user_id: Optional[str] = None, schedule_kind: str = "weekdays", interval_days: Optional[int] = 1, weekday_mask: int = 0, pause_until=None, ) -> dict[str, Any]: """Plan a repeating AP at a work-gate. RecurringElement is the cadence bridge.""" from datetime import date from services.actions import create_action from services.schedule import weekday_mask_for_date if schedule_kind == "weekdays" and weekday_mask <= 0: weekday_mask = weekday_mask_for_date(date.today()) action = create_action( tenant_id=tenant_id, initiative_id=initiative_id, title=title, description=description, status="open", roadmap_item_id=gate_id, user_id=user_id, ) bundle = upsert_action_schedule( tenant_id=tenant_id, action_id=str(action["id"]), user_id=user_id, schedule_kind=schedule_kind, interval_days=interval_days, weekday_mask=weekday_mask, pause_until=pause_until, ) recurring = bundle.get("recurring") if not recurring: raise ValueError("Cadence-Brücke für Arbeitspaket fehlt") return recurring