Agrega backend FastAPI al proyecto

This commit is contained in:
Erick Cesar Mondragon Palacios
2026-05-22 23:15:56 -06:00
parent 4f2e099ea8
commit 7da903a0ab
18 changed files with 1502 additions and 0 deletions

View File

@@ -0,0 +1,4 @@
APP_NAME=Recolector Inteligente API
DATABASE_URL=sqlite:///./recolector.db
JWT_SECRET=CAMBIA_ESTE_SECRETO_EN_PRODUCCION
JWT_EXPIRE_MINUTES=1440

View File

@@ -0,0 +1,203 @@
# Recolector Inteligente API
Backend completo para el prototipo del hackathon. Incluye autenticación JWT, roles, rutas, domicilios, reportes, alertas operativas, operador y administrador.
## Roles incluidos
- **Ciudadano**: registra domicilios, consulta ETA, ve alertas de su ruta, manda reportes y califica.
- **Operador**: ve sus rutas asignadas, inicia jornada, avanza positionId, reporta retrasos, averías e incidencias.
- **Administrador**: ve dashboard logístico, reportes, alertas, rutas, operadores y asigna operadores a rutas.
## Instalación
Desde la carpeta `recolector_backend`:
```bash
python -m venv .venv
```
Windows PowerShell:
```bash
.venv\Scripts\Activate.ps1
```
Mac/Linux:
```bash
source .venv/bin/activate
```
Instalar dependencias:
```bash
pip install -r requirements.txt
```
Copiar variables de entorno:
```bash
copy .env.example .env
```
En Mac/Linux:
```bash
cp .env.example .env
```
Correr servidor:
```bash
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
```
Abrir documentación interactiva:
```text
http://127.0.0.1:8000/docs
```
## Cuentas demo
```text
Ciudadano:
demo@correo.com
123456
Operador:
operador@demo.com
123456
Operador 2:
operador2@demo.com
123456
Administrador:
admin@demo.com
123456
```
## Flujo rápido de prueba en /docs
1. `POST /auth/login` con `admin@demo.com` o el rol que quieras.
2. Copia el `access_token`.
3. Presiona **Authorize** arriba a la derecha.
4. Escribe: `Bearer TU_TOKEN`.
5. Prueba endpoints según rol.
## Endpoints principales
### Auth
```text
POST /auth/login
POST /auth/register
GET /auth/me
```
### Público
```text
GET /public/health
GET /public/colonias
GET /public/routes
GET /public/guide
```
### Ciudadano
```text
GET /citizen/domicilios
POST /citizen/domicilios
GET /citizen/domicilios/{domicilio_id}/eta
GET /citizen/alerts
POST /citizen/reports
POST /citizen/ratings
```
Ejemplo para crear domicilio:
```json
{
"tipo": "Casa principal",
"direccion": "Calle Luna 123",
"colonia": "Zona Centro",
"lat": 20.521,
"lng": -100.821
}
```
### Operador
```text
GET /operator/routes
GET /operator/routes/{route_id}
GET /operator/alerts
POST /operator/routes/{route_id}/start
POST /operator/routes/{route_id}/advance/{position_id}
POST /operator/routes/{route_id}/delay
POST /operator/routes/{route_id}/breakdown
POST /operator/routes/{route_id}/incident
POST /operator/routes/{route_id}/complete
```
### Administrador
```text
GET /admin/dashboard
GET /admin/users
GET /admin/operators
GET /admin/routes
POST /admin/routes/{route_id}/assign-operator
GET /admin/alerts
PATCH /admin/alerts/{alert_id}/close
GET /admin/reports
PATCH /admin/reports/{report_id}/status
```
## Simulador de ruta
En otra terminal, con el servidor corriendo:
```bash
python -m app.simulator RUTA-01
```
Esto actualiza `positionId` y genera alertas automáticamente.
## Cómo conectar Flutter
Agrega `http`:
```bash
flutter pub add http
```
Ejemplo base:
```dart
import 'dart:convert';
import 'package:http/http.dart' as http;
const apiBase = 'http://10.0.2.2:8000'; // Android emulator
// const apiBase = 'http://127.0.0.1:8000'; // Chrome en la misma PC
Future<String> login(String email, String password) async {
final res = await http.post(
Uri.parse('$apiBase/auth/login'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({'email': email, 'password': password}),
);
if (res.statusCode >= 400) {
throw Exception(res.body);
}
return jsonDecode(res.body)['access_token'];
}
```
## Nota de arquitectura
Este backend usa SQLite para que corra rápido en hackathon. Para producción pueden cambiar `DATABASE_URL` a PostgreSQL/MySQL y conservar la misma API.

View File

View File

@@ -0,0 +1,13 @@
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
app_name: str = "Recolector Inteligente API"
database_url: str = "sqlite:///./recolector.db"
jwt_secret: str = "CAMBIA_ESTE_SECRETO_EN_PRODUCCION"
jwt_expire_minutes: int = 1440
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
settings = Settings()

View File

@@ -0,0 +1,20 @@
from sqlalchemy import create_engine
from sqlalchemy.orm import DeclarativeBase, sessionmaker
from app.config import settings
connect_args = {"check_same_thread": False} if settings.database_url.startswith("sqlite") else {}
engine = create_engine(settings.database_url, connect_args=connect_args)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
class Base(DeclarativeBase):
pass
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()

