Files
shinra32 56c51378b8 Co-authored-by: MENDOZA BALLARDO GAEL RICARDO <gael-meb123@users.noreply.github.com>
version final final ya enserio la final del proyecto :)
2026-05-23 08:42:27 -06:00

618 lines
21 KiB
Python

"""
Endpoints de administración — Solo accesibles para usuarios con role='admin'.
Operan directamente contra Supabase (RLS bypaseado por service_role).
"""
import traceback
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException
from app.core.deps import get_current_user, require_role
from app.core.supabase_client import supabase_admin
from app.services import notifications
from app.schemas.admin import (
AdminUser,
AdminUserCreate,
AdminUserUpdate,
AdminRoute,
AdminRouteCreate,
AdminRouteUpdate,
AdminUnit,
AdminUnitCreate,
AdminUnitUpdate,
AdminDriver,
AdminDriverCreate,
AdminDriverUpdate,
)
router = APIRouter(
prefix="/admin",
tags=["admin"],
dependencies=[Depends(require_role("admin"))],
)
# ── Helpers ───────────────────────────────────────────────────────────────────
def _auth_user_map() -> dict[str, dict]:
"""Devuelve {user_id: {email, phone}} desde Supabase Auth (paginado)."""
mapping: dict[str, dict] = {}
try:
page = 1
while True:
resp = supabase_admin.auth.admin.list_users(page=page, per_page=200)
users = getattr(resp, "users", None) or (
resp if isinstance(resp, list) else []
)
if not users:
break
for u in users:
mapping[str(u.id)] = {
"email": getattr(u, "email", None),
"phone": getattr(u, "phone", None),
}
if len(users) < 200:
break
page += 1
except Exception as e:
print(f"[admin] list_users falló: {e}")
return mapping
def _auth_user(user_id: str) -> dict:
try:
resp = supabase_admin.auth.admin.get_user_by_id(user_id)
u = getattr(resp, "user", None) or resp
return {
"email": getattr(u, "email", None),
"phone": getattr(u, "phone", None),
}
except Exception:
return {"email": None, "phone": None}
# ── Users ─────────────────────────────────────────────────────────────────────
@router.get("/users", response_model=list[AdminUser])
def list_users():
res = supabase_admin.table("users").select("id, name, role").execute()
rows = res.data or []
auth_map = _auth_user_map()
return [
AdminUser(
id=str(r["id"]),
name=r.get("name"),
role=r.get("role", "citizen"),
email=auth_map.get(str(r["id"]), {}).get("email"),
phone=auth_map.get(str(r["id"]), {}).get("phone"),
)
for r in rows
]
@router.post("/users", response_model=AdminUser, status_code=201)
def create_user(body: AdminUserCreate):
if not body.email and not body.phone:
raise HTTPException(400, "Se requiere email o teléfono")
if len(body.password) < 6:
raise HTTPException(400, "La contraseña debe tener al menos 6 caracteres")
create_attrs: dict = {"password": body.password}
if body.email:
create_attrs["email"] = body.email
create_attrs["email_confirm"] = True
else:
create_attrs["phone"] = body.phone
create_attrs["phone_confirm"] = True
try:
resp = supabase_admin.auth.admin.create_user(create_attrs)
except Exception as e:
raise HTTPException(400, f"Error al crear el usuario en Supabase Auth: {e}")
auth_user = getattr(resp, "user", None)
if not auth_user:
raise HTTPException(500, "Supabase Auth no devolvió un usuario válido")
try:
supabase_admin.table("users").upsert(
{"id": str(auth_user.id), "name": body.name, "role": body.role}
).execute()
except Exception as e:
raise HTTPException(500, f"Error al guardar el usuario en public.users: {e}")
return AdminUser(
id=str(auth_user.id),
name=body.name,
email=body.email,
phone=body.phone,
role=body.role,
)
@router.patch("/users/{user_id}", response_model=AdminUser)
def update_user(user_id: str, body: AdminUserUpdate):
public_payload: dict = {}
if body.name is not None:
public_payload["name"] = body.name
if body.role is not None:
public_payload["role"] = body.role
if public_payload:
try:
supabase_admin.table("users").update(public_payload).eq(
"id", user_id
).execute()
except Exception as e:
raise HTTPException(500, f"Error al actualizar public.users: {e}")
if body.email is not None:
try:
supabase_admin.auth.admin.update_user_by_id(
user_id, {"email": body.email}
)
except Exception as e:
raise HTTPException(500, f"Error al actualizar email: {e}")
res = (
supabase_admin.table("users")
.select("id, name, role")
.eq("id", user_id)
.maybe_single()
.execute()
)
if not res.data:
raise HTTPException(404, "Usuario no encontrado")
auth = _auth_user(user_id)
return AdminUser(
id=str(res.data["id"]),
name=res.data.get("name"),
role=res.data.get("role", "citizen"),
email=auth.get("email"),
phone=auth.get("phone"),
)
@router.delete("/users/{user_id}", status_code=204)
def delete_user(user_id: str):
try:
supabase_admin.auth.admin.delete_user(user_id)
except Exception as e:
raise HTTPException(500, f"Error al borrar el usuario: {e}")
try:
supabase_admin.table("users").delete().eq("id", user_id).execute()
except Exception:
pass
return None
# ── Routes ────────────────────────────────────────────────────────────────────
_ROUTE_COLS = "id, name, truck_id, turno, status, current_position_id"
@router.get("/routes", response_model=list[AdminRoute])
def list_routes():
res = supabase_admin.table("routes").select(_ROUTE_COLS).execute()
return [AdminRoute(**r) for r in (res.data or [])]
@router.post("/routes", response_model=AdminRoute, status_code=201)
def create_route(body: AdminRouteCreate):
payload = body.model_dump(exclude_none=True)
try:
res = (
supabase_admin.table("routes")
.insert(payload)
.execute()
)
except Exception as e:
raise HTTPException(400, f"Error al crear la ruta: {e}")
row = (res.data or [None])[0]
if not row:
raise HTTPException(500, "Supabase no devolvió la fila creada")
return AdminRoute(**row)
@router.patch("/routes/{route_id}", response_model=AdminRoute)
def update_route(route_id: str, body: AdminRouteUpdate):
payload = body.model_dump(exclude_none=True)
if not payload:
raise HTTPException(400, "Sin cambios")
try:
old_res = supabase_admin.table("routes").select("truck_id").eq("id", route_id).maybe_single().execute()
old_truck_id = old_res.data.get("truck_id") if old_res.data else None
except Exception:
old_truck_id = None
try:
supabase_admin.table("routes").update(payload).eq("id", route_id).execute()
except Exception as e:
raise HTTPException(400, f"Error al actualizar la ruta: {e}")
if body.truck_id is not None and old_truck_id != body.truck_id:
try:
notifications.send_to_topic(
f"topic_{route_id}",
{
"title": "Ruta reasignada 🚛",
"body": f"Tu recolección ha sido reasignada a la unidad #{body.truck_id}. El servicio se reanudará en breve.",
}
)
except Exception as e:
print(f"Error al enviar alerta ciudadana por reasignación: {e}")
res = (
supabase_admin.table("routes")
.select(_ROUTE_COLS)
.eq("id", route_id)
.maybe_single()
.execute()
)
if not res.data:
raise HTTPException(404, "Ruta no encontrada")
return AdminRoute(**res.data)
@router.delete("/routes/{route_id}", status_code=204)
def delete_route(route_id: str):
try:
supabase_admin.table("routes").delete().eq("id", route_id).execute()
except Exception as e:
raise HTTPException(400, f"Error al borrar la ruta: {e}")
return None
# ── Units ─────────────────────────────────────────────────────────────────────
@router.get("/units", response_model=list[AdminUnit])
def list_units():
res = supabase_admin.table("units").select("id, plate, status").execute()
return [AdminUnit(**r) for r in (res.data or [])]
@router.post("/units", response_model=AdminUnit, status_code=201)
def create_unit(body: AdminUnitCreate):
try:
res = (
supabase_admin.table("units")
.insert(body.model_dump(exclude_none=True))
.execute()
)
except Exception as e:
raise HTTPException(400, f"Error al crear la unidad: {e}")
row = (res.data or [None])[0]
if not row:
raise HTTPException(500, "Supabase no devolvió la fila creada")
return AdminUnit(**row)
@router.patch("/units/{unit_id}", response_model=AdminUnit)
def update_unit(unit_id: int, body: AdminUnitUpdate):
payload = body.model_dump(exclude_none=True)
if not payload:
raise HTTPException(400, "Sin cambios")
try:
supabase_admin.table("units").update(payload).eq("id", unit_id).execute()
except Exception as e:
raise HTTPException(400, f"Error al actualizar la unidad: {e}")
# ── ALERTA EN VIVO: Notificar a ciudadanos si la unidad se inhabilita o va a taller ──
if body.status in ["inactive", "maintenance"]:
try:
routes_res = supabase_admin.table("routes").select("id").eq("truck_id", unit_id).execute()
for route in (routes_res.data or []):
route_id = route["id"]
if route_id == "RUTA-01":
msg_title = "Aviso de reprogramación ⚠️"
msg_body = "El camión de tu ruta ha presentado una falla. El servicio matutino se suspende y tu recolección se retomará en la tarde. Por favor, resguarda tus residuos."
else:
msg_title = "Aviso sobre tu recolección ⚠️"
msg_body = "El camión de tu ruta ha sido enviado a taller o inhabilitado. El servicio podría sufrir retrasos. Trabajamos para reasignar la ruta."
notifications.send_to_topic(
f"topic_{route_id}",
{
"title": msg_title,
"body": msg_body,
}
)
except Exception as e:
print(f"Error al enviar alerta ciudadana por falla de unidad: {e}")
# ── Salvavidas Anti-Desconexión para el Hackathon ──
try:
res = (
supabase_admin.table("units")
.select("id, plate, status")
.eq("id", unit_id)
.maybe_single()
.execute()
)
except Exception as e:
print(f"Reintentando lectura por micro-corte de red en Supabase: {e}")
res = (
supabase_admin.table("units")
.select("id, plate, status")
.eq("id", unit_id)
.maybe_single()
.execute()
)
if not res.data:
raise HTTPException(404, "Unidad no encontrada")
return AdminUnit(**res.data)
@router.delete("/units/{unit_id}", status_code=204)
def delete_unit(unit_id: int):
try:
supabase_admin.table("units").delete().eq("id", unit_id).execute()
except Exception as e:
raise HTTPException(400, f"Error al borrar la unidad: {e}")
return None
# ── Drivers ───────────────────────────────────────────────────────────────────
def _hydrate_driver(row: dict, users_map: dict[str, dict], units_map: dict[int, dict]) -> AdminDriver:
user_id = str(row.get("user_id"))
unit_id: Optional[int] = row.get("unit_id")
u = users_map.get(user_id, {})
unit = units_map.get(unit_id, {}) if unit_id is not None else {}
return AdminDriver(
id=str(row["id"]),
user_id=user_id,
user_name=u.get("name"),
user_email=u.get("email"),
unit_id=unit_id,
plate=unit.get("plate"),
)
def _drivers_context() -> tuple[dict[str, dict], dict[int, dict]]:
users_res = supabase_admin.table("users").select("id, name").execute()
auth_map = _auth_user_map()
users_map: dict[str, dict] = {
str(u["id"]): {
"name": u.get("name"),
"email": auth_map.get(str(u["id"]), {}).get("email"),
}
for u in (users_res.data or [])
}
units_res = supabase_admin.table("units").select("id, plate").execute()
units_map: dict[int, dict] = {u["id"]: u for u in (units_res.data or [])}
return users_map, units_map
@router.get("/drivers", response_model=list[AdminDriver])
def list_drivers():
res = supabase_admin.table("drivers").select("id, user_id, unit_id").execute()
users_map, units_map = _drivers_context()
return [_hydrate_driver(r, users_map, units_map) for r in (res.data or [])]
@router.post("/drivers", response_model=AdminDriver, status_code=201)
def create_driver(body: AdminDriverCreate):
try:
res = (
supabase_admin.table("drivers")
.insert(body.model_dump(exclude_none=True))
.execute()
)
except Exception as e:
raise HTTPException(400, f"Error al crear el conductor: {e}")
row = (res.data or [None])[0]
if not row:
raise HTTPException(500, "Supabase no devolvió la fila creada")
users_map, units_map = _drivers_context()
return _hydrate_driver(row, users_map, units_map)
@router.patch("/drivers/{driver_id}", response_model=AdminDriver)
def update_driver(driver_id: str, body: AdminDriverUpdate):
payload = body.model_dump(exclude_none=True)
if not payload:
raise HTTPException(400, "Sin cambios")
try:
supabase_admin.table("drivers").update(payload).eq("id", driver_id).execute()
except Exception as e:
raise HTTPException(400, f"Error al actualizar el conductor: {e}")
res = (
supabase_admin.table("drivers")
.select("id, user_id, unit_id")
.eq("id", driver_id)
.maybe_single()
.execute()
)
if not res.data:
raise HTTPException(404, "Conductor no encontrado")
users_map, units_map = _drivers_context()
return _hydrate_driver(res.data, users_map, units_map)
@router.delete("/drivers/{driver_id}", status_code=204)
def delete_driver(driver_id: str):
try:
supabase_admin.table("drivers").delete().eq("id", driver_id).execute()
except Exception as e:
raise HTTPException(400, f"Error al borrar el conductor: {e}")
return None
# ── Incidents por unidad ──────────────────────────────────────────────────────
_INCIDENT_CATEGORIES = {
"derrame",
"dano_propiedad",
"conducta",
"no_recoleccion",
"otro",
}
def _route_for_unit(unit_id: int) -> Optional[str]:
"""Devuelve el route_id que actualmente tiene asignada la unidad
(vía routes.truck_id), o None si no está asignada a ninguna ruta."""
try:
res = (
supabase_admin.table("routes")
.select("id")
.eq("truck_id", unit_id)
.limit(1)
.execute()
)
rows = res.data or []
return rows[0]["id"] if rows else None
except Exception:
return None
def _driver_for_unit(unit_id: int, users_map: dict[str, dict]) -> Optional[str]:
"""Devuelve el nombre del chofer asignado a la unidad (drivers.unit_id)."""
try:
res = (
supabase_admin.table("drivers")
.select("user_id")
.eq("unit_id", unit_id)
.limit(1)
.execute()
)
rows = res.data or []
if not rows:
return None
user_id = str(rows[0].get("user_id"))
return (users_map.get(user_id) or {}).get("name")
except Exception:
return None
def _serialize_incident(row: dict, route_id: Optional[str], driver_name: Optional[str]) -> dict:
"""Da la forma que espera el cliente Flutter (admin_incident.dart)."""
return {
"id": str(row.get("id")),
"unit_id": row.get("unit_id"),
"route_id": route_id,
"type": row.get("type"),
"description": row.get("description"),
"driver_name": driver_name,
"status": row.get("status") or "open",
"photo_url": row.get("photo_url"),
"created_at": row.get("created_at") and str(row["created_at"]),
}
@router.get("/units/{unit_id}/incidents")
def list_unit_incidents(unit_id: int):
"""Reportes ciudadanos asociados a esta unidad, más reciente primero."""
print(f"[admin] GET /admin/units/{unit_id}/incidents")
# Verifica que la unidad exista. Usamos limit(1) (no maybe_single)
# porque maybe_single() lanza APIError cuando no hay filas en supabase-py 2.x.
try:
unit_res = (
supabase_admin.table("units")
.select("id")
.eq("id", unit_id)
.limit(1)
.execute()
)
except Exception as e:
traceback.print_exc()
raise HTTPException(500, f"verificar unidad: {type(e).__name__}: {e}")
if not (unit_res.data or []):
raise HTTPException(404, f"Unidad {unit_id} no existe en la base de datos")
try:
res = (
supabase_admin.table("incidents")
.select("id, unit_id, user_id, type, description, status, photo_url, created_at")
.eq("unit_id", unit_id)
.order("created_at", desc=True)
.execute()
)
rows = res.data or []
except Exception as e:
traceback.print_exc()
if "Could not find the table" in str(e):
print("⚠️ ADVERTENCIA: La tabla 'incidents' no existe en Supabase.")
rows = []
else:
raise HTTPException(500, f"listar incidencias: {type(e).__name__}: {e}")
print(f"[admin] incidents para unit {unit_id}: {len(rows)} filas")
# Hidratación route_id y driver_name (no debe romper la respuesta).
try:
route_id = _route_for_unit(unit_id)
except Exception as e:
traceback.print_exc()
route_id = None
try:
users_res = supabase_admin.table("users").select("id, name").execute()
users_map: dict[str, dict] = {
str(u["id"]): {"name": u.get("name")} for u in (users_res.data or [])
}
except Exception as e:
traceback.print_exc()
users_map = {}
try:
driver_name = _driver_for_unit(unit_id, users_map)
except Exception as e:
traceback.print_exc()
driver_name = None
return [_serialize_incident(r, route_id, driver_name) for r in rows]
@router.post("/units/{unit_id}/incidents", status_code=201)
def create_unit_incident(
unit_id: int,
body: dict,
current_user: dict = Depends(get_current_user),
):
"""Crea una incidencia para esta unidad. El admin queda como reporter."""
category = (body.get("type") or body.get("category") or "").strip()
description = (body.get("description") or "").strip()
if category not in _INCIDENT_CATEGORIES:
raise HTTPException(
400,
f"Categoría inválida. Use una de: {sorted(_INCIDENT_CATEGORIES)}",
)
if len(description) < 3:
# La tabla requiere description NOT NULL y un mínimo razonable.
description = description or category
# La unidad debe existir
try:
unit_res = (
supabase_admin.table("units")
.select("id")
.eq("id", unit_id)
.limit(1)
.execute()
)
except Exception as e:
raise HTTPException(500, f"Error al verificar la unidad: {e}")
if not (unit_res.data or []):
raise HTTPException(404, f"Unidad {unit_id} no existe en la base de datos")
payload = {
"user_id": current_user["user_id"],
"unit_id": unit_id,
"type": category,
"description": description,
}
try:
res = supabase_admin.table("incidents").insert(payload).execute()
except Exception as e:
raise HTTPException(500, f"Error al crear la incidencia: {e}")
row = (res.data or [None])[0]
if not row:
raise HTTPException(500, "Supabase no devolvió la fila creada")
route_id = _route_for_unit(unit_id)
users_res = supabase_admin.table("users").select("id, name").execute()
users_map: dict[str, dict] = {
str(u["id"]): {"name": u.get("name")} for u in (users_res.data or [])
}
driver_name = _driver_for_unit(unit_id, users_map)
return _serialize_incident(row, route_id, driver_name)