Agrega backend FastAPI al proyecto
This commit is contained in:
0
recolector_backend/app/__init__.py
Normal file
0
recolector_backend/app/__init__.py
Normal file
13
recolector_backend/app/config.py
Normal file
13
recolector_backend/app/config.py
Normal 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()
|
||||
20
recolector_backend/app/database.py
Normal file
20
recolector_backend/app/database.py
Normal 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()
|
||||
42
recolector_backend/app/main.py
Normal file
42
recolector_backend/app/main.py
Normal 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"],
|
||||
}
|
||||
113
recolector_backend/app/models.py
Normal file
113
recolector_backend/app/models.py
Normal 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)
|
||||
0
recolector_backend/app/routers/__init__.py
Normal file
0
recolector_backend/app/routers/__init__.py
Normal file
110
recolector_backend/app/routers/admin.py
Normal file
110
recolector_backend/app/routers/admin.py
Normal 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
|
||||
108
recolector_backend/app/routers/auth.py
Normal file
108
recolector_backend/app/routers/auth.py
Normal 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
|
||||
160
recolector_backend/app/routers/citizen.py
Normal file
160
recolector_backend/app/routers/citizen.py
Normal 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
|
||||
242
recolector_backend/app/routers/operator.py
Normal file
242
recolector_backend/app/routers/operator.py
Normal 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")
|
||||
49
recolector_backend/app/routers/public.py
Normal file
49
recolector_backend/app/routers/public.py
Normal 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.",
|
||||
},
|
||||
]
|
||||
179
recolector_backend/app/schemas.py
Normal file
179
recolector_backend/app/schemas.py
Normal 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
|
||||
95
recolector_backend/app/security.py
Normal file
95
recolector_backend/app/security.py
Normal 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
|
||||
115
recolector_backend/app/seed.py
Normal file
115
recolector_backend/app/seed.py
Normal 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()
|
||||
42
recolector_backend/app/simulator.py
Normal file
42
recolector_backend/app/simulator.py
Normal 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")
|
||||
Reference in New Issue
Block a user