View File

@@ -0,0 +1,42 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.config import settings
from app.database import Base, SessionLocal, engine
from app.routers import admin, auth, citizen, operator, public
from app.seed import seed_database
Base.metadata.create_all(bind=engine)
with SessionLocal() as db:
seed_database(db)
app = FastAPI(
title=settings.app_name,
version="1.0.0",
description="Backend MVP para app de notificación privada de recolección de residuos por roles: ciudadano, operador y administrador.",
)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(public.router)
app.include_router(auth.router)
app.include_router(citizen.router)
app.include_router(operator.router)
app.include_router(admin.router)
@app.get("/")
def root():
return {
"ok": True,
"name": settings.app_name,
"docs": "/docs",
"roles": ["ciudadano", "operador", "admin"],
}

View File

@@ -0,0 +1,113 @@
from datetime import datetime
from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
name: Mapped[str] = mapped_column(String(120), nullable=False)
email: Mapped[str] = mapped_column(String(160), unique=True, index=True, nullable=False)
phone: Mapped[str | None] = mapped_column(String(30), nullable=True)
password_hash: Mapped[str] = mapped_column(String(200), nullable=False)
role: Mapped[str] = mapped_column(String(30), index=True, nullable=False) # ciudadano, operador, admin
active: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
domicilios: Mapped[list["Domicilio"]] = relationship(back_populates="user")
assigned_routes: Mapped[list["Route"]] = relationship(back_populates="assigned_operator")
class Colonia(Base):
__tablename__ = "colonias"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
colonia: Mapped[str] = mapped_column(String(120), unique=True, index=True, nullable=False)
route_id: Mapped[str] = mapped_column(String(30), ForeignKey("routes.route_id"), nullable=False)
horario_estimado: Mapped[str] = mapped_column(String(120), nullable=False)
class Route(Base):
__tablename__ = "routes"
route_id: Mapped[str] = mapped_column(String(30), primary_key=True, index=True)
name: Mapped[str] = mapped_column(String(160), nullable=False)
truck_id: Mapped[int] = mapped_column(Integer, index=True, nullable=False)
status: Mapped[str] = mapped_column(String(50), default="PENDIENTE")
assigned_operator_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("users.id"), nullable=True)
current_position_id: Mapped[int] = mapped_column(Integer, default=1)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
assigned_operator: Mapped[User | None] = relationship(back_populates="assigned_routes")
positions: Mapped[list["RoutePosition"]] = relationship(back_populates="route", cascade="all, delete-orphan")
class RoutePosition(Base):
__tablename__ = "route_positions"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
route_id: Mapped[str] = mapped_column(String(30), ForeignKey("routes.route_id"), index=True)
position_id: Mapped[int] = mapped_column(Integer, index=True)
lat: Mapped[float] = mapped_column(Float, nullable=False)
lng: Mapped[float] = mapped_column(Float, nullable=False)
speed: Mapped[int] = mapped_column(Integer, default=0)
timestamp: Mapped[str] = mapped_column(String(40), nullable=False)
route: Mapped[Route] = relationship(back_populates="positions")
class Domicilio(Base):
__tablename__ = "domicilios"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), index=True)
tipo: Mapped[str] = mapped_column(String(80), default="Casa principal")
direccion: Mapped[str] = mapped_column(String(220), nullable=False)
colonia: Mapped[str] = mapped_column(String(120), nullable=False)
lat: Mapped[float | None] = mapped_column(Float, nullable=True)
lng: Mapped[float | None] = mapped_column(Float, nullable=True)
route_id: Mapped[str] = mapped_column(String(30), ForeignKey("routes.route_id"), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
user: Mapped[User] = relationship(back_populates="domicilios")
class Alert(Base):
__tablename__ = "alerts"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
type: Mapped[str] = mapped_column(String(50), index=True, nullable=False)
title: Mapped[str] = mapped_column(String(140), nullable=False)
message: Mapped[str] = mapped_column(Text, nullable=False)
route_id: Mapped[str] = mapped_column(String(30), ForeignKey("routes.route_id"), index=True)
truck_id: Mapped[int] = mapped_column(Integer, index=True)
operator_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("users.id"), nullable=True)
priority: Mapped[int] = mapped_column(Integer, default=1) # 1 baja, 2 media, 3 alta
status: Mapped[str] = mapped_column(String(40), default="NUEVA")
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
class Report(Base):
__tablename__ = "reports"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), index=True)
domicilio_id: Mapped[int] = mapped_column(Integer, ForeignKey("domicilios.id"), index=True)
type: Mapped[str] = mapped_column(String(80), nullable=False)
comment: Mapped[str] = mapped_column(Text, nullable=False)
status: Mapped[str] = mapped_column(String(40), default="NUEVO")
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
class Rating(Base):
__tablename__ = "ratings"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), index=True)
domicilio_id: Mapped[int] = mapped_column(Integer, ForeignKey("domicilios.id"), index=True)
stars: Mapped[int] = mapped_column(Integer, nullable=False)
comment: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)

View File

