FlotillasGPS - Sistema completo de monitoreo de flotillas GPS

Sistema completo para monitoreo y gestion de flotas de vehiculos con:
- Backend FastAPI con PostgreSQL/TimescaleDB
- Frontend React con TypeScript y TailwindCSS
- App movil React Native con Expo
- Soporte para dispositivos GPS, Meshtastic y celulares
- Video streaming en vivo con MediaMTX
- Geocercas, alertas, viajes y reportes
- Autenticacion JWT y WebSockets en tiempo real

Documentacion completa y guias de usuario incluidas.
This commit is contained in:
FlotillasGPS Developer
2026-01-21 08:18:00 +00:00
commit 51d78bacf4
248 changed files with 50171 additions and 0 deletions

View File

@@ -0,0 +1,7 @@
"""
API v1 - Endpoints REST.
"""
from app.api.v1.router import api_router
__all__ = ["api_router"]

View File

@@ -0,0 +1,402 @@
"""
Endpoints para gestión de alertas.
"""
from datetime import datetime, timezone
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.core.database import get_db
from app.core.security import get_current_user
from app.models.usuario import Usuario
from app.models.alerta import Alerta
from app.models.tipo_alerta import TipoAlerta
from app.schemas.alerta import (
TipoAlertaCreate,
TipoAlertaUpdate,
TipoAlertaResponse,
AlertaCreate,
AlertaResponse,
AlertaConRelaciones,
AlertaResumen,
AlertasEstadisticas,
AlertaAtenderRequest,
)
from app.services.alerta_service import AlertaService
router = APIRouter(prefix="/alertas", tags=["Alertas"])
# ============================================================================
# Tipos de Alerta
# ============================================================================
@router.get("/tipos", response_model=List[TipoAlertaResponse])
async def listar_tipos_alerta(
activo: Optional[bool] = None,
db: AsyncSession = Depends(get_db),
current_user: Usuario = Depends(get_current_user),
):
"""
Lista todos los tipos de alerta.
Args:
activo: Filtrar por estado activo.
Returns:
Lista de tipos de alerta.
"""
query = select(TipoAlerta).order_by(TipoAlerta.prioridad)
if activo is not None:
query = query.where(TipoAlerta.activo == activo)
result = await db.execute(query)
tipos = result.scalars().all()
return [TipoAlertaResponse.model_validate(t) for t in tipos]
@router.post("/tipos", response_model=TipoAlertaResponse, status_code=status.HTTP_201_CREATED)
async def crear_tipo_alerta(
tipo_data: TipoAlertaCreate,
db: AsyncSession = Depends(get_db),
current_user: Usuario = Depends(get_current_user),
):
"""
Crea un nuevo tipo de alerta.
Args:
tipo_data: Datos del tipo de alerta.
Returns:
Tipo de alerta creado.
"""
# Verificar código único
result = await db.execute(
select(TipoAlerta).where(TipoAlerta.codigo == tipo_data.codigo)
)
if result.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Ya existe un tipo de alerta con el código {tipo_data.codigo}",
)
tipo = TipoAlerta(**tipo_data.model_dump())
db.add(tipo)
await db.commit()
await db.refresh(tipo)
return TipoAlertaResponse.model_validate(tipo)
@router.put("/tipos/{tipo_id}", response_model=TipoAlertaResponse)
async def actualizar_tipo_alerta(
tipo_id: int,
tipo_data: TipoAlertaUpdate,
db: AsyncSession = Depends(get_db),
current_user: Usuario = Depends(get_current_user),
):
"""
Actualiza un tipo de alerta.
Args:
tipo_id: ID del tipo.
tipo_data: Datos a actualizar.
Returns:
Tipo actualizado.
"""
result = await db.execute(
select(TipoAlerta).where(TipoAlerta.id == tipo_id)
)
tipo = result.scalar_one_or_none()
if not tipo:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Tipo de alerta con id {tipo_id} no encontrado",
)
update_data = tipo_data.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(tipo, field, value)
await db.commit()
await db.refresh(tipo)
return TipoAlertaResponse.model_validate(tipo)
# ============================================================================
# Alertas
# ============================================================================
@router.get("", response_model=List[AlertaResumen])
async def listar_alertas(
vehiculo_id: Optional[int] = None,
tipo_alerta_id: Optional[int] = None,
severidad: Optional[str] = None,
atendida: Optional[bool] = None,
desde: Optional[datetime] = None,
hasta: Optional[datetime] = None,
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100),
db: AsyncSession = Depends(get_db),
current_user: Usuario = Depends(get_current_user),
):
"""
Lista alertas con filtros opcionales.
Args:
vehiculo_id: Filtrar por vehículo.
tipo_alerta_id: Filtrar por tipo.
severidad: Filtrar por severidad.
atendida: Filtrar por estado de atención.
desde: Fecha inicio.
hasta: Fecha fin.
skip: Registros a saltar.
limit: Límite de registros.
Returns:
Lista de alertas.
"""
query = (
select(Alerta)
.options(selectinload(Alerta.tipo_alerta))
.order_by(Alerta.creado_en.desc())
)
if vehiculo_id:
query = query.where(Alerta.vehiculo_id == vehiculo_id)
if tipo_alerta_id:
query = query.where(Alerta.tipo_alerta_id == tipo_alerta_id)
if severidad:
query = query.where(Alerta.severidad == severidad)
if atendida is not None:
query = query.where(Alerta.atendida == atendida)
if desde:
query = query.where(Alerta.creado_en >= desde)
if hasta:
query = query.where(Alerta.creado_en <= hasta)
query = query.offset(skip).limit(limit)
result = await db.execute(query)
alertas = result.scalars().all()
return [
AlertaResumen(
id=a.id,
tipo_codigo=a.tipo_alerta.codigo if a.tipo_alerta else "",
tipo_nombre=a.tipo_alerta.nombre if a.tipo_alerta else "",
severidad=a.severidad,
mensaje=a.mensaje,
vehiculo_nombre=a.vehiculo.nombre if a.vehiculo else None,
vehiculo_placa=a.vehiculo.placa if a.vehiculo else None,
creado_en=a.creado_en,
atendida=a.atendida,
)
for a in alertas
]
@router.get("/pendientes", response_model=List[AlertaResumen])
async def listar_alertas_pendientes(
severidad: Optional[str] = None,
limit: int = Query(50, ge=1, le=100),
db: AsyncSession = Depends(get_db),
current_user: Usuario = Depends(get_current_user),
):
"""
Lista alertas pendientes de atender.
Args:
severidad: Filtrar por severidad.
limit: Límite de resultados.
Returns:
Lista de alertas pendientes.
"""
alerta_service = AlertaService(db)
alertas = await alerta_service.obtener_alertas_pendientes(
severidad=severidad,
limite=limit,
)
# Cargar relaciones
for a in alertas:
await db.refresh(a, ["tipo_alerta", "vehiculo"])
return [
AlertaResumen(
id=a.id,
tipo_codigo=a.tipo_alerta.codigo if a.tipo_alerta else "",
tipo_nombre=a.tipo_alerta.nombre if a.tipo_alerta else "",
severidad=a.severidad,
mensaje=a.mensaje,
vehiculo_nombre=a.vehiculo.nombre if a.vehiculo else None,
vehiculo_placa=a.vehiculo.placa if a.vehiculo else None,
creado_en=a.creado_en,
atendida=a.atendida,
)
for a in alertas
]
@router.get("/estadisticas", response_model=AlertasEstadisticas)
async def obtener_estadisticas_alertas(
desde: Optional[datetime] = None,
hasta: Optional[datetime] = None,
db: AsyncSession = Depends(get_db),
current_user: Usuario = Depends(get_current_user),
):
"""
Obtiene estadísticas de alertas.
Args:
desde: Fecha inicio (opcional).
hasta: Fecha fin (opcional).
Returns:
Estadísticas de alertas.
"""
alerta_service = AlertaService(db)
stats = await alerta_service.obtener_estadisticas(desde, hasta)
return AlertasEstadisticas(
total=stats["total"],
pendientes=stats["pendientes"],
atendidas=stats["atendidas"],
criticas=stats["criticas"],
altas=stats["altas"],
medias=stats["medias"],
bajas=stats["bajas"],
por_tipo=stats["por_tipo"],
por_vehiculo=[], # TODO: Agregar en servicio
)
@router.get("/{alerta_id}", response_model=AlertaConRelaciones)
async def obtener_alerta(
alerta_id: int,
db: AsyncSession = Depends(get_db),
current_user: Usuario = Depends(get_current_user),
):
"""
Obtiene una alerta por su ID.
Args:
alerta_id: ID de la alerta.
Returns:
Alerta con relaciones.
"""
result = await db.execute(
select(Alerta)
.options(
selectinload(Alerta.tipo_alerta),
selectinload(Alerta.vehiculo),
selectinload(Alerta.conductor),
)
.where(Alerta.id == alerta_id)
)
alerta = result.scalar_one_or_none()
if not alerta:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Alerta con id {alerta_id} no encontrada",
)
return AlertaConRelaciones(
id=alerta.id,
tipo_alerta_id=alerta.tipo_alerta_id,
severidad=alerta.severidad,
mensaje=alerta.mensaje,
descripcion=alerta.descripcion,
vehiculo_id=alerta.vehiculo_id,
conductor_id=alerta.conductor_id,
dispositivo_id=alerta.dispositivo_id,
lat=alerta.lat,
lng=alerta.lng,
direccion=alerta.direccion,
velocidad=alerta.velocidad,
valor=alerta.valor,
umbral=alerta.umbral,
datos_extra=alerta.datos_extra,
atendida=alerta.atendida,
atendida_por_id=alerta.atendida_por_id,
atendida_en=alerta.atendida_en,
notas_atencion=alerta.notas_atencion,
notificacion_email_enviada=alerta.notificacion_email_enviada,
notificacion_push_enviada=alerta.notificacion_push_enviada,
notificacion_sms_enviada=alerta.notificacion_sms_enviada,
creado_en=alerta.creado_en,
actualizado_en=alerta.actualizado_en,
es_critica=alerta.es_critica,
tipo_alerta=TipoAlertaResponse.model_validate(alerta.tipo_alerta),
vehiculo_nombre=alerta.vehiculo.nombre if alerta.vehiculo else None,
vehiculo_placa=alerta.vehiculo.placa if alerta.vehiculo else None,
conductor_nombre=alerta.conductor.nombre_completo if alerta.conductor else None,
)
@router.post("", response_model=AlertaResponse, status_code=status.HTTP_201_CREATED)
async def crear_alerta(
alerta_data: AlertaCreate,
db: AsyncSession = Depends(get_db),
current_user: Usuario = Depends(get_current_user),
):
"""
Crea una alerta manualmente.
Args:
alerta_data: Datos de la alerta.
Returns:
Alerta creada.
"""
alerta_service = AlertaService(db)
alerta = await alerta_service.crear_alerta(alerta_data)
return AlertaResponse.model_validate(alerta)
@router.put("/{alerta_id}/atender")
async def atender_alerta(
alerta_id: int,
request: AlertaAtenderRequest,
db: AsyncSession = Depends(get_db),
current_user: Usuario = Depends(get_current_user),
):
"""
Marca una alerta como atendida.
Args:
alerta_id: ID de la alerta.
request: Notas de atención.
Returns:
Alerta actualizada.
"""
alerta_service = AlertaService(db)
alerta = await alerta_service.marcar_atendida(
alerta_id,
current_user.id,
request.notas_atencion,
)
if not alerta:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Alerta con id {alerta_id} no encontrada",
)
return {"message": "Alerta marcada como atendida", "alerta_id": alerta_id}

274
backend/app/api/v1/auth.py Normal file
View File

