1400 lines
65 KiB
Python
1400 lines
65 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
|
|
|
|
# ---------------------------------------------------------------
|
|
# 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)
|