@@ -0,0 +1,110 @@
from sqlalchemy import func
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app.database import get_db
from app.models import Alert, Rating, Report, Route, User
from app.schemas import (
AlertOut,
AssignOperatorIn,
DashboardOut,
ReportOut,
ReportStatusUpdate,
RouteOut,
UserOut,
)
from app.security import require_role
router = APIRouter(prefix="/admin", tags=["Admin"])
@router.get("/dashboard", response_model=DashboardOut)
def dashboard(db: Session = Depends(get_db), admin: User = Depends(require_role("admin"))):
routes_total = db.query(Route).count()
active_routes = db.query(Route).filter(Route.status.in_(["EN_RUTA", "RETRASO", "AVERIA", "INCIDENCIA"])).count()
trucks_total = db.query(func.count(func.distinct(Route.truck_id))).scalar() or 0
operators_total = db.query(User).filter(User.role == "operador").count()
alerts_open = db.query(Alert).filter(Alert.status != "CERRADA").count()
reports_open = db.query(Report).filter(Report.status.in_(["NUEVO", "EN_REVISION"])).count()
avg = db.query(func.avg(Rating.stars)).scalar()
return DashboardOut(
routes_total=routes_total,
active_routes=active_routes,
trucks_total=trucks_total,
operators_total=operators_total,
alerts_open=alerts_open,
reports_open=reports_open,
average_rating=round(float(avg or 0), 2),
)
@router.get("/users", response_model=list[UserOut])
def users(db: Session = Depends(get_db), admin: User = Depends(require_role("admin"))):
return db.query(User).order_by(User.role.asc(), User.name.asc()).all()
@router.get("/operators", response_model=list[UserOut])
def operators(db: Session = Depends(get_db), admin: User = Depends(require_role("admin"))):
return db.query(User).filter(User.role == "operador").order_by(User.name.asc()).all()
@router.get("/routes", response_model=list[RouteOut])
def routes(db: Session = Depends(get_db), admin: User = Depends(require_role("admin"))):
return db.query(Route).order_by(Route.route_id.asc()).all()
@router.post("/routes/{route_id}/assign-operator", response_model=RouteOut)
def assign_operator(
route_id: str,
payload: AssignOperatorIn,
db: Session = Depends(get_db),
admin: User = Depends(require_role("admin")),
):
route = db.get(Route, route_id)
if not route:
raise HTTPException(status_code=404, detail="Ruta no encontrada")
operator = db.get(User, payload.operator_id)
if not operator or operator.role != "operador":
raise HTTPException(status_code=422, detail="Operador inválido")
route.assigned_operator_id = operator.id
db.commit()
db.refresh(route)
return route
@router.get("/alerts", response_model=list[AlertOut])
def alerts(db: Session = Depends(get_db), admin: User = Depends(require_role("admin"))):
return db.query(Alert).order_by(Alert.created_at.desc()).limit(100).all()
@router.patch("/alerts/{alert_id}/close", response_model=AlertOut)
def close_alert(alert_id: int, db: Session = Depends(get_db), admin: User = Depends(require_role("admin"))):
alert = db.get(Alert, alert_id)
if not alert:
raise HTTPException(status_code=404, detail="Alerta no encontrada")
alert.status = "CERRADA"
db.commit()
db.refresh(alert)
return alert
@router.get("/reports", response_model=list[ReportOut])
def reports(db: Session = Depends(get_db), admin: User = Depends(require_role("admin"))):
return db.query(Report).order_by(Report.created_at.desc()).limit(100).all()
@router.patch("/reports/{report_id}/status", response_model=ReportOut)
def update_report_status(
report_id: int,
payload: ReportStatusUpdate,
db: Session = Depends(get_db),
admin: User = Depends(require_role("admin")),
):
report = db.get(Report, report_id)
if not report:
raise HTTPException(status_code=404, detail="Reporte no encontrado")
report.status = payload.status
db.commit()
db.refresh(report)
return report

View File

@@ -0,0 +1,108 @@
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy.orm import Session
from app.database import get_db
from app.models import User
from app.schemas import RegisterIn, TokenOut, UserOut
from app.security import (
create_access_token,
get_current_user,
hash_password,
validate_email,
verify_password,
)
router = APIRouter(prefix="/auth", tags=["Auth"])
@router.post("/login", response_model=TokenOut)
async def login(request: Request, db: Session = Depends(get_db)):
"""
Login compatible con:
1. Swagger Authorize OAuth2 Password:
username=correo
password=contraseña
2. Flutter / Postman JSON:
{
"email": "admin@demo.com",
"password": "123456"
}
"""
content_type = request.headers.get("content-type", "")
email = ""
password = ""
if "application/x-www-form-urlencoded" in content_type or "multipart/form-data" in content_type:
form = await request.form()
email = str(form.get("username") or form.get("email") or "").strip()
password = str(form.get("password") or "").strip()
else:
try:
payload = await request.json()
except Exception:
payload = {}
email = str(payload.get("email") or payload.get("username") or "").strip()
password = str(payload.get("password") or "").strip()
if not email or not password:
raise HTTPException(
status_code=422,
detail="Debes enviar correo y contraseña.",
)
email = validate_email(email)
user = db.query(User).filter(User.email == email).first()
if not user or not verify_password(password, user.password_hash):
raise HTTPException(
status_code=401,
detail="Correo o contraseña incorrectos",
)
token = create_access_token(user)
return TokenOut(
access_token=token,
token_type="bearer",
user=user,
)
@router.post("/register", response_model=TokenOut)
def register(payload: RegisterIn, db: Session = Depends(get_db)):
email = validate_email(payload.email)
role = payload.role.strip().lower()
if role not in {"ciudadano", "operador", "admin"}:
raise HTTPException(status_code=422, detail="Rol inválido")
if db.query(User).filter(User.email == email).first():
raise HTTPException(status_code=409, detail="Ese correo ya está registrado")
user = User(
name=payload.name.strip(),
email=email,
phone=payload.phone,
password_hash=hash_password(payload.password),
role=role,
)
db.add(user)
db.commit()
db.refresh(user)
return TokenOut(
access_token=create_access_token(user),
token_type="bearer",
user=user,
)
@router.get("/me", response_model=UserOut)
def me(current_user: User = Depends(get_current_user)):
return current_user

