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:
FlotillasGPS Developer
2026-01-21 08:18:00 +00:00
commit 51d78bacf4
248 changed files with 50171 additions and 0 deletions

7
backend/app/__init__.py Normal file
View File

@@ -0,0 +1,7 @@
"""
Adan Fleet Monitor Backend.
Sistema de monitoreo de flotillas GPS.
"""
__version__ = "1.0.0"

View File

@@ -0,0 +1,7 @@
"""
API v1 - Endpoints REST.
"""
from app.api.v1.router import api_router
__all__ = ["api_router"]

View 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
View 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"}

View 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
]

View 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,
)

View 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,
)

View 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

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

View 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()

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

View 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
]

View File

@@ -0,0 +1,14 @@
"""
Módulo WebSocket - Endpoints para comunicación en tiempo real.
"""
from app.api.websocket.manager import manager, ConnectionManager
from app.api.websocket.ubicaciones import router as ubicaciones_router
from app.api.websocket.alertas import router as alertas_router
__all__ = [
"manager",
"ConnectionManager",
"ubicaciones_router",
"alertas_router",
]

View File

@@ -0,0 +1,125 @@
"""
WebSocket endpoint para alertas en tiempo real.
"""
import json
from typing import Optional
from fastapi import APIRouter, Query, WebSocket, WebSocketDisconnect
from app.api.websocket.manager import manager
from app.core.security import decode_token
router = APIRouter()
async def get_user_from_token(token: Optional[str]) -> Optional[int]:
"""
Obtiene el ID de usuario desde un token JWT.
Args:
token: Token JWT.
Returns:
ID del usuario o None.
"""
if not token:
return None
try:
payload = decode_token(token)
return int(payload.get("sub"))
except Exception:
return None
@router.websocket("/ws/alertas")
async def websocket_alertas(
websocket: WebSocket,
token: Optional[str] = Query(None),
):
"""
WebSocket para recibir alertas en tiempo real.
Recibe todas las alertas generadas en el sistema.
Args:
websocket: Conexión WebSocket.
token: Token JWT para autenticación (opcional).
"""
user_id = await get_user_from_token(token)
await manager.connect(websocket, "alertas", user_id)
# Enviar confirmación
await websocket.send_json({
"type": "connected",
"channel": "alerts",
})
try:
while True:
data = await websocket.receive_text()
try:
message = json.loads(data)
if message.get("action") == "ping":
await websocket.send_json({"type": "pong"})
elif message.get("action") == "acknowledge":
# Cliente confirma recepción de alerta
alert_id = message.get("alert_id")
await websocket.send_json({
"type": "acknowledged",
"alert_id": alert_id,
})
except json.JSONDecodeError:
pass
except WebSocketDisconnect:
await manager.disconnect(websocket, "alertas", user_id)
@router.websocket("/ws/alertas/vehiculo/{vehiculo_id}")
async def websocket_alertas_vehiculo(
websocket: WebSocket,
vehiculo_id: int,
token: Optional[str] = Query(None),
):
"""
WebSocket para alertas de un vehículo específico.
Args:
websocket: Conexión WebSocket.
vehiculo_id: ID del vehículo.
token: Token JWT para autenticación (opcional).
"""
user_id = await get_user_from_token(token)
await manager.connect(websocket, "alertas", user_id)
await manager.subscribe_vehicle(websocket, vehiculo_id)
# Enviar confirmación
await websocket.send_json({
"type": "connected",
"channel": "vehicle_alerts",
"vehicle_id": vehiculo_id,
})
try:
while True:
data = await websocket.receive_text()
try:
message = json.loads(data)
if message.get("action") == "ping":
await websocket.send_json({"type": "pong"})
except json.JSONDecodeError:
pass
except WebSocketDisconnect:
await manager.unsubscribe_vehicle(websocket, vehiculo_id)
await manager.disconnect(websocket, "alertas", user_id)

View File

@@ -0,0 +1,266 @@
"""
Gestor de conexiones WebSocket.
Maneja las conexiones de clientes WebSocket para
actualizaciones en tiempo real.
"""
import asyncio
import json
from datetime import datetime, timezone
from typing import Dict, List, Optional, Set
from fastapi import WebSocket
class ConnectionManager:
"""
Gestor de conexiones WebSocket.
Mantiene un registro de conexiones activas y permite
enviar mensajes a clientes específicos o a todos.
"""
def __init__(self):
"""Inicializa el gestor de conexiones."""
# Conexiones activas por tipo de suscripción
self._connections: Dict[str, Set[WebSocket]] = {
"ubicaciones": set(),
"alertas": set(),
"vehiculos": set(),
}
# Conexiones por usuario
self._user_connections: Dict[int, Set[WebSocket]] = {}
# Suscripciones a vehículos específicos
self._vehicle_subscriptions: Dict[int, Set[WebSocket]] = {}
# Lock para operaciones thread-safe
self._lock = asyncio.Lock()
async def connect(
self,
websocket: WebSocket,
channel: str = "ubicaciones",
user_id: Optional[int] = None,
) -> None:
"""
Acepta una nueva conexión WebSocket.
Args:
websocket: Conexión WebSocket.
channel: Canal de suscripción.
user_id: ID del usuario (opcional).
"""
await websocket.accept()
async with self._lock:
# Agregar a conexiones del canal
if channel in self._connections:
self._connections[channel].add(websocket)
# Agregar a conexiones del usuario
if user_id:
if user_id not in self._user_connections:
self._user_connections[user_id] = set()
self._user_connections[user_id].add(websocket)
async def disconnect(
self,
websocket: WebSocket,
channel: str = "ubicaciones",
user_id: Optional[int] = None,
) -> None:
"""
Desconecta un WebSocket.
Args:
websocket: Conexión WebSocket.
channel: Canal de suscripción.
user_id: ID del usuario (opcional).
"""
async with self._lock:
# Remover de conexiones del canal
if channel in self._connections:
self._connections[channel].discard(websocket)
# Remover de conexiones del usuario
if user_id and user_id in self._user_connections:
self._user_connections[user_id].discard(websocket)
if not self._user_connections[user_id]:
del self._user_connections[user_id]
# Remover de suscripciones de vehículos
for vehicle_id in list(self._vehicle_subscriptions.keys()):
self._vehicle_subscriptions[vehicle_id].discard(websocket)
if not self._vehicle_subscriptions[vehicle_id]:
del self._vehicle_subscriptions[vehicle_id]
async def subscribe_vehicle(
self,
websocket: WebSocket,
vehicle_id: int,
) -> None:
"""
Suscribe un WebSocket a actualizaciones de un vehículo específico.
Args:
websocket: Conexión WebSocket.
vehicle_id: ID del vehículo.
"""
async with self._lock:
if vehicle_id not in self._vehicle_subscriptions:
self._vehicle_subscriptions[vehicle_id] = set()
self._vehicle_subscriptions[vehicle_id].add(websocket)
async def unsubscribe_vehicle(
self,
websocket: WebSocket,
vehicle_id: int,
) -> None:
"""
Desuscribe un WebSocket de un vehículo.
Args:
websocket: Conexión WebSocket.
vehicle_id: ID del vehículo.
"""
async with self._lock:
if vehicle_id in self._vehicle_subscriptions:
self._vehicle_subscriptions[vehicle_id].discard(websocket)
async def broadcast(
self,
message: dict,
channel: str = "ubicaciones",
) -> None:
"""
Envía un mensaje a todos los clientes de un canal.
Args:
message: Mensaje a enviar.
channel: Canal de destino.
"""
if channel not in self._connections:
return
message_json = json.dumps(message, default=str)
disconnected = []
for websocket in self._connections[channel]:
try:
await websocket.send_text(message_json)
except Exception:
disconnected.append(websocket)
# Limpiar conexiones desconectadas
for ws in disconnected:
await self.disconnect(ws, channel)
async def broadcast_vehicle_update(
self,
vehicle_id: int,
data: dict,
) -> None:
"""
Envía actualización a suscriptores de un vehículo específico.
Args:
vehicle_id: ID del vehículo.
data: Datos a enviar.
"""
message = {
"type": "vehicle_update",
"vehicle_id": vehicle_id,
"timestamp": datetime.now(timezone.utc).isoformat(),
"data": data,
}
message_json = json.dumps(message, default=str)
# Enviar a suscriptores del vehículo
if vehicle_id in self._vehicle_subscriptions:
disconnected = []
for websocket in self._vehicle_subscriptions[vehicle_id]:
try:
await websocket.send_text(message_json)
except Exception:
disconnected.append(websocket)
for ws in disconnected:
await self.unsubscribe_vehicle(ws, vehicle_id)
# También enviar al canal general de ubicaciones
await self.broadcast(message, "ubicaciones")
async def send_to_user(
self,
user_id: int,
message: dict,
) -> None:
"""
Envía un mensaje a todas las conexiones de un usuario.
Args:
user_id: ID del usuario.
message: Mensaje a enviar.
"""
if user_id not in self._user_connections:
return
message_json = json.dumps(message, default=str)
disconnected = []
for websocket in self._user_connections[user_id]:
try:
await websocket.send_text(message_json)
except Exception:
disconnected.append(websocket)
# Limpiar conexiones desconectadas
for ws in disconnected:
await self.disconnect(ws, user_id=user_id)
async def send_alert(
self,
alert_data: dict,
) -> None:
"""
Envía una alerta a todos los clientes suscritos.
Args:
alert_data: Datos de la alerta.
"""
message = {
"type": "alert",
"timestamp": datetime.now(timezone.utc).isoformat(),
"data": alert_data,
}
await self.broadcast(message, "alertas")
def get_connection_count(self) -> dict:
"""
Obtiene el conteo de conexiones activas.
Returns:
Dict con conteo por canal.
"""
return {
channel: len(connections)
for channel, connections in self._connections.items()
}
def get_vehicle_subscribers(self, vehicle_id: int) -> int:
"""
Obtiene el número de suscriptores de un vehículo.
Args:
vehicle_id: ID del vehículo.
Returns:
Número de suscriptores.
"""
if vehicle_id in self._vehicle_subscriptions:
return len(self._vehicle_subscriptions[vehicle_id])
return 0
# Instancia global del gestor de conexiones
manager = ConnectionManager()

View File

@@ -0,0 +1,187 @@
"""
WebSocket endpoint para ubicaciones en tiempo real.
"""
import json
from typing import Optional
from fastapi import APIRouter, Depends, Query, WebSocket, WebSocketDisconnect
from jose import JWTError
from app.api.websocket.manager import manager
from app.core.security import decode_token
router = APIRouter()
async def get_user_from_token(token: Optional[str]) -> Optional[int]:
"""
Obtiene el ID de usuario desde un token JWT.
Args:
token: Token JWT.
Returns:
ID del usuario o None.
"""
if not token:
return None
try:
payload = decode_token(token)
return int(payload.get("sub"))
except (JWTError, ValueError):
return None
@router.websocket("/ws/ubicaciones")
async def websocket_ubicaciones(
websocket: WebSocket,
token: Optional[str] = Query(None),
):
"""
WebSocket para recibir actualizaciones de ubicaciones.
Permite suscribirse a:
- Todas las ubicaciones de la flota
- Vehículos específicos
Args:
websocket: Conexión WebSocket.
token: Token JWT para autenticación (opcional).
"""
user_id = await get_user_from_token(token)
await manager.connect(websocket, "ubicaciones", user_id)
try:
while True:
# Recibir mensajes del cliente
data = await websocket.receive_text()
try:
message = json.loads(data)
action = message.get("action")
if action == "subscribe_vehicle":
# Suscribirse a un vehículo específico
vehicle_id = message.get("vehicle_id")
if vehicle_id:
await manager.subscribe_vehicle(websocket, vehicle_id)
await websocket.send_json({
"type": "subscribed",
"vehicle_id": vehicle_id,
})
elif action == "unsubscribe_vehicle":
# Desuscribirse de un vehículo
vehicle_id = message.get("vehicle_id")
if vehicle_id:
await manager.unsubscribe_vehicle(websocket, vehicle_id)
await websocket.send_json({
"type": "unsubscribed",
"vehicle_id": vehicle_id,
})
elif action == "ping":
# Responder ping para keepalive
await websocket.send_json({"type": "pong"})
except json.JSONDecodeError:
await websocket.send_json({
"type": "error",
"message": "Invalid JSON",
})
except WebSocketDisconnect:
await manager.disconnect(websocket, "ubicaciones", user_id)
@router.websocket("/ws/vehiculo/{vehiculo_id}")
async def websocket_vehiculo(
websocket: WebSocket,
vehiculo_id: int,
token: Optional[str] = Query(None),
):
"""
WebSocket para seguir un vehículo específico.
Args:
websocket: Conexión WebSocket.
vehiculo_id: ID del vehículo a seguir.
token: Token JWT para autenticación (opcional).
"""
user_id = await get_user_from_token(token)
await manager.connect(websocket, "ubicaciones", user_id)
await manager.subscribe_vehicle(websocket, vehiculo_id)
# Enviar confirmación de suscripción
await websocket.send_json({
"type": "connected",
"vehicle_id": vehiculo_id,
})
try:
while True:
data = await websocket.receive_text()
try:
message = json.loads(data)
if message.get("action") == "ping":
await websocket.send_json({"type": "pong"})
except json.JSONDecodeError:
pass
except WebSocketDisconnect:
await manager.unsubscribe_vehicle(websocket, vehiculo_id)
await manager.disconnect(websocket, "ubicaciones", user_id)
@router.websocket("/ws/flota")
async def websocket_flota(
websocket: WebSocket,
token: Optional[str] = Query(None),
):
"""
WebSocket para monitoreo de toda la flota.
Recibe actualizaciones de todos los vehículos activos.
Args:
websocket: Conexión WebSocket.
token: Token JWT para autenticación (opcional).
"""
user_id = await get_user_from_token(token)
await manager.connect(websocket, "ubicaciones", user_id)
# Enviar confirmación
await websocket.send_json({
"type": "connected",
"channel": "fleet",
})
try:
while True:
data = await websocket.receive_text()
try:
message = json.loads(data)
if message.get("action") == "ping":
await websocket.send_json({"type": "pong"})
elif message.get("action") == "request_status":
# Enviar estado actual de conexiones
await websocket.send_json({
"type": "status",
"connections": manager.get_connection_count(),
})
except json.JSONDecodeError:
pass
except WebSocketDisconnect:
await manager.disconnect(websocket, "ubicaciones", user_id)

View File

@@ -0,0 +1,56 @@
"""
Módulo core - Configuración, seguridad y utilidades base.
"""
from app.core.config import settings
from app.core.database import Base, get_db, engine, async_session_factory
from app.core.security import (
hash_password,
verify_password,
create_access_token,
create_refresh_token,
decode_token,
get_current_user,
get_current_active_admin,
CurrentUser,
CurrentAdmin,
)
from app.core.exceptions import (
AdanException,
NotFoundError,
AlreadyExistsError,
ValidationError,
AuthenticationError,
AuthorizationError,
ExternalServiceError,
register_exception_handlers,
)
__all__ = [
# Config
"settings",
# Database
"Base",
"get_db",
"engine",
"async_session_factory",
# Security
"hash_password",
"verify_password",
"create_access_token",
"create_refresh_token",
"decode_token",
"get_current_user",
"get_current_active_admin",
"CurrentUser",
"CurrentAdmin",
# Exceptions
"AdanException",
"NotFoundError",
"AlreadyExistsError",
"ValidationError",
"AuthenticationError",
"AuthorizationError",
"ExternalServiceError",
"register_exception_handlers",
]

177
backend/app/core/config.py Normal file
View File

@@ -0,0 +1,177 @@
"""
Configuración central de la aplicación.
Utiliza Pydantic BaseSettings para cargar variables de entorno
con validación de tipos y valores por defecto.
"""
from functools import lru_cache
from typing import Any, List, Optional
from pydantic import PostgresDsn, field_validator, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
"""Configuración de la aplicación cargada desde variables de entorno."""
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
case_sensitive=False,
extra="ignore",
)
# Aplicación
APP_NAME: str = "Adan Fleet Monitor"
APP_VERSION: str = "1.0.0"
DEBUG: bool = False
ENVIRONMENT: str = "development"
API_V1_PREFIX: str = "/api/v1"
# Servidor
HOST: str = "0.0.0.0"
PORT: int = 8000
WORKERS: int = 4
# Base de datos PostgreSQL/TimescaleDB
POSTGRES_HOST: str = "localhost"
POSTGRES_PORT: int = 5432
POSTGRES_USER: str = "adan"
POSTGRES_PASSWORD: str = "adan_secret"
POSTGRES_DB: str = "adan_fleet"
DATABASE_URL: Optional[str] = None
DATABASE_POOL_SIZE: int = 20
DATABASE_MAX_OVERFLOW: int = 10
@model_validator(mode="after")
def build_database_url(self) -> "Settings":
"""Construye la URL de conexión a la base de datos."""
if not self.DATABASE_URL:
self.DATABASE_URL = (
f"postgresql+asyncpg://{self.POSTGRES_USER}:{self.POSTGRES_PASSWORD}"
f"@{self.POSTGRES_HOST}:{self.POSTGRES_PORT}/{self.POSTGRES_DB}"
)
return self
# Redis
REDIS_HOST: str = "localhost"
REDIS_PORT: int = 6379
REDIS_DB: int = 0
REDIS_PASSWORD: Optional[str] = None
REDIS_URL: Optional[str] = None
@model_validator(mode="after")
def build_redis_url(self) -> "Settings":
"""Construye la URL de conexión a Redis."""
if not self.REDIS_URL:
password_part = f":{self.REDIS_PASSWORD}@" if self.REDIS_PASSWORD else ""
self.REDIS_URL = f"redis://{password_part}{self.REDIS_HOST}:{self.REDIS_PORT}/{self.REDIS_DB}"
return self
# Seguridad JWT
SECRET_KEY: str = "your-super-secret-key-change-in-production"
ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
REFRESH_TOKEN_EXPIRE_DAYS: int = 7
# CORS
CORS_ORIGINS: List[str] = ["http://localhost:3000", "http://localhost:5173"]
CORS_ALLOW_CREDENTIALS: bool = True
CORS_ALLOW_METHODS: List[str] = ["*"]
CORS_ALLOW_HEADERS: List[str] = ["*"]
@field_validator("CORS_ORIGINS", mode="before")
@classmethod
def parse_cors_origins(cls, v: Any) -> List[str]:
"""Parsea los orígenes CORS desde string separado por comas."""
if isinstance(v, str):
return [origin.strip() for origin in v.split(",")]
return v
# Traccar Integration
TRACCAR_HOST: str = "localhost"
TRACCAR_PORT: int = 5055
TRACCAR_API_URL: str = "http://localhost:8082/api"
TRACCAR_USERNAME: Optional[str] = None
TRACCAR_PASSWORD: Optional[str] = None
# MediaMTX Video Server
MEDIAMTX_HOST: str = "localhost"
MEDIAMTX_API_PORT: int = 9997
MEDIAMTX_RTSP_PORT: int = 8554
MEDIAMTX_WEBRTC_PORT: int = 8889
# Meshtastic
MESHTASTIC_ENABLED: bool = False
MESHTASTIC_SERIAL_PORT: Optional[str] = None
MESHTASTIC_TCP_HOST: Optional[str] = None
MESHTASTIC_TCP_PORT: int = 4403
# MQTT (para dispositivos IoT)
MQTT_ENABLED: bool = False
MQTT_HOST: str = "localhost"
MQTT_PORT: int = 1883
MQTT_USERNAME: Optional[str] = None
MQTT_PASSWORD: Optional[str] = None
MQTT_TOPIC_LOCATIONS: str = "adan/locations/#"
MQTT_TOPIC_ALERTS: str = "adan/alerts/#"
# Email (notificaciones)
SMTP_HOST: str = "localhost"
SMTP_PORT: int = 587
SMTP_USER: Optional[str] = None
SMTP_PASSWORD: Optional[str] = None
SMTP_FROM_EMAIL: str = "noreply@adan-fleet.com"
SMTP_FROM_NAME: str = "Adan Fleet Monitor"
SMTP_TLS: bool = True
# Push Notifications (Firebase)
FIREBASE_CREDENTIALS_PATH: Optional[str] = None
FIREBASE_ENABLED: bool = False
# Almacenamiento de archivos
UPLOAD_DIR: str = "/var/lib/adan/uploads"
MAX_UPLOAD_SIZE_MB: int = 100
ALLOWED_IMAGE_TYPES: List[str] = ["image/jpeg", "image/png", "image/webp"]
ALLOWED_VIDEO_TYPES: List[str] = ["video/mp4", "video/webm"]
# Reportes
REPORTS_DIR: str = "/var/lib/adan/reports"
REPORT_RETENTION_DAYS: int = 90
# Geocoding
GEOCODING_PROVIDER: str = "nominatim" # nominatim, google, mapbox
GOOGLE_MAPS_API_KEY: Optional[str] = None
MAPBOX_ACCESS_TOKEN: Optional[str] = None
# Alertas y umbrales
ALERT_SPEED_LIMIT_DEFAULT: int = 120 # km/h
ALERT_IDLE_MINUTES: int = 15
ALERT_BATTERY_LOW_PERCENT: int = 20
ALERT_NO_SIGNAL_MINUTES: int = 30
# Limpieza de datos
LOCATION_RETENTION_DAYS: int = 365
ALERT_RETENTION_DAYS: int = 180
VIDEO_RETENTION_DAYS: int = 30
# Logging
LOG_LEVEL: str = "INFO"
LOG_FORMAT: str = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
LOG_FILE: Optional[str] = None
@lru_cache()
def get_settings() -> Settings:
"""
Obtiene la instancia de configuración (singleton cacheado).
Returns:
Settings: Instancia de configuración de la aplicación.
"""
return Settings()
# Instancia global de configuración
settings = get_settings()

View File

@@ -0,0 +1,140 @@
"""
Configuración de conexión a la base de datos PostgreSQL/TimescaleDB.
Proporciona:
- Engine async para SQLAlchemy
- Session factory async
- Dependency para obtener sesiones en endpoints
- Base declarativa para modelos
"""
from typing import AsyncGenerator
from sqlalchemy.ext.asyncio import (
AsyncSession,
async_sessionmaker,
create_async_engine,
)
from sqlalchemy.orm import DeclarativeBase
from sqlalchemy.pool import NullPool
from app.core.config import settings
class Base(DeclarativeBase):
"""Clase base para todos los modelos SQLAlchemy."""
pass
# Configuración del engine según el entorno
if settings.ENVIRONMENT == "testing":
# En testing usamos NullPool para evitar problemas con conexiones
engine = create_async_engine(
settings.DATABASE_URL,
echo=settings.DEBUG,
poolclass=NullPool,
)
else:
# En producción usamos pool de conexiones
engine = create_async_engine(
settings.DATABASE_URL,
echo=settings.DEBUG,
pool_size=settings.DATABASE_POOL_SIZE,
max_overflow=settings.DATABASE_MAX_OVERFLOW,
pool_pre_ping=True, # Verifica conexiones antes de usar
pool_recycle=3600, # Recicla conexiones cada hora
)
# Factory de sesiones async
async_session_factory = async_sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False,
autocommit=False,
autoflush=False,
)
async def get_db() -> AsyncGenerator[AsyncSession, None]:
"""
Dependency que proporciona una sesión de base de datos.
Yields:
AsyncSession: Sesión de base de datos para usar en el endpoint.
Example:
@router.get("/items")
async def get_items(db: AsyncSession = Depends(get_db)):
result = await db.execute(select(Item))
return result.scalars().all()
"""
async with async_session_factory() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
finally:
await session.close()
async def init_db() -> None:
"""
Inicializa la base de datos creando todas las tablas.
Nota: En producción se recomienda usar Alembic para migraciones.
Esta función es útil para desarrollo y testing.
"""
async with engine.begin() as conn:
# Importar todos los modelos para que SQLAlchemy los conozca
from app.models import ( # noqa: F401
alerta,
camara,
carga_combustible,
conductor,
configuracion,
dispositivo,
evento_video,
geocerca,
grabacion,
grupo_vehiculos,
mantenimiento,
mensaje,
parada,
poi,
tipo_alerta,
tipo_mantenimiento,
ubicacion,
usuario,
vehiculo,
viaje,
)
await conn.run_sync(Base.metadata.create_all)
async def close_db() -> None:
"""
Cierra el pool de conexiones a la base de datos.
Debe llamarse al apagar la aplicación para liberar recursos.
"""
await engine.dispose()
async def check_db_connection() -> bool:
"""
Verifica que la conexión a la base de datos funcione.
Returns:
bool: True si la conexión es exitosa.
Raises:
Exception: Si no se puede conectar a la base de datos.
"""
try:
async with engine.connect() as conn:
await conn.execute("SELECT 1")
return True
except Exception as e:
raise Exception(f"Error conectando a la base de datos: {e}")

View File

@@ -0,0 +1,280 @@
"""
Excepciones personalizadas para la aplicación.
Define excepciones específicas del dominio y handlers
para convertirlas en respuestas HTTP apropiadas.
"""
from typing import Any, Dict, Optional
from fastapi import HTTPException, Request, status
from fastapi.responses import JSONResponse
class AdanException(Exception):
"""Excepción base para todas las excepciones de la aplicación."""
def __init__(
self,
message: str,
code: str = "ADAN_ERROR",
details: Optional[Dict[str, Any]] = None,
):
self.message = message
self.code = code
self.details = details or {}
super().__init__(self.message)
class NotFoundError(AdanException):
"""Recurso no encontrado."""
def __init__(
self,
resource: str,
identifier: Any = None,
details: Optional[Dict[str, Any]] = None,
):
message = f"{resource} no encontrado"
if identifier:
message = f"{resource} con id '{identifier}' no encontrado"
super().__init__(message, "NOT_FOUND", details)
self.resource = resource
self.identifier = identifier
class AlreadyExistsError(AdanException):
"""El recurso ya existe."""
def __init__(
self,
resource: str,
field: str,
value: Any,
details: Optional[Dict[str, Any]] = None,
):
message = f"{resource} con {field}='{value}' ya existe"
super().__init__(message, "ALREADY_EXISTS", details)
self.resource = resource
self.field = field
self.value = value
class ValidationError(AdanException):
"""Error de validación de datos."""
def __init__(
self,
message: str,
field: Optional[str] = None,
details: Optional[Dict[str, Any]] = None,
):
super().__init__(message, "VALIDATION_ERROR", details)
self.field = field
class AuthenticationError(AdanException):
"""Error de autenticación."""
def __init__(
self,
message: str = "Credenciales inválidas",
details: Optional[Dict[str, Any]] = None,
):
super().__init__(message, "AUTHENTICATION_ERROR", details)
class AuthorizationError(AdanException):
"""Error de autorización (permisos insuficientes)."""
def __init__(
self,
message: str = "No tiene permisos para realizar esta acción",
details: Optional[Dict[str, Any]] = None,
):
super().__init__(message, "AUTHORIZATION_ERROR", details)
class ExternalServiceError(AdanException):
"""Error al comunicarse con un servicio externo."""
def __init__(
self,
service: str,
message: str,
details: Optional[Dict[str, Any]] = None,
):
full_message = f"Error en servicio {service}: {message}"
super().__init__(full_message, "EXTERNAL_SERVICE_ERROR", details)
self.service = service
class GeocercaViolationError(AdanException):
"""Violación de geocerca detectada."""
def __init__(
self,
geocerca_id: int,
geocerca_nombre: str,
tipo_violacion: str, # 'entrada' o 'salida'
vehiculo_id: int,
details: Optional[Dict[str, Any]] = None,
):
message = f"Vehículo {vehiculo_id} {tipo_violacion} de geocerca '{geocerca_nombre}'"
super().__init__(message, "GEOCERCA_VIOLATION", details)
self.geocerca_id = geocerca_id
self.geocerca_nombre = geocerca_nombre
self.tipo_violacion = tipo_violacion
self.vehiculo_id = vehiculo_id
class SpeedLimitExceededError(AdanException):
"""Límite de velocidad excedido."""
def __init__(
self,
vehiculo_id: int,
velocidad: float,
limite: float,
details: Optional[Dict[str, Any]] = None,
):
message = f"Vehículo {vehiculo_id} excedió límite de velocidad: {velocidad} km/h (límite: {limite} km/h)"
super().__init__(message, "SPEED_LIMIT_EXCEEDED", details)
self.vehiculo_id = vehiculo_id
self.velocidad = velocidad
self.limite = limite
class DeviceConnectionError(AdanException):
"""Error de conexión con dispositivo."""
def __init__(
self,
dispositivo_id: int,
message: str,
details: Optional[Dict[str, Any]] = None,
):
full_message = f"Error de conexión con dispositivo {dispositivo_id}: {message}"
super().__init__(full_message, "DEVICE_CONNECTION_ERROR", details)
self.dispositivo_id = dispositivo_id
class VideoStreamError(AdanException):
"""Error con stream de video."""
def __init__(
self,
camara_id: int,
message: str,
details: Optional[Dict[str, Any]] = None,
):
full_message = f"Error de video en cámara {camara_id}: {message}"
super().__init__(full_message, "VIDEO_STREAM_ERROR", details)
self.camara_id = camara_id
class MaintenanceRequiredError(AdanException):
"""Mantenimiento requerido para el vehículo."""
def __init__(
self,
vehiculo_id: int,
tipo_mantenimiento: str,
details: Optional[Dict[str, Any]] = None,
):
message = f"Vehículo {vehiculo_id} requiere mantenimiento: {tipo_mantenimiento}"
super().__init__(message, "MAINTENANCE_REQUIRED", details)
self.vehiculo_id = vehiculo_id
self.tipo_mantenimiento = tipo_mantenimiento
class DatabaseError(AdanException):
"""Error de base de datos."""
def __init__(
self,
operation: str,
message: str,
details: Optional[Dict[str, Any]] = None,
):
full_message = f"Error de base de datos en {operation}: {message}"
super().__init__(full_message, "DATABASE_ERROR", details)
self.operation = operation
# ============================================================================
# Exception Handlers para FastAPI
# ============================================================================
async def adan_exception_handler(request: Request, exc: AdanException) -> JSONResponse:
"""Handler para excepciones base de Adan."""
status_code = status.HTTP_500_INTERNAL_SERVER_ERROR
if isinstance(exc, NotFoundError):
status_code = status.HTTP_404_NOT_FOUND
elif isinstance(exc, AlreadyExistsError):
status_code = status.HTTP_409_CONFLICT
elif isinstance(exc, ValidationError):
status_code = status.HTTP_422_UNPROCESSABLE_ENTITY
elif isinstance(exc, AuthenticationError):
status_code = status.HTTP_401_UNAUTHORIZED
elif isinstance(exc, AuthorizationError):
status_code = status.HTTP_403_FORBIDDEN
elif isinstance(exc, ExternalServiceError):
status_code = status.HTTP_502_BAD_GATEWAY
return JSONResponse(
status_code=status_code,
content={
"error": {
"code": exc.code,
"message": exc.message,
"details": exc.details,
}
},
)
async def http_exception_handler(request: Request, exc: HTTPException) -> JSONResponse:
"""Handler para HTTPException estándar de FastAPI."""
return JSONResponse(
status_code=exc.status_code,
content={
"error": {
"code": "HTTP_ERROR",
"message": exc.detail,
"details": {},
}
},
)
async def general_exception_handler(request: Request, exc: Exception) -> JSONResponse:
"""Handler para excepciones no capturadas."""
# En producción, loguear el error completo pero no exponerlo al cliente
import traceback
traceback.print_exc()
return JSONResponse(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
content={
"error": {
"code": "INTERNAL_ERROR",
"message": "Error interno del servidor",
"details": {},
}
},
)
def register_exception_handlers(app) -> None:
"""
Registra los handlers de excepciones en la aplicación FastAPI.
Args:
app: Instancia de FastAPI.
"""
app.add_exception_handler(AdanException, adan_exception_handler)
app.add_exception_handler(HTTPException, http_exception_handler)
app.add_exception_handler(Exception, general_exception_handler)

View File

