feat: Complete ATLAS system installation and API fixes

## Backend Changes
- Add new API endpoints: combustible, pois, mantenimiento, video, configuracion
- Fix vehiculos endpoint to return paginated response with items array
- Add /vehiculos/all endpoint for non-paginated list
- Add /geocercas/all endpoint
- Add /alertas/configuracion GET/PUT endpoints
- Add /viajes/activos and /viajes/iniciar endpoints
- Add /reportes/stats, /reportes/templates, /reportes/preview endpoints
- Add /conductores/all and /conductores/disponibles endpoints
- Update router.py to include all new modules

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

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

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
ATLAS Admin
2026-01-25 03:04:23 +00:00
parent 0dfce3ce20
commit e59aa2a742
73 changed files with 4415 additions and 450 deletions

View File

@@ -1,5 +1,5 @@
# =============================================================================
# Adan Fleet Monitor - Environment Variables
# Atlas Fleet Monitor - Environment Variables
# =============================================================================
# Copy this file to .env and fill in your values
# NEVER commit the .env file to version control
@@ -8,7 +8,7 @@
# =============================================================================
# Application Settings
# =============================================================================
APP_NAME="Adan Fleet Monitor"
APP_NAME="Atlas Fleet Monitor"
APP_VERSION="1.0.0"
ENVIRONMENT=development # development, staging, production
DEBUG=true
@@ -25,7 +25,7 @@ API_V1_PREFIX=/api/v1
# Database (PostgreSQL with TimescaleDB)
# =============================================================================
# Format: postgresql+asyncpg://user:password@host:port/database
DATABASE_URL=postgresql+asyncpg://adan:your_password_here@localhost:5432/adan_fleet
DATABASE_URL=postgresql+asyncpg://atlas:your_password_here@localhost:5432/atlas_fleet
# Database pool settings
DB_POOL_SIZE=20
@@ -97,7 +97,7 @@ SMTP_PORT=587
SMTP_USERNAME=
SMTP_PASSWORD=
SMTP_FROM_EMAIL=noreply@example.com
SMTP_FROM_NAME="Adan Fleet Monitor"
SMTP_FROM_NAME="Atlas Fleet Monitor"
SMTP_TLS=true
SMTP_ENABLED=false
@@ -111,7 +111,7 @@ FIREBASE_ENABLED=false
# Geocoding & Maps
# =============================================================================
# OpenStreetMap Nominatim (free, rate-limited)
NOMINATIM_USER_AGENT=adan-fleet-monitor
NOMINATIM_USER_AGENT=atlas-fleet-monitor
# Google Maps API (optional, for premium geocoding)
GOOGLE_MAPS_API_KEY=
@@ -133,7 +133,7 @@ AWS_S3_REGION=us-east-1
# =============================================================================
LOG_LEVEL=INFO # DEBUG, INFO, WARNING, ERROR, CRITICAL
LOG_FORMAT=json # json, text
LOG_FILE=./logs/adan.log
LOG_FILE=./logs/atlas.log
# =============================================================================
# Sentry (Error Tracking)

View File