@@ -0,0 +1,274 @@
"""
Endpoints de autenticación.
"""
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.security import (
create_access_token,
create_refresh_token,
decode_token,
hash_password,
verify_password,
verify_token_type,
get_current_user,
)
from app.core.config import settings
from app.models.usuario import Usuario
from app.schemas.usuario import (
LoginRequest,
LoginResponse,
RefreshTokenRequest,
TokenResponse,
UsuarioCreate,
UsuarioResponse,
UsuarioUpdate,
UsuarioUpdatePassword,
)
router = APIRouter(prefix="/auth", tags=["Autenticacion"])
@router.post("/login", response_model=LoginResponse)
async def login(
request: LoginRequest,
db: AsyncSession = Depends(get_db),
):
"""
Autentica un usuario y devuelve tokens JWT.
Args:
request: Credenciales de login.
db: Sesión de base de datos.
Returns:
Tokens de acceso y refresco.
"""
# Buscar usuario por email
result = await db.execute(
select(Usuario).where(Usuario.email == request.email)
)
user = result.scalar_one_or_none()
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Email o contraseña incorrectos",
)
if not verify_password(request.password, user.password_hash):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Email o contraseña incorrectos",
)
if not user.activo:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Usuario desactivado",
)
# Actualizar último acceso
user.ultimo_acceso = datetime.now(timezone.utc)
await db.commit()
# Generar tokens
token_data = {"sub": str(user.id), "email": user.email}
access_token = create_access_token(token_data)
refresh_token = create_refresh_token(token_data)
return LoginResponse(
access_token=access_token,
refresh_token=refresh_token,
token_type="bearer",
expires_in=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60,
user=UsuarioResponse.model_validate(user),
)
@router.post("/refresh", response_model=TokenResponse)
async def refresh_token(
request: RefreshTokenRequest,
db: AsyncSession = Depends(get_db),
):
"""
Renueva los tokens usando el refresh token.
Args:
request: Token de refresco.
db: Sesión de base de datos.
Returns:
Nuevos tokens de acceso y refresco.
"""
# Decodificar y verificar el refresh token
payload = decode_token(request.refresh_token)
verify_token_type(payload, "refresh")
user_id = payload.get("sub")
if not user_id:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token inválido",
)
# Verificar que el usuario existe y está activo
result = await db.execute(
select(Usuario).where(Usuario.id == int(user_id))
)
user = result.scalar_one_or_none()
if not user or not user.activo:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Usuario no válido",
)
# Generar nuevos tokens
token_data = {"sub": str(user.id), "email": user.email}
access_token = create_access_token(token_data)
refresh_token = create_refresh_token(token_data)
return TokenResponse(
access_token=access_token,
refresh_token=refresh_token,
token_type="bearer",
expires_in=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60,
)
@router.post("/logout")
async def logout():
"""
Cierra la sesión del usuario.
En una implementación con blacklist de tokens, aquí se
agregaría el token a la lista negra.
Returns:
Mensaje de confirmación.
"""
# TODO: Implementar blacklist de tokens en Redis
return {"message": "Sesión cerrada correctamente"}
@router.post("/register", response_model=UsuarioResponse, status_code=status.HTTP_201_CREATED)
async def register(
user_data: UsuarioCreate,
db: AsyncSession = Depends(get_db),
):
"""
Registra un nuevo usuario.
Args:
user_data: Datos del usuario a crear.
db: Sesión de base de datos.
Returns:
Usuario creado.
"""
# Verificar si el email ya existe
result = await db.execute(
select(Usuario).where(Usuario.email == user_data.email)
)
if result.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="El email ya está registrado",
)
# Crear usuario
user = Usuario(
email=user_data.email,
password_hash=hash_password(user_data.password),
nombre=user_data.nombre,
apellido=user_data.apellido,
telefono=user_data.telefono,
es_admin=user_data.es_admin,
)
db.add(user)
await db.commit()
await db.refresh(user)
return UsuarioResponse.model_validate(user)
@router.get("/me", response_model=UsuarioResponse)
async def get_current_user_info(
current_user: Usuario = Depends(get_current_user),
):
"""
Obtiene información del usuario actual.
Args:
current_user: Usuario autenticado.
Returns:
Información del usuario.
"""
return UsuarioResponse.model_validate(current_user)
@router.put("/me", response_model=UsuarioResponse)
async def update_current_user(
user_data: UsuarioUpdate,
current_user: Usuario = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""
Actualiza información del usuario actual.
Args:
user_data: Datos a actualizar.
current_user: Usuario autenticado.
db: Sesión de base de datos.
Returns:
Usuario actualizado.
"""
# Actualizar solo campos proporcionados
update_data = user_data.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(current_user, field, value)
await db.commit()
await db.refresh(current_user)
return UsuarioResponse.model_validate(current_user)
@router.put("/me/password")
async def change_password(
password_data: UsuarioUpdatePassword,
current_user: Usuario = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""
Cambia la contraseña del usuario actual.
Args:
password_data: Contraseñas actual y nueva.
current_user: Usuario autenticado.
db: Sesión de base de datos.
Returns:
Mensaje de confirmación.
"""
# Verificar contraseña actual
if not verify_password(password_data.password_actual, current_user.password_hash):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Contraseña actual incorrecta",
)
# Actualizar contraseña
current_user.password_hash = hash_password(password_data.password_nuevo)
await db.commit()
return {"message": "Contraseña actualizada correctamente"}

View File

@@ -0,0 +1,411 @@
"""
Endpoints para gestión de conductores.
"""
from datetime import datetime, timedelta, timezone
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.security import get_current_user
from app.models.usuario import Usuario
from app.models.conductor import Conductor
from app.models.viaje import Viaje
from app.models.alerta import Alerta
from app.schemas.conductor import (
ConductorCreate,
ConductorUpdate,
ConductorResponse,
ConductorResumen,
ConductorEstadisticas,
)
router = APIRouter(prefix="/conductores", tags=["Conductores"])
@router.get("", response_model=List[ConductorResumen])
async def listar_conductores(
activo: Optional[bool] = None,
buscar: Optional[str] = None,
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100),
db: AsyncSession = Depends(get_db),
current_user: Usuario = Depends(get_current_user),
):
"""
Lista todos los conductores con filtros opcionales.
Args:
activo: Filtrar por estado activo.
buscar: Búsqueda por nombre, apellido o licencia.
skip: Registros a saltar.
limit: Límite de registros.
Returns:
Lista de conductores.
"""
query = select(Conductor)
if activo is not None:
query = query.where(Conductor.activo == activo)
if buscar:
query = query.where(
(Conductor.nombre.ilike(f"%{buscar}%")) |
(Conductor.apellido.ilike(f"%{buscar}%")) |
(Conductor.licencia_numero.ilike(f"%{buscar}%"))
)
query = query.offset(skip).limit(limit).order_by(Conductor.nombre)
result = await db.execute(query)
conductores = result.scalars().all()
return [
ConductorResumen(
id=c.id,
nombre_completo=c.nombre_completo,
telefono=c.telefono,
licencia_vigente=c.licencia_vigente,
activo=c.activo,
)
for c in conductores
]
@router.get("/{conductor_id}", response_model=ConductorResponse)
async def obtener_conductor(
conductor_id: int,
db: AsyncSession = Depends(get_db),
current_user: Usuario = Depends(get_current_user),
):
"""
Obtiene un conductor por su ID.
Args:
conductor_id: ID del conductor.
Returns:
Conductor encontrado.
"""
result = await db.execute(
select(Conductor).where(Conductor.id == conductor_id)
)
conductor = result.scalar_one_or_none()
if not conductor:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Conductor con id {conductor_id} no encontrado",
)
return ConductorResponse(
id=conductor.id,
nombre=conductor.nombre,
apellido=conductor.apellido,
telefono=conductor.telefono,
email=conductor.email,
documento_tipo=conductor.documento_tipo,
documento_numero=conductor.documento_numero,
licencia_numero=conductor.licencia_numero,
licencia_tipo=conductor.licencia_tipo,
licencia_vencimiento=conductor.licencia_vencimiento,
fecha_nacimiento=conductor.fecha_nacimiento,
direccion=conductor.direccion,
contacto_emergencia=conductor.contacto_emergencia,
telefono_emergencia=conductor.telefono_emergencia,
fecha_contratacion=conductor.fecha_contratacion,
numero_empleado=conductor.numero_empleado,
foto_url=conductor.foto_url,
activo=conductor.activo,
notas=conductor.notas,
nombre_completo=conductor.nombre_completo,
licencia_vigente=conductor.licencia_vigente,
creado_en=conductor.creado_en,
actualizado_en=conductor.actualizado_en,
)
@router.post("", response_model=ConductorResponse, status_code=status.HTTP_201_CREATED)
async def crear_conductor(
conductor_data: ConductorCreate,
db: AsyncSession = Depends(get_db),
current_user: Usuario = Depends(get_current_user),
):
"""
Crea un nuevo conductor.
Args:
conductor_data: Datos del conductor.
Returns:
Conductor creado.
"""
# Verificar licencia única si se proporciona
if conductor_data.licencia_numero:
result = await db.execute(
select(Conductor).where(Conductor.licencia_numero == conductor_data.licencia_numero)
)
if result.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Ya existe un conductor con la licencia {conductor_data.licencia_numero}",
)
conductor = Conductor(**conductor_data.model_dump())
db.add(conductor)
await db.commit()
await db.refresh(conductor)
return ConductorResponse(
id=conductor.id,
nombre=conductor.nombre,
apellido=conductor.apellido,
telefono=conductor.telefono,
email=conductor.email,
documento_tipo=conductor.documento_tipo,
documento_numero=conductor.documento_numero,
licencia_numero=conductor.licencia_numero,
licencia_tipo=conductor.licencia_tipo,
licencia_vencimiento=conductor.licencia_vencimiento,
fecha_nacimiento=conductor.fecha_nacimiento,
direccion=conductor.direccion,
contacto_emergencia=conductor.contacto_emergencia,
telefono_emergencia=conductor.telefono_emergencia,
fecha_contratacion=conductor.fecha_contratacion,
numero_empleado=conductor.numero_empleado,
foto_url=conductor.foto_url,
activo=conductor.activo,
notas=conductor.notas,
nombre_completo=conductor.nombre_completo,
licencia_vigente=conductor.licencia_vigente,
creado_en=conductor.creado_en,
actualizado_en=conductor.actualizado_en,
)
@router.put("/{conductor_id}", response_model=ConductorResponse)
async def actualizar_conductor(
conductor_id: int,
conductor_data: ConductorUpdate,
db: AsyncSession = Depends(get_db),
current_user: Usuario = Depends(get_current_user),
):
"""
Actualiza un conductor existente.
Args:
conductor_id: ID del conductor.
conductor_data: Datos a actualizar.
Returns:
Conductor actualizado.
"""
result = await db.execute(
select(Conductor).where(Conductor.id == conductor_id)
)
conductor = result.scalar_one_or_none()
if not conductor:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Conductor con id {conductor_id} no encontrado",
)
# Verificar licencia única si se cambia
if conductor_data.licencia_numero and conductor_data.licencia_numero != conductor.licencia_numero:
result = await db.execute(
select(Conductor).where(Conductor.licencia_numero == conductor_data.licencia_numero)
)
if result.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Ya existe un conductor con la licencia {conductor_data.licencia_numero}",
)
update_data = conductor_data.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(conductor, field, value)
await db.commit()
await db.refresh(conductor)
return ConductorResponse(
id=conductor.id,
nombre=conductor.nombre,
apellido=conductor.apellido,
telefono=conductor.telefono,
email=conductor.email,
documento_tipo=conductor.documento_tipo,
documento_numero=conductor.documento_numero,
licencia_numero=conductor.licencia_numero,
licencia_tipo=conductor.licencia_tipo,
licencia_vencimiento=conductor.licencia_vencimiento,
fecha_nacimiento=conductor.fecha_nacimiento,
direccion=conductor.direccion,
contacto_emergencia=conductor.contacto_emergencia,
telefono_emergencia=conductor.telefono_emergencia,
fecha_contratacion=conductor.fecha_contratacion,
numero_empleado=conductor.numero_empleado,
foto_url=conductor.foto_url,
activo=conductor.activo,
notas=conductor.notas,
nombre_completo=conductor.nombre_completo,
licencia_vigente=conductor.licencia_vigente,
creado_en=conductor.creado_en,
actualizado_en=conductor.actualizado_en,
)
@router.delete("/{conductor_id}", status_code=status.HTTP_204_NO_CONTENT)
async def eliminar_conductor(
conductor_id: int,
db: AsyncSession = Depends(get_db),
current_user: Usuario = Depends(get_current_user),
):
"""
Elimina un conductor (soft delete - desactiva).
Args:
conductor_id: ID del conductor.
"""
result = await db.execute(
select(Conductor).where(Conductor.id == conductor_id)
)
conductor = result.scalar_one_or_none()
if not conductor:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Conductor con id {conductor_id} no encontrado",
)
conductor.activo = False
await db.commit()
@router.get("/{conductor_id}/estadisticas", response_model=ConductorEstadisticas)
async def obtener_estadisticas_conductor(
conductor_id: int,
db: AsyncSession = Depends(get_db),
current_user: Usuario = Depends(get_current_user),
):
"""
Obtiene estadísticas de un conductor.
Args:
conductor_id: ID del conductor.
Returns:
Estadísticas del conductor.
"""
result = await db.execute(
select(Conductor).where(Conductor.id == conductor_id)
)
conductor = result.scalar_one_or_none()
if not conductor:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Conductor con id {conductor_id} no encontrado",
)
# Total de viajes
result = await db.execute(
select(func.count(Viaje.id))
.where(Viaje.conductor_id == conductor_id)
)
total_viajes = result.scalar() or 0
# Distancia total
result = await db.execute(
select(func.coalesce(func.sum(Viaje.distancia_km), 0))
.where(Viaje.conductor_id == conductor_id)
)
distancia_total = result.scalar() or 0
# Tiempo de conducción
result = await db.execute(
select(func.coalesce(func.sum(Viaje.tiempo_movimiento_segundos), 0))
.where(Viaje.conductor_id == conductor_id)
)
tiempo_conduccion = result.scalar() or 0
# Velocidad promedio
result = await db.execute(
select(func.avg(Viaje.velocidad_promedio))
.where(Viaje.conductor_id == conductor_id)
.where(Viaje.velocidad_promedio.isnot(None))
)
velocidad_promedio = result.scalar() or 0
# Total de alertas
result = await db.execute(
select(func.count(Alerta.id))
.where(Alerta.conductor_id == conductor_id)
)
alertas_total = result.scalar() or 0
# Alertas de velocidad
from app.models.tipo_alerta import TipoAlerta
result = await db.execute(
select(func.count(Alerta.id))
.join(TipoAlerta, Alerta.tipo_alerta_id == TipoAlerta.id)
.where(Alerta.conductor_id == conductor_id)
.where(TipoAlerta.codigo == "EXCESO_VELOCIDAD")
)
alertas_velocidad = result.scalar() or 0
return ConductorEstadisticas(
conductor_id=conductor.id,
nombre_completo=conductor.nombre_completo,
total_viajes=total_viajes,
distancia_total_km=float(distancia_total),
tiempo_conduccion_horas=tiempo_conduccion / 3600,
velocidad_promedio=float(velocidad_promedio) if velocidad_promedio else 0,
alertas_total=alertas_total,
alertas_velocidad=alertas_velocidad,
)
@router.get("/licencias/por-vencer", response_model=List[ConductorResumen])
async def obtener_licencias_por_vencer(
dias: int = Query(30, ge=1, le=365),
db: AsyncSession = Depends(get_db),
current_user: Usuario = Depends(get_current_user),
):
"""
Obtiene conductores con licencias próximas a vencer.
Args:
dias: Días para considerar como "próximo a vencer".
Returns:
Lista de conductores con licencias por vencer.
"""
from datetime import date
fecha_limite = date.today() + timedelta(days=dias)
result = await db.execute(
select(Conductor)
.where(Conductor.activo == True)
.where(Conductor.licencia_vencimiento.isnot(None))
.where(Conductor.licencia_vencimiento <= fecha_limite)
.order_by(Conductor.licencia_vencimiento)
)
conductores = result.scalars().all()
return [
ConductorResumen(
id=c.id,
nombre_completo=c.nombre_completo,
telefono=c.telefono,
licencia_vigente=c.licencia_vigente,
activo=c.activo,
)
for c in conductores
]

