Files
ATLAS/backend/app/api/v1/vehiculos.py
FlotillasGPS Developer 51d78bacf4 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.
2026-01-21 08:18:00 +00:00

482 lines
14 KiB
Python

"""
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
)