View File

@@ -0,0 +1,160 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app.database import get_db
from app.models import Alert, Colonia, Domicilio, Rating, Report, Route, User
from app.schemas import (
AlertOut,
DomicilioCreate,
DomicilioOut,
EtaOut,
RatingCreate,
RatingOut,
ReportCreate,
ReportOut,
)
from app.security import get_current_user, require_role, validate_address
router = APIRouter(prefix="/citizen", tags=["Citizen"])
def normalize(text: str) -> str:
repl = str.maketrans("áéíóúüÁÉÍÓÚÜ", "aeiouuAEIOUU")
return text.strip().translate(repl).lower()
def find_colonia(db: Session, colonia_name: str) -> Colonia:
wanted = normalize(colonia_name)
colonias = db.query(Colonia).all()
for c in colonias:
if normalize(c.colonia) == wanted:
return c
raise HTTPException(status_code=422, detail="Colonia no válida o fuera de cobertura")
def ensure_owner(db: Session, domicilio_id: int, user: User) -> Domicilio:
domicilio = db.get(Domicilio, domicilio_id)
if not domicilio or domicilio.user_id != user.id:
raise HTTPException(status_code=404, detail="Domicilio no encontrado")
return domicilio
@router.get("/domicilios", response_model=list[DomicilioOut])
def list_domicilios(
db: Session = Depends(get_db),
current_user: User = Depends(require_role("ciudadano")),
):
return db.query(Domicilio).filter(Domicilio.user_id == current_user.id).all()
@router.post("/domicilios", response_model=DomicilioOut)
def create_domicilio(
payload: DomicilioCreate,
db: Session = Depends(get_db),
current_user: User = Depends(require_role("ciudadano")),
):
direccion = validate_address(payload.direccion)
colonia = find_colonia(db, payload.colonia)
domicilio = Domicilio(
user_id=current_user.id,
tipo=payload.tipo.strip() or "Casa principal",
direccion=direccion,
colonia=colonia.colonia,
lat=payload.lat,
lng=payload.lng,
route_id=colonia.route_id,
)
db.add(domicilio)
db.commit()
db.refresh(domicilio)
return domicilio
@router.get("/domicilios/{domicilio_id}/eta", response_model=EtaOut)
def get_eta(
domicilio_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_role("ciudadano")),
):
domicilio = ensure_owner(db, domicilio_id, current_user)
route = db.get(Route, domicilio.route_id)
colonia = db.query(Colonia).filter(Colonia.colonia == domicilio.colonia).first()
if not route or not colonia:
raise HTTPException(status_code=404, detail="Ruta no encontrada")
if route.current_position_id >= 8:
eta = "El servicio de tu sector ya finalizó."
elif route.current_position_id >= 4:
eta = "El camión llegará a tu zona en aproximadamente 15 minutos."
elif route.status in {"RETRASO", "AVERIA"}:
eta = "Hay una incidencia operativa. Revisa tus alertas antes de sacar tus residuos."
else:
eta = f"Ventana estimada de recolección: {colonia.horario_estimado}."
return EtaOut(
domicilio_id=domicilio.id,
route_id=route.route_id,
route_name=route.name,
truck_id=route.truck_id,
colonia=domicilio.colonia,
horario_estimado=colonia.horario_estimado,
eta_message=eta,
current_position_id=route.current_position_id,
privacy_note="Privacidad por diseño: no se expone el mapa ni la ubicación exacta del camión.",
)
@router.get("/alerts", response_model=list[AlertOut])
def my_alerts(
db: Session = Depends(get_db),
current_user: User = Depends(require_role("ciudadano")),
):
route_ids = [d.route_id for d in db.query(Domicilio).filter(Domicilio.user_id == current_user.id).all()]
if not route_ids:
return []
return (
db.query(Alert)
.filter(Alert.route_id.in_(route_ids))
.order_by(Alert.created_at.desc())
.limit(20)
.all()
)
@router.post("/reports", response_model=ReportOut)
def create_report(
payload: ReportCreate,
db: Session = Depends(get_db),
current_user: User = Depends(require_role("ciudadano")),
):
ensure_owner(db, payload.domicilio_id, current_user)
report = Report(
user_id=current_user.id,
domicilio_id=payload.domicilio_id,
type=payload.type.strip(),
comment=payload.comment.strip(),
)
db.add(report)
db.commit()
db.refresh(report)
return report
@router.post("/ratings", response_model=RatingOut)
def create_rating(
payload: RatingCreate,
db: Session = Depends(get_db),
current_user: User = Depends(require_role("ciudadano")),
):
ensure_owner(db, payload.domicilio_id, current_user)
rating = Rating(
user_id=current_user.id,
domicilio_id=payload.domicilio_id,
stars=payload.stars,
comment=payload.comment,
)
db.add(rating)
db.commit()
db.refresh(rating)
return rating