View File

@@ -0,0 +1,362 @@
"""
Endpoints para gestión de dispositivos GPS.
"""
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.core.database import get_db
from app.core.security import get_current_user
from app.models.usuario import Usuario
from app.models.dispositivo import Dispositivo
from app.schemas.dispositivo import (
DispositivoCreate,
DispositivoUpdate,
DispositivoResponse,
DispositivoResumen,
DispositivoConVehiculo,
)
router = APIRouter(prefix="/dispositivos", tags=["Dispositivos"])
@router.get("", response_model=List[DispositivoResumen])
async def listar_dispositivos(
vehiculo_id: Optional[int] = None,
tipo: Optional[str] = None,
activo: Optional[bool] = None,
conectado: Optional[bool] = None,
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100),
db: AsyncSession = Depends(get_db),
current_user: Usuario = Depends(get_current_user),
):
"""
Lista dispositivos con filtros opcionales.
Args:
vehiculo_id: Filtrar por vehículo.
tipo: Filtrar por tipo.
activo: Filtrar por estado activo.
conectado: Filtrar por estado de conexión.
skip: Registros a saltar.
limit: Límite de registros.
Returns:
Lista de dispositivos.
"""
query = select(Dispositivo).order_by(Dispositivo.identificador)
if vehiculo_id:
query = query.where(Dispositivo.vehiculo_id == vehiculo_id)
if tipo:
query = query.where(Dispositivo.tipo == tipo)
if activo is not None:
query = query.where(Dispositivo.activo == activo)
if conectado is not None:
query = query.where(Dispositivo.conectado == conectado)
query = query.offset(skip).limit(limit)
result = await db.execute(query)
dispositivos = result.scalars().all()
return [
DispositivoResumen(
id=d.id,
identificador=d.identificador,
tipo=d.tipo,
protocolo=d.protocolo,
activo=d.activo,
conectado=d.conectado,
ultimo_contacto=d.ultimo_contacto,
bateria=d.bateria,
)
for d in dispositivos
]
@router.get("/{dispositivo_id}", response_model=DispositivoConVehiculo)
async def obtener_dispositivo(
dispositivo_id: int,
db: AsyncSession = Depends(get_db),
current_user: Usuario = Depends(get_current_user),
):
"""
Obtiene un dispositivo por su ID.
Args:
dispositivo_id: ID del dispositivo.
Returns:
Dispositivo con información del vehículo.
"""
result = await db.execute(
select(Dispositivo)
.options(selectinload(Dispositivo.vehiculo))
.where(Dispositivo.id == dispositivo_id)
)
dispositivo = result.scalar_one_or_none()
if not dispositivo:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Dispositivo con id {dispositivo_id} no encontrado",
)
return DispositivoConVehiculo(
id=dispositivo.id,
vehiculo_id=dispositivo.vehiculo_id,
tipo=dispositivo.tipo,
identificador=dispositivo.identificador,
nombre=dispositivo.nombre,
marca=dispositivo.marca,
modelo=dispositivo.modelo,
numero_serie=dispositivo.numero_serie,
telefono_sim=dispositivo.telefono_sim,
operador_sim=dispositivo.operador_sim,
iccid=dispositivo.iccid,
imei=dispositivo.imei,
protocolo=dispositivo.protocolo,
ultimo_contacto=dispositivo.ultimo_contacto,
bateria=dispositivo.bateria,
señal_gsm=dispositivo.señal_gsm,
satelites=dispositivo.satelites,
intervalo_reporte=dispositivo.intervalo_reporte,
configuracion=dispositivo.configuracion,
firmware_version=dispositivo.firmware_version,
activo=dispositivo.activo,
conectado=dispositivo.conectado,
notas=dispositivo.notas,
esta_online=dispositivo.esta_online,
creado_en=dispositivo.creado_en,
actualizado_en=dispositivo.actualizado_en,
vehiculo_nombre=dispositivo.vehiculo.nombre if dispositivo.vehiculo else None,
vehiculo_placa=dispositivo.vehiculo.placa if dispositivo.vehiculo else None,
)
@router.post("", response_model=DispositivoResponse, status_code=status.HTTP_201_CREATED)
async def crear_dispositivo(
dispositivo_data: DispositivoCreate,
db: AsyncSession = Depends(get_db),
current_user: Usuario = Depends(get_current_user),
):
"""
Crea un nuevo dispositivo.
Args:
dispositivo_data: Datos del dispositivo.
Returns:
Dispositivo creado.
"""
# Verificar identificador único
result = await db.execute(
select(Dispositivo).where(Dispositivo.identificador == dispositivo_data.identificador)
)
if result.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Ya existe un dispositivo con el identificador {dispositivo_data.identificador}",
)
# Verificar IMEI único si se proporciona
if dispositivo_data.imei:
result = await db.execute(
select(Dispositivo).where(Dispositivo.imei == dispositivo_data.imei)
)
if result.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Ya existe un dispositivo con el IMEI {dispositivo_data.imei}",
)
dispositivo = Dispositivo(**dispositivo_data.model_dump())
db.add(dispositivo)
await db.commit()
await db.refresh(dispositivo)
return DispositivoResponse(
id=dispositivo.id,
vehiculo_id=dispositivo.vehiculo_id,
tipo=dispositivo.tipo,
identificador=dispositivo.identificador,
nombre=dispositivo.nombre,
marca=dispositivo.marca,
modelo=dispositivo.modelo,
numero_serie=dispositivo.numero_serie,
telefono_sim=dispositivo.telefono_sim,
operador_sim=dispositivo.operador_sim,
iccid=dispositivo.iccid,
imei=dispositivo.imei,
protocolo=dispositivo.protocolo,
ultimo_contacto=dispositivo.ultimo_contacto,
bateria=dispositivo.bateria,
señal_gsm=dispositivo.señal_gsm,
satelites=dispositivo.satelites,
intervalo_reporte=dispositivo.intervalo_reporte,
configuracion=dispositivo.configuracion,
firmware_version=dispositivo.firmware_version,
activo=dispositivo.activo,
conectado=dispositivo.conectado,
notas=dispositivo.notas,
esta_online=dispositivo.esta_online,
creado_en=dispositivo.creado_en,
actualizado_en=dispositivo.actualizado_en,
)
@router.put("/{dispositivo_id}", response_model=DispositivoResponse)
async def actualizar_dispositivo(
dispositivo_id: int,
dispositivo_data: DispositivoUpdate,
db: AsyncSession = Depends(get_db),
current_user: Usuario = Depends(get_current_user),
):
"""
Actualiza un dispositivo.
Args:
dispositivo_id: ID del dispositivo.
dispositivo_data: Datos a actualizar.
Returns:
Dispositivo actualizado.
"""
result = await db.execute(
select(Dispositivo).where(Dispositivo.id == dispositivo_id)
)
dispositivo = result.scalar_one_or_none()
if not dispositivo:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Dispositivo con id {dispositivo_id} no encontrado",
)
update_data = dispositivo_data.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(dispositivo, field, value)
await db.commit()
await db.refresh(dispositivo)
return DispositivoResponse(
id=dispositivo.id,
vehiculo_id=dispositivo.vehiculo_id,
tipo=dispositivo.tipo,
identificador=dispositivo.identificador,
nombre=dispositivo.nombre,
marca=dispositivo.marca,
modelo=dispositivo.modelo,
numero_serie=dispositivo.numero_serie,
telefono_sim=dispositivo.telefono_sim,
operador_sim=dispositivo.operador_sim,
iccid=dispositivo.iccid,
imei=dispositivo.imei,
protocolo=dispositivo.protocolo,
ultimo_contacto=dispositivo.ultimo_contacto,
bateria=dispositivo.bateria,
señal_gsm=dispositivo.señal_gsm,
satelites=dispositivo.satelites,
intervalo_reporte=dispositivo.intervalo_reporte,
configuracion=dispositivo.configuracion,
firmware_version=dispositivo.firmware_version,
activo=dispositivo.activo,
conectado=dispositivo.conectado,
notas=dispositivo.notas,
esta_online=dispositivo.esta_online,
creado_en=dispositivo.creado_en,
actualizado_en=dispositivo.actualizado_en,
)
@router.delete("/{dispositivo_id}", status_code=status.HTTP_204_NO_CONTENT)
async def eliminar_dispositivo(
dispositivo_id: int,
db: AsyncSession = Depends(get_db),
current_user: Usuario = Depends(get_current_user),
):
"""
Elimina un dispositivo (soft delete - desactiva).
Args:
dispositivo_id: ID del dispositivo.
"""
result = await db.execute(
select(Dispositivo).where(Dispositivo.id == dispositivo_id)
)
dispositivo = result.scalar_one_or_none()
if not dispositivo:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Dispositivo con id {dispositivo_id} no encontrado",
)
dispositivo.activo = False
await db.commit()
@router.get("/por-identificador/{identificador}", response_model=DispositivoResponse)
async def obtener_dispositivo_por_identificador(
identificador: str,
db: AsyncSession = Depends(get_db),
):
"""
Obtiene un dispositivo por su identificador.
Este endpoint no requiere autenticación para
facilitar la búsqueda desde dispositivos.
Args:
identificador: Identificador del dispositivo.
Returns:
Dispositivo encontrado.
"""
result = await db.execute(
select(Dispositivo).where(Dispositivo.identificador == identificador)
)
dispositivo = result.scalar_one_or_none()
if not dispositivo:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Dispositivo con identificador {identificador} no encontrado",
)
return DispositivoResponse(
id=dispositivo.id,
vehiculo_id=dispositivo.vehiculo_id,
tipo=dispositivo.tipo,
identificador=dispositivo.identificador,
nombre=dispositivo.nombre,
marca=dispositivo.marca,
modelo=dispositivo.modelo,
numero_serie=dispositivo.numero_serie,
telefono_sim=dispositivo.telefono_sim,
operador_sim=dispositivo.operador_sim,
iccid=dispositivo.iccid,
imei=dispositivo.imei,
protocolo=dispositivo.protocolo,
ultimo_contacto=dispositivo.ultimo_contacto,
bateria=dispositivo.bateria,
señal_gsm=dispositivo.señal_gsm,
satelites=dispositivo.satelites,
intervalo_reporte=dispositivo.intervalo_reporte,
configuracion=dispositivo.configuracion,
firmware_version=dispositivo.firmware_version,
activo=dispositivo.activo,
conectado=dispositivo.conectado,
notas=dispositivo.notas,
esta_online=dispositivo.esta_online,
creado_en=dispositivo.creado_en,
actualizado_en=dispositivo.actualizado_en,
)