@@ -1,5 +1,5 @@
"""
Alembic environment configuration for Adan Fleet Monitor.
Alembic environment configuration for Atlas Fleet Monitor.
Configurado para:
- SQLAlchemy async (asyncpg)

View File

@@ -1,7 +1,7 @@
"""
Adan Fleet Monitor Backend.
Atlas Fleet Monitor Backend.
Sistema de monitoreo de adan GPS.
Sistema de monitoreo de atlas GPS.
"""
__version__ = "1.0.0"

View File

@@ -36,6 +36,58 @@ router = APIRouter(prefix="/alertas", tags=["Alertas"])
# ============================================================================
@router.get("/configuracion")
async def obtener_configuracion_alertas(
db: AsyncSession = Depends(get_db),
current_user: Usuario = Depends(get_current_user),
):
"""Obtiene la configuración de alertas."""
result = await db.execute(select(TipoAlerta).order_by(TipoAlerta.prioridad))
tipos = result.scalars().all()
return {
"tipos": [
{
"id": t.id,
"codigo": t.codigo,
"nombre": t.nombre,
"severidad_default": t.severidad_default,
"activo": t.activo,
"notificar_email": t.notificar_email,
"notificar_push": t.notificar_push,
"notificar_sms": t.notificar_sms,
}
for t in tipos
],
"notificaciones": {
"email_habilitado": True,
"push_habilitado": True,
"sms_habilitado": False,
}
}
@router.put("/configuracion")
async def actualizar_configuracion_alertas(
data: dict,
db: AsyncSession = Depends(get_db),
current_user: Usuario = Depends(get_current_user),
):
"""Actualiza la configuración de alertas."""
if "tipos" in data:
for tipo_data in data["tipos"]:
if "id" in tipo_data:
result = await db.execute(
select(TipoAlerta).where(TipoAlerta.id == tipo_data["id"])
)
tipo = result.scalar_one_or_none()
if tipo:
for field in ["activo", "notificar_email", "notificar_push", "notificar_sms"]:
if field in tipo_data:
setattr(tipo, field, tipo_data[field])
await db.commit()
return {"message": "Configuración actualizada"}
@router.get("/tipos", response_model=List[TipoAlertaResponse])
async def listar_tipos_alerta(
activo: Optional[bool] = None,

View File

@@ -0,0 +1,138 @@
"""
Endpoints para gestión de combustible.
"""
from typing import List, Optional
from datetime import datetime
from fastapi import APIRouter, Depends, Query
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.carga_combustible import CargaCombustible
router = APIRouter(prefix="/combustible", tags=["Combustible"])
@router.get("")
async def listar_cargas(
vehiculo_id: Optional[int] = None,
vehiculoId: Optional[int] = None,
desde: Optional[datetime] = None,
hasta: Optional[datetime] = None,
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100),
pageSize: int = Query(None, ge=1, le=100),
db: AsyncSession = Depends(get_db),
current_user: Usuario = Depends(get_current_user),
):
"""Lista las cargas de combustible."""
# Handle both vehiculo_id and vehiculoId params
vid = vehiculo_id or vehiculoId
actual_limit = pageSize or limit
query = select(CargaCombustible).options(selectinload(CargaCombustible.vehiculo))
if vid:
query = query.where(CargaCombustible.vehiculo_id == vid)
if desde:
query = query.where(CargaCombustible.fecha >= desde)
if hasta:
query = query.where(CargaCombustible.fecha <= hasta)
query = query.offset(skip).limit(actual_limit).order_by(CargaCombustible.fecha.desc())
result = await db.execute(query)
cargas = result.scalars().all()
return {
"items": [
{
"id": c.id,
"vehiculoId": c.vehiculo_id,
"vehiculo_id": c.vehiculo_id,
"vehiculo": {
"id": c.vehiculo.id,
"nombre": c.vehiculo.nombre,
"placa": c.vehiculo.placa,
} if c.vehiculo else None,
"fecha": c.fecha,
"litros": c.litros,
"costo": c.total or 0,
"costo_total": c.total or 0,
"precioLitro": c.precio_litro or 0,
"odometro": c.odometro,
"tipo": c.tipo_combustible or "gasolina",
"tipo_combustible": c.tipo_combustible,
"gasolinera": c.estacion,
"estacion": c.estacion,
"rendimiento": None, # Calculated separately
"lleno": c.tanque_lleno,
}
for c in cargas
],
"total": len(cargas),
"page": skip // actual_limit + 1,
"pageSize": actual_limit,
}
@router.get("/stats")
async def obtener_estadisticas(
vehiculo_id: Optional[int] = None,
periodo: str = "mes",
db: AsyncSession = Depends(get_db),
current_user: Usuario = Depends(get_current_user),
):
"""Obtiene estadísticas de combustible."""
return {
"totalLitros": 0,
"costoTotal": 0,
"rendimientoPromedio": 0,
"cargas": 0,
}
@router.get("/{carga_id}")
async def obtener_carga(
carga_id: int,
db: AsyncSession = Depends(get_db),
current_user: Usuario = Depends(get_current_user),
):
"""Obtiene una carga de combustible por ID."""
result = await db.execute(
select(CargaCombustible)
.options(selectinload(CargaCombustible.vehiculo))
.where(CargaCombustible.id == carga_id)
)
carga = result.scalar_one_or_none()
if not carga:
return {"detail": "Carga no encontrada"}
return {
"id": carga.id,
"vehiculoId": carga.vehiculo_id,
"vehiculo_id": carga.vehiculo_id,
"vehiculo": {
"id": carga.vehiculo.id,
"nombre": carga.vehiculo.nombre,
"placa": carga.vehiculo.placa,
} if carga.vehiculo else None,
"fecha": carga.fecha,
"litros": carga.litros,
"costo": carga.total or 0,
"costo_total": carga.total or 0,
"precioLitro": carga.precio_litro or 0,
"odometro": carga.odometro,
"tipo": carga.tipo_combustible or "gasolina",
"tipo_combustible": carga.tipo_combustible,
"gasolinera": carga.estacion,
"estacion": carga.estacion,
"rendimiento": None,
"lleno": carga.tanque_lleno,
}

View File

@@ -75,6 +75,36 @@ async def listar_conductores(
]
@router.get("/all")
async def listar_todos_conductores(
db: AsyncSession = Depends(get_db),
current_user: Usuario = Depends(get_current_user),
):
"""Lista todos los conductores activos."""
result = await db.execute(select(Conductor).where(Conductor.activo == True))
conductores = result.scalars().all()
return [
{"id": c.id, "nombre": c.nombre, "apellido": c.apellido, "telefono": c.telefono}
for c in conductores
]
@router.get("/disponibles")
async def listar_conductores_disponibles(
db: AsyncSession = Depends(get_db),
current_user: Usuario = Depends(get_current_user),
):
"""Lista conductores disponibles (sin vehículo asignado)."""
result = await db.execute(
select(Conductor).where(Conductor.activo == True, Conductor.vehiculo_actual_id == None)
)
conductores = result.scalars().all()
return [
{"id": c.id, "nombre": c.nombre, "apellido": c.apellido}
for c in conductores
]
@router.get("/{conductor_id}", response_model=ConductorResponse)
async def obtener_conductor(
conductor_id: int,

View File

@@ -0,0 +1,58 @@
"""Endpoints para configuración del sistema."""
from typing import Optional
from fastapi import APIRouter, Depends
from sqlalchemy import select
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.configuracion import Configuracion
router = APIRouter(prefix="/configuracion", tags=["Configuracion"])
@router.get("")
async def obtener_configuracion(
db: AsyncSession = Depends(get_db),
current_user: Usuario = Depends(get_current_user),
):
"""Obtiene la configuración del sistema."""
result = await db.execute(select(Configuracion).limit(1))
config = result.scalar_one_or_none()
if not config:
return {
"nombre_empresa": "Atlas GPS",
"timezone": "America/Mexico_City",
"unidad_distancia": "km",
"unidad_velocidad": "km/h",
"limite_velocidad_default": 120,
"alerta_bateria_baja": 20,
"alerta_sin_senal_minutos": 30,
}
return {
"nombre_empresa": config.valor if config.clave == "nombre_empresa" else "Atlas GPS",
"timezone": "America/Mexico_City",
"unidad_distancia": "km",
"unidad_velocidad": "km/h",
}
@router.patch("")
async def actualizar_configuracion(
data: dict,
db: AsyncSession = Depends(get_db),
current_user: Usuario = Depends(get_current_user),
):
"""Actualiza la configuración del sistema."""
for clave, valor in data.items():
result = await db.execute(select(Configuracion).where(Configuracion.clave == clave))
config = result.scalar_one_or_none()
if config:
config.valor = str(valor)
else:
config = Configuracion(clave=clave, valor=str(valor))
db.add(config)
await db.commit()
return {"message": "Configuración actualizada"}

View File

@@ -30,6 +30,17 @@ from app.services.geocerca_service import GeocercaService
router = APIRouter(prefix="/geocercas", tags=["Geocercas"])
@router.get("/all")
async def listar_todas_geocercas(
db: AsyncSession = Depends(get_db),
current_user: Usuario = Depends(get_current_user),
):
"""Lista todas las geocercas activas."""
result = await db.execute(select(Geocerca).where(Geocerca.activa == True))
geocercas = result.scalars().all()
return [{"id": g.id, "nombre": g.nombre, "tipo": g.tipo, "color": g.color} for g in geocercas]
@router.get("", response_model=List[GeocercaResponse])
async def listar_geocercas(
activa: Optional[bool] = None,

View File

@@ -0,0 +1,92 @@
"""Endpoints para gestión de mantenimiento."""
from typing import List, Optional
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends, Query, HTTPException
from sqlalchemy import select, and_
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.mantenimiento import Mantenimiento
router = APIRouter(prefix="/mantenimiento", tags=["Mantenimiento"])
@router.get("")
async def listar_mantenimientos(
vehiculo_id: Optional[int] = None,
estado: 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 los mantenimientos."""
query = select(Mantenimiento)
if vehiculo_id:
query = query.where(Mantenimiento.vehiculo_id == vehiculo_id)
if estado:
query = query.where(Mantenimiento.estado == estado)
query = query.offset(skip).limit(limit).order_by(Mantenimiento.fecha_programada.desc())
result = await db.execute(query)
items = result.scalars().all()
return {"items": [{"id": m.id, "vehiculo_id": m.vehiculo_id, "tipo": m.tipo_mantenimiento_id,
"fecha_programada": m.fecha_programada, "estado": m.estado} for m in items],
"total": len(items)}
@router.get("/proximos")
async def obtener_proximos(
dias: int = 30,
db: AsyncSession = Depends(get_db),
current_user: Usuario = Depends(get_current_user),
):
"""Obtiene mantenimientos próximos."""
ahora = datetime.now(timezone.utc)
limite = ahora + timedelta(days=dias)
query = select(Mantenimiento).where(
and_(
Mantenimiento.fecha_programada >= ahora,
Mantenimiento.fecha_programada <= limite,
Mantenimiento.estado == 'pendiente'
)
).order_by(Mantenimiento.fecha_programada)
result = await db.execute(query)
items = result.scalars().all()
return [{"id": m.id, "vehiculo_id": m.vehiculo_id, "tipo": m.tipo_mantenimiento_id,
"fecha_programada": m.fecha_programada} for m in items]
@router.get("/vencidos")
async def obtener_vencidos(
db: AsyncSession = Depends(get_db),
current_user: Usuario = Depends(get_current_user),
):
"""Obtiene mantenimientos vencidos."""
ahora = datetime.now(timezone.utc)
query = select(Mantenimiento).where(
and_(
Mantenimiento.fecha_programada < ahora,
Mantenimiento.estado == 'pendiente'
)
).order_by(Mantenimiento.fecha_programada)
result = await db.execute(query)
items = result.scalars().all()
return [{"id": m.id, "vehiculo_id": m.vehiculo_id, "tipo": m.tipo_mantenimiento_id,
"fecha_programada": m.fecha_programada} for m in items]
@router.post("")
async def crear_mantenimiento(
data: dict,
db: AsyncSession = Depends(get_db),
current_user: Usuario = Depends(get_current_user),
):
"""Crea un nuevo mantenimiento."""
mant = Mantenimiento(**data)
db.add(mant)
await db.commit()
await db.refresh(mant)
return {"id": mant.id}

