Files
2026-05-23 10:19:24 -06:00

1761 lines
78 KiB
Python

"""
===============================================================
SISTEMA DE NOTIFICACIÓN PRIVADA DE RECOLECCIÓN DE RESIDUOS
Backend MVP - Hackathon 24h (v2 — mejorado post-hackathon)
===============================================================
Stack: FastAPI + SQLite (SQLAlchemy) + Firebase Admin SDK
MEJORAS v2:
- Passwords hasheadas con bcrypt (antes: sha256 plano)
- Migración automática de hashes legacy sha256 → bcrypt
- Endpoint /api/dashboard: estadísticas globales de rutas
- Endpoint /api/rutas/{route_id}/posiciones: historial GPS
- Endpoint /api/rutas/resumen: vista rápida de todos los camiones
- Endpoint /api/estadisticas/colonias: colonias más activas
CÓMO CORRER:
pip install fastapi uvicorn sqlalchemy firebase-admin bcrypt
uvicorn main:app --reload --port 8000
===============================================================
"""
from fastapi import FastAPI, HTTPException, Depends, Query
from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy import create_engine, Column, Integer, String, ForeignKey, inspect, text
from sqlalchemy.orm import declarative_base, sessionmaker, Session, relationship
from pydantic import BaseModel
from typing import Optional, List, Dict
from datetime import datetime, timezone, timedelta
import bcrypt
import hashlib
import logging
from analytics import generar_reporte_completo
from analytics_real import generar_reporte_completo as generar_reporte_real
# ---------------------------------------------------------------
# LOGGING
# ---------------------------------------------------------------
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
# ===============================================================
# SEGURIDAD DE CONTRASEÑAS — bcrypt
#
# POR QUÉ bcrypt en vez de sha256:
# - sha256 es rápido → fácil de atacar con fuerza bruta
# - bcrypt incluye salt automático → dos hashes del mismo
# password son distintos (evita ataques de rainbow table)
# - El "work factor" (12) hace cada verificación ~250ms,
# tolerable para usuarios, costoso para atacantes.
#
# MIGRACIÓN LEGACY:
# Los usuarios registrados antes de esta versión tienen
# password_hash en sha256. Al hacer login, si el hash
# antiguo coincide, re-hasheamos con bcrypt y guardamos.
# ===============================================================
def hash_password(password: str) -> str:
"""Genera un hash bcrypt del password. Incluye salt automático."""
return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt(rounds=12)).decode("utf-8")
def _es_hash_legacy(password_hash: str) -> bool:
"""Detecta si el hash guardado es sha256 (hex de 64 chars) en vez de bcrypt."""
return len(password_hash) == 64 and not password_hash.startswith("$2b$")
def verify_password(password: str, password_hash: str) -> bool:
if not password_hash: # ← hash vacío o None → fallo seguro
return False
if _es_hash_legacy(password_hash):
legacy_hash = hashlib.sha256(password.encode("utf-8")).hexdigest()
return legacy_hash == password_hash
try:
return bcrypt.checkpw(password.encode("utf-8"), password_hash.encode("utf-8"))
except ValueError: # ← salt inválido por cualquier corrupción
return False
# ---------------------------------------------------------------
# BASE DE DATOS (SQLite — hackathon mode)
# ---------------------------------------------------------------
DATABASE_URL = "sqlite:///./hackathon.db"
engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})
SessionLocal = sessionmaker(bind=engine, autocommit=False, autoflush=False)
Base = declarative_base()
# ===============================================================
# MODELOS ORM
# ===============================================================
class Usuario(Base):
__tablename__ = "usuarios"
id = Column(Integer, primary_key=True, index=True)
nombre = Column(String, nullable=False)
email = Column(String, nullable=False, unique=True, index=True)
password_hash = Column(String, nullable=False)
fcm_token = Column(String, nullable=True)
direcciones = relationship("Domicilio", back_populates="usuario", cascade="all, delete-orphan")
class Domicilio(Base):
__tablename__ = "domicilios"
id = Column(Integer, primary_key=True, index=True)
usuario_id = Column(Integer, ForeignKey("usuarios.id"))
colonia = Column(String, nullable=False)
direccion = Column(String, nullable=False)
route_id = Column(String, nullable=False) # Interno, no se expone al cliente
usuario = relationship("Usuario", back_populates="direcciones")
# ================================================================
# PEGA ESTE BLOQUE EN main.py JUSTO DESPUÉS DE LA CLASE Domicilio
# (después de la línea: usuario = relationship(...))
# ================================================================
class RegistroRecoleccion(Base):
"""
Registro automático cada vez que una ruta avanza o se completa.
Es la fuente de verdad para el módulo de analytics.
"""
__tablename__ = "registros_recoleccion"
id = Column(Integer, primary_key=True, index=True)
fecha = Column(String, nullable=False) # YYYY-MM-DD
hora = Column(String, nullable=False) # HH:MM:SS
ruta_id = Column(String, nullable=False, index=True)
colonia = Column(String, nullable=False, index=True)
position_id = Column(Integer, nullable=False) # 1..8
evento = Column(String, nullable=False) # INICIO, AVANCE, COMPLETADO
volumen_kg = Column(Integer, nullable=True) # estimado por posición
tiempo_min = Column(Integer, nullable=True) # minutos desde inicio de ruta
class ReporteUsuario(Base):
"""
Reportes manuales enviados por los ciudadanos.
Permiten enriquecer el análisis con datos cualitativos.
"""
__tablename__ = "reportes_usuarios"
id = Column(Integer, primary_key=True, index=True)
usuario_id = Column(Integer, ForeignKey("usuarios.id"), nullable=False)
fecha = Column(String, nullable=False) # YYYY-MM-DD
hora = Column(String, nullable=False) # HH:MM:SS
colonia = Column(String, nullable=False)
ruta_id = Column(String, nullable=False)
tipo = Column(String, nullable=False) # VOLUMEN_ALTO, CAMION_NO_PASO, BASURA_FUERA_HORARIO, OTRO
descripcion = Column(String, nullable=True)
foto_url = Column(String, nullable=True) # futuro: S3/Firebase Storage
estado = Column(String, nullable=False, default="PENDIENTE") # PENDIENTE, ATENDIDO
usuario = relationship("Usuario")
# ---------------------------------------------------------------
# MIGRACIÓN AUTOMÁTICA DE ESQUEMA
# Añade columnas nuevas si la DB existe desde una versión anterior.
# ---------------------------------------------------------------
inspector = inspect(engine)
if inspector.has_table("usuarios"):
cols = [c["name"] for c in inspector.get_columns("usuarios")]
with engine.connect() as conn:
if "email" not in cols:
conn.execute(text("ALTER TABLE usuarios ADD COLUMN email TEXT"))
if "password_hash" not in cols:
conn.execute(text("ALTER TABLE usuarios ADD COLUMN password_hash TEXT NOT NULL DEFAULT ''"))
conn.commit()
if inspector.has_table("domicilios"):
cols = [c["name"] for c in inspector.get_columns("domicilios")]
with engine.connect() as conn:
if "direccion" not in cols:
conn.execute(text("ALTER TABLE domicilios ADD COLUMN direccion TEXT NOT NULL DEFAULT ''"))
conn.commit()
Base.metadata.create_all(bind=engine)
# ===============================================================
# DATOS MOCKEADOS
# ===============================================================
# ===============================================================
# PEGA ESTO EN main.py REEMPLAZANDO DESDE "ROUTE_DATA" HASTA
# "ROUTE_STATE" (inclusive el bloque de inicialización de ROUTE_STATE)
# ===============================================================
ROUTE_DATA = [
{
"route_id": "RUTA-01", "name": "Zona Centro - Las Arboledas", "truck_id": 101, "status": "EN_RUTA",
"positions": [
{"positionId": 1, "lat": 20.5111, "lng": -100.9037, "speed": 0, "timestamp": "2026-05-22T06:00:00Z"},
{"positionId": 2, "lat": 20.5185, "lng": -100.8450, "speed": 45, "timestamp": "2026-05-22T06:12:00Z"},
{"positionId": 3, "lat": 20.5215, "lng": -100.8142, "speed": 22, "timestamp": "2026-05-22T06:25:00Z"},
{"positionId": 4, "lat": 20.5212, "lng": -100.8175, "speed": 15, "timestamp": "2026-05-22T06:38:00Z"},
{"positionId": 5, "lat": 20.5210, "lng": -100.8210, "speed": 0, "timestamp": "2026-05-22T06:50:00Z"},
{"positionId": 6, "lat": 20.5235, "lng": -100.8212, "speed": 18, "timestamp": "2026-05-22T07:05:00Z"},
{"positionId": 7, "lat": 20.5260, "lng": -100.8215, "speed": 20, "timestamp": "2026-05-22T07:18:00Z"},
{"positionId": 8, "lat": 20.5111, "lng": -100.9037, "speed": 40, "timestamp": "2026-05-22T07:40:00Z"},
],
},
{
"route_id": "RUTA-02", "name": "Sector Norte - Av. Tecnológico", "truck_id": 102, "status": "EN_RUTA",
"positions": [
{"positionId": 1, "lat": 20.5111, "lng": -100.9037, "speed": 0, "timestamp": "2026-05-22T06:05:00Z"},
{"positionId": 2, "lat": 20.5280, "lng": -100.8135, "speed": 38, "timestamp": "2026-05-22T06:18:00Z"},
{"positionId": 3, "lat": 20.5410, "lng": -100.8130, "speed": 25, "timestamp": "2026-05-22T06:30:00Z"},
{"positionId": 4, "lat": 20.5445, "lng": -100.8132, "speed": 12, "timestamp": "2026-05-22T06:45:00Z"},
{"positionId": 5, "lat": 20.5480, "lng": -100.8135, "speed": 0, "timestamp": "2026-05-22T06:58:00Z"},
{"positionId": 6, "lat": 20.5515, "lng": -100.8138, "speed": 15, "timestamp": "2026-05-22T07:10:00Z"},
{"positionId": 7, "lat": 20.5540, "lng": -100.8110, "speed": 22, "timestamp": "2026-05-22T07:25:00Z"},
{"positionId": 8, "lat": 20.5111, "lng": -100.9037, "speed": 45, "timestamp": "2026-05-22T07:50:00Z"},
],
},
{
"route_id": "RUTA-03", "name": "Sector Poniente - San Juanico", "truck_id": 103, "status": "EN_RUTA",
"positions": [
{"positionId": 1, "lat": 20.5111, "lng": -100.9037, "speed": 0, "timestamp": "2026-05-22T06:10:00Z"},
{"positionId": 2, "lat": 20.5250, "lng": -100.8510, "speed": 42, "timestamp": "2026-05-22T06:20:00Z"},
{"positionId": 3, "lat": 20.5290, "lng": -100.8320, "speed": 20, "timestamp": "2026-05-22T06:35:00Z"},
{"positionId": 4, "lat": 20.5315, "lng": -100.8355, "speed": 15, "timestamp": "2026-05-22T06:48:00Z"},
{"positionId": 5, "lat": 20.5340, "lng": -100.8390, "speed": 0, "timestamp": "2026-05-22T07:00:00Z"},
{"positionId": 6, "lat": 20.5362, "lng": -100.8425, "speed": 10, "timestamp": "2026-05-22T07:15:00Z"},
{"positionId": 7, "lat": 20.5330, "lng": -100.8430, "speed": 18, "timestamp": "2026-05-22T07:28:00Z"},
{"positionId": 8, "lat": 20.5111, "lng": -100.9037, "speed": 35, "timestamp": "2026-05-22T07:45:00Z"},
],
},
{
"route_id": "RUTA-04", "name": "Oriente - Los Olivos", "truck_id": 104, "status": "EN_RUTA",
"positions": [
{"positionId": 1, "lat": 20.5111, "lng": -100.9037, "speed": 0, "timestamp": "2026-05-22T06:15:00Z"},
{"positionId": 2, "lat": 20.5260, "lng": -100.8010, "speed": 45, "timestamp": "2026-05-22T06:30:00Z"},
{"positionId": 3, "lat": 20.5295, "lng": -100.7890, "speed": 24, "timestamp": "2026-05-22T06:45:00Z"},
{"positionId": 4, "lat": 20.5320, "lng": -100.7850, "speed": 12, "timestamp": "2026-05-22T06:58:00Z"},
{"positionId": 5, "lat": 20.5350, "lng": -100.7790, "speed": 0, "timestamp": "2026-05-22T07:12:00Z"},
{"positionId": 6, "lat": 20.5310, "lng": -100.7760, "speed": 15, "timestamp": "2026-05-22T07:25:00Z"},
{"positionId": 7, "lat": 20.5270, "lng": -100.7820, "speed": 26, "timestamp": "2026-05-22T07:38:00Z"},
{"positionId": 8, "lat": 20.5111, "lng": -100.9037, "speed": 48, "timestamp": "2026-05-22T07:58:00Z"},
],
},
{
"route_id": "RUTA-05", "name": "Sector Sur - Rancho Seco", "truck_id": 105, "status": "EN_RUTA",
"positions": [
{"positionId": 1, "lat": 20.5111, "lng": -100.9037, "speed": 0, "timestamp": "2026-05-22T06:20:00Z"},
{"positionId": 2, "lat": 20.5050, "lng": -100.8620, "speed": 35, "timestamp": "2026-05-22T06:32:00Z"},
{"positionId": 3, "lat": 20.5020, "lng": -100.8350, "speed": 22, "timestamp": "2026-05-22T06:45:00Z"},
{"positionId": 4, "lat": 20.4995, "lng": -100.8210, "speed": 14, "timestamp": "2026-05-22T06:58:00Z"},
{"positionId": 5, "lat": 20.4970, "lng": -100.8150, "speed": 0, "timestamp": "2026-05-22T07:10:00Z"},
{"positionId": 6, "lat": 20.5010, "lng": -100.8120, "speed": 16, "timestamp": "2026-05-22T07:22:00Z"},
{"positionId": 7, "lat": 20.5060, "lng": -100.8160, "speed": 25, "timestamp": "2026-05-22T07:35:00Z"},
{"positionId": 8, "lat": 20.5111, "lng": -100.9037, "speed": 40, "timestamp": "2026-05-22T07:55:00Z"},
],
},
{
"route_id": "RUTA-06", "name": "Norte Extremo - Rumbos de Roque", "truck_id": 106, "status": "EN_RUTA",
"positions": [
{"positionId": 1, "lat": 20.5111, "lng": -100.9037, "speed": 0, "timestamp": "2026-05-22T06:00:00Z"},
{"positionId": 2, "lat": 20.5380, "lng": -100.8380, "speed": 40, "timestamp": "2026-05-22T06:15:00Z"},
{"positionId": 3, "lat": 20.5610, "lng": -100.8370, "speed": 30, "timestamp": "2026-05-22T06:30:00Z"},
{"positionId": 4, "lat": 20.5750, "lng": -100.8360, "speed": 15, "timestamp": "2026-05-22T06:45:00Z"},
{"positionId": 5, "lat": 20.5820, "lng": -100.8350, "speed": 0, "timestamp": "2026-05-22T07:00:00Z"},
{"positionId": 6, "lat": 20.5780, "lng": -100.8310, "speed": 20, "timestamp": "2026-05-22T07:15:00Z"},
{"positionId": 7, "lat": 20.5650, "lng": -100.8320, "speed": 28, "timestamp": "2026-05-22T07:30:00Z"},
{"positionId": 8, "lat": 20.5111, "lng": -100.9037, "speed": 45, "timestamp": "2026-05-22T07:55:00Z"},
],
},
{
"route_id": "RUTA-07", "name": "Nororiente - Ciudad Industrial", "truck_id": 107, "status": "EN_RUTA",
"positions": [
{"positionId": 1, "lat": 20.5111, "lng": -100.9037, "speed": 0, "timestamp": "2026-05-22T06:10:00Z"},
{"positionId": 2, "lat": 20.5350, "lng": -100.8050, "speed": 44, "timestamp": "2026-05-22T06:24:00Z"},
{"positionId": 3, "lat": 20.5450, "lng": -100.7950, "speed": 25, "timestamp": "2026-05-22T06:38:00Z"},
{"positionId": 4, "lat": 20.5480, "lng": -100.7850, "speed": 18, "timestamp": "2026-05-22T06:52:00Z"},
{"positionId": 5, "lat": 20.5510, "lng": -100.7750, "speed": 0, "timestamp": "2026-05-22T07:05:00Z"},
{"positionId": 6, "lat": 20.5460, "lng": -100.7720, "speed": 12, "timestamp": "2026-05-22T07:18:00Z"},
{"positionId": 7, "lat": 20.5390, "lng": -100.7820, "speed": 30, "timestamp": "2026-05-22T07:30:00Z"},
{"positionId": 8, "lat": 20.5111, "lng": -100.9037, "speed": 42, "timestamp": "2026-05-22T07:52:00Z"},
],
},
{
"route_id": "RUTA-08", "name": "Suroriente - Universidad Latina", "truck_id": 108, "status": "EN_RUTA",
"positions": [
{"positionId": 1, "lat": 20.5111, "lng": -100.9037, "speed": 0, "timestamp": "2026-05-22T06:15:00Z"},
{"positionId": 2, "lat": 20.5180, "lng": -100.8310, "speed": 38, "timestamp": "2026-05-22T06:28:00Z"},
{"positionId": 3, "lat": 20.5245, "lng": -100.7980, "speed": 30, "timestamp": "2026-05-22T06:42:00Z"},
{"positionId": 4, "lat": 20.5210, "lng": -100.7995, "speed": 14, "timestamp": "2026-05-22T06:55:00Z"},
{"positionId": 5, "lat": 20.5175, "lng": -100.8010, "speed": 0, "timestamp": "2026-05-22T07:08:00Z"},
{"positionId": 6, "lat": 20.5140, "lng": -100.8030, "speed": 18, "timestamp": "2026-05-22T07:20:00Z"},
{"positionId": 7, "lat": 20.5110, "lng": -100.8055, "speed": 22, "timestamp": "2026-05-22T07:32:00Z"},
{"positionId": 8, "lat": 20.5111, "lng": -100.9037, "speed": 40, "timestamp": "2026-05-22T07:54:00Z"},
],
},
{
"route_id": "RUTA-09", "name": "Poniente - Hospital General", "truck_id": 109, "status": "EN_RUTA",
"positions": [
{"positionId": 1, "lat": 20.5111, "lng": -100.9037, "speed": 0, "timestamp": "2026-05-22T06:02:00Z"},
{"positionId": 2, "lat": 20.5210, "lng": -100.8650, "speed": 45, "timestamp": "2026-05-22T06:12:00Z"},
{"positionId": 3, "lat": 20.5260, "lng": -100.8520, "speed": 26, "timestamp": "2026-05-22T06:24:00Z"},
{"positionId": 4, "lat": 20.5275, "lng": -100.8490, "speed": 12, "timestamp": "2026-05-22T06:36:00Z"},
{"positionId": 5, "lat": 20.5285, "lng": -100.8460, "speed": 0, "timestamp": "2026-05-22T06:48:00Z"},
{"positionId": 6, "lat": 20.5250, "lng": -100.8470, "speed": 15, "timestamp": "2026-05-22T07:00:00Z"},
{"positionId": 7, "lat": 20.5220, "lng": -100.8550, "speed": 32, "timestamp": "2026-05-22T07:12:00Z"},
{"positionId": 8, "lat": 20.5111, "lng": -100.9037, "speed": 44, "timestamp": "2026-05-22T07:30:00Z"},
],
},
{
"route_id": "RUTA-10", "name": "Eje Juan Pablo II - Sede UG Sur", "truck_id": 110, "status": "EN_RUTA",
"positions": [
{"positionId": 1, "lat": 20.5111, "lng": -100.9037, "speed": 0, "timestamp": "2026-05-22T06:22:00Z"},
{"positionId": 2, "lat": 20.5015, "lng": -100.8520, "speed": 40, "timestamp": "2026-05-22T06:34:00Z"},
{"positionId": 3, "lat": 20.4990, "lng": -100.8390, "speed": 28, "timestamp": "2026-05-22T06:46:00Z"},
{"positionId": 4, "lat": 20.4950, "lng": -100.8320, "speed": 18, "timestamp": "2026-05-22T06:58:00Z"},
{"positionId": 5, "lat": 20.4920, "lng": -100.8280, "speed": 0, "timestamp": "2026-05-22T07:10:00Z"},
{"positionId": 6, "lat": 20.4945, "lng": -100.8240, "speed": 14, "timestamp": "2026-05-22T07:22:00Z"},
{"positionId": 7, "lat": 20.4980, "lng": -100.8300, "speed": 30, "timestamp": "2026-05-22T07:34:00Z"},
{"positionId": 8, "lat": 20.5111, "lng": -100.9037, "speed": 38, "timestamp": "2026-05-22T07:52:00Z"},
],
},
{
"route_id": "RUTA-11", "name": "Zona de Oro - Torres Landa", "truck_id": 111, "status": "EN_RUTA",
"positions": [
{"positionId": 1, "lat": 20.5111, "lng": -100.9037, "speed": 0, "timestamp": "2026-05-22T06:04:00Z"},
{"positionId": 2, "lat": 20.5240, "lng": -100.8350, "speed": 36, "timestamp": "2026-05-22T06:16:00Z"},
{"positionId": 3, "lat": 20.5280, "lng": -100.8250, "speed": 22, "timestamp": "2026-05-22T06:29:00Z"},
{"positionId": 4, "lat": 20.5295, "lng": -100.8210, "speed": 10, "timestamp": "2026-05-22T06:42:00Z"},
{"positionId": 5, "lat": 20.5310, "lng": -100.8170, "speed": 0, "timestamp": "2026-05-22T06:55:00Z"},
{"positionId": 6, "lat": 20.5290, "lng": -100.8140, "speed": 16, "timestamp": "2026-05-22T07:08:00Z"},
{"positionId": 7, "lat": 20.5260, "lng": -100.8220, "speed": 28, "timestamp": "2026-05-22T07:21:00Z"},
{"positionId": 8, "lat": 20.5111, "lng": -100.9037, "speed": 42, "timestamp": "2026-05-22T07:42:00Z"},
],
},
{
"route_id": "RUTA-12", "name": "Nororiente - Las Insurgentes", "truck_id": 112, "status": "EN_RUTA",
"positions": [
{"positionId": 1, "lat": 20.5111, "lng": -100.9037, "speed": 0, "timestamp": "2026-05-22T06:08:00Z"},
{"positionId": 2, "lat": 20.5280, "lng": -100.8080, "speed": 40, "timestamp": "2026-05-22T06:22:00Z"},
{"positionId": 3, "lat": 20.5320, "lng": -100.7980, "speed": 24, "timestamp": "2026-05-22T06:35:00Z"},
{"positionId": 4, "lat": 20.5340, "lng": -100.7940, "speed": 15, "timestamp": "2026-05-22T06:48:00Z"},
{"positionId": 5, "lat": 20.5360, "lng": -100.7900, "speed": 0, "timestamp": "2026-05-22T07:00:00Z"},
{"positionId": 6, "lat": 20.5310, "lng": -100.7920, "speed": 12, "timestamp": "2026-05-22T07:12:00Z"},
{"positionId": 7, "lat": 20.5270, "lng": -100.8020, "speed": 26, "timestamp": "2026-05-22T07:25:00Z"},
{"positionId": 8, "lat": 20.5111, "lng": -100.9037, "speed": 44, "timestamp": "2026-05-22T07:48:00Z"},
],
},
{
"route_id": "RUTA-13", "name": "Sector Norte - Trojes e Irrigación", "truck_id": 113, "status": "EN_RUTA",
"positions": [
{"positionId": 1, "lat": 20.5111, "lng": -100.9037, "speed": 0, "timestamp": "2026-05-22T06:12:00Z"},
{"positionId": 2, "lat": 20.5360, "lng": -100.8190, "speed": 35, "timestamp": "2026-05-22T06:26:00Z"},
{"positionId": 3, "lat": 20.5420, "lng": -100.8080, "speed": 28, "timestamp": "2026-05-22T06:40:00Z"},
{"positionId": 4, "lat": 20.5440, "lng": -100.8040, "speed": 14, "timestamp": "2026-05-22T06:54:00Z"},
{"positionId": 5, "lat": 20.5460, "lng": -100.8000, "speed": 0, "timestamp": "2026-05-22T07:06:00Z"},
{"positionId": 6, "lat": 20.5410, "lng": -100.8020, "speed": 18, "timestamp": "2026-05-22T07:18:00Z"},
{"positionId": 7, "lat": 20.5370, "lng": -100.8120, "speed": 25, "timestamp": "2026-05-22T07:30:00Z"},
{"positionId": 8, "lat": 20.5111, "lng": -100.9037, "speed": 39, "timestamp": "2026-05-22T07:54:00Z"},
],
},
{
"route_id": "RUTA-14", "name": "Sur Poniente - La Toscana", "truck_id": 114, "status": "EN_RUTA",
"positions": [
{"positionId": 1, "lat": 20.5111, "lng": -100.9037, "speed": 0, "timestamp": "2026-05-22T06:16:00Z"},
{"positionId": 2, "lat": 20.5150, "lng": -100.8580, "speed": 42, "timestamp": "2026-05-22T06:28:00Z"},
{"positionId": 3, "lat": 20.5140, "lng": -100.8390, "speed": 26, "timestamp": "2026-05-22T06:41:00Z"},
{"positionId": 4, "lat": 20.5125, "lng": -100.8310, "speed": 16, "timestamp": "2026-05-22T06:54:00Z"},
{"positionId": 5, "lat": 20.5110, "lng": -100.8250, "speed": 0, "timestamp": "2026-05-22T07:06:00Z"},
{"positionId": 6, "lat": 20.5135, "lng": -100.8280, "speed": 12, "timestamp": "2026-05-22T07:18:00Z"},
{"positionId": 7, "lat": 20.5160, "lng": -100.8420, "speed": 32, "timestamp": "2026-05-22T07:30:00Z"},
{"positionId": 8, "lat": 20.5111, "lng": -100.9037, "speed": 45, "timestamp": "2026-05-22T07:51:00Z"},
],
},
{
"route_id": "RUTA-15", "name": "Norponiente - Camino a San José de Celaya", "truck_id": 115, "status": "EN_RUTA",
"positions": [
{"positionId": 1, "lat": 20.5111, "lng": -100.9037, "speed": 0, "timestamp": "2026-05-22T06:18:00Z"},
{"positionId": 2, "lat": 20.5320, "lng": -100.8590, "speed": 38, "timestamp": "2026-05-22T06:31:00Z"},
{"positionId": 3, "lat": 20.5390, "lng": -100.8480, "speed": 24, "timestamp": "2026-05-22T06:44:00Z"},
{"positionId": 4, "lat": 20.5420, "lng": -100.8440, "speed": 15, "timestamp": "2026-05-22T06:57:00Z"},
{"positionId": 5, "lat": 20.5450, "lng": -100.8410, "speed": 0, "timestamp": "2026-05-22T07:09:00Z"},
{"positionId": 6, "lat": 20.5410, "lng": -100.8430, "speed": 14, "timestamp": "2026-05-22T07:21:00Z"},
{"positionId": 7, "lat": 20.5360, "lng": -100.8520, "speed": 28, "timestamp": "2026-05-22T07:33:00Z"},
{"positionId": 8, "lat": 20.5111, "lng": -100.9037, "speed": 41, "timestamp": "2026-05-22T07:54:00Z"},
],
},
]
ROUTAS_POR_ID = {r["route_id"]: r for r in ROUTE_DATA}
HORARIOS_POR_RUTA = {
"RUTA-01": {"eta_texto": "Llega en aproximadamente 15 minutos", "eta_minutos": 15},
"RUTA-02": {"eta_texto": "Llega en aproximadamente 30 minutos", "eta_minutos": 30},
"RUTA-03": {"eta_texto": "Llega en aproximadamente 40 minutos", "eta_minutos": 40},
"RUTA-04": {"eta_texto": "Llega en aproximadamente 60 minutos", "eta_minutos": 60},
"RUTA-05": {"eta_texto": "Llega en aproximadamente 75 minutos", "eta_minutos": 75},
"RUTA-06": {"eta_texto": "Llega en aproximadamente 50 minutos", "eta_minutos": 50},
"RUTA-07": {"eta_texto": "Llega en aproximadamente 45 minutos", "eta_minutos": 45},
"RUTA-08": {"eta_texto": "Llega en aproximadamente 55 minutos", "eta_minutos": 55},
"RUTA-09": {"eta_texto": "Llega en aproximadamente 20 minutos", "eta_minutos": 20},
"RUTA-10": {"eta_texto": "Llega en aproximadamente 65 minutos", "eta_minutos": 65},
"RUTA-11": {"eta_texto": "Llega en aproximadamente 25 minutos", "eta_minutos": 25},
"RUTA-12": {"eta_texto": "Llega en aproximadamente 35 minutos", "eta_minutos": 35},
"RUTA-13": {"eta_texto": "Llega en aproximadamente 40 minutos", "eta_minutos": 40},
"RUTA-14": {"eta_texto": "Llega en aproximadamente 45 minutos", "eta_minutos": 45},
"RUTA-15": {"eta_texto": "Llega en aproximadamente 55 minutos", "eta_minutos": 55},
}
HORARIOS_POR_COLONIA = [
{"colonia": "Zona Centro", "routeId": "RUTA-01", "horarioEstimado": "Matutino (06:30 - 07:15)"},
{"colonia": "Las Arboledas", "routeId": "RUTA-01", "horarioEstimado": "Matutino (07:00 - 07:30)"},
{"colonia": "Trojes", "routeId": "RUTA-13", "horarioEstimado": "Matutino (06:40 - 07:10)"},
{"colonia": "San Juanico", "routeId": "RUTA-03", "horarioEstimado": "Matutino (06:45 - 07:15)"},
{"colonia": "Los Olivos", "routeId": "RUTA-04", "horarioEstimado": "Matutino (07:00 - 07:40)"},
{"colonia": "Rancho Seco", "routeId": "RUTA-05", "horarioEstimado": "Vespertino (14:15 - 15:00)"},
{"colonia": "Las Insurgentes", "routeId": "RUTA-12", "horarioEstimado": "Matutino (06:35 - 07:10)"},
]
COLONIAS_A_RUTAS = {item["colonia"]: item["routeId"] for item in HORARIOS_POR_COLONIA}
TRIGGER_NOTIFICATIONS = {
"ROUTE_START": {
"position_id": 2,
"title": "¡Ruta Iniciada!",
"body": "El camión recolector ha salido del Relleno Sanitario rumbo a tu sector. Asegúrate de tener listos tus residuos.",
},
"TRUCK_PROXIMITY": {
"position_id": 4,
"title": "Camión Cercano",
"body": "El camión está a menos de 15 minutos de tu domicilio. Es momento de sacar tus bolsas a la acera.",
},
"ROUTE_COMPLETED": {
"position_id": 8,
"title": "Servicio Finalizado",
"body": "El camión de tu sector ha concluido su jornada de recolección diaria.",
},
"GPS_OUTAGE": {
"title": "Alerta GPS",
"body": "El GPS del camión dejó de reportar su ubicación. Estamos investigando la ruta.",
},
}
TIPOS_EVENTO_VALIDOS = ["en_camino", "llegando", "completado", "retrasado"]
INFO_ARTICULOS = [
{
"id": "separacion-basica",
"categoria": "Separación",
"imagen": "assets/images/recycle.jpg",
"titulo": "Cómo separar correctamente tu basura",
"resumen": "La separación correcta es el primer paso para reciclar y reducir el impacto ambiental.",
"contenido": [
{
"subtitulo": "Residuos Orgánicos 🟤",
"texto": "Restos de comida, cáscaras de frutas y verduras, posos de café, bolsas de té, restos de jardín. Van en bolsa oscura o café. Se convierten en composta.",
},
{
"subtitulo": "Residuos Inorgánicos Reciclables 🟡",
"texto": "Plásticos (botellas PET, envases), papel y cartón limpios, vidrio, latas de aluminio y hojalata. Van en bolsa transparente o amarilla. Deben estar limpios y secos.",
},
{
"subtitulo": "Residuos No Reciclables 🔴",
"texto": "Papel higiénico usado, pañales, colillas de cigarro, envolturas metalizadas (como papas). Van en bolsa negra. No tienen valor de reciclaje.",
},
{
"subtitulo": "Residuos Especiales ⚠️",
"texto": "Pilas, medicamentos caducados, electrónicos, aceite de cocina. NUNCA los mezcles con la basura regular. Lleva pilas y electrónicos a puntos de acopio en supermercados.",
},
],
"consejo_rapido": "Regla fácil: si vino de la naturaleza y se pudre → orgánico. Si es artificial y limpio → reciclable.",
},
{
"id": "cuando-sacar",
"categoria": "Horarios",
"imagen": "assets/images/reloj.png",
"titulo": "¿Cuándo sacar tu basura?",
"resumen": "Sacar la basura en el momento correcto evita plagas, malos olores y que el camión se la pierda.",
"contenido": [
{
"subtitulo": "El momento ideal",
"texto": "Saca tu basura cuando recibas la alerta de 'Camión Cercano' en la app. Eso significa que el camión está a menos de 15 minutos de tu domicilio.",
},
{
"subtitulo": "¿Por qué no sacarla de noche?",
"texto": "Las bolsas en la acera de noche atraen perros, gatos y fauna nocturna que las rompen y dispersan los residuos. Además el plástico se deteriora con la humedad nocturna.",
},
{
"subtitulo": "¿Y si me lo pierdo?",
"texto": "Si el camión ya pasó, guarda tu basura hasta el siguiente día. Nunca dejes bolsas en la vía pública fuera del horario de recolección: es una multa en muchos municipios.",
},
{
"subtitulo": "Días festivos",
"texto": "En días festivos el servicio puede retrasarse o cancelarse. Activa las notificaciones de la app para recibir alertas de retraso o cambio de horario.",
},
],
"consejo_rapido": "Espera la alerta de la app antes de salir con tus bolsas. Te ahorra tiempo y evita dejar basura expuesta.",
},
{
"id": "plasticos-guia",
"categoria": "Reciclaje",
"imagen": "assets/images/bottle.png",
"titulo": "Guía de plásticos: cuáles sí y cuáles no",
"resumen": "No todos los plásticos son iguales. Aprende a leer el número en el triángulo de reciclaje.",
"contenido": [
{
"subtitulo": "✅ Plástico #1 — PET",
"texto": "Botellas de agua, refrescos, aceite. El más reciclado. Aplástalo para ahorrar espacio. Quita la tapa (es diferente material).",
},
{
"subtitulo": "✅ Plástico #2 — HDPE",
"texto": "Garrafones, botellas de leche, shampú. También muy reciclable. Enjuágalo antes de separarlo.",
},
{
"subtitulo": "✅ Plástico #5 — PP",
"texto": "Tapas de botellas, envases de yogur, popotes. Sí se recicla pero menos centros lo aceptan.",
},
{
"subtitulo": "❌ Plásticos #3, #6, #7",
"texto": "PVC (mangueras, tuberías), poliestireno expandido (unicel), policarbonato. Difíciles o imposibles de reciclar. Van a basura no reciclable.",
},
{
"subtitulo": "❌ Bolsas de plástico",
"texto": "Las bolsas de supermercado no van en el reciclaje de casa: tapan las máquinas clasificadoras. Lleva tus bolsas a centros de acopio específicos en supermercados.",
},
],
"consejo_rapido": "Busca el número dentro del triángulo en el fondo del envase. #1 y #2 siempre al reciclaje.",
},
{
"id": "composta",
"categoria": "Compostaje",
"imagen": "assets/images/planta.png",
"titulo": "Haz composta en casa",
"resumen": "Convierte tus residuos orgánicos en abono natural. Es más fácil de lo que crees.",
"contenido": [
{
"subtitulo": "¿Qué necesitas?",
"texto": "Un contenedor con tapa (puede ser una cubeta con tapa o una caja de madera), residuos orgánicos, tierra o tierra de hojarasca, y un poco de paciencia.",
},
{
"subtitulo": "¿Qué puedes compostar?",
"texto": "Cáscaras de frutas y verduras, restos de comida cocida sin carne, posos de café y filtros de papel, cáscaras de huevo (aplástelas), hojas secas, recortes de jardín.",
},
{
"subtitulo": "¿Qué NO debes compostar?",
"texto": "Carnes, pescados, lácteos, aceites (atraen plagas), excrementos de mascotas (patógenos), plásticos ni metales.",
},
{
"subtitulo": "El proceso",
"texto": "Alterna capas de residuos orgánicos húmedos con capas de material seco (tierra, hojas). Voltea la mezcla cada semana. En 2-3 meses tendrás composta lista para tus plantas.",
},
],
"consejo_rapido": "La composta lista huele a tierra mojada, no a podrido. Si huele mal, agrega más material seco y voltéala.",
},
{
"id": "residuos-peligrosos",
"categoria": "Residuos Especiales",
"imagen": "assets/images/megafono.png",
"titulo": "Residuos peligrosos: cómo deshacerte de ellos",
"resumen": "Pilas, medicamentos y electrónicos requieren un manejo especial para no contaminar el suelo y el agua.",
"contenido": [
{
"subtitulo": "Pilas y baterías",
"texto": "Una sola pila AA puede contaminar 600,000 litros de agua. Guárdalas en una bolsa o caja y lleva a los puntos de acopio en Walmart, Soriana, Home Depot o OXXO. Nunca al drenaje ni al fuego.",
},
{
"subtitulo": "Medicamentos caducados",
"texto": "No los tires al drenaje ni a la basura regular. Farmacias como Farmacias del Ahorro y Benavides cuentan con contenedores REPARED para medicamentos. El municipio también hace jornadas de recolección.",
},
{
"subtitulo": "Electrónicos (RAEE)",
"texto": "Celulares, computadoras, cables, focos LED. Contienen plomo, mercurio y cadmio. Lleva a tiendas de electrónicos (Best Buy, Liverpool) o espera las jornadas municipales de recolección.",
},
{
"subtitulo": "Aceite de cocina",
"texto": "Un litro de aceite contamina hasta 1,000 litros de agua potable. Enfríalo, viértelo en una botella PET con tapa y lleva a centros de acopio o úsalo para hacer jabón casero.",
},
],
"consejo_rapido": "Guarda una caja en casa exclusiva para residuos peligrosos. Cuando esté llena, busca el punto de acopio más cercano.",
},
{
"id": "impacto-ambiental",
"categoria": "Medio Ambiente",
"imagen": "assets/images/globo.png",
"titulo": "El impacto real de reciclar",
"resumen": "Números concretos para entender por qué vale la pena separar tu basura cada día.",
"contenido": [
{
"subtitulo": "Papel y cartón",
"texto": "Reciclar 1 tonelada de papel salva 17 árboles, ahorra 26,000 litros de agua y evita 4,000 kWh de energía. Una familia promedio genera ~500 kg de papel al año.",
},
{
"subtitulo": "Aluminio",
"texto": "Reciclar una lata de aluminio ahorra la energía suficiente para que un foco LED funcione 20 horas. El aluminio puede reciclarse infinitas veces sin perder calidad.",
},
{
"subtitulo": "Vidrio",
"texto": "El vidrio tarda más de 4,000 años en degradarse. Reciclarlo reduce en 20% las emisiones de CO₂ de su producción. Una botella puede reciclarse indefinidamente.",
},
{
"subtitulo": "Plástico PET",
"texto": "5 botellas PET recicladas generan fibra suficiente para una camiseta de poliéster. México recicla menos del 20% del PET que consume — hay mucho potencial de mejora.",
},
{
"subtitulo": "Residuos en México",
"texto": "México genera ~120,000 toneladas de basura al día. Solo el 9% se recicla formalmente. Si cada hogar separara correctamente, ese porcentaje podría triplicarse.",
},
],
"consejo_rapido": "Cada lata de aluminio que reciclas ahorra energía equivalente a medio litro de gasolina. Sí importa.",
},
]
ROUTE_STATE = {}
for _route in ROUTE_DATA:
_pos = _route.get("positions", [])
ROUTE_STATE[_route["route_id"]] = {
"last_position_id": _pos[0]["positionId"] if _pos else 0,
"last_timestamp": datetime.now(timezone.utc),
"gps_ok": True,
"gps_alert_sent": False,
"triggers_sent": {k: False for k in TRIGGER_NOTIFICATIONS if k != "GPS_OUTAGE"},
"error_message": None,
}
# ===============================================================
# FIREBASE ADMIN SDK
# ===============================================================
import firebase_admin
from firebase_admin import credentials, messaging
try:
cred = credentials.Certificate("firebase-credentials.json")
firebase_admin.initialize_app(cred)
logger.info("✅ Firebase Admin SDK inicializado correctamente")
except Exception as e:
logger.error(f"❌ Error inicializando Firebase: {e}")
FIREBASE_ACTIVO = True
# ===============================================================
# FASTAPI APP
# ===============================================================
app = FastAPI(
title="Sistema de Notificación de Residuos — v2",
description="API privada para notificaciones de recolección de basura",
version="0.2.0",
)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
# ===============================================================
# SCHEMAS PYDANTIC
# ===============================================================
class ETAResponse(BaseModel):
usuario_id: int
colonia: str
ruta_nombre: str
ruta_status: str
gps_ok: bool
eta_texto: str
eta_minutos: int
mensaje_preventivo: str
error_message: Optional[str] = None
class UsuarioRegisterRequest(BaseModel):
nombre: str
email: str
password: str
colonia: str
direccion: str
class UsuarioLoginRequest(BaseModel):
email: str
password: str
class DireccionRequest(BaseModel):
colonia: str
direccion: str
class DomicilioResponse(BaseModel):
colonia: str
direccion: str
class DomicilioAdminResponse(BaseModel):
colonia: str
direccion: str
route_id: str
ruta_nombre: str
class UsuarioResponse(BaseModel):
usuario_id: int
nombre: str
email: Optional[str] = None # ← igual aquí
direcciones: List[DomicilioResponse]
class UsuarioAdminResponse(BaseModel):
usuario_id: int
nombre: str
email: Optional[str] = None # ← cambia str por Optional[str]
direcciones: List[DomicilioAdminResponse]
class RegisterResponse(BaseModel):
usuario_id: int
mensaje: str
class LoginResponse(BaseModel):
usuario_id: int
nombre: str
mensaje: str
class RouteStatusResponse(BaseModel):
route_id: str
name: str
status: str
last_position_id: int
last_timestamp: str
gps_ok: bool
error_message: Optional[str] = None
class RoutePositionUpdateRequest(BaseModel):
position_id: int
lat: float
lng: float
timestamp: str
class ActualizarPasswordRequest(BaseModel):
password_actual: str # Requerimos la contraseña actual para cambiarla
password_nuevo: str
class ActualizarTokenRequest(BaseModel):
fcm_token: str
class SimularEventoRequest(BaseModel):
route_id: str
tipo_evento: str
class SimularEventoResponse(BaseModel):
usuarios_notificados: int
route_id: str
tipo_evento: str
detalle: list[str]
# ---------------------------------------------------------------
# NUEVOS SCHEMAS PARA VISUALIZACIÓN
# ---------------------------------------------------------------
class PosicionGPS(BaseModel):
position_id: int
lat: float
lng: float
speed: int
timestamp: str
es_actual: bool # True si esta es la posición donde está el camión ahora
class RutaDetalleResponse(BaseModel):
route_id: str
name: str
status: str
truck_id: int
posicion_actual: int
total_posiciones: int
porcentaje_completado: float
eta_minutos: int
gps_ok: bool
usuarios_en_ruta: int # Cuántos usuarios están en esta ruta
class DashboardResponse(BaseModel):
total_rutas: int
rutas_en_progreso: int
rutas_completadas: int
total_usuarios: int
usuarios_con_token: int # Cuántos pueden recibir push notifications
cobertura_notificaciones: float # % de usuarios con FCM token
rutas: List[RutaDetalleResponse]
class ColoniaEstadisticaResponse(BaseModel):
colonia: str
route_id: str
ruta_nombre: str
horario: str
total_usuarios: int
usuarios_con_notificaciones: int
# ===============================================================
# UTILIDADES INTERNAS
# ===============================================================
def generar_mensaje_preventivo(eta_minutos: int) -> str:
if eta_minutos <= 5:
return "🚛 ¡El camión está muy cerca! Saca tu basura AHORA."
elif eta_minutos <= 15:
return "⏰ Prepárate, el camión llega pronto. No saques tu basura aún."
elif eta_minutos <= 30:
return "🕐 Tienes tiempo. No saques tu basura todavía."
else:
return "😌 Aún falta bastante. Mantén tu basura adentro por ahora."
def enviar_notificacion_firebase(fcm_token: str, titulo: str, cuerpo: str) -> bool:
if not FIREBASE_ACTIVO:
logger.info(f"[SIMULADO] Push → Token: {fcm_token[:20]}... | {titulo}: {cuerpo}")
return True
try:
message = messaging.Message(
notification=messaging.Notification(title=titulo, body=cuerpo),
token=fcm_token,
)
response = messaging.send(message)
logger.info(f"✅ Notificación enviada: {response}")
return True
except Exception as e:
logger.error(f"❌ Error enviando push: {e}")
return False
def _calcular_eta_por_ruta(route_id: str) -> Dict:
estado = ROUTE_STATE.get(route_id)
ruta = ROUTAS_POR_ID.get(route_id, {})
horario = HORARIOS_POR_RUTA.get(route_id)
if not horario:
return {"eta_texto": "Horario no disponible para esta zona.", "eta_minutos": 60}
if estado and ruta.get("positions"):
if ruta.get("status") == "DETENIDA":
return {"eta_texto": "Ruta detenida", "eta_minutos": 0}
posiciones = ruta["positions"]
ultimo_id = estado.get("last_position_id", 1)
if ultimo_id >= len(posiciones):
return {"eta_texto": "El servicio ya pasó por tu zona.", "eta_minutos": 0}
pasos_restantes = max(0, len(posiciones) - ultimo_id)
eta = pasos_restantes * 10
return {"eta_texto": f"Llega en aproximadamente {eta} minutos", "eta_minutos": eta}
return {"eta_texto": horario["eta_texto"], "eta_minutos": horario["eta_minutos"]}
def _verificar_gps_outage(route_id: str, db: Session) -> None:
estado = ROUTE_STATE.get(route_id)
if not estado:
return
ultimo = estado.get("last_timestamp", datetime.now(timezone.utc))
gps_ok = (datetime.now(timezone.utc) - ultimo) < timedelta(minutes=10)
if not gps_ok and not estado.get("gps_alert_sent", False):
logger.warning(f"Alerta GPS outage para {route_id}")
_notificar_ruta(db, route_id, "GPS_OUTAGE")
estado["gps_alert_sent"] = True
def _obtener_estado_ruta(route_id: str, db: Optional[Session] = None) -> Dict:
ruta = ROUTAS_POR_ID.get(route_id)
estado = ROUTE_STATE.get(route_id, {})
if not ruta:
# Ruta en DB pero sin datos en memoria — retornar estado genérico
return {
"route_id": route_id,
"name": f"Ruta {route_id}",
"status": "SIN_DATOS",
"last_position_id": 0,
"last_timestamp": datetime.now(timezone.utc).isoformat(),
"gps_ok": False,
"error_message": "Ruta sin datos de seguimiento disponibles.",
}
ultimo = estado.get("last_timestamp", datetime.now(timezone.utc))
gps_ok = (datetime.now(timezone.utc) - ultimo) < timedelta(minutes=10)
if db is not None and not gps_ok:
_verificar_gps_outage(route_id, db)
return {
"route_id": route_id,
"name": ruta.get("name", "Ruta desconocida"),
"status": ruta.get("status", "DESCONOCIDA"),
"last_position_id": estado.get("last_position_id", 0),
"last_timestamp": ultimo.isoformat(),
"gps_ok": gps_ok,
"error_message": estado.get("error_message"),
}
def _procesar_trigger_posicion(route_id: str, position_id: int, db: Session) -> list[str]:
estado = ROUTE_STATE.get(route_id)
if not estado:
return []
mensajes = []
sent_map = estado.setdefault("triggers_sent", {})
for trigger_key, trigger in TRIGGER_NOTIFICATIONS.items():
if trigger_key == "GPS_OUTAGE":
continue
if trigger.get("position_id") == position_id and not sent_map.get(trigger_key, False):
mensajes.extend(_notificar_ruta(db, route_id, trigger_key))
sent_map[trigger_key] = True
return mensajes
def _notificar_ruta(db: Session, route_id: str, trigger_key: str) -> list[str]:
trigger = TRIGGER_NOTIFICATIONS.get(trigger_key)
if not trigger:
return [f"Trigger desconocido: {trigger_key}"]
domicilios = db.query(Domicilio).filter(Domicilio.route_id == route_id).all()
mensajes = []
for domicilio in domicilios:
usuario = domicilio.usuario
if not usuario or not usuario.fcm_token:
mensajes.append("Usuario sin token.")
continue
enviado = enviar_notificacion_firebase(usuario.fcm_token, trigger["title"], trigger["body"])
mensajes.append(
f"✅ Push a {usuario.nombre}" if enviado else f"❌ Fallo push a {usuario.nombre}"
)
return mensajes
def _obtener_rutas_usuario(usuario_id: int, db: Session) -> List[str]:
usuario = db.query(Usuario).filter(Usuario.id == usuario_id).first()
if not usuario:
raise HTTPException(status_code=404, detail="Usuario no encontrado.")
return list({d.route_id for d in usuario.direcciones})
# ===============================================================
# ENDPOINTS — USUARIOS
# ===============================================================
@app.post("/api/usuarios/register", response_model=RegisterResponse, tags=["Usuarios"])
def registrar_usuario(payload: UsuarioRegisterRequest, db: Session = Depends(get_db)):
existing = db.query(Usuario).filter(Usuario.email == payload.email.lower().strip()).first()
if existing:
raise HTTPException(status_code=400, detail="El correo ya está registrado.")
if not payload.password or not payload.password.strip():
raise HTTPException(status_code=400, detail="La contraseña no puede estar vacía.")
route_id = COLONIAS_A_RUTAS.get(payload.colonia)
if not route_id:
raise HTTPException(status_code=400, detail=f"Colonia no válida. Opciones: {list(COLONIAS_A_RUTAS.keys())}")
usuario = Usuario(
nombre=payload.nombre.strip(),
email=payload.email.lower().strip(),
password_hash=hash_password(payload.password),
fcm_token=None,
)
db.add(usuario)
db.flush()
db.add(Domicilio(usuario_id=usuario.id, colonia=payload.colonia, direccion=payload.direccion, route_id=route_id))
db.commit()
logger.info(f"✅ Usuario registrado: {usuario.email} (ID {usuario.id})")
return RegisterResponse(usuario_id=usuario.id, mensaje="Usuario registrado correctamente.")
@app.post("/api/usuarios/login", response_model=LoginResponse, tags=["Usuarios"])
def login_usuario(payload: UsuarioLoginRequest, db: Session = Depends(get_db)):
usuario = db.query(Usuario).filter(Usuario.email == payload.email.lower().strip()).first()
if not usuario:
raise HTTPException(status_code=404, detail="Usuario no encontrado. Regístrate primero.")
if not verify_password(payload.password, usuario.password_hash):
raise HTTPException(status_code=401, detail="Contraseña incorrecta.")
# ── MIGRACIÓN AUTOMÁTICA LEGACY ──────────────────────────────
# Si el hash guardado es sha256 (antiguo), lo re-hasheamos con
# bcrypt ahora que sabemos que el password es correcto.
if _es_hash_legacy(usuario.password_hash):
usuario.password_hash = hash_password(payload.password)
db.commit()
logger.info(f"🔄 Hash migrado sha256→bcrypt para usuario {usuario.id}")
return LoginResponse(usuario_id=usuario.id, nombre=usuario.nombre, mensaje="Login exitoso.")
@app.get("/api/usuarios/{usuario_id}", response_model=UsuarioResponse, tags=["Usuarios"])
def obtener_usuario(usuario_id: int, db: Session = Depends(get_db)):
usuario = db.query(Usuario).filter(Usuario.id == usuario_id).first()
if not usuario:
raise HTTPException(status_code=404, detail="Usuario no encontrado.")
return UsuarioResponse(
usuario_id=usuario.id,
nombre=usuario.nombre,
email=usuario.email,
direcciones=[DomicilioResponse(colonia=d.colonia, direccion=d.direccion) for d in usuario.direcciones],
)
@app.get("/api/usuarios", response_model=List[UsuarioAdminResponse], tags=["Usuarios"])
def listar_usuarios(db: Session = Depends(get_db)):
usuarios = db.query(Usuario).all()
return [
UsuarioAdminResponse(
usuario_id=u.id,
nombre=u.nombre,
email=u.email,
direcciones=[
DomicilioAdminResponse(
colonia=d.colonia,
direccion=d.direccion,
route_id=d.route_id,
ruta_nombre=ROUTAS_POR_ID.get(d.route_id, {}).get("name", "Desconocida"),
)
for d in u.direcciones
],
)
for u in usuarios
]
@app.post("/api/usuarios/{usuario_id}/direcciones", tags=["Usuarios"])
def agregar_direccion(usuario_id: int, payload: DireccionRequest, db: Session = Depends(get_db)):
usuario = db.query(Usuario).filter(Usuario.id == usuario_id).first()
if not usuario:
raise HTTPException(status_code=404, detail="Usuario no encontrado.")
route_id = COLONIAS_A_RUTAS.get(payload.colonia)
if not route_id:
raise HTTPException(status_code=400, detail="Colonia no válida.")
db.add(Domicilio(usuario_id=usuario.id, colonia=payload.colonia, direccion=payload.direccion, route_id=route_id))
db.commit()
return {"mensaje": "Dirección agregada correctamente."}
@app.put("/api/usuarios/{usuario_id}/password", tags=["Usuarios"])
def actualizar_password(usuario_id: int, payload: ActualizarPasswordRequest, db: Session = Depends(get_db)):
"""
Actualiza la contraseña de un usuario.
Requiere la contraseña actual para confirmar identidad.
"""
usuario = db.query(Usuario).filter(Usuario.id == usuario_id).first()
if not usuario:
raise HTTPException(status_code=404, detail="Usuario no encontrado.")
if not verify_password(payload.password_actual, usuario.password_hash):
raise HTTPException(status_code=401, detail="La contraseña actual es incorrecta.")
if not payload.password_nuevo or not payload.password_nuevo.strip():
raise HTTPException(status_code=400, detail="La nueva contraseña no puede estar vacía.")
if len(payload.password_nuevo) < 6:
raise HTTPException(status_code=400, detail="La nueva contraseña debe tener al menos 6 caracteres.")
usuario.password_hash = hash_password(payload.password_nuevo)
db.commit()
logger.info(f"🔑 Contraseña actualizada para usuario {usuario_id}")
return {"mensaje": "Contraseña actualizada correctamente."}
@app.put("/api/usuarios/{usuario_id}/fcm-token", tags=["Utilidades"])
def actualizar_fcm_token(usuario_id: int, payload: ActualizarTokenRequest, db: Session = Depends(get_db)):
usuario = db.query(Usuario).filter(Usuario.id == usuario_id).first()
if not usuario:
raise HTTPException(status_code=404, detail="Usuario no encontrado.")
usuario.fcm_token = payload.fcm_token
db.commit()
return {"mensaje": f"Token actualizado para usuario {usuario_id}"}
# ===============================================================
# ENDPOINTS — RUTAS Y ETA
# ===============================================================
@app.get("/api/eta/{usuario_id}", response_model=ETAResponse, tags=["Core"])
def obtener_eta(usuario_id: int, db: Session = Depends(get_db)):
usuario = db.query(Usuario).filter(Usuario.id == usuario_id).first()
if not usuario:
raise HTTPException(status_code=404, detail=f"Usuario {usuario_id} no encontrado.")
if not usuario.direcciones:
raise HTTPException(status_code=404, detail="El usuario no tiene direcciones registradas.")
direccion = db.query(Domicilio).filter(Domicilio.usuario_id == usuario_id).order_by(Domicilio.id.desc()).first()
route_id = direccion.route_id
ruta = ROUTAS_POR_ID.get(route_id, {})
estado_ruta = _obtener_estado_ruta(route_id, db)
calculo = _calcular_eta_por_ruta(route_id)
return ETAResponse(
usuario_id=usuario_id,
colonia=direccion.colonia,
ruta_nombre=ruta.get("name", "Ruta desconocida"),
ruta_status=estado_ruta["status"],
gps_ok=estado_ruta["gps_ok"], # ← agregar esta línea
eta_texto=calculo["eta_texto"],
eta_minutos=calculo["eta_minutos"],
mensaje_preventivo=generar_mensaje_preventivo(calculo["eta_minutos"]),
)
@app.get("/api/rutas", tags=["Rutas"])
def listar_rutas(usuario_id: int = Query(...), db: Session = Depends(get_db)):
rutas_usuario = _obtener_rutas_usuario(usuario_id, db)
return {"rutas": [_obtener_estado_ruta(r, db) for r in rutas_usuario]}
@app.get("/api/rutas/{route_id}/estado", response_model=RouteStatusResponse, tags=["Rutas"])
def estado_ruta(route_id: str, db: Session = Depends(get_db)):
try:
return RouteStatusResponse(**_obtener_estado_ruta(route_id, db))
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
@app.post("/api/rutas/{route_id}/avanzar", response_model=RouteStatusResponse, tags=["Rutas"])
def avanzar_ruta(route_id: str, usuario_id: int = Query(...), db: Session = Depends(get_db)):
if route_id not in ROUTAS_POR_ID:
raise HTTPException(status_code=404, detail="Ruta no encontrada.")
rutas_usuario = _obtener_rutas_usuario(usuario_id, db)
if route_id not in rutas_usuario:
raise HTTPException(status_code=403, detail="No tienes permiso para avanzar esta ruta.")
ruta = ROUTAS_POR_ID[route_id]
estado = ROUTE_STATE.get(route_id)
posiciones = ruta.get("positions", [])
if not estado or not posiciones:
raise HTTPException(status_code=500, detail="Estado de ruta no disponible.")
actual = estado.get("last_position_id", 0)
if actual < len(posiciones):
siguiente = actual + 1
estado["last_position_id"] = siguiente
estado["last_timestamp"] = datetime.now(timezone.utc)
estado["gps_ok"] = True
estado["gps_alert_sent"] = False
mensajes = _procesar_trigger_posicion(route_id, siguiente, db)
ruta["status"] = "COMPLETADO" if siguiente >= len(posiciones) else "EN_RUTA"
if mensajes:
logger.info(f"Triggers en {route_id}: {mensajes}")
colonia = db.query(Domicilio).filter(
Domicilio.route_id == route_id
).first()
colonia_nombre = colonia.colonia if colonia else "Desconocida"
evento = "COMPLETADO" if siguiente >= len(posiciones) else "AVANCE"
_registrar_evento_ruta(db, route_id, siguiente, evento, colonia_nombre)
return RouteStatusResponse(**_obtener_estado_ruta(route_id, db))
@app.post("/api/rutas/{route_id}/posicion", tags=["Rutas"])
def actualizar_posicion_ruta(route_id: str, payload: RoutePositionUpdateRequest, db: Session = Depends(get_db)):
if route_id not in ROUTAS_POR_ID:
raise HTTPException(status_code=404, detail="Ruta no encontrada.")
estado = ROUTE_STATE.get(route_id)
if not estado:
raise HTTPException(status_code=500, detail="Estado de ruta no inicializado.")
try:
timestamp = datetime.fromisoformat(payload.timestamp.replace("Z", "+00:00"))
except ValueError:
raise HTTPException(status_code=400, detail="Timestamp inválido. Usa formato ISO 8601 UTC.")
mensajes = []
if payload.position_id > estado["last_position_id"]:
mensajes = _procesar_trigger_posicion(route_id, payload.position_id, db)
estado.update({
"last_position_id": payload.position_id,
"last_timestamp": timestamp,
"gps_ok": True,
"gps_alert_sent": False,
})
if payload.position_id == 8:
ROUTAS_POR_ID[route_id]["status"] = "COMPLETADO"
return {"route_id": route_id, "position_id": payload.position_id, "timestamp": payload.timestamp, "gps_ok": True, "mensajes": mensajes}
# ===============================================================
# ENDPOINTS — VISUALIZACIÓN Y ESTADÍSTICAS (NUEVOS EN v2)
# ===============================================================
@app.get("/api/dashboard", response_model=DashboardResponse, tags=["Visualización"])
def obtener_dashboard(db: Session = Depends(get_db)):
"""
Vista global del sistema para monitoreo.
Muestra el estado de todas las rutas + métricas de usuarios.
Útil para:
- Panel de control del operador municipal
- Demo en hackathon (muestra todo de un vistazo)
"""
todos_usuarios = db.query(Usuario).all()
total_usuarios = len(todos_usuarios)
usuarios_con_token = sum(1 for u in todos_usuarios if u.fcm_token)
cobertura = (usuarios_con_token / total_usuarios * 100) if total_usuarios > 0 else 0.0
rutas_detalle = []
rutas_en_progreso = 0
rutas_completadas = 0
for route_id, ruta in ROUTAS_POR_ID.items():
estado = ROUTE_STATE.get(route_id, {})
calculo = _calcular_eta_por_ruta(route_id)
posiciones = ruta.get("positions", [])
total_pos = len(posiciones)
actual_pos = estado.get("last_position_id", 0)
porcentaje = round((actual_pos / total_pos * 100) if total_pos > 0 else 0.0, 1)
status = ruta.get("status", "DESCONOCIDA")
if status == "EN_RUTA":
rutas_en_progreso += 1
elif status == "COMPLETADO":
rutas_completadas += 1
# Contar usuarios asignados a esta ruta
usuarios_en_ruta = db.query(Domicilio).filter(Domicilio.route_id == route_id).count()
ultimo = estado.get("last_timestamp", datetime.now(timezone.utc))
gps_ok = (datetime.now(timezone.utc) - ultimo) < timedelta(minutes=10)
rutas_detalle.append(RutaDetalleResponse(
route_id=route_id,
name=ruta.get("name", "Desconocida"),
status=status,
truck_id=ruta.get("truck_id", 0),
posicion_actual=actual_pos,
total_posiciones=total_pos,
porcentaje_completado=porcentaje,
eta_minutos=calculo["eta_minutos"],
gps_ok=gps_ok,
usuarios_en_ruta=usuarios_en_ruta,
))
return DashboardResponse(
total_rutas=len(ROUTAS_POR_ID),
rutas_en_progreso=rutas_en_progreso,
rutas_completadas=rutas_completadas,
total_usuarios=total_usuarios,
usuarios_con_token=usuarios_con_token,
cobertura_notificaciones=round(cobertura, 1),
rutas=rutas_detalle,
)
@app.get("/api/rutas/{route_id}/posiciones", response_model=List[PosicionGPS], tags=["Visualización"])
def historial_posiciones(route_id: str):
"""
Devuelve el historial completo de posiciones GPS de una ruta,
marcando cuál es la posición actual del camión.
Útil para dibujar el recorrido en un mapa en la app.
"""
ruta = ROUTAS_POR_ID.get(route_id)
if not ruta:
raise HTTPException(status_code=404, detail="Ruta no encontrada.")
estado = ROUTE_STATE.get(route_id, {})
posicion_actual = estado.get("last_position_id", 0)
resultado = []
for pos in ruta.get("positions", []):
resultado.append(PosicionGPS(
position_id=pos["positionId"],
lat=pos["lat"],
lng=pos["lng"],
speed=pos["speed"],
timestamp=pos["timestamp"],
es_actual=(pos["positionId"] == posicion_actual),
))
return resultado
@app.get("/api/rutas/resumen", tags=["Visualización"])
def resumen_rutas(db: Session = Depends(get_db)):
"""
Vista rápida y ligera de todas las rutas activas.
Diseñada para refrescarse cada 30s en la app sin sobrecargar.
"""
resumen = []
for route_id, ruta in ROUTAS_POR_ID.items():
estado = ROUTE_STATE.get(route_id, {})
ultimo = estado.get("last_timestamp", datetime.now(timezone.utc))
gps_ok = (datetime.now(timezone.utc) - ultimo) < timedelta(minutes=10)
calculo = _calcular_eta_por_ruta(route_id)
resumen.append({
"route_id": route_id,
"name": ruta.get("name"),
"status": ruta.get("status"),
"eta_minutos": calculo["eta_minutos"],
"gps_ok": gps_ok,
"posicion_actual": estado.get("last_position_id", 0),
"total_posiciones": len(ruta.get("positions", [])),
})
return {"rutas": resumen, "timestamp": datetime.now(timezone.utc).isoformat()}
@app.get("/api/estadisticas/colonias", response_model=List[ColoniaEstadisticaResponse], tags=["Visualización"])
def estadisticas_por_colonia(db: Session = Depends(get_db)):
"""
Estadísticas por colonia: cuántos usuarios hay y cuántos
tienen notificaciones activas. Útil para el operador.
"""
resultado = []
for item in HORARIOS_POR_COLONIA:
colonia = item["colonia"]
route_id = item["routeId"]
domicilios = db.query(Domicilio).filter(Domicilio.colonia == colonia).all()
total = len(domicilios)
con_notif = sum(1 for d in domicilios if d.usuario and d.usuario.fcm_token)
resultado.append(ColoniaEstadisticaResponse(
colonia=colonia,
route_id=route_id,
ruta_nombre=ROUTAS_POR_ID.get(route_id, {}).get("name", "Desconocida"),
horario=item.get("horarioEstimado", ""),
total_usuarios=total,
usuarios_con_notificaciones=con_notif,
))
return resultado
# ===============================================================
# ENDPOINTS — UTILIDADES
# ===============================================================
@app.get("/api/colonias", tags=["Utilidades"])
def listar_colonias():
return {"colonias": [item["colonia"] for item in HORARIOS_POR_COLONIA]}
@app.post("/api/simular-evento", response_model=SimularEventoResponse, tags=["Core"])
def simular_evento(payload: SimularEventoRequest, db: Session = Depends(get_db)):
if payload.tipo_evento not in TIPOS_EVENTO_VALIDOS:
raise HTTPException(status_code=400, detail=f"tipo_evento inválido. Opciones: {TIPOS_EVENTO_VALIDOS}")
if payload.route_id not in HORARIOS_POR_RUTA:
raise HTTPException(status_code=404, detail=f"Ruta {payload.route_id} no encontrada.")
domicilios = db.query(Domicilio).filter(Domicilio.route_id == payload.route_id).all()
if not domicilios:
return SimularEventoResponse(usuarios_notificados=0, route_id=payload.route_id, tipo_evento=payload.tipo_evento, detalle=["No hay usuarios registrados en esta ruta."])
mensajes_por_evento = {
"en_camino": ("🚛 Camión en camino", "El camión de recolección está en ruta hacia tu zona."),
"llegando": ("⚠️ ¡El camión está cerca!", "Saca tu basura ahora, el camión llega en minutos."),
"completado": ("✅ Recolección completada", "El camión ya pasó por tu zona. Nos vemos mañana."),
"retrasado": ("🕐 Retraso en ruta", "El camión se ha retrasado. Te avisaremos cuando esté cerca."),
}
titulo, cuerpo = mensajes_por_evento[payload.tipo_evento]
detalle_log = []
notificados = 0
for domicilio in domicilios:
usuario = domicilio.usuario
if not usuario:
detalle_log.append(f"Domicilio ID {domicilio.id}: sin usuario asociado.")
continue
if not usuario.fcm_token:
detalle_log.append(f"Usuario '{usuario.nombre}': sin FCM token.")
continue
if enviar_notificacion_firebase(usuario.fcm_token, titulo, cuerpo):
notificados += 1
detalle_log.append(f"✅ Push a {usuario.nombre} ({domicilio.colonia})")
else:
detalle_log.append(f"❌ Fallo push a {usuario.nombre}")
return SimularEventoResponse(usuarios_notificados=notificados, route_id=payload.route_id, tipo_evento=payload.tipo_evento, detalle=detalle_log)
@app.post("/api/seed", tags=["Utilidades"])
def seed_datos(db: Session = Depends(get_db)):
if db.query(Usuario).count() > 0:
return {"mensaje": "Ya hay datos en la DB. No se hizo nada."}
usuarios_seed = [
{"nombre": "Ana García", "email": "ana@example.com", "colonia": "Zona Centro", "direccion": "Calle Principal 123", "password": "123456"},
{"nombre": "Carlos López", "email": "carlos@example.com", "colonia": "Las Arboledas", "direccion": "Av. Hidalgo 45", "password": "123456"},
{"nombre": "María Torres", "email": "maria@example.com", "colonia": "San Juanico", "direccion": "Calle Luna 12", "password": "123456"},
{"nombre": "Pedro Ruiz", "email": "pedro@example.com", "colonia": "Los Olivos", "direccion": "Calle Sol 78", "password": "123456"},
]
for u in usuarios_seed:
route_id = COLONIAS_A_RUTAS.get(u["colonia"], "RUTA-01")
usuario = Usuario(nombre=u["nombre"], email=u["email"], password_hash=hash_password(u["password"]))
db.add(usuario)
db.flush()
db.add(Domicilio(usuario_id=usuario.id, colonia=u["colonia"], direccion=u["direccion"], route_id=route_id))
db.commit()
logger.info("✅ Seed completado: 4 usuarios creados con bcrypt")
return {"mensaje": "Seed exitoso. Passwords: 123456 para todos."}
# Schema Pydantic para la respuesta
# (agregar junto a los otros schemas en main.py)
class SubseccionInfo(BaseModel):
subtitulo: str
texto: str
class ArticuloInfo(BaseModel):
id: str
categoria: str
emoji: str
titulo: str
resumen: str
contenido: List[SubseccionInfo]
consejo_rapido: str
# ---------------------------------------------------------------
# ENDPOINTS DE INFORMACIÓN
# (agregar en la sección de endpoints de main.py)
# ---------------------------------------------------------------
@app.get("/api/info", tags=["Información"])
def listar_articulos():
"""Lista todos los artículos de información relevante (solo metadatos)."""
return {
"articulos": [
{
"id": a["id"],
"categoria": a["categoria"],
"emoji": a.get("emoji", "♻️"),
"titulo": a["titulo"],
"resumen": a["resumen"],
}
for a in INFO_ARTICULOS
]
}
@app.get("/api/info/{articulo_id}", tags=["Información"])
def obtener_articulo(articulo_id: str):
"""Devuelve el contenido completo de un artículo por su ID."""
articulo = next((a for a in INFO_ARTICULOS if a["id"] == articulo_id), None)
if not articulo:
raise HTTPException(status_code=404, detail="Artículo no encontrado.")
return articulo
# ---------------------------------------------------------------
# ================================================================
# PEGA ESTE BLOQUE EN main.py JUSTO ANTES DE:
# if __name__ == "__main__":
#
# Y agrega al inicio de main.py:
# from analytics import generar_reporte_completo
# import time as _time
# ================================================================
# Cache simple en memoria para no recalcular en cada request
_reporte_cache: dict = {"data": None, "ts": 0}
_CACHE_TTL = 600 # 10 minutos
@app.get("/api/analytics/reporte", tags=["Analytics"])
def obtener_reporte():
"""
Reporte completo: días pico, zonas críticas, predicción 7 días
y recomendaciones de logística.
Cacheado 10 minutos — pesado de calcular, ligero de servir.
"""
import time as _time
ahora = _time.time()
if _reporte_cache["data"] is None or (ahora - _reporte_cache["ts"]) > _CACHE_TTL:
_reporte_cache["data"] = generar_reporte_completo(dias_historico=90)
_reporte_cache["ts"] = ahora
return _reporte_cache["data"]
@app.get("/api/analytics/prediccion/{colonia}", tags=["Analytics"])
def prediccion_colonia(colonia: str):
"""
Predicción de los próximos 7 días solo para una colonia específica.
Más rápido que el reporte completo para consultas frecuentes.
"""
colonias_validas = [
"Zona Centro", "Las Arboledas", "Trojes",
"San Juanico", "Los Olivos", "Rancho Seco", "Las Insurgentes",
]
if colonia not in colonias_validas:
raise HTTPException(
status_code=400,
detail=f"Colonia no válida. Opciones: {colonias_validas}"
)
reporte = generar_reporte_completo(90)
pred = next(
(p for p in reporte["prediccion_proxima_semana"] if p["colonia"] == colonia),
None
)
if not pred:
raise HTTPException(status_code=404, detail="Sin datos para esta colonia.")
return pred
@app.get("/api/analytics/resumen-ejecutivo", tags=["Analytics"])
def resumen_ejecutivo():
"""
Vista rápida: solo el resumen + recomendaciones.
Ideal para mostrar en el dashboard sin cargar todos los datos.
"""
reporte = generar_reporte_completo(90)
return {
"resumen": reporte["resumen"],
"recomendaciones": reporte["recomendaciones"],
"dia_pico": reporte["dias_semana"][0] if reporte["dias_semana"] else None,
"zona_mas_critica": reporte["zonas_criticas"][0] if reporte["zonas_criticas"] else None,
}
# ================================================================
# PEGA ESTE BLOQUE EN main.py JUSTO ANTES DE:
# if __name__ == "__main__":
#
# TAMBIÉN agrega estos imports al inicio de main.py:
# from analytics_real import generar_reporte_real
# ================================================================
# ---------------------------------------------------------------
# SCHEMAS PARA REPORTES DE USUARIO
# ---------------------------------------------------------------
class ReporteUsuarioRequest(BaseModel):
colonia: str
tipo: str # VOLUMEN_ALTO | CAMION_NO_PASO | BASURA_FUERA_HORARIO | OTRO
descripcion: Optional[str] = None
class ReporteUsuarioResponse(BaseModel):
reporte_id: int
fecha: str
colonia: str
tipo: str
descripcion: Optional[str]
estado: str
mensaje: str
class ReporteListResponse(BaseModel):
reporte_id: int
fecha: str
hora: str
colonia: str
tipo: str
descripcion: Optional[str]
estado: str
# ---------------------------------------------------------------
# HELPER: Estimar volumen por posición de ruta
# Cada posición representa un tramo; el volumen aumenta gradualmente.
# ---------------------------------------------------------------
def _estimar_volumen_kg(position_id: int, total_positions: int = 8) -> int:
"""Estima los kg recolectados hasta esta posición (lineal)."""
base_por_ruta = 1200 # kg promedio total por ruta
return int(base_por_ruta * (position_id / total_positions))
def _registrar_evento_ruta(
db: Session,
route_id: str,
position_id: int,
evento: str,
colonia: str,
) -> None:
"""Inserta un registro de recolección en la DB."""
ahora = datetime.now(timezone.utc)
ruta = ROUTAS_POR_ID.get(route_id, {})
total_pos = len(ruta.get("positions", [])) or 8
estado = ROUTE_STATE.get(route_id, {})
inicio_ts = estado.get("inicio_timestamp")
tiempo_min = None
if inicio_ts:
tiempo_min = int((ahora - inicio_ts).total_seconds() / 60)
db.add(RegistroRecoleccion(
fecha=ahora.strftime("%Y-%m-%d"),
hora=ahora.strftime("%H:%M:%S"),
ruta_id=route_id,
colonia=colonia,
position_id=position_id,
evento=evento,
volumen_kg=_estimar_volumen_kg(position_id, total_pos),
tiempo_min=tiempo_min,
))
db.commit()
# ---------------------------------------------------------------
# ENDPOINTS — REPORTES DE USUARIO
# ---------------------------------------------------------------
@app.post("/api/reportes", response_model=ReporteUsuarioResponse, tags=["Reportes"])
def crear_reporte(
usuario_id: int,
payload: ReporteUsuarioRequest,
db: Session = Depends(get_db),
):
"""
El ciudadano envía un reporte manual:
- Camión no pasó
- Volumen inusualmente alto
- Basura tirada fuera de horario
- Otro (con descripción libre)
"""
tipos_validos = ["VOLUMEN_ALTO", "CAMION_NO_PASO", "BASURA_FUERA_HORARIO", "OTRO"]
if payload.tipo not in tipos_validos:
raise HTTPException(
status_code=400,
detail=f"Tipo inválido. Opciones: {tipos_validos}"
)
usuario = db.query(Usuario).filter(Usuario.id == usuario_id).first()
if not usuario:
raise HTTPException(status_code=404, detail="Usuario no encontrado.")
route_id = COLONIAS_A_RUTAS.get(payload.colonia, "RUTA-01")
ahora = datetime.now(timezone.utc)
reporte = ReporteUsuario(
usuario_id=usuario_id,
fecha=ahora.strftime("%Y-%m-%d"),
hora=ahora.strftime("%H:%M:%S"),
colonia=payload.colonia,
ruta_id=route_id,
tipo=payload.tipo,
descripcion=payload.descripcion,
estado="PENDIENTE",
)
db.add(reporte)
db.commit()
db.refresh(reporte)
logger.info(f"📋 Reporte #{reporte.id} creado por usuario {usuario_id}: {payload.tipo}")
return ReporteUsuarioResponse(
reporte_id=reporte.id,
fecha=reporte.fecha,
colonia=reporte.colonia,
tipo=reporte.tipo,
descripcion=reporte.descripcion,
estado=reporte.estado,
mensaje="Reporte enviado correctamente. Gracias por contribuir.",
)
@app.get("/api/reportes/usuario/{usuario_id}", response_model=List[ReporteListResponse], tags=["Reportes"])
def listar_reportes_usuario(usuario_id: int, db: Session = Depends(get_db)):
"""Historial de reportes enviados por un usuario específico."""
reportes = (
db.query(ReporteUsuario)
.filter(ReporteUsuario.usuario_id == usuario_id)
.order_by(ReporteUsuario.id.desc())
.all()
)
return [
ReporteListResponse(
reporte_id=r.id,
fecha=r.fecha,
hora=r.hora,
colonia=r.colonia,
tipo=r.tipo,
descripcion=r.descripcion,
estado=r.estado,
)
for r in reportes
]
@app.get("/api/reportes", tags=["Reportes"])
def listar_todos_reportes(
estado: Optional[str] = None,
colonia: Optional[str] = None,
db: Session = Depends(get_db),
):
"""Vista de operador: todos los reportes, filtrables por estado o colonia."""
query = db.query(ReporteUsuario)
if estado:
query = query.filter(ReporteUsuario.estado == estado)
if colonia:
query = query.filter(ReporteUsuario.colonia == colonia)
reportes = query.order_by(ReporteUsuario.id.desc()).limit(100).all()
return {
"total": len(reportes),
"reportes": [
{
"reporte_id": r.id,
"usuario_id": r.usuario_id,
"fecha": r.fecha,
"hora": r.hora,
"colonia": r.colonia,
"ruta_id": r.ruta_id,
"tipo": r.tipo,
"descripcion": r.descripcion,
"estado": r.estado,
}
for r in reportes
],
}
@app.put("/api/reportes/{reporte_id}/atender", tags=["Reportes"])
def atender_reporte(reporte_id: int, db: Session = Depends(get_db)):
"""Marca un reporte como atendido (uso del operador)."""
reporte = db.query(ReporteUsuario).filter(ReporteUsuario.id == reporte_id).first()
if not reporte:
raise HTTPException(status_code=404, detail="Reporte no encontrado.")
reporte.estado = "ATENDIDO"
db.commit()
return {"mensaje": f"Reporte #{reporte_id} marcado como atendido."}
# ---------------------------------------------------------------
# ENDPOINT: Analytics con datos reales de la DB
# ---------------------------------------------------------------
@app.get("/api/analytics/reporte-real", tags=["Analytics"])
def reporte_real(db: Session = Depends(get_db)):
return generar_reporte_real(db)
@app.get("/api/analytics/registros", tags=["Analytics"])
def listar_registros(
ruta_id: Optional[str] = None,
colonia: Optional[str] = None,
fecha_inicio: Optional[str] = None,
db: Session = Depends(get_db),
):
"""Lista los registros de recolección reales guardados en la DB."""
query = db.query(RegistroRecoleccion)
if ruta_id:
query = query.filter(RegistroRecoleccion.ruta_id == ruta_id)
if colonia:
query = query.filter(RegistroRecoleccion.colonia == colonia)
if fecha_inicio:
query = query.filter(RegistroRecoleccion.fecha >= fecha_inicio)
registros = query.order_by(RegistroRecoleccion.id.desc()).limit(500).all()
return {
"total": len(registros),
"registros": [
{
"id": r.id,
"fecha": r.fecha,
"hora": r.hora,
"ruta_id": r.ruta_id,
"colonia": r.colonia,
"position_id": r.position_id,
"evento": r.evento,
"volumen_kg": r.volumen_kg,
"tiempo_min": r.tiempo_min,
}
for r in registros
],
}
if __name__ == "__main__":
import uvicorn
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)