""" =============================================================== 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 # --------------------------------------------------------------- # 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") # --------------------------------------------------------------- # 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", "emoji": "♻️", "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", "emoji": "⏰", "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", "emoji": "🧴", "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", "emoji": "🌱", "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", "emoji": "⚠️", "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", "emoji": "🌍", "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}") 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["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 # --------------------------------------------------------------- if __name__ == "__main__": import uvicorn uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)