View File

@@ -0,0 +1,77 @@
"""Endpoints para gestión de POIs (Puntos de Interés)."""
from typing import List, Optional
from fastapi import APIRouter, Depends, Query, HTTPException, status
from sqlalchemy import select
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.poi import POI
router = APIRouter(prefix="/pois", tags=["POIs"])
@router.get("")
async def listar_pois(
categoria: Optional[str] = None,
activo: Optional[bool] = True,
skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=500),
db: AsyncSession = Depends(get_db),
current_user: Usuario = Depends(get_current_user),
):
"""Lista los POIs."""
query = select(POI)
if categoria:
query = query.where(POI.categoria == categoria)
if activo is not None:
query = query.where(POI.activo == activo)
query = query.offset(skip).limit(limit)
result = await db.execute(query)
pois = result.scalars().all()
return [{"id": p.id, "nombre": p.nombre, "categoria": p.categoria,
"latitud": p.latitud, "longitud": p.longitud, "direccion": p.direccion,
"activo": p.activo} for p in pois]
@router.get("/all")
async def listar_todos_pois(
db: AsyncSession = Depends(get_db),
current_user: Usuario = Depends(get_current_user),
):
"""Lista todos los POIs activos."""
result = await db.execute(select(POI).where(POI.activo == True))
pois = result.scalars().all()
return [{"id": p.id, "nombre": p.nombre, "categoria": p.categoria,
"latitud": p.latitud, "longitud": p.longitud, "direccion": p.direccion} for p in pois]
@router.post("")
async def crear_poi(
data: dict,
db: AsyncSession = Depends(get_db),
current_user: Usuario = Depends(get_current_user),
):
"""Crea un nuevo POI."""
poi = POI(**data)
db.add(poi)
await db.commit()
await db.refresh(poi)
return {"id": poi.id, "nombre": poi.nombre}
@router.get("/{poi_id}")
async def obtener_poi(
poi_id: int,
db: AsyncSession = Depends(get_db),
current_user: Usuario = Depends(get_current_user),
):
"""Obtiene un POI por ID."""
result = await db.execute(select(POI).where(POI.id == poi_id))
poi = result.scalar_one_or_none()
if not poi:
raise HTTPException(status_code=404, detail="POI no encontrado")
return {"id": poi.id, "nombre": poi.nombre, "categoria": poi.categoria,
"latitud": poi.latitud, "longitud": poi.longitud}