View File

@@ -0,0 +1,242 @@
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session, selectinload
from app.database import get_db
from app.models import Alert, Route, User
from app.schemas import AlertOut, OperatorActionOut, RouteDetailOut, RouteOut
from app.security import require_role
router = APIRouter(prefix="/operator", tags=["Operator"])
def get_assigned_route(db: Session, route_id: str, operator: User) -> Route:
route = (
db.query(Route)
.options(selectinload(Route.positions))
.filter(Route.route_id == route_id)
.first()
)
if not route:
raise HTTPException(status_code=404, detail="Ruta no encontrada")
if route.assigned_operator_id != operator.id:
raise HTTPException(status_code=403, detail="Esta ruta no está asignada a este operador")
return route
def create_alert(
db: Session,
route: Route,
operator: User,
type_: str,
title: str,
message: str,
priority: int,
) -> Alert:
alert = Alert(
type=type_,
title=title,
message=message,
route_id=route.route_id,
truck_id=route.truck_id,
operator_id=operator.id,
priority=priority,
status="NUEVA",
)
db.add(alert)
db.flush()
return alert
def action_response(db: Session, route: Route, alert: Alert | None, message: str) -> OperatorActionOut:
db.commit()
db.refresh(route)
if alert:
db.refresh(alert)
return OperatorActionOut(ok=True, route=route, alert=alert, message=message)
@router.get("/routes", response_model=list[RouteOut])
def my_routes(
db: Session = Depends(get_db),
operator: User = Depends(require_role("operador")),
):
return (
db.query(Route)
.filter(Route.assigned_operator_id == operator.id)
.order_by(Route.route_id.asc())
.all()
)
@router.get("/routes/{route_id}", response_model=RouteDetailOut)
def route_detail(
route_id: str,
db: Session = Depends(get_db),
operator: User = Depends(require_role("operador")),
):
route = get_assigned_route(db, route_id, operator)
route.positions = sorted(route.positions, key=lambda p: p.position_id)
return route
@router.get("/alerts", response_model=list[AlertOut])
def my_sent_alerts(
db: Session = Depends(get_db),
operator: User = Depends(require_role("operador")),
):
return (
db.query(Alert)
.filter(Alert.operator_id == operator.id)
.order_by(Alert.created_at.desc())
.limit(30)
.all()
)
@router.post("/routes/{route_id}/start", response_model=OperatorActionOut)
def start_route(
route_id: str,
db: Session = Depends(get_db),
operator: User = Depends(require_role("operador")),
):
route = get_assigned_route(db, route_id, operator)
route.status = "EN_RUTA"
route.current_position_id = 2
route.updated_at = datetime.utcnow()
alert = create_alert(
db,
route,
operator,
"ROUTE_START",
"Ruta iniciada",
f"El camión {route.truck_id} inició la ruta {route.route_id}.",
1,
)
return action_response(db, route, alert, "Jornada iniciada correctamente")
@router.post("/routes/{route_id}/advance/{position_id}", response_model=OperatorActionOut)
def advance_route(
route_id: str,
position_id: int,
db: Session = Depends(get_db),
operator: User = Depends(require_role("operador")),
):
route = get_assigned_route(db, route_id, operator)
valid_positions = {p.position_id for p in route.positions}
if position_id not in valid_positions:
raise HTTPException(status_code=422, detail="positionId inválido para esta ruta")
route.current_position_id = position_id
route.status = "EN_RUTA" if position_id < 8 else "FINALIZADA"
route.updated_at = datetime.utcnow()
alert = None
if position_id == 4:
alert = create_alert(
db,
route,
operator,
"TRUCK_PROXIMITY",
"Camión cercano",
f"El camión {route.truck_id} está a menos de 15 minutos de la zona asignada.",
2,
)
elif position_id == 8:
alert = create_alert(
db,
route,
operator,
"ROUTE_COMPLETED",
"Servicio finalizado",
f"El camión {route.truck_id} finalizó la ruta {route.route_id}.",
1,
)
return action_response(db, route, alert, "Avance de ruta actualizado")
@router.post("/routes/{route_id}/delay", response_model=OperatorActionOut)
def delay_route(
route_id: str,
db: Session = Depends(get_db),
operator: User = Depends(require_role("operador")),
):
route = get_assigned_route(db, route_id, operator)
route.status = "RETRASO"
route.updated_at = datetime.utcnow()
alert = create_alert(
db,
route,
operator,
"DELAY",
"Retraso operativo",
f"La ruta {route.route_id} presenta un retraso aproximado de 25 minutos.",
2,
)
return action_response(db, route, alert, "Retraso reportado")
@router.post("/routes/{route_id}/breakdown", response_model=OperatorActionOut)
def breakdown_route(
route_id: str,
db: Session = Depends(get_db),
operator: User = Depends(require_role("operador")),
):
route = get_assigned_route(db, route_id, operator)
route.status = "AVERIA"
route.updated_at = datetime.utcnow()
alert = create_alert(
db,
route,
operator,
"MECHANICAL_FAILURE",
"Avería mecánica",
f"El camión {route.truck_id} presenta falla mecánica. Se requiere apoyo logístico.",
3,
)
return action_response(db, route, alert, "Avería reportada")
@router.post("/routes/{route_id}/incident", response_model=OperatorActionOut)
def incident_route(
route_id: str,
db: Session = Depends(get_db),
operator: User = Depends(require_role("operador")),
):
route = get_assigned_route(db, route_id, operator)
route.status = "INCIDENCIA"
route.updated_at = datetime.utcnow()
alert = create_alert(
db,
route,
operator,
"INCIDENT",
"Incidencia en ruta",
f"Se registró una incidencia menor en {route.route_id}: obstrucción vial o exceso de residuos.",
2,
)
return action_response(db, route, alert, "Incidencia reportada")
@router.post("/routes/{route_id}/complete", response_model=OperatorActionOut)
def complete_route(
route_id: str,
db: Session = Depends(get_db),
operator: User = Depends(require_role("operador")),
):
route = get_assigned_route(db, route_id, operator)
route.status = "FINALIZADA"
route.current_position_id = 8
route.updated_at = datetime.utcnow()
alert = create_alert(
db,
route,
operator,
"ROUTE_COMPLETED",
"Servicio finalizado",
f"El operador finalizó la ruta {route.route_id}.",
1,
)
return action_response(db, route, alert, "Ruta finalizada")

