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