View File

@@ -0,0 +1,502 @@
"""
Endpoints para gestión de geocercas.
"""
import json
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.core.database import get_db
from app.core.security import get_current_user
from app.models.usuario import Usuario
from app.models.geocerca import Geocerca
from app.models.vehiculo import Vehiculo
from app.schemas.geocerca import (
GeocercaCircularCreate,
GeocercaPoligonoCreate,
GeocercaUpdate,
GeocercaResponse,
GeocercaConVehiculos,
AsignarVehiculosRequest,
VerificarPuntoRequest,
VerificarPuntoResponse,
)
from app.services.geocerca_service import GeocercaService
router = APIRouter(prefix="/geocercas", tags=["Geocercas"])
@router.get("", response_model=List[GeocercaResponse])
async def listar_geocercas(
activa: Optional[bool] = None,
tipo: Optional[str] = None,
categoria: Optional[str] = None,
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100),
db: AsyncSession = Depends(get_db),
current_user: Usuario = Depends(get_current_user),
):
"""
Lista todas las geocercas.
Args:
activa: Filtrar por estado.
tipo: Filtrar por tipo (circular/poligono).
categoria: Filtrar por categoría.
skip: Registros a saltar.
limit: Límite de registros.
Returns:
Lista de geocercas.
"""
query = select(Geocerca).order_by(Geocerca.nombre)
if activa is not None:
query = query.where(Geocerca.activa == activa)
if tipo:
query = query.where(Geocerca.tipo == tipo)
if categoria:
query = query.where(Geocerca.categoria == categoria)
query = query.offset(skip).limit(limit)
result = await db.execute(query)
geocercas = result.scalars().all()
return [
GeocercaResponse(
id=g.id,
nombre=g.nombre,
descripcion=g.descripcion,
tipo=g.tipo,
color=g.color,
opacidad=g.opacidad,
color_borde=g.color_borde,
categoria=g.categoria,
centro_lat=g.centro_lat,
centro_lng=g.centro_lng,
radio_metros=g.radio_metros,
coordenadas_json=g.coordenadas_json,
alerta_entrada=g.alerta_entrada,
alerta_salida=g.alerta_salida,
velocidad_maxima=g.velocidad_maxima,
horario_json=g.horario_json,
activa=g.activa,
aplica_todos_vehiculos=g.aplica_todos_vehiculos,
creado_en=g.creado_en,
actualizado_en=g.actualizado_en,
)
for g in geocercas
]
@router.get("/geojson")
async def obtener_geocercas_geojson(
activa: bool = True,
db: AsyncSession = Depends(get_db),
current_user: Usuario = Depends(get_current_user),
):
"""
Obtiene todas las geocercas en formato GeoJSON.
Args:
activa: Solo geocercas activas.
Returns:
FeatureCollection GeoJSON.
"""
query = select(Geocerca)
if activa:
query = query.where(Geocerca.activa == True)
result = await db.execute(query)
geocercas = result.scalars().all()
features = [g.to_geojson() for g in geocercas]
return {
"type": "FeatureCollection",
"features": features,
}
@router.get("/{geocerca_id}", response_model=GeocercaConVehiculos)
async def obtener_geocerca(
geocerca_id: int,
db: AsyncSession = Depends(get_db),
current_user: Usuario = Depends(get_current_user),
):
"""
Obtiene una geocerca por su ID.
Args:
geocerca_id: ID de la geocerca.
Returns:
Geocerca con vehículos asignados.
"""
result = await db.execute(
select(Geocerca)
.options(selectinload(Geocerca.vehiculos_asignados))
.where(Geocerca.id == geocerca_id)
)
geocerca = result.scalar_one_or_none()
if not geocerca:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Geocerca con id {geocerca_id} no encontrada",
)
from app.schemas.vehiculo import VehiculoResumen
return GeocercaConVehiculos(
id=geocerca.id,
nombre=geocerca.nombre,
descripcion=geocerca.descripcion,
tipo=geocerca.tipo,
color=geocerca.color,
opacidad=geocerca.opacidad,
color_borde=geocerca.color_borde,
categoria=geocerca.categoria,
centro_lat=geocerca.centro_lat,
centro_lng=geocerca.centro_lng,
radio_metros=geocerca.radio_metros,
coordenadas_json=geocerca.coordenadas_json,
alerta_entrada=geocerca.alerta_entrada,
alerta_salida=geocerca.alerta_salida,
velocidad_maxima=geocerca.velocidad_maxima,
horario_json=geocerca.horario_json,
activa=geocerca.activa,
aplica_todos_vehiculos=geocerca.aplica_todos_vehiculos,
creado_en=geocerca.creado_en,
actualizado_en=geocerca.actualizado_en,
vehiculos_asignados=[
VehiculoResumen.model_validate(v)
for v in geocerca.vehiculos_asignados
],
)
@router.post("/circular", response_model=GeocercaResponse, status_code=status.HTTP_201_CREATED)
async def crear_geocerca_circular(
geocerca_data: GeocercaCircularCreate,
db: AsyncSession = Depends(get_db),
current_user: Usuario = Depends(get_current_user),
):
"""
Crea una geocerca circular.
Args:
geocerca_data: Datos de la geocerca.
Returns:
Geocerca creada.
"""
geocerca = Geocerca(
nombre=geocerca_data.nombre,
descripcion=geocerca_data.descripcion,
tipo="circular",
centro_lat=geocerca_data.centro_lat,
centro_lng=geocerca_data.centro_lng,
radio_metros=geocerca_data.radio_metros,
color=geocerca_data.color,
opacidad=geocerca_data.opacidad,
color_borde=geocerca_data.color_borde,
categoria=geocerca_data.categoria,
alerta_entrada=geocerca_data.alerta_entrada,
alerta_salida=geocerca_data.alerta_salida,
velocidad_maxima=geocerca_data.velocidad_maxima,
horario_json=geocerca_data.horario_json,
)
db.add(geocerca)
await db.commit()
await db.refresh(geocerca)
# Asignar vehículos si se especificaron
if geocerca_data.vehiculos_ids:
result = await db.execute(
select(Vehiculo).where(Vehiculo.id.in_(geocerca_data.vehiculos_ids))
)
vehiculos = result.scalars().all()
geocerca.vehiculos_asignados = list(vehiculos)
await db.commit()
return GeocercaResponse(
id=geocerca.id,
nombre=geocerca.nombre,
descripcion=geocerca.descripcion,
tipo=geocerca.tipo,
color=geocerca.color,
opacidad=geocerca.opacidad,
color_borde=geocerca.color_borde,
categoria=geocerca.categoria,
centro_lat=geocerca.centro_lat,
centro_lng=geocerca.centro_lng,
radio_metros=geocerca.radio_metros,
coordenadas_json=geocerca.coordenadas_json,
alerta_entrada=geocerca.alerta_entrada,
alerta_salida=geocerca.alerta_salida,
velocidad_maxima=geocerca.velocidad_maxima,
horario_json=geocerca.horario_json,
activa=geocerca.activa,
aplica_todos_vehiculos=geocerca.aplica_todos_vehiculos,
creado_en=geocerca.creado_en,
actualizado_en=geocerca.actualizado_en,
)
@router.post("/poligono", response_model=GeocercaResponse, status_code=status.HTTP_201_CREATED)
async def crear_geocerca_poligono(
geocerca_data: GeocercaPoligonoCreate,
db: AsyncSession = Depends(get_db),
current_user: Usuario = Depends(get_current_user),
):
"""
Crea una geocerca poligonal.
Args:
geocerca_data: Datos de la geocerca.
Returns:
Geocerca creada.
"""
geocerca = Geocerca(
nombre=geocerca_data.nombre,
descripcion=geocerca_data.descripcion,
tipo="poligono",
coordenadas_json=json.dumps(geocerca_data.coordenadas),
color=geocerca_data.color,
opacidad=geocerca_data.opacidad,
color_borde=geocerca_data.color_borde,
categoria=geocerca_data.categoria,
alerta_entrada=geocerca_data.alerta_entrada,
alerta_salida=geocerca_data.alerta_salida,
velocidad_maxima=geocerca_data.velocidad_maxima,
horario_json=geocerca_data.horario_json,
)
db.add(geocerca)
await db.commit()
await db.refresh(geocerca)
# Asignar vehículos si se especificaron
if geocerca_data.vehiculos_ids:
result = await db.execute(
select(Vehiculo).where(Vehiculo.id.in_(geocerca_data.vehiculos_ids))
)
vehiculos = result.scalars().all()
geocerca.vehiculos_asignados = list(vehiculos)
await db.commit()
return GeocercaResponse(
id=geocerca.id,
nombre=geocerca.nombre,
descripcion=geocerca.descripcion,
tipo=geocerca.tipo,
color=geocerca.color,
opacidad=geocerca.opacidad,
color_borde=geocerca.color_borde,
categoria=geocerca.categoria,
centro_lat=geocerca.centro_lat,
centro_lng=geocerca.centro_lng,
radio_metros=geocerca.radio_metros,
coordenadas_json=geocerca.coordenadas_json,
alerta_entrada=geocerca.alerta_entrada,
alerta_salida=geocerca.alerta_salida,
velocidad_maxima=geocerca.velocidad_maxima,
horario_json=geocerca.horario_json,
activa=geocerca.activa,
aplica_todos_vehiculos=geocerca.aplica_todos_vehiculos,
creado_en=geocerca.creado_en,
actualizado_en=geocerca.actualizado_en,
)
@router.put("/{geocerca_id}", response_model=GeocercaResponse)
async def actualizar_geocerca(
geocerca_id: int,
geocerca_data: GeocercaUpdate,
db: AsyncSession = Depends(get_db),
current_user: Usuario = Depends(get_current_user),
):
"""
Actualiza una geocerca.
Args:
geocerca_id: ID de la geocerca.
geocerca_data: Datos a actualizar.
Returns:
Geocerca actualizada.
"""
result = await db.execute(
select(Geocerca).where(Geocerca.id == geocerca_id)
)
geocerca = result.scalar_one_or_none()
if not geocerca:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Geocerca con id {geocerca_id} no encontrada",
)
update_data = geocerca_data.model_dump(exclude_unset=True)
# Manejar coordenadas si es polígono
if "coordenadas" in update_data and update_data["coordenadas"]:
update_data["coordenadas_json"] = json.dumps(update_data.pop("coordenadas"))
for field, value in update_data.items():
if hasattr(geocerca, field):
setattr(geocerca, field, value)
await db.commit()
await db.refresh(geocerca)
return GeocercaResponse(
id=geocerca.id,
nombre=geocerca.nombre,
descripcion=geocerca.descripcion,
tipo=geocerca.tipo,
color=geocerca.color,
opacidad=geocerca.opacidad,
color_borde=geocerca.color_borde,
categoria=geocerca.categoria,
centro_lat=geocerca.centro_lat,
centro_lng=geocerca.centro_lng,
radio_metros=geocerca.radio_metros,
coordenadas_json=geocerca.coordenadas_json,
alerta_entrada=geocerca.alerta_entrada,
alerta_salida=geocerca.alerta_salida,
velocidad_maxima=geocerca.velocidad_maxima,
horario_json=geocerca.horario_json,
activa=geocerca.activa,
aplica_todos_vehiculos=geocerca.aplica_todos_vehiculos,
creado_en=geocerca.creado_en,
actualizado_en=geocerca.actualizado_en,
)
@router.delete("/{geocerca_id}", status_code=status.HTTP_204_NO_CONTENT)
async def eliminar_geocerca(
geocerca_id: int,
db: AsyncSession = Depends(get_db),
current_user: Usuario = Depends(get_current_user),
):
"""
Elimina una geocerca (soft delete - desactiva).
Args:
geocerca_id: ID de la geocerca.
"""
result = await db.execute(
select(Geocerca).where(Geocerca.id == geocerca_id)
)
geocerca = result.scalar_one_or_none()
if not geocerca:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Geocerca con id {geocerca_id} no encontrada",
)
geocerca.activa = False
await db.commit()
@router.post("/{geocerca_id}/vehiculos")
async def asignar_vehiculos(
geocerca_id: int,
request: AsignarVehiculosRequest,
db: AsyncSession = Depends(get_db),
current_user: Usuario = Depends(get_current_user),
):
"""
Asigna vehículos a una geocerca.
Args:
geocerca_id: ID de la geocerca.
request: Lista de IDs de vehículos.
Returns:
Confirmación.
"""
result = await db.execute(
select(Geocerca)
.options(selectinload(Geocerca.vehiculos_asignados))
.where(Geocerca.id == geocerca_id)
)
geocerca = result.scalar_one_or_none()
if not geocerca:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Geocerca con id {geocerca_id} no encontrada",
)
# Obtener vehículos
result = await db.execute(
select(Vehiculo).where(Vehiculo.id.in_(request.vehiculos_ids))
)
vehiculos = result.scalars().all()
if request.reemplazar:
geocerca.vehiculos_asignados = list(vehiculos)
else:
for v in vehiculos:
if v not in geocerca.vehiculos_asignados:
geocerca.vehiculos_asignados.append(v)
await db.commit()
return {
"message": "Vehículos asignados correctamente",
"total_asignados": len(geocerca.vehiculos_asignados),
}
@router.post("/{geocerca_id}/verificar", response_model=VerificarPuntoResponse)
async def verificar_punto(
geocerca_id: int,
request: VerificarPuntoRequest,
db: AsyncSession = Depends(get_db),
current_user: Usuario = Depends(get_current_user),
):
"""
Verifica si un punto está dentro de una geocerca.
Args:
geocerca_id: ID de la geocerca.
request: Coordenadas del punto.
Returns:
Resultado de la verificación.
"""
result = await db.execute(
select(Geocerca).where(Geocerca.id == geocerca_id)
)
geocerca = result.scalar_one_or_none()
if not geocerca:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Geocerca con id {geocerca_id} no encontrada",
)
geocerca_service = GeocercaService(db)
dentro, distancia = await geocerca_service.verificar_punto_en_geocerca(
request.lat, request.lng, geocerca_id
)
return VerificarPuntoResponse(
dentro=dentro,
geocerca_id=geocerca_id,
geocerca_nombre=geocerca.nombre,
distancia_metros=distancia,
)