View File

@@ -0,0 +1,49 @@
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from app.database import get_db
from app.models import Colonia, Route
from app.schemas import ColoniaOut, RouteOut
router = APIRouter(prefix="/public", tags=["Public"])
@router.get("/health")
def health():
return {"ok": True, "message": "Recolector Inteligente API funcionando"}
@router.get("/colonias", response_model=list[ColoniaOut])
def colonias(db: Session = Depends(get_db)):
return db.query(Colonia).order_by(Colonia.colonia.asc()).all()
@router.get("/routes", response_model=list[RouteOut])
def routes(db: Session = Depends(get_db)):
return db.query(Route).order_by(Route.route_id.asc()).all()
@router.get("/guide")
def guide():
return [
{
"categoria": "Orgánicos",
"ejemplos": "Comida, frutas, verduras, restos de café y hojas",
"detalle": "Pueden convertirse en composta y reducen malos olores si se separan.",
},
{
"categoria": "Reciclables",
"ejemplos": "Cartón, plástico, vidrio, latas y papel limpio",
"detalle": "Deben entregarse limpios y secos para poder reutilizarse.",
},
{
"categoria": "Sanitarios",
"ejemplos": "Papel higiénico, pañales, toallas sanitarias y cubrebocas",
"detalle": "Deben ir en bolsa cerrada porque pueden representar riesgo sanitario.",
},
{
"categoria": "Especiales",
"ejemplos": "Pilas, electrónicos, focos, aceite y medicamentos",
"detalle": "No deben mezclarse con basura común; requieren centros de acopio.",
},
]

View File

@@ -0,0 +1,179 @@
from datetime import datetime
from pydantic import BaseModel, Field
class UserOut(BaseModel):
id: int
name: str
email: str
phone: str | None
role: str
model_config = {"from_attributes": True}
class TokenOut(BaseModel):
access_token: str
token_type: str = "bearer"
user: UserOut
class LoginIn(BaseModel):
email: str
password: str
class RegisterIn(BaseModel):
name: str = Field(min_length=3, max_length=120)
email: str
phone: str | None = None
password: str = Field(min_length=6, max_length=80)
role: str = "ciudadano"
class PositionOut(BaseModel):
position_id: int
lat: float
lng: float
speed: int
timestamp: str
model_config = {"from_attributes": True}
class RouteOut(BaseModel):
route_id: str
name: str
truck_id: int
status: str
current_position_id: int
assigned_operator_id: int | None = None
model_config = {"from_attributes": True}
class RouteDetailOut(RouteOut):
positions: list[PositionOut]
class ColoniaOut(BaseModel):
colonia: str
route_id: str
horario_estimado: str
model_config = {"from_attributes": True}
class DomicilioCreate(BaseModel):
tipo: str = "Casa principal"
direccion: str
colonia: str
lat: float | None = None
lng: float | None = None
class DomicilioOut(BaseModel):
id: int
tipo: str
direccion: str
colonia: str
lat: float | None
lng: float | None
route_id: str
model_config = {"from_attributes": True}
class EtaOut(BaseModel):
domicilio_id: int
route_id: str
route_name: str
truck_id: int
colonia: str
horario_estimado: str
eta_message: str
current_position_id: int
privacy_note: str
class AlertCreate(BaseModel):
type: str
title: str
message: str
route_id: str
priority: int = 1
class AlertOut(BaseModel):
id: int
type: str
title: str
message: str
route_id: str
truck_id: int
operator_id: int | None
priority: int
status: str
created_at: datetime
model_config = {"from_attributes": True}
class ReportCreate(BaseModel):
domicilio_id: int
type: str
comment: str = Field(min_length=5, max_length=800)
class ReportOut(BaseModel):
id: int
user_id: int
domicilio_id: int
type: str
comment: str
status: str
created_at: datetime
model_config = {"from_attributes": True}
class ReportStatusUpdate(BaseModel):
status: str = Field(pattern="^(NUEVO|EN_REVISION|ATENDIDO|CERRADO)$")
class RatingCreate(BaseModel):
domicilio_id: int
stars: int = Field(ge=1, le=5)
comment: str | None = Field(default=None, max_length=400)
class RatingOut(BaseModel):
id: int
user_id: int
domicilio_id: int
stars: int
comment: str | None
created_at: datetime
model_config = {"from_attributes": True}
class OperatorActionOut(BaseModel):
ok: bool
route: RouteOut
alert: AlertOut | None = None
message: str
class AssignOperatorIn(BaseModel):
operator_id: int
class DashboardOut(BaseModel):
routes_total: int
active_routes: int
trucks_total: int
operators_total: int
alerts_open: int
reports_open: int
average_rating: float

