major restructure

This commit is contained in:
buenosairesam
2026-01-20 05:31:26 -03:00
parent 27b32deba4
commit e4052374db
328 changed files with 1018 additions and 10018 deletions

View File

@@ -0,0 +1,6 @@
"""
Turnos Monitor - At-a-glance view of request/turno pipeline.
Shows the flow: Request -> Vet Accept -> Payment -> Turno
Color-coded by state, minimal info (vet - pet owner).
"""

View File

@@ -0,0 +1,244 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="refresh" content="30">
<title>Turnos · {{ nest_name }}</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: system-ui, -apple-system, sans-serif;
background: #111827;
color: #f3f4f6;
min-height: 100vh;
padding: 1rem;
}
header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem 0 1rem;
border-bottom: 1px solid #374151;
margin-bottom: 1rem;
}
h1 {
font-size: 1.25rem;
font-weight: 600;
display: flex;
align-items: center;
gap: 0.5rem;
}
.nest-badge {
font-size: 0.7rem;
background: #374151;
padding: 0.2rem 0.5rem;
border-radius: 4px;
color: #9ca3af;
}
.total {
font-size: 2rem;
font-weight: 700;
color: #60a5fa;
}
.total small {
font-size: 0.8rem;
color: #9ca3af;
font-weight: 400;
}
/* Pipeline columns */
.pipeline {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 0.75rem;
margin-bottom: 1rem;
}
.column {
background: #1f2937;
border-radius: 8px;
overflow: hidden;
}
.column-header {
padding: 0.5rem 0.75rem;
font-size: 0.75rem;
font-weight: 600;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 2px solid;
}
.column-count {
font-size: 1rem;
font-weight: 700;
}
.column-items {
padding: 0.5rem;
display: flex;
flex-direction: column;
gap: 0.4rem;
max-height: 70vh;
overflow-y: auto;
}
/* Individual request card */
.card {
background: #111827;
border-radius: 6px;
padding: 0.5rem 0.6rem;
font-size: 0.75rem;
border-left: 3px solid;
}
.card-id {
font-weight: 600;
color: #9ca3af;
font-size: 0.65rem;
}
.card-vet {
color: #60a5fa;
font-weight: 500;
}
.card-petowner {
color: #d1d5db;
}
.card-meta {
font-size: 0.65rem;
color: #6b7280;
margin-top: 0.25rem;
}
/* Age indicators */
.card.old {
background: #450a0a;
}
.card.warn {
background: #422006;
}
/* Indicators */
.indicator {
display: inline-block;
width: 6px;
height: 6px;
border-radius: 50%;
margin-right: 0.25rem;
}
.indicator.paid { background: #22c55e; }
.indicator.scheduled { background: #3b82f6; }
/* Empty state */
.empty {
text-align: center;
color: #6b7280;
padding: 2rem;
font-size: 0.8rem;
}
/* Footer */
footer {
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid #374151;
font-size: 0.7rem;
color: #6b7280;
display: flex;
justify-content: space-between;
}
.refresh-notice {
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 0.5; }
50% { opacity: 1; }
}
.header-right {
display: flex;
align-items: center;
gap: 1rem;
}
.view-toggle a {
color: #6b7280;
text-decoration: none;
font-size: 0.8rem;
padding: 0.3rem 0.6rem;
border-radius: 4px;
}
.view-toggle a:hover { background: #374151; }
.view-toggle a.active { background: #374151; color: #f3f4f6; }
</style>
</head>
<body>
<header>
<h1>
Turnos
<span class="nest-badge">{{ nest_name }}</span>
</h1>
<div class="header-right">
<div class="view-toggle">
<a href="?view=pipeline" class="active">Pipeline</a>
<a href="?view=list">List</a>
</div>
<div class="total">
{{ total }}
<small>activos</small>
</div>
</div>
</header>
{% if total == 0 %}
<div class="empty">
No hay solicitudes activas
</div>
{% else %}
<div class="pipeline">
{% for state in active_states %}
{% set info = states.get(state, ('?', '#888', 99)) %}
{% set items = by_state.get(state, []) %}
<div class="column">
<div class="column-header" style="border-color: {{ info[1] }}; color: {{ info[1] }};">
<span>{{ info[0] }}</span>
<span class="column-count">{{ items|length }}</span>
</div>
<div class="column-items">
{% for item in items %}
<div class="card {{ item.age_class }}" style="border-color: {{ info[1] }};">
<div class="card-id">#{{ item.id }}</div>
<div class="card-vet">{{ item.vet }}</div>
<div class="card-petowner">{{ item.petowner }}</div>
<div class="card-meta">
{% if item.is_paid %}<span class="indicator paid" title="Pagado"></span>{% endif %}
{% if item.is_scheduled %}<span class="indicator scheduled" title="Coordinado"></span>{% endif %}
{{ item.age_hours }}h
</div>
</div>
{% endfor %}
</div>
</div>
{% endfor %}
</div>
{% endif %}
<footer>
<span class="refresh-notice">Auto-refresh 30s</span>
<span><a href="/health" style="color: #6b7280;">/health</a></span>
</footer>
</body>
</html>

View File

@@ -0,0 +1,188 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="refresh" content="30">
<title>Turnos List · {{ nest_name }}</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: system-ui, -apple-system, sans-serif;
background: #111827;
color: #f3f4f6;
min-height: 100vh;
padding: 1rem;
}
header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem 0 1rem;
border-bottom: 1px solid #374151;
margin-bottom: 1rem;
}
h1 {
font-size: 1.25rem;
font-weight: 600;
display: flex;
align-items: center;
gap: 0.5rem;
}
.nest-badge {
font-size: 0.7rem;
background: #374151;
padding: 0.2rem 0.5rem;
border-radius: 4px;
color: #9ca3af;
}
.header-right {
display: flex;
align-items: center;
gap: 1rem;
}
.total {
font-size: 1.5rem;
font-weight: 700;
color: #60a5fa;
}
.view-toggle a {
color: #6b7280;
text-decoration: none;
font-size: 0.8rem;
padding: 0.3rem 0.6rem;
border-radius: 4px;
}
.view-toggle a:hover { background: #374151; }
.view-toggle a.active { background: #374151; color: #f3f4f6; }
/* Table */
table {
width: 100%;
border-collapse: collapse;
font-size: 0.85rem;
}
th {
text-align: left;
padding: 0.5rem;
color: #9ca3af;
font-weight: 500;
border-bottom: 1px solid #374151;
}
td {
padding: 0.5rem;
border-bottom: 1px solid #1f2937;
}
tr:hover td {
background: #1f2937;
}
.state-badge {
display: inline-block;
padding: 0.2rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 500;
}
.vet { color: #60a5fa; }
.petowner { color: #d1d5db; }
.id { color: #6b7280; font-family: monospace; }
.age { color: #6b7280; }
.age.warn { color: #fbbf24; }
.age.old { color: #f87171; }
.indicator {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
margin-right: 0.25rem;
}
.indicator.paid { background: #22c55e; }
.indicator.scheduled { background: #3b82f6; }
.empty {
text-align: center;
color: #6b7280;
padding: 3rem;
}
footer {
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid #374151;
font-size: 0.7rem;
color: #6b7280;
display: flex;
justify-content: space-between;
}
</style>
</head>
<body>
<header>
<h1>
Turnos
<span class="nest-badge">{{ nest_name }}</span>
</h1>
<div class="header-right">
<div class="view-toggle">
<a href="?view=pipeline">Pipeline</a>
<a href="?view=list" class="active">List</a>
</div>
<div class="total">{{ total }}</div>
</div>
</header>
{% if total == 0 %}
<div class="empty">No hay solicitudes activas</div>
{% else %}
<table>
<thead>
<tr>
<th>#</th>
<th>Estado</th>
<th>Veterinario</th>
<th>Cliente</th>
<th>Flags</th>
<th>Edad</th>
</tr>
</thead>
<tbody>
{% for item in items %}
<tr>
<td class="id">{{ item.id }}</td>
<td>
<span class="state-badge" style="background: {{ item.state_color }}20; color: {{ item.state_color }};">
{{ item.state_label }}
</span>
</td>
<td class="vet">{{ item.vet }}</td>
<td class="petowner">{{ item.petowner }}</td>
<td>
{% if item.is_paid %}<span class="indicator paid" title="Pagado"></span>{% endif %}
{% if item.is_scheduled %}<span class="indicator scheduled" title="Coordinado"></span>{% endif %}
</td>
<td class="age {{ item.age_class }}">{{ item.age_hours }}h</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
<footer>
<span>Auto-refresh 30s</span>
<span><a href="/health" style="color: #6b7280;">/health</a></span>
</footer>
</body>
</html>

View File

@@ -0,0 +1,270 @@
"""
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,
)