""" Turnos Monitor - At-a-glance view of the request/turno pipeline. Request -> Vet Accept + Payment -> Turno (VetVisit) Run standalone: python -m ward.tools.monitors.turnos.main Or use uvicorn: uvicorn ward.tools.monitors.turnos.main:app --port 12010 --reload """ import os from datetime import datetime from pathlib import Path from typing import Optional from fastapi import FastAPI, Request from fastapi.responses import HTMLResponse from fastapi.templating import Jinja2Templates from sqlalchemy import create_engine, text from sqlalchemy.engine import Engine app = FastAPI(title="Turnos Monitor", version="0.1.0") templates = Jinja2Templates(directory=Path(__file__).parent) # ============================================================================= # NEST CONFIG - Pluggable environment targeting # ============================================================================= # Default nest: local development database # Override with env vars or future nest selector UI NEST_CONFIG = { "name": os.getenv("NEST_NAME", "local"), "db": { "host": os.getenv("DB_HOST", "localhost"), "port": os.getenv("DB_PORT", "5433"), # local default "name": os.getenv("DB_NAME", "amarback"), "user": os.getenv("DB_USER", "mariano"), "password": os.getenv("DB_PASSWORD", ""), } } def get_db_url() -> str: """Build database URL from nest config.""" db = NEST_CONFIG["db"] return f"postgresql://{db['user']}:{db['password']}@{db['host']}:{db['port']}/{db['name']}" # ============================================================================= # STATE DEFINITIONS # ============================================================================= # Pipeline states with labels, colors, and order STATES = { "pending": ("Sin Atender", "#fbbf24", 1), # amber "in_progress_vet": ("Buscando Vet", "#f97316", 2), # orange "vet_asked": ("Esperando Vet", "#fb923c", 3), # orange light "vet_accepted": ("Vet OK", "#4ade80", 4), # green "in_progress_pay": ("Esp. Pago", "#60a5fa", 5), # blue "payed": ("Pagado", "#2dd4bf", 6), # teal "coordinated": ("Coordinado", "#22c55e", 7), # green "not_coordinated": ("Sin Coord.", "#facc15", 8), # yellow "completed": ("Turno", "#059669", 9), # emerald "rejected": ("Rechazado", "#f87171", 10), # red } # States to show in the active pipeline (exclude end states) ACTIVE_STATES = [ "pending", "in_progress_vet", "vet_asked", "vet_accepted", "in_progress_pay", "payed", "not_coordinated", "coordinated", ] # ============================================================================= # DATABASE # ============================================================================= _engine: Optional[Engine] = None def get_engine() -> Optional[Engine]: """Get or create database engine (lazy singleton).""" global _engine if _engine is None: try: _engine = create_engine(get_db_url(), pool_pre_ping=True) except Exception as e: print(f"[turnos] DB engine error: {e}") return _engine def fetch_active_requests() -> list[dict]: """Fetch active service requests with vet and petowner info.""" engine = get_engine() if not engine: return [] query = text(""" SELECT sr.id, sr.state, sr.created_at, sr.date_coordinated, sr.hour_coordinated, sr.pay_number, po.first_name || ' ' || COALESCE(po.last_name, '') as petowner_name, COALESCE(v.first_name || ' ' || v.last_name, '') as vet_name FROM solicitudes_servicerequest sr JOIN mascotas_petowner po ON sr.petowner_id = po.id LEFT JOIN mascotas_veterinarian v ON sr.veterinarian_id = v.id WHERE sr.state = ANY(:states) ORDER BY CASE sr.state WHEN 'pending' THEN 1 WHEN 'in_progress_vet' THEN 2 WHEN 'vet_asked' THEN 3 WHEN 'vet_accepted' THEN 4 WHEN 'in_progress_pay' THEN 5 WHEN 'payed' THEN 6 WHEN 'not_coordinated' THEN 7 WHEN 'coordinated' THEN 8 ELSE 9 END, sr.created_at DESC """) try: with engine.connect() as conn: result = conn.execute(query, {"states": ACTIVE_STATES}) rows = result.fetchall() requests = [] for row in rows: state_info = STATES.get(row.state, ("?", "#888", 99)) age_h = _hours_since(row.created_at) requests.append({ "id": row.id, "state": row.state, "state_label": state_info[0], "state_color": state_info[1], "petowner": row.petowner_name.strip(), "vet": row.vet_name.strip() if row.vet_name else "-", "is_paid": bool(row.pay_number), "is_scheduled": bool(row.date_coordinated and row.hour_coordinated), "age_hours": age_h, "age_class": "old" if age_h > 48 else ("warn" if age_h > 24 else ""), }) return requests except Exception as e: print(f"[turnos] Query error: {e}") return [] def fetch_counts() -> dict[str, int]: """Fetch count per state.""" engine = get_engine() if not engine: return {} query = text(""" SELECT state, COUNT(*) as cnt FROM solicitudes_servicerequest WHERE state = ANY(:states) GROUP BY state """) try: with engine.connect() as conn: result = conn.execute(query, {"states": ACTIVE_STATES}) return {row.state: row.cnt for row in result.fetchall()} except Exception as e: print(f"[turnos] Count error: {e}") return {} def _hours_since(dt: Optional[datetime]) -> int: """Hours since datetime.""" if not dt: return 0 try: now = datetime.now(dt.tzinfo) if dt.tzinfo else datetime.now() return int((now - dt).total_seconds() / 3600) except: return 0 # ============================================================================= # ROUTES # ============================================================================= @app.get("/health") def health(): """Health check.""" engine = get_engine() db_ok = False if engine: try: with engine.connect() as conn: conn.execute(text("SELECT 1")) db_ok = True except: pass return { "status": "ok" if db_ok else "degraded", "service": "turnos-monitor", "nest": NEST_CONFIG["name"], "database": "connected" if db_ok else "disconnected", } @app.get("/", response_class=HTMLResponse) def index(request: Request, view: str = "pipeline"): """Main monitor view. ?view=list for list view.""" requests_data = fetch_active_requests() counts = fetch_counts() # Group by state by_state = {s: [] for s in ACTIVE_STATES} for req in requests_data: if req["state"] in by_state: by_state[req["state"]].append(req) template = "list.html" if view == "list" else "index.html" return templates.TemplateResponse(template, { "request": request, "items": requests_data, "by_state": by_state, "counts": counts, "states": STATES, "active_states": ACTIVE_STATES, "total": len(requests_data), "nest_name": NEST_CONFIG["name"], "view": view, }) @app.get("/api/data") def api_data(): """JSON API endpoint.""" return { "nest": NEST_CONFIG["name"], "requests": fetch_active_requests(), "counts": fetch_counts(), } # ============================================================================= # MAIN # ============================================================================= if __name__ == "__main__": import uvicorn uvicorn.run( "main:app", host="0.0.0.0", port=int(os.getenv("PORT", "12010")), reload=True, )