View File

@@ -0,0 +1,227 @@
"""
Endpoints para reportes y dashboard.
"""
from datetime import datetime
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.security import get_current_user
from app.models.usuario import Usuario
from app.schemas.reporte import (
DashboardResumen,
DashboardGrafico,
ReporteRequest,
ReporteResponse,
)
from app.services.reporte_service import ReporteService
router = APIRouter(prefix="/reportes", tags=["Reportes"])
@router.get("/dashboard", response_model=DashboardResumen)
async def obtener_dashboard(
db: AsyncSession = Depends(get_db),
current_user: Usuario = Depends(get_current_user),
):
"""
Obtiene datos del dashboard principal.
Returns:
Resumen del dashboard.
"""
reporte_service = ReporteService(db)
return await reporte_service.obtener_dashboard_resumen()
@router.get("/dashboard/graficos", response_model=DashboardGrafico)
async def obtener_graficos_dashboard(
db: AsyncSession = Depends(get_db),
current_user: Usuario = Depends(get_current_user),
):
"""
Obtiene datos para gráficos del dashboard.
Returns:
Datos para gráficos.
"""
reporte_service = ReporteService(db)
return await reporte_service.obtener_dashboard_graficos()
@router.post("/generar", response_model=ReporteResponse)
async def generar_reporte(
request: ReporteRequest,
db: AsyncSession = Depends(get_db),
current_user: Usuario = Depends(get_current_user),
):
"""
Genera un reporte según los parámetros.
Args:
request: Parámetros del reporte.
Returns:
Información del reporte generado.
"""
reporte_service = ReporteService(db)
return await reporte_service.generar_reporte(request)
@router.get("/viajes")
async def reporte_viajes(
desde: datetime,
hasta: datetime,
vehiculo_id: Optional[int] = None,
formato: str = "json",
db: AsyncSession = Depends(get_db),
current_user: Usuario = Depends(get_current_user),
):
"""
Genera reporte de viajes.
Args:
desde: Fecha inicio.
hasta: Fecha fin.
vehiculo_id: Filtrar por vehículo.
formato: Formato de salida.
Returns:
Datos del reporte.
"""
reporte_service = ReporteService(db)
request = ReporteRequest(
tipo="viajes",
formato=formato if formato != "json" else "pdf",
fecha_inicio=desde,
fecha_fin=hasta,
vehiculos_ids=[vehiculo_id] if vehiculo_id else None,
)
datos = await reporte_service._recopilar_datos_reporte(request)
if formato == "json":
return datos
resultado = await reporte_service.generar_reporte(request)
return resultado
@router.get("/alertas")
async def reporte_alertas(
desde: datetime,
hasta: datetime,
vehiculo_id: Optional[int] = None,
formato: str = "json",
db: AsyncSession = Depends(get_db),
current_user: Usuario = Depends(get_current_user),
):
"""
Genera reporte de alertas.
Args:
desde: Fecha inicio.
hasta: Fecha fin.
vehiculo_id: Filtrar por vehículo.
formato: Formato de salida.
Returns:
Datos del reporte.
"""
reporte_service = ReporteService(db)
request = ReporteRequest(
tipo="alertas",
formato=formato if formato != "json" else "pdf",
fecha_inicio=desde,
fecha_fin=hasta,
vehiculos_ids=[vehiculo_id] if vehiculo_id else None,
)
datos = await reporte_service._recopilar_datos_reporte(request)
if formato == "json":
return datos
resultado = await reporte_service.generar_reporte(request)
return resultado
@router.get("/combustible")
async def reporte_combustible(
desde: datetime,
hasta: datetime,
vehiculo_id: Optional[int] = None,
formato: str = "json",
db: AsyncSession = Depends(get_db),
current_user: Usuario = Depends(get_current_user),
):
"""
Genera reporte de combustible.
Args:
desde: Fecha inicio.
hasta: Fecha fin.
vehiculo_id: Filtrar por vehículo.
formato: Formato de salida.
Returns:
Datos del reporte.
"""
reporte_service = ReporteService(db)
request = ReporteRequest(
tipo="combustible",
formato=formato if formato != "json" else "pdf",
fecha_inicio=desde,
fecha_fin=hasta,
vehiculos_ids=[vehiculo_id] if vehiculo_id else None,
)
datos = await reporte_service._recopilar_datos_reporte(request)
if formato == "json":
return datos
resultado = await reporte_service.generar_reporte(request)
return resultado
@router.get("/mantenimiento")
async def reporte_mantenimiento(
desde: datetime,
hasta: datetime,
vehiculo_id: Optional[int] = None,
formato: str = "json",
db: AsyncSession = Depends(get_db),
current_user: Usuario = Depends(get_current_user),
):
"""
Genera reporte de mantenimiento.
Args:
desde: Fecha inicio.
hasta: Fecha fin.
vehiculo_id: Filtrar por vehículo.
formato: Formato de salida.
Returns:
Datos del reporte.
"""
reporte_service = ReporteService(db)
request = ReporteRequest(
tipo="mantenimiento",
formato=formato if formato != "json" else "pdf",
fecha_inicio=desde,
fecha_fin=hasta,
vehiculos_ids=[vehiculo_id] if vehiculo_id else None,
)
datos = await reporte_service._recopilar_datos_reporte(request)
if formato == "json":
return datos
resultado = await reporte_service.generar_reporte(request)
return resultado

View File

@@ -0,0 +1,48 @@
"""
Router principal de la API v1.
Incluye todos los sub-routers de cada módulo.
"""
from fastapi import APIRouter
from app.api.v1.auth import router as auth_router
from app.api.v1.vehiculos import router as vehiculos_router
from app.api.v1.conductores import router as conductores_router
from app.api.v1.ubicaciones import router as ubicaciones_router
from app.api.v1.viajes import router as viajes_router
from app.api.v1.alertas import router as alertas_router
from app.api.v1.geocercas import router as geocercas_router
from app.api.v1.dispositivos import router as dispositivos_router
from app.api.v1.reportes import router as reportes_router
# Router principal
api_router = APIRouter()
# Incluir todos los sub-routers
api_router.include_router(auth_router)
api_router.include_router(vehiculos_router)
api_router.include_router(conductores_router)
api_router.include_router(ubicaciones_router)
api_router.include_router(viajes_router)
api_router.include_router(alertas_router)
api_router.include_router(geocercas_router)
api_router.include_router(dispositivos_router)
api_router.include_router(reportes_router)
# TODO: Agregar cuando se completen
# from app.api.v1.pois import router as pois_router
# from app.api.v1.combustible import router as combustible_router
# from app.api.v1.mantenimiento import router as mantenimiento_router
# from app.api.v1.video import router as video_router
# from app.api.v1.mensajes import router as mensajes_router
# from app.api.v1.configuracion import router as configuracion_router
# from app.api.v1.meshtastic import router as meshtastic_router
# api_router.include_router(pois_router)
# api_router.include_router(combustible_router)
# api_router.include_router(mantenimiento_router)
# api_router.include_router(video_router)
# api_router.include_router(mensajes_router)
# api_router.include_router(configuracion_router)
# api_router.include_router(meshtastic_router)

View File

@@ -0,0 +1,237 @@
"""
Endpoints para recepción y consulta de ubicaciones GPS.
"""
from datetime import datetime
from typing import Optional
from fastapi import APIRouter, Depends, Query, Request
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.security import get_current_user
from app.models.usuario import Usuario
from app.schemas.ubicacion import (
UbicacionCreate,
UbicacionBulkCreate,
UbicacionResponse,
HistorialUbicacionesResponse,
OsmAndLocationCreate,
)
from app.services.ubicacion_service import UbicacionService
from app.services.alerta_service import AlertaService
from app.services.viaje_service import ViajeService
router = APIRouter(prefix="/ubicaciones", tags=["Ubicaciones"])
@router.post("", response_model=Optional[UbicacionResponse])
async def recibir_ubicacion(
ubicacion_data: UbicacionCreate,
db: AsyncSession = Depends(get_db),
):
"""
Recibe una ubicación GPS desde la app móvil o dispositivo.
Este endpoint no requiere autenticación para facilitar
la integración con dispositivos GPS simples.
Args:
ubicacion_data: Datos de la ubicación.
Returns:
Ubicación procesada o None si se descartó.
"""
ubicacion_service = UbicacionService(db)
resultado = await ubicacion_service.procesar_ubicacion(ubicacion_data)
if resultado:
# Procesar alertas y viajes en background
alerta_service = AlertaService(db)
viaje_service = ViajeService(db)
# Verificar velocidad
if ubicacion_data.velocidad:
await alerta_service.verificar_velocidad(
resultado.vehiculo_id,
ubicacion_data.velocidad,
ubicacion_data.lat,
ubicacion_data.lng,
)
# Verificar batería
if ubicacion_data.bateria_dispositivo:
await alerta_service.verificar_bateria_baja(
resultado.vehiculo_id,
ubicacion_data.bateria_dispositivo,
ubicacion_data.lat,
ubicacion_data.lng,
)
# Procesar viaje
await viaje_service.procesar_ubicacion_viaje(
resultado.vehiculo_id,
ubicacion_data.lat,
ubicacion_data.lng,
ubicacion_data.velocidad or 0,
resultado.tiempo,
)
return resultado
@router.post("/bulk", response_model=dict)
async def recibir_ubicaciones_bulk(
data: UbicacionBulkCreate,
db: AsyncSession = Depends(get_db),
):
"""
Recibe múltiples ubicaciones en una sola petición.
Útil para sincronización de datos acumulados cuando
el dispositivo estuvo offline.
Args:
data: Lista de ubicaciones.
Returns:
Conteo de ubicaciones procesadas.
"""
ubicacion_service = UbicacionService(db)
procesadas = 0
errores = 0
for ubicacion_data in data.ubicaciones:
try:
resultado = await ubicacion_service.procesar_ubicacion(ubicacion_data)
if resultado:
procesadas += 1
except Exception:
errores += 1
return {
"total": len(data.ubicaciones),
"procesadas": procesadas,
"errores": errores,
}
@router.get("/osmand")
async def recibir_ubicacion_osmand(
request: Request,
id: str,
lat: float,
lon: float,
timestamp: Optional[int] = None,
speed: Optional[float] = None,
bearing: Optional[float] = None,
altitude: Optional[float] = None,
accuracy: Optional[float] = None,
batt: Optional[float] = None,
db: AsyncSession = Depends(get_db),
):
"""
Endpoint compatible con OsmAnd Live Tracking.
OsmAnd envía ubicaciones mediante GET con parámetros en URL.
Args:
id: Identificador del dispositivo.
lat: Latitud.
lon: Longitud.
timestamp: Unix timestamp (opcional).
speed: Velocidad en km/h (opcional).
bearing: Rumbo en grados (opcional).
altitude: Altitud en metros (opcional).
accuracy: Precisión en metros (opcional).
batt: Porcentaje de batería (opcional).
Returns:
Confirmación de recepción.
"""
ubicacion_data = UbicacionCreate(
dispositivo_id=id,
lat=lat,
lng=lon,
velocidad=speed,
rumbo=bearing,
altitud=altitude,
precision=accuracy,
bateria_dispositivo=batt,
tiempo=datetime.fromtimestamp(timestamp) if timestamp else None,
fuente="osmand",
)
ubicacion_service = UbicacionService(db)
resultado = await ubicacion_service.procesar_ubicacion(ubicacion_data)
if resultado:
return {"status": "ok"}
return {"status": "device_not_found"}
@router.get("/historial/{vehiculo_id}", response_model=HistorialUbicacionesResponse)
async def obtener_historial(
vehiculo_id: int,
desde: datetime,
hasta: datetime,
simplificar: bool = True,
intervalo_segundos: Optional[int] = None,
db: AsyncSession = Depends(get_db),
current_user: Usuario = Depends(get_current_user),
):
"""
Obtiene el historial de ubicaciones de un vehículo.
Args:
vehiculo_id: ID del vehículo.
desde: Fecha/hora de inicio.
hasta: Fecha/hora de fin.
simplificar: Simplificar ruta (Douglas-Peucker).
intervalo_segundos: Muestreo por intervalo.
Returns:
Historial con estadísticas.
"""
ubicacion_service = UbicacionService(db)
return await ubicacion_service.obtener_historial(
vehiculo_id,
desde,
hasta,
simplificar,
intervalo_segundos,
)
@router.get("/ultima/{vehiculo_id}", response_model=Optional[UbicacionResponse])
async def obtener_ultima_ubicacion(
vehiculo_id: int,
db: AsyncSession = Depends(get_db),
current_user: Usuario = Depends(get_current_user),
):
"""
Obtiene la última ubicación de un vehículo.
Args:
vehiculo_id: ID del vehículo.
Returns:
Última ubicación conocida.
"""
ubicacion_service = UbicacionService(db)
return await ubicacion_service.obtener_ultima_ubicacion(vehiculo_id)
@router.get("/flota")
async def obtener_ubicaciones_flota(
db: AsyncSession = Depends(get_db),
current_user: Usuario = Depends(get_current_user),
):
"""
Obtiene las ubicaciones actuales de toda la flota.
Returns:
Lista de ubicaciones de todos los vehículos activos.
"""
ubicacion_service = UbicacionService(db)
return await ubicacion_service.obtener_ubicaciones_flota()