@@ -0,0 +1,285 @@
"""
Módulo de seguridad para autenticación y autorización.
Proporciona:
- Generación y verificación de tokens JWT
- Hashing de contraseñas con bcrypt
- Dependencies para obtener el usuario actual
- Encriptación de datos sensibles
"""
from datetime import datetime, timedelta, timezone
from typing import Annotated, Any, Optional
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from jose import JWTError, jwt
from passlib.context import CryptContext
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.core.database import get_db
# Contexto de hashing para contraseñas
pwd_context = CryptContext(
schemes=["bcrypt"],
deprecated="auto",
bcrypt__rounds=12, # Factor de costo para bcrypt
)
# Esquema de autenticación Bearer
bearer_scheme = HTTPBearer(auto_error=False)
def hash_password(password: str) -> str:
"""
Genera un hash seguro de una contraseña.
Args:
password: Contraseña en texto plano.
Returns:
str: Hash bcrypt de la contraseña.
"""
return pwd_context.hash(password)
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""
Verifica una contraseña contra su hash.
Args:
plain_password: Contraseña en texto plano a verificar.
hashed_password: Hash almacenado de la contraseña.
Returns:
bool: True si la contraseña coincide.
"""
return pwd_context.verify(plain_password, hashed_password)
def create_access_token(
data: dict[str, Any],
expires_delta: Optional[timedelta] = None,
) -> str:
"""
Crea un token JWT de acceso.
Args:
data: Datos a incluir en el payload del token.
expires_delta: Tiempo de expiración personalizado.
Returns:
str: Token JWT codificado.
"""
to_encode = data.copy()
expire = datetime.now(timezone.utc) + (
expires_delta or timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
)
to_encode.update({
"exp": expire,
"iat": datetime.now(timezone.utc),
"type": "access",
})
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
def create_refresh_token(
data: dict[str, Any],
expires_delta: Optional[timedelta] = None,
) -> str:
"""
Crea un token JWT de refresco.
Args:
data: Datos a incluir en el payload del token.
expires_delta: Tiempo de expiración personalizado.
Returns:
str: Token JWT de refresco codificado.
"""
to_encode = data.copy()
expire = datetime.now(timezone.utc) + (
expires_delta or timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS)
)
to_encode.update({
"exp": expire,
"iat": datetime.now(timezone.utc),
"type": "refresh",
})
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
def decode_token(token: str) -> dict[str, Any]:
"""
Decodifica y valida un token JWT.
Args:
token: Token JWT a decodificar.
Returns:
dict: Payload del token decodificado.
Raises:
HTTPException: Si el token es inválido o ha expirado.
"""
try:
payload = jwt.decode(
token,
settings.SECRET_KEY,
algorithms=[settings.ALGORITHM],
)
return payload
except JWTError as e:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=f"Token inválido: {str(e)}",
headers={"WWW-Authenticate": "Bearer"},
)
def verify_token_type(payload: dict[str, Any], expected_type: str) -> None:
"""
Verifica que el token sea del tipo esperado.
Args:
payload: Payload del token decodificado.
expected_type: Tipo esperado ('access' o 'refresh').
Raises:
HTTPException: Si el tipo de token no coincide.
"""
token_type = payload.get("type")
if token_type != expected_type:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=f"Tipo de token inválido. Se esperaba '{expected_type}'",
headers={"WWW-Authenticate": "Bearer"},
)
async def get_current_user(
credentials: Annotated[Optional[HTTPAuthorizationCredentials], Depends(bearer_scheme)],
db: Annotated[AsyncSession, Depends(get_db)],
):
"""
Dependency que obtiene el usuario actual desde el token JWT.
Args:
credentials: Credenciales Bearer del header Authorization.
db: Sesión de base de datos.
Returns:
Usuario: Modelo del usuario autenticado.
Raises:
HTTPException: Si no hay token, es inválido, o el usuario no existe.
"""
if credentials is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="No se proporcionaron credenciales",
headers={"WWW-Authenticate": "Bearer"},
)
payload = decode_token(credentials.credentials)
verify_token_type(payload, "access")
user_id = payload.get("sub")
if user_id is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token inválido: falta el identificador de usuario",
headers={"WWW-Authenticate": "Bearer"},
)
# Importar aquí para evitar importación circular
from app.models.usuario import Usuario
result = await db.execute(select(Usuario).where(Usuario.id == int(user_id)))
user = result.scalar_one_or_none()
if user is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Usuario no encontrado",
headers={"WWW-Authenticate": "Bearer"},
)
if not user.activo:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Usuario desactivado",
)
return user
async def get_current_active_admin(
current_user: Annotated[Any, Depends(get_current_user)],
):
"""
Dependency que verifica que el usuario actual sea administrador.
Args:
current_user: Usuario actual obtenido del token.
Returns:
Usuario: Usuario administrador verificado.
Raises:
HTTPException: Si el usuario no es administrador.
"""
if not current_user.es_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Se requieren permisos de administrador",
)
return current_user
def encrypt_sensitive_data(data: str) -> str:
"""
Encripta datos sensibles (ej: contraseñas de cámaras).
Por simplicidad, usa el mismo mecanismo de hash.
En producción, usar Fernet o similar para encriptación reversible.
Args:
data: Datos a encriptar.
Returns:
str: Datos encriptados.
"""
# Para datos que necesitan ser recuperados (como passwords de cámaras),
# se debería usar encriptación simétrica (Fernet)
# Por ahora, usamos base64 + XOR simple como placeholder
# TODO: Implementar encriptación Fernet apropiada
import base64
key = settings.SECRET_KEY[:32].encode()
data_bytes = data.encode()
encrypted = bytes(a ^ b for a, b in zip(data_bytes, key * (len(data_bytes) // len(key) + 1)))
return base64.b64encode(encrypted).decode()
def decrypt_sensitive_data(encrypted_data: str) -> str:
"""
Desencripta datos sensibles.
Args:
encrypted_data: Datos encriptados.
Returns:
str: Datos desencriptados.
"""
import base64
key = settings.SECRET_KEY[:32].encode()
encrypted_bytes = base64.b64decode(encrypted_data.encode())
decrypted = bytes(a ^ b for a, b in zip(encrypted_bytes, key * (len(encrypted_bytes) // len(key) + 1)))
return decrypted.decode()
# Type aliases para uso en endpoints
CurrentUser = Annotated[Any, Depends(get_current_user)]
CurrentAdmin = Annotated[Any, Depends(get_current_active_admin)]

268
backend/app/main.py Normal file
View File

@@ -0,0 +1,268 @@
"""
Aplicación principal FastAPI para Adan Fleet Monitor.
Sistema de monitoreo de flotillas GPS con soporte para:
- Tracking en tiempo real
- Gestión de vehículos y conductores
- Alertas y geocercas
- Video vigilancia
- Reportes y análisis
"""
from contextlib import asynccontextmanager
from datetime import datetime, timezone
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.gzip import GZipMiddleware
from fastapi.responses import JSONResponse
from app.core.config import settings
from app.core.database import close_db, init_db
from app.core.exceptions import register_exception_handlers
from app.api.v1 import api_router
from app.api.websocket import ubicaciones_router, alertas_router
@asynccontextmanager
async def lifespan(app: FastAPI):
"""
Manejador del ciclo de vida de la aplicación.
Ejecuta código de inicialización al arrancar y
limpieza al cerrar la aplicación.
"""
# Startup
print(f"Starting {settings.APP_NAME} v{settings.APP_VERSION}")
print(f"Environment: {settings.ENVIRONMENT}")
print(f"Debug mode: {settings.DEBUG}")
# Inicializar base de datos (crear tablas si no existen)
if settings.ENVIRONMENT == "development":
try:
await init_db()
print("Database initialized")
except Exception as e:
print(f"Warning: Could not initialize database: {e}")
yield # La aplicación se ejecuta aquí
# Shutdown
print("Shutting down...")
await close_db()
print("Database connections closed")
# Crear aplicación FastAPI
app = FastAPI(
title=settings.APP_NAME,
description="""
## Adan Fleet Monitor API
Sistema de monitoreo de flotillas GPS.
### Funcionalidades principales:
- **Tracking en tiempo real** de vehículos
- **Gestión de flota**: vehículos, conductores, dispositivos
- **Alertas inteligentes**: velocidad, geocercas, batería
- **Viajes automáticos**: detección de inicio/fin
- **Geocercas**: zonas circulares y poligonales
- **Video vigilancia**: integración con cámaras
- **Reportes**: PDF, Excel, dashboards
### WebSocket endpoints:
- `/ws/ubicaciones` - Ubicaciones en tiempo real
- `/ws/alertas` - Alertas en tiempo real
- `/ws/vehiculo/{id}` - Seguimiento de un vehículo
- `/ws/flota` - Monitoreo de toda la flota
""",
version=settings.APP_VERSION,
docs_url="/docs" if settings.DEBUG else None,
redoc_url="/redoc" if settings.DEBUG else None,
openapi_url="/openapi.json" if settings.DEBUG else None,
lifespan=lifespan,
)
# ============================================================================
# Middlewares
# ============================================================================
# CORS
app.add_middleware(
CORSMiddleware,
allow_origins=settings.CORS_ORIGINS,
allow_credentials=settings.CORS_ALLOW_CREDENTIALS,
allow_methods=settings.CORS_ALLOW_METHODS,
allow_headers=settings.CORS_ALLOW_HEADERS,
)
# Compresión GZip
app.add_middleware(GZipMiddleware, minimum_size=1000)
# Middleware de logging de requests
@app.middleware("http")
async def log_requests(request: Request, call_next):
"""
Middleware para logging de requests.
Registra el tiempo de respuesta de cada request.
"""
start_time = datetime.now(timezone.utc)
response = await call_next(request)
# Calcular tiempo de procesamiento
process_time = (datetime.now(timezone.utc) - start_time).total_seconds()
response.headers["X-Process-Time"] = str(process_time)
# Log en modo debug
if settings.DEBUG:
print(
f"{request.method} {request.url.path} "
f"- {response.status_code} "
f"- {process_time:.3f}s"
)
return response
# Middleware de seguridad
@app.middleware("http")
async def security_headers(request: Request, call_next):
"""
Middleware para agregar headers de seguridad.
"""
response = await call_next(request)
# Headers de seguridad
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["X-Frame-Options"] = "DENY"
response.headers["X-XSS-Protection"] = "1; mode=block"
if not settings.DEBUG:
response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
return response
# ============================================================================
# Registrar exception handlers
# ============================================================================
register_exception_handlers(app)
# ============================================================================
# Routers
# ============================================================================
# API REST v1
app.include_router(api_router, prefix=settings.API_V1_PREFIX)
# WebSocket endpoints
app.include_router(ubicaciones_router)
app.include_router(alertas_router)
# ============================================================================
# Endpoints base
# ============================================================================
@app.get("/", tags=["Root"])
async def root():
"""
Endpoint raíz de la API.
Returns:
Información básica de la API.
"""
return {
"name": settings.APP_NAME,
"version": settings.APP_VERSION,
"status": "online",
"environment": settings.ENVIRONMENT,
"docs": "/docs" if settings.DEBUG else "disabled",
}
@app.get("/health", tags=["Health"])
async def health_check():
"""
Health check endpoint.
Verifica el estado de la aplicación y sus dependencias.
Returns:
Estado de salud de la aplicación.
"""
health = {
"status": "healthy",
"timestamp": datetime.now(timezone.utc).isoformat(),
"version": settings.APP_VERSION,
"checks": {
"api": "ok",
},
}
# Verificar base de datos
try:
from app.core.database import check_db_connection
await check_db_connection()
health["checks"]["database"] = "ok"
except Exception as e:
health["checks"]["database"] = f"error: {str(e)}"
health["status"] = "degraded"
# Verificar Redis (si está configurado)
if settings.REDIS_URL:
try:
import aioredis
redis = await aioredis.from_url(settings.REDIS_URL)
await redis.ping()
await redis.close()
health["checks"]["redis"] = "ok"
except Exception as e:
health["checks"]["redis"] = f"error: {str(e)}"
health["status"] = "degraded"
return health
@app.get("/ready", tags=["Health"])
async def readiness_check():
"""
Readiness check para Kubernetes.
Verifica si la aplicación está lista para recibir tráfico.
Returns:
Estado de preparación.
"""
try:
from app.core.database import check_db_connection
await check_db_connection()
return {"ready": True}
except Exception:
return JSONResponse(
status_code=503,
content={"ready": False, "reason": "Database not ready"},
)
# ============================================================================
# Entry point para desarrollo
# ============================================================================
if __name__ == "__main__":
import uvicorn
uvicorn.run(
"app.main:app",
host=settings.HOST,
port=settings.PORT,
reload=settings.DEBUG,
workers=1 if settings.DEBUG else settings.WORKERS,
log_level="debug" if settings.DEBUG else "info",
)

View File

@@ -0,0 +1,73 @@
"""
Módulo de modelos SQLAlchemy.
Exporta todos los modelos para facilitar importaciones
y asegurar que SQLAlchemy los registre correctamente.
"""
from app.models.base import TimestampMixin, SoftDeleteMixin
from app.models.usuario import Usuario
from app.models.grupo_vehiculos import GrupoVehiculos
from app.models.conductor import Conductor
from app.models.vehiculo import Vehiculo
from app.models.dispositivo import Dispositivo
from app.models.ubicacion import Ubicacion
from app.models.viaje import Viaje
from app.models.parada import Parada
from app.models.tipo_alerta import TipoAlerta, TIPOS_ALERTA_DEFAULT
from app.models.alerta import Alerta
from app.models.geocerca import Geocerca, geocerca_vehiculo
from app.models.poi import POI, CATEGORIAS_POI
from app.models.carga_combustible import CargaCombustible
from app.models.tipo_mantenimiento import TipoMantenimiento, TIPOS_MANTENIMIENTO_DEFAULT
from app.models.mantenimiento import Mantenimiento
from app.models.camara import Camara
from app.models.grabacion import Grabacion
from app.models.evento_video import EventoVideo, TIPOS_EVENTO_VIDEO
from app.models.mensaje import Mensaje
from app.models.configuracion import Configuracion, CONFIGURACIONES_DEFAULT
__all__ = [
# Base
"TimestampMixin",
"SoftDeleteMixin",
# Usuarios
"Usuario",
# Grupos
"GrupoVehiculos",
# Conductores
"Conductor",
# Vehículos
"Vehiculo",
"Dispositivo",
# Ubicaciones
"Ubicacion",
# Viajes
"Viaje",
"Parada",
# Alertas
"TipoAlerta",
"TIPOS_ALERTA_DEFAULT",
"Alerta",
# Geocercas y POIs
"Geocerca",
"geocerca_vehiculo",
"POI",
"CATEGORIAS_POI",
# Combustible
"CargaCombustible",
# Mantenimiento
"TipoMantenimiento",
"TIPOS_MANTENIMIENTO_DEFAULT",
"Mantenimiento",
# Video
"Camara",
"Grabacion",
"EventoVideo",
"TIPOS_EVENTO_VIDEO",
# Mensajes
"Mensaje",
# Configuración
"Configuracion",
"CONFIGURACIONES_DEFAULT",
]

View File

@@ -0,0 +1,117 @@
"""
Modelo de Alerta para registrar eventos y notificaciones.
"""
from datetime import datetime
from sqlalchemy import (
Boolean,
DateTime,
Float,
ForeignKey,
Index,
String,
Text,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
from app.models.base import TimestampMixin
class Alerta(Base, TimestampMixin):
"""Modelo de alerta/evento del sistema."""
__tablename__ = "alertas"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
# Relaciones
vehiculo_id: Mapped[int | None] = mapped_column(
ForeignKey("vehiculos.id", ondelete="CASCADE"),
nullable=True,
index=True,
)
conductor_id: Mapped[int | None] = mapped_column(
ForeignKey("conductores.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
tipo_alerta_id: Mapped[int] = mapped_column(
ForeignKey("tipos_alerta.id", ondelete="RESTRICT"),
nullable=False,
index=True,
)
dispositivo_id: Mapped[int | None] = mapped_column(
ForeignKey("dispositivos.id", ondelete="SET NULL"),
nullable=True,
)
# Severidad (puede sobrescribir la del tipo)
severidad: Mapped[str] = mapped_column(
String(20),
default="media",
nullable=False,
) # baja, media, alta, critica
# Mensaje descriptivo
mensaje: Mapped[str] = mapped_column(String(500), nullable=False)
descripcion: Mapped[str | None] = mapped_column(Text, nullable=True)
# Ubicación donde ocurrió
lat: Mapped[float | None] = mapped_column(Float, nullable=True)
lng: Mapped[float | None] = mapped_column(Float, nullable=True)
direccion: Mapped[str | None] = mapped_column(String(255), nullable=True)
# Datos adicionales
velocidad: Mapped[float | None] = mapped_column(Float, nullable=True)
valor: Mapped[float | None] = mapped_column(Float, nullable=True) # Valor que disparó la alerta
umbral: Mapped[float | None] = mapped_column(Float, nullable=True) # Umbral configurado
datos_extra: Mapped[str | None] = mapped_column(Text, nullable=True) # JSON con datos adicionales
# Estado de atención
atendida: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
atendida_por_id: Mapped[int | None] = mapped_column(
ForeignKey("usuarios.id", ondelete="SET NULL"),
nullable=True,
)
atendida_en: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
notas_atencion: Mapped[str | None] = mapped_column(Text, nullable=True)
# Notificaciones enviadas
notificacion_email_enviada: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
notificacion_push_enviada: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
notificacion_sms_enviada: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
# Relaciones ORM
vehiculo: Mapped["Vehiculo | None"] = relationship(
"Vehiculo",
back_populates="alertas",
lazy="selectin",
)
conductor: Mapped["Conductor | None"] = relationship(
"Conductor",
back_populates="alertas",
lazy="selectin",
)
tipo_alerta: Mapped["TipoAlerta"] = relationship(
"TipoAlerta",
back_populates="alertas",
lazy="selectin",
)
# Índices
__table_args__ = (
Index("idx_alertas_vehiculo_creado", "vehiculo_id", "creado_en"),
Index("idx_alertas_atendida", "atendida"),
Index("idx_alertas_severidad", "severidad"),
Index("idx_alertas_tipo_creado", "tipo_alerta_id", "creado_en"),
)
@property
def es_critica(self) -> bool:
"""Verifica si la alerta es crítica."""
return self.severidad == "critica"
def __repr__(self) -> str:
return f"<Alerta(id={self.id}, tipo_id={self.tipo_alerta_id}, severidad='{self.severidad}')>"

View File

@@ -0,0 +1,43 @@
"""
Clases y mixins base para los modelos SQLAlchemy.
"""
from datetime import datetime, timezone
from sqlalchemy import DateTime, func
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
class TimestampMixin:
"""Mixin que agrega campos de timestamp (creado_en, actualizado_en)."""
creado_en: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
server_default=func.now(),
nullable=False,
)
actualizado_en: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
server_default=func.now(),
onupdate=lambda: datetime.now(timezone.utc),
nullable=False,
)
class SoftDeleteMixin:
"""Mixin para soft delete (eliminado_en en lugar de borrar físicamente)."""
eliminado_en: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True),
nullable=True,
default=None,
)
@property
def is_deleted(self) -> bool:
"""Verifica si el registro está eliminado."""
return self.eliminado_en is not None

View File

@@ -0,0 +1,142 @@
"""
Modelo de Cámara para video vigilancia en vehículos.
"""
from datetime import datetime
from sqlalchemy import (
Boolean,
DateTime,
ForeignKey,
Index,
Integer,
String,
Text,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
from app.models.base import TimestampMixin
class Camara(Base, TimestampMixin):
"""Modelo de cámara instalada en un vehículo."""
__tablename__ = "camaras"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
# Relación con vehículo
vehiculo_id: Mapped[int] = mapped_column(
ForeignKey("vehiculos.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
# Identificación
nombre: Mapped[str] = mapped_column(String(100), nullable=False)
posicion: Mapped[str] = mapped_column(
String(50),
default="frontal",
nullable=False,
) # frontal, trasera, interior, lateral_izq, lateral_der
# Tipo de cámara
tipo: Mapped[str] = mapped_column(
String(50),
default="ip",
nullable=False,
) # ip, dashcam, mdvr, usb
# Información del hardware
marca: Mapped[str | None] = mapped_column(String(50), nullable=True)
modelo: Mapped[str | None] = mapped_column(String(50), nullable=True)
numero_serie: Mapped[str | None] = mapped_column(String(100), nullable=True)
resolucion: Mapped[str | None] = mapped_column(String(20), nullable=True) # 1080p, 720p, 4K
# Conexión de streaming
url_stream: Mapped[str | None] = mapped_column(String(500), nullable=True) # URL RTSP/RTMP
puerto: Mapped[int | None] = mapped_column(Integer, nullable=True)
protocolo: Mapped[str] = mapped_column(
String(20),
default="rtsp",
nullable=False,
) # rtsp, rtmp, hls, webrtc
# Autenticación
usuario: Mapped[str | None] = mapped_column(String(100), nullable=True)
password_encrypted: Mapped[str | None] = mapped_column(String(255), nullable=True)
# Configuración de MediaMTX
mediamtx_path: Mapped[str | None] = mapped_column(String(100), nullable=True) # Path en MediaMTX
# Estado
estado: Mapped[str] = mapped_column(
String(20),
default="desconectada",
nullable=False,
) # conectada, desconectada, grabando, error
activa: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
# Última conexión
ultima_conexion: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
# Configuración de grabación
grabacion_continua: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
grabacion_evento: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) # Grabar en eventos
duracion_pre_evento: Mapped[int] = mapped_column(Integer, default=10, nullable=False) # Segundos antes
duracion_post_evento: Mapped[int] = mapped_column(Integer, default=20, nullable=False) # Segundos después
# Detección de eventos (AI/ADAS)
deteccion_colision: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
deteccion_distraccion: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
deteccion_fatiga: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
deteccion_cambio_carril: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
# Notas
notas: Mapped[str | None] = mapped_column(Text, nullable=True)
# Relaciones ORM
vehiculo: Mapped["Vehiculo"] = relationship(
"Vehiculo",
back_populates="camaras",
lazy="selectin",
)
grabaciones: Mapped[list["Grabacion"]] = relationship(
"Grabacion",
back_populates="camara",
lazy="dynamic",
cascade="all, delete-orphan",
)
eventos_video: Mapped[list["EventoVideo"]] = relationship(
"EventoVideo",
back_populates="camara",
lazy="dynamic",
cascade="all, delete-orphan",
)
# Índices
__table_args__ = (
Index("idx_camaras_vehiculo", "vehiculo_id"),
Index("idx_camaras_estado", "estado"),
)
@property
def url_stream_completa(self) -> str | None:
"""Construye la URL completa de streaming."""
if not self.url_stream:
return None
if self.usuario and self.password_encrypted:
# Desencriptar password y construir URL con autenticación
from app.core.security import decrypt_sensitive_data
try:
password = decrypt_sensitive_data(self.password_encrypted)
# Insertar credenciales en URL RTSP
if self.url_stream.startswith("rtsp://"):
return self.url_stream.replace("rtsp://", f"rtsp://{self.usuario}:{password}@")
except Exception:
pass
return self.url_stream
def __repr__(self) -> str:
return f"<Camara(id={self.id}, nombre='{self.nombre}', vehiculo_id={self.vehiculo_id})>"

View File

@@ -0,0 +1,100 @@
"""
Modelo de Carga de Combustible para registrar recargas de combustible.
"""
from datetime import datetime
from sqlalchemy import (
DateTime,
Float,
ForeignKey,
Index,
String,
Text,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
from app.models.base import TimestampMixin
class CargaCombustible(Base, TimestampMixin):
"""Modelo para registrar cargas de combustible de los vehículos."""
__tablename__ = "cargas_combustible"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
# Relaciones
vehiculo_id: Mapped[int] = mapped_column(
ForeignKey("vehiculos.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
conductor_id: Mapped[int | None] = mapped_column(
ForeignKey("conductores.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
# Fecha y hora de la carga
fecha: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
# Cantidad y precio
litros: Mapped[float] = mapped_column(Float, nullable=False)
precio_litro: Mapped[float | None] = mapped_column(Float, nullable=True)
total: Mapped[float | None] = mapped_column(Float, nullable=True)
# Tipo de combustible
tipo_combustible: Mapped[str | None] = mapped_column(String(20), nullable=True) # gasolina, diesel, premium
# Odómetro al momento de la carga
odometro: Mapped[float | None] = mapped_column(Float, nullable=True)
# Estación de servicio
estacion: Mapped[str | None] = mapped_column(String(100), nullable=True)
estacion_direccion: Mapped[str | None] = mapped_column(String(255), nullable=True)
# Ubicación de la carga
lat: Mapped[float | None] = mapped_column(Float, nullable=True)
lng: Mapped[float | None] = mapped_column(Float, nullable=True)
# Tanque lleno (para cálculo de rendimiento)
tanque_lleno: Mapped[bool] = mapped_column(default=True, nullable=False)
# Método de pago
metodo_pago: Mapped[str | None] = mapped_column(String(50), nullable=True) # efectivo, tarjeta, vales
numero_factura: Mapped[str | None] = mapped_column(String(50), nullable=True)
# Notas
notas: Mapped[str | None] = mapped_column(Text, nullable=True)
# Relaciones ORM
vehiculo: Mapped["Vehiculo"] = relationship(
"Vehiculo",
back_populates="cargas_combustible",
lazy="selectin",
)
conductor: Mapped["Conductor | None"] = relationship(
"Conductor",
back_populates="cargas_combustible",
lazy="selectin",
)
# Índices
__table_args__ = (
Index("idx_cargas_vehiculo_fecha", "vehiculo_id", "fecha"),
)
@property
def rendimiento_calculado(self) -> float | None:
"""
Calcula el rendimiento en km/litro si hay datos suficientes.
Este cálculo requiere la carga anterior para comparar odómetros.
Se implementa en el servicio de combustible.
"""
return None # Se calcula en el servicio
def __repr__(self) -> str:
return f"<CargaCombustible(id={self.id}, vehiculo_id={self.vehiculo_id}, litros={self.litros})>"

View File

@@ -0,0 +1,89 @@
"""
Modelo de Conductor para gestión de operadores de vehículos.
"""
from datetime import date, datetime
from sqlalchemy import Boolean, Date, DateTime, String, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
from app.models.base import TimestampMixin
class Conductor(Base, TimestampMixin):
"""Modelo de conductor/operador de vehículo."""
__tablename__ = "conductores"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
nombre: Mapped[str] = mapped_column(String(100), nullable=False)
apellido: Mapped[str] = mapped_column(String(100), nullable=False)
telefono: Mapped[str | None] = mapped_column(String(20), nullable=True)
email: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
# Documento de identidad
documento_tipo: Mapped[str | None] = mapped_column(String(20), nullable=True) # DNI, INE, etc.
documento_numero: Mapped[str | None] = mapped_column(String(50), nullable=True)
# Licencia de conducir
licencia_numero: Mapped[str | None] = mapped_column(String(50), nullable=True, unique=True)
licencia_tipo: Mapped[str | None] = mapped_column(String(20), nullable=True) # A, B, C, D, E
licencia_vencimiento: Mapped[date | None] = mapped_column(Date, nullable=True)
# Información personal
foto_url: Mapped[str | None] = mapped_column(Text, nullable=True)
fecha_nacimiento: Mapped[date | None] = mapped_column(Date, nullable=True)
direccion: Mapped[str | None] = mapped_column(Text, nullable=True)
contacto_emergencia: Mapped[str | None] = mapped_column(String(100), nullable=True)
telefono_emergencia: Mapped[str | None] = mapped_column(String(20), nullable=True)
# Información laboral
fecha_contratacion: Mapped[date | None] = mapped_column(Date, nullable=True)
numero_empleado: Mapped[str | None] = mapped_column(String(50), nullable=True)
# Estado
activo: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
notas: Mapped[str | None] = mapped_column(Text, nullable=True)
# Relaciones
vehiculos: Mapped[list["Vehiculo"]] = relationship(
"Vehiculo",
back_populates="conductor",
lazy="selectin",
)
viajes: Mapped[list["Viaje"]] = relationship(
"Viaje",
back_populates="conductor",
lazy="dynamic",
)
alertas: Mapped[list["Alerta"]] = relationship(
"Alerta",
back_populates="conductor",
lazy="dynamic",
)
cargas_combustible: Mapped[list["CargaCombustible"]] = relationship(
"CargaCombustible",
back_populates="conductor",
lazy="dynamic",
)
mensajes: Mapped[list["Mensaje"]] = relationship(
"Mensaje",
back_populates="conductor",
lazy="dynamic",
)
@property
def nombre_completo(self) -> str:
"""Retorna el nombre completo del conductor."""
return f"{self.nombre} {self.apellido}"
@property
def licencia_vigente(self) -> bool:
"""Verifica si la licencia está vigente."""
if not self.licencia_vencimiento:
return False
return self.licencia_vencimiento >= date.today()
def __repr__(self) -> str:
return f"<Conductor(id={self.id}, nombre='{self.nombre_completo}')>"

View File

@@ -0,0 +1,249 @@
"""
Modelo de Configuración para almacenar settings del sistema.
"""
from sqlalchemy import String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
from app.models.base import TimestampMixin
class Configuracion(Base, TimestampMixin):
"""
Modelo para almacenar configuraciones del sistema.
Permite guardar configuraciones dinámicas sin necesidad
de reiniciar la aplicación.
"""
__tablename__ = "configuraciones"
clave: Mapped[str] = mapped_column(String(100), primary_key=True)
valor_json: Mapped[str] = mapped_column(Text, nullable=False) # Valor en formato JSON
categoria: Mapped[str] = mapped_column(
String(50),
default="general",
nullable=False,
index=True,
)
descripcion: Mapped[str | None] = mapped_column(Text, nullable=True)
# Tipo de dato para validación
tipo_dato: Mapped[str] = mapped_column(
String(20),
default="string",
nullable=False,
) # string, number, boolean, json, array
# Si la configuración es sensible (no mostrar en logs)
sensible: Mapped[bool] = mapped_column(default=False, nullable=False)
# Si puede ser modificada desde la UI
editable: Mapped[bool] = mapped_column(default=True, nullable=False)
def __repr__(self) -> str:
return f"<Configuracion(clave='{self.clave}', categoria='{self.categoria}')>"
def get_value(self):
"""Parsea y retorna el valor según su tipo."""
import json
if self.tipo_dato == "string":
return json.loads(self.valor_json)
elif self.tipo_dato == "number":
return float(json.loads(self.valor_json))
elif self.tipo_dato == "boolean":
return bool(json.loads(self.valor_json))
else:
return json.loads(self.valor_json)
# Configuraciones por defecto del sistema
CONFIGURACIONES_DEFAULT = [
# Alertas
{
"clave": "alerta_velocidad_maxima",
"valor_json": "120",
"categoria": "alertas",
"descripcion": "Velocidad máxima permitida (km/h) antes de generar alerta",
"tipo_dato": "number",
},
{
"clave": "alerta_parada_minutos",
"valor_json": "15",
"categoria": "alertas",
"descripcion": "Minutos de parada para considerar como parada prolongada",
"tipo_dato": "number",
},
{
"clave": "alerta_bateria_minima",
"valor_json": "20",
"categoria": "alertas",
"descripcion": "Porcentaje mínimo de batería antes de alertar",
"tipo_dato": "number",
},
{
"clave": "alerta_sin_señal_minutos",
"valor_json": "30",
"categoria": "alertas",
"descripcion": "Minutos sin señal para generar alerta",
"tipo_dato": "number",
},
{
"clave": "alerta_motor_encendido_minutos",
"valor_json": "10",
"categoria": "alertas",
"descripcion": "Minutos con motor encendido sin movimiento para alertar",
"tipo_dato": "number",
},
# Viajes
{
"clave": "viaje_velocidad_minima",
"valor_json": "5",
"categoria": "viajes",
"descripcion": "Velocidad mínima (km/h) para considerar movimiento",
"tipo_dato": "number",
},
{
"clave": "viaje_parada_minutos",
"valor_json": "5",
"categoria": "viajes",
"descripcion": "Minutos de parada para finalizar un viaje automáticamente",
"tipo_dato": "number",
},
# Paradas
{
"clave": "parada_duracion_minima",
"valor_json": "120",
"categoria": "paradas",
"descripcion": "Segundos mínimos para registrar una parada",
"tipo_dato": "number",
},
# Combustible
{
"clave": "combustible_precio_gasolina",
"valor_json": "22.50",
"categoria": "combustible",
"descripcion": "Precio por defecto del litro de gasolina",
"tipo_dato": "number",
},
{
"clave": "combustible_precio_diesel",
"valor_json": "23.80",
"categoria": "combustible",
"descripcion": "Precio por defecto del litro de diesel",
"tipo_dato": "number",
},
# Mantenimiento
{
"clave": "mantenimiento_recordatorio_dias",
"valor_json": "7",
"categoria": "mantenimiento",
"descripcion": "Días de anticipación para recordatorio de mantenimiento",
"tipo_dato": "number",
},
{
"clave": "mantenimiento_recordatorio_km",
"valor_json": "500",
"categoria": "mantenimiento",
"descripcion": "Km de anticipación para recordatorio de mantenimiento",
"tipo_dato": "number",
},
# Notificaciones
{
"clave": "notificaciones_email_habilitado",
"valor_json": "true",
"categoria": "notificaciones",
"descripcion": "Habilitar notificaciones por email",
"tipo_dato": "boolean",
},
{
"clave": "notificaciones_push_habilitado",
"valor_json": "true",
"categoria": "notificaciones",
"descripcion": "Habilitar notificaciones push",
"tipo_dato": "boolean",
},
{
"clave": "notificaciones_destinatarios",
"valor_json": '["admin@adan-fleet.com"]',
"categoria": "notificaciones",
"descripcion": "Lista de emails para notificaciones críticas",
"tipo_dato": "array",
"sensible": True,
},
# Mapas
{
"clave": "mapa_centro_lat",
"valor_json": "19.4326",
"categoria": "mapas",
"descripcion": "Latitud del centro del mapa por defecto",
"tipo_dato": "number",
},
{
"clave": "mapa_centro_lng",
"valor_json": "-99.1332",
"categoria": "mapas",
"descripcion": "Longitud del centro del mapa por defecto",
"tipo_dato": "number",
},
{
"clave": "mapa_zoom_default",
"valor_json": "12",
"categoria": "mapas",
"descripcion": "Nivel de zoom inicial del mapa",
"tipo_dato": "number",
},
# Retención de datos
{
"clave": "retencion_ubicaciones_dias",
"valor_json": "365",
"categoria": "retencion",
"descripcion": "Días de retención de ubicaciones GPS",
"tipo_dato": "number",
},
{
"clave": "retencion_alertas_dias",
"valor_json": "180",
"categoria": "retencion",
"descripcion": "Días de retención de alertas",
"tipo_dato": "number",
},
{
"clave": "retencion_videos_dias",
"valor_json": "30",
"categoria": "retencion",
"descripcion": "Días de retención de videos",
"tipo_dato": "number",
},
# General
{
"clave": "empresa_nombre",
"valor_json": '"Adan Fleet"',
"categoria": "general",
"descripcion": "Nombre de la empresa",
"tipo_dato": "string",
},
{
"clave": "empresa_logo_url",
"valor_json": '""',
"categoria": "general",
"descripcion": "URL del logo de la empresa",
"tipo_dato": "string",
},
{
"clave": "zona_horaria",
"valor_json": '"America/Mexico_City"',
"categoria": "general",
"descripcion": "Zona horaria del sistema",
"tipo_dato": "string",
},
]

View File

@@ -0,0 +1,111 @@
"""
Modelo de Dispositivo GPS/Tracker para vehículos.
"""
from datetime import datetime
from sqlalchemy import (
Boolean,
DateTime,
Float,
ForeignKey,
Integer,
String,
Text,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
from app.models.base import TimestampMixin
class Dispositivo(Base, TimestampMixin):
"""Modelo de dispositivo GPS/tracker instalado en un vehículo."""
__tablename__ = "dispositivos"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
# Relación con vehículo
vehiculo_id: Mapped[int] = mapped_column(
ForeignKey("vehiculos.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
# Tipo de dispositivo
tipo: Mapped[str] = mapped_column(
String(50),
nullable=False,
default="gps",
) # gps, obd, meshtastic, smartphone
# Identificación
identificador: Mapped[str] = mapped_column(
String(100),
unique=True,
nullable=False,
index=True,
) # ID único del dispositivo
nombre: Mapped[str | None] = mapped_column(String(100), nullable=True)
marca: Mapped[str | None] = mapped_column(String(50), nullable=True)
modelo: Mapped[str | None] = mapped_column(String(50), nullable=True)
numero_serie: Mapped[str | None] = mapped_column(String(100), nullable=True)
# Información de SIM
telefono_sim: Mapped[str | None] = mapped_column(String(20), nullable=True)
operador_sim: Mapped[str | None] = mapped_column(String(50), nullable=True)
iccid: Mapped[str | None] = mapped_column(String(25), nullable=True) # ID de la SIM
# IMEI (para dispositivos celulares)
imei: Mapped[str | None] = mapped_column(String(20), nullable=True, unique=True)
# Protocolo de comunicación
protocolo: Mapped[str] = mapped_column(
String(50),
default="osmand",
nullable=False,
) # osmand, traccar, gt06, meshtastic, mqtt
# Estado de conexión
ultimo_contacto: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
bateria: Mapped[float | None] = mapped_column(Float, nullable=True) # Porcentaje 0-100
señal_gsm: Mapped[int | None] = mapped_column(Integer, nullable=True) # Nivel de señal
satelites: Mapped[int | None] = mapped_column(Integer, nullable=True) # Satélites GPS
# Configuración del dispositivo
intervalo_reporte: Mapped[int] = mapped_column(
Integer,
default=30,
nullable=False,
) # Segundos entre reportes
configuracion: Mapped[str | None] = mapped_column(Text, nullable=True) # JSON con config adicional
# Firmware
firmware_version: Mapped[str | None] = mapped_column(String(50), nullable=True)
# Estado
activo: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
conectado: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
# Notas
notas: Mapped[str | None] = mapped_column(Text, nullable=True)
# Relaciones ORM
vehiculo: Mapped["Vehiculo"] = relationship(
"Vehiculo",
back_populates="dispositivos",
lazy="selectin",
)
@property
def esta_online(self) -> bool:
"""Verifica si el dispositivo está online (último contacto < 5 minutos)."""
if not self.ultimo_contacto:
return False
from datetime import timezone, timedelta
tiempo_limite = datetime.now(timezone.utc) - timedelta(minutes=5)
return self.ultimo_contacto > tiempo_limite
def __repr__(self) -> str:
return f"<Dispositivo(id={self.id}, identificador='{self.identificador}', tipo='{self.tipo}')>"

View File

@@ -0,0 +1,156 @@
"""
Modelo de Evento de Video para registrar eventos detectados por cámaras.
"""
from datetime import datetime
from sqlalchemy import (
Boolean,
DateTime,
Float,
ForeignKey,
Index,
String,
Text,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
from app.models.base import TimestampMixin
class EventoVideo(Base, TimestampMixin):
"""Modelo para eventos detectados por cámaras (AI/ADAS)."""
__tablename__ = "eventos_video"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
# Relaciones
camara_id: Mapped[int] = mapped_column(
ForeignKey("camaras.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
vehiculo_id: Mapped[int] = mapped_column(
ForeignKey("vehiculos.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
# Tipo de evento
tipo: Mapped[str] = mapped_column(
String(50),
nullable=False,
) # colision, distraccion, fatiga, cambio_carril, exceso_velocidad, objeto_detectado
# Severidad
severidad: Mapped[str] = mapped_column(
String(20),
default="media",
nullable=False,
) # baja, media, alta, critica
# Tiempo del evento
tiempo: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
# Ubicación
lat: Mapped[float | None] = mapped_column(Float, nullable=True)
lng: Mapped[float | None] = mapped_column(Float, nullable=True)
velocidad: Mapped[float | None] = mapped_column(Float, nullable=True)
# Descripción
descripcion: Mapped[str | None] = mapped_column(Text, nullable=True)
# Confianza de la detección (si es detección AI)
confianza: Mapped[float | None] = mapped_column(Float, nullable=True) # 0-100%
# Datos adicionales (JSON)
datos_extra: Mapped[str | None] = mapped_column(Text, nullable=True)
# Estado de revisión
revisado: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
revisado_por_id: Mapped[int | None] = mapped_column(
ForeignKey("usuarios.id", ondelete="SET NULL"),
nullable=True,
)
revisado_en: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
notas_revision: Mapped[str | None] = mapped_column(Text, nullable=True)
# Falso positivo
falso_positivo: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
# Snapshot del momento
snapshot_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
# Clip de video asociado
clip_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
clip_duracion: Mapped[int | None] = mapped_column(default=None, nullable=True) # segundos
# Relaciones ORM
camara: Mapped["Camara"] = relationship(
"Camara",
back_populates="eventos_video",
lazy="selectin",
)
# Índices
__table_args__ = (
Index("idx_eventos_video_camara_tiempo", "camara_id", "tiempo"),
Index("idx_eventos_video_vehiculo_tiempo", "vehiculo_id", "tiempo"),
Index("idx_eventos_video_tipo", "tipo"),
Index("idx_eventos_video_revisado", "revisado"),
)
def __repr__(self) -> str:
return f"<EventoVideo(id={self.id}, tipo='{self.tipo}', severidad='{self.severidad}')>"
# Tipos de eventos de video predefinidos
TIPOS_EVENTO_VIDEO = [
{
"codigo": "COLISION_FRONTAL",
"nombre": "Posible colisión frontal",
"severidad": "critica",
},
{
"codigo": "DISTRACCION_CONDUCTOR",
"nombre": "Distracción del conductor",
"severidad": "alta",
},
{
"codigo": "FATIGA_CONDUCTOR",
"nombre": "Fatiga del conductor",
"severidad": "alta",
},
{
"codigo": "CAMBIO_CARRIL_PELIGROSO",
"nombre": "Cambio de carril peligroso",
"severidad": "media",
},
{
"codigo": "SEGUIMIENTO_CERCANO",
"nombre": "Seguimiento muy cercano",
"severidad": "media",
},
{
"codigo": "PEATON_DETECTADO",
"nombre": "Peatón detectado",
"severidad": "media",
},
{
"codigo": "USO_CELULAR",
"nombre": "Uso de celular",
"severidad": "alta",
},
{
"codigo": "SIN_CINTURON",
"nombre": "Sin cinturón de seguridad",
"severidad": "media",
},
{
"codigo": "FUMANDO",
"nombre": "Conductor fumando",
"severidad": "baja",
},
]

View File

@@ -0,0 +1,143 @@
"""
Modelo de Geocerca para delimitar zonas geográficas.
"""
from sqlalchemy import (
Boolean,
Float,
Index,
Integer,
String,
Text,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
from app.models.base import TimestampMixin
# Tabla de asociación para geocercas y vehículos
from sqlalchemy import Table, Column, ForeignKey
geocerca_vehiculo = Table(
"geocerca_vehiculo",
Base.metadata,
Column("geocerca_id", Integer, ForeignKey("geocercas.id", ondelete="CASCADE"), primary_key=True),
Column("vehiculo_id", Integer, ForeignKey("vehiculos.id", ondelete="CASCADE"), primary_key=True),
)
class Geocerca(Base, TimestampMixin):
"""
Modelo de geocerca (zona geográfica delimitada).
Soporta dos tipos de geometría:
- circular: definida por un punto central y radio
- poligono: definida por una lista de coordenadas
"""
__tablename__ = "geocercas"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
# Identificación
nombre: Mapped[str] = mapped_column(String(100), nullable=False)
descripcion: Mapped[str | None] = mapped_column(Text, nullable=True)
# Tipo de geometría
tipo: Mapped[str] = mapped_column(
String(20),
default="circular",
nullable=False,
) # circular, poligono
# Para geocercas circulares
centro_lat: Mapped[float | None] = mapped_column(Float, nullable=True)
centro_lng: Mapped[float | None] = mapped_column(Float, nullable=True)
radio_metros: Mapped[float | None] = mapped_column(Float, nullable=True)
# Para geocercas poligonales (JSON array de coordenadas)
# Formato: [[lat1, lng1], [lat2, lng2], ...]
coordenadas_json: Mapped[str | None] = mapped_column(Text, nullable=True)
# Visualización
color: Mapped[str] = mapped_column(String(7), default="#3B82F6", nullable=False)
opacidad: Mapped[float] = mapped_column(Float, default=0.3, nullable=False)
color_borde: Mapped[str] = mapped_column(String(7), default="#1D4ED8", nullable=False)
# Configuración de alertas
alerta_entrada: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
alerta_salida: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
velocidad_maxima: Mapped[float | None] = mapped_column(Float, nullable=True) # km/h dentro de la geocerca
# Horario de activación (opcional)
# Formato JSON: {"dias": [1,2,3,4,5], "hora_inicio": "08:00", "hora_fin": "18:00"}
horario_json: Mapped[str | None] = mapped_column(Text, nullable=True)
# Categoría
categoria: Mapped[str | None] = mapped_column(String(50), nullable=True) # oficina, cliente, zona_riesgo, etc.
# Estado
activa: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
# Vehículos asignados (many-to-many)
# Si está vacío, aplica a todos los vehículos
vehiculos_asignados: Mapped[list["Vehiculo"]] = relationship(
"Vehiculo",
secondary=geocerca_vehiculo,
lazy="selectin",
)
# Índices
__table_args__ = (
Index("idx_geocercas_activa", "activa"),
Index("idx_geocercas_tipo", "tipo"),
)
@property
def aplica_todos_vehiculos(self) -> bool:
"""Verifica si la geocerca aplica a todos los vehículos."""
return len(self.vehiculos_asignados) == 0
def to_geojson(self) -> dict:
"""Convierte la geocerca a formato GeoJSON."""
import json
if self.tipo == "circular":
return {
"type": "Feature",
"geometry": {
"type": "Point",
"coordinates": [self.centro_lng, self.centro_lat],
},
"properties": {
"id": self.id,
"nombre": self.nombre,
"tipo": self.tipo,
"radio_metros": self.radio_metros,
"color": self.color,
},
}
else:
coords = json.loads(self.coordenadas_json) if self.coordenadas_json else []
# GeoJSON usa [lng, lat], no [lat, lng]
coords_geojson = [[c[1], c[0]] for c in coords]
# Cerrar el polígono si no está cerrado
if coords_geojson and coords_geojson[0] != coords_geojson[-1]:
coords_geojson.append(coords_geojson[0])
return {
"type": "Feature",
"geometry": {
"type": "Polygon",
"coordinates": [coords_geojson],
},
"properties": {
"id": self.id,
"nombre": self.nombre,
"tipo": self.tipo,
"color": self.color,
},
}
def __repr__(self) -> str:
return f"<Geocerca(id={self.id}, nombre='{self.nombre}', tipo='{self.tipo}')>"

View File

@@ -0,0 +1,109 @@
"""
Modelo de Grabación para almacenar videos de cámaras.
"""
from datetime import datetime
from sqlalchemy import (
DateTime,
Float,
ForeignKey,
Index,
Integer,
String,
Text,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
from app.models.base import TimestampMixin
class Grabacion(Base, TimestampMixin):
"""Modelo para almacenar grabaciones de video."""
__tablename__ = "grabaciones"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
# Relaciones
camara_id: Mapped[int] = mapped_column(
ForeignKey("camaras.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
vehiculo_id: Mapped[int] = mapped_column(
ForeignKey("vehiculos.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
# Tiempo
inicio_tiempo: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
fin_tiempo: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
duracion_segundos: Mapped[int | None] = mapped_column(Integer, nullable=True)
# Archivo
archivo_url: Mapped[str] = mapped_column(String(500), nullable=False)
archivo_nombre: Mapped[str] = mapped_column(String(255), nullable=False)
tamaño_mb: Mapped[float | None] = mapped_column(Float, nullable=True)
formato: Mapped[str] = mapped_column(String(10), default="mp4", nullable=False) # mp4, webm, mkv
resolucion: Mapped[str | None] = mapped_column(String(20), nullable=True)
# Tipo de grabación
tipo: Mapped[str] = mapped_column(
String(50),
default="continua",
nullable=False,
) # continua, evento, manual, snapshot
# Evento asociado (si es grabación por evento)
evento_video_id: Mapped[int | None] = mapped_column(
ForeignKey("eventos_video.id", ondelete="SET NULL"),
nullable=True,
)
# Ubicación al inicio de la grabación
lat: Mapped[float | None] = mapped_column(Float, nullable=True)
lng: Mapped[float | None] = mapped_column(Float, nullable=True)
# Estado
estado: Mapped[str] = mapped_column(
String(20),
default="disponible",
nullable=False,
) # grabando, procesando, disponible, error, eliminado
# Thumbnail
thumbnail_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
# Notas
notas: Mapped[str | None] = mapped_column(Text, nullable=True)
# Relaciones ORM
camara: Mapped["Camara"] = relationship(
"Camara",
back_populates="grabaciones",
lazy="selectin",
)
# Índices
__table_args__ = (
Index("idx_grabaciones_camara_inicio", "camara_id", "inicio_tiempo"),
Index("idx_grabaciones_vehiculo_inicio", "vehiculo_id", "inicio_tiempo"),
Index("idx_grabaciones_tipo", "tipo"),
)
@property
def duracion_formateada(self) -> str:
"""Retorna la duración en formato legible."""
if not self.duracion_segundos:
return "N/A"
minutos = self.duracion_segundos // 60
segundos = self.duracion_segundos % 60
if minutos > 0:
return f"{minutos}m {segundos}s"
return f"{segundos}s"
def __repr__(self) -> str:
return f"<Grabacion(id={self.id}, camara_id={self.camara_id}, tipo='{self.tipo}')>"

View File

@@ -0,0 +1,31 @@
"""
Modelo de Grupo de Vehículos para organizar la flota.
"""
from sqlalchemy import String, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
from app.models.base import TimestampMixin
class GrupoVehiculos(Base, TimestampMixin):
"""Modelo para agrupar vehículos (ej: Reparto Norte, Ejecutivos, etc.)."""
__tablename__ = "grupos_vehiculos"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
nombre: Mapped[str] = mapped_column(String(100), nullable=False, unique=True)
descripcion: Mapped[str | None] = mapped_column(Text, nullable=True)
color: Mapped[str] = mapped_column(String(7), default="#3B82F6", nullable=False) # Hex color
icono: Mapped[str | None] = mapped_column(String(50), nullable=True) # Nombre del icono
# Relaciones
vehiculos: Mapped[list["Vehiculo"]] = relationship(
"Vehiculo",
back_populates="grupo",
lazy="selectin",
)
def __repr__(self) -> str:
return f"<GrupoVehiculos(id={self.id}, nombre='{self.nombre}')>"

View File

@@ -0,0 +1,127 @@
"""
Modelo de Mantenimiento para registrar servicios de mantenimiento.
"""
from datetime import date, datetime
from sqlalchemy import (
Boolean,
Date,
DateTime,
Float,
ForeignKey,
Index,
String,
Text,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
from app.models.base import TimestampMixin
class Mantenimiento(Base, TimestampMixin):
"""Modelo para registrar mantenimientos de vehículos."""
__tablename__ = "mantenimientos"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
# Relaciones
vehiculo_id: Mapped[int] = mapped_column(
ForeignKey("vehiculos.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
tipo_mantenimiento_id: Mapped[int] = mapped_column(
ForeignKey("tipos_mantenimiento.id", ondelete="RESTRICT"),
nullable=False,
index=True,
)
# Estado
estado: Mapped[str] = mapped_column(
String(20),
default="programado",
nullable=False,
) # programado, en_proceso, completado, cancelado, vencido
# Fechas
fecha_programada: Mapped[date] = mapped_column(Date, nullable=False)
fecha_realizada: Mapped[date | None] = mapped_column(Date, nullable=True)
# Odómetro
odometro_programado: Mapped[float | None] = mapped_column(Float, nullable=True)
odometro_realizado: Mapped[float | None] = mapped_column(Float, nullable=True)
# Costos
costo_estimado: Mapped[float | None] = mapped_column(Float, nullable=True)
costo_real: Mapped[float | None] = mapped_column(Float, nullable=True)
costo_mano_obra: Mapped[float | None] = mapped_column(Float, nullable=True)
costo_refacciones: Mapped[float | None] = mapped_column(Float, nullable=True)
# Proveedor
proveedor: Mapped[str | None] = mapped_column(String(100), nullable=True)
proveedor_direccion: Mapped[str | None] = mapped_column(String(255), nullable=True)
proveedor_telefono: Mapped[str | None] = mapped_column(String(20), nullable=True)
# Documentación
numero_factura: Mapped[str | None] = mapped_column(String(50), nullable=True)
numero_orden: Mapped[str | None] = mapped_column(String(50), nullable=True)
# Detalles
descripcion: Mapped[str | None] = mapped_column(Text, nullable=True)
trabajos_realizados: Mapped[str | None] = mapped_column(Text, nullable=True)
refacciones_usadas: Mapped[str | None] = mapped_column(Text, nullable=True) # JSON array
# Notas
notas: Mapped[str | None] = mapped_column(Text, nullable=True)
# Técnico responsable
tecnico: Mapped[str | None] = mapped_column(String(100), nullable=True)
# Próximo mantenimiento (para calcular el siguiente)
proximo_km: Mapped[float | None] = mapped_column(Float, nullable=True)
proxima_fecha: Mapped[date | None] = mapped_column(Date, nullable=True)
# Archivos adjuntos (JSON array de URLs)
archivos_adjuntos: Mapped[str | None] = mapped_column(Text, nullable=True)
# Recordatorios enviados
recordatorio_enviado: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
# Relaciones ORM
vehiculo: Mapped["Vehiculo"] = relationship(
"Vehiculo",
back_populates="mantenimientos",
lazy="selectin",
)
tipo_mantenimiento: Mapped["TipoMantenimiento"] = relationship(
"TipoMantenimiento",
back_populates="mantenimientos",
lazy="selectin",
)
# Índices
__table_args__ = (
Index("idx_mantenimientos_vehiculo_fecha", "vehiculo_id", "fecha_programada"),
Index("idx_mantenimientos_estado", "estado"),
Index("idx_mantenimientos_fecha_prog", "fecha_programada"),
)
@property
def esta_vencido(self) -> bool:
"""Verifica si el mantenimiento está vencido."""
if self.estado in ["completado", "cancelado"]:
return False
return self.fecha_programada < date.today()
@property
def dias_para_vencimiento(self) -> int | None:
"""Calcula los días restantes para el vencimiento."""
if self.estado in ["completado", "cancelado"]:
return None
return (self.fecha_programada - date.today()).days
def __repr__(self) -> str:
return f"<Mantenimiento(id={self.id}, vehiculo_id={self.vehiculo_id}, estado='{self.estado}')>"

View File

@@ -0,0 +1,94 @@
"""
Modelo de Mensaje para comunicación con conductores.
"""
from datetime import datetime
from sqlalchemy import (
Boolean,
DateTime,
ForeignKey,
Index,
String,
Text,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
from app.models.base import TimestampMixin
class Mensaje(Base, TimestampMixin):
"""Modelo para mensajes entre administradores y conductores."""
__tablename__ = "mensajes"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
# Conductor asociado
conductor_id: Mapped[int] = mapped_column(
ForeignKey("conductores.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
# Dirección del mensaje
de_admin: Mapped[bool] = mapped_column(Boolean, nullable=False) # True = admin->conductor, False = conductor->admin
# Usuario admin que envió/recibió (si aplica)
usuario_id: Mapped[int | None] = mapped_column(
ForeignKey("usuarios.id", ondelete="SET NULL"),
nullable=True,
)
# Contenido
asunto: Mapped[str | None] = mapped_column(String(200), nullable=True)
contenido: Mapped[str] = mapped_column(Text, nullable=False)
# Tipo de mensaje
tipo: Mapped[str] = mapped_column(
String(20),
default="texto",
nullable=False,
) # texto, alerta, instruccion, emergencia
# Prioridad
prioridad: Mapped[str] = mapped_column(
String(20),
default="normal",
nullable=False,
) # baja, normal, alta, urgente
# Estado de lectura
leido: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
leido_en: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
# Archivos adjuntos (JSON array de URLs)
adjuntos: Mapped[str | None] = mapped_column(Text, nullable=True)
# Respuesta a otro mensaje
respuesta_a_id: Mapped[int | None] = mapped_column(
ForeignKey("mensajes.id", ondelete="SET NULL"),
nullable=True,
)
# Mensaje eliminado (soft delete)
eliminado_por_admin: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
eliminado_por_conductor: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
# Relaciones ORM
conductor: Mapped["Conductor"] = relationship(
"Conductor",
back_populates="mensajes",
lazy="selectin",
)
# Índices
__table_args__ = (
Index("idx_mensajes_conductor_creado", "conductor_id", "creado_en"),
Index("idx_mensajes_leido", "leido"),
)
def __repr__(self) -> str:
direccion = "admin->conductor" if self.de_admin else "conductor->admin"
return f"<Mensaje(id={self.id}, {direccion}, conductor_id={self.conductor_id})>"

View File

@@ -0,0 +1,111 @@
"""
Modelo de Parada para registrar detenciones durante viajes.
"""
from datetime import datetime
from sqlalchemy import (
Boolean,
DateTime,
Float,
ForeignKey,
Index,
Integer,
String,
Text,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
class Parada(Base):
"""
Modelo de parada/detención de un vehículo durante un viaje.
Se registra cuando el vehículo permanece detenido por más
de un tiempo mínimo configurado (ej: 2 minutos).
"""
__tablename__ = "paradas"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
# Relaciones
viaje_id: Mapped[int | None] = mapped_column(
ForeignKey("viajes.id", ondelete="CASCADE"),
nullable=True,
index=True,
)
vehiculo_id: Mapped[int] = mapped_column(
ForeignKey("vehiculos.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
# Tiempo
inicio_tiempo: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
fin_tiempo: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
duracion_segundos: Mapped[int | None] = mapped_column(Integer, nullable=True)
# Ubicación
lat: Mapped[float] = mapped_column(Float, nullable=False)
lng: Mapped[float] = mapped_column(Float, nullable=False)
direccion: Mapped[str | None] = mapped_column(String(255), nullable=True)
# Clasificación
tipo: Mapped[str] = mapped_column(
String(50),
default="desconocido",
nullable=False,
) # desconocido, entrega, carga, descanso, trafico, cliente, otro
# Estado del vehículo durante la parada
motor_apagado: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
# POI asociado (si aplica)
poi_id: Mapped[int | None] = mapped_column(
ForeignKey("pois.id", ondelete="SET NULL"),
nullable=True,
)
# Geocerca asociada (si aplica)
geocerca_id: Mapped[int | None] = mapped_column(
ForeignKey("geocercas.id", ondelete="SET NULL"),
nullable=True,
)
# Estado (para paradas en curso)
en_curso: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
# Notas
notas: Mapped[str | None] = mapped_column(Text, nullable=True)
# Relaciones ORM
viaje: Mapped["Viaje | None"] = relationship(
"Viaje",
back_populates="paradas",
lazy="selectin",
)
# Índices
__table_args__ = (
Index("idx_paradas_vehiculo_inicio", "vehiculo_id", "inicio_tiempo"),
Index("idx_paradas_en_curso", "en_curso"),
)
@property
def duracion_formateada(self) -> str:
"""Retorna la duración en formato legible."""
if not self.duracion_segundos:
if self.en_curso:
return "En curso"
return "N/A"
horas = self.duracion_segundos // 3600
minutos = (self.duracion_segundos % 3600) // 60
if horas > 0:
return f"{horas}h {minutos}m"
return f"{minutos}m"
def __repr__(self) -> str:
return f"<Parada(id={self.id}, vehiculo_id={self.vehiculo_id}, tipo='{self.tipo}')>"

108
backend/app/models/poi.py Normal file
View File

@@ -0,0 +1,108 @@
"""
Modelo de POI (Punto de Interés) para marcar ubicaciones importantes.
"""
from sqlalchemy import Boolean, Float, Index, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
from app.models.base import TimestampMixin
class POI(Base, TimestampMixin):
"""
Modelo de Punto de Interés.
Representa ubicaciones importantes como clientes, proveedores,
estaciones de servicio, talleres, etc.
"""
__tablename__ = "pois"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
# Identificación
nombre: Mapped[str] = mapped_column(String(100), nullable=False)
descripcion: Mapped[str | None] = mapped_column(Text, nullable=True)
# Categoría
categoria: Mapped[str] = mapped_column(
String(50),
default="otro",
nullable=False,
) # cliente, proveedor, gasolinera, taller, oficina, almacen, otro
# Ubicación
lat: Mapped[float] = mapped_column(Float, nullable=False)
lng: Mapped[float] = mapped_column(Float, nullable=False)
direccion: Mapped[str | None] = mapped_column(String(255), nullable=True)
ciudad: Mapped[str | None] = mapped_column(String(100), nullable=True)
estado: Mapped[str | None] = mapped_column(String(100), nullable=True)
codigo_postal: Mapped[str | None] = mapped_column(String(10), nullable=True)
# Radio de proximidad (para detectar llegadas)
radio_metros: Mapped[float] = mapped_column(Float, default=100.0, nullable=False)
# Contacto
telefono: Mapped[str | None] = mapped_column(String(20), nullable=True)
email: Mapped[str | None] = mapped_column(String(255), nullable=True)
contacto_nombre: Mapped[str | None] = mapped_column(String(100), nullable=True)
# Horario (JSON)
# Formato: {"lunes": {"apertura": "09:00", "cierre": "18:00"}, ...}
horario_json: Mapped[str | None] = mapped_column(Text, nullable=True)
# Visualización
icono: Mapped[str | None] = mapped_column(String(50), nullable=True)
color: Mapped[str] = mapped_column(String(7), default="#10B981", nullable=False)
# Código externo (para integración con otros sistemas)
codigo_externo: Mapped[str | None] = mapped_column(String(50), nullable=True, index=True)
# Estado
activo: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
# Notas
notas: Mapped[str | None] = mapped_column(Text, nullable=True)
# Índices
__table_args__ = (
Index("idx_pois_coords", "lat", "lng"),
Index("idx_pois_categoria", "categoria"),
Index("idx_pois_activo", "activo"),
)
def to_geojson(self) -> dict:
"""Convierte el POI a formato GeoJSON."""
return {
"type": "Feature",
"geometry": {
"type": "Point",
"coordinates": [self.lng, self.lat],
},
"properties": {
"id": self.id,
"nombre": self.nombre,
"categoria": self.categoria,
"direccion": self.direccion,
"telefono": self.telefono,
"icono": self.icono,
"color": self.color,
},
}
def __repr__(self) -> str:
return f"<POI(id={self.id}, nombre='{self.nombre}', categoria='{self.categoria}')>"
# Categorías predefinidas de POIs
CATEGORIAS_POI = [
{"codigo": "cliente", "nombre": "Cliente", "icono": "building", "color": "#3B82F6"},
{"codigo": "proveedor", "nombre": "Proveedor", "icono": "truck", "color": "#8B5CF6"},
{"codigo": "gasolinera", "nombre": "Gasolinera", "icono": "fuel", "color": "#F59E0B"},
{"codigo": "taller", "nombre": "Taller", "icono": "wrench", "color": "#6B7280"},
{"codigo": "oficina", "nombre": "Oficina", "icono": "briefcase", "color": "#10B981"},
{"codigo": "almacen", "nombre": "Almacén", "icono": "warehouse", "color": "#EC4899"},
{"codigo": "estacionamiento", "nombre": "Estacionamiento", "icono": "parking", "color": "#06B6D4"},
{"codigo": "otro", "nombre": "Otro", "icono": "map-pin", "color": "#6B7280"},
]

View File

@@ -0,0 +1,192 @@
"""
Modelo de Tipo de Alerta para definir categorías de alertas.
"""
from sqlalchemy import Boolean, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
from app.models.base import TimestampMixin
class TipoAlerta(Base, TimestampMixin):
"""Modelo para definir tipos/categorías de alertas del sistema."""
__tablename__ = "tipos_alerta"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
# Identificación
codigo: Mapped[str] = mapped_column(String(50), unique=True, nullable=False, index=True)
nombre: Mapped[str] = mapped_column(String(100), nullable=False)
descripcion: Mapped[str | None] = mapped_column(Text, nullable=True)
# Configuración
severidad_default: Mapped[str] = mapped_column(
String(20),
default="media",
nullable=False,
) # baja, media, alta, critica
icono: Mapped[str | None] = mapped_column(String(50), nullable=True)
color: Mapped[str] = mapped_column(String(7), default="#EF4444", nullable=False)
# Notificaciones
notificar_email: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
notificar_push: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
notificar_sms: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
# Estado
activo: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
# Prioridad para ordenamiento
prioridad: Mapped[int] = mapped_column(Integer, default=50, nullable=False)
# Relaciones ORM
alertas: Mapped[list["Alerta"]] = relationship(
"Alerta",
back_populates="tipo_alerta",
lazy="dynamic",
)
def __repr__(self) -> str:
return f"<TipoAlerta(id={self.id}, codigo='{self.codigo}', nombre='{self.nombre}')>"
# Tipos de alerta predefinidos
TIPOS_ALERTA_DEFAULT = [
{
"codigo": "EXCESO_VELOCIDAD",
"nombre": "Exceso de velocidad",
"descripcion": "El vehículo superó el límite de velocidad configurado",
"severidad_default": "media",
"icono": "speed",
"color": "#F59E0B",
"prioridad": 40,
},
{
"codigo": "ENTRADA_GEOCERCA",
"nombre": "Entrada a geocerca",
"descripcion": "El vehículo entró a una zona delimitada",
"severidad_default": "baja",
"icono": "map-pin",
"color": "#10B981",
"prioridad": 60,
},
{
"codigo": "SALIDA_GEOCERCA",
"nombre": "Salida de geocerca",
"descripcion": "El vehículo salió de una zona delimitada",
"severidad_default": "baja",
"icono": "map-pin-off",
"color": "#F59E0B",
"prioridad": 60,
},
{
"codigo": "PARADA_PROLONGADA",
"nombre": "Parada prolongada",
"descripcion": "El vehículo ha permanecido detenido por tiempo excesivo",
"severidad_default": "baja",
"icono": "clock",
"color": "#6B7280",
"prioridad": 70,
},
{
"codigo": "BATERIA_BAJA",
"nombre": "Batería baja",
"descripcion": "El dispositivo GPS tiene batería baja",
"severidad_default": "media",
"icono": "battery-low",
"color": "#EF4444",
"prioridad": 30,
},
{
"codigo": "SIN_SEÑAL",
"nombre": "Sin señal GPS",
"descripcion": "El vehículo no ha reportado ubicación en el tiempo configurado",
"severidad_default": "alta",
"icono": "signal-off",
"color": "#EF4444",
"prioridad": 20,
},
{
"codigo": "MOTOR_ENCENDIDO_PROLONGADO",
"nombre": "Motor encendido sin movimiento",
"descripcion": "El vehículo tiene el motor encendido pero no se mueve",
"severidad_default": "baja",
"icono": "engine",
"color": "#F59E0B",
"prioridad": 80,
},
{
"codigo": "MANTENIMIENTO_PROXIMO",
"nombre": "Mantenimiento próximo",
"descripcion": "El vehículo tiene un mantenimiento programado próximamente",
"severidad_default": "baja",
"icono": "wrench",
"color": "#3B82F6",
"prioridad": 90,
},
{
"codigo": "MANTENIMIENTO_VENCIDO",
"nombre": "Mantenimiento vencido",
"descripcion": "El vehículo tiene un mantenimiento vencido",
"severidad_default": "alta",
"icono": "alert-triangle",
"color": "#EF4444",
"prioridad": 10,
},
{
"codigo": "ACELERACION_BRUSCA",
"nombre": "Aceleración brusca",
"descripcion": "Se detectó una aceleración brusca",
"severidad_default": "media",
"icono": "trending-up",
"color": "#F59E0B",
"prioridad": 50,
},
{
"codigo": "FRENADO_BRUSCO",
"nombre": "Frenado brusco",
"descripcion": "Se detectó un frenado brusco",
"severidad_default": "media",
"icono": "trending-down",
"color": "#F59E0B",
"prioridad": 50,
},
{
"codigo": "COLISION",
"nombre": "Posible colisión",
"descripcion": "Se detectó un impacto que podría indicar una colisión",
"severidad_default": "critica",
"icono": "alert-octagon",
"color": "#DC2626",
"prioridad": 1,
},
{
"codigo": "BOTON_PANICO",
"nombre": "Botón de pánico",
"descripcion": "El conductor presionó el botón de pánico",
"severidad_default": "critica",
"icono": "alert-circle",
"color": "#DC2626",
"prioridad": 1,
},
{
"codigo": "FUERA_HORARIO",
"nombre": "Uso fuera de horario",
"descripcion": "El vehículo está en uso fuera del horario permitido",
"severidad_default": "media",
"icono": "calendar-x",
"color": "#F59E0B",
"prioridad": 45,
},
{
"codigo": "COMBUSTIBLE_BAJO",
"nombre": "Combustible bajo",
"descripcion": "El nivel de combustible es bajo",
"severidad_default": "media",
"icono": "fuel",
"color": "#F59E0B",
"prioridad": 55,
},
]

View File

@@ -0,0 +1,175 @@
"""
Modelo de Tipo de Mantenimiento para definir categorías de mantenimiento.
"""
from sqlalchemy import Boolean, Float, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
from app.models.base import TimestampMixin
class TipoMantenimiento(Base, TimestampMixin):
"""Modelo para definir tipos de mantenimiento de vehículos."""
__tablename__ = "tipos_mantenimiento"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
# Identificación
nombre: Mapped[str] = mapped_column(String(100), nullable=False, unique=True)
descripcion: Mapped[str | None] = mapped_column(Text, nullable=True)
codigo: Mapped[str | None] = mapped_column(String(20), nullable=True, unique=True)
# Categoría
categoria: Mapped[str] = mapped_column(
String(50),
default="preventivo",
nullable=False,
) # preventivo, correctivo, predictivo
# Intervalos de mantenimiento
intervalo_km: Mapped[int | None] = mapped_column(Integer, nullable=True) # Cada X km
intervalo_dias: Mapped[int | None] = mapped_column(Integer, nullable=True) # Cada X días
# Costo estimado
costo_estimado: Mapped[float | None] = mapped_column(Float, nullable=True)
# Duración estimada (en horas)
duracion_estimada_horas: Mapped[float | None] = mapped_column(Float, nullable=True)
# Prioridad
prioridad: Mapped[int] = mapped_column(Integer, default=50, nullable=False) # 1 = más urgente
# Requiere inmovilización del vehículo
requiere_inmovilizacion: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
# Estado
activo: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
# Relaciones ORM
mantenimientos: Mapped[list["Mantenimiento"]] = relationship(
"Mantenimiento",
back_populates="tipo_mantenimiento",
lazy="dynamic",
)
def __repr__(self) -> str:
return f"<TipoMantenimiento(id={self.id}, nombre='{self.nombre}')>"
# Tipos de mantenimiento predefinidos
TIPOS_MANTENIMIENTO_DEFAULT = [
{
"nombre": "Cambio de aceite",
"codigo": "ACEITE",
"descripcion": "Cambio de aceite de motor y filtro",
"categoria": "preventivo",
"intervalo_km": 10000,
"intervalo_dias": 180,
"costo_estimado": 1500.0,
"duracion_estimada_horas": 0.5,
"prioridad": 30,
},
{
"nombre": "Cambio de filtros",
"codigo": "FILTROS",
"descripcion": "Cambio de filtros de aire, combustible y cabina",
"categoria": "preventivo",
"intervalo_km": 20000,
"intervalo_dias": 365,
"costo_estimado": 800.0,
"duracion_estimada_horas": 1.0,
"prioridad": 40,
},
{
"nombre": "Rotación de llantas",
"codigo": "ROTACION_LLANTAS",
"descripcion": "Rotación y balanceo de llantas",
"categoria": "preventivo",
"intervalo_km": 15000,
"intervalo_dias": None,
"costo_estimado": 500.0,
"duracion_estimada_horas": 0.5,
"prioridad": 50,
},
{
"nombre": "Cambio de llantas",
"codigo": "CAMBIO_LLANTAS",
"descripcion": "Reemplazo de llantas",
"categoria": "preventivo",
"intervalo_km": 60000,
"intervalo_dias": None,
"costo_estimado": 8000.0,
"duracion_estimada_horas": 1.5,
"prioridad": 35,
},
{
"nombre": "Revisión de frenos",
"codigo": "FRENOS",
"descripcion": "Inspección y ajuste del sistema de frenos",
"categoria": "preventivo",
"intervalo_km": 30000,
"intervalo_dias": 365,
"costo_estimado": 2000.0,
"duracion_estimada_horas": 2.0,
"prioridad": 20,
},
{
"nombre": "Cambio de banda de distribución",
"codigo": "BANDA_DIST",
"descripcion": "Reemplazo de banda o cadena de distribución",
"categoria": "preventivo",
"intervalo_km": 100000,
"intervalo_dias": None,
"costo_estimado": 5000.0,
"duracion_estimada_horas": 4.0,
"prioridad": 15,
"requiere_inmovilizacion": True,
},
{
"nombre": "Servicio mayor",
"codigo": "SERVICIO_MAYOR",
"descripcion": "Servicio de mantenimiento completo",
"categoria": "preventivo",
"intervalo_km": 50000,
"intervalo_dias": None,
"costo_estimado": 10000.0,
"duracion_estimada_horas": 8.0,
"prioridad": 25,
"requiere_inmovilizacion": True,
},
{
"nombre": "Afinación",
"codigo": "AFINACION",
"descripcion": "Afinación de motor",
"categoria": "preventivo",
"intervalo_km": 30000,
"intervalo_dias": 365,
"costo_estimado": 2500.0,
"duracion_estimada_horas": 2.0,
"prioridad": 35,
},
{
"nombre": "Verificación vehicular",
"codigo": "VERIFICACION",
"descripcion": "Verificación de emisiones",
"categoria": "preventivo",
"intervalo_km": None,
"intervalo_dias": 180,
"costo_estimado": 500.0,
"duracion_estimada_horas": 0.5,
"prioridad": 45,
},
{
"nombre": "Reparación general",
"codigo": "REPARACION",
"descripcion": "Reparación no programada",
"categoria": "correctivo",
"intervalo_km": None,
"intervalo_dias": None,
"costo_estimado": None,
"duracion_estimada_horas": None,
"prioridad": 10,
},
]

View File

@@ -0,0 +1,156 @@
"""
Modelo de Ubicación para almacenar datos GPS.
Utiliza TimescaleDB hypertable para almacenamiento eficiente
de series temporales de ubicaciones.
"""
from datetime import datetime
from sqlalchemy import (
Boolean,
DateTime,
Float,
ForeignKey,
Index,
Integer,
String,
event,
)
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
class Ubicacion(Base):
"""
Modelo de ubicación GPS.
Esta tabla está diseñada para ser una hypertable de TimescaleDB,
optimizada para almacenar millones de registros de ubicación.
"""
__tablename__ = "ubicaciones"
# Clave primaria compuesta: tiempo + vehiculo_id
tiempo: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
primary_key=True,
nullable=False,
)
vehiculo_id: Mapped[int] = mapped_column(
ForeignKey("vehiculos.id", ondelete="CASCADE"),
primary_key=True,
nullable=False,
)
# Coordenadas
lat: Mapped[float] = mapped_column(Float, nullable=False)
lng: Mapped[float] = mapped_column(Float, nullable=False)
altitud: Mapped[float | None] = mapped_column(Float, nullable=True) # metros sobre nivel del mar
# Movimiento
velocidad: Mapped[float | None] = mapped_column(Float, nullable=True) # km/h
rumbo: Mapped[float | None] = mapped_column(Float, nullable=True) # grados (0-360)
# Precisión
precision: Mapped[float | None] = mapped_column(Float, nullable=True) # metros
hdop: Mapped[float | None] = mapped_column(Float, nullable=True) # Horizontal Dilution of Precision
# Información GPS
satelites: Mapped[int | None] = mapped_column(Integer, nullable=True)
fuente: Mapped[str] = mapped_column(
String(20),
default="gps",
nullable=False,
) # gps, network, fused, meshtastic
# Estado del dispositivo
bateria_dispositivo: Mapped[float | None] = mapped_column(Float, nullable=True) # porcentaje
bateria_vehiculo: Mapped[float | None] = mapped_column(Float, nullable=True) # voltaje
# Estado del vehículo
motor_encendido: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
odometro: Mapped[float | None] = mapped_column(Float, nullable=True) # km
# Sensores OBD (opcional)
rpm: Mapped[int | None] = mapped_column(Integer, nullable=True)
temperatura_motor: Mapped[float | None] = mapped_column(Float, nullable=True) # Celsius
nivel_combustible: Mapped[float | None] = mapped_column(Float, nullable=True) # porcentaje
# Índices para consultas frecuentes
__table_args__ = (
# Índice espacial aproximado para consultas por área
Index("idx_ubicaciones_coords", "lat", "lng"),
# Índice para consultas por vehículo en un rango de tiempo
Index("idx_ubicaciones_vehiculo_tiempo", "vehiculo_id", "tiempo"),
# Índice para encontrar paradas (velocidad 0)
Index("idx_ubicaciones_velocidad", "velocidad"),
# Configuración para TimescaleDB
{
"timescaledb_hypertable": {
"time_column_name": "tiempo",
"chunk_time_interval": "1 day",
}
},
)
def __repr__(self) -> str:
return f"<Ubicacion(vehiculo_id={self.vehiculo_id}, tiempo={self.tiempo}, lat={self.lat}, lng={self.lng})>"
def to_geojson(self) -> dict:
"""Convierte la ubicación a formato GeoJSON Point."""
return {
"type": "Feature",
"geometry": {
"type": "Point",
"coordinates": [self.lng, self.lat],
},
"properties": {
"vehiculo_id": self.vehiculo_id,
"tiempo": self.tiempo.isoformat(),
"velocidad": self.velocidad,
"rumbo": self.rumbo,
"motor_encendido": self.motor_encendido,
},
}
# Función para crear la hypertable en TimescaleDB
# Se ejecuta después de crear la tabla
def create_hypertable(target, connection, **kw):
"""Crea la hypertable de TimescaleDB después de crear la tabla."""
# Esta función se ejecutará solo si TimescaleDB está instalado
try:
connection.execute(
"""
SELECT create_hypertable(
'ubicaciones',
'tiempo',
if_not_exists => TRUE,
chunk_time_interval => INTERVAL '1 day'
);
"""
)
# Habilitar compresión después de 7 días
connection.execute(
"""
ALTER TABLE ubicaciones SET (
timescaledb.compress,
timescaledb.compress_segmentby = 'vehiculo_id'
);
"""
)
# Política de compresión automática
connection.execute(
"""
SELECT add_compression_policy('ubicaciones', INTERVAL '7 days', if_not_exists => TRUE);
"""
)
except Exception:
# Si TimescaleDB no está instalado, continuar sin hypertable
pass
# Registrar evento para crear hypertable
event.listen(Ubicacion.__table__, "after_create", create_hypertable)

View File

@@ -0,0 +1,34 @@
"""
Modelo de Usuario para autenticación y autorización.
"""
from datetime import datetime
from sqlalchemy import Boolean, DateTime, String, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
from app.models.base import TimestampMixin
class Usuario(Base, TimestampMixin):
"""Modelo de usuario del sistema."""
__tablename__ = "usuarios"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True)
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
nombre: Mapped[str] = mapped_column(String(100), nullable=False)
apellido: Mapped[str | None] = mapped_column(String(100), nullable=True)
telefono: Mapped[str | None] = mapped_column(String(20), nullable=True)
avatar_url: Mapped[str | None] = mapped_column(Text, nullable=True)
es_admin: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
activo: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
ultimo_acceso: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
# Configuraciones del usuario en JSON
preferencias: Mapped[str | None] = mapped_column(Text, nullable=True) # JSON string
def __repr__(self) -> str:
return f"<Usuario(id={self.id}, email='{self.email}', nombre='{self.nombre}')>"

View File

@@ -0,0 +1,130 @@
"""
Modelo de Vehículo para gestión de la flota.
"""
from datetime import datetime
from sqlalchemy import (
Boolean,
DateTime,
Float,
ForeignKey,
Integer,
String,
Text,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
from app.models.base import TimestampMixin
class Vehiculo(Base, TimestampMixin):
"""Modelo de vehículo de la flota."""
__tablename__ = "vehiculos"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
# Identificación
nombre: Mapped[str] = mapped_column(String(100), nullable=False)
placa: Mapped[str] = mapped_column(String(20), unique=True, nullable=False, index=True)
vin: Mapped[str | None] = mapped_column(String(17), unique=True, nullable=True) # Vehicle Identification Number
numero_economico: Mapped[str | None] = mapped_column(String(50), nullable=True) # Número interno
# Características del vehículo
marca: Mapped[str | None] = mapped_column(String(50), nullable=True)
modelo: Mapped[str | None] = mapped_column(String(50), nullable=True)
año: Mapped[int | None] = mapped_column(Integer, nullable=True)
color: Mapped[str | None] = mapped_column(String(30), nullable=True)
tipo: Mapped[str | None] = mapped_column(String(50), nullable=True) # Sedan, SUV, Camión, etc.
# Capacidades
capacidad_carga_kg: Mapped[float | None] = mapped_column(Float, nullable=True)
capacidad_pasajeros: Mapped[int | None] = mapped_column(Integer, nullable=True)
capacidad_combustible_litros: Mapped[float | None] = mapped_column(Float, nullable=True)
tipo_combustible: Mapped[str | None] = mapped_column(String(20), nullable=True) # Gasolina, Diesel, Eléctrico
# Odómetro
odometro_inicial: Mapped[float] = mapped_column(Float, default=0.0, nullable=False)
odometro_actual: Mapped[float] = mapped_column(Float, default=0.0, nullable=False)
# Visualización
icono: Mapped[str | None] = mapped_column(String(50), nullable=True) # Nombre del icono en el mapa
color_marcador: Mapped[str] = mapped_column(String(7), default="#3B82F6", nullable=False) # Hex color
# Relaciones
conductor_id: Mapped[int | None] = mapped_column(
ForeignKey("conductores.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
grupo_id: Mapped[int | None] = mapped_column(
ForeignKey("grupos_vehiculos.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
# Estado
activo: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
en_servicio: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
notas: Mapped[str | None] = mapped_column(Text, nullable=True)
# Última ubicación conocida (para consultas rápidas)
ultima_lat: Mapped[float | None] = mapped_column(Float, nullable=True)
ultima_lng: Mapped[float | None] = mapped_column(Float, nullable=True)
ultima_velocidad: Mapped[float | None] = mapped_column(Float, nullable=True)
ultimo_rumbo: Mapped[float | None] = mapped_column(Float, nullable=True)
ultima_ubicacion_tiempo: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
motor_encendido: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
# Relaciones ORM
conductor: Mapped["Conductor | None"] = relationship(
"Conductor",
back_populates="vehiculos",
lazy="selectin",
)
grupo: Mapped["GrupoVehiculos | None"] = relationship(
"GrupoVehiculos",
back_populates="vehiculos",
lazy="selectin",
)
dispositivos: Mapped[list["Dispositivo"]] = relationship(
"Dispositivo",
back_populates="vehiculo",
lazy="selectin",
cascade="all, delete-orphan",
)
viajes: Mapped[list["Viaje"]] = relationship(
"Viaje",
back_populates="vehiculo",
lazy="dynamic",
)
alertas: Mapped[list["Alerta"]] = relationship(
"Alerta",
back_populates="vehiculo",
lazy="dynamic",
)
cargas_combustible: Mapped[list["CargaCombustible"]] = relationship(
"CargaCombustible",
back_populates="vehiculo",
lazy="dynamic",
)
mantenimientos: Mapped[list["Mantenimiento"]] = relationship(
"Mantenimiento",
back_populates="vehiculo",
lazy="dynamic",
)
camaras: Mapped[list["Camara"]] = relationship(
"Camara",
back_populates="vehiculo",
lazy="selectin",
)
@property
def distancia_recorrida(self) -> float:
"""Calcula la distancia total recorrida."""
return self.odometro_actual - self.odometro_inicial
def __repr__(self) -> str:
return f"<Vehiculo(id={self.id}, placa='{self.placa}', nombre='{self.nombre}')>"

135
backend/app/models/viaje.py Normal file
View File

@@ -0,0 +1,135 @@
"""
Modelo de Viaje para registrar trayectos de vehículos.
"""
from datetime import datetime
from sqlalchemy import (
DateTime,
Float,
ForeignKey,
Index,
Integer,
String,
Text,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
from app.models.base import TimestampMixin
class Viaje(Base, TimestampMixin):
"""
Modelo de viaje/trayecto de un vehículo.
Un viaje se define desde que el vehículo inicia movimiento
hasta que se detiene por un período prolongado.
"""
__tablename__ = "viajes"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
# Relaciones
vehiculo_id: Mapped[int] = mapped_column(
ForeignKey("vehiculos.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
conductor_id: Mapped[int | None] = mapped_column(
ForeignKey("conductores.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
# Tiempo
inicio_tiempo: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
fin_tiempo: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
# Ubicación de inicio
inicio_lat: Mapped[float] = mapped_column(Float, nullable=False)
inicio_lng: Mapped[float] = mapped_column(Float, nullable=False)
inicio_direccion: Mapped[str | None] = mapped_column(String(255), nullable=True)
# Ubicación de fin
fin_lat: Mapped[float | None] = mapped_column(Float, nullable=True)
fin_lng: Mapped[float | None] = mapped_column(Float, nullable=True)
fin_direccion: Mapped[str | None] = mapped_column(String(255), nullable=True)
# Estadísticas de distancia
distancia_km: Mapped[float | None] = mapped_column(Float, nullable=True)
# Estadísticas de tiempo
duracion_segundos: Mapped[int | None] = mapped_column(Integer, nullable=True)
tiempo_movimiento_segundos: Mapped[int | None] = mapped_column(Integer, nullable=True)
tiempo_parado_segundos: Mapped[int | None] = mapped_column(Integer, nullable=True)
# Estadísticas de velocidad
velocidad_promedio: Mapped[float | None] = mapped_column(Float, nullable=True) # km/h
velocidad_maxima: Mapped[float | None] = mapped_column(Float, nullable=True) # km/h
# Combustible
combustible_usado: Mapped[float | None] = mapped_column(Float, nullable=True) # litros
rendimiento: Mapped[float | None] = mapped_column(Float, nullable=True) # km/litro
# Odómetro
odometro_inicio: Mapped[float | None] = mapped_column(Float, nullable=True)
odometro_fin: Mapped[float | None] = mapped_column(Float, nullable=True)
# Estado
estado: Mapped[str] = mapped_column(
String(20),
default="en_curso",
nullable=False,
) # en_curso, completado, cancelado
# Notas
proposito: Mapped[str | None] = mapped_column(String(100), nullable=True) # Trabajo, personal, etc.
notas: Mapped[str | None] = mapped_column(Text, nullable=True)
# Número de puntos GPS registrados
puntos_gps: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
# Relaciones ORM
vehiculo: Mapped["Vehiculo"] = relationship(
"Vehiculo",
back_populates="viajes",
lazy="selectin",
)
conductor: Mapped["Conductor | None"] = relationship(
"Conductor",
back_populates="viajes",
lazy="selectin",
)
paradas: Mapped[list["Parada"]] = relationship(
"Parada",
back_populates="viaje",
lazy="selectin",
cascade="all, delete-orphan",
)
# Índices
__table_args__ = (
Index("idx_viajes_vehiculo_inicio", "vehiculo_id", "inicio_tiempo"),
Index("idx_viajes_estado", "estado"),
)
@property
def duracion_formateada(self) -> str:
"""Retorna la duración en formato legible (ej: 2h 30m)."""
if not self.duracion_segundos:
return "N/A"
horas = self.duracion_segundos // 3600
minutos = (self.duracion_segundos % 3600) // 60
if horas > 0:
return f"{horas}h {minutos}m"
return f"{minutos}m"
@property
def en_curso(self) -> bool:
"""Verifica si el viaje está en curso."""
return self.estado == "en_curso"
def __repr__(self) -> str:
return f"<Viaje(id={self.id}, vehiculo_id={self.vehiculo_id}, estado='{self.estado}')>"

View File

@@ -0,0 +1,370 @@
"""
Módulo de schemas Pydantic.
Exporta todos los schemas para facilitar importaciones.
"""
from app.schemas.base import (
BaseSchema,
TimestampSchema,
PaginatedResponse,
MessageResponse,
ErrorResponse,
GeoJSONPoint,
GeoJSONFeature,
GeoJSONFeatureCollection,
CoordenadasSchema,
RangoFechasSchema,
)
from app.schemas.usuario import (
UsuarioCreate,
UsuarioUpdate,
UsuarioUpdatePassword,
UsuarioResponse,
LoginRequest,
LoginResponse,
RefreshTokenRequest,
TokenResponse,
)
from app.schemas.grupo_vehiculos import (
GrupoVehiculosCreate,
GrupoVehiculosUpdate,
GrupoVehiculosResponse,
GrupoVehiculosConVehiculos,
)
from app.schemas.conductor import (
ConductorCreate,
ConductorUpdate,
ConductorResponse,
ConductorResumen,
ConductorEstadisticas,
)
from app.schemas.vehiculo import (
VehiculoCreate,
VehiculoUpdate,
VehiculoResponse,
VehiculoResumen,
VehiculoConRelaciones,
VehiculoUbicacionActual,
VehiculoEstadisticas,
)
from app.schemas.dispositivo import (
DispositivoCreate,
DispositivoUpdate,
DispositivoResponse,
DispositivoResumen,
DispositivoConVehiculo,
)
from app.schemas.ubicacion import (
UbicacionCreate,
UbicacionBulkCreate,
UbicacionResponse,
UbicacionConVehiculo,
HistorialUbicacionesRequest,
HistorialUbicacionesResponse,
OsmAndLocationCreate,
TraccarLocationCreate,
)
from app.schemas.viaje import (
ViajeCreate,
ViajeUpdate,
ViajeResponse,
ViajeResumen,
ViajeConParadas,
ViajeReplayData,
ParadaCreate,
ParadaUpdate,
ParadaResponse,
ParadaResumen,
)
from app.schemas.alerta import (
TipoAlertaCreate,
TipoAlertaUpdate,
TipoAlertaResponse,
AlertaCreate,
AlertaUpdate,
AlertaResponse,
AlertaConTipo,
AlertaConRelaciones,
AlertaResumen,
AlertasEstadisticas,
AlertaAtenderRequest,
)
from app.schemas.geocerca import (
GeocercaCircularCreate,
GeocercaPoligonoCreate,
GeocercaUpdate,
GeocercaResponse,
GeocercaConVehiculos,
GeocercaGeoJSON,
AsignarVehiculosRequest,
VerificarPuntoRequest,
VerificarPuntoResponse,
)
from app.schemas.poi import (
POICreate,
POIUpdate,
POIResponse,
POIResumen,
POICercano,
BuscarPOIsCercanosRequest,
BuscarPOIsCercanosResponse,
CategoriasPOIResponse,
)
from app.schemas.combustible import (
CargaCombustibleCreate,
CargaCombustibleUpdate,
CargaCombustibleResponse,
CargaCombustibleConRelaciones,
RendimientoCombustible,
ReporteConsumoVehiculo,
ReporteConsumoFlota,
)
from app.schemas.mantenimiento import (
TipoMantenimientoCreate,
TipoMantenimientoUpdate,
TipoMantenimientoResponse,
MantenimientoCreate,
MantenimientoUpdate,
MantenimientoResponse,
MantenimientoConRelaciones,
MantenimientoResumen,
ProximosMantenimientos,
CompletarMantenimientoRequest,
)
from app.schemas.video import (
CamaraCreate,
CamaraUpdate,
CamaraResponse,
CamaraConVehiculo,
CamaraStreamURL,
GrabacionCreate,
GrabacionResponse,
GrabacionResumen,
EventoVideoCreate,
EventoVideoUpdate,
EventoVideoResponse,
EventoVideoConRelaciones,
EventoVideoResumen,
TiposEventoVideoResponse,
)
from app.schemas.mensaje import (
MensajeCreate,
MensajeEnviarAConductores,
MensajeUpdate,
MensajeResponse,
MensajeConConductor,
MensajeResumen,
ConversacionConductor,
MensajesNoLeidosResponse,
ResponderMensajeRequest,
)
from app.schemas.configuracion import (
ConfiguracionCreate,
ConfiguracionUpdate,
ConfiguracionResponse,
ConfiguracionResumen,
ConfiguracionesPorCategoria,
ConfiguracionesResponse,
ActualizarConfiguracionesRequest,
ConfiguracionesAlertasResponse,
ConfiguracionesViajesResponse,
ConfiguracionesNotificacionesResponse,
ConfiguracionesMapaResponse,
)
from app.schemas.reporte import (
DashboardResumen,
DashboardGrafico,
ReporteRequest,
ReporteResponse,
ReporteViajesResumen,
ReporteAlertasResumen,
ReporteCombustibleResumen,
ReporteMantenimientoResumen,
ReporteUbicacionesResumen,
EstadisticasFlota,
KPIsFlota,
)
__all__ = [
# Base
"BaseSchema",
"TimestampSchema",
"PaginatedResponse",
"MessageResponse",
"ErrorResponse",
"GeoJSONPoint",
"GeoJSONFeature",
"GeoJSONFeatureCollection",
"CoordenadasSchema",
"RangoFechasSchema",
# Usuario
"UsuarioCreate",
"UsuarioUpdate",
"UsuarioUpdatePassword",
"UsuarioResponse",
"LoginRequest",
"LoginResponse",
"RefreshTokenRequest",
"TokenResponse",
# Grupo Vehículos
"GrupoVehiculosCreate",
"GrupoVehiculosUpdate",
"GrupoVehiculosResponse",
"GrupoVehiculosConVehiculos",
# Conductor
"ConductorCreate",
"ConductorUpdate",
"ConductorResponse",
"ConductorResumen",
"ConductorEstadisticas",
# Vehículo
"VehiculoCreate",
"VehiculoUpdate",
"VehiculoResponse",
"VehiculoResumen",
"VehiculoConRelaciones",
"VehiculoUbicacionActual",
"VehiculoEstadisticas",
# Dispositivo
"DispositivoCreate",
"DispositivoUpdate",
"DispositivoResponse",
"DispositivoResumen",
"DispositivoConVehiculo",
# Ubicación
"UbicacionCreate",
"UbicacionBulkCreate",
"UbicacionResponse",
"UbicacionConVehiculo",
"HistorialUbicacionesRequest",
"HistorialUbicacionesResponse",
"OsmAndLocationCreate",
"TraccarLocationCreate",
# Viaje
"ViajeCreate",
"ViajeUpdate",
"ViajeResponse",
"ViajeResumen",
"ViajeConParadas",
"ViajeReplayData",
"ParadaCreate",
"ParadaUpdate",
"ParadaResponse",
"ParadaResumen",
# Alerta
"TipoAlertaCreate",
"TipoAlertaUpdate",
"TipoAlertaResponse",
"AlertaCreate",
"AlertaUpdate",
"AlertaResponse",
"AlertaConTipo",
"AlertaConRelaciones",
"AlertaResumen",
"AlertasEstadisticas",
"AlertaAtenderRequest",
# Geocerca
"GeocercaCircularCreate",
"GeocercaPoligonoCreate",
"GeocercaUpdate",
"GeocercaResponse",
"GeocercaConVehiculos",
"GeocercaGeoJSON",
"AsignarVehiculosRequest",
"VerificarPuntoRequest",
"VerificarPuntoResponse",
# POI
"POICreate",
"POIUpdate",
"POIResponse",
"POIResumen",
"POICercano",
"BuscarPOIsCercanosRequest",
"BuscarPOIsCercanosResponse",
"CategoriasPOIResponse",
# Combustible
"CargaCombustibleCreate",
"CargaCombustibleUpdate",
"CargaCombustibleResponse",
"CargaCombustibleConRelaciones",
"RendimientoCombustible",
"ReporteConsumoVehiculo",
"ReporteConsumoFlota",
# Mantenimiento
"TipoMantenimientoCreate",
"TipoMantenimientoUpdate",
"TipoMantenimientoResponse",
"MantenimientoCreate",
"MantenimientoUpdate",
"MantenimientoResponse",
"MantenimientoConRelaciones",
"MantenimientoResumen",
"ProximosMantenimientos",
"CompletarMantenimientoRequest",
# Video
"CamaraCreate",
"CamaraUpdate",
"CamaraResponse",
"CamaraConVehiculo",
"CamaraStreamURL",
"GrabacionCreate",
"GrabacionResponse",
"GrabacionResumen",
"EventoVideoCreate",
"EventoVideoUpdate",
"EventoVideoResponse",
"EventoVideoConRelaciones",
"EventoVideoResumen",
"TiposEventoVideoResponse",
# Mensaje
"MensajeCreate",
"MensajeEnviarAConductores",
"MensajeUpdate",
"MensajeResponse",
"MensajeConConductor",
"MensajeResumen",
"ConversacionConductor",
"MensajesNoLeidosResponse",
"ResponderMensajeRequest",
# Configuración
"ConfiguracionCreate",
"ConfiguracionUpdate",
"ConfiguracionResponse",
"ConfiguracionResumen",
"ConfiguracionesPorCategoria",
"ConfiguracionesResponse",
"ActualizarConfiguracionesRequest",
"ConfiguracionesAlertasResponse",
"ConfiguracionesViajesResponse",
"ConfiguracionesNotificacionesResponse",
"ConfiguracionesMapaResponse",
# Reportes
"DashboardResumen",
"DashboardGrafico",
"ReporteRequest",
"ReporteResponse",
"ReporteViajesResumen",
"ReporteAlertasResumen",
"ReporteCombustibleResumen",
"ReporteMantenimientoResumen",
"ReporteUbicacionesResumen",
"EstadisticasFlota",
"KPIsFlota",
]

View File

@@ -0,0 +1,172 @@
"""
Schemas Pydantic para Alerta y Tipo de Alerta.
"""
from datetime import datetime
from typing import List, Optional
from pydantic import Field
from app.schemas.base import BaseSchema, TimestampSchema
# ============================================================================
# Schemas de Tipo de Alerta
# ============================================================================
class TipoAlertaBase(BaseSchema):
"""Schema base de tipo de alerta."""
codigo: str = Field(..., min_length=2, max_length=50)
nombre: str = Field(..., min_length=2, max_length=100)
descripcion: Optional[str] = None
severidad_default: str = Field(default="media", pattern="^(baja|media|alta|critica)$")
icono: Optional[str] = Field(None, max_length=50)
color: str = Field(default="#EF4444", pattern=r"^#[0-9A-Fa-f]{6}$")
class TipoAlertaCreate(TipoAlertaBase):
"""Schema para crear tipo de alerta."""
notificar_email: bool = False
notificar_push: bool = True
notificar_sms: bool = False
prioridad: int = Field(default=50, ge=1, le=100)
class TipoAlertaUpdate(BaseSchema):
"""Schema para actualizar tipo de alerta."""
nombre: Optional[str] = Field(None, min_length=2, max_length=100)
descripcion: Optional[str] = None
severidad_default: Optional[str] = Field(None, pattern="^(baja|media|alta|critica)$")
icono: Optional[str] = Field(None, max_length=50)
color: Optional[str] = Field(None, pattern=r"^#[0-9A-Fa-f]{6}$")
notificar_email: Optional[bool] = None
notificar_push: Optional[bool] = None
notificar_sms: Optional[bool] = None
prioridad: Optional[int] = Field(None, ge=1, le=100)
activo: Optional[bool] = None
class TipoAlertaResponse(TipoAlertaBase, TimestampSchema):
"""Schema de respuesta de tipo de alerta."""
id: int
notificar_email: bool
notificar_push: bool
notificar_sms: bool
prioridad: int
activo: bool
# ============================================================================
# Schemas de Alerta
# ============================================================================
class AlertaBase(BaseSchema):
"""Schema base de alerta."""
tipo_alerta_id: int
severidad: str = Field(default="media", pattern="^(baja|media|alta|critica)$")
mensaje: str = Field(..., min_length=5, max_length=500)
descripcion: Optional[str] = None
class AlertaCreate(AlertaBase):
"""Schema para crear alerta manualmente."""
vehiculo_id: Optional[int] = None
conductor_id: Optional[int] = None
dispositivo_id: Optional[int] = None
lat: Optional[float] = Field(None, ge=-90, le=90)
lng: Optional[float] = Field(None, ge=-180, le=180)
direccion: Optional[str] = Field(None, max_length=255)
velocidad: Optional[float] = Field(None, ge=0)
valor: Optional[float] = None
umbral: Optional[float] = None
datos_extra: Optional[str] = None # JSON
class AlertaUpdate(BaseSchema):
"""Schema para actualizar alerta (marcar atendida)."""
atendida: Optional[bool] = None
notas_atencion: Optional[str] = None
class AlertaResponse(AlertaBase, TimestampSchema):
"""Schema de respuesta de alerta."""
id: int
vehiculo_id: Optional[int] = None
conductor_id: Optional[int] = None
dispositivo_id: Optional[int] = None
lat: Optional[float] = None
lng: Optional[float] = None
direccion: Optional[str] = None
velocidad: Optional[float] = None
valor: Optional[float] = None
umbral: Optional[float] = None
datos_extra: Optional[str] = None
atendida: bool
atendida_por_id: Optional[int] = None
atendida_en: Optional[datetime] = None
notas_atencion: Optional[str] = None
notificacion_email_enviada: bool
notificacion_push_enviada: bool
notificacion_sms_enviada: bool
# Calculado
es_critica: bool
class AlertaConTipo(AlertaResponse):
"""Schema de alerta con información del tipo."""
tipo_alerta: TipoAlertaResponse
class AlertaConRelaciones(AlertaResponse):
"""Schema de alerta con todas las relaciones."""
tipo_alerta: TipoAlertaResponse
vehiculo_nombre: Optional[str] = None
vehiculo_placa: Optional[str] = None
conductor_nombre: Optional[str] = None
class AlertaResumen(BaseSchema):
"""Schema resumido de alerta para listas."""
id: int
tipo_codigo: str
tipo_nombre: str
severidad: str
mensaje: str
vehiculo_nombre: Optional[str] = None
vehiculo_placa: Optional[str] = None
creado_en: datetime
atendida: bool
class AlertasEstadisticas(BaseSchema):
"""Estadísticas de alertas."""
total: int
pendientes: int
atendidas: int
criticas: int
altas: int
medias: int
bajas: int
por_tipo: List[dict] # [{codigo, nombre, cantidad}]
por_vehiculo: List[dict] # [{vehiculo_id, nombre, cantidad}]
class AlertaAtenderRequest(BaseSchema):
"""Schema para marcar alerta como atendida."""
notas_atencion: Optional[str] = None

View File

@@ -0,0 +1,97 @@
"""
Schemas base y utilidades comunes para Pydantic.
"""
from datetime import datetime
from typing import Generic, List, Optional, TypeVar
from pydantic import BaseModel, ConfigDict
class BaseSchema(BaseModel):
"""Schema base con configuración común."""
model_config = ConfigDict(
from_attributes=True,
populate_by_name=True,
use_enum_values=True,
json_encoders={datetime: lambda v: v.isoformat()},
)
class TimestampSchema(BaseSchema):
"""Schema con campos de timestamp."""
creado_en: datetime
actualizado_en: datetime
# Type variable para paginación genérica
T = TypeVar("T")
class PaginatedResponse(BaseModel, Generic[T]):
"""Respuesta paginada genérica."""
items: List[T]
total: int
page: int
page_size: int
pages: int
@property
def has_next(self) -> bool:
return self.page < self.pages
@property
def has_prev(self) -> bool:
return self.page > 1
class MessageResponse(BaseModel):
"""Respuesta simple con mensaje."""
message: str
success: bool = True
class ErrorResponse(BaseModel):
"""Respuesta de error."""
error: dict
class GeoJSONPoint(BaseModel):
"""Schema para punto GeoJSON."""
type: str = "Point"
coordinates: List[float] # [lng, lat]
class GeoJSONFeature(BaseModel):
"""Schema para feature GeoJSON."""
type: str = "Feature"
geometry: dict
properties: dict
class GeoJSONFeatureCollection(BaseModel):
"""Schema para colección de features GeoJSON."""
type: str = "FeatureCollection"
features: List[GeoJSONFeature]
class CoordenadasSchema(BaseModel):
"""Schema para coordenadas simples."""
lat: float
lng: float
class RangoFechasSchema(BaseModel):
"""Schema para filtros de rango de fechas."""
desde: Optional[datetime] = None
hasta: Optional[datetime] = None

View File

@@ -0,0 +1,136 @@
"""
Schemas Pydantic para Carga de Combustible.
"""
from datetime import datetime
from typing import List, Optional
from pydantic import Field
from app.schemas.base import BaseSchema, TimestampSchema
class CargaCombustibleBase(BaseSchema):
"""Schema base de carga de combustible."""
vehiculo_id: int
fecha: datetime
litros: float = Field(..., gt=0)
precio_litro: Optional[float] = Field(None, ge=0)
tipo_combustible: Optional[str] = Field(None, max_length=20)
class CargaCombustibleCreate(CargaCombustibleBase):
"""Schema para crear carga de combustible."""
conductor_id: Optional[int] = None
total: Optional[float] = Field(None, ge=0)
odometro: Optional[float] = Field(None, ge=0)
estacion: Optional[str] = Field(None, max_length=100)
estacion_direccion: Optional[str] = Field(None, max_length=255)
lat: Optional[float] = Field(None, ge=-90, le=90)
lng: Optional[float] = Field(None, ge=-180, le=180)
tanque_lleno: bool = True
metodo_pago: Optional[str] = Field(None, max_length=50)
numero_factura: Optional[str] = Field(None, max_length=50)
notas: Optional[str] = None
class CargaCombustibleUpdate(BaseSchema):
"""Schema para actualizar carga de combustible."""
fecha: Optional[datetime] = None
litros: Optional[float] = Field(None, gt=0)
precio_litro: Optional[float] = Field(None, ge=0)
total: Optional[float] = Field(None, ge=0)
tipo_combustible: Optional[str] = Field(None, max_length=20)
odometro: Optional[float] = Field(None, ge=0)
estacion: Optional[str] = Field(None, max_length=100)
estacion_direccion: Optional[str] = Field(None, max_length=255)
tanque_lleno: Optional[bool] = None
metodo_pago: Optional[str] = Field(None, max_length=50)
numero_factura: Optional[str] = Field(None, max_length=50)
notas: Optional[str] = None
class CargaCombustibleResponse(CargaCombustibleBase, TimestampSchema):
"""Schema de respuesta de carga de combustible."""
id: int
conductor_id: Optional[int] = None
total: Optional[float] = None
odometro: Optional[float] = None
estacion: Optional[str] = None
estacion_direccion: Optional[str] = None
lat: Optional[float] = None
lng: Optional[float] = None
tanque_lleno: bool
metodo_pago: Optional[str] = None
numero_factura: Optional[str] = None
notas: Optional[str] = None
class CargaCombustibleConRelaciones(CargaCombustibleResponse):
"""Schema con información del vehículo y conductor."""
vehiculo_nombre: Optional[str] = None
vehiculo_placa: Optional[str] = None
conductor_nombre: Optional[str] = None
class RendimientoCombustible(BaseSchema):
"""Schema para rendimiento de combustible entre cargas."""
carga_id: int
fecha: datetime
litros: float
distancia_km: float
rendimiento_km_litro: float
costo_por_km: Optional[float] = None
class ReporteConsumoVehiculo(BaseSchema):
"""Schema para reporte de consumo de un vehículo."""
vehiculo_id: int
vehiculo_nombre: str
vehiculo_placa: str
periodo_inicio: datetime
periodo_fin: datetime
# Totales
total_litros: float
total_cargas: int
total_costo: float
distancia_recorrida_km: float
# Promedios
rendimiento_promedio: float # km/litro
costo_promedio_litro: float
costo_por_km: float
# Detalle de cargas
cargas: List[CargaCombustibleResponse]
class ReporteConsumoFlota(BaseSchema):
"""Schema para reporte de consumo de toda la flota."""
periodo_inicio: datetime
periodo_fin: datetime
# Totales flota
total_litros: float
total_cargas: int
total_costo: float
total_vehiculos: int
# Promedios flota
rendimiento_promedio_flota: float
costo_promedio_flota: float
# Por vehículo
por_vehiculo: List[dict] # [{vehiculo_id, nombre, placa, litros, costo, rendimiento}]
# Por tipo de combustible
por_tipo_combustible: List[dict] # [{tipo, litros, costo}]

View File

@@ -0,0 +1,94 @@
"""
Schemas Pydantic para Conductor.
"""
from datetime import date
from typing import Optional
from pydantic import EmailStr, Field
from app.schemas.base import BaseSchema, TimestampSchema
class ConductorBase(BaseSchema):
"""Schema base de conductor."""
nombre: str = Field(..., min_length=2, max_length=100)
apellido: str = Field(..., min_length=2, max_length=100)
telefono: Optional[str] = Field(None, max_length=20)
email: Optional[EmailStr] = None
documento_tipo: Optional[str] = Field(None, max_length=20)
documento_numero: Optional[str] = Field(None, max_length=50)
licencia_numero: Optional[str] = Field(None, max_length=50)
licencia_tipo: Optional[str] = Field(None, max_length=20)
licencia_vencimiento: Optional[date] = None
fecha_nacimiento: Optional[date] = None
direccion: Optional[str] = None
contacto_emergencia: Optional[str] = Field(None, max_length=100)
telefono_emergencia: Optional[str] = Field(None, max_length=20)
fecha_contratacion: Optional[date] = None
numero_empleado: Optional[str] = Field(None, max_length=50)
class ConductorCreate(ConductorBase):
"""Schema para crear conductor."""
pass
class ConductorUpdate(BaseSchema):
"""Schema para actualizar conductor."""
nombre: Optional[str] = Field(None, min_length=2, max_length=100)
apellido: Optional[str] = Field(None, min_length=2, max_length=100)
telefono: Optional[str] = Field(None, max_length=20)
email: Optional[EmailStr] = None
documento_tipo: Optional[str] = Field(None, max_length=20)
documento_numero: Optional[str] = Field(None, max_length=50)
licencia_numero: Optional[str] = Field(None, max_length=50)
licencia_tipo: Optional[str] = Field(None, max_length=20)
licencia_vencimiento: Optional[date] = None
foto_url: Optional[str] = None
fecha_nacimiento: Optional[date] = None
direccion: Optional[str] = None
contacto_emergencia: Optional[str] = Field(None, max_length=100)
telefono_emergencia: Optional[str] = Field(None, max_length=20)
fecha_contratacion: Optional[date] = None
numero_empleado: Optional[str] = Field(None, max_length=50)
activo: Optional[bool] = None
notas: Optional[str] = None
class ConductorResponse(ConductorBase, TimestampSchema):
"""Schema de respuesta de conductor."""
id: int
foto_url: Optional[str] = None
activo: bool
notas: Optional[str] = None
nombre_completo: str
licencia_vigente: bool
class ConductorResumen(BaseSchema):
"""Schema resumido de conductor."""
id: int
nombre_completo: str
telefono: Optional[str] = None
licencia_vigente: bool
activo: bool
class ConductorEstadisticas(BaseSchema):
"""Estadísticas de un conductor."""
conductor_id: int
nombre_completo: str
total_viajes: int
distancia_total_km: float
tiempo_conduccion_horas: float
velocidad_promedio: float
alertas_total: int
alertas_velocidad: int
calificacion: Optional[float] = None # Score calculado

View File

@@ -0,0 +1,108 @@
"""
Schemas Pydantic para Configuración.
"""
from typing import Any, Dict, List, Optional
from pydantic import Field
from app.schemas.base import BaseSchema, TimestampSchema
class ConfiguracionBase(BaseSchema):
"""Schema base de configuración."""
clave: str = Field(..., min_length=2, max_length=100)
categoria: str = Field(default="general", max_length=50)
descripcion: Optional[str] = None
class ConfiguracionCreate(ConfiguracionBase):
"""Schema para crear configuración."""
valor: Any # Se convertirá a JSON
tipo_dato: str = Field(default="string", pattern="^(string|number|boolean|json|array)$")
sensible: bool = False
editable: bool = True
class ConfiguracionUpdate(BaseSchema):
"""Schema para actualizar configuración."""
valor: Any
descripcion: Optional[str] = None
class ConfiguracionResponse(ConfiguracionBase, TimestampSchema):
"""Schema de respuesta de configuración."""
valor_json: str
tipo_dato: str
sensible: bool
editable: bool
# Valor parseado
valor: Optional[Any] = None
class ConfiguracionResumen(BaseSchema):
"""Schema resumido de configuración."""
clave: str
categoria: str
valor: Any
tipo_dato: str
editable: bool
class ConfiguracionesPorCategoria(BaseSchema):
"""Schema con configuraciones agrupadas por categoría."""
categoria: str
configuraciones: List[ConfiguracionResumen]
class ConfiguracionesResponse(BaseSchema):
"""Schema de respuesta con todas las configuraciones."""
categorias: List[str]
configuraciones: Dict[str, List[ConfiguracionResumen]]
class ActualizarConfiguracionesRequest(BaseSchema):
"""Schema para actualizar múltiples configuraciones."""
configuraciones: Dict[str, Any] # {clave: valor}
class ConfiguracionesAlertasResponse(BaseSchema):
"""Schema específico para configuraciones de alertas."""
velocidad_maxima: int
parada_minutos: int
bateria_minima: int
sin_señal_minutos: int
motor_encendido_minutos: int
class ConfiguracionesViajesResponse(BaseSchema):
"""Schema específico para configuraciones de viajes."""
velocidad_minima: float
parada_minutos: int
class ConfiguracionesNotificacionesResponse(BaseSchema):
"""Schema específico para configuraciones de notificaciones."""
email_habilitado: bool
push_habilitado: bool
destinatarios: List[str]
class ConfiguracionesMapaResponse(BaseSchema):
"""Schema específico para configuraciones de mapa."""
centro_lat: float
centro_lng: float
zoom_default: int

View File

@@ -0,0 +1,92 @@
"""
Schemas Pydantic para Dispositivo GPS/Tracker.
"""
from datetime import datetime
from typing import Optional
from pydantic import Field
from app.schemas.base import BaseSchema, TimestampSchema
class DispositivoBase(BaseSchema):
"""Schema base de dispositivo."""
tipo: str = Field(default="gps", max_length=50)
identificador: str = Field(..., min_length=1, max_length=100)
nombre: Optional[str] = Field(None, max_length=100)
marca: Optional[str] = Field(None, max_length=50)
modelo: Optional[str] = Field(None, max_length=50)
numero_serie: Optional[str] = Field(None, max_length=100)
telefono_sim: Optional[str] = Field(None, max_length=20)
operador_sim: Optional[str] = Field(None, max_length=50)
iccid: Optional[str] = Field(None, max_length=25)
imei: Optional[str] = Field(None, max_length=20)
protocolo: str = Field(default="osmand", max_length=50)
intervalo_reporte: int = Field(default=30, ge=1, le=3600)
class DispositivoCreate(DispositivoBase):
"""Schema para crear dispositivo."""
vehiculo_id: int
class DispositivoUpdate(BaseSchema):
"""Schema para actualizar dispositivo."""
tipo: Optional[str] = Field(None, max_length=50)
nombre: Optional[str] = Field(None, max_length=100)
marca: Optional[str] = Field(None, max_length=50)
modelo: Optional[str] = Field(None, max_length=50)
numero_serie: Optional[str] = Field(None, max_length=100)
telefono_sim: Optional[str] = Field(None, max_length=20)
operador_sim: Optional[str] = Field(None, max_length=50)
iccid: Optional[str] = Field(None, max_length=25)
imei: Optional[str] = Field(None, max_length=20)
protocolo: Optional[str] = Field(None, max_length=50)
intervalo_reporte: Optional[int] = Field(None, ge=1, le=3600)
configuracion: Optional[str] = None
firmware_version: Optional[str] = Field(None, max_length=50)
activo: Optional[bool] = None
notas: Optional[str] = None
class DispositivoResponse(DispositivoBase, TimestampSchema):
"""Schema de respuesta de dispositivo."""
id: int
vehiculo_id: int
ultimo_contacto: Optional[datetime] = None
bateria: Optional[float] = None
señal_gsm: Optional[int] = None
satelites: Optional[int] = None
configuracion: Optional[str] = None
firmware_version: Optional[str] = None
activo: bool
conectado: bool
notas: Optional[str] = None
# Calculado
esta_online: bool
class DispositivoResumen(BaseSchema):
"""Schema resumido de dispositivo."""
id: int
identificador: str
tipo: str
protocolo: str
activo: bool
conectado: bool
ultimo_contacto: Optional[datetime] = None
bateria: Optional[float] = None
class DispositivoConVehiculo(DispositivoResponse):
"""Schema de dispositivo con información del vehículo."""
vehiculo_nombre: Optional[str] = None
vehiculo_placa: Optional[str] = None

View File

@@ -0,0 +1,160 @@
"""
Schemas Pydantic para Geocerca.
"""
from typing import List, Optional
from pydantic import Field, field_validator
from app.schemas.base import BaseSchema, TimestampSchema
class GeocercaBase(BaseSchema):
"""Schema base de geocerca."""
nombre: str = Field(..., min_length=2, max_length=100)
descripcion: Optional[str] = None
tipo: str = Field(default="circular", pattern="^(circular|poligono)$")
color: str = Field(default="#3B82F6", pattern=r"^#[0-9A-Fa-f]{6}$")
opacidad: float = Field(default=0.3, ge=0, le=1)
color_borde: str = Field(default="#1D4ED8", pattern=r"^#[0-9A-Fa-f]{6}$")
categoria: Optional[str] = Field(None, max_length=50)
class GeocercaCircularCreate(GeocercaBase):
"""Schema para crear geocerca circular."""
tipo: str = "circular"
centro_lat: float = Field(..., ge=-90, le=90)
centro_lng: float = Field(..., ge=-180, le=180)
radio_metros: float = Field(..., gt=0, le=100000)
# Configuración de alertas
alerta_entrada: bool = True
alerta_salida: bool = True
velocidad_maxima: Optional[float] = Field(None, ge=0)
# Horario (opcional, JSON)
horario_json: Optional[str] = None
# Vehículos asignados (opcional, vacío = todos)
vehiculos_ids: Optional[List[int]] = None
class GeocercaPoligonoCreate(GeocercaBase):
"""Schema para crear geocerca poligonal."""
tipo: str = "poligono"
coordenadas: List[List[float]] # [[lat, lng], [lat, lng], ...]
# Configuración de alertas
alerta_entrada: bool = True
alerta_salida: bool = True
velocidad_maxima: Optional[float] = Field(None, ge=0)
# Horario (opcional, JSON)
horario_json: Optional[str] = None
# Vehículos asignados (opcional, vacío = todos)
vehiculos_ids: Optional[List[int]] = None
@field_validator("coordenadas")
@classmethod
def validate_coordenadas(cls, v: List[List[float]]) -> List[List[float]]:
if len(v) < 3:
raise ValueError("Un polígono debe tener al menos 3 puntos")
for coord in v:
if len(coord) != 2:
raise ValueError("Cada coordenada debe tener [lat, lng]")
if not (-90 <= coord[0] <= 90):
raise ValueError("Latitud debe estar entre -90 y 90")
if not (-180 <= coord[1] <= 180):
raise ValueError("Longitud debe estar entre -180 y 180")
return v
class GeocercaUpdate(BaseSchema):
"""Schema para actualizar geocerca."""
nombre: Optional[str] = Field(None, min_length=2, max_length=100)
descripcion: Optional[str] = None
color: Optional[str] = Field(None, pattern=r"^#[0-9A-Fa-f]{6}$")
opacidad: Optional[float] = Field(None, ge=0, le=1)
color_borde: Optional[str] = Field(None, pattern=r"^#[0-9A-Fa-f]{6}$")
categoria: Optional[str] = Field(None, max_length=50)
# Para circular
centro_lat: Optional[float] = Field(None, ge=-90, le=90)
centro_lng: Optional[float] = Field(None, ge=-180, le=180)
radio_metros: Optional[float] = Field(None, gt=0, le=100000)
# Para polígono
coordenadas: Optional[List[List[float]]] = None
# Configuración
alerta_entrada: Optional[bool] = None
alerta_salida: Optional[bool] = None
velocidad_maxima: Optional[float] = Field(None, ge=0)
horario_json: Optional[str] = None
activa: Optional[bool] = None
class GeocercaResponse(GeocercaBase, TimestampSchema):
"""Schema de respuesta de geocerca."""
id: int
centro_lat: Optional[float] = None
centro_lng: Optional[float] = None
radio_metros: Optional[float] = None
coordenadas_json: Optional[str] = None
alerta_entrada: bool
alerta_salida: bool
velocidad_maxima: Optional[float] = None
horario_json: Optional[str] = None
activa: bool
# Calculado
aplica_todos_vehiculos: bool
class GeocercaConVehiculos(GeocercaResponse):
"""Schema de geocerca con lista de vehículos asignados."""
vehiculos_asignados: List["VehiculoResumen"] = []
class GeocercaGeoJSON(BaseSchema):
"""Schema de geocerca en formato GeoJSON."""
type: str = "Feature"
geometry: dict
properties: dict
class AsignarVehiculosRequest(BaseSchema):
"""Schema para asignar vehículos a una geocerca."""
vehiculos_ids: List[int]
reemplazar: bool = False # True = reemplaza todos, False = agrega a existentes
class VerificarPuntoRequest(BaseSchema):
"""Schema para verificar si un punto está dentro de una geocerca."""
lat: float = Field(..., ge=-90, le=90)
lng: float = Field(..., ge=-180, le=180)
class VerificarPuntoResponse(BaseSchema):
"""Schema de respuesta de verificación de punto."""
dentro: bool
geocerca_id: int
geocerca_nombre: str
distancia_metros: Optional[float] = None # Distancia al borde si está fuera
# Import fix
from app.schemas.vehiculo import VehiculoResumen # noqa: E402
GeocercaConVehiculos.model_rebuild()

View File

@@ -0,0 +1,52 @@
"""
Schemas Pydantic para Grupo de Vehículos.
"""
from typing import List, Optional
from pydantic import Field
from app.schemas.base import BaseSchema, TimestampSchema
class GrupoVehiculosBase(BaseSchema):
"""Schema base de grupo de vehículos."""
nombre: str = Field(..., min_length=2, max_length=100)
descripcion: Optional[str] = None
color: str = Field(default="#3B82F6", pattern=r"^#[0-9A-Fa-f]{6}$")
icono: Optional[str] = Field(None, max_length=50)
class GrupoVehiculosCreate(GrupoVehiculosBase):
"""Schema para crear grupo de vehículos."""
pass
class GrupoVehiculosUpdate(BaseSchema):
"""Schema para actualizar grupo de vehículos."""
nombre: Optional[str] = Field(None, min_length=2, max_length=100)
descripcion: Optional[str] = None
color: Optional[str] = Field(None, pattern=r"^#[0-9A-Fa-f]{6}$")
icono: Optional[str] = Field(None, max_length=50)
class GrupoVehiculosResponse(GrupoVehiculosBase, TimestampSchema):
"""Schema de respuesta de grupo de vehículos."""
id: int
cantidad_vehiculos: Optional[int] = None
class GrupoVehiculosConVehiculos(GrupoVehiculosResponse):
"""Schema con lista de vehículos del grupo."""
vehiculos: List["VehiculoResumen"] = []
# Import circular fix
from app.schemas.vehiculo import VehiculoResumen # noqa: E402
GrupoVehiculosConVehiculos.model_rebuild()

View File

@@ -0,0 +1,198 @@
"""
Schemas Pydantic para Mantenimiento y Tipo de Mantenimiento.
"""
from datetime import date, datetime
from typing import List, Optional
from pydantic import Field
from app.schemas.base import BaseSchema, TimestampSchema
# ============================================================================
# Schemas de Tipo de Mantenimiento
# ============================================================================
class TipoMantenimientoBase(BaseSchema):
"""Schema base de tipo de mantenimiento."""
nombre: str = Field(..., min_length=2, max_length=100)
descripcion: Optional[str] = None
codigo: Optional[str] = Field(None, max_length=20)
categoria: str = Field(default="preventivo", pattern="^(preventivo|correctivo|predictivo)$")
class TipoMantenimientoCreate(TipoMantenimientoBase):
"""Schema para crear tipo de mantenimiento."""
intervalo_km: Optional[int] = Field(None, gt=0)
intervalo_dias: Optional[int] = Field(None, gt=0)
costo_estimado: Optional[float] = Field(None, ge=0)
duracion_estimada_horas: Optional[float] = Field(None, ge=0)
prioridad: int = Field(default=50, ge=1, le=100)
requiere_inmovilizacion: bool = False
class TipoMantenimientoUpdate(BaseSchema):
"""Schema para actualizar tipo de mantenimiento."""
nombre: Optional[str] = Field(None, min_length=2, max_length=100)
descripcion: Optional[str] = None
codigo: Optional[str] = Field(None, max_length=20)
categoria: Optional[str] = Field(None, pattern="^(preventivo|correctivo|predictivo)$")
intervalo_km: Optional[int] = Field(None, gt=0)
intervalo_dias: Optional[int] = Field(None, gt=0)
costo_estimado: Optional[float] = Field(None, ge=0)
duracion_estimada_horas: Optional[float] = Field(None, ge=0)
prioridad: Optional[int] = Field(None, ge=1, le=100)
requiere_inmovilizacion: Optional[bool] = None
activo: Optional[bool] = None
class TipoMantenimientoResponse(TipoMantenimientoBase, TimestampSchema):
"""Schema de respuesta de tipo de mantenimiento."""
id: int
intervalo_km: Optional[int] = None
intervalo_dias: Optional[int] = None
costo_estimado: Optional[float] = None
duracion_estimada_horas: Optional[float] = None
prioridad: int
requiere_inmovilizacion: bool
activo: bool
# ============================================================================
# Schemas de Mantenimiento
# ============================================================================
class MantenimientoBase(BaseSchema):
"""Schema base de mantenimiento."""
vehiculo_id: int
tipo_mantenimiento_id: int
fecha_programada: date
class MantenimientoCreate(MantenimientoBase):
"""Schema para crear/programar mantenimiento."""
odometro_programado: Optional[float] = Field(None, ge=0)
costo_estimado: Optional[float] = Field(None, ge=0)
proveedor: Optional[str] = Field(None, max_length=100)
proveedor_direccion: Optional[str] = Field(None, max_length=255)
proveedor_telefono: Optional[str] = Field(None, max_length=20)
descripcion: Optional[str] = None
notas: Optional[str] = None
class MantenimientoUpdate(BaseSchema):
"""Schema para actualizar mantenimiento."""
estado: Optional[str] = Field(None, pattern="^(programado|en_proceso|completado|cancelado|vencido)$")
fecha_programada: Optional[date] = None
fecha_realizada: Optional[date] = None
odometro_programado: Optional[float] = Field(None, ge=0)
odometro_realizado: Optional[float] = Field(None, ge=0)
costo_estimado: Optional[float] = Field(None, ge=0)
costo_real: Optional[float] = Field(None, ge=0)
costo_mano_obra: Optional[float] = Field(None, ge=0)
costo_refacciones: Optional[float] = Field(None, ge=0)
proveedor: Optional[str] = Field(None, max_length=100)
proveedor_direccion: Optional[str] = Field(None, max_length=255)
proveedor_telefono: Optional[str] = Field(None, max_length=20)
numero_factura: Optional[str] = Field(None, max_length=50)
numero_orden: Optional[str] = Field(None, max_length=50)
descripcion: Optional[str] = None
trabajos_realizados: Optional[str] = None
refacciones_usadas: Optional[str] = None
tecnico: Optional[str] = Field(None, max_length=100)
proximo_km: Optional[float] = Field(None, ge=0)
proxima_fecha: Optional[date] = None
notas: Optional[str] = None
class MantenimientoResponse(MantenimientoBase, TimestampSchema):
"""Schema de respuesta de mantenimiento."""
id: int
estado: str
fecha_realizada: Optional[date] = None
odometro_programado: Optional[float] = None
odometro_realizado: Optional[float] = None
costo_estimado: Optional[float] = None
costo_real: Optional[float] = None
costo_mano_obra: Optional[float] = None
costo_refacciones: Optional[float] = None
proveedor: Optional[str] = None
proveedor_direccion: Optional[str] = None
proveedor_telefono: Optional[str] = None
numero_factura: Optional[str] = None
numero_orden: Optional[str] = None
descripcion: Optional[str] = None
trabajos_realizados: Optional[str] = None
refacciones_usadas: Optional[str] = None
tecnico: Optional[str] = None
proximo_km: Optional[float] = None
proxima_fecha: Optional[date] = None
archivos_adjuntos: Optional[str] = None
recordatorio_enviado: bool
notas: Optional[str] = None
# Calculados
esta_vencido: bool
dias_para_vencimiento: Optional[int] = None
class MantenimientoConRelaciones(MantenimientoResponse):
"""Schema con información del vehículo y tipo."""
vehiculo_nombre: Optional[str] = None
vehiculo_placa: Optional[str] = None
tipo_mantenimiento_nombre: Optional[str] = None
tipo_mantenimiento_categoria: Optional[str] = None
class MantenimientoResumen(BaseSchema):
"""Schema resumido de mantenimiento."""
id: int
vehiculo_id: int
vehiculo_nombre: str
vehiculo_placa: str
tipo_mantenimiento_nombre: str
estado: str
fecha_programada: date
dias_para_vencimiento: Optional[int] = None
esta_vencido: bool
class ProximosMantenimientos(BaseSchema):
"""Schema para próximos mantenimientos."""
vencidos: List[MantenimientoResumen]
proximos_7_dias: List[MantenimientoResumen]
proximos_30_dias: List[MantenimientoResumen]
class CompletarMantenimientoRequest(BaseSchema):
"""Schema para completar un mantenimiento."""
fecha_realizada: date
odometro_realizado: Optional[float] = Field(None, ge=0)
costo_real: Optional[float] = Field(None, ge=0)
costo_mano_obra: Optional[float] = Field(None, ge=0)
costo_refacciones: Optional[float] = Field(None, ge=0)
trabajos_realizados: Optional[str] = None
refacciones_usadas: Optional[str] = None
tecnico: Optional[str] = Field(None, max_length=100)
numero_factura: Optional[str] = Field(None, max_length=50)
notas: Optional[str] = None
# Próximo mantenimiento
programar_siguiente: bool = False
proximo_km: Optional[float] = Field(None, ge=0)
proxima_fecha: Optional[date] = None

View File

@@ -0,0 +1,105 @@
"""
Schemas Pydantic para Mensaje.
"""
from datetime import datetime
from typing import List, Optional
from pydantic import Field
from app.schemas.base import BaseSchema, TimestampSchema
class MensajeBase(BaseSchema):
"""Schema base de mensaje."""
asunto: Optional[str] = Field(None, max_length=200)
contenido: str = Field(..., min_length=1)
class MensajeCreate(MensajeBase):
"""Schema para crear/enviar mensaje."""
conductor_id: int
tipo: str = Field(default="texto", pattern="^(texto|alerta|instruccion|emergencia)$")
prioridad: str = Field(default="normal", pattern="^(baja|normal|alta|urgente)$")
adjuntos: Optional[List[str]] = None # Lista de URLs
class MensajeEnviarAConductores(BaseSchema):
"""Schema para enviar mensaje a múltiples conductores."""
conductores_ids: List[int]
asunto: Optional[str] = Field(None, max_length=200)
contenido: str = Field(..., min_length=1)
tipo: str = Field(default="texto", pattern="^(texto|alerta|instruccion|emergencia)$")
prioridad: str = Field(default="normal", pattern="^(baja|normal|alta|urgente)$")
class MensajeUpdate(BaseSchema):
"""Schema para actualizar mensaje."""
leido: Optional[bool] = None
eliminado_por_admin: Optional[bool] = None
eliminado_por_conductor: Optional[bool] = None
class MensajeResponse(MensajeBase, TimestampSchema):
"""Schema de respuesta de mensaje."""
id: int
conductor_id: int
de_admin: bool
usuario_id: Optional[int] = None
tipo: str
prioridad: str
leido: bool
leido_en: Optional[datetime] = None
adjuntos: Optional[str] = None
respuesta_a_id: Optional[int] = None
eliminado_por_admin: bool
eliminado_por_conductor: bool
class MensajeConConductor(MensajeResponse):
"""Schema con información del conductor."""
conductor_nombre: Optional[str] = None
usuario_nombre: Optional[str] = None
class MensajeResumen(BaseSchema):
"""Schema resumido de mensaje."""
id: int
conductor_id: int
conductor_nombre: str
de_admin: bool
asunto: Optional[str] = None
tipo: str
prioridad: str
leido: bool
creado_en: datetime
class ConversacionConductor(BaseSchema):
"""Schema para conversación con un conductor."""
conductor_id: int
conductor_nombre: str
mensajes: List[MensajeResponse]
no_leidos: int
class MensajesNoLeidosResponse(BaseSchema):
"""Schema con conteo de mensajes no leídos."""
total_no_leidos: int
por_conductor: List[dict] # [{conductor_id, nombre, cantidad}]
class ResponderMensajeRequest(BaseSchema):
"""Schema para responder a un mensaje."""
contenido: str = Field(..., min_length=1)
adjuntos: Optional[List[str]] = None

120
backend/app/schemas/poi.py Normal file
View File

@@ -0,0 +1,120 @@
"""
Schemas Pydantic para POI (Punto de Interés).
"""
from typing import List, Optional
from pydantic import EmailStr, Field
from app.schemas.base import BaseSchema, TimestampSchema
class POIBase(BaseSchema):
"""Schema base de POI."""
nombre: str = Field(..., min_length=2, max_length=100)
descripcion: Optional[str] = None
categoria: str = Field(default="otro", max_length=50)
lat: float = Field(..., ge=-90, le=90)
lng: float = Field(..., ge=-180, le=180)
direccion: Optional[str] = Field(None, max_length=255)
ciudad: Optional[str] = Field(None, max_length=100)
estado: Optional[str] = Field(None, max_length=100)
codigo_postal: Optional[str] = Field(None, max_length=10)
radio_metros: float = Field(default=100.0, gt=0, le=10000)
class POICreate(POIBase):
"""Schema para crear POI."""
telefono: Optional[str] = Field(None, max_length=20)
email: Optional[EmailStr] = None
contacto_nombre: Optional[str] = Field(None, max_length=100)
horario_json: Optional[str] = None
icono: Optional[str] = Field(None, max_length=50)
color: str = Field(default="#10B981", pattern=r"^#[0-9A-Fa-f]{6}$")
codigo_externo: Optional[str] = Field(None, max_length=50)
notas: Optional[str] = None
class POIUpdate(BaseSchema):
"""Schema para actualizar POI."""
nombre: Optional[str] = Field(None, min_length=2, max_length=100)
descripcion: Optional[str] = None
categoria: Optional[str] = Field(None, max_length=50)
lat: Optional[float] = Field(None, ge=-90, le=90)
lng: Optional[float] = Field(None, ge=-180, le=180)
direccion: Optional[str] = Field(None, max_length=255)
ciudad: Optional[str] = Field(None, max_length=100)
estado: Optional[str] = Field(None, max_length=100)
codigo_postal: Optional[str] = Field(None, max_length=10)
radio_metros: Optional[float] = Field(None, gt=0, le=10000)
telefono: Optional[str] = Field(None, max_length=20)
email: Optional[EmailStr] = None
contacto_nombre: Optional[str] = Field(None, max_length=100)
horario_json: Optional[str] = None
icono: Optional[str] = Field(None, max_length=50)
color: Optional[str] = Field(None, pattern=r"^#[0-9A-Fa-f]{6}$")
codigo_externo: Optional[str] = Field(None, max_length=50)
activo: Optional[bool] = None
notas: Optional[str] = None
class POIResponse(POIBase, TimestampSchema):
"""Schema de respuesta de POI."""
id: int
telefono: Optional[str] = None
email: Optional[str] = None
contacto_nombre: Optional[str] = None
horario_json: Optional[str] = None
icono: Optional[str] = None
color: str
codigo_externo: Optional[str] = None
activo: bool
notas: Optional[str] = None
class POIResumen(BaseSchema):
"""Schema resumido de POI."""
id: int
nombre: str
categoria: str
lat: float
lng: float
icono: Optional[str] = None
color: str
class POICercano(POIResumen):
"""Schema de POI con distancia."""
distancia_metros: float
class BuscarPOIsCercanosRequest(BaseSchema):
"""Schema para buscar POIs cercanos."""
lat: float = Field(..., ge=-90, le=90)
lng: float = Field(..., ge=-180, le=180)
radio_metros: float = Field(default=1000, gt=0, le=50000)
categoria: Optional[str] = None
limite: int = Field(default=10, ge=1, le=100)
class BuscarPOIsCercanosResponse(BaseSchema):
"""Schema de respuesta de búsqueda de POIs cercanos."""
centro_lat: float
centro_lng: float
radio_metros: float
total: int
pois: List[POICercano]
class CategoriasPOIResponse(BaseSchema):
"""Schema de respuesta con categorías de POI."""
categorias: List[dict] # [{codigo, nombre, icono, color}]

View File

@@ -0,0 +1,217 @@
"""
Schemas Pydantic para Reportes y Dashboard.
"""
from datetime import datetime
from typing import Any, Dict, List, Optional
from pydantic import Field
from app.schemas.base import BaseSchema
class DashboardResumen(BaseSchema):
"""Schema para datos del dashboard principal."""
# Contadores
total_vehiculos: int
vehiculos_activos: int
vehiculos_en_movimiento: int
vehiculos_detenidos: int
vehiculos_sin_señal: int
total_conductores: int
conductores_activos: int
# Alertas
alertas_pendientes: int
alertas_criticas: int
alertas_hoy: int
# Viajes de hoy
viajes_hoy: int
distancia_hoy_km: float
# Mantenimiento
mantenimientos_vencidos: int
mantenimientos_proximos: int
# Última actualización
actualizado_en: datetime
class DashboardGrafico(BaseSchema):
"""Schema para datos de gráficos del dashboard."""
# Distancia por día (últimos 7 días)
distancia_diaria: List[dict] # [{fecha, km}]
# Viajes por día (últimos 7 días)
viajes_diarios: List[dict] # [{fecha, cantidad}]
# Alertas por tipo (últimos 7 días)
alertas_por_tipo: List[dict] # [{tipo, cantidad}]
# Consumo de combustible (últimos 30 días)
consumo_combustible: List[dict] # [{fecha, litros}]
class ReporteRequest(BaseSchema):
"""Schema para solicitar generación de reporte."""
tipo: str = Field(
...,
pattern="^(viajes|alertas|combustible|mantenimiento|ubicaciones|resumen)$"
)
formato: str = Field(default="pdf", pattern="^(pdf|excel|csv)$")
fecha_inicio: datetime
fecha_fin: datetime
vehiculos_ids: Optional[List[int]] = None # None = todos
conductores_ids: Optional[List[int]] = None
parametros: Optional[Dict[str, Any]] = None
class ReporteResponse(BaseSchema):
"""Schema de respuesta de generación de reporte."""
id: str # UUID del reporte
tipo: str
formato: str
estado: str # pendiente, procesando, completado, error
archivo_url: Optional[str] = None
creado_en: datetime
completado_en: Optional[datetime] = None
error: Optional[str] = None
class ReporteViajesResumen(BaseSchema):
"""Schema para reporte de viajes."""
periodo_inicio: datetime
periodo_fin: datetime
total_viajes: int
distancia_total_km: float
tiempo_total_conduccion: str
velocidad_promedio: float
por_vehiculo: List[dict]
por_conductor: List[dict]
viajes: List[dict]
class ReporteAlertasResumen(BaseSchema):
"""Schema para reporte de alertas."""
periodo_inicio: datetime
periodo_fin: datetime
total_alertas: int
atendidas: int
pendientes: int
por_tipo: List[dict]
por_severidad: List[dict]
por_vehiculo: List[dict]
alertas: List[dict]
class ReporteCombustibleResumen(BaseSchema):
"""Schema para reporte de combustible."""
periodo_inicio: datetime
periodo_fin: datetime
total_litros: float
total_costo: float
rendimiento_promedio: float
por_vehiculo: List[dict]
cargas: List[dict]
class ReporteMantenimientoResumen(BaseSchema):
"""Schema para reporte de mantenimiento."""
periodo_inicio: datetime
periodo_fin: datetime
total_mantenimientos: int
completados: int
pendientes: int
vencidos: int
costo_total: float
por_tipo: List[dict]
por_vehiculo: List[dict]
mantenimientos: List[dict]
class ReporteUbicacionesResumen(BaseSchema):
"""Schema para reporte de ubicaciones/recorridos."""
periodo_inicio: datetime
periodo_fin: datetime
vehiculo_id: int
vehiculo_nombre: str
total_puntos: int
distancia_km: float
# Ruta en GeoJSON
ruta_geojson: dict
class EstadisticasFlota(BaseSchema):
"""Schema para estadísticas generales de la flota."""
periodo: str # diario, semanal, mensual
# Distancia
distancia_total_km: float
distancia_promedio_vehiculo_km: float
# Tiempo
tiempo_conduccion_total_horas: float
tiempo_ocioso_total_horas: float
# Combustible
combustible_total_litros: float
costo_combustible_total: float
rendimiento_promedio: float
# Alertas
alertas_total: int
alertas_por_vehiculo_promedio: float
# Mantenimiento
costo_mantenimiento_total: float
# Top vehículos
top_distancia: List[dict] # [{vehiculo_id, nombre, km}]
top_alertas: List[dict] # [{vehiculo_id, nombre, cantidad}]
top_combustible: List[dict] # [{vehiculo_id, nombre, litros}]
class KPIsFlota(BaseSchema):
"""Schema para KPIs de la flota."""
# Utilización
porcentaje_utilizacion: float # % de vehículos en uso
horas_promedio_uso_diario: float
# Eficiencia
km_por_litro_flota: float
costo_por_km: float
# Seguridad
alertas_por_1000km: float
excesos_velocidad_por_1000km: float
# Mantenimiento
porcentaje_mantenimientos_a_tiempo: float
costo_mantenimiento_por_km: float
# Disponibilidad
porcentaje_disponibilidad: float # % de tiempo operativo
# Comparación con periodo anterior
variacion_km: float # % vs periodo anterior
variacion_combustible: float
variacion_alertas: float
variacion_costo: float

View File

@@ -0,0 +1,139 @@
"""
Schemas Pydantic para Ubicación GPS.
"""
from datetime import datetime
from typing import List, Optional
from pydantic import Field
from app.schemas.base import BaseSchema, GeoJSONFeature
class UbicacionBase(BaseSchema):
"""Schema base de ubicación."""
lat: float = Field(..., ge=-90, le=90)
lng: float = Field(..., ge=-180, le=180)
velocidad: Optional[float] = Field(None, ge=0)
rumbo: Optional[float] = Field(None, ge=0, le=360)
altitud: Optional[float] = None
precision: Optional[float] = Field(None, ge=0)
satelites: Optional[int] = Field(None, ge=0)
class UbicacionCreate(UbicacionBase):
"""Schema para crear/recibir ubicación."""
vehiculo_id: Optional[int] = None # Puede venir por identificador de dispositivo
dispositivo_id: Optional[str] = None # Identificador del dispositivo
tiempo: Optional[datetime] = None # Si no se envía, usa timestamp del servidor
fuente: str = Field(default="gps", max_length=20)
bateria_dispositivo: Optional[float] = Field(None, ge=0, le=100)
bateria_vehiculo: Optional[float] = None
motor_encendido: Optional[bool] = None
odometro: Optional[float] = Field(None, ge=0)
hdop: Optional[float] = None
# Datos OBD opcionales
rpm: Optional[int] = Field(None, ge=0)
temperatura_motor: Optional[float] = None
nivel_combustible: Optional[float] = Field(None, ge=0, le=100)
class UbicacionBulkCreate(BaseSchema):
"""Schema para recibir múltiples ubicaciones."""
ubicaciones: List[UbicacionCreate]
class UbicacionResponse(UbicacionBase):
"""Schema de respuesta de ubicación."""
tiempo: datetime
vehiculo_id: int
fuente: str
bateria_dispositivo: Optional[float] = None
motor_encendido: Optional[bool] = None
odometro: Optional[float] = None
class UbicacionConVehiculo(UbicacionResponse):
"""Schema de ubicación con información del vehículo."""
vehiculo_nombre: str
vehiculo_placa: str
vehiculo_color: str
class HistorialUbicacionesRequest(BaseSchema):
"""Schema para solicitar historial de ubicaciones."""
vehiculo_id: int
desde: datetime
hasta: datetime
simplificar: bool = True # Simplificar ruta con Douglas-Peucker
intervalo_segundos: Optional[int] = None # Muestreo por intervalo
class HistorialUbicacionesResponse(BaseSchema):
"""Schema de respuesta de historial de ubicaciones."""
vehiculo_id: int
desde: datetime
hasta: datetime
total_puntos: int
distancia_km: float
tiempo_movimiento_segundos: int
velocidad_promedio: Optional[float] = None
velocidad_maxima: Optional[float] = None
ubicaciones: List[UbicacionResponse]
class UbicacionGeoJSON(GeoJSONFeature):
"""Schema de ubicación en formato GeoJSON."""
pass
class RutaGeoJSON(BaseSchema):
"""Schema de ruta completa en formato GeoJSON LineString."""
type: str = "Feature"
geometry: dict # LineString
properties: dict
# Schema para recibir ubicaciones de OsmAnd/Traccar
class OsmAndLocationCreate(BaseSchema):
"""Schema para ubicaciones recibidas de OsmAnd."""
id: str # Device identifier
lat: float
lon: float
timestamp: Optional[int] = None # Unix timestamp
speed: Optional[float] = None # km/h
bearing: Optional[float] = None # degrees
altitude: Optional[float] = None # meters
accuracy: Optional[float] = None # meters
batt: Optional[float] = None # battery percentage
class TraccarLocationCreate(BaseSchema):
"""Schema para ubicaciones recibidas de Traccar."""
id: int # Device ID in Traccar
deviceId: int
protocol: str
serverTime: datetime
deviceTime: datetime
fixTime: datetime
valid: bool
latitude: float
longitude: float
altitude: Optional[float] = None
speed: Optional[float] = None # knots
course: Optional[float] = None
address: Optional[str] = None
accuracy: Optional[float] = None
attributes: Optional[dict] = None

View File

@@ -0,0 +1,116 @@
"""
Schemas Pydantic para Usuario.
"""
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, EmailStr, Field, field_validator
from app.schemas.base import BaseSchema, TimestampSchema
class UsuarioBase(BaseSchema):
"""Schema base de usuario."""
email: EmailStr
nombre: str = Field(..., min_length=2, max_length=100)
apellido: Optional[str] = Field(None, max_length=100)
telefono: Optional[str] = Field(None, max_length=20)
class UsuarioCreate(UsuarioBase):
"""Schema para crear usuario."""
password: str = Field(..., min_length=8, max_length=100)
es_admin: bool = False
@field_validator("password")
@classmethod
def validate_password(cls, v: str) -> str:
if len(v) < 8:
raise ValueError("La contraseña debe tener al menos 8 caracteres")
if not any(c.isupper() for c in v):
raise ValueError("La contraseña debe tener al menos una mayúscula")
if not any(c.islower() for c in v):
raise ValueError("La contraseña debe tener al menos una minúscula")
if not any(c.isdigit() for c in v):
raise ValueError("La contraseña debe tener al menos un número")
return v
class UsuarioUpdate(BaseSchema):
"""Schema para actualizar usuario."""
nombre: Optional[str] = Field(None, min_length=2, max_length=100)
apellido: Optional[str] = Field(None, max_length=100)
telefono: Optional[str] = Field(None, max_length=20)
avatar_url: Optional[str] = None
preferencias: Optional[str] = None
class UsuarioUpdatePassword(BaseModel):
"""Schema para cambiar contraseña."""
password_actual: str
password_nuevo: str = Field(..., min_length=8, max_length=100)
@field_validator("password_nuevo")
@classmethod
def validate_password(cls, v: str) -> str:
if len(v) < 8:
raise ValueError("La contraseña debe tener al menos 8 caracteres")
return v
class UsuarioResponse(UsuarioBase, TimestampSchema):
"""Schema de respuesta de usuario."""
id: int
es_admin: bool
activo: bool
ultimo_acceso: Optional[datetime] = None
avatar_url: Optional[str] = None
class UsuarioInDB(UsuarioResponse):
"""Schema interno con hash de password."""
password_hash: str
# ============================================================================
# Schemas de Autenticación
# ============================================================================
class LoginRequest(BaseModel):
"""Schema para solicitud de login."""
email: EmailStr
password: str
class LoginResponse(BaseModel):
"""Schema de respuesta de login."""
access_token: str
refresh_token: str
token_type: str = "bearer"
expires_in: int
user: UsuarioResponse
class RefreshTokenRequest(BaseModel):
"""Schema para refresh token."""
refresh_token: str
class TokenResponse(BaseModel):
"""Schema de respuesta de tokens."""
access_token: str
refresh_token: str
token_type: str = "bearer"
expires_in: int

View File

@@ -0,0 +1,176 @@
"""
Schemas Pydantic para Vehículo.
"""
from datetime import datetime
from typing import List, Optional
from pydantic import Field
from app.schemas.base import BaseSchema, TimestampSchema
class VehiculoBase(BaseSchema):
"""Schema base de vehículo."""
nombre: str = Field(..., min_length=2, max_length=100)
placa: str = Field(..., min_length=2, max_length=20)
vin: Optional[str] = Field(None, max_length=17)
numero_economico: Optional[str] = Field(None, max_length=50)
marca: Optional[str] = Field(None, max_length=50)
modelo: Optional[str] = Field(None, max_length=50)
año: Optional[int] = Field(None, ge=1900, le=2100)
color: Optional[str] = Field(None, max_length=30)
tipo: Optional[str] = Field(None, max_length=50)
capacidad_carga_kg: Optional[float] = Field(None, ge=0)
capacidad_pasajeros: Optional[int] = Field(None, ge=0)
capacidad_combustible_litros: Optional[float] = Field(None, ge=0)
tipo_combustible: Optional[str] = Field(None, max_length=20)
odometro_inicial: float = Field(default=0.0, ge=0)
class VehiculoCreate(VehiculoBase):
"""Schema para crear vehículo."""
conductor_id: Optional[int] = None
grupo_id: Optional[int] = None
icono: Optional[str] = Field(None, max_length=50)
color_marcador: str = Field(default="#3B82F6", pattern=r"^#[0-9A-Fa-f]{6}$")
class VehiculoUpdate(BaseSchema):
"""Schema para actualizar vehículo."""
nombre: Optional[str] = Field(None, min_length=2, max_length=100)
placa: Optional[str] = Field(None, min_length=2, max_length=20)
vin: Optional[str] = Field(None, max_length=17)
numero_economico: Optional[str] = Field(None, max_length=50)
marca: Optional[str] = Field(None, max_length=50)
modelo: Optional[str] = Field(None, max_length=50)
año: Optional[int] = Field(None, ge=1900, le=2100)
color: Optional[str] = Field(None, max_length=30)
tipo: Optional[str] = Field(None, max_length=50)
capacidad_carga_kg: Optional[float] = Field(None, ge=0)
capacidad_pasajeros: Optional[int] = Field(None, ge=0)
capacidad_combustible_litros: Optional[float] = Field(None, ge=0)
tipo_combustible: Optional[str] = Field(None, max_length=20)
conductor_id: Optional[int] = None
grupo_id: Optional[int] = None
icono: Optional[str] = Field(None, max_length=50)
color_marcador: Optional[str] = Field(None, pattern=r"^#[0-9A-Fa-f]{6}$")
activo: Optional[bool] = None
en_servicio: Optional[bool] = None
notas: Optional[str] = None
class VehiculoResponse(VehiculoBase, TimestampSchema):
"""Schema de respuesta de vehículo."""
id: int
odometro_actual: float
icono: Optional[str] = None
color_marcador: str
conductor_id: Optional[int] = None
grupo_id: Optional[int] = None
activo: bool
en_servicio: bool
notas: Optional[str] = None
# Última ubicación
ultima_lat: Optional[float] = None
ultima_lng: Optional[float] = None
ultima_velocidad: Optional[float] = None
ultimo_rumbo: Optional[float] = None
ultima_ubicacion_tiempo: Optional[datetime] = None
motor_encendido: Optional[bool] = None
# Calculados
distancia_recorrida: float
class VehiculoResumen(BaseSchema):
"""Schema resumido de vehículo para listas."""
id: int
nombre: str
placa: str
marca: Optional[str] = None
modelo: Optional[str] = None
color_marcador: str
activo: bool
en_servicio: bool
# Estado actual
ultima_lat: Optional[float] = None
ultima_lng: Optional[float] = None
ultima_velocidad: Optional[float] = None
motor_encendido: Optional[bool] = None
ultima_ubicacion_tiempo: Optional[datetime] = None
class VehiculoConRelaciones(VehiculoResponse):
"""Schema de vehículo con relaciones expandidas."""
conductor: Optional["ConductorResumen"] = None
grupo: Optional["GrupoVehiculosResponse"] = None
dispositivos: List["DispositivoResumen"] = []
class VehiculoUbicacionActual(BaseSchema):
"""Schema para ubicación actual de vehículo (dashboard/mapa)."""
id: int
nombre: str
placa: str
color_marcador: str
icono: Optional[str] = None
# Ubicación
lat: Optional[float] = None
lng: Optional[float] = None
velocidad: Optional[float] = None
rumbo: Optional[float] = None
tiempo: Optional[datetime] = None
# Estado
motor_encendido: Optional[bool] = None
en_movimiento: bool = False
conductor_nombre: Optional[str] = None
class VehiculoEstadisticas(BaseSchema):
"""Estadísticas de un vehículo."""
vehiculo_id: int
nombre: str
placa: str
# Distancia
distancia_hoy_km: float
distancia_semana_km: float
distancia_mes_km: float
distancia_total_km: float
# Tiempo
tiempo_movimiento_hoy_min: int
tiempo_parado_hoy_min: int
# Combustible
consumo_mes_litros: Optional[float] = None
rendimiento_km_litro: Optional[float] = None
# Alertas
alertas_activas: int
alertas_mes: int
# Mantenimiento
proximo_mantenimiento: Optional[datetime] = None
mantenimientos_vencidos: int
# Import circular fix
from app.schemas.conductor import ConductorResumen # noqa: E402
from app.schemas.grupo_vehiculos import GrupoVehiculosResponse # noqa: E402
from app.schemas.dispositivo import DispositivoResumen # noqa: E402
VehiculoConRelaciones.model_rebuild()

View File

@@ -0,0 +1,168 @@
"""
Schemas Pydantic para Viaje y Parada.
"""
from datetime import datetime
from typing import List, Optional
from pydantic import Field
from app.schemas.base import BaseSchema, TimestampSchema
class ViajeBase(BaseSchema):
"""Schema base de viaje."""
vehiculo_id: int
conductor_id: Optional[int] = None
proposito: Optional[str] = Field(None, max_length=100)
notas: Optional[str] = None
class ViajeCreate(ViajeBase):
"""Schema para crear viaje manualmente."""
inicio_tiempo: datetime
inicio_lat: float
inicio_lng: float
inicio_direccion: Optional[str] = None
class ViajeUpdate(BaseSchema):
"""Schema para actualizar viaje."""
conductor_id: Optional[int] = None
proposito: Optional[str] = Field(None, max_length=100)
notas: Optional[str] = None
estado: Optional[str] = Field(None, pattern="^(en_curso|completado|cancelado)$")
class ViajeResponse(ViajeBase, TimestampSchema):
"""Schema de respuesta de viaje."""
id: int
inicio_tiempo: datetime
fin_tiempo: Optional[datetime] = None
inicio_lat: float
inicio_lng: float
inicio_direccion: Optional[str] = None
fin_lat: Optional[float] = None
fin_lng: Optional[float] = None
fin_direccion: Optional[str] = None
distancia_km: Optional[float] = None
duracion_segundos: Optional[int] = None
tiempo_movimiento_segundos: Optional[int] = None
tiempo_parado_segundos: Optional[int] = None
velocidad_promedio: Optional[float] = None
velocidad_maxima: Optional[float] = None
combustible_usado: Optional[float] = None
rendimiento: Optional[float] = None
odometro_inicio: Optional[float] = None
odometro_fin: Optional[float] = None
estado: str
puntos_gps: int
# Calculados
duracion_formateada: str
en_curso: bool
class ViajeResumen(BaseSchema):
"""Schema resumido de viaje para listas."""
id: int
vehiculo_id: int
vehiculo_nombre: Optional[str] = None
vehiculo_placa: Optional[str] = None
conductor_nombre: Optional[str] = None
inicio_tiempo: datetime
fin_tiempo: Optional[datetime] = None
inicio_direccion: Optional[str] = None
fin_direccion: Optional[str] = None
distancia_km: Optional[float] = None
duracion_formateada: str
estado: str
class ViajeConParadas(ViajeResponse):
"""Schema de viaje con lista de paradas."""
paradas: List["ParadaResponse"] = []
class ViajeReplayData(BaseSchema):
"""Schema para datos de replay de viaje."""
viaje: ViajeResponse
ubicaciones: List["UbicacionResponse"]
paradas: List["ParadaResponse"]
# ============================================================================
# Schemas de Parada
# ============================================================================
class ParadaBase(BaseSchema):
"""Schema base de parada."""
lat: float = Field(..., ge=-90, le=90)
lng: float = Field(..., ge=-180, le=180)
tipo: str = Field(default="desconocido", max_length=50)
notas: Optional[str] = None
class ParadaCreate(ParadaBase):
"""Schema para crear parada manualmente."""
viaje_id: Optional[int] = None
vehiculo_id: int
inicio_tiempo: datetime
fin_tiempo: Optional[datetime] = None
direccion: Optional[str] = Field(None, max_length=255)
motor_apagado: Optional[bool] = None
class ParadaUpdate(BaseSchema):
"""Schema para actualizar parada."""
tipo: Optional[str] = Field(None, max_length=50)
direccion: Optional[str] = Field(None, max_length=255)
notas: Optional[str] = None
motor_apagado: Optional[bool] = None
class ParadaResponse(ParadaBase):
"""Schema de respuesta de parada."""
id: int
viaje_id: Optional[int] = None
vehiculo_id: int
inicio_tiempo: datetime
fin_tiempo: Optional[datetime] = None
duracion_segundos: Optional[int] = None
direccion: Optional[str] = None
motor_apagado: Optional[bool] = None
poi_id: Optional[int] = None
geocerca_id: Optional[int] = None
en_curso: bool
# Calculado
duracion_formateada: str
class ParadaResumen(BaseSchema):
"""Schema resumido de parada."""
id: int
vehiculo_id: int
inicio_tiempo: datetime
duracion_formateada: str
tipo: str
direccion: Optional[str] = None
# Import fix
from app.schemas.ubicacion import UbicacionResponse # noqa: E402
ViajeReplayData.model_rebuild()

View File

@@ -0,0 +1,264 @@
"""
Schemas Pydantic para Cámara, Grabación y Evento de Video.
"""
from datetime import datetime
from typing import List, Optional
from pydantic import Field
from app.schemas.base import BaseSchema, TimestampSchema
# ============================================================================
# Schemas de Cámara
# ============================================================================
class CamaraBase(BaseSchema):
"""Schema base de cámara."""
nombre: str = Field(..., min_length=2, max_length=100)
posicion: str = Field(default="frontal", max_length=50)
tipo: str = Field(default="ip", max_length=50)
class CamaraCreate(CamaraBase):
"""Schema para crear cámara."""
vehiculo_id: int
marca: Optional[str] = Field(None, max_length=50)
modelo: Optional[str] = Field(None, max_length=50)
numero_serie: Optional[str] = Field(None, max_length=100)
resolucion: Optional[str] = Field(None, max_length=20)
url_stream: Optional[str] = Field(None, max_length=500)
puerto: Optional[int] = Field(None, ge=1, le=65535)
protocolo: str = Field(default="rtsp", max_length=20)
usuario: Optional[str] = Field(None, max_length=100)
password: Optional[str] = Field(None, max_length=100) # Se encriptará
mediamtx_path: Optional[str] = Field(None, max_length=100)
grabacion_continua: bool = False
grabacion_evento: bool = True
duracion_pre_evento: int = Field(default=10, ge=0, le=60)
duracion_post_evento: int = Field(default=20, ge=0, le=120)
deteccion_colision: bool = False
deteccion_distraccion: bool = False
deteccion_fatiga: bool = False
deteccion_cambio_carril: bool = False
notas: Optional[str] = None
class CamaraUpdate(BaseSchema):
"""Schema para actualizar cámara."""
nombre: Optional[str] = Field(None, min_length=2, max_length=100)
posicion: Optional[str] = Field(None, max_length=50)
tipo: Optional[str] = Field(None, max_length=50)
marca: Optional[str] = Field(None, max_length=50)
modelo: Optional[str] = Field(None, max_length=50)
numero_serie: Optional[str] = Field(None, max_length=100)
resolucion: Optional[str] = Field(None, max_length=20)
url_stream: Optional[str] = Field(None, max_length=500)
puerto: Optional[int] = Field(None, ge=1, le=65535)
protocolo: Optional[str] = Field(None, max_length=20)
usuario: Optional[str] = Field(None, max_length=100)
password: Optional[str] = Field(None, max_length=100)
mediamtx_path: Optional[str] = Field(None, max_length=100)
grabacion_continua: Optional[bool] = None
grabacion_evento: Optional[bool] = None
duracion_pre_evento: Optional[int] = Field(None, ge=0, le=60)
duracion_post_evento: Optional[int] = Field(None, ge=0, le=120)
deteccion_colision: Optional[bool] = None
deteccion_distraccion: Optional[bool] = None
deteccion_fatiga: Optional[bool] = None
deteccion_cambio_carril: Optional[bool] = None
activa: Optional[bool] = None
notas: Optional[str] = None
class CamaraResponse(CamaraBase, TimestampSchema):
"""Schema de respuesta de cámara."""
id: int
vehiculo_id: int
marca: Optional[str] = None
modelo: Optional[str] = None
numero_serie: Optional[str] = None
resolucion: Optional[str] = None
url_stream: Optional[str] = None
puerto: Optional[int] = None
protocolo: str
usuario: Optional[str] = None
# password no se expone
mediamtx_path: Optional[str] = None
estado: str
activa: bool
ultima_conexion: Optional[datetime] = None
grabacion_continua: bool
grabacion_evento: bool
duracion_pre_evento: int
duracion_post_evento: int
deteccion_colision: bool
deteccion_distraccion: bool
deteccion_fatiga: bool
deteccion_cambio_carril: bool
notas: Optional[str] = None
class CamaraConVehiculo(CamaraResponse):
"""Schema de cámara con información del vehículo."""
vehiculo_nombre: Optional[str] = None
vehiculo_placa: Optional[str] = None
class CamaraStreamURL(BaseSchema):
"""Schema con URLs de streaming de una cámara."""
camara_id: int
camara_nombre: str
rtsp_url: Optional[str] = None
hls_url: Optional[str] = None
webrtc_url: Optional[str] = None
estado: str
# ============================================================================
# Schemas de Grabación
# ============================================================================
class GrabacionBase(BaseSchema):
"""Schema base de grabación."""
camara_id: int
vehiculo_id: int
inicio_tiempo: datetime
tipo: str = Field(default="continua", max_length=50)
class GrabacionCreate(GrabacionBase):
"""Schema para crear registro de grabación."""
archivo_url: str = Field(..., max_length=500)
archivo_nombre: str = Field(..., max_length=255)
formato: str = Field(default="mp4", max_length=10)
class GrabacionResponse(GrabacionBase, TimestampSchema):
"""Schema de respuesta de grabación."""
id: int
fin_tiempo: Optional[datetime] = None
duracion_segundos: Optional[int] = None
archivo_url: str
archivo_nombre: str
tamaño_mb: Optional[float] = None
formato: str
resolucion: Optional[str] = None
evento_video_id: Optional[int] = None
lat: Optional[float] = None
lng: Optional[float] = None
estado: str
thumbnail_url: Optional[str] = None
notas: Optional[str] = None
# Calculado
duracion_formateada: str
class GrabacionResumen(BaseSchema):
"""Schema resumido de grabación."""
id: int
camara_id: int
vehiculo_id: int
inicio_tiempo: datetime
duracion_formateada: str
tipo: str
thumbnail_url: Optional[str] = None
# ============================================================================
# Schemas de Evento de Video
# ============================================================================
class EventoVideoBase(BaseSchema):
"""Schema base de evento de video."""
camara_id: int
vehiculo_id: int
tipo: str = Field(..., max_length=50)
severidad: str = Field(default="media", pattern="^(baja|media|alta|critica)$")
tiempo: datetime
class EventoVideoCreate(EventoVideoBase):
"""Schema para crear evento de video."""
lat: Optional[float] = Field(None, ge=-90, le=90)
lng: Optional[float] = Field(None, ge=-180, le=180)
velocidad: Optional[float] = Field(None, ge=0)
descripcion: Optional[str] = None
confianza: Optional[float] = Field(None, ge=0, le=100)
datos_extra: Optional[str] = None
snapshot_url: Optional[str] = Field(None, max_length=500)
clip_url: Optional[str] = Field(None, max_length=500)
clip_duracion: Optional[int] = Field(None, ge=0)
class EventoVideoUpdate(BaseSchema):
"""Schema para actualizar evento de video."""
revisado: Optional[bool] = None
notas_revision: Optional[str] = None
falso_positivo: Optional[bool] = None
class EventoVideoResponse(EventoVideoBase, TimestampSchema):
"""Schema de respuesta de evento de video."""
id: int
lat: Optional[float] = None
lng: Optional[float] = None
velocidad: Optional[float] = None
descripcion: Optional[str] = None
confianza: Optional[float] = None
datos_extra: Optional[str] = None
revisado: bool
revisado_por_id: Optional[int] = None
revisado_en: Optional[datetime] = None
notas_revision: Optional[str] = None
falso_positivo: bool
snapshot_url: Optional[str] = None
clip_url: Optional[str] = None
clip_duracion: Optional[int] = None
class EventoVideoConRelaciones(EventoVideoResponse):
"""Schema con información de cámara y vehículo."""
camara_nombre: Optional[str] = None
vehiculo_nombre: Optional[str] = None
vehiculo_placa: Optional[str] = None
class EventoVideoResumen(BaseSchema):
"""Schema resumido de evento de video."""
id: int
tipo: str
severidad: str
tiempo: datetime
vehiculo_nombre: str
camara_nombre: str
revisado: bool
falso_positivo: bool
snapshot_url: Optional[str] = None
class TiposEventoVideoResponse(BaseSchema):
"""Schema con tipos de eventos de video disponibles."""
tipos: List[dict] # [{codigo, nombre, severidad}]

View File

@@ -0,0 +1,23 @@
"""
Módulo de servicios de lógica de negocio.
"""
from app.services.ubicacion_service import UbicacionService
from app.services.geocerca_service import GeocercaService
from app.services.alerta_service import AlertaService
from app.services.viaje_service import ViajeService
from app.services.traccar_service import TraccarService
from app.services.video_service import VideoService
from app.services.reporte_service import ReporteService
from app.services.notificacion_service import NotificacionService
__all__ = [
"UbicacionService",
"GeocercaService",
"AlertaService",
"ViajeService",
"TraccarService",
"VideoService",
"ReporteService",
"NotificacionService",
]

View File

@@ -0,0 +1,495 @@
"""
Servicio para gestión y generación de alertas.
Motor de reglas que detecta y genera alertas basándose
en ubicaciones, velocidad, geocercas, batería, etc.
"""
from datetime import datetime, timedelta, timezone
from typing import List, Optional
from sqlalchemy import and_, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.models.alerta import Alerta
from app.models.tipo_alerta import TipoAlerta
from app.models.vehiculo import Vehiculo
from app.schemas.alerta import AlertaCreate, AlertaResponse
from app.services.geocerca_service import GeocercaService
class AlertaService:
"""Servicio para gestión de alertas."""
# Cache de tipos de alerta (código -> id)
_tipos_alerta_cache: dict = {}
def __init__(self, db: AsyncSession):
"""
Inicializa el servicio.
Args:
db: Sesión de base de datos async.
"""
self.db = db
self.geocerca_service = GeocercaService(db)
async def _obtener_tipo_alerta_id(self, codigo: str) -> Optional[int]:
"""
Obtiene el ID de un tipo de alerta por su código.
Args:
codigo: Código del tipo de alerta.
Returns:
ID del tipo de alerta o None.
"""
if codigo in self._tipos_alerta_cache:
return self._tipos_alerta_cache[codigo]
result = await self.db.execute(
select(TipoAlerta).where(TipoAlerta.codigo == codigo)
)
tipo = result.scalar_one_or_none()
if tipo:
self._tipos_alerta_cache[codigo] = tipo.id
return tipo.id
return None
async def verificar_velocidad(
self,
vehiculo_id: int,
velocidad: float,
lat: float,
lng: float,
limite_general: float = None,
) -> Optional[Alerta]:
"""
Verifica si la velocidad excede el límite.
Args:
vehiculo_id: ID del vehículo.
velocidad: Velocidad actual en km/h.
lat: Latitud actual.
lng: Longitud actual.
limite_general: Límite de velocidad general (si no, usa config).
Returns:
Alerta creada si excede el límite, None si no.
"""
limite = limite_general or settings.ALERT_SPEED_LIMIT_DEFAULT
if velocidad <= limite:
return None
tipo_alerta_id = await self._obtener_tipo_alerta_id("EXCESO_VELOCIDAD")
if not tipo_alerta_id:
return None
# Verificar si ya existe una alerta reciente (últimos 5 minutos)
tiempo_limite = datetime.now(timezone.utc) - timedelta(minutes=5)
result = await self.db.execute(
select(Alerta)
.where(
and_(
Alerta.vehiculo_id == vehiculo_id,
Alerta.tipo_alerta_id == tipo_alerta_id,
Alerta.creado_en >= tiempo_limite,
)
)
)
if result.scalar_one_or_none():
return None # Ya existe una alerta reciente
# Crear alerta
alerta = Alerta(
vehiculo_id=vehiculo_id,
tipo_alerta_id=tipo_alerta_id,
severidad="media" if velocidad < limite * 1.2 else "alta",
mensaje=f"Exceso de velocidad: {velocidad:.1f} km/h (límite: {limite} km/h)",
lat=lat,
lng=lng,
velocidad=velocidad,
valor=velocidad,
umbral=limite,
)
self.db.add(alerta)
await self.db.commit()
await self.db.refresh(alerta)
return alerta
async def verificar_geocercas(
self,
vehiculo_id: int,
lat: float,
lng: float,
estado_anterior: dict = None,
) -> List[Alerta]:
"""
Verifica transiciones de entrada/salida de geocercas.
Args:
vehiculo_id: ID del vehículo.
lat: Latitud actual.
lng: Longitud actual.
estado_anterior: Estado de geocercas anterior {geocerca_id: dentro}.
Returns:
Lista de alertas generadas.
"""
alertas = []
estado_anterior = estado_anterior or {}
resultados = await self.geocerca_service.verificar_todas_geocercas(
lat, lng, vehiculo_id
)
for r in resultados:
geocerca_id = r["geocerca_id"]
dentro = r["dentro"]
estaba_dentro = estado_anterior.get(geocerca_id, None)
# Entrada a geocerca
if dentro and not estaba_dentro and r["alerta_entrada"]:
tipo_id = await self._obtener_tipo_alerta_id("ENTRADA_GEOCERCA")
if tipo_id:
alerta = Alerta(
vehiculo_id=vehiculo_id,
tipo_alerta_id=tipo_id,
severidad="baja",
mensaje=f"Entrada a geocerca: {r['geocerca_nombre']}",
lat=lat,
lng=lng,
)
self.db.add(alerta)
alertas.append(alerta)
# Salida de geocerca
elif not dentro and estaba_dentro and r["alerta_salida"]:
tipo_id = await self._obtener_tipo_alerta_id("SALIDA_GEOCERCA")
if tipo_id:
alerta = Alerta(
vehiculo_id=vehiculo_id,
tipo_alerta_id=tipo_id,
severidad="media",
mensaje=f"Salida de geocerca: {r['geocerca_nombre']}",
lat=lat,
lng=lng,
)
self.db.add(alerta)
alertas.append(alerta)
if alertas:
await self.db.commit()
return alertas
async def verificar_bateria_baja(
self,
vehiculo_id: int,
bateria: float,
lat: float,
lng: float,
dispositivo_id: int = None,
) -> Optional[Alerta]:
"""
Verifica si la batería del dispositivo está baja.
Args:
vehiculo_id: ID del vehículo.
bateria: Porcentaje de batería.
lat: Latitud actual.
lng: Longitud actual.
dispositivo_id: ID del dispositivo (opcional).
Returns:
Alerta creada si la batería está baja.
"""
if bateria > settings.ALERT_BATTERY_LOW_PERCENT:
return None
tipo_alerta_id = await self._obtener_tipo_alerta_id("BATERIA_BAJA")
if not tipo_alerta_id:
return None
# Verificar si ya existe una alerta reciente (últimas 2 horas)
tiempo_limite = datetime.now(timezone.utc) - timedelta(hours=2)
result = await self.db.execute(
select(Alerta)
.where(
and_(
Alerta.vehiculo_id == vehiculo_id,
Alerta.tipo_alerta_id == tipo_alerta_id,
Alerta.creado_en >= tiempo_limite,
)
)
)
if result.scalar_one_or_none():
return None
severidad = "alta" if bateria < 10 else "media"
alerta = Alerta(
vehiculo_id=vehiculo_id,
dispositivo_id=dispositivo_id,
tipo_alerta_id=tipo_alerta_id,
severidad=severidad,
mensaje=f"Batería baja del dispositivo: {bateria:.0f}%",
lat=lat,
lng=lng,
valor=bateria,
umbral=settings.ALERT_BATTERY_LOW_PERCENT,
)
self.db.add(alerta)
await self.db.commit()
await self.db.refresh(alerta)
return alerta
async def verificar_sin_señal(self) -> List[Alerta]:
"""
Verifica vehículos que no han reportado ubicación.
Busca vehículos activos cuya última ubicación sea mayor
al tiempo configurado.
Returns:
Lista de alertas generadas.
"""
alertas = []
tiempo_limite = datetime.now(timezone.utc) - timedelta(
minutes=settings.ALERT_NO_SIGNAL_MINUTES
)
result = await self.db.execute(
select(Vehiculo)
.where(Vehiculo.activo == True)
.where(Vehiculo.en_servicio == True)
.where(Vehiculo.ultima_ubicacion_tiempo < tiempo_limite)
)
vehiculos = result.scalars().all()
tipo_alerta_id = await self._obtener_tipo_alerta_id("SIN_SEÑAL")
if not tipo_alerta_id:
return alertas
for v in vehiculos:
# Verificar si ya existe una alerta reciente (últimas 2 horas)
tiempo_alerta_limite = datetime.now(timezone.utc) - timedelta(hours=2)
result = await self.db.execute(
select(Alerta)
.where(
and_(
Alerta.vehiculo_id == v.id,
Alerta.tipo_alerta_id == tipo_alerta_id,
Alerta.creado_en >= tiempo_alerta_limite,
)
)
)
if result.scalar_one_or_none():
continue
minutos_sin_señal = int(
(datetime.now(timezone.utc) - v.ultima_ubicacion_tiempo).total_seconds() / 60
)
alerta = Alerta(
vehiculo_id=v.id,
tipo_alerta_id=tipo_alerta_id,
severidad="alta",
mensaje=f"Sin señal GPS por {minutos_sin_señal} minutos",
lat=v.ultima_lat,
lng=v.ultima_lng,
valor=minutos_sin_señal,
umbral=settings.ALERT_NO_SIGNAL_MINUTES,
)
self.db.add(alerta)
alertas.append(alerta)
if alertas:
await self.db.commit()
return alertas
async def crear_alerta(
self,
alerta_data: AlertaCreate,
) -> Alerta:
"""
Crea una alerta manualmente.
Args:
alerta_data: Datos de la alerta.
Returns:
Alerta creada.
"""
alerta = Alerta(
vehiculo_id=alerta_data.vehiculo_id,
conductor_id=alerta_data.conductor_id,
tipo_alerta_id=alerta_data.tipo_alerta_id,
dispositivo_id=alerta_data.dispositivo_id,
severidad=alerta_data.severidad,
mensaje=alerta_data.mensaje,
descripcion=alerta_data.descripcion,
lat=alerta_data.lat,
lng=alerta_data.lng,
direccion=alerta_data.direccion,
velocidad=alerta_data.velocidad,
valor=alerta_data.valor,
umbral=alerta_data.umbral,
datos_extra=alerta_data.datos_extra,
)
self.db.add(alerta)
await self.db.commit()
await self.db.refresh(alerta)
return alerta
async def marcar_atendida(
self,
alerta_id: int,
usuario_id: int,
notas: str = None,
) -> Optional[Alerta]:
"""
Marca una alerta como atendida.
Args:
alerta_id: ID de la alerta.
usuario_id: ID del usuario que atiende.
notas: Notas de atención (opcional).
Returns:
Alerta actualizada o None si no existe.
"""
result = await self.db.execute(
select(Alerta).where(Alerta.id == alerta_id)
)
alerta = result.scalar_one_or_none()
if not alerta:
return None
alerta.atendida = True
alerta.atendida_por_id = usuario_id
alerta.atendida_en = datetime.now(timezone.utc)
alerta.notas_atencion = notas
await self.db.commit()
await self.db.refresh(alerta)
return alerta
async def obtener_alertas_pendientes(
self,
vehiculo_id: int = None,
severidad: str = None,
limite: int = 50,
) -> List[Alerta]:
"""
Obtiene alertas pendientes de atender.
Args:
vehiculo_id: Filtrar por vehículo (opcional).
severidad: Filtrar por severidad (opcional).
limite: Límite de resultados.
Returns:
Lista de alertas pendientes.
"""
query = (
select(Alerta)
.where(Alerta.atendida == False)
.order_by(
Alerta.severidad.desc(), # Críticas primero
Alerta.creado_en.desc()
)
.limit(limite)
)
if vehiculo_id:
query = query.where(Alerta.vehiculo_id == vehiculo_id)
if severidad:
query = query.where(Alerta.severidad == severidad)
result = await self.db.execute(query)
return result.scalars().all()
async def obtener_estadisticas(
self,
desde: datetime = None,
hasta: datetime = None,
) -> dict:
"""
Obtiene estadísticas de alertas.
Args:
desde: Fecha inicio (opcional).
hasta: Fecha fin (opcional).
Returns:
Diccionario con estadísticas.
"""
desde = desde or (datetime.now(timezone.utc) - timedelta(days=30))
hasta = hasta or datetime.now(timezone.utc)
# Total de alertas
result = await self.db.execute(
select(func.count(Alerta.id))
.where(Alerta.creado_en >= desde)
.where(Alerta.creado_en <= hasta)
)
total = result.scalar()
# Pendientes
result = await self.db.execute(
select(func.count(Alerta.id))
.where(Alerta.atendida == False)
.where(Alerta.creado_en >= desde)
.where(Alerta.creado_en <= hasta)
)
pendientes = result.scalar()
# Por severidad
result = await self.db.execute(
select(Alerta.severidad, func.count(Alerta.id))
.where(Alerta.creado_en >= desde)
.where(Alerta.creado_en <= hasta)
.group_by(Alerta.severidad)
)
por_severidad = {row[0]: row[1] for row in result.all()}
# Por tipo
result = await self.db.execute(
select(TipoAlerta.codigo, TipoAlerta.nombre, func.count(Alerta.id))
.join(TipoAlerta, Alerta.tipo_alerta_id == TipoAlerta.id)
.where(Alerta.creado_en >= desde)
.where(Alerta.creado_en <= hasta)
.group_by(TipoAlerta.codigo, TipoAlerta.nombre)
.order_by(func.count(Alerta.id).desc())
)
por_tipo = [
{"codigo": row[0], "nombre": row[1], "cantidad": row[2]}
for row in result.all()
]
return {
"total": total,
"pendientes": pendientes,
"atendidas": total - pendientes,
"criticas": por_severidad.get("critica", 0),
"altas": por_severidad.get("alta", 0),
"medias": por_severidad.get("media", 0),
"bajas": por_severidad.get("baja", 0),
"por_tipo": por_tipo,
}

View File

@@ -0,0 +1,351 @@
"""
Servicio para gestión de geocercas.
Proporciona funcionalidades para verificar si un punto está dentro
de una geocerca y calcular distancias.
"""
import json
import math
from typing import List, Optional, Tuple
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.geocerca import Geocerca
class GeocercaService:
"""Servicio para operaciones con geocercas."""
def __init__(self, db: AsyncSession):
"""
Inicializa el servicio.
Args:
db: Sesión de base de datos async.
"""
self.db = db
async def verificar_punto_en_geocerca(
self,
lat: float,
lng: float,
geocerca_id: int,
) -> Tuple[bool, Optional[float]]:
"""
Verifica si un punto está dentro de una geocerca.
Args:
lat: Latitud del punto.
lng: Longitud del punto.
geocerca_id: ID de la geocerca.
Returns:
Tupla (está_dentro, distancia_al_borde_metros).
distancia es None si está dentro, o la distancia al borde si está fuera.
"""
result = await self.db.execute(
select(Geocerca).where(Geocerca.id == geocerca_id)
)
geocerca = result.scalar_one_or_none()
if not geocerca:
return False, None
if geocerca.tipo == "circular":
return self._punto_en_circulo(
lat, lng,
geocerca.centro_lat, geocerca.centro_lng,
geocerca.radio_metros
)
else:
coordenadas = json.loads(geocerca.coordenadas_json) if geocerca.coordenadas_json else []
return self._punto_en_poligono(lat, lng, coordenadas)
async def obtener_geocercas_activas_para_vehiculo(
self,
vehiculo_id: int,
) -> List[Geocerca]:
"""
Obtiene las geocercas activas aplicables a un vehículo.
Args:
vehiculo_id: ID del vehículo.
Returns:
Lista de geocercas aplicables.
"""
# Geocercas sin vehículos asignados (aplican a todos)
# o con este vehículo asignado
result = await self.db.execute(
select(Geocerca)
.where(Geocerca.activa == True)
)
todas_geocercas = result.scalars().all()
geocercas_aplicables = []
for g in todas_geocercas:
# Si no tiene vehículos asignados, aplica a todos
if not g.vehiculos_asignados:
geocercas_aplicables.append(g)
# Si tiene vehículos asignados, verificar si incluye este
elif any(v.id == vehiculo_id for v in g.vehiculos_asignados):
geocercas_aplicables.append(g)
return geocercas_aplicables
async def verificar_todas_geocercas(
self,
lat: float,
lng: float,
vehiculo_id: int,
) -> List[dict]:
"""
Verifica un punto contra todas las geocercas aplicables.
Args:
lat: Latitud del punto.
lng: Longitud del punto.
vehiculo_id: ID del vehículo.
Returns:
Lista de geocercas con información de si está dentro o fuera.
"""
geocercas = await self.obtener_geocercas_activas_para_vehiculo(vehiculo_id)
resultados = []
for g in geocercas:
if g.tipo == "circular":
dentro, distancia = self._punto_en_circulo(
lat, lng,
g.centro_lat, g.centro_lng,
g.radio_metros
)
else:
coordenadas = json.loads(g.coordenadas_json) if g.coordenadas_json else []
dentro, distancia = self._punto_en_poligono(lat, lng, coordenadas)
resultados.append({
"geocerca_id": g.id,
"geocerca_nombre": g.nombre,
"dentro": dentro,
"distancia_metros": distancia,
"alerta_entrada": g.alerta_entrada,
"alerta_salida": g.alerta_salida,
"velocidad_maxima": g.velocidad_maxima,
})
return resultados
def _punto_en_circulo(
self,
lat: float,
lng: float,
centro_lat: float,
centro_lng: float,
radio_metros: float,
) -> Tuple[bool, Optional[float]]:
"""
Verifica si un punto está dentro de un círculo.
Args:
lat, lng: Coordenadas del punto.
centro_lat, centro_lng: Centro del círculo.
radio_metros: Radio del círculo.
Returns:
(está_dentro, distancia_al_borde).
"""
distancia = self._distancia_haversine(lat, lng, centro_lat, centro_lng)
distancia_metros = distancia * 1000 # km a metros
dentro = distancia_metros <= radio_metros
if dentro:
return True, None
else:
return False, distancia_metros - radio_metros
def _punto_en_poligono(
self,
lat: float,
lng: float,
coordenadas: List[List[float]],
) -> Tuple[bool, Optional[float]]:
"""
Verifica si un punto está dentro de un polígono.
Usa el algoritmo ray casting.
Args:
lat, lng: Coordenadas del punto.
coordenadas: Lista de coordenadas [[lat, lng], ...].
Returns:
(está_dentro, distancia_al_borde).
"""
if not coordenadas or len(coordenadas) < 3:
return False, None
n = len(coordenadas)
dentro = False
j = n - 1
for i in range(n):
yi, xi = coordenadas[i][0], coordenadas[i][1]
yj, xj = coordenadas[j][0], coordenadas[j][1]
if ((yi > lat) != (yj > lat)) and (
lng < (xj - xi) * (lat - yi) / (yj - yi) + xi
):
dentro = not dentro
j = i
if dentro:
return True, None
else:
# Calcular distancia al borde más cercano
distancia_min = float('inf')
for i in range(n):
j = (i + 1) % n
d = self._distancia_punto_segmento(
lat, lng,
coordenadas[i][0], coordenadas[i][1],
coordenadas[j][0], coordenadas[j][1]
)
if d < distancia_min:
distancia_min = d
return False, distancia_min * 1000 # km a metros
def _distancia_haversine(
self,
lat1: float,
lng1: float,
lat2: float,
lng2: float,
) -> float:
"""
Calcula la distancia entre dos puntos usando Haversine.
Args:
lat1, lng1: Primer punto.
lat2, lng2: Segundo punto.
Returns:
Distancia en kilómetros.
"""
R = 6371 # Radio de la Tierra en km
lat1_rad = math.radians(lat1)
lat2_rad = math.radians(lat2)
dlat = math.radians(lat2 - lat1)
dlng = math.radians(lng2 - lng1)
a = (
math.sin(dlat / 2) ** 2
+ math.cos(lat1_rad) * math.cos(lat2_rad) * math.sin(dlng / 2) ** 2
)
c = 2 * math.asin(math.sqrt(a))
return R * c
def _distancia_punto_segmento(
self,
px: float,
py: float,
x1: float,
y1: float,
x2: float,
y2: float,
) -> float:
"""
Calcula la distancia de un punto a un segmento de línea.
Args:
px, py: Punto.
x1, y1, x2, y2: Extremos del segmento.
Returns:
Distancia en kilómetros.
"""
# Longitud del segmento al cuadrado
l2 = (x2 - x1) ** 2 + (y2 - y1) ** 2
if l2 == 0:
# El segmento es un punto
return self._distancia_haversine(px, py, x1, y1)
# Proyección del punto sobre la línea
t = max(0, min(1, ((px - x1) * (x2 - x1) + (py - y1) * (y2 - y1)) / l2))
# Punto más cercano en el segmento
proj_x = x1 + t * (x2 - x1)
proj_y = y1 + t * (y2 - y1)
return self._distancia_haversine(px, py, proj_x, proj_y)
@staticmethod
def calcular_area_poligono(coordenadas: List[List[float]]) -> float:
"""
Calcula el área de un polígono en metros cuadrados.
Args:
coordenadas: Lista de coordenadas [[lat, lng], ...].
Returns:
Área en metros cuadrados.
"""
if len(coordenadas) < 3:
return 0.0
# Usar la fórmula del topógrafo (Shoelace) con conversión a metros
n = len(coordenadas)
area = 0.0
# Factor de conversión aproximado para grados a metros
# (varía según la latitud)
lat_media = sum(c[0] for c in coordenadas) / n
m_per_deg_lat = 111132.92 - 559.82 * math.cos(2 * math.radians(lat_media))
m_per_deg_lng = 111412.84 * math.cos(math.radians(lat_media))
for i in range(n):
j = (i + 1) % n
xi = coordenadas[i][1] * m_per_deg_lng
yi = coordenadas[i][0] * m_per_deg_lat
xj = coordenadas[j][1] * m_per_deg_lng
yj = coordenadas[j][0] * m_per_deg_lat
area += xi * yj - xj * yi
return abs(area) / 2
@staticmethod
def calcular_perimetro_poligono(coordenadas: List[List[float]]) -> float:
"""
Calcula el perímetro de un polígono en metros.
Args:
coordenadas: Lista de coordenadas [[lat, lng], ...].
Returns:
Perímetro en metros.
"""
if len(coordenadas) < 2:
return 0.0
servicio = GeocercaService(None) # Solo para usar método estático
perimetro = 0.0
n = len(coordenadas)
for i in range(n):
j = (i + 1) % n
d = servicio._distancia_haversine(
coordenadas[i][0], coordenadas[i][1],
coordenadas[j][0], coordenadas[j][1]
)
perimetro += d * 1000 # km a metros
return perimetro

View File

@@ -0,0 +1,348 @@
"""
Servicio para envío de notificaciones.
Maneja el envío de notificaciones por email, push y SMS.
"""
import json
from datetime import datetime, timezone
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from typing import List, Optional
import aiosmtplib
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.models.alerta import Alerta
class NotificacionService:
"""Servicio para envío de notificaciones."""
def __init__(self, db: AsyncSession = None):
"""
Inicializa el servicio.
Args:
db: Sesión de base de datos async (opcional).
"""
self.db = db
async def enviar_notificacion_alerta(
self,
alerta: Alerta,
destinatarios_email: List[str] = None,
) -> dict:
"""
Envía notificaciones para una alerta.
Args:
alerta: Alerta a notificar.
destinatarios_email: Lista de emails (opcional, usa config si no se especifica).
Returns:
Resultado del envío.
"""
resultado = {
"email_enviado": False,
"push_enviado": False,
"sms_enviado": False,
}
# Determinar si enviar cada tipo de notificación
tipo_alerta = alerta.tipo_alerta
if tipo_alerta.notificar_email:
resultado["email_enviado"] = await self.enviar_email_alerta(
alerta,
destinatarios_email,
)
if tipo_alerta.notificar_push:
resultado["push_enviado"] = await self.enviar_push_alerta(alerta)
if tipo_alerta.notificar_sms:
resultado["sms_enviado"] = await self.enviar_sms_alerta(alerta)
# Actualizar estado de notificaciones en la alerta
if self.db:
alerta.notificacion_email_enviada = resultado["email_enviado"]
alerta.notificacion_push_enviada = resultado["push_enviado"]
alerta.notificacion_sms_enviada = resultado["sms_enviado"]
await self.db.commit()
return resultado
async def enviar_email_alerta(
self,
alerta: Alerta,
destinatarios: List[str] = None,
) -> bool:
"""
Envía notificación de alerta por email.
Args:
alerta: Alerta a notificar.
destinatarios: Lista de emails.
Returns:
True si se envió correctamente.
"""
if not settings.SMTP_HOST or not settings.SMTP_USER:
return False
destinatarios = destinatarios or [settings.SMTP_FROM_EMAIL]
# Crear mensaje
mensaje = MIMEMultipart("alternative")
mensaje["Subject"] = f"[{alerta.severidad.upper()}] {alerta.mensaje[:50]}"
mensaje["From"] = f"{settings.SMTP_FROM_NAME} <{settings.SMTP_FROM_EMAIL}>"
mensaje["To"] = ", ".join(destinatarios)
# Contenido HTML
html_content = self._crear_html_alerta(alerta)
mensaje.attach(MIMEText(html_content, "html"))
# Contenido texto plano
text_content = self._crear_texto_alerta(alerta)
mensaje.attach(MIMEText(text_content, "plain"))
try:
async with aiosmtplib.SMTP(
hostname=settings.SMTP_HOST,
port=settings.SMTP_PORT,
use_tls=settings.SMTP_TLS,
) as smtp:
await smtp.login(settings.SMTP_USER, settings.SMTP_PASSWORD)
await smtp.send_message(mensaje)
return True
except Exception as e:
print(f"Error enviando email: {e}")
return False
async def enviar_push_alerta(
self,
alerta: Alerta,
) -> bool:
"""
Envía notificación push de alerta.
Args:
alerta: Alerta a notificar.
Returns:
True si se envió correctamente.
"""
if not settings.FIREBASE_ENABLED:
return False
# TODO: Implementar con Firebase Cloud Messaging
# from firebase_admin import messaging
#
# message = messaging.Message(
# notification=messaging.Notification(
# title=f"Alerta: {alerta.tipo_alerta.nombre}",
# body=alerta.mensaje,
# ),
# topic="alertas",
# )
# messaging.send(message)
return False
async def enviar_sms_alerta(
self,
alerta: Alerta,
) -> bool:
"""
Envía notificación SMS de alerta.
Args:
alerta: Alerta a notificar.
Returns:
True si se envió correctamente.
"""
# TODO: Implementar con Twilio u otro proveedor SMS
return False
async def enviar_email(
self,
destinatarios: List[str],
asunto: str,
contenido_html: str,
contenido_texto: str = None,
) -> bool:
"""
Envía un email genérico.
Args:
destinatarios: Lista de emails.
asunto: Asunto del email.
contenido_html: Contenido HTML.
contenido_texto: Contenido texto plano (opcional).
Returns:
True si se envió correctamente.
"""
if not settings.SMTP_HOST or not settings.SMTP_USER:
return False
mensaje = MIMEMultipart("alternative")
mensaje["Subject"] = asunto
mensaje["From"] = f"{settings.SMTP_FROM_NAME} <{settings.SMTP_FROM_EMAIL}>"
mensaje["To"] = ", ".join(destinatarios)
mensaje.attach(MIMEText(contenido_html, "html"))
if contenido_texto:
mensaje.attach(MIMEText(contenido_texto, "plain"))
try:
async with aiosmtplib.SMTP(
hostname=settings.SMTP_HOST,
port=settings.SMTP_PORT,
use_tls=settings.SMTP_TLS,
) as smtp:
await smtp.login(settings.SMTP_USER, settings.SMTP_PASSWORD)
await smtp.send_message(mensaje)
return True
except Exception as e:
print(f"Error enviando email: {e}")
return False
def _crear_html_alerta(self, alerta: Alerta) -> str:
"""Crea el contenido HTML para el email de alerta."""
color_severidad = {
"baja": "#10B981",
"media": "#F59E0B",
"alta": "#EF4444",
"critica": "#DC2626",
}
color = color_severidad.get(alerta.severidad, "#6B7280")
html = f"""
<!DOCTYPE html>
<html>
<head>
<style>
body {{ font-family: Arial, sans-serif; line-height: 1.6; }}
.container {{ max-width: 600px; margin: 0 auto; padding: 20px; }}
.header {{ background-color: {color}; color: white; padding: 20px; border-radius: 8px 8px 0 0; }}
.content {{ background-color: #f9fafb; padding: 20px; border: 1px solid #e5e7eb; }}
.footer {{ padding: 10px; text-align: center; color: #6b7280; font-size: 12px; }}
.badge {{ display: inline-block; padding: 4px 8px; border-radius: 4px; font-size: 12px; }}
.info-row {{ margin: 10px 0; }}
.label {{ color: #6b7280; }}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h2 style="margin: 0;">Alerta: {alerta.tipo_alerta.nombre if alerta.tipo_alerta else 'Sistema'}</h2>
<span class="badge" style="background-color: rgba(255,255,255,0.2);">
{alerta.severidad.upper()}
</span>
</div>
<div class="content">
<p><strong>{alerta.mensaje}</strong></p>
<div class="info-row">
<span class="label">Fecha/Hora:</span>
{alerta.creado_en.strftime('%Y-%m-%d %H:%M:%S')}
</div>
{'<div class="info-row"><span class="label">Vehiculo ID:</span> ' + str(alerta.vehiculo_id) + '</div>' if alerta.vehiculo_id else ''}
{'<div class="info-row"><span class="label">Ubicacion:</span> ' + str(alerta.lat) + ', ' + str(alerta.lng) + '</div>' if alerta.lat else ''}
{'<div class="info-row"><span class="label">Velocidad:</span> ' + str(alerta.velocidad) + ' km/h</div>' if alerta.velocidad else ''}
{f'<div class="info-row"><span class="label">Descripcion:</span> {alerta.descripcion}</div>' if alerta.descripcion else ''}
</div>
<div class="footer">
<p>Este es un mensaje automatico de {settings.APP_NAME}</p>
</div>
</div>
</body>
</html>
"""
return html
def _crear_texto_alerta(self, alerta: Alerta) -> str:
"""Crea el contenido de texto plano para el email de alerta."""
texto = f"""
ALERTA: {alerta.tipo_alerta.nombre if alerta.tipo_alerta else 'Sistema'}
Severidad: {alerta.severidad.upper()}
{alerta.mensaje}
Fecha/Hora: {alerta.creado_en.strftime('%Y-%m-%d %H:%M:%S')}
"""
if alerta.vehiculo_id:
texto += f"Vehiculo ID: {alerta.vehiculo_id}\n"
if alerta.lat and alerta.lng:
texto += f"Ubicacion: {alerta.lat}, {alerta.lng}\n"
if alerta.velocidad:
texto += f"Velocidad: {alerta.velocidad} km/h\n"
texto += f"\n--\nEste es un mensaje automatico de {settings.APP_NAME}"
return texto
async def enviar_recordatorio_mantenimiento(
self,
vehiculo_nombre: str,
vehiculo_placa: str,
tipo_mantenimiento: str,
fecha_programada: str,
destinatarios: List[str],
) -> bool:
"""
Envía recordatorio de mantenimiento por email.
Args:
vehiculo_nombre: Nombre del vehículo.
vehiculo_placa: Placa del vehículo.
tipo_mantenimiento: Tipo de mantenimiento.
fecha_programada: Fecha programada.
destinatarios: Lista de emails.
Returns:
True si se envió correctamente.
"""
asunto = f"Recordatorio: Mantenimiento próximo - {vehiculo_placa}"
html = f"""
<!DOCTYPE html>
<html>
<head>
<style>
body {{ font-family: Arial, sans-serif; line-height: 1.6; }}
.container {{ max-width: 600px; margin: 0 auto; padding: 20px; }}
.header {{ background-color: #3B82F6; color: white; padding: 20px; border-radius: 8px 8px 0 0; }}
.content {{ background-color: #f9fafb; padding: 20px; border: 1px solid #e5e7eb; }}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h2>Recordatorio de Mantenimiento</h2>
</div>
<div class="content">
<p>Se aproxima la fecha de mantenimiento programado:</p>
<ul>
<li><strong>Vehiculo:</strong> {vehiculo_nombre} ({vehiculo_placa})</li>
<li><strong>Tipo:</strong> {tipo_mantenimiento}</li>
<li><strong>Fecha programada:</strong> {fecha_programada}</li>
</ul>
<p>Por favor, programe el mantenimiento con anticipacion.</p>
</div>
</div>
</body>
</html>
"""
return await self.enviar_email(destinatarios, asunto, html)

View File

@@ -0,0 +1,529 @@
"""
Servicio para generación de reportes.
Genera reportes en PDF y Excel para diferentes tipos de datos.
"""
import io
import json
import uuid
from datetime import datetime, timedelta, timezone
from typing import Any, Dict, List, Optional
from sqlalchemy import and_, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.models.alerta import Alerta
from app.models.carga_combustible import CargaCombustible
from app.models.mantenimiento import Mantenimiento
from app.models.ubicacion import Ubicacion
from app.models.vehiculo import Vehiculo
from app.models.viaje import Viaje
from app.schemas.reporte import (
DashboardGrafico,
DashboardResumen,
ReporteRequest,
ReporteResponse,
)
class ReporteService:
"""Servicio para generación de reportes."""
def __init__(self, db: AsyncSession):
"""
Inicializa el servicio.
Args:
db: Sesión de base de datos async.
"""
self.db = db
async def obtener_dashboard_resumen(self) -> DashboardResumen:
"""
Obtiene el resumen para el dashboard principal.
Returns:
Datos del dashboard.
"""
ahora = datetime.now(timezone.utc)
inicio_hoy = ahora.replace(hour=0, minute=0, second=0, microsecond=0)
# Contadores de vehículos
result = await self.db.execute(
select(func.count(Vehiculo.id)).where(Vehiculo.activo == True)
)
total_vehiculos = result.scalar()
result = await self.db.execute(
select(func.count(Vehiculo.id))
.where(Vehiculo.activo == True)
.where(Vehiculo.en_servicio == True)
)
vehiculos_activos = result.scalar()
# Vehículos en movimiento (velocidad > 5 km/h, última ubicación < 5 min)
tiempo_reciente = ahora - timedelta(minutes=5)
result = await self.db.execute(
select(func.count(Vehiculo.id))
.where(Vehiculo.activo == True)
.where(Vehiculo.ultima_velocidad > 5)
.where(Vehiculo.ultima_ubicacion_tiempo >= tiempo_reciente)
)
vehiculos_en_movimiento = result.scalar()
# Vehículos detenidos (velocidad < 5, ubicación reciente)
result = await self.db.execute(
select(func.count(Vehiculo.id))
.where(Vehiculo.activo == True)
.where(Vehiculo.ultima_velocidad <= 5)
.where(Vehiculo.ultima_ubicacion_tiempo >= tiempo_reciente)
)
vehiculos_detenidos = result.scalar()
# Sin señal (última ubicación > 30 min)
tiempo_sin_señal = ahora - timedelta(minutes=30)
result = await self.db.execute(
select(func.count(Vehiculo.id))
.where(Vehiculo.activo == True)
.where(Vehiculo.en_servicio == True)
.where(Vehiculo.ultima_ubicacion_tiempo < tiempo_sin_señal)
)
vehiculos_sin_señal = result.scalar()
# Conductores (simplificado)
from app.models.conductor import Conductor
result = await self.db.execute(
select(func.count(Conductor.id)).where(Conductor.activo == True)
)
conductores_activos = result.scalar()
# Alertas
result = await self.db.execute(
select(func.count(Alerta.id)).where(Alerta.atendida == False)
)
alertas_pendientes = result.scalar()
result = await self.db.execute(
select(func.count(Alerta.id))
.where(Alerta.atendida == False)
.where(Alerta.severidad == "critica")
)
alertas_criticas = result.scalar()
result = await self.db.execute(
select(func.count(Alerta.id)).where(Alerta.creado_en >= inicio_hoy)
)
alertas_hoy = result.scalar()
# Viajes de hoy
result = await self.db.execute(
select(func.count(Viaje.id)).where(Viaje.inicio_tiempo >= inicio_hoy)
)
viajes_hoy = result.scalar()
result = await self.db.execute(
select(func.coalesce(func.sum(Viaje.distancia_km), 0))
.where(Viaje.inicio_tiempo >= inicio_hoy)
)
distancia_hoy = result.scalar() or 0
# Mantenimientos
result = await self.db.execute(
select(func.count(Mantenimiento.id))
.where(Mantenimiento.estado == "vencido")
)
mantenimientos_vencidos = result.scalar()
proximos_7_dias = ahora + timedelta(days=7)
result = await self.db.execute(
select(func.count(Mantenimiento.id))
.where(Mantenimiento.estado == "programado")
.where(Mantenimiento.fecha_programada <= proximos_7_dias.date())
)
mantenimientos_proximos = result.scalar()
return DashboardResumen(
total_vehiculos=total_vehiculos,
vehiculos_activos=vehiculos_activos,
vehiculos_en_movimiento=vehiculos_en_movimiento,
vehiculos_detenidos=vehiculos_detenidos,
vehiculos_sin_señal=vehiculos_sin_señal,
total_conductores=conductores_activos,
conductores_activos=conductores_activos,
alertas_pendientes=alertas_pendientes,
alertas_criticas=alertas_criticas,
alertas_hoy=alertas_hoy,
viajes_hoy=viajes_hoy,
distancia_hoy_km=float(distancia_hoy),
mantenimientos_vencidos=mantenimientos_vencidos,
mantenimientos_proximos=mantenimientos_proximos,
actualizado_en=ahora,
)
async def obtener_dashboard_graficos(self) -> DashboardGrafico:
"""
Obtiene datos para gráficos del dashboard.
Returns:
Datos para gráficos.
"""
ahora = datetime.now(timezone.utc)
# Distancia por día (últimos 7 días)
distancia_diaria = []
for i in range(6, -1, -1):
fecha = ahora - timedelta(days=i)
inicio_dia = fecha.replace(hour=0, minute=0, second=0, microsecond=0)
fin_dia = inicio_dia + timedelta(days=1)
result = await self.db.execute(
select(func.coalesce(func.sum(Viaje.distancia_km), 0))
.where(Viaje.inicio_tiempo >= inicio_dia)
.where(Viaje.inicio_tiempo < fin_dia)
)
km = result.scalar() or 0
distancia_diaria.append({
"fecha": inicio_dia.strftime("%Y-%m-%d"),
"km": float(km),
})
# Viajes por día
viajes_diarios = []
for i in range(6, -1, -1):
fecha = ahora - timedelta(days=i)
inicio_dia = fecha.replace(hour=0, minute=0, second=0, microsecond=0)
fin_dia = inicio_dia + timedelta(days=1)
result = await self.db.execute(
select(func.count(Viaje.id))
.where(Viaje.inicio_tiempo >= inicio_dia)
.where(Viaje.inicio_tiempo < fin_dia)
)
cantidad = result.scalar() or 0
viajes_diarios.append({
"fecha": inicio_dia.strftime("%Y-%m-%d"),
"cantidad": cantidad,
})
# Alertas por tipo (últimos 7 días)
inicio_semana = ahora - timedelta(days=7)
from app.models.tipo_alerta import TipoAlerta
result = await self.db.execute(
select(TipoAlerta.nombre, func.count(Alerta.id))
.join(TipoAlerta, Alerta.tipo_alerta_id == TipoAlerta.id)
.where(Alerta.creado_en >= inicio_semana)
.group_by(TipoAlerta.nombre)
.order_by(func.count(Alerta.id).desc())
.limit(5)
)
alertas_por_tipo = [
{"tipo": row[0], "cantidad": row[1]}
for row in result.all()
]
# Consumo de combustible (últimos 30 días)
consumo_combustible = []
for i in range(29, -1, -1):
fecha = ahora - timedelta(days=i)
inicio_dia = fecha.replace(hour=0, minute=0, second=0, microsecond=0)
fin_dia = inicio_dia + timedelta(days=1)
result = await self.db.execute(
select(func.coalesce(func.sum(CargaCombustible.litros), 0))
.where(CargaCombustible.fecha >= inicio_dia)
.where(CargaCombustible.fecha < fin_dia)
)
litros = result.scalar() or 0
consumo_combustible.append({
"fecha": inicio_dia.strftime("%Y-%m-%d"),
"litros": float(litros),
})
return DashboardGrafico(
distancia_diaria=distancia_diaria,
viajes_diarios=viajes_diarios,
alertas_por_tipo=alertas_por_tipo,
consumo_combustible=consumo_combustible,
)
async def generar_reporte(
self,
request: ReporteRequest,
) -> ReporteResponse:
"""
Genera un reporte según los parámetros especificados.
Args:
request: Parámetros del reporte.
Returns:
Información del reporte generado.
"""
reporte_id = str(uuid.uuid4())
# Recopilar datos según el tipo de reporte
datos = await self._recopilar_datos_reporte(request)
# Generar archivo según formato
if request.formato == "pdf":
archivo_url = await self._generar_pdf(reporte_id, request.tipo, datos)
elif request.formato == "excel":
archivo_url = await self._generar_excel(reporte_id, request.tipo, datos)
else: # csv
archivo_url = await self._generar_csv(reporte_id, request.tipo, datos)
return ReporteResponse(
id=reporte_id,
tipo=request.tipo,
formato=request.formato,
estado="completado",
archivo_url=archivo_url,
creado_en=datetime.now(timezone.utc),
completado_en=datetime.now(timezone.utc),
)
async def _recopilar_datos_reporte(
self,
request: ReporteRequest,
) -> Dict[str, Any]:
"""Recopila los datos necesarios para el reporte."""
datos = {
"periodo_inicio": request.fecha_inicio,
"periodo_fin": request.fecha_fin,
}
if request.tipo == "viajes":
datos["viajes"] = await self._obtener_datos_viajes(
request.fecha_inicio,
request.fecha_fin,
request.vehiculos_ids,
)
elif request.tipo == "alertas":
datos["alertas"] = await self._obtener_datos_alertas(
request.fecha_inicio,
request.fecha_fin,
request.vehiculos_ids,
)
elif request.tipo == "combustible":
datos["combustible"] = await self._obtener_datos_combustible(
request.fecha_inicio,
request.fecha_fin,
request.vehiculos_ids,
)
elif request.tipo == "mantenimiento":
datos["mantenimiento"] = await self._obtener_datos_mantenimiento(
request.fecha_inicio,
request.fecha_fin,
request.vehiculos_ids,
)
return datos
async def _obtener_datos_viajes(
self,
desde: datetime,
hasta: datetime,
vehiculos_ids: List[int] = None,
) -> List[dict]:
"""Obtiene datos de viajes para el reporte."""
query = (
select(Viaje)
.where(Viaje.inicio_tiempo >= desde)
.where(Viaje.inicio_tiempo <= hasta)
.order_by(Viaje.inicio_tiempo)
)
if vehiculos_ids:
query = query.where(Viaje.vehiculo_id.in_(vehiculos_ids))
result = await self.db.execute(query)
viajes = result.scalars().all()
return [
{
"id": v.id,
"vehiculo_id": v.vehiculo_id,
"inicio": v.inicio_tiempo.isoformat(),
"fin": v.fin_tiempo.isoformat() if v.fin_tiempo else None,
"distancia_km": v.distancia_km,
"duracion_segundos": v.duracion_segundos,
"velocidad_promedio": v.velocidad_promedio,
"velocidad_maxima": v.velocidad_maxima,
"estado": v.estado,
}
for v in viajes
]
async def _obtener_datos_alertas(
self,
desde: datetime,
hasta: datetime,
vehiculos_ids: List[int] = None,
) -> List[dict]:
"""Obtiene datos de alertas para el reporte."""
query = (
select(Alerta)
.where(Alerta.creado_en >= desde)
.where(Alerta.creado_en <= hasta)
.order_by(Alerta.creado_en)
)
if vehiculos_ids:
query = query.where(Alerta.vehiculo_id.in_(vehiculos_ids))
result = await self.db.execute(query)
alertas = result.scalars().all()
return [
{
"id": a.id,
"vehiculo_id": a.vehiculo_id,
"tipo_alerta_id": a.tipo_alerta_id,
"severidad": a.severidad,
"mensaje": a.mensaje,
"creado_en": a.creado_en.isoformat(),
"atendida": a.atendida,
}
for a in alertas
]
async def _obtener_datos_combustible(
self,
desde: datetime,
hasta: datetime,
vehiculos_ids: List[int] = None,
) -> List[dict]:
"""Obtiene datos de combustible para el reporte."""
query = (
select(CargaCombustible)
.where(CargaCombustible.fecha >= desde)
.where(CargaCombustible.fecha <= hasta)
.order_by(CargaCombustible.fecha)
)
if vehiculos_ids:
query = query.where(CargaCombustible.vehiculo_id.in_(vehiculos_ids))
result = await self.db.execute(query)
cargas = result.scalars().all()
return [
{
"id": c.id,
"vehiculo_id": c.vehiculo_id,
"fecha": c.fecha.isoformat(),
"litros": c.litros,
"precio_litro": c.precio_litro,
"total": c.total,
"odometro": c.odometro,
"estacion": c.estacion,
}
for c in cargas
]
async def _obtener_datos_mantenimiento(
self,
desde: datetime,
hasta: datetime,
vehiculos_ids: List[int] = None,
) -> List[dict]:
"""Obtiene datos de mantenimiento para el reporte."""
query = (
select(Mantenimiento)
.where(Mantenimiento.fecha_programada >= desde.date())
.where(Mantenimiento.fecha_programada <= hasta.date())
.order_by(Mantenimiento.fecha_programada)
)
if vehiculos_ids:
query = query.where(Mantenimiento.vehiculo_id.in_(vehiculos_ids))
result = await self.db.execute(query)
mantenimientos = result.scalars().all()
return [
{
"id": m.id,
"vehiculo_id": m.vehiculo_id,
"tipo_mantenimiento_id": m.tipo_mantenimiento_id,
"estado": m.estado,
"fecha_programada": m.fecha_programada.isoformat(),
"fecha_realizada": m.fecha_realizada.isoformat() if m.fecha_realizada else None,
"costo_real": m.costo_real,
}
for m in mantenimientos
]
async def _generar_pdf(
self,
reporte_id: str,
tipo: str,
datos: Dict[str, Any],
) -> str:
"""Genera un reporte en PDF."""
# Implementación simplificada
# En producción se usaría WeasyPrint o similar
archivo_path = f"{settings.REPORTS_DIR}/{reporte_id}.pdf"
# TODO: Implementar generación de PDF con WeasyPrint
return archivo_path
async def _generar_excel(
self,
reporte_id: str,
tipo: str,
datos: Dict[str, Any],
) -> str:
"""Genera un reporte en Excel."""
try:
from openpyxl import Workbook
wb = Workbook()
ws = wb.active
ws.title = tipo.capitalize()
# Escribir datos según el tipo
if tipo in datos:
items = datos[tipo]
if items:
# Headers
headers = list(items[0].keys())
for col, header in enumerate(headers, 1):
ws.cell(row=1, column=col, value=header)
# Data
for row, item in enumerate(items, 2):
for col, key in enumerate(headers, 1):
ws.cell(row=row, column=col, value=item.get(key))
archivo_path = f"{settings.REPORTS_DIR}/{reporte_id}.xlsx"
wb.save(archivo_path)
return archivo_path
except ImportError:
return ""
async def _generar_csv(
self,
reporte_id: str,
tipo: str,
datos: Dict[str, Any],
) -> str:
"""Genera un reporte en CSV."""
import csv
archivo_path = f"{settings.REPORTS_DIR}/{reporte_id}.csv"
if tipo in datos:
items = datos[tipo]
if items:
with open(archivo_path, 'w', newline='') as f:
writer = csv.DictWriter(f, fieldnames=items[0].keys())
writer.writeheader()
writer.writerows(items)
return archivo_path

View File

@@ -0,0 +1,286 @@
"""
Servicio para integración con Traccar.
Recibe datos de ubicación desde Traccar via forward
y los procesa en el sistema.
"""
from datetime import datetime, timezone
from typing import Optional
import httpx
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.models.dispositivo import Dispositivo
from app.schemas.ubicacion import TraccarLocationCreate, UbicacionCreate
from app.services.ubicacion_service import UbicacionService
class TraccarService:
"""Servicio para integración con Traccar GPS Server."""
def __init__(self, db: AsyncSession):
"""
Inicializa el servicio.
Args:
db: Sesión de base de datos async.
"""
self.db = db
self.ubicacion_service = UbicacionService(db)
self.api_url = settings.TRACCAR_API_URL
self.username = settings.TRACCAR_USERNAME
self.password = settings.TRACCAR_PASSWORD
async def procesar_posicion_traccar(
self,
posicion: TraccarLocationCreate,
) -> Optional[dict]:
"""
Procesa una posición recibida desde Traccar.
Args:
posicion: Datos de posición de Traccar.
Returns:
Resultado del procesamiento o None.
"""
# Buscar dispositivo por ID de Traccar
result = await self.db.execute(
select(Dispositivo)
.where(Dispositivo.identificador == str(posicion.deviceId))
.where(Dispositivo.protocolo == "traccar")
)
dispositivo = result.scalar_one_or_none()
if not dispositivo:
# Intentar buscar por IMEI en attributes
if posicion.attributes and "imei" in posicion.attributes:
result = await self.db.execute(
select(Dispositivo)
.where(Dispositivo.imei == posicion.attributes["imei"])
)
dispositivo = result.scalar_one_or_none()
if not dispositivo:
return None
# Convertir velocidad de nudos a km/h
velocidad = None
if posicion.speed is not None:
velocidad = posicion.speed * 1.852 # nudos a km/h
# Extraer datos adicionales de attributes
bateria = None
motor_encendido = None
odometro = None
if posicion.attributes:
bateria = posicion.attributes.get("batteryLevel")
motor_encendido = posicion.attributes.get("ignition")
# Odómetro puede venir en metros
odometro_metros = posicion.attributes.get("totalDistance")
if odometro_metros:
odometro = odometro_metros / 1000 # a km
# Crear schema de ubicación
ubicacion_data = UbicacionCreate(
vehiculo_id=dispositivo.vehiculo_id,
dispositivo_id=dispositivo.identificador,
lat=posicion.latitude,
lng=posicion.longitude,
velocidad=velocidad,
rumbo=posicion.course,
altitud=posicion.altitude,
precision=posicion.accuracy,
tiempo=posicion.fixTime,
fuente="traccar",
bateria_dispositivo=bateria,
motor_encendido=motor_encendido,
odometro=odometro,
)
# Procesar ubicación
resultado = await self.ubicacion_service.procesar_ubicacion(ubicacion_data)
if resultado:
return {
"status": "processed",
"vehiculo_id": dispositivo.vehiculo_id,
"dispositivo_id": dispositivo.identificador,
}
return None
async def sincronizar_dispositivos(self) -> dict:
"""
Sincroniza dispositivos desde Traccar.
Obtiene la lista de dispositivos de Traccar y los sincroniza
con la base de datos local.
Returns:
Resultado de la sincronización.
"""
if not self.username or not self.password:
return {"error": "Credenciales de Traccar no configuradas"}
try:
async with httpx.AsyncClient() as client:
response = await client.get(
f"{self.api_url}/devices",
auth=(self.username, self.password),
timeout=30.0,
)
response.raise_for_status()
dispositivos_traccar = response.json()
except httpx.HTTPError as e:
return {"error": f"Error conectando a Traccar: {str(e)}"}
sincronizados = 0
for d in dispositivos_traccar:
# Verificar si ya existe
result = await self.db.execute(
select(Dispositivo)
.where(Dispositivo.identificador == str(d["id"]))
.where(Dispositivo.protocolo == "traccar")
)
dispositivo = result.scalar_one_or_none()
if not dispositivo:
# Solo registrar, no crear vehículo automáticamente
continue
# Actualizar información
dispositivo.nombre = d.get("name", dispositivo.nombre)
if d.get("lastUpdate"):
dispositivo.ultimo_contacto = datetime.fromisoformat(
d["lastUpdate"].replace("Z", "+00:00")
)
dispositivo.conectado = d.get("status", "") == "online"
sincronizados += 1
await self.db.commit()
return {
"total_traccar": len(dispositivos_traccar),
"sincronizados": sincronizados,
}
async def obtener_posicion_actual(
self,
dispositivo_id: str,
) -> Optional[dict]:
"""
Obtiene la posición actual de un dispositivo desde Traccar.
Args:
dispositivo_id: ID del dispositivo en Traccar.
Returns:
Posición actual o None.
"""
if not self.username or not self.password:
return None
try:
async with httpx.AsyncClient() as client:
response = await client.get(
f"{self.api_url}/positions",
params={"deviceId": dispositivo_id},
auth=(self.username, self.password),
timeout=10.0,
)
response.raise_for_status()
posiciones = response.json()
if posiciones:
return posiciones[0]
except httpx.HTTPError:
pass
return None
async def enviar_comando(
self,
dispositivo_id: str,
tipo_comando: str,
data: dict = None,
) -> Optional[dict]:
"""
Envía un comando a un dispositivo via Traccar.
Args:
dispositivo_id: ID del dispositivo en Traccar.
tipo_comando: Tipo de comando (ej: "engineStop", "engineResume").
data: Datos adicionales del comando.
Returns:
Respuesta de Traccar o None.
"""
if not self.username or not self.password:
return None
comando = {
"deviceId": int(dispositivo_id),
"type": tipo_comando,
"attributes": data or {},
}
try:
async with httpx.AsyncClient() as client:
response = await client.post(
f"{self.api_url}/commands/send",
json=comando,
auth=(self.username, self.password),
timeout=30.0,
)
response.raise_for_status()
return response.json()
except httpx.HTTPError as e:
return {"error": str(e)}
async def obtener_reportes_traccar(
self,
dispositivo_id: str,
desde: datetime,
hasta: datetime,
tipo: str = "route",
) -> Optional[list]:
"""
Obtiene reportes desde Traccar.
Args:
dispositivo_id: ID del dispositivo.
desde: Fecha inicio.
hasta: Fecha fin.
tipo: Tipo de reporte (route, events, trips, stops).
Returns:
Lista de datos del reporte.
"""
if not self.username or not self.password:
return None
try:
async with httpx.AsyncClient() as client:
response = await client.get(
f"{self.api_url}/reports/{tipo}",
params={
"deviceId": dispositivo_id,
"from": desde.isoformat(),
"to": hasta.isoformat(),
},
auth=(self.username, self.password),
timeout=60.0,
)
response.raise_for_status()
return response.json()
except httpx.HTTPError:
return None

View File

@@ -0,0 +1,489 @@
"""
Servicio para procesamiento de ubicaciones GPS.
Maneja la recepción, procesamiento y análisis de datos de ubicación.
"""
import json
from datetime import datetime, timedelta, timezone
from typing import List, Optional, Tuple
from sqlalchemy import and_, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.models.dispositivo import Dispositivo
from app.models.ubicacion import Ubicacion
from app.models.vehiculo import Vehiculo
from app.schemas.ubicacion import (
HistorialUbicacionesResponse,
UbicacionCreate,
UbicacionResponse,
)
class UbicacionService:
"""Servicio para gestión de ubicaciones GPS."""
def __init__(self, db: AsyncSession):
"""
Inicializa el servicio.
Args:
db: Sesión de base de datos async.
"""
self.db = db
async def procesar_ubicacion(
self,
ubicacion_data: UbicacionCreate,
) -> Optional[UbicacionResponse]:
"""
Procesa una nueva ubicación recibida.
Args:
ubicacion_data: Datos de la ubicación a procesar.
Returns:
UbicacionResponse si se procesó correctamente, None si se descartó.
"""
# Determinar el vehículo
vehiculo_id = ubicacion_data.vehiculo_id
if not vehiculo_id and ubicacion_data.dispositivo_id:
# Buscar vehículo por identificador de dispositivo
result = await self.db.execute(
select(Dispositivo)
.where(Dispositivo.identificador == ubicacion_data.dispositivo_id)
.where(Dispositivo.activo == True)
)
dispositivo = result.scalar_one_or_none()
if dispositivo:
vehiculo_id = dispositivo.vehiculo_id
# Actualizar último contacto del dispositivo
dispositivo.ultimo_contacto = datetime.now(timezone.utc)
dispositivo.conectado = True
if ubicacion_data.bateria_dispositivo:
dispositivo.bateria = ubicacion_data.bateria_dispositivo
if ubicacion_data.satelites:
dispositivo.satelites = ubicacion_data.satelites
if not vehiculo_id:
return None
# Usar timestamp del servidor si no viene
tiempo = ubicacion_data.tiempo or datetime.now(timezone.utc)
# Crear registro de ubicación
ubicacion = Ubicacion(
tiempo=tiempo,
vehiculo_id=vehiculo_id,
lat=ubicacion_data.lat,
lng=ubicacion_data.lng,
velocidad=ubicacion_data.velocidad,
rumbo=ubicacion_data.rumbo,
altitud=ubicacion_data.altitud,
precision=ubicacion_data.precision,
hdop=ubicacion_data.hdop,
satelites=ubicacion_data.satelites,
fuente=ubicacion_data.fuente,
bateria_dispositivo=ubicacion_data.bateria_dispositivo,
bateria_vehiculo=ubicacion_data.bateria_vehiculo,
motor_encendido=ubicacion_data.motor_encendido,
odometro=ubicacion_data.odometro,
rpm=ubicacion_data.rpm,
temperatura_motor=ubicacion_data.temperatura_motor,
nivel_combustible=ubicacion_data.nivel_combustible,
)
self.db.add(ubicacion)
# Actualizar última ubicación conocida del vehículo
result = await self.db.execute(
select(Vehiculo).where(Vehiculo.id == vehiculo_id)
)
vehiculo = result.scalar_one_or_none()
if vehiculo:
vehiculo.ultima_lat = ubicacion_data.lat
vehiculo.ultima_lng = ubicacion_data.lng
vehiculo.ultima_velocidad = ubicacion_data.velocidad
vehiculo.ultimo_rumbo = ubicacion_data.rumbo
vehiculo.ultima_ubicacion_tiempo = tiempo
vehiculo.motor_encendido = ubicacion_data.motor_encendido
if ubicacion_data.odometro:
vehiculo.odometro_actual = ubicacion_data.odometro
await self.db.commit()
return UbicacionResponse(
tiempo=ubicacion.tiempo,
vehiculo_id=ubicacion.vehiculo_id,
lat=ubicacion.lat,
lng=ubicacion.lng,
velocidad=ubicacion.velocidad,
rumbo=ubicacion.rumbo,
altitud=ubicacion.altitud,
precision=ubicacion.precision,
satelites=ubicacion.satelites,
fuente=ubicacion.fuente,
bateria_dispositivo=ubicacion.bateria_dispositivo,
motor_encendido=ubicacion.motor_encendido,
odometro=ubicacion.odometro,
)
async def obtener_historial(
self,
vehiculo_id: int,
desde: datetime,
hasta: datetime,
simplificar: bool = True,
intervalo_segundos: Optional[int] = None,
) -> HistorialUbicacionesResponse:
"""
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: Si simplificar la ruta (Douglas-Peucker).
intervalo_segundos: Intervalo de muestreo opcional.
Returns:
Historial de ubicaciones con estadísticas.
"""
query = (
select(Ubicacion)
.where(
and_(
Ubicacion.vehiculo_id == vehiculo_id,
Ubicacion.tiempo >= desde,
Ubicacion.tiempo <= hasta,
)
)
.order_by(Ubicacion.tiempo)
)
result = await self.db.execute(query)
ubicaciones = result.scalars().all()
# Aplicar muestreo por intervalo si se especifica
if intervalo_segundos and ubicaciones:
ubicaciones = self._muestrear_por_intervalo(
ubicaciones, intervalo_segundos
)
# Calcular estadísticas
distancia_km = self._calcular_distancia_total(ubicaciones)
tiempo_movimiento = self._calcular_tiempo_movimiento(ubicaciones)
velocidad_promedio = None
velocidad_maxima = None
if ubicaciones:
velocidades = [u.velocidad for u in ubicaciones if u.velocidad]
if velocidades:
velocidad_promedio = sum(velocidades) / len(velocidades)
velocidad_maxima = max(velocidades)
# Simplificar ruta si se solicita
if simplificar and len(ubicaciones) > 100:
ubicaciones = self._simplificar_ruta(ubicaciones, epsilon=0.0001)
ubicaciones_response = [
UbicacionResponse(
tiempo=u.tiempo,
vehiculo_id=u.vehiculo_id,
lat=u.lat,
lng=u.lng,
velocidad=u.velocidad,
rumbo=u.rumbo,
altitud=u.altitud,
precision=u.precision,
satelites=u.satelites,
fuente=u.fuente,
bateria_dispositivo=u.bateria_dispositivo,
motor_encendido=u.motor_encendido,
odometro=u.odometro,
)
for u in ubicaciones
]
return HistorialUbicacionesResponse(
vehiculo_id=vehiculo_id,
desde=desde,
hasta=hasta,
total_puntos=len(ubicaciones_response),
distancia_km=distancia_km,
tiempo_movimiento_segundos=tiempo_movimiento,
velocidad_promedio=velocidad_promedio,
velocidad_maxima=velocidad_maxima,
ubicaciones=ubicaciones_response,
)
async def obtener_ultima_ubicacion(
self,
vehiculo_id: int,
) -> Optional[UbicacionResponse]:
"""
Obtiene la última ubicación conocida de un vehículo.
Args:
vehiculo_id: ID del vehículo.
Returns:
Última ubicación o None.
"""
result = await self.db.execute(
select(Ubicacion)
.where(Ubicacion.vehiculo_id == vehiculo_id)
.order_by(Ubicacion.tiempo.desc())
.limit(1)
)
ubicacion = result.scalar_one_or_none()
if not ubicacion:
return None
return UbicacionResponse(
tiempo=ubicacion.tiempo,
vehiculo_id=ubicacion.vehiculo_id,
lat=ubicacion.lat,
lng=ubicacion.lng,
velocidad=ubicacion.velocidad,
rumbo=ubicacion.rumbo,
altitud=ubicacion.altitud,
precision=ubicacion.precision,
satelites=ubicacion.satelites,
fuente=ubicacion.fuente,
bateria_dispositivo=ubicacion.bateria_dispositivo,
motor_encendido=ubicacion.motor_encendido,
odometro=ubicacion.odometro,
)
async def obtener_ubicaciones_flota(
self,
) -> List[dict]:
"""
Obtiene las últimas ubicaciones de todos los vehículos activos.
Returns:
Lista de ubicaciones actuales de la flota.
"""
result = await self.db.execute(
select(Vehiculo)
.where(Vehiculo.activo == True)
.where(Vehiculo.ultima_lat.isnot(None))
)
vehiculos = result.scalars().all()
ubicaciones = []
for v in vehiculos:
# Determinar si está en movimiento
en_movimiento = False
if v.ultima_velocidad and v.ultima_velocidad > 5:
en_movimiento = True
ubicaciones.append({
"id": v.id,
"nombre": v.nombre,
"placa": v.placa,
"color_marcador": v.color_marcador,
"icono": v.icono,
"lat": v.ultima_lat,
"lng": v.ultima_lng,
"velocidad": v.ultima_velocidad,
"rumbo": v.ultimo_rumbo,
"tiempo": v.ultima_ubicacion_tiempo,
"motor_encendido": v.motor_encendido,
"en_movimiento": en_movimiento,
"conductor_nombre": v.conductor.nombre_completo if v.conductor else None,
})
return ubicaciones
def _calcular_distancia_total(
self,
ubicaciones: List[Ubicacion],
) -> float:
"""
Calcula la distancia total recorrida entre ubicaciones.
Usa la fórmula de Haversine para calcular distancias.
Args:
ubicaciones: Lista de ubicaciones ordenadas por tiempo.
Returns:
Distancia total en kilómetros.
"""
if len(ubicaciones) < 2:
return 0.0
import math
total_km = 0.0
for i in range(1, len(ubicaciones)):
lat1 = math.radians(ubicaciones[i - 1].lat)
lat2 = math.radians(ubicaciones[i].lat)
dlat = lat2 - lat1
dlon = math.radians(ubicaciones[i].lng - ubicaciones[i - 1].lng)
a = (
math.sin(dlat / 2) ** 2
+ math.cos(lat1) * math.cos(lat2) * math.sin(dlon / 2) ** 2
)
c = 2 * math.asin(math.sqrt(a))
r = 6371 # Radio de la Tierra en km
total_km += r * c
return round(total_km, 2)
def _calcular_tiempo_movimiento(
self,
ubicaciones: List[Ubicacion],
) -> int:
"""
Calcula el tiempo en movimiento (velocidad > 5 km/h).
Args:
ubicaciones: Lista de ubicaciones ordenadas.
Returns:
Tiempo en movimiento en segundos.
"""
if len(ubicaciones) < 2:
return 0
tiempo_total = 0
for i in range(1, len(ubicaciones)):
if (
ubicaciones[i - 1].velocidad
and ubicaciones[i - 1].velocidad > 5
):
delta = (
ubicaciones[i].tiempo - ubicaciones[i - 1].tiempo
).total_seconds()
tiempo_total += delta
return int(tiempo_total)
def _muestrear_por_intervalo(
self,
ubicaciones: List[Ubicacion],
intervalo_segundos: int,
) -> List[Ubicacion]:
"""
Muestrea ubicaciones por intervalo de tiempo.
Args:
ubicaciones: Lista de ubicaciones.
intervalo_segundos: Intervalo de muestreo.
Returns:
Lista filtrada de ubicaciones.
"""
if not ubicaciones:
return []
resultado = [ubicaciones[0]]
ultimo_tiempo = ubicaciones[0].tiempo
for u in ubicaciones[1:]:
delta = (u.tiempo - ultimo_tiempo).total_seconds()
if delta >= intervalo_segundos:
resultado.append(u)
ultimo_tiempo = u.tiempo
# Siempre incluir el último punto
if resultado[-1] != ubicaciones[-1]:
resultado.append(ubicaciones[-1])
return resultado
def _simplificar_ruta(
self,
ubicaciones: List[Ubicacion],
epsilon: float = 0.0001,
) -> List[Ubicacion]:
"""
Simplifica la ruta usando el algoritmo Douglas-Peucker.
Args:
ubicaciones: Lista de ubicaciones.
epsilon: Tolerancia de simplificación.
Returns:
Lista simplificada de ubicaciones.
"""
if len(ubicaciones) <= 2:
return ubicaciones
# Convertir a lista de puntos
points = [(u.lat, u.lng, u) for u in ubicaciones]
# Douglas-Peucker
simplified = self._douglas_peucker(points, epsilon)
return [p[2] for p in simplified]
def _douglas_peucker(
self,
points: List[Tuple],
epsilon: float,
) -> List[Tuple]:
"""Implementación del algoritmo Douglas-Peucker."""
if len(points) <= 2:
return points
# Encontrar el punto más lejano de la línea
dmax = 0
index = 0
end = len(points) - 1
for i in range(1, end):
d = self._perpendicular_distance(
points[i], points[0], points[end]
)
if d > dmax:
index = i
dmax = d
# Si la distancia máxima es mayor que epsilon, simplificar recursivamente
if dmax > epsilon:
# Dividir en dos segmentos
rec1 = self._douglas_peucker(points[: index + 1], epsilon)
rec2 = self._douglas_peucker(points[index:], epsilon)
# Combinar (evitar duplicar el punto medio)
return rec1[:-1] + rec2
else:
return [points[0], points[end]]
def _perpendicular_distance(
self,
point: Tuple,
line_start: Tuple,
line_end: Tuple,
) -> float:
"""Calcula la distancia perpendicular de un punto a una línea."""
import math
x, y = point[0], point[1]
x1, y1 = line_start[0], line_start[1]
x2, y2 = line_end[0], line_end[1]
# Caso especial: línea de longitud cero
dx = x2 - x1
dy = y2 - y1
if dx == 0 and dy == 0:
return math.sqrt((x - x1) ** 2 + (y - y1) ** 2)
# Distancia perpendicular
numerator = abs(dy * x - dx * y + x2 * y1 - y2 * x1)
denominator = math.sqrt(dx ** 2 + dy ** 2)
return numerator / denominator

View File

@@ -0,0 +1,405 @@
"""
Servicio para gestión automática de viajes.
Detecta automáticamente el inicio y fin de viajes basándose
en el movimiento del vehículo.
"""
from datetime import datetime, timedelta, timezone
from typing import List, Optional
from sqlalchemy import and_, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.models.parada import Parada
from app.models.ubicacion import Ubicacion
from app.models.vehiculo import Vehiculo
from app.models.viaje import Viaje
from app.schemas.viaje import ViajeResponse
class ViajeService:
"""Servicio para detección y gestión de viajes."""
# Configuración de detección
VELOCIDAD_MINIMA_MOVIMIENTO = 5 # km/h
MINUTOS_PARADA_FIN_VIAJE = 5 # minutos para considerar fin de viaje
SEGUNDOS_MINIMOS_PARADA = 120 # segundos mínimos para registrar parada
def __init__(self, db: AsyncSession):
"""
Inicializa el servicio.
Args:
db: Sesión de base de datos async.
"""
self.db = db
async def procesar_ubicacion_viaje(
self,
vehiculo_id: int,
lat: float,
lng: float,
velocidad: float,
tiempo: datetime,
) -> Optional[dict]:
"""
Procesa una ubicación para detección de viajes.
Args:
vehiculo_id: ID del vehículo.
lat: Latitud.
lng: Longitud.
velocidad: Velocidad en km/h.
tiempo: Timestamp de la ubicación.
Returns:
Dict con información del evento de viaje si hubo cambio.
"""
# Obtener viaje en curso
viaje_activo = await self._obtener_viaje_activo(vehiculo_id)
en_movimiento = velocidad >= self.VELOCIDAD_MINIMA_MOVIMIENTO
if not viaje_activo:
# No hay viaje activo
if en_movimiento:
# Iniciar nuevo viaje
viaje = await self._iniciar_viaje(vehiculo_id, lat, lng, tiempo)
return {
"evento": "viaje_iniciado",
"viaje_id": viaje.id,
"vehiculo_id": vehiculo_id,
}
else:
# Hay viaje activo
if en_movimiento:
# Actualizar viaje (incrementar puntos GPS)
viaje_activo.puntos_gps += 1
# Verificar si había parada en curso y cerrarla
await self._cerrar_parada_en_curso(viaje_activo.id, vehiculo_id, tiempo)
await self.db.commit()
else:
# Vehículo detenido
resultado = await self._procesar_parada(
viaje_activo, vehiculo_id, lat, lng, tiempo
)
if resultado:
return resultado
return None
async def _obtener_viaje_activo(
self,
vehiculo_id: int,
) -> Optional[Viaje]:
"""Obtiene el viaje activo de un vehículo."""
result = await self.db.execute(
select(Viaje)
.where(Viaje.vehiculo_id == vehiculo_id)
.where(Viaje.estado == "en_curso")
)
return result.scalar_one_or_none()
async def _iniciar_viaje(
self,
vehiculo_id: int,
lat: float,
lng: float,
tiempo: datetime,
) -> Viaje:
"""Inicia un nuevo viaje."""
# Obtener conductor asignado
result = await self.db.execute(
select(Vehiculo).where(Vehiculo.id == vehiculo_id)
)
vehiculo = result.scalar_one_or_none()
conductor_id = vehiculo.conductor_id if vehiculo else None
# Obtener odómetro actual
odometro_inicio = vehiculo.odometro_actual if vehiculo else None
viaje = Viaje(
vehiculo_id=vehiculo_id,
conductor_id=conductor_id,
inicio_tiempo=tiempo,
inicio_lat=lat,
inicio_lng=lng,
odometro_inicio=odometro_inicio,
estado="en_curso",
puntos_gps=1,
)
self.db.add(viaje)
await self.db.commit()
await self.db.refresh(viaje)
return viaje
async def _procesar_parada(
self,
viaje: Viaje,
vehiculo_id: int,
lat: float,
lng: float,
tiempo: datetime,
) -> Optional[dict]:
"""
Procesa una parada durante un viaje.
Returns:
Dict con evento si el viaje terminó.
"""
# Buscar parada en curso
result = await self.db.execute(
select(Parada)
.where(Parada.vehiculo_id == vehiculo_id)
.where(Parada.en_curso == True)
)
parada = result.scalar_one_or_none()
if not parada:
# Iniciar nueva parada
parada = Parada(
viaje_id=viaje.id,
vehiculo_id=vehiculo_id,
inicio_tiempo=tiempo,
lat=lat,
lng=lng,
en_curso=True,
)
self.db.add(parada)
await self.db.commit()
return None
# Calcular duración de la parada
duracion_segundos = (tiempo - parada.inicio_tiempo).total_seconds()
parada.duracion_segundos = int(duracion_segundos)
# Verificar si la parada es suficientemente larga para terminar el viaje
if duracion_segundos >= self.MINUTOS_PARADA_FIN_VIAJE * 60:
# Terminar viaje
return await self._finalizar_viaje(viaje, parada, tiempo)
await self.db.commit()
return None
async def _cerrar_parada_en_curso(
self,
viaje_id: int,
vehiculo_id: int,
tiempo: datetime,
) -> None:
"""Cierra una parada en curso si existe."""
result = await self.db.execute(
select(Parada)
.where(Parada.vehiculo_id == vehiculo_id)
.where(Parada.en_curso == True)
)
parada = result.scalar_one_or_none()
if parada:
duracion = (tiempo - parada.inicio_tiempo).total_seconds()
if duracion >= self.SEGUNDOS_MINIMOS_PARADA:
# Registrar parada
parada.fin_tiempo = tiempo
parada.duracion_segundos = int(duracion)
parada.en_curso = False
else:
# Parada muy corta, eliminar
await self.db.delete(parada)
async def _finalizar_viaje(
self,
viaje: Viaje,
parada: Parada,
tiempo: datetime,
) -> dict:
"""Finaliza un viaje."""
# Cerrar parada
parada.fin_tiempo = tiempo
parada.en_curso = False
# Calcular estadísticas del viaje
viaje.fin_tiempo = parada.inicio_tiempo # El viaje termina al inicio de la parada final
viaje.fin_lat = parada.lat
viaje.fin_lng = parada.lng
viaje.estado = "completado"
# Calcular duración
viaje.duracion_segundos = int(
(viaje.fin_tiempo - viaje.inicio_tiempo).total_seconds()
)
# Calcular estadísticas desde ubicaciones
await self._calcular_estadisticas_viaje(viaje)
await self.db.commit()
return {
"evento": "viaje_finalizado",
"viaje_id": viaje.id,
"vehiculo_id": viaje.vehiculo_id,
"distancia_km": viaje.distancia_km,
"duracion_segundos": viaje.duracion_segundos,
}
async def _calcular_estadisticas_viaje(
self,
viaje: Viaje,
) -> None:
"""Calcula las estadísticas de un viaje finalizado."""
# Obtener ubicaciones del viaje
result = await self.db.execute(
select(Ubicacion)
.where(Ubicacion.vehiculo_id == viaje.vehiculo_id)
.where(Ubicacion.tiempo >= viaje.inicio_tiempo)
.where(Ubicacion.tiempo <= viaje.fin_tiempo)
.order_by(Ubicacion.tiempo)
)
ubicaciones = result.scalars().all()
if not ubicaciones:
return
# Distancia
viaje.distancia_km = self._calcular_distancia(ubicaciones)
# Velocidades
velocidades = [u.velocidad for u in ubicaciones if u.velocidad is not None]
if velocidades:
viaje.velocidad_promedio = sum(velocidades) / len(velocidades)
viaje.velocidad_maxima = max(velocidades)
# Tiempo en movimiento
tiempo_movimiento = 0
tiempo_parado = 0
for i in range(1, len(ubicaciones)):
delta = (ubicaciones[i].tiempo - ubicaciones[i-1].tiempo).total_seconds()
if ubicaciones[i-1].velocidad and ubicaciones[i-1].velocidad >= self.VELOCIDAD_MINIMA_MOVIMIENTO:
tiempo_movimiento += delta
else:
tiempo_parado += delta
viaje.tiempo_movimiento_segundos = int(tiempo_movimiento)
viaje.tiempo_parado_segundos = int(tiempo_parado)
# Odómetro final
result = await self.db.execute(
select(Vehiculo).where(Vehiculo.id == viaje.vehiculo_id)
)
vehiculo = result.scalar_one_or_none()
if vehiculo:
viaje.odometro_fin = vehiculo.odometro_actual
def _calcular_distancia(
self,
ubicaciones: List[Ubicacion],
) -> float:
"""Calcula la distancia total entre ubicaciones."""
import math
if len(ubicaciones) < 2:
return 0.0
total_km = 0.0
for i in range(1, len(ubicaciones)):
lat1 = math.radians(ubicaciones[i - 1].lat)
lat2 = math.radians(ubicaciones[i].lat)
dlat = lat2 - lat1
dlon = math.radians(ubicaciones[i].lng - ubicaciones[i - 1].lng)
a = (
math.sin(dlat / 2) ** 2
+ math.cos(lat1) * math.cos(lat2) * math.sin(dlon / 2) ** 2
)
c = 2 * math.asin(math.sqrt(a))
r = 6371
total_km += r * c
return round(total_km, 2)
async def obtener_viajes_vehiculo(
self,
vehiculo_id: int,
desde: datetime = None,
hasta: datetime = None,
limite: int = 50,
) -> List[Viaje]:
"""
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.
"""
query = (
select(Viaje)
.where(Viaje.vehiculo_id == vehiculo_id)
.order_by(Viaje.inicio_tiempo.desc())
.limit(limite)
)
if desde:
query = query.where(Viaje.inicio_tiempo >= desde)
if hasta:
query = query.where(Viaje.inicio_tiempo <= hasta)
result = await self.db.execute(query)
return result.scalars().all()
async def obtener_replay_viaje(
self,
viaje_id: int,
) -> Optional[dict]:
"""
Obtiene los datos para replay de un viaje.
Args:
viaje_id: ID del viaje.
Returns:
Datos del viaje con ubicaciones y paradas.
"""
result = await self.db.execute(
select(Viaje).where(Viaje.id == viaje_id)
)
viaje = result.scalar_one_or_none()
if not viaje:
return None
# Obtener ubicaciones
result = await self.db.execute(
select(Ubicacion)
.where(Ubicacion.vehiculo_id == viaje.vehiculo_id)
.where(Ubicacion.tiempo >= viaje.inicio_tiempo)
.where(
Ubicacion.tiempo <= (viaje.fin_tiempo or datetime.now(timezone.utc))
)
.order_by(Ubicacion.tiempo)
)
ubicaciones = result.scalars().all()
# Obtener paradas
result = await self.db.execute(
select(Parada)
.where(Parada.viaje_id == viaje_id)
.order_by(Parada.inicio_tiempo)
)
paradas = result.scalars().all()
return {
"viaje": viaje,
"ubicaciones": ubicaciones,
"paradas": paradas,
}

View File

@@ -0,0 +1,411 @@
"""
Servicio para gestión de video y cámaras.
Integración con MediaMTX para streaming de video.
"""
from datetime import datetime, timezone
from typing import List, Optional
import httpx
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.core.security import decrypt_sensitive_data, encrypt_sensitive_data
from app.models.camara import Camara
from app.models.grabacion import Grabacion
from app.models.evento_video import EventoVideo
from app.schemas.video import CamaraStreamURL
class VideoService:
"""Servicio para gestión de video y streaming."""
def __init__(self, db: AsyncSession):
"""
Inicializa el servicio.
Args:
db: Sesión de base de datos async.
"""
self.db = db
self.mediamtx_api = f"http://{settings.MEDIAMTX_HOST}:{settings.MEDIAMTX_API_PORT}"
async def obtener_urls_stream(
self,
camara_id: int,
) -> Optional[CamaraStreamURL]:
"""
Obtiene las URLs de streaming de una cámara.
Args:
camara_id: ID de la cámara.
Returns:
URLs de streaming disponibles.
"""
result = await self.db.execute(
select(Camara).where(Camara.id == camara_id)
)
camara = result.scalar_one_or_none()
if not camara or not camara.activa:
return None
# Construir URLs según el path de MediaMTX
path = camara.mediamtx_path or f"cam{camara.id}"
rtsp_url = None
hls_url = None
webrtc_url = None
if camara.url_stream:
# Usar URL directa de la cámara
rtsp_url = camara.url_stream_completa
else:
# Usar MediaMTX como proxy
rtsp_url = f"rtsp://{settings.MEDIAMTX_HOST}:{settings.MEDIAMTX_RTSP_PORT}/{path}"
# URLs de MediaMTX para diferentes protocolos
hls_url = f"http://{settings.MEDIAMTX_HOST}:8888/{path}/index.m3u8"
webrtc_url = f"http://{settings.MEDIAMTX_HOST}:{settings.MEDIAMTX_WEBRTC_PORT}/{path}"
return CamaraStreamURL(
camara_id=camara.id,
camara_nombre=camara.nombre,
rtsp_url=rtsp_url,
hls_url=hls_url,
webrtc_url=webrtc_url,
estado=camara.estado,
)
async def verificar_estado_camaras(self) -> List[dict]:
"""
Verifica el estado de todas las cámaras activas.
Returns:
Lista con estado de cada cámara.
"""
result = await self.db.execute(
select(Camara).where(Camara.activa == True)
)
camaras = result.scalars().all()
estados = []
for camara in camaras:
estado = await self._verificar_stream(camara)
estados.append({
"camara_id": camara.id,
"nombre": camara.nombre,
"vehiculo_id": camara.vehiculo_id,
"estado_anterior": camara.estado,
"estado_actual": estado,
"cambio": camara.estado != estado,
})
# Actualizar estado si cambió
if camara.estado != estado:
camara.estado = estado
if estado == "conectada":
camara.ultima_conexion = datetime.now(timezone.utc)
await self.db.commit()
return estados
async def _verificar_stream(
self,
camara: Camara,
) -> str:
"""
Verifica si un stream está activo.
Args:
camara: Cámara a verificar.
Returns:
Estado del stream.
"""
if not camara.url_stream and not camara.mediamtx_path:
return "desconectada"
path = camara.mediamtx_path or f"cam{camara.id}"
try:
async with httpx.AsyncClient() as client:
# Verificar en MediaMTX API
response = await client.get(
f"{self.mediamtx_api}/v3/paths/get/{path}",
timeout=5.0,
)
if response.status_code == 200:
data = response.json()
if data.get("ready"):
return "conectada"
return "desconectada"
return "desconectada"
except httpx.HTTPError:
return "error"
async def iniciar_grabacion(
self,
camara_id: int,
tipo: str = "manual",
) -> Optional[Grabacion]:
"""
Inicia una grabación de una cámara.
Args:
camara_id: ID de la cámara.
tipo: Tipo de grabación.
Returns:
Registro de grabación creado.
"""
result = await self.db.execute(
select(Camara).where(Camara.id == camara_id)
)
camara = result.scalar_one_or_none()
if not camara or camara.estado != "conectada":
return None
# Generar nombre de archivo
timestamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
archivo_nombre = f"cam{camara_id}_{timestamp}.mp4"
archivo_url = f"{settings.UPLOAD_DIR}/videos/{camara.vehiculo_id}/{archivo_nombre}"
grabacion = Grabacion(
camara_id=camara_id,
vehiculo_id=camara.vehiculo_id,
inicio_tiempo=datetime.now(timezone.utc),
archivo_url=archivo_url,
archivo_nombre=archivo_nombre,
tipo=tipo,
estado="grabando",
)
self.db.add(grabacion)
# Actualizar estado de cámara
camara.estado = "grabando"
await self.db.commit()
await self.db.refresh(grabacion)
# Enviar comando a MediaMTX para iniciar grabación
await self._iniciar_grabacion_mediamtx(camara, archivo_url)
return grabacion
async def detener_grabacion(
self,
grabacion_id: int,
) -> Optional[Grabacion]:
"""
Detiene una grabación en curso.
Args:
grabacion_id: ID de la grabación.
Returns:
Grabación actualizada.
"""
result = await self.db.execute(
select(Grabacion).where(Grabacion.id == grabacion_id)
)
grabacion = result.scalar_one_or_none()
if not grabacion or grabacion.estado != "grabando":
return None
# Detener grabación en MediaMTX
result_cam = await self.db.execute(
select(Camara).where(Camara.id == grabacion.camara_id)
)
camara = result_cam.scalar_one_or_none()
if camara:
await self._detener_grabacion_mediamtx(camara)
camara.estado = "conectada"
# Actualizar registro
grabacion.fin_tiempo = datetime.now(timezone.utc)
grabacion.duracion_segundos = int(
(grabacion.fin_tiempo - grabacion.inicio_tiempo).total_seconds()
)
grabacion.estado = "procesando" # Se procesará para generar thumbnail, etc.
await self.db.commit()
await self.db.refresh(grabacion)
return grabacion
async def _iniciar_grabacion_mediamtx(
self,
camara: Camara,
archivo_url: str,
) -> bool:
"""Envía comando a MediaMTX para iniciar grabación."""
# MediaMTX usa configuración para grabación automática
# o se puede usar ffmpeg para grabar el stream
# Esta es una implementación simplificada
try:
# En una implementación real, se usaría la API de MediaMTX
# o se ejecutaría ffmpeg como proceso
return True
except Exception:
return False
async def _detener_grabacion_mediamtx(
self,
camara: Camara,
) -> bool:
"""Envía comando a MediaMTX para detener grabación."""
try:
return True
except Exception:
return False
async def registrar_evento_video(
self,
camara_id: int,
tipo: str,
severidad: str,
lat: float = None,
lng: float = None,
velocidad: float = None,
descripcion: str = None,
confianza: float = None,
snapshot_url: str = None,
) -> EventoVideo:
"""
Registra un evento de video detectado.
Args:
camara_id: ID de la cámara.
tipo: Tipo de evento.
severidad: Severidad del evento.
lat, lng: Coordenadas.
velocidad: Velocidad al momento del evento.
descripcion: Descripción del evento.
confianza: Confianza de la detección (0-100).
snapshot_url: URL de la imagen del evento.
Returns:
Evento creado.
"""
result = await self.db.execute(
select(Camara).where(Camara.id == camara_id)
)
camara = result.scalar_one_or_none()
if not camara:
raise ValueError(f"Cámara {camara_id} no encontrada")
evento = EventoVideo(
camara_id=camara_id,
vehiculo_id=camara.vehiculo_id,
tipo=tipo,
severidad=severidad,
tiempo=datetime.now(timezone.utc),
lat=lat,
lng=lng,
velocidad=velocidad,
descripcion=descripcion,
confianza=confianza,
snapshot_url=snapshot_url,
)
self.db.add(evento)
await self.db.commit()
await self.db.refresh(evento)
# Iniciar grabación de evento si está configurado
if camara.grabacion_evento:
await self.iniciar_grabacion(camara_id, tipo="evento")
return evento
async def obtener_grabaciones(
self,
vehiculo_id: int = None,
camara_id: int = None,
desde: datetime = None,
hasta: datetime = None,
tipo: str = None,
limite: int = 50,
) -> List[Grabacion]:
"""
Obtiene grabaciones filtradas.
Args:
vehiculo_id: Filtrar por vehículo.
camara_id: Filtrar por cámara.
desde: Fecha inicio.
hasta: Fecha fin.
tipo: Tipo de grabación.
limite: Límite de resultados.
Returns:
Lista de grabaciones.
"""
query = (
select(Grabacion)
.where(Grabacion.estado != "eliminado")
.order_by(Grabacion.inicio_tiempo.desc())
.limit(limite)
)
if vehiculo_id:
query = query.where(Grabacion.vehiculo_id == vehiculo_id)
if camara_id:
query = query.where(Grabacion.camara_id == camara_id)
if desde:
query = query.where(Grabacion.inicio_tiempo >= desde)
if hasta:
query = query.where(Grabacion.inicio_tiempo <= hasta)
if tipo:
query = query.where(Grabacion.tipo == tipo)
result = await self.db.execute(query)
return result.scalars().all()
async def configurar_camara_mediamtx(
self,
camara: Camara,
) -> bool:
"""
Configura una cámara en MediaMTX.
Args:
camara: Cámara a configurar.
Returns:
True si se configuró correctamente.
"""
if not camara.url_stream:
return False
path = camara.mediamtx_path or f"cam{camara.id}"
# Construir configuración para MediaMTX
config = {
"name": path,
"source": camara.url_stream_completa,
"sourceOnDemand": True,
"record": camara.grabacion_continua,
}
try:
async with httpx.AsyncClient() as client:
response = await client.post(
f"{self.mediamtx_api}/v3/config/paths/add/{path}",
json=config,
timeout=10.0,
)
return response.status_code in [200, 201]
except httpx.HTTPError:
return False