simulacion de estados y flujo de notificacion, modificacion de estilos en todas las vistas

This commit is contained in:
shinra32
2026-05-23 07:08:49 -06:00
parent ca076607c7
commit 92f570294a
43 changed files with 4335 additions and 2035 deletions

View File

@@ -96,3 +96,61 @@ def get_address(
raise HTTPException(status_code=403, detail="No tienes acceso a este domicilio")
return address
@router.get("/{address_id}/unit")
def get_address_unit(
address_id: str,
current_user: dict = Depends(get_current_user),
):
"""
Devuelve la unidad asignada al domicilio (a través de su ruta).
Cadena: addresses.route_id → routes.truck_id → units.
El ciudadano sólo puede consultar sus propios domicilios.
"""
addr_res = (
supabase_admin.table("addresses")
.select("id, user_id, route_id")
.eq("id", address_id)
.maybe_single()
.execute()
)
if not addr_res.data:
raise HTTPException(status_code=404, detail="Domicilio no encontrado")
address = addr_res.data
if current_user["role"] != "admin" and address["user_id"] != current_user["user_id"]:
raise HTTPException(status_code=403, detail="No tienes acceso a este domicilio")
route_id = address.get("route_id")
if not route_id:
raise HTTPException(
status_code=404,
detail="El domicilio no tiene una ruta asociada",
)
route_res = (
supabase_admin.table("routes")
.select("id, truck_id")
.eq("id", route_id)
.maybe_single()
.execute()
)
if not route_res.data or route_res.data.get("truck_id") is None:
raise HTTPException(
status_code=404,
detail="La ruta asignada no tiene unidad activa",
)
truck_id = route_res.data["truck_id"]
unit_res = (
supabase_admin.table("units")
.select("id, plate, status")
.eq("id", truck_id)
.maybe_single()
.execute()
)
if not unit_res.data:
raise HTTPException(status_code=404, detail="Unidad no encontrada")
return unit_res.data

View File

@@ -6,7 +6,7 @@ Operan directamente contra Supabase (RLS bypaseado por service_role).
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException
from app.core.deps import require_role
from app.core.deps import get_current_user, require_role
from app.core.supabase_client import supabase_admin
from app.schemas.admin import (
AdminUser,
@@ -375,3 +375,156 @@ def delete_driver(driver_id: str):
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("category"),
"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."""
# Verifica que la unidad exista (devuelve 404 si no)
unit_res = (
supabase_admin.table("units")
.select("id")
.eq("id", unit_id)
.maybe_single()
.execute()
)
if not unit_res.data:
raise HTTPException(404, "Unidad no encontrada")
try:
res = (
supabase_admin.table("incidents")
.select("id, unit_id, user_id, category, description, status, photo_url, created_at")
.eq("unit_id", unit_id)
.order("created_at", desc=True)
.execute()
)
except Exception as e:
raise HTTPException(500, f"Error al listar incidencias: {e}")
rows = res.data or []
route_id = _route_for_unit(unit_id)
# Pre-cargar el mapa de nombres una sola vez
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(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
unit_res = (
supabase_admin.table("units")
.select("id")
.eq("id", unit_id)
.maybe_single()
.execute()
)
if not unit_res.data:
raise HTTPException(404, "Unidad no encontrada")
payload = {
"user_id": current_user["user_id"],
"unit_id": unit_id,
"category": 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)

View File

@@ -0,0 +1,93 @@
"""Endpoint de retroalimentación ciudadana.
Reglas:
- El user_id se toma del token (nunca del body).
- target_unit_id es la UNIDAD (camión), no el chofer.
- La inserción usa supabase_admin (service_role) que bypassea RLS.
"""
from fastapi import APIRouter, Depends, HTTPException
from app.core.deps import get_current_user
from app.core.supabase_client import supabase_admin
from app.schemas.feedback import FeedbackCreate, FeedbackOut
router = APIRouter(prefix="/feedback", tags=["feedback"])
@router.post("", response_model=FeedbackOut, status_code=201)
def create_feedback(
body: FeedbackCreate,
current_user: dict = Depends(get_current_user),
):
user_id = current_user["user_id"]
# Validar que la unidad exista si se envió
if body.target_unit_id is not None:
try:
unit = (
supabase_admin.table("units")
.select("id")
.eq("id", body.target_unit_id)
.limit(1)
.execute()
)
except Exception as e:
raise HTTPException(status_code=500, detail=f"Error al validar unidad: {e}")
if not unit.data:
raise HTTPException(status_code=400, detail="Unidad inexistente")
payload = {
"user_id": user_id,
"address_id": body.address_id,
"type": body.type,
"target_unit_id": body.target_unit_id,
"message": body.message,
"rating": body.rating,
}
try:
res = supabase_admin.table("feedback").insert(payload).execute()
except Exception as e:
raise HTTPException(status_code=500, detail=f"Error al guardar feedback: {e}")
row = (res.data or [{}])[0]
return FeedbackOut(
id=str(row.get("id")),
user_id=str(row.get("user_id")),
address_id=row.get("address_id") and str(row["address_id"]),
type=row.get("type") or body.type,
target_unit_id=row.get("target_unit_id"),
message=row.get("message"),
rating=row.get("rating"),
created_at=row.get("created_at") and str(row["created_at"]),
)
@router.get("/me", response_model=list[FeedbackOut])
def my_feedback(current_user: dict = Depends(get_current_user)):
try:
res = (
supabase_admin.table("feedback")
.select("*")
.eq("user_id", current_user["user_id"])
.order("created_at", desc=True)
.execute()
)
except Exception as e:
raise HTTPException(status_code=500, detail=f"Error al listar feedback: {e}")
out: list[FeedbackOut] = []
for row in res.data or []:
out.append(
FeedbackOut(
id=str(row.get("id")),
user_id=str(row.get("user_id")),
address_id=row.get("address_id") and str(row["address_id"]),
type=row.get("type") or "",
target_unit_id=row.get("target_unit_id"),
message=row.get("message"),
rating=row.get("rating"),
created_at=row.get("created_at") and str(row["created_at"]),
)
)
return out

View File

@@ -0,0 +1,28 @@
"""Esquemas Pydantic para retroalimentación ciudadana."""
from typing import Optional
from pydantic import BaseModel, Field
class FeedbackCreate(BaseModel):
"""Cuerpo enviado por el ciudadano al crear una queja/sugerencia.
Importante: NUNCA se acepta driver_id; solo el número de la unidad.
"""
address_id: Optional[str] = None # UUID
type: str = Field(min_length=1, max_length=40)
rating: int = Field(ge=1, le=5)
# Llega como string ("101") desde Flutter; Pydantic lo coacciona a int.
target_unit_id: Optional[int] = None
message: Optional[str] = Field(default=None, max_length=1000)
class FeedbackOut(BaseModel):
id: str
user_id: str
address_id: Optional[str] = None
type: str
target_unit_id: Optional[int] = None
message: Optional[str] = None
rating: Optional[int] = None
created_at: Optional[str] = None

View File

@@ -1,5 +1,7 @@
import os
import firebase_admin
import urllib.parse
import urllib.request
from firebase_admin import credentials, messaging
_firebase_initialized = False
@@ -27,9 +29,13 @@ def send_to_topic(topic: str, payload: dict):
"""Envía una notificación push a todos los dispositivos suscritos a un topic (ej. RUTA-01)."""
title = payload.get("title", "")
body = payload.get("body", "")
# `data` viaja como Dict[str, str] en FCM; permite que el cliente
# clasifique el evento (event, routeId) en notifications_screen.dart.
raw_data = payload.get("data") or {}
data: dict[str, str] = {str(k): str(v) for k, v in raw_data.items() if v is not None}
if not _firebase_initialized:
print(f"[MOCK PUSH] -> Topic: {topic} | Título: '{title}' | Mensaje: '{body}'")
print(f"[MOCK PUSH] -> Topic: {topic} | Título: '{title}' | Mensaje: '{body}' | Data: {data}")
return
try:
@@ -38,9 +44,29 @@ def send_to_topic(topic: str, payload: dict):
title=title,
body=body,
),
data=data or None,
topic=topic,
)
response = messaging.send(message)
print(f"Push enviado al topic '{topic}' exitosamente. MessageID: {response}")
except Exception as e:
print(f"Error al enviar push al topic '{topic}': {e}")
print(f"Error al enviar push al topic '{topic}': {e}")
def send_whatsapp_alert(phone: str, message: str):
"""
Envía un WhatsApp usando la API gratuita de CallMeBot.
"""
print("\n" + "="*50)
print(f"🟢 [WHATSAPP TRIGGER] Preparando mensaje para {phone}")
print(f"💬 Mensaje: {message}")
print("="*50 + "\n")
# REEMPLAZA ESTO POR EL CÓDIGO QUE TE DIO EL BOT EN WHATSAPP:
apikey = "6756808" # <--- BORRA "TU_API_KEY_AQUI" Y PON TU CÓDIGO REAL AQUÍ
safe_msg = urllib.parse.quote(message)
url = f"https://api.callmebot.com/whatsapp.php?phone={phone}&text={safe_msg}&apikey={apikey}"
try:
urllib.request.urlopen(url, timeout=5)
except Exception as e:
print(f"Error enviando WhatsApp real: {e}")

View File

@@ -1,5 +1,6 @@
import json
import os
import time
from typing import Dict, List, Optional
from app.services import notifications
@@ -58,34 +59,65 @@ def _find_notif(event_name: str) -> Optional[Dict]:
def tick() -> List[Dict]:
"""Avanza todas las rutas en memoria (pos 1..8) y devuelve eventos disparados."""
global ESTADO, LAST_EVENTS
"""Avanza todas las rutas en memoria (pos 18) y devuelve eventos disparados.
Al llegar a pos 8, reinicia la ruta para que la demo sea un ciclo continuo."""
global ESTADO, STATUS, LAST_EVENTS
events = []
for route_id, pos in list(ESTADO.items()):
if pos < 8:
antes = pos
ahora = pos + 1
ESTADO[route_id] = ahora
evt = None
if antes == 1 and ahora == 2:
evt = "ROUTE_START"
elif ahora == 4:
evt = "TRUCK_PROXIMITY"
elif ahora == 8:
evt = "ROUTE_COMPLETED"
# Ciclo: cuando la ruta completó, reiniciar en el siguiente tick
if pos >= 8:
ESTADO[route_id] = 1
STATUS[route_id] = "PENDIENTE"
print(f"[SIM RESET] {route_id} reiniciado para nuevo ciclo.")
continue
if evt:
notif = _find_notif(evt)
payload = notif.get("pushPayload") if notif else {"title": evt, "body": ""}
simulated = {"routeId": route_id, "event": evt, "payload": payload}
events.append(simulated)
LAST_EVENTS.append(simulated)
# Enviar push vía servicio de notificaciones (FCM) o mock
topic = f"topic_{route_id}"
try:
notifications.send_to_topic(topic, payload)
except Exception:
print(f"[SIM PUSH FAIL] {route_id} -> {evt}: {payload.get('title')} - {payload.get('body')}")
antes = pos
ahora = pos + 1
ESTADO[route_id] = ahora
# Actualizar STATUS según la nueva posición
if ahora == 2:
STATUS[route_id] = "en_ruta"
elif ahora == 8:
STATUS[route_id] = "completada"
evt = None
if antes == 1 and ahora == 2:
evt = "ROUTE_START"
elif ahora == 4:
evt = "TRUCK_PROXIMITY"
elif ahora == 8:
evt = "ROUTE_COMPLETED"
if evt:
notif = _find_notif(evt)
payload = notif.get("pushPayload") if notif else {"title": evt, "body": ""}
# Adjuntar event + routeId en `data` para que el cliente Flutter
# los clasifique (notifications_screen.dart usa msg.data['event']).
payload = {
**payload,
"data": {
**(payload.get("data") or {}),
"event": evt,
"routeId": route_id,
},
}
simulated = {"routeId": route_id, "event": evt, "payload": payload}
events.append(simulated)
LAST_EVENTS.append(simulated)
topic = f"topic_{route_id}"
try:
notifications.send_to_topic(topic, payload)
# ── WHATSAPP: Se dispara cuando el camión está cerca (posición 4) ──
if evt == "TRUCK_PROXIMITY":
# Para evitar saturar el bot y bloquear el servidor,
# enviamos el WhatsApp de demostración solo para la primera ruta.
if route_id == "RUTA-01":
msg = f"¡Hola! Soy Eco 🍃. El camión recolector de tu ruta ({route_id}) está a menos de 15 minutos. ¡Saca la basura! ♻️"
notifications.send_whatsapp_alert("5214131060699", msg)
except Exception:
print(f"[SIM PUSH FAIL] {route_id} -> {evt}: {payload.get('title')} - {payload.get('body')}")
return events