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,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.",
},
]