"""CadenceInstance service — AP A1.1 minimal daily practice tracking.""" from __future__ import annotations from datetime import datetime, time, 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 _next_due_at(*, now: datetime, interval_days: int | None) -> datetime: days = max(int(interval_days or 1), 1) next_date = now.date() + timedelta(days=days) return datetime.combine(next_date, time.min, tzinfo=timezone.utc) def _parse_due(value: Any) -> Optional[datetime]: if value is None: return None if isinstance(value, datetime): return value if value.tzinfo else value.replace(tzinfo=timezone.utc) try: return datetime.fromisoformat(str(value).replace("Z", "+00:00")) except ValueError: return None 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 = _next_due_at(now=now, interval_days=interval) update_recurring_element( tenant_id=tenant_id, recurring_id=recurring_element_id, user_id=user_id, next_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_due_at": next_due.isoformat(), "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] now = datetime.now(timezone.utc) 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() } cur.execute( """ SELECT DISTINCT ON (recurring_element_id) recurring_element_id, measurement_note, completed_at FROM cadence_instances WHERE tenant_id = %s AND recurring_element_id = ANY(%s::uuid[]) AND status = 'completed' AND completed_at >= CURRENT_DATE ORDER BY recurring_element_id, completed_at DESC """, (tenant_id, ids), ) completed_today = { str(row["recurring_element_id"]): { "measurement_note": row.get("measurement_note") or "", "completed_at": row["completed_at"].isoformat() if row.get("completed_at") else None, } for row in cur.fetchall() } finally: conn.close() enriched = [] for item in items: copy = dict(item) today = completed_today.get(item["id"]) copy["today_completed"] = today is not None copy["today_measurement_note"] = today.get("measurement_note", "") if today else "" open_inst = by_recurring.get(item["id"]) next_due = _parse_due(item.get("next_due_at")) due_now = next_due is None or next_due <= now if ( not open_inst and item.get("status") == "active" and due_now and not copy["today_completed"] ): open_inst = ensure_open_instance( tenant_id=tenant_id, recurring_element_id=item["id"], due_at=next_due or now, ) copy["open_cadence_instance"] = open_inst enriched.append(copy) return enriched