View File

@@ -0,0 +1,481 @@
"""
Endpoints para gestión de vehículos.
"""
from datetime import datetime, timezone
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.core.database import get_db
from app.core.security import get_current_user
from app.models.usuario import Usuario
from app.models.vehiculo import Vehiculo
from app.models.viaje import Viaje
from app.models.alerta import Alerta
from app.schemas.vehiculo import (
VehiculoCreate,
VehiculoUpdate,
VehiculoResponse,
VehiculoResumen,
VehiculoConRelaciones,
VehiculoUbicacionActual,
VehiculoEstadisticas,
)
from app.schemas.base import PaginatedResponse
from app.services.ubicacion_service import UbicacionService
router = APIRouter(prefix="/vehiculos", tags=["Vehiculos"])
@router.get("", response_model=List[VehiculoResumen])
async def listar_vehiculos(
activo: Optional[bool] = None,
en_servicio: Optional[bool] = None,
grupo_id: Optional[int] = None,
buscar: Optional[str] = None,
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100),
db: AsyncSession = Depends(get_db),
current_user: Usuario = Depends(get_current_user),
):
"""
Lista todos los vehículos con filtros opcionales.
Args:
activo: Filtrar por estado activo.
en_servicio: Filtrar por en servicio.
grupo_id: Filtrar por grupo.
buscar: Búsqueda por nombre o placa.
skip: Registros a saltar.
limit: Límite de registros.
Returns:
Lista de vehículos.
"""
query = select(Vehiculo)
if activo is not None:
query = query.where(Vehiculo.activo == activo)
if en_servicio is not None:
query = query.where(Vehiculo.en_servicio == en_servicio)
if grupo_id:
query = query.where(Vehiculo.grupo_id == grupo_id)
if buscar:
query = query.where(
(Vehiculo.nombre.ilike(f"%{buscar}%")) |
(Vehiculo.placa.ilike(f"%{buscar}%"))
)
query = query.offset(skip).limit(limit).order_by(Vehiculo.nombre)
result = await db.execute(query)
vehiculos = result.scalars().all()
return [VehiculoResumen.model_validate(v) for v in vehiculos]
@router.get("/ubicaciones", response_model=List[VehiculoUbicacionActual])
async def obtener_ubicaciones_flota(
db: AsyncSession = Depends(get_db),
current_user: Usuario = Depends(get_current_user),
):
"""
Obtiene las ubicaciones actuales de todos los vehículos.
Returns:
Lista de ubicaciones actuales.
"""
ubicacion_service = UbicacionService(db)
return await ubicacion_service.obtener_ubicaciones_flota()
@router.get("/{vehiculo_id}", response_model=VehiculoConRelaciones)
async def obtener_vehiculo(
vehiculo_id: int,
db: AsyncSession = Depends(get_db),
current_user: Usuario = Depends(get_current_user),
):
"""
Obtiene un vehículo por su ID con todas sus relaciones.
Args:
vehiculo_id: ID del vehículo.
Returns:
Vehículo con relaciones.
"""
result = await db.execute(
select(Vehiculo)
.options(
selectinload(Vehiculo.conductor),
selectinload(Vehiculo.grupo),
selectinload(Vehiculo.dispositivos),
)
.where(Vehiculo.id == vehiculo_id)
)
vehiculo = result.scalar_one_or_none()
if not vehiculo:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Vehículo con id {vehiculo_id} no encontrado",
)
return VehiculoConRelaciones.model_validate(vehiculo)
@router.post("", response_model=VehiculoResponse, status_code=status.HTTP_201_CREATED)
async def crear_vehiculo(
vehiculo_data: VehiculoCreate,
db: AsyncSession = Depends(get_db),
current_user: Usuario = Depends(get_current_user),
):
"""
Crea un nuevo vehículo.
Args:
vehiculo_data: Datos del vehículo.
Returns:
Vehículo creado.
"""
# Verificar que la placa no exista
result = await db.execute(
select(Vehiculo).where(Vehiculo.placa == vehiculo_data.placa)
)
if result.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Ya existe un vehículo con la placa {vehiculo_data.placa}",
)
# Verificar VIN si se proporciona
if vehiculo_data.vin:
result = await db.execute(
select(Vehiculo).where(Vehiculo.vin == vehiculo_data.vin)
)
if result.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Ya existe un vehículo con el VIN {vehiculo_data.vin}",
)
vehiculo = Vehiculo(**vehiculo_data.model_dump())
vehiculo.odometro_actual = vehiculo_data.odometro_inicial
db.add(vehiculo)
await db.commit()
await db.refresh(vehiculo)
return VehiculoResponse.model_validate(vehiculo)
@router.put("/{vehiculo_id}", response_model=VehiculoResponse)
async def actualizar_vehiculo(
vehiculo_id: int,
vehiculo_data: VehiculoUpdate,
db: AsyncSession = Depends(get_db),
current_user: Usuario = Depends(get_current_user),
):
"""
Actualiza un vehículo existente.
Args:
vehiculo_id: ID del vehículo.
vehiculo_data: Datos a actualizar.
Returns:
Vehículo actualizado.
"""
result = await db.execute(
select(Vehiculo).where(Vehiculo.id == vehiculo_id)
)
vehiculo = result.scalar_one_or_none()
if not vehiculo:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Vehículo con id {vehiculo_id} no encontrado",
)
# Verificar placa única si se cambia
if vehiculo_data.placa and vehiculo_data.placa != vehiculo.placa:
result = await db.execute(
select(Vehiculo).where(Vehiculo.placa == vehiculo_data.placa)
)
if result.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Ya existe un vehículo con la placa {vehiculo_data.placa}",
)
update_data = vehiculo_data.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(vehiculo, field, value)
await db.commit()
await db.refresh(vehiculo)
return VehiculoResponse.model_validate(vehiculo)
@router.delete("/{vehiculo_id}", status_code=status.HTTP_204_NO_CONTENT)
async def eliminar_vehiculo(
vehiculo_id: int,
db: AsyncSession = Depends(get_db),
current_user: Usuario = Depends(get_current_user),
):
"""
Elimina un vehículo (soft delete - desactiva).
Args:
vehiculo_id: ID del vehículo.
"""
result = await db.execute(
select(Vehiculo).where(Vehiculo.id == vehiculo_id)
)
vehiculo = result.scalar_one_or_none()
if not vehiculo:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Vehículo con id {vehiculo_id} no encontrado",
)
# Soft delete
vehiculo.activo = False
vehiculo.en_servicio = False
await db.commit()
@router.get("/{vehiculo_id}/ubicacion")
async def obtener_ubicacion_actual(
vehiculo_id: int,
db: AsyncSession = Depends(get_db),
current_user: Usuario = Depends(get_current_user),
):
"""
Obtiene la ubicación actual de un vehículo.
Args:
vehiculo_id: ID del vehículo.
Returns:
Última ubicación conocida.
"""
ubicacion_service = UbicacionService(db)
ubicacion = await ubicacion_service.obtener_ultima_ubicacion(vehiculo_id)
if not ubicacion:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="No hay ubicación registrada para este vehículo",
)
return ubicacion
@router.get("/{vehiculo_id}/historial")
async def obtener_historial_ubicaciones(
vehiculo_id: int,
desde: datetime,
hasta: datetime,
simplificar: bool = True,
db: AsyncSession = Depends(get_db),
current_user: Usuario = Depends(get_current_user),
):
"""
Obtiene el historial de ubicaciones de un vehículo.
Args:
vehiculo_id: ID del vehículo.
desde: Fecha/hora de inicio.
hasta: Fecha/hora de fin.
simplificar: Simplificar la ruta.
Returns:
Historial de ubicaciones con estadísticas.
"""
ubicacion_service = UbicacionService(db)
return await ubicacion_service.obtener_historial(
vehiculo_id, desde, hasta, simplificar
)
@router.get("/{vehiculo_id}/viajes", response_model=List[dict])
async def obtener_viajes_vehiculo(
vehiculo_id: int,
desde: Optional[datetime] = None,
hasta: Optional[datetime] = None,
limite: int = Query(50, ge=1, le=100),
db: AsyncSession = Depends(get_db),
current_user: Usuario = Depends(get_current_user),
):
"""
Obtiene los viajes de un vehículo.
Args:
vehiculo_id: ID del vehículo.
desde: Fecha inicio (opcional).
hasta: Fecha fin (opcional).
limite: Límite de resultados.
Returns:
Lista de viajes.
"""
from app.services.viaje_service import ViajeService
viaje_service = ViajeService(db)
viajes = await viaje_service.obtener_viajes_vehiculo(
vehiculo_id, desde, hasta, limite
)
return [
{
"id": v.id,
"inicio_tiempo": v.inicio_tiempo,
"fin_tiempo": v.fin_tiempo,
"inicio_direccion": v.inicio_direccion,
"fin_direccion": v.fin_direccion,
"distancia_km": v.distancia_km,
"duracion_formateada": v.duracion_formateada,
"estado": v.estado,
}
for v in viajes
]
@router.get("/{vehiculo_id}/alertas", response_model=List[dict])
async def obtener_alertas_vehiculo(
vehiculo_id: int,
atendidas: Optional[bool] = None,
limite: int = Query(50, ge=1, le=100),
db: AsyncSession = Depends(get_db),
current_user: Usuario = Depends(get_current_user),
):
"""
Obtiene las alertas de un vehículo.
Args:
vehiculo_id: ID del vehículo.
atendidas: Filtrar por estado de atención.
limite: Límite de resultados.
Returns:
Lista de alertas.
"""
query = (
select(Alerta)
.where(Alerta.vehiculo_id == vehiculo_id)
.order_by(Alerta.creado_en.desc())
.limit(limite)
)
if atendidas is not None:
query = query.where(Alerta.atendida == atendidas)
result = await db.execute(query)
alertas = result.scalars().all()
return [
{
"id": a.id,
"tipo_alerta_id": a.tipo_alerta_id,
"severidad": a.severidad,
"mensaje": a.mensaje,
"creado_en": a.creado_en,
"atendida": a.atendida,
}
for a in alertas
]
@router.get("/{vehiculo_id}/estadisticas", response_model=VehiculoEstadisticas)
async def obtener_estadisticas_vehiculo(
vehiculo_id: int,
db: AsyncSession = Depends(get_db),
current_user: Usuario = Depends(get_current_user),
):
"""
Obtiene estadísticas de un vehículo.
Args:
vehiculo_id: ID del vehículo.
Returns:
Estadísticas del vehículo.
"""
result = await db.execute(
select(Vehiculo).where(Vehiculo.id == vehiculo_id)
)
vehiculo = result.scalar_one_or_none()
if not vehiculo:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Vehículo con id {vehiculo_id} no encontrado",
)
ahora = datetime.now(timezone.utc)
inicio_hoy = ahora.replace(hour=0, minute=0, second=0, microsecond=0)
inicio_semana = ahora - timedelta(days=7)
inicio_mes = ahora - timedelta(days=30)
# Distancia hoy
result = await db.execute(
select(func.coalesce(func.sum(Viaje.distancia_km), 0))
.where(Viaje.vehiculo_id == vehiculo_id)
.where(Viaje.inicio_tiempo >= inicio_hoy)
)
distancia_hoy = result.scalar() or 0
# Distancia semana
result = await db.execute(
select(func.coalesce(func.sum(Viaje.distancia_km), 0))
.where(Viaje.vehiculo_id == vehiculo_id)
.where(Viaje.inicio_tiempo >= inicio_semana)
)
distancia_semana = result.scalar() or 0
# Distancia mes
result = await db.execute(
select(func.coalesce(func.sum(Viaje.distancia_km), 0))
.where(Viaje.vehiculo_id == vehiculo_id)
.where(Viaje.inicio_tiempo >= inicio_mes)
)
distancia_mes = result.scalar() or 0
# Alertas activas
result = await db.execute(
select(func.count(Alerta.id))
.where(Alerta.vehiculo_id == vehiculo_id)
.where(Alerta.atendida == False)
)
alertas_activas = result.scalar() or 0
# Alertas mes
result = await db.execute(
select(func.count(Alerta.id))
.where(Alerta.vehiculo_id == vehiculo_id)
.where(Alerta.creado_en >= inicio_mes)
)
alertas_mes = result.scalar() or 0
return VehiculoEstadisticas(
vehiculo_id=vehiculo.id,
nombre=vehiculo.nombre,
placa=vehiculo.placa,
distancia_hoy_km=float(distancia_hoy),
distancia_semana_km=float(distancia_semana),
distancia_mes_km=float(distancia_mes),
distancia_total_km=vehiculo.distancia_recorrida,
tiempo_movimiento_hoy_min=0, # TODO: Calcular
tiempo_parado_hoy_min=0, # TODO: Calcular
alertas_activas=alertas_activas,
alertas_mes=alertas_mes,
mantenimientos_vencidos=0, # TODO: Calcular
)

