Files
ATLAS/backend/app/api/v1/vehiculos.py
ATLAS Admin e59aa2a742 feat: Complete ATLAS system installation and API fixes
## Backend Changes
- Add new API endpoints: combustible, pois, mantenimiento, video, configuracion
- Fix vehiculos endpoint to return paginated response with items array
- Add /vehiculos/all endpoint for non-paginated list
- Add /geocercas/all endpoint
- Add /alertas/configuracion GET/PUT endpoints
- Add /viajes/activos and /viajes/iniciar endpoints
- Add /reportes/stats, /reportes/templates, /reportes/preview endpoints
- Add /conductores/all and /conductores/disponibles endpoints
- Update router.py to include all new modules

## Frontend Changes
- Fix authentication token handling (snake_case vs camelCase)
- Update vehiculosApi.listAll to use /vehiculos/all
- Fix FuelGauge component usage in Combustible page
- Fix chart component exports (named + default exports)
- Update API client for proper token refresh

## Infrastructure
- Rename services from ADAN to ATLAS
- Configure Cloudflare tunnel for atlas.consultoria-as.com
- Update systemd service files
- Configure PostgreSQL with TimescaleDB
- Configure Redis, Mosquitto, Traccar, MediaMTX

## Documentation
- Update installation guides
- Update API reference
- Rename all ADAN references to ATLAS

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 03:04:23 +00:00

577 lines
16 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("")
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),
page: int = Query(None, ge=1),
pageSize: int = Query(None, 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 paginada.
"""
# Handle pagination params
actual_limit = pageSize or limit
actual_skip = ((page - 1) * actual_limit) if page else skip
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}%"))
)
# Get total count
count_query = select(func.count()).select_from(query.subquery())
total_result = await db.execute(count_query)
total = total_result.scalar() or 0
query = query.offset(actual_skip).limit(actual_limit).order_by(Vehiculo.nombre)
result = await db.execute(query)
vehiculos = result.scalars().all()
return {
"items": [VehiculoResumen.model_validate(v) for v in vehiculos],
"total": total,
"page": (actual_skip // actual_limit) + 1,
"pageSize": actual_limit,
}
@router.get("/all")
async def listar_todos_vehiculos(
db: AsyncSession = Depends(get_db),
current_user: Usuario = Depends(get_current_user),
):
"""
Lista todos los vehículos activos (sin paginación).
Para uso en mapas, selectores, etc.
"""
result = await db.execute(
select(Vehiculo)
.where(Vehiculo.activo == True)
.order_by(Vehiculo.nombre)
)
vehiculos = result.scalars().all()
return [VehiculoResumen.model_validate(v) for v in vehiculos]
@router.get("/ubicaciones/actuales", response_model=List[VehiculoUbicacionActual])
async def obtener_ubicaciones_actuales(
db: AsyncSession = Depends(get_db),
current_user: Usuario = Depends(get_current_user),
):
"""
Obtiene las ubicaciones actuales de todos los vehículos.
Alias para /ubicaciones.
"""
ubicacion_service = UbicacionService(db)
return await ubicacion_service.obtener_ubicaciones_flota()
@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("/fleet/stats")
async def obtener_estadisticas_flota(
db: AsyncSession = Depends(get_db),
current_user: Usuario = Depends(get_current_user),
):
"""
Obtiene estadísticas generales de la flota.
Returns:
Estadísticas de la flota.
"""
# Total de vehículos
result = await db.execute(select(func.count(Vehiculo.id)))
total = result.scalar() or 0
# Activos
result = await db.execute(
select(func.count(Vehiculo.id)).where(Vehiculo.activo == True)
)
activos = result.scalar() or 0
# Inactivos
inactivos = total - activos
# En servicio
result = await db.execute(
select(func.count(Vehiculo.id)).where(Vehiculo.en_servicio == True)
)
en_servicio = result.scalar() or 0
# Alertas activas
result = await db.execute(
select(func.count(Alerta.id)).where(Alerta.atendida == False)
)
alertas_activas = result.scalar() or 0
return {
"total": total,
"activos": activos,
"inactivos": inactivos,
"mantenimiento": 0,
"enMovimiento": 0,
"detenidos": en_servicio,
"sinSenal": 0,
"alertasActivas": alertas_activas,
}
@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
)