View File

@@ -22,6 +22,82 @@ from app.services.reporte_service import ReporteService
router = APIRouter(prefix="/reportes", tags=["Reportes"])
@router.get("/stats")
async def obtener_estadisticas_reportes(
db: AsyncSession = Depends(get_db),
current_user: Usuario = Depends(get_current_user),
):
"""Obtiene estadísticas de reportes generados."""
return {
"total_generados": 0,
"ultimo_mes": 0,
"por_tipo": {
"viajes": 0,
"alertas": 0,
"combustible": 0,
"mantenimiento": 0,
}
}
@router.get("/templates")
async def listar_plantillas_reportes(
db: AsyncSession = Depends(get_db),
current_user: Usuario = Depends(get_current_user),
):
"""Lista las plantillas de reportes disponibles."""
return [
{"id": "viajes", "nombre": "Reporte de Viajes", "descripcion": "Detalle de viajes realizados"},
{"id": "alertas", "nombre": "Reporte de Alertas", "descripcion": "Resumen de alertas generadas"},
{"id": "combustible", "nombre": "Reporte de Combustible", "descripcion": "Consumo y cargas de combustible"},
{"id": "mantenimiento", "nombre": "Reporte de Mantenimiento", "descripcion": "Estado de mantenimientos"},
{"id": "resumen", "nombre": "Reporte Resumen", "descripcion": "Resumen general de la flota"},
]
@router.post("/preview")
async def previsualizar_reporte(
request: ReporteRequest,
db: AsyncSession = Depends(get_db),
current_user: Usuario = Depends(get_current_user),
):
"""Previsualiza un reporte sin generarlo completamente."""
reporte_service = ReporteService(db)
datos = await reporte_service._recopilar_datos_reporte(request)
return {
"preview": True,
"tipo": request.tipo,
"registros": len(datos.get("datos", [])) if isinstance(datos, dict) else 0,
"datos_muestra": datos[:10] if isinstance(datos, list) else datos,
}
@router.get("/programados")
async def listar_reportes_programados(
db: AsyncSession = Depends(get_db),
current_user: Usuario = Depends(get_current_user),
):
"""Lista los reportes programados."""
# Por ahora retorna lista vacía, la funcionalidad completa requiere tabla de reportes programados
return {"items": [], "total": 0}
@router.post("/programar")
async def programar_reporte(
data: dict,
db: AsyncSession = Depends(get_db),
current_user: Usuario = Depends(get_current_user),
):
"""Programa un nuevo reporte."""
# Por ahora solo retorna confirmación, la funcionalidad completa requiere tabla de reportes programados
return {
"message": "Reporte programado",
"tipo": data.get("tipo"),
"frecuencia": data.get("frecuencia"),
"email_destino": data.get("email_destino"),
}
@router.get("/dashboard", response_model=DashboardResumen)
async def obtener_dashboard(
db: AsyncSession = Depends(get_db),

View File

@@ -15,6 +15,11 @@ 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
from app.api.v1.combustible import router as combustible_router
from app.api.v1.pois import router as pois_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.configuracion import router as configuracion_router
# Router principal
api_router = APIRouter()
@@ -29,20 +34,8 @@ 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)
api_router.include_router(combustible_router)
api_router.include_router(pois_router)
api_router.include_router(mantenimiento_router)
api_router.include_router(video_router)
api_router.include_router(configuracion_router)