View File

@@ -0,0 +1,95 @@
import hashlib
import hmac
import os
import re
from datetime import datetime, timedelta, timezone
from typing import Annotated
import jwt
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from sqlalchemy.orm import Session
from app.config import settings
from app.database import get_db
from app.models import User
ALGORITHM = "HS256"
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login")
EMAIL_RE = re.compile(r"^[^\s@]+@[^\s@]+\.[^\s@]+$")
ADDRESS_RE = re.compile(r"^(?=.*[A-Za-zÁÉÍÓÚáéíóúÑñ])(?=.*\d).{8,}$")
def hash_password(password: str) -> str:
salt = os.urandom(16).hex()
digest = hashlib.pbkdf2_hmac("sha256", password.encode(), salt.encode(), 120_000).hex()
return f"pbkdf2_sha256${salt}${digest}"
def verify_password(password: str, stored: str) -> bool:
try:
algo, salt, digest = stored.split("$")
if algo != "pbkdf2_sha256":
return False
candidate = hashlib.pbkdf2_hmac("sha256", password.encode(), salt.encode(), 120_000).hex()
return hmac.compare_digest(candidate, digest)
except ValueError:
return False
def create_access_token(user: User) -> str:
expire = datetime.now(timezone.utc) + timedelta(minutes=settings.jwt_expire_minutes)
payload = {
"sub": str(user.id),
"email": user.email,
"role": user.role,
"exp": expire,
}
return jwt.encode(payload, settings.jwt_secret, algorithm=ALGORITHM)
def get_current_user(
token: Annotated[str, Depends(oauth2_scheme)],
db: Annotated[Session, Depends(get_db)],
) -> User:
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token inválido o expirado",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = jwt.decode(token, settings.jwt_secret, algorithms=[ALGORITHM])
user_id = int(payload.get("sub"))
except Exception as exc:
raise credentials_exception from exc
user = db.get(User, user_id)
if not user or not user.active:
raise credentials_exception
return user
def require_role(*roles: str):
def dependency(current_user: Annotated[User, Depends(get_current_user)]) -> User:
if current_user.role not in roles:
raise HTTPException(status_code=403, detail="No tienes permisos para esta acción")
return current_user
return dependency
def validate_email(email: str) -> str:
email = email.strip().lower()
if not EMAIL_RE.match(email):
raise HTTPException(status_code=422, detail="Correo inválido")
return email
def validate_address(address: str) -> str:
value = " ".join(address.strip().split())
if not ADDRESS_RE.match(value):
raise HTTPException(
status_code=422,
detail="Dirección inválida. Debe incluir calle y número. Ejemplo: Calle Luna 123",
)
return value

View File

