Files
soleprint/cfg/amar/station/monitors/turnos/main.py
2026-01-20 05:31:26 -03:00

271 lines
8.1 KiB
Python

"""
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,
)