View File

@@ -0,0 +1,339 @@
"""
Endpoints para gestión de viajes.
"""
from datetime import datetime
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.core.database import get_db
from app.core.security import get_current_user
from app.models.usuario import Usuario
from app.models.viaje import Viaje
from app.schemas.viaje import (
ViajeResponse,
ViajeResumen,
ViajeConParadas,
ViajeReplayData,
ParadaResponse,
)
from app.schemas.ubicacion import UbicacionResponse
from app.services.viaje_service import ViajeService
router = APIRouter(prefix="/viajes", tags=["Viajes"])
@router.get("", response_model=List[ViajeResumen])
async def listar_viajes(
vehiculo_id: Optional[int] = None,
conductor_id: Optional[int] = None,
estado: Optional[str] = None,
desde: Optional[datetime] = None,
hasta: Optional[datetime] = None,
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100),
db: AsyncSession = Depends(get_db),
current_user: Usuario = Depends(get_current_user),
):
"""
Lista viajes con filtros opcionales.
Args:
vehiculo_id: Filtrar por vehículo.
conductor_id: Filtrar por conductor.
estado: Filtrar por estado.
desde: Fecha inicio.
hasta: Fecha fin.
skip: Registros a saltar.
limit: Límite de registros.
Returns:
Lista de viajes.
"""
query = (
select(Viaje)
.options(
selectinload(Viaje.vehiculo),
selectinload(Viaje.conductor),
)
.order_by(Viaje.inicio_tiempo.desc())
)
if vehiculo_id:
query = query.where(Viaje.vehiculo_id == vehiculo_id)
if conductor_id:
query = query.where(Viaje.conductor_id == conductor_id)
if estado:
query = query.where(Viaje.estado == estado)
if desde:
query = query.where(Viaje.inicio_tiempo >= desde)
if hasta:
query = query.where(Viaje.inicio_tiempo <= hasta)
query = query.offset(skip).limit(limit)
result = await db.execute(query)
viajes = result.scalars().all()
return [
ViajeResumen(
id=v.id,
vehiculo_id=v.vehiculo_id,
vehiculo_nombre=v.vehiculo.nombre if v.vehiculo else None,
vehiculo_placa=v.vehiculo.placa if v.vehiculo else None,
conductor_nombre=v.conductor.nombre_completo if v.conductor else None,
inicio_tiempo=v.inicio_tiempo,
fin_tiempo=v.fin_tiempo,
inicio_direccion=v.inicio_direccion,
fin_direccion=v.fin_direccion,
distancia_km=v.distancia_km,
duracion_formateada=v.duracion_formateada,
estado=v.estado,
)
for v in viajes
]
@router.get("/{viaje_id}", response_model=ViajeConParadas)
async def obtener_viaje(
viaje_id: int,
db: AsyncSession = Depends(get_db),
current_user: Usuario = Depends(get_current_user),
):
"""
Obtiene un viaje por su ID con paradas.
Args:
viaje_id: ID del viaje.
Returns:
Viaje con paradas.
"""
result = await db.execute(
select(Viaje)
.options(
selectinload(Viaje.vehiculo),
selectinload(Viaje.conductor),
selectinload(Viaje.paradas),
)
.where(Viaje.id == viaje_id)
)
viaje = result.scalar_one_or_none()
if not viaje:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Viaje con id {viaje_id} no encontrado",
)
return ViajeConParadas(
id=viaje.id,
vehiculo_id=viaje.vehiculo_id,
conductor_id=viaje.conductor_id,
proposito=viaje.proposito,
notas=viaje.notas,
inicio_tiempo=viaje.inicio_tiempo,
fin_tiempo=viaje.fin_tiempo,
inicio_lat=viaje.inicio_lat,
inicio_lng=viaje.inicio_lng,
inicio_direccion=viaje.inicio_direccion,
fin_lat=viaje.fin_lat,
fin_lng=viaje.fin_lng,
fin_direccion=viaje.fin_direccion,
distancia_km=viaje.distancia_km,
duracion_segundos=viaje.duracion_segundos,
tiempo_movimiento_segundos=viaje.tiempo_movimiento_segundos,
tiempo_parado_segundos=viaje.tiempo_parado_segundos,
velocidad_promedio=viaje.velocidad_promedio,
velocidad_maxima=viaje.velocidad_maxima,
combustible_usado=viaje.combustible_usado,
rendimiento=viaje.rendimiento,
odometro_inicio=viaje.odometro_inicio,
odometro_fin=viaje.odometro_fin,
estado=viaje.estado,
puntos_gps=viaje.puntos_gps,
duracion_formateada=viaje.duracion_formateada,
en_curso=viaje.en_curso,
creado_en=viaje.creado_en,
actualizado_en=viaje.actualizado_en,
paradas=[
ParadaResponse(
id=p.id,
viaje_id=p.viaje_id,
vehiculo_id=p.vehiculo_id,
inicio_tiempo=p.inicio_tiempo,
fin_tiempo=p.fin_tiempo,
duracion_segundos=p.duracion_segundos,
lat=p.lat,
lng=p.lng,
direccion=p.direccion,
tipo=p.tipo,
motor_apagado=p.motor_apagado,
poi_id=p.poi_id,
geocerca_id=p.geocerca_id,
en_curso=p.en_curso,
notas=p.notas,
duracion_formateada=p.duracion_formateada,
)
for p in viaje.paradas
],
)
@router.get("/{viaje_id}/replay")
async def obtener_replay_viaje(
viaje_id: int,
db: AsyncSession = Depends(get_db),
current_user: Usuario = Depends(get_current_user),
):
"""
Obtiene datos para replay de un viaje.
Args:
viaje_id: ID del viaje.
Returns:
Viaje con ubicaciones y paradas.
"""
viaje_service = ViajeService(db)
datos = await viaje_service.obtener_replay_viaje(viaje_id)
if not datos:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Viaje con id {viaje_id} no encontrado",
)
viaje = datos["viaje"]
return {
"viaje": {
"id": viaje.id,
"vehiculo_id": viaje.vehiculo_id,
"inicio_tiempo": viaje.inicio_tiempo,
"fin_tiempo": viaje.fin_tiempo,
"inicio_lat": viaje.inicio_lat,
"inicio_lng": viaje.inicio_lng,
"fin_lat": viaje.fin_lat,
"fin_lng": viaje.fin_lng,
"distancia_km": viaje.distancia_km,
"duracion_formateada": viaje.duracion_formateada,
"estado": viaje.estado,
},
"ubicaciones": [
{
"tiempo": u.tiempo,
"lat": u.lat,
"lng": u.lng,
"velocidad": u.velocidad,
"rumbo": u.rumbo,
"motor_encendido": u.motor_encendido,
}
for u in datos["ubicaciones"]
],
"paradas": [
{
"id": p.id,
"inicio_tiempo": p.inicio_tiempo,
"fin_tiempo": p.fin_tiempo,
"duracion_formateada": p.duracion_formateada,
"lat": p.lat,
"lng": p.lng,
"direccion": p.direccion,
"tipo": p.tipo,
}
for p in datos["paradas"]
],
}
@router.get("/{viaje_id}/geojson")
async def obtener_viaje_geojson(
viaje_id: int,
db: AsyncSession = Depends(get_db),
current_user: Usuario = Depends(get_current_user),
):
"""
Obtiene la ruta de un viaje en formato GeoJSON.
Args:
viaje_id: ID del viaje.
Returns:
GeoJSON LineString de la ruta.
"""
viaje_service = ViajeService(db)
datos = await viaje_service.obtener_replay_viaje(viaje_id)
if not datos:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Viaje con id {viaje_id} no encontrado",
)
viaje = datos["viaje"]
ubicaciones = datos["ubicaciones"]
# Crear LineString
coordinates = [[u.lng, u.lat] for u in ubicaciones]
return {
"type": "Feature",
"geometry": {
"type": "LineString",
"coordinates": coordinates,
},
"properties": {
"viaje_id": viaje.id,
"vehiculo_id": viaje.vehiculo_id,
"inicio_tiempo": viaje.inicio_tiempo.isoformat(),
"fin_tiempo": viaje.fin_tiempo.isoformat() if viaje.fin_tiempo else None,
"distancia_km": viaje.distancia_km,
"estado": viaje.estado,
},
}
@router.get("/activos/lista", response_model=List[ViajeResumen])
async def listar_viajes_activos(
db: AsyncSession = Depends(get_db),
current_user: Usuario = Depends(get_current_user),
):
"""
Lista viajes actualmente en curso.
Returns:
Lista de viajes en curso.
"""
result = await db.execute(
select(Viaje)
.options(
selectinload(Viaje.vehiculo),
selectinload(Viaje.conductor),
)
.where(Viaje.estado == "en_curso")
.order_by(Viaje.inicio_tiempo.desc())
)
viajes = result.scalars().all()
return [
ViajeResumen(
id=v.id,
vehiculo_id=v.vehiculo_id,
vehiculo_nombre=v.vehiculo.nombre if v.vehiculo else None,
vehiculo_placa=v.vehiculo.placa if v.vehiculo else None,
conductor_nombre=v.conductor.nombre_completo if v.conductor else None,
inicio_tiempo=v.inicio_tiempo,
fin_tiempo=v.fin_tiempo,
inicio_direccion=v.inicio_direccion,
fin_direccion=v.fin_direccion,
distancia_km=v.distancia_km,
duracion_formateada=v.duracion_formateada,
estado=v.estado,
)
for v in viajes
]

View File

@@ -0,0 +1,14 @@
"""
Módulo WebSocket - Endpoints para comunicación en tiempo real.
"""
from app.api.websocket.manager import manager, ConnectionManager
from app.api.websocket.ubicaciones import router as ubicaciones_router
from app.api.websocket.alertas import router as alertas_router
__all__ = [
"manager",
"ConnectionManager",
"ubicaciones_router",
"alertas_router",
]

View File