@@ -0,0 +1,115 @@
from sqlalchemy.orm import Session
from app.models import Colonia, Route, RoutePosition, User
from app.security import hash_password
COLONIAS = [
{"colonia": "Zona Centro", "route_id": "RUTA-01", "horario_estimado": "Matutino (06:30 - 07:15)"},
{"colonia": "Las Arboledas", "route_id": "RUTA-01", "horario_estimado": "Matutino (07:00 - 07:30)"},
{"colonia": "Trojes", "route_id": "RUTA-13", "horario_estimado": "Matutino (06:40 - 07:10)"},
{"colonia": "San Juanico", "route_id": "RUTA-03", "horario_estimado": "Matutino (06:45 - 07:15)"},
{"colonia": "Los Olivos", "route_id": "RUTA-04", "horario_estimado": "Matutino (07:00 - 07:40)"},
{"colonia": "Rancho Seco", "route_id": "RUTA-05", "horario_estimado": "Vespertino (14:15 - 15:00)"},
{"colonia": "Las Insurgentes", "route_id": "RUTA-12", "horario_estimado": "Matutino (06:35 - 07:10)"},
]
ROUTE_DEFS = [
("RUTA-01", "Zona Centro - Las Arboledas", 101, "06:00"),
("RUTA-02", "Sector Norte - Av. Tecnológico", 102, "06:05"),
("RUTA-03", "Sector Poniente - San Juanico", 103, "06:10"),
("RUTA-04", "Oriente - Los Olivos", 104, "06:15"),
("RUTA-05", "Sector Sur - Rancho Seco", 105, "06:20"),
("RUTA-06", "Norte Extremo - Rumbos de Roque", 106, "06:00"),
("RUTA-07", "Nororiente - Ciudad Industrial", 107, "06:10"),
("RUTA-08", "Suroriente - Universidad Latina", 108, "06:15"),
("RUTA-09", "Poniente - Hospital General", 109, "06:02"),
("RUTA-10", "Eje Juan Pablo II - Sede UG Sur", 110, "06:22"),
("RUTA-11", "Zona de Oro - Torres Landa", 111, "06:04"),
("RUTA-12", "Nororiente - Las Insurgentes", 112, "06:08"),
("RUTA-13", "Sector Norte - Trojes e Irrigación", 113, "06:12"),
("RUTA-14", "Sur Poniente - La Toscana", 114, "06:16"),
("RUTA-15", "Norponiente - Camino a San José de Celaya", 115, "06:18"),
]
BASE_POSITIONS = [
(1, 20.5111, -100.9037, 0, 0),
(2, 20.5185, -100.8450, 42, 12),
(3, 20.5215, -100.8142, 25, 25),
(4, 20.5212, -100.8175, 15, 38),
(5, 20.5210, -100.8210, 0, 50),
(6, 20.5235, -100.8212, 18, 65),
(7, 20.5260, -100.8215, 24, 78),
(8, 20.5111, -100.9037, 40, 100),
]
def _timestamp(start_hour_min: str, delta_minutes: int) -> str:
hour, minute = map(int, start_hour_min.split(":"))
total = hour * 60 + minute + delta_minutes
hh = (total // 60) % 24
mm = total % 60
return f"2026-05-22T{hh:02d}:{mm:02d}:00Z"
def seed_database(db: Session):
if db.query(User).count() > 0:
return
citizen = User(
name="Usuario Demo",
email="demo@correo.com",
phone="4610000000",
password_hash=hash_password("123456"),
role="ciudadano",
)
operator = User(
name="Operador José Martínez",
email="operador@demo.com",
phone="4611111111",
password_hash=hash_password("123456"),
role="operador",
)
operator2 = User(
name="Operadora Ana Torres",
email="operador2@demo.com",
phone="4612222222",
password_hash=hash_password("123456"),
role="operador",
)
admin = User(
name="Administrador Logístico",
email="admin@demo.com",
phone="4613333333",
password_hash=hash_password("123456"),
role="admin",
)
db.add_all([citizen, operator, operator2, admin])
db.flush()
for idx, (route_id, name, truck_id, start) in enumerate(ROUTE_DEFS):
assigned = operator.id if route_id in {"RUTA-01", "RUTA-03", "RUTA-05"} else operator2.id if route_id in {"RUTA-04", "RUTA-12", "RUTA-13"} else None
route = Route(
route_id=route_id,
name=name,
truck_id=truck_id,
status="PENDIENTE",
assigned_operator_id=assigned,
current_position_id=1,
)
db.add(route)
db.flush()
offset = idx * 0.0025
for position_id, lat, lng, speed, delta in BASE_POSITIONS:
db.add(RoutePosition(
route_id=route_id,
position_id=position_id,
lat=lat + offset,
lng=lng - offset,
speed=speed,
timestamp=_timestamp(start, delta),
))
for c in COLONIAS:
db.add(Colonia(**c))
db.commit()

View File

@@ -0,0 +1,42 @@
"""
Simulador sencillo de avance de rutas.
Uso:
python -m app.simulator RUTA-01
Avanza positionId 1..8 y genera alertas en 2, 4 y 8.
Ideal para demo si quieren enseñar backend actualizando estados sin GPS real.
"""
import sys
import time
from datetime import datetime
from app.database import SessionLocal
from app.models import Alert, Route
def main(route_id: str):
with SessionLocal() as db:
route = db.get(Route, route_id)
if not route:
print(f"Ruta {route_id} no encontrada")
return
for position_id in range(1, 9):
route.current_position_id = position_id
route.status = "EN_RUTA" if position_id < 8 else "FINALIZADA"
route.updated_at = datetime.utcnow()
if position_id == 2:
db.add(Alert(type="ROUTE_START", title="Ruta iniciada", message=f"El camión {route.truck_id} salió a ruta.", route_id=route.route_id, truck_id=route.truck_id, priority=1))
if position_id == 4:
db.add(Alert(type="TRUCK_PROXIMITY", title="Camión cercano", message="El camión está a menos de 15 minutos de tu zona.", route_id=route.route_id, truck_id=route.truck_id, priority=2))
if position_id == 8:
db.add(Alert(type="ROUTE_COMPLETED", title="Servicio finalizado", message=f"La ruta {route.route_id} concluyó la jornada.", route_id=route.route_id, truck_id=route.truck_id, priority=1))
db.commit()
print(f"{route.route_id}: positionId {position_id} actualizado")
time.sleep(3)
if __name__ == "__main__":
main(sys.argv[1] if len(sys.argv) > 1 else "RUTA-01")

View File

@@ -0,0 +1,7 @@
fastapi==0.115.6
uvicorn[standard]==0.34.0
sqlalchemy==2.0.36
pydantic==2.10.4
pydantic-settings==2.7.0
PyJWT==2.10.1
python-multipart==0.0.20