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:
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
|
||||
)
|
||||
Reference in New Issue
Block a user