"""CadenceInstance service — AP A1.1 minimal daily practice tracking.""" from __future__ import annotations from datetime import datetime, timedelta, timezone from typing import Any, Literal, Optional from psycopg2.extras import RealDictCursor from db import get_connection from services.audit import log_audit CadenceStatus = Literal["open", "completed"] _CADENCE_COLUMNS = """ id, tenant_id, recurring_element_id, status, due_at, completed_at, measurement_note, created_at, updated_at """ def _serialize_row(row: dict[str, Any]) -> dict[str, Any]: result = dict(row) for key in ("id", "tenant_id", "recurring_element_id"): if result.get(key): result[key] = str(result[key]) for ts_key in ("due_at", "completed_at", "created_at", "updated_at"): if result.get(ts_key): result[ts_key] = result[ts_key].isoformat() return result def get_open_instance( *, tenant_id: str, recurring_element_id: str ) -> Optional[dict[str, Any]]: conn = get_connection() try: with conn.cursor(cursor_factory=RealDictCursor) as cur: cur.execute( f""" SELECT {_CADENCE_COLUMNS} FROM cadence_instances WHERE tenant_id = %s AND recurring_element_id = %s AND status = 'open' LIMIT 1 """, (tenant_id, recurring_element_id), ) row = cur.fetchone() return _serialize_row(dict(row)) if row else None finally: conn.close() def ensure_open_instance( *, tenant_id: str, recurring_element_id: str, due_at: Optional[datetime] = None, ) -> dict[str, Any]: """Ensure exactly one open CadenceInstance (idempotent).""" existing = get_open_instance( tenant_id=tenant_id, recurring_element_id=recurring_element_id ) if existing: return existing due = due_at or datetime.now(timezone.utc) conn = get_connection() try: with conn.cursor(cursor_factory=RealDictCursor) as cur: cur.execute( f""" INSERT INTO cadence_instances ( tenant_id, recurring_element_id, status, due_at ) VALUES (%s, %s, 'open', %s) RETURNING {_CADENCE_COLUMNS} """, (tenant_id, recurring_element_id, due), ) row = _serialize_row(dict(cur.fetchone())) conn.commit() finally: conn.close() return row def complete_open_instance( *, tenant_id: str, recurring_element_id: str, user_id: Optional[str] = None, measurement_note: str = "", ) -> dict[str, Any]: """ Close open instance, advance recurring next_due_at, open next instance. Returns { completed, recurring, next_instance }. """ from services.recurring import get_recurring_element, update_recurring_element recurring = get_recurring_element( tenant_id=tenant_id, recurring_id=recurring_element_id ) if not recurring: raise ValueError("Recurring-Element nicht gefunden") if recurring["status"] != "active": raise ValueError("Recurring-Element ist nicht aktiv") open_inst = get_open_instance( tenant_id=tenant_id, recurring_element_id=recurring_element_id ) if not open_inst: open_inst = ensure_open_instance( tenant_id=tenant_id, recurring_element_id=recurring_element_id, due_at=datetime.now(timezone.utc), ) now = datetime.now(timezone.utc) note = (measurement_note or "").strip() conn = get_connection() try: with conn.cursor(cursor_factory=RealDictCursor) as cur: cur.execute( f""" UPDATE cadence_instances SET status = 'completed', completed_at = %s, measurement_note = %s, updated_at = NOW() WHERE id = %s AND tenant_id = %s AND status = 'open' RETURNING {_CADENCE_COLUMNS} """, (now, note, open_inst["id"], tenant_id), ) completed_row = cur.fetchone() if not completed_row: raise ValueError("Keine offene Cadence-Instanz") completed = _serialize_row(dict(completed_row)) conn.commit() finally: conn.close() interval = recurring.get("interval_days") or 1 next_due = now + timedelta(days=int(interval)) update_recurring_element( tenant_id=tenant_id, recurring_id=recurring_element_id, user_id=user_id, next_due_at=next_due, ) next_instance = ensure_open_instance( tenant_id=tenant_id, recurring_element_id=recurring_element_id, due_at=next_due, ) log_audit( "cadence_instance.completed", user_id=user_id, tenant_id=tenant_id, details={ "cadence_instance_id": completed["id"], "recurring_id": recurring_element_id, "measurement_note": note[:200] if note else "", }, ) updated_recurring = get_recurring_element( tenant_id=tenant_id, recurring_id=recurring_element_id ) return { "completed_instance": completed, "next_instance": next_instance, "recurring": updated_recurring, } def attach_open_instances_to_recurring_list( items: list[dict[str, Any]], *, tenant_id: str ) -> list[dict[str, Any]]: if not items: return items ids = [item["id"] for item in items] conn = get_connection() try: with conn.cursor(cursor_factory=RealDictCursor) as cur: cur.execute( f""" SELECT {_CADENCE_COLUMNS} FROM cadence_instances WHERE tenant_id = %s AND recurring_element_id = ANY(%s::uuid[]) AND status = 'open' """, (tenant_id, ids), ) by_recurring = { str(row["recurring_element_id"]): _serialize_row(dict(row)) for row in cur.fetchall() } finally: conn.close() enriched = [] for item in items: copy = dict(item) open_inst = by_recurring.get(item["id"]) if not open_inst and item.get("status") == "active": open_inst = ensure_open_instance( tenant_id=tenant_id, recurring_element_id=item["id"], ) copy["open_cadence_instance"] = open_inst enriched.append(copy) return enriched