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.
482 lines
14 KiB
Python
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
|
|
)
|