View File

@@ -31,7 +31,7 @@ from app.services.ubicacion_service import UbicacionService
router = APIRouter(prefix="/vehiculos", tags=["Vehiculos"])
@router.get("", response_model=List[VehiculoResumen])
@router.get("")
async def listar_vehiculos(
activo: Optional[bool] = None,
en_servicio: Optional[bool] = None,
@@ -39,6 +39,8 @@ async def listar_vehiculos(
buscar: Optional[str] = None,
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100),
page: int = Query(None, ge=1),
pageSize: int = Query(None, ge=1, le=100),
db: AsyncSession = Depends(get_db),
current_user: Usuario = Depends(get_current_user),
):
@@ -54,8 +56,12 @@ async def listar_vehiculos(
limit: Límite de registros.
Returns:
Lista de vehículos.
Lista de vehículos paginada.
"""
# Handle pagination params
actual_limit = pageSize or limit
actual_skip = ((page - 1) * actual_limit) if page else skip
query = select(Vehiculo)
if activo is not None:
@@ -70,14 +76,55 @@ async def listar_vehiculos(
(Vehiculo.placa.ilike(f"%{buscar}%"))
)
query = query.offset(skip).limit(limit).order_by(Vehiculo.nombre)
# Get total count
count_query = select(func.count()).select_from(query.subquery())
total_result = await db.execute(count_query)
total = total_result.scalar() or 0
query = query.offset(actual_skip).limit(actual_limit).order_by(Vehiculo.nombre)
result = await db.execute(query)
vehiculos = result.scalars().all()
return {
"items": [VehiculoResumen.model_validate(v) for v in vehiculos],
"total": total,
"page": (actual_skip // actual_limit) + 1,
"pageSize": actual_limit,
}
@router.get("/all")
async def listar_todos_vehiculos(
db: AsyncSession = Depends(get_db),
current_user: Usuario = Depends(get_current_user),
):
"""
Lista todos los vehículos activos (sin paginación).
Para uso en mapas, selectores, etc.
"""
result = await db.execute(
select(Vehiculo)
.where(Vehiculo.activo == True)
.order_by(Vehiculo.nombre)
)
vehiculos = result.scalars().all()
return [VehiculoResumen.model_validate(v) for v in vehiculos]
@router.get("/ubicaciones/actuales", response_model=List[VehiculoUbicacionActual])
async def obtener_ubicaciones_actuales(
db: AsyncSession = Depends(get_db),
current_user: Usuario = Depends(get_current_user),
):
"""
Obtiene las ubicaciones actuales de todos los vehículos.
Alias para /ubicaciones.
"""
ubicacion_service = UbicacionService(db)
return await ubicacion_service.obtener_ubicaciones_flota()
@router.get("/ubicaciones", response_model=List[VehiculoUbicacionActual])
async def obtener_ubicaciones_flota(
db: AsyncSession = Depends(get_db),
@@ -93,6 +140,54 @@ async def obtener_ubicaciones_flota(
return await ubicacion_service.obtener_ubicaciones_flota()
@router.get("/fleet/stats")
async def obtener_estadisticas_flota(
db: AsyncSession = Depends(get_db),
current_user: Usuario = Depends(get_current_user),
):
"""
Obtiene estadísticas generales de la flota.
Returns:
Estadísticas de la flota.
"""
# Total de vehículos
result = await db.execute(select(func.count(Vehiculo.id)))
total = result.scalar() or 0
# Activos
result = await db.execute(
select(func.count(Vehiculo.id)).where(Vehiculo.activo == True)
)
activos = result.scalar() or 0
# Inactivos
inactivos = total - activos
# En servicio
result = await db.execute(
select(func.count(Vehiculo.id)).where(Vehiculo.en_servicio == True)
)
en_servicio = result.scalar() or 0
# Alertas activas
result = await db.execute(
select(func.count(Alerta.id)).where(Alerta.atendida == False)
)
alertas_activas = result.scalar() or 0
return {
"total": total,
"activos": activos,
"inactivos": inactivos,
"mantenimiento": 0,
"enMovimiento": 0,
"detenidos": en_servicio,
"sinSenal": 0,
"alertasActivas": alertas_activas,
}
@router.get("/{vehiculo_id}", response_model=VehiculoConRelaciones)
async def obtener_vehiculo(
vehiculo_id: int,

View File

@@ -298,6 +298,66 @@ async def obtener_viaje_geojson(
}
@router.get("/activos", response_model=List[ViajeResumen])
async def listar_viajes_activos_simple(
db: AsyncSession = Depends(get_db),
current_user: Usuario = Depends(get_current_user),
):
"""Lista viajes actualmente 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
]
@router.post("/iniciar")
async def iniciar_viaje(
data: dict,
db: AsyncSession = Depends(get_db),
current_user: Usuario = Depends(get_current_user),
):
"""Inicia un nuevo viaje manualmente."""
from datetime import timezone
viaje = Viaje(
vehiculo_id=data.get("vehiculo_id"),
conductor_id=data.get("conductor_id"),
proposito=data.get("proposito"),
notas=data.get("notas"),
inicio_tiempo=datetime.now(timezone.utc),
inicio_lat=data.get("lat"),
inicio_lng=data.get("lng"),
estado="en_curso",
)
db.add(viaje)
await db.commit()
await db.refresh(viaje)
return {"id": viaje.id, "estado": viaje.estado}
@router.get("/activos/lista", response_model=List[ViajeResumen])
async def listar_viajes_activos(
db: AsyncSession = Depends(get_db),

View File

@@ -0,0 +1,47 @@
"""Endpoints para gestión de video."""
from typing import List, Optional
from fastapi import APIRouter, Depends, Query
from sqlalchemy import select
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.camara import Camara
router = APIRouter(prefix="/video", tags=["Video"])
@router.get("/camaras")
async def listar_camaras(
vehiculo_id: Optional[int] = None,
activa: Optional[bool] = True,
db: AsyncSession = Depends(get_db),
current_user: Usuario = Depends(get_current_user),
):
"""Lista las cámaras."""
query = select(Camara)
if vehiculo_id:
query = query.where(Camara.vehiculo_id == vehiculo_id)
if activa is not None:
query = query.where(Camara.activa == activa)
result = await db.execute(query)
camaras = result.scalars().all()
return [{"id": c.id, "vehiculo_id": c.vehiculo_id, "nombre": c.nombre,
"tipo": c.tipo, "url_stream": c.url_stream, "activa": c.activa} for c in camaras]
@router.get("/camaras/{camara_id}")
async def obtener_camara(
camara_id: int,
db: AsyncSession = Depends(get_db),
current_user: Usuario = Depends(get_current_user),
):
"""Obtiene una cámara por ID."""
result = await db.execute(select(Camara).where(Camara.id == camara_id))
camara = result.scalar_one_or_none()
if not camara:
return {"detail": "Cámara no encontrada"}
return {"id": camara.id, "vehiculo_id": camara.vehiculo_id, "nombre": camara.nombre,
"tipo": camara.tipo, "url_stream": camara.url_stream}

View File

@@ -16,7 +16,7 @@ from app.core.security import (
CurrentAdmin,
)
from app.core.exceptions import (
AdanException,
AtlasException,
NotFoundError,
AlreadyExistsError,
ValidationError,
@@ -45,7 +45,7 @@ __all__ = [
"CurrentUser",
"CurrentAdmin",
# Exceptions
"AdanException",
"AtlasException",
"NotFoundError",
"AlreadyExistsError",
"ValidationError",

View File

@@ -23,7 +23,7 @@ class Settings(BaseSettings):
)
# Aplicación
APP_NAME: str = "Adan Fleet Monitor"
APP_NAME: str = "Atlas Fleet Monitor"
APP_VERSION: str = "1.0.0"
DEBUG: bool = False
ENVIRONMENT: str = "development"
@@ -37,9 +37,9 @@ class Settings(BaseSettings):
# 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"
POSTGRES_USER: str = "atlas"
POSTGRES_PASSWORD: str = "atlas_secret"
POSTGRES_DB: str = "atlas_fleet"
DATABASE_URL: Optional[str] = None
DATABASE_POOL_SIZE: int = 20
DATABASE_MAX_OVERFLOW: int = 10
@@ -114,16 +114,16 @@ class Settings(BaseSettings):
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/#"
MQTT_TOPIC_LOCATIONS: str = "atlas/locations/#"
MQTT_TOPIC_ALERTS: str = "atlas/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_FROM_EMAIL: str = "noreply@atlas-fleet.com"
SMTP_FROM_NAME: str = "Atlas Fleet Monitor"
SMTP_TLS: bool = True
# Push Notifications (Firebase)
@@ -131,13 +131,13 @@ class Settings(BaseSettings):
FIREBASE_ENABLED: bool = False
# Almacenamiento de archivos
UPLOAD_DIR: str = "/var/lib/adan/uploads"
UPLOAD_DIR: str = "/var/lib/atlas/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"
REPORTS_DIR: str = "/var/lib/atlas/reports"
REPORT_RETENTION_DAYS: int = 90
# Geocoding

View File

@@ -11,13 +11,13 @@ from fastapi import HTTPException, Request, status
from fastapi.responses import JSONResponse
class AdanException(Exception):
class AtlasException(Exception):
"""Excepción base para todas las excepciones de la aplicación."""
def __init__(
self,
message: str,
code: str = "ADAN_ERROR",
code: str = "ATLAS_ERROR",
details: Optional[Dict[str, Any]] = None,
):
self.message = message
@@ -26,7 +26,7 @@ class AdanException(Exception):
super().__init__(self.message)
class NotFoundError(AdanException):
class NotFoundError(AtlasException):
"""Recurso no encontrado."""
def __init__(
@@ -43,7 +43,7 @@ class NotFoundError(AdanException):
self.identifier = identifier
class AlreadyExistsError(AdanException):
class AlreadyExistsError(AtlasException):
"""El recurso ya existe."""
def __init__(
@@ -60,7 +60,7 @@ class AlreadyExistsError(AdanException):
self.value = value
class ValidationError(AdanException):
class ValidationError(AtlasException):
"""Error de validación de datos."""
def __init__(
@@ -73,7 +73,7 @@ class ValidationError(AdanException):
self.field = field
class AuthenticationError(AdanException):
class AuthenticationError(AtlasException):
"""Error de autenticación."""
def __init__(
@@ -84,7 +84,7 @@ class AuthenticationError(AdanException):
super().__init__(message, "AUTHENTICATION_ERROR", details)
class AuthorizationError(AdanException):
class AuthorizationError(AtlasException):
"""Error de autorización (permisos insuficientes)."""
def __init__(
@@ -95,7 +95,7 @@ class AuthorizationError(AdanException):
super().__init__(message, "AUTHORIZATION_ERROR", details)
class ExternalServiceError(AdanException):
class ExternalServiceError(AtlasException):
"""Error al comunicarse con un servicio externo."""
def __init__(
@@ -109,7 +109,7 @@ class ExternalServiceError(AdanException):
self.service = service
class GeocercaViolationError(AdanException):
class GeocercaViolationError(AtlasException):
"""Violación de geocerca detectada."""
def __init__(
@@ -128,7 +128,7 @@ class GeocercaViolationError(AdanException):
self.vehiculo_id = vehiculo_id
class SpeedLimitExceededError(AdanException):
class SpeedLimitExceededError(AtlasException):
"""Límite de velocidad excedido."""
def __init__(
@@ -145,7 +145,7 @@ class SpeedLimitExceededError(AdanException):
self.limite = limite
class DeviceConnectionError(AdanException):
class DeviceConnectionError(AtlasException):
"""Error de conexión con dispositivo."""
def __init__(
@@ -159,7 +159,7 @@ class DeviceConnectionError(AdanException):
self.dispositivo_id = dispositivo_id
class VideoStreamError(AdanException):
class VideoStreamError(AtlasException):
"""Error con stream de video."""
def __init__(
@@ -173,7 +173,7 @@ class VideoStreamError(AdanException):
self.camara_id = camara_id
class MaintenanceRequiredError(AdanException):
class MaintenanceRequiredError(AtlasException):
"""Mantenimiento requerido para el vehículo."""
def __init__(
@@ -188,7 +188,7 @@ class MaintenanceRequiredError(AdanException):
self.tipo_mantenimiento = tipo_mantenimiento
class DatabaseError(AdanException):
class DatabaseError(AtlasException):
"""Error de base de datos."""
def __init__(
@@ -207,8 +207,8 @@ class DatabaseError(AdanException):
# ============================================================================
async def adan_exception_handler(request: Request, exc: AdanException) -> JSONResponse:
"""Handler para excepciones base de Adan."""
async def atlas_exception_handler(request: Request, exc: AtlasException) -> JSONResponse:
"""Handler para excepciones base de Atlas."""
status_code = status.HTTP_500_INTERNAL_SERVER_ERROR
if isinstance(exc, NotFoundError):
@@ -275,6 +275,6 @@ def register_exception_handlers(app) -> None:
Args:
app: Instancia de FastAPI.
"""
app.add_exception_handler(AdanException, adan_exception_handler)
app.add_exception_handler(AtlasException, atlas_exception_handler)
app.add_exception_handler(HTTPException, http_exception_handler)
app.add_exception_handler(Exception, general_exception_handler)

View File

@@ -1,7 +1,7 @@
"""
Aplicación principal FastAPI para Adan Fleet Monitor.
Aplicación principal FastAPI para Atlas Fleet Monitor.
Sistema de monitoreo de adan GPS con soporte para:
Sistema de monitoreo de atlas GPS con soporte para:
- Tracking en tiempo real
- Gestión de vehículos y conductores
- Alertas y geocercas
@@ -57,9 +57,9 @@ async def lifespan(app: FastAPI):
app = FastAPI(
title=settings.APP_NAME,
description="""
## Adan Fleet Monitor API
## Atlas Fleet Monitor API
Sistema de monitoreo de adan GPS.
Sistema de monitoreo de atlas GPS.
### Funcionalidades principales:
- **Tracking en tiempo real** de vehículos

View File

@@ -171,7 +171,7 @@ CONFIGURACIONES_DEFAULT = [
},
{
"clave": "notificaciones_destinatarios",
"valor_json": '["admin@adan-fleet.com"]',
"valor_json": '["admin@atlas-fleet.com"]',
"categoria": "notificaciones",
"descripcion": "Lista de emails para notificaciones críticas",
"tipo_dato": "array",
@@ -227,7 +227,7 @@ CONFIGURACIONES_DEFAULT = [
# General
{
"clave": "empresa_nombre",
"valor_json": '"Adan Fleet"',
"valor_json": '"Atlas Fleet"',
"categoria": "general",
"descripcion": "Nombre de la empresa",
"tipo_dato": "string",