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:
7
backend/app/api/v1/__init__.py
Normal file
7
backend/app/api/v1/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
||||
"""
|
||||
API v1 - Endpoints REST.
|
||||
"""
|
||||
|
||||
from app.api.v1.router import api_router
|
||||
|
||||
__all__ = ["api_router"]
|
||||
402
backend/app/api/v1/alertas.py
Normal file
402
backend/app/api/v1/alertas.py
Normal 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
274
backend/app/api/v1/auth.py
Normal 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"}
|
||||
411
backend/app/api/v1/conductores.py
Normal file
411
backend/app/api/v1/conductores.py
Normal 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
|
||||
]
|
||||
362
backend/app/api/v1/dispositivos.py
Normal file
362
backend/app/api/v1/dispositivos.py
Normal 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,
|
||||
)
|
||||
502
backend/app/api/v1/geocercas.py
Normal file
502
backend/app/api/v1/geocercas.py
Normal 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,
|
||||
)
|
||||
227
backend/app/api/v1/reportes.py
Normal file
227
backend/app/api/v1/reportes.py
Normal 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
|
||||
48
backend/app/api/v1/router.py
Normal file
48
backend/app/api/v1/router.py
Normal 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)
|
||||
237
backend/app/api/v1/ubicaciones.py
Normal file
237
backend/app/api/v1/ubicaciones.py
Normal 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()
|
||||
481
backend/app/api/v1/vehiculos.py
Normal file
481
backend/app/api/v1/vehiculos.py
Normal 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
|
||||
)
|
||||
339
backend/app/api/v1/viajes.py
Normal file
339
backend/app/api/v1/viajes.py
Normal 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
|
||||
]
|
||||
Reference in New Issue
Block a user