@@ -0,0 +1,125 @@
"""
WebSocket endpoint para alertas en tiempo real.
"""
import json
from typing import Optional
from fastapi import APIRouter, Query, WebSocket, WebSocketDisconnect
from app.api.websocket.manager import manager
from app.core.security import decode_token
router = APIRouter()
async def get_user_from_token(token: Optional[str]) -> Optional[int]:
"""
Obtiene el ID de usuario desde un token JWT.
Args:
token: Token JWT.
Returns:
ID del usuario o None.
"""
if not token:
return None
try:
payload = decode_token(token)
return int(payload.get("sub"))
except Exception:
return None
@router.websocket("/ws/alertas")
async def websocket_alertas(
websocket: WebSocket,
token: Optional[str] = Query(None),
):
"""
WebSocket para recibir alertas en tiempo real.
Recibe todas las alertas generadas en el sistema.
Args:
websocket: Conexión WebSocket.
token: Token JWT para autenticación (opcional).
"""
user_id = await get_user_from_token(token)
await manager.connect(websocket, "alertas", user_id)
# Enviar confirmación
await websocket.send_json({
"type": "connected",
"channel": "alerts",
})
try:
while True:
data = await websocket.receive_text()
try:
message = json.loads(data)
if message.get("action") == "ping":
await websocket.send_json({"type": "pong"})
elif message.get("action") == "acknowledge":
# Cliente confirma recepción de alerta
alert_id = message.get("alert_id")
await websocket.send_json({
"type": "acknowledged",
"alert_id": alert_id,
})
except json.JSONDecodeError:
pass
except WebSocketDisconnect:
await manager.disconnect(websocket, "alertas", user_id)
@router.websocket("/ws/alertas/vehiculo/{vehiculo_id}")
async def websocket_alertas_vehiculo(
websocket: WebSocket,
vehiculo_id: int,
token: Optional[str] = Query(None),
):
"""
WebSocket para alertas de un vehículo específico.
Args:
websocket: Conexión WebSocket.
vehiculo_id: ID del vehículo.
token: Token JWT para autenticación (opcional).
"""
user_id = await get_user_from_token(token)
await manager.connect(websocket, "alertas", user_id)
await manager.subscribe_vehicle(websocket, vehiculo_id)
# Enviar confirmación
await websocket.send_json({
"type": "connected",
"channel": "vehicle_alerts",
"vehicle_id": vehiculo_id,
})
try:
while True:
data = await websocket.receive_text()
try:
message = json.loads(data)
if message.get("action") == "ping":
await websocket.send_json({"type": "pong"})
except json.JSONDecodeError:
pass
except WebSocketDisconnect:
await manager.unsubscribe_vehicle(websocket, vehiculo_id)
await manager.disconnect(websocket, "alertas", user_id)

View File

@@ -0,0 +1,266 @@
"""
Gestor de conexiones WebSocket.
Maneja las conexiones de clientes WebSocket para
actualizaciones en tiempo real.
"""
import asyncio
import json
from datetime import datetime, timezone
from typing import Dict, List, Optional, Set
from fastapi import WebSocket
class ConnectionManager:
"""
Gestor de conexiones WebSocket.
Mantiene un registro de conexiones activas y permite
enviar mensajes a clientes específicos o a todos.
"""
def __init__(self):
"""Inicializa el gestor de conexiones."""
# Conexiones activas por tipo de suscripción
self._connections: Dict[str, Set[WebSocket]] = {
"ubicaciones": set(),
"alertas": set(),
"vehiculos": set(),
}
# Conexiones por usuario
self._user_connections: Dict[int, Set[WebSocket]] = {}
# Suscripciones a vehículos específicos
self._vehicle_subscriptions: Dict[int, Set[WebSocket]] = {}
# Lock para operaciones thread-safe
self._lock = asyncio.Lock()
async def connect(
self,
websocket: WebSocket,
channel: str = "ubicaciones",
user_id: Optional[int] = None,
) -> None:
"""
Acepta una nueva conexión WebSocket.
Args:
websocket: Conexión WebSocket.
channel: Canal de suscripción.
user_id: ID del usuario (opcional).
"""
await websocket.accept()
async with self._lock:
# Agregar a conexiones del canal
if channel in self._connections:
self._connections[channel].add(websocket)
# Agregar a conexiones del usuario
if user_id:
if user_id not in self._user_connections:
self._user_connections[user_id] = set()
self._user_connections[user_id].add(websocket)
async def disconnect(
self,
websocket: WebSocket,
channel: str = "ubicaciones",
user_id: Optional[int] = None,
) -> None:
"""
Desconecta un WebSocket.
Args:
websocket: Conexión WebSocket.
channel: Canal de suscripción.
user_id: ID del usuario (opcional).
"""
async with self._lock:
# Remover de conexiones del canal
if channel in self._connections:
self._connections[channel].discard(websocket)
# Remover de conexiones del usuario
if user_id and user_id in self._user_connections:
self._user_connections[user_id].discard(websocket)
if not self._user_connections[user_id]:
del self._user_connections[user_id]
# Remover de suscripciones de vehículos
for vehicle_id in list(self._vehicle_subscriptions.keys()):
self._vehicle_subscriptions[vehicle_id].discard(websocket)
if not self._vehicle_subscriptions[vehicle_id]:
del self._vehicle_subscriptions[vehicle_id]
async def subscribe_vehicle(
self,
websocket: WebSocket,
vehicle_id: int,
) -> None:
"""
Suscribe un WebSocket a actualizaciones de un vehículo específico.
Args:
websocket: Conexión WebSocket.
vehicle_id: ID del vehículo.
"""
async with self._lock:
if vehicle_id not in self._vehicle_subscriptions:
self._vehicle_subscriptions[vehicle_id] = set()
self._vehicle_subscriptions[vehicle_id].add(websocket)
async def unsubscribe_vehicle(
self,
websocket: WebSocket,
vehicle_id: int,
) -> None:
"""
Desuscribe un WebSocket de un vehículo.
Args:
websocket: Conexión WebSocket.
vehicle_id: ID del vehículo.
"""
async with self._lock:
if vehicle_id in self._vehicle_subscriptions:
self._vehicle_subscriptions[vehicle_id].discard(websocket)
async def broadcast(
self,
message: dict,
channel: str = "ubicaciones",
) -> None:
"""
Envía un mensaje a todos los clientes de un canal.
Args:
message: Mensaje a enviar.
channel: Canal de destino.
"""
if channel not in self._connections:
return
message_json = json.dumps(message, default=str)
disconnected = []
for websocket in self._connections[channel]:
try:
await websocket.send_text(message_json)
except Exception:
disconnected.append(websocket)
# Limpiar conexiones desconectadas
for ws in disconnected:
await self.disconnect(ws, channel)
async def broadcast_vehicle_update(
self,
vehicle_id: int,
data: dict,
) -> None:
"""
Envía actualización a suscriptores de un vehículo específico.
Args:
vehicle_id: ID del vehículo.
data: Datos a enviar.
"""
message = {
"type": "vehicle_update",
"vehicle_id": vehicle_id,
"timestamp": datetime.now(timezone.utc).isoformat(),
"data": data,
}
message_json = json.dumps(message, default=str)
# Enviar a suscriptores del vehículo
if vehicle_id in self._vehicle_subscriptions:
disconnected = []
for websocket in self._vehicle_subscriptions[vehicle_id]:
try:
await websocket.send_text(message_json)
except Exception:
disconnected.append(websocket)
for ws in disconnected:
await self.unsubscribe_vehicle(ws, vehicle_id)
# También enviar al canal general de ubicaciones
await self.broadcast(message, "ubicaciones")
async def send_to_user(
self,
user_id: int,
message: dict,
) -> None:
"""
Envía un mensaje a todas las conexiones de un usuario.
Args:
user_id: ID del usuario.
message: Mensaje a enviar.
"""
if user_id not in self._user_connections:
return
message_json = json.dumps(message, default=str)
disconnected = []
for websocket in self._user_connections[user_id]:
try:
await websocket.send_text(message_json)
except Exception:
disconnected.append(websocket)
# Limpiar conexiones desconectadas
for ws in disconnected:
await self.disconnect(ws, user_id=user_id)
async def send_alert(
self,
alert_data: dict,
) -> None:
"""
Envía una alerta a todos los clientes suscritos.
Args:
alert_data: Datos de la alerta.
"""
message = {
"type": "alert",
"timestamp": datetime.now(timezone.utc).isoformat(),
"data": alert_data,
}
await self.broadcast(message, "alertas")
def get_connection_count(self) -> dict:
"""
Obtiene el conteo de conexiones activas.
Returns:
Dict con conteo por canal.
"""
return {
channel: len(connections)
for channel, connections in self._connections.items()
}
def get_vehicle_subscribers(self, vehicle_id: int) -> int:
"""
Obtiene el número de suscriptores de un vehículo.
Args:
vehicle_id: ID del vehículo.
Returns:
Número de suscriptores.
"""
if vehicle_id in self._vehicle_subscriptions:
return len(self._vehicle_subscriptions[vehicle_id])
return 0
# Instancia global del gestor de conexiones
manager = ConnectionManager()

View File

@@ -0,0 +1,187 @@
"""
WebSocket endpoint para ubicaciones en tiempo real.
"""
import json
from typing import Optional
from fastapi import APIRouter, Depends, Query, WebSocket, WebSocketDisconnect
from jose import JWTError
from app.api.websocket.manager import manager
from app.core.security import decode_token
router = APIRouter()
async def get_user_from_token(token: Optional[str]) -> Optional[int]:
"""
Obtiene el ID de usuario desde un token JWT.
Args:
token: Token JWT.
Returns:
ID del usuario o None.
"""
if not token:
return None
try:
payload = decode_token(token)
return int(payload.get("sub"))
except (JWTError, ValueError):
return None
@router.websocket("/ws/ubicaciones")
async def websocket_ubicaciones(
websocket: WebSocket,
token: Optional[str] = Query(None),
):
"""
WebSocket para recibir actualizaciones de ubicaciones.
Permite suscribirse a:
- Todas las ubicaciones de la flota
- Vehículos específicos
Args:
websocket: Conexión WebSocket.
token: Token JWT para autenticación (opcional).
"""
user_id = await get_user_from_token(token)
await manager.connect(websocket, "ubicaciones", user_id)
try:
while True:
# Recibir mensajes del cliente
data = await websocket.receive_text()
try:
message = json.loads(data)
action = message.get("action")
if action == "subscribe_vehicle":
# Suscribirse a un vehículo específico
vehicle_id = message.get("vehicle_id")
if vehicle_id:
await manager.subscribe_vehicle(websocket, vehicle_id)
await websocket.send_json({
"type": "subscribed",
"vehicle_id": vehicle_id,
})
elif action == "unsubscribe_vehicle":
# Desuscribirse de un vehículo
vehicle_id = message.get("vehicle_id")
if vehicle_id:
await manager.unsubscribe_vehicle(websocket, vehicle_id)
await websocket.send_json({
"type": "unsubscribed",
"vehicle_id": vehicle_id,
})
elif action == "ping":
# Responder ping para keepalive
await websocket.send_json({"type": "pong"})
except json.JSONDecodeError:
await websocket.send_json({
"type": "error",
"message": "Invalid JSON",
})
except WebSocketDisconnect:
await manager.disconnect(websocket, "ubicaciones", user_id)
@router.websocket("/ws/vehiculo/{vehiculo_id}")
async def websocket_vehiculo(
websocket: WebSocket,
vehiculo_id: int,
token: Optional[str] = Query(None),
):
"""
WebSocket para seguir un vehículo específico.
Args:
websocket: Conexión WebSocket.
vehiculo_id: ID del vehículo a seguir.
token: Token JWT para autenticación (opcional).
"""
user_id = await get_user_from_token(token)
await manager.connect(websocket, "ubicaciones", user_id)
await manager.subscribe_vehicle(websocket, vehiculo_id)
# Enviar confirmación de suscripción
await websocket.send_json({
"type": "connected",
"vehicle_id": vehiculo_id,
})
try:
while True:
data = await websocket.receive_text()
try:
message = json.loads(data)
if message.get("action") == "ping":
await websocket.send_json({"type": "pong"})
except json.JSONDecodeError:
pass
except WebSocketDisconnect:
await manager.unsubscribe_vehicle(websocket, vehiculo_id)
await manager.disconnect(websocket, "ubicaciones", user_id)
@router.websocket("/ws/flota")
async def websocket_flota(
websocket: WebSocket,
token: Optional[str] = Query(None),
):
"""
WebSocket para monitoreo de toda la flota.
Recibe actualizaciones de todos los vehículos activos.
Args:
websocket: Conexión WebSocket.
token: Token JWT para autenticación (opcional).
"""
user_id = await get_user_from_token(token)
await manager.connect(websocket, "ubicaciones", user_id)
# Enviar confirmación
await websocket.send_json({
"type": "connected",
"channel": "fleet",
})
try:
while True:
data = await websocket.receive_text()
try:
message = json.loads(data)
if message.get("action") == "ping":
await websocket.send_json({"type": "pong"})
elif message.get("action") == "request_status":
# Enviar estado actual de conexiones
await websocket.send_json({
"type": "status",
"connections": manager.get_connection_count(),
})
except json.JSONDecodeError:
pass
except WebSocketDisconnect:
await manager.disconnect(websocket, "ubicaciones", user_id)