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:
197
backend/.env.example
Normal file
197
backend/.env.example
Normal file
@@ -0,0 +1,197 @@
|
||||
# =============================================================================
|
||||
# Adan Fleet Monitor - Environment Variables
|
||||
# =============================================================================
|
||||
# Copy this file to .env and fill in your values
|
||||
# NEVER commit the .env file to version control
|
||||
# =============================================================================
|
||||
|
||||
# =============================================================================
|
||||
# Application Settings
|
||||
# =============================================================================
|
||||
APP_NAME="Adan Fleet Monitor"
|
||||
APP_VERSION="1.0.0"
|
||||
ENVIRONMENT=development # development, staging, production
|
||||
DEBUG=true
|
||||
HOST=0.0.0.0
|
||||
PORT=8000
|
||||
WORKERS=4 # Number of workers for production
|
||||
|
||||
# =============================================================================
|
||||
# API Settings
|
||||
# =============================================================================
|
||||
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 pool settings
|
||||
DB_POOL_SIZE=20
|
||||
DB_MAX_OVERFLOW=10
|
||||
DB_POOL_TIMEOUT=30
|
||||
|
||||
# =============================================================================
|
||||
# Redis (Caching & Real-time)
|
||||
# =============================================================================
|
||||
# Format: redis://[:password@]host:port/db
|
||||
REDIS_URL=redis://localhost:6379/0
|
||||
|
||||
# =============================================================================
|
||||
# JWT Authentication
|
||||
# =============================================================================
|
||||
# Generate with: openssl rand -hex 32
|
||||
SECRET_KEY=your_super_secret_key_change_this_in_production_minimum_32_characters
|
||||
JWT_ALGORITHM=HS256
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES=30
|
||||
REFRESH_TOKEN_EXPIRE_DAYS=7
|
||||
|
||||
# =============================================================================
|
||||
# Password Hashing
|
||||
# =============================================================================
|
||||
PASSWORD_MIN_LENGTH=8
|
||||
|
||||
# =============================================================================
|
||||
# CORS Settings
|
||||
# =============================================================================
|
||||
# Comma-separated list of allowed origins
|
||||
CORS_ORIGINS=http://localhost:3000,http://localhost:5173,http://127.0.0.1:3000
|
||||
CORS_ALLOW_CREDENTIALS=true
|
||||
CORS_ALLOW_METHODS=GET,POST,PUT,PATCH,DELETE,OPTIONS
|
||||
CORS_ALLOW_HEADERS=*
|
||||
|
||||
# =============================================================================
|
||||
# Traccar GPS Server Integration
|
||||
# =============================================================================
|
||||
TRACCAR_URL=http://localhost:8082
|
||||
TRACCAR_API_URL=http://localhost:8082/api
|
||||
TRACCAR_USERNAME=admin
|
||||
TRACCAR_PASSWORD=admin
|
||||
TRACCAR_ENABLED=true
|
||||
|
||||
# =============================================================================
|
||||
# MediaMTX Video Streaming Server
|
||||
# =============================================================================
|
||||
MEDIAMTX_URL=http://localhost:8554
|
||||
MEDIAMTX_API_URL=http://localhost:9997
|
||||
MEDIAMTX_ENABLED=false
|
||||
|
||||
# =============================================================================
|
||||
# MQTT Broker (IoT Communication)
|
||||
# =============================================================================
|
||||
MQTT_BROKER=localhost
|
||||
MQTT_PORT=1883
|
||||
MQTT_USERNAME=
|
||||
MQTT_PASSWORD=
|
||||
MQTT_ENABLED=false
|
||||
|
||||
# Meshtastic MQTT Topic
|
||||
MESHTASTIC_TOPIC=meshtastic/#
|
||||
|
||||
# =============================================================================
|
||||
# Email (SMTP)
|
||||
# =============================================================================
|
||||
SMTP_HOST=smtp.gmail.com
|
||||
SMTP_PORT=587
|
||||
SMTP_USERNAME=
|
||||
SMTP_PASSWORD=
|
||||
SMTP_FROM_EMAIL=noreply@example.com
|
||||
SMTP_FROM_NAME="Adan Fleet Monitor"
|
||||
SMTP_TLS=true
|
||||
SMTP_ENABLED=false
|
||||
|
||||
# =============================================================================
|
||||
# Push Notifications (Firebase)
|
||||
# =============================================================================
|
||||
FIREBASE_CREDENTIALS_PATH=
|
||||
FIREBASE_ENABLED=false
|
||||
|
||||
# =============================================================================
|
||||
# Geocoding & Maps
|
||||
# =============================================================================
|
||||
# OpenStreetMap Nominatim (free, rate-limited)
|
||||
NOMINATIM_USER_AGENT=adan-fleet-monitor
|
||||
|
||||
# Google Maps API (optional, for premium geocoding)
|
||||
GOOGLE_MAPS_API_KEY=
|
||||
|
||||
# =============================================================================
|
||||
# File Storage
|
||||
# =============================================================================
|
||||
UPLOAD_DIR=./uploads
|
||||
MAX_UPLOAD_SIZE_MB=50
|
||||
|
||||
# AWS S3 (optional, for cloud storage)
|
||||
AWS_ACCESS_KEY_ID=
|
||||
AWS_SECRET_ACCESS_KEY=
|
||||
AWS_S3_BUCKET=
|
||||
AWS_S3_REGION=us-east-1
|
||||
|
||||
# =============================================================================
|
||||
# Logging
|
||||
# =============================================================================
|
||||
LOG_LEVEL=INFO # DEBUG, INFO, WARNING, ERROR, CRITICAL
|
||||
LOG_FORMAT=json # json, text
|
||||
LOG_FILE=./logs/adan.log
|
||||
|
||||
# =============================================================================
|
||||
# Sentry (Error Tracking)
|
||||
# =============================================================================
|
||||
SENTRY_DSN=
|
||||
SENTRY_ENVIRONMENT=development
|
||||
SENTRY_ENABLED=false
|
||||
|
||||
# =============================================================================
|
||||
# Rate Limiting
|
||||
# =============================================================================
|
||||
RATE_LIMIT_REQUESTS=100
|
||||
RATE_LIMIT_PERIOD_SECONDS=60
|
||||
|
||||
# =============================================================================
|
||||
# Background Tasks (Celery)
|
||||
# =============================================================================
|
||||
CELERY_BROKER_URL=redis://localhost:6379/1
|
||||
CELERY_RESULT_BACKEND=redis://localhost:6379/2
|
||||
CELERY_ENABLED=false
|
||||
|
||||
# =============================================================================
|
||||
# Alert Settings
|
||||
# =============================================================================
|
||||
# Default speed limit (km/h) for new vehicles
|
||||
DEFAULT_SPEED_LIMIT=120
|
||||
|
||||
# Time without signal before generating alert (seconds)
|
||||
NO_SIGNAL_THRESHOLD_SECONDS=300
|
||||
|
||||
# Battery level threshold for low battery alert (%)
|
||||
LOW_BATTERY_THRESHOLD=20
|
||||
|
||||
# Minimum stop duration to register as a stop (seconds)
|
||||
MIN_STOP_DURATION_SECONDS=120
|
||||
|
||||
# =============================================================================
|
||||
# Trip Detection
|
||||
# =============================================================================
|
||||
# Minimum movement to start a trip (meters)
|
||||
TRIP_START_DISTANCE_METERS=100
|
||||
|
||||
# Minimum idle time to end a trip (seconds)
|
||||
TRIP_END_IDLE_SECONDS=300
|
||||
|
||||
# =============================================================================
|
||||
# WebSocket Settings
|
||||
# =============================================================================
|
||||
WS_HEARTBEAT_INTERVAL=30
|
||||
WS_MAX_CONNECTIONS=1000
|
||||
|
||||
# =============================================================================
|
||||
# Report Settings
|
||||
# =============================================================================
|
||||
REPORTS_OUTPUT_DIR=./reports
|
||||
REPORTS_RETENTION_DAYS=90
|
||||
|
||||
# =============================================================================
|
||||
# Timezone
|
||||
# =============================================================================
|
||||
TIMEZONE=America/Mexico_City
|
||||
73
backend/alembic.ini
Normal file
73
backend/alembic.ini
Normal file
@@ -0,0 +1,73 @@
|
||||
# Alembic configuration file for Adan Fleet Monitor
|
||||
|
||||
[alembic]
|
||||
# path to migration scripts
|
||||
script_location = alembic
|
||||
|
||||
# template used to generate migration files
|
||||
file_template = %%(year)d%%(month).2d%%(day).2d_%%(hour).2d%%(minute).2d%%(second).2d_%%(rev)s_%%(slug)s
|
||||
|
||||
# sys.path path, will be prepended to sys.path if present.
|
||||
prepend_sys_path = .
|
||||
|
||||
# timezone to use when rendering the date within the migration file
|
||||
timezone = UTC
|
||||
|
||||
# max length of characters to apply to the "slug" field
|
||||
truncate_slug_length = 40
|
||||
|
||||
# set to 'true' to run the environment during the 'revision' command
|
||||
revision_environment = false
|
||||
|
||||
# set to 'true' to allow .pyc and .pyo files without a source .py file to be detected as revisions in the versions/ directory
|
||||
sourceless = false
|
||||
|
||||
# version location specification
|
||||
version_locations = %(here)s/alembic/versions
|
||||
|
||||
# version path separator
|
||||
version_path_separator = os
|
||||
|
||||
# the output encoding used when revision files are written from script.py.mako
|
||||
output_encoding = utf-8
|
||||
|
||||
# Database URL - will be overridden by env.py
|
||||
sqlalchemy.url = driver://user:pass@localhost/dbname
|
||||
|
||||
[post_write_hooks]
|
||||
# post_write_hooks defines scripts or Python functions that are run
|
||||
# on newly generated revision scripts.
|
||||
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
137
backend/alembic/env.py
Normal file
137
backend/alembic/env.py
Normal file
@@ -0,0 +1,137 @@
|
||||
"""
|
||||
Alembic environment configuration for Adan Fleet Monitor.
|
||||
|
||||
Configurado para:
|
||||
- SQLAlchemy async (asyncpg)
|
||||
- TimescaleDB hypertables
|
||||
- Migraciones automáticas desde modelos
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from logging.config import fileConfig
|
||||
|
||||
from alembic import context
|
||||
from sqlalchemy import pool, text
|
||||
from sqlalchemy.engine import Connection
|
||||
from sqlalchemy.ext.asyncio import async_engine_from_config
|
||||
|
||||
from app.core.config import settings
|
||||
from app.models.base import Base
|
||||
|
||||
# Import all models so they are registered with Base.metadata
|
||||
from app.models import (
|
||||
Usuario,
|
||||
GrupoVehiculos,
|
||||
Conductor,
|
||||
Vehiculo,
|
||||
Dispositivo,
|
||||
Ubicacion,
|
||||
Viaje,
|
||||
Parada,
|
||||
TipoAlerta,
|
||||
Alerta,
|
||||
Geocerca,
|
||||
POI,
|
||||
CargaCombustible,
|
||||
TipoMantenimiento,
|
||||
Mantenimiento,
|
||||
Camara,
|
||||
Grabacion,
|
||||
EventoVideo,
|
||||
Mensaje,
|
||||
Configuracion,
|
||||
)
|
||||
|
||||
# this is the Alembic Config object, which provides
|
||||
# access to the values within the .ini file in use.
|
||||
config = context.config
|
||||
|
||||
# Interpret the config file for Python logging.
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
# add your model's MetaData object here
|
||||
target_metadata = Base.metadata
|
||||
|
||||
|
||||
def get_url() -> str:
|
||||
"""
|
||||
Obtiene la URL de la base de datos desde la configuración.
|
||||
"""
|
||||
return settings.DATABASE_URL
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
"""
|
||||
Run migrations in 'offline' mode.
|
||||
|
||||
This configures the context with just a URL
|
||||
and not an Engine, though an Engine is acceptable
|
||||
here as well. By skipping the Engine creation
|
||||
we don't even need a DBAPI to be available.
|
||||
|
||||
Calls to context.execute() here emit the given string to the
|
||||
script output.
|
||||
"""
|
||||
url = get_url()
|
||||
context.configure(
|
||||
url=url,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
compare_type=True,
|
||||
compare_server_default=True,
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def do_run_migrations(connection: Connection) -> None:
|
||||
"""
|
||||
Run migrations with the given connection.
|
||||
"""
|
||||
context.configure(
|
||||
connection=connection,
|
||||
target_metadata=target_metadata,
|
||||
compare_type=True,
|
||||
compare_server_default=True,
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
async def run_async_migrations() -> None:
|
||||
"""
|
||||
Run migrations in 'online' mode with async engine.
|
||||
|
||||
In this scenario we need to create an Engine
|
||||
and associate a connection with the context.
|
||||
"""
|
||||
configuration = config.get_section(config.config_ini_section)
|
||||
configuration["sqlalchemy.url"] = get_url()
|
||||
|
||||
connectable = async_engine_from_config(
|
||||
configuration,
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
|
||||
async with connectable.connect() as connection:
|
||||
await connection.run_sync(do_run_migrations)
|
||||
|
||||
await connectable.dispose()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
"""
|
||||
Run migrations in 'online' mode.
|
||||
"""
|
||||
asyncio.run(run_async_migrations())
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
26
backend/alembic/script.py.mako
Normal file
26
backend/alembic/script.py.mako
Normal file
@@ -0,0 +1,26 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = ${repr(up_revision)}
|
||||
down_revision: Union[str, None] = ${repr(down_revision)}
|
||||
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
${downgrades if downgrades else "pass"}
|
||||
847
backend/alembic/versions/20260121_000000_001_initial_schema.py
Normal file
847
backend/alembic/versions/20260121_000000_001_initial_schema.py
Normal file
@@ -0,0 +1,847 @@
|
||||
"""Initial schema with TimescaleDB support.
|
||||
|
||||
Revision ID: 001
|
||||
Revises:
|
||||
Create Date: 2026-01-21
|
||||
|
||||
Este migration crea:
|
||||
- Extensiones PostgreSQL (TimescaleDB, PostGIS opcional)
|
||||
- Todas las tablas del sistema
|
||||
- Hypertable para ubicaciones (time-series)
|
||||
- Índices y constraints
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "001"
|
||||
down_revision: Union[str, None] = None
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ========================================================================
|
||||
# Extensiones PostgreSQL
|
||||
# ========================================================================
|
||||
|
||||
# TimescaleDB para series temporales
|
||||
op.execute("CREATE EXTENSION IF NOT EXISTS timescaledb CASCADE;")
|
||||
|
||||
# PostGIS para datos geoespaciales (opcional)
|
||||
op.execute("CREATE EXTENSION IF NOT EXISTS postgis;")
|
||||
|
||||
# UUID generation
|
||||
op.execute('CREATE EXTENSION IF NOT EXISTS "uuid-ossp";')
|
||||
|
||||
# ========================================================================
|
||||
# Tipos ENUM
|
||||
# ========================================================================
|
||||
|
||||
tipo_vehiculo_enum = postgresql.ENUM(
|
||||
"sedan", "suv", "pickup", "van", "camion", "motocicleta", "autobus", "otro",
|
||||
name="tipo_vehiculo",
|
||||
create_type=True,
|
||||
)
|
||||
tipo_vehiculo_enum.create(op.get_bind(), checkfirst=True)
|
||||
|
||||
estado_vehiculo_enum = postgresql.ENUM(
|
||||
"activo", "inactivo", "mantenimiento", "baja",
|
||||
name="estado_vehiculo",
|
||||
create_type=True,
|
||||
)
|
||||
estado_vehiculo_enum.create(op.get_bind(), checkfirst=True)
|
||||
|
||||
tipo_conductor_enum = postgresql.ENUM(
|
||||
"interno", "externo", "temporal",
|
||||
name="tipo_conductor",
|
||||
create_type=True,
|
||||
)
|
||||
tipo_conductor_enum.create(op.get_bind(), checkfirst=True)
|
||||
|
||||
tipo_dispositivo_enum = postgresql.ENUM(
|
||||
"gps", "obd", "dashcam", "sensor", "meshtastic", "otro",
|
||||
name="tipo_dispositivo",
|
||||
create_type=True,
|
||||
)
|
||||
tipo_dispositivo_enum.create(op.get_bind(), checkfirst=True)
|
||||
|
||||
estado_dispositivo_enum = postgresql.ENUM(
|
||||
"activo", "inactivo", "sin_senal", "bateria_baja",
|
||||
name="estado_dispositivo",
|
||||
create_type=True,
|
||||
)
|
||||
estado_dispositivo_enum.create(op.get_bind(), checkfirst=True)
|
||||
|
||||
tipo_geocerca_enum = postgresql.ENUM(
|
||||
"circular", "poligonal",
|
||||
name="tipo_geocerca",
|
||||
create_type=True,
|
||||
)
|
||||
tipo_geocerca_enum.create(op.get_bind(), checkfirst=True)
|
||||
|
||||
severidad_alerta_enum = postgresql.ENUM(
|
||||
"baja", "media", "alta", "critica",
|
||||
name="severidad_alerta",
|
||||
create_type=True,
|
||||
)
|
||||
severidad_alerta_enum.create(op.get_bind(), checkfirst=True)
|
||||
|
||||
prioridad_mensaje_enum = postgresql.ENUM(
|
||||
"baja", "normal", "alta", "urgente",
|
||||
name="prioridad_mensaje",
|
||||
create_type=True,
|
||||
)
|
||||
prioridad_mensaje_enum.create(op.get_bind(), checkfirst=True)
|
||||
|
||||
# ========================================================================
|
||||
# Tabla: usuarios
|
||||
# ========================================================================
|
||||
|
||||
op.create_table(
|
||||
"usuarios",
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("email", sa.String(255), nullable=False),
|
||||
sa.Column("hashed_password", sa.String(255), nullable=False),
|
||||
sa.Column("nombre", sa.String(100), nullable=False),
|
||||
sa.Column("apellido", sa.String(100), nullable=True),
|
||||
sa.Column("telefono", sa.String(20), nullable=True),
|
||||
sa.Column("rol", sa.String(50), nullable=False, server_default="operador"),
|
||||
sa.Column("activo", sa.Boolean(), nullable=False, server_default="true"),
|
||||
sa.Column("ultimo_login", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("avatar_url", sa.String(500), nullable=True),
|
||||
sa.Column("preferencias", postgresql.JSONB(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("email"),
|
||||
)
|
||||
op.create_index("ix_usuarios_email", "usuarios", ["email"])
|
||||
op.create_index("ix_usuarios_rol", "usuarios", ["rol"])
|
||||
|
||||
# ========================================================================
|
||||
# Tabla: grupos_vehiculos
|
||||
# ========================================================================
|
||||
|
||||
op.create_table(
|
||||
"grupos_vehiculos",
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("nombre", sa.String(100), nullable=False),
|
||||
sa.Column("descripcion", sa.Text(), nullable=True),
|
||||
sa.Column("color", sa.String(7), nullable=True, server_default="#3B82F6"),
|
||||
sa.Column("icono", sa.String(50), nullable=True),
|
||||
sa.Column("activo", sa.Boolean(), nullable=False, server_default="true"),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("nombre"),
|
||||
)
|
||||
|
||||
# ========================================================================
|
||||
# Tabla: conductores
|
||||
# ========================================================================
|
||||
|
||||
op.create_table(
|
||||
"conductores",
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("numero_empleado", sa.String(50), nullable=True),
|
||||
sa.Column("nombre", sa.String(100), nullable=False),
|
||||
sa.Column("apellido", sa.String(100), nullable=False),
|
||||
sa.Column("email", sa.String(255), nullable=True),
|
||||
sa.Column("telefono", sa.String(20), nullable=True),
|
||||
sa.Column("licencia_numero", sa.String(50), nullable=True),
|
||||
sa.Column("licencia_tipo", sa.String(20), nullable=True),
|
||||
sa.Column("licencia_vigencia", sa.Date(), nullable=True),
|
||||
sa.Column("fecha_nacimiento", sa.Date(), nullable=True),
|
||||
sa.Column("fecha_contratacion", sa.Date(), nullable=True),
|
||||
sa.Column("direccion", sa.Text(), nullable=True),
|
||||
sa.Column("contacto_emergencia", sa.String(100), nullable=True),
|
||||
sa.Column("telefono_emergencia", sa.String(20), nullable=True),
|
||||
sa.Column("foto_url", sa.String(500), nullable=True),
|
||||
sa.Column("tipo", tipo_conductor_enum, nullable=False, server_default="interno"),
|
||||
sa.Column("activo", sa.Boolean(), nullable=False, server_default="true"),
|
||||
sa.Column("notas", sa.Text(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index("ix_conductores_numero_empleado", "conductores", ["numero_empleado"])
|
||||
op.create_index("ix_conductores_licencia_numero", "conductores", ["licencia_numero"])
|
||||
op.create_index("ix_conductores_activo", "conductores", ["activo"])
|
||||
|
||||
# ========================================================================
|
||||
# Tabla: vehiculos
|
||||
# ========================================================================
|
||||
|
||||
op.create_table(
|
||||
"vehiculos",
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("nombre", sa.String(100), nullable=False),
|
||||
sa.Column("placa", sa.String(20), nullable=False),
|
||||
sa.Column("vin", sa.String(17), nullable=True),
|
||||
sa.Column("marca", sa.String(50), nullable=True),
|
||||
sa.Column("modelo", sa.String(50), nullable=True),
|
||||
sa.Column("anio", sa.Integer(), nullable=True),
|
||||
sa.Column("color", sa.String(30), nullable=True),
|
||||
sa.Column("tipo", tipo_vehiculo_enum, nullable=False, server_default="sedan"),
|
||||
sa.Column("capacidad_pasajeros", sa.Integer(), nullable=True),
|
||||
sa.Column("capacidad_carga_kg", sa.Float(), nullable=True),
|
||||
sa.Column("odometro_actual", sa.Float(), nullable=True, server_default="0"),
|
||||
sa.Column("consumo_promedio", sa.Float(), nullable=True),
|
||||
sa.Column("tipo_combustible", sa.String(20), nullable=True),
|
||||
sa.Column("capacidad_tanque", sa.Float(), nullable=True),
|
||||
sa.Column("icono", sa.String(50), nullable=True),
|
||||
sa.Column("foto_url", sa.String(500), nullable=True),
|
||||
sa.Column("estado", estado_vehiculo_enum, nullable=False, server_default="activo"),
|
||||
sa.Column("grupo_id", sa.Integer(), nullable=True),
|
||||
sa.Column("conductor_actual_id", sa.Integer(), nullable=True),
|
||||
sa.Column("ultima_latitud", sa.Float(), nullable=True),
|
||||
sa.Column("ultima_longitud", sa.Float(), nullable=True),
|
||||
sa.Column("ultima_velocidad", sa.Float(), nullable=True),
|
||||
sa.Column("ultimo_rumbo", sa.Float(), nullable=True),
|
||||
sa.Column("ultima_ubicacion_tiempo", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("motor_encendido", sa.Boolean(), nullable=True),
|
||||
sa.Column("en_movimiento", sa.Boolean(), nullable=True, server_default="false"),
|
||||
sa.Column("velocidad_maxima", sa.Float(), nullable=True, server_default="120"),
|
||||
sa.Column("notas", sa.Text(), nullable=True),
|
||||
sa.Column("metadata", postgresql.JSONB(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("placa"),
|
||||
sa.ForeignKeyConstraint(["grupo_id"], ["grupos_vehiculos.id"], ondelete="SET NULL"),
|
||||
sa.ForeignKeyConstraint(["conductor_actual_id"], ["conductores.id"], ondelete="SET NULL"),
|
||||
)
|
||||
op.create_index("ix_vehiculos_placa", "vehiculos", ["placa"])
|
||||
op.create_index("ix_vehiculos_estado", "vehiculos", ["estado"])
|
||||
op.create_index("ix_vehiculos_grupo_id", "vehiculos", ["grupo_id"])
|
||||
|
||||
# ========================================================================
|
||||
# Tabla: dispositivos
|
||||
# ========================================================================
|
||||
|
||||
op.create_table(
|
||||
"dispositivos",
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("identificador", sa.String(100), nullable=False),
|
||||
sa.Column("nombre", sa.String(100), nullable=True),
|
||||
sa.Column("tipo", tipo_dispositivo_enum, nullable=False, server_default="gps"),
|
||||
sa.Column("marca", sa.String(50), nullable=True),
|
||||
sa.Column("modelo", sa.String(50), nullable=True),
|
||||
sa.Column("numero_serie", sa.String(100), nullable=True),
|
||||
sa.Column("numero_sim", sa.String(20), nullable=True),
|
||||
sa.Column("operador_sim", sa.String(50), nullable=True),
|
||||
sa.Column("imei", sa.String(20), nullable=True),
|
||||
sa.Column("firmware_version", sa.String(50), nullable=True),
|
||||
sa.Column("protocolo", sa.String(50), nullable=True),
|
||||
sa.Column("estado", estado_dispositivo_enum, nullable=False, server_default="activo"),
|
||||
sa.Column("vehiculo_id", sa.Integer(), nullable=True),
|
||||
sa.Column("ultima_comunicacion", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("nivel_bateria", sa.Float(), nullable=True),
|
||||
sa.Column("nivel_senal", sa.Float(), nullable=True),
|
||||
sa.Column("configuracion", postgresql.JSONB(), nullable=True),
|
||||
sa.Column("notas", sa.Text(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("identificador"),
|
||||
sa.ForeignKeyConstraint(["vehiculo_id"], ["vehiculos.id"], ondelete="SET NULL"),
|
||||
)
|
||||
op.create_index("ix_dispositivos_identificador", "dispositivos", ["identificador"])
|
||||
op.create_index("ix_dispositivos_vehiculo_id", "dispositivos", ["vehiculo_id"])
|
||||
op.create_index("ix_dispositivos_imei", "dispositivos", ["imei"])
|
||||
|
||||
# ========================================================================
|
||||
# Tabla: ubicaciones (TimescaleDB Hypertable)
|
||||
# ========================================================================
|
||||
|
||||
op.create_table(
|
||||
"ubicaciones",
|
||||
sa.Column("tiempo", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("vehiculo_id", sa.Integer(), nullable=False),
|
||||
sa.Column("dispositivo_id", sa.Integer(), nullable=True),
|
||||
sa.Column("latitud", sa.Float(), nullable=False),
|
||||
sa.Column("longitud", sa.Float(), nullable=False),
|
||||
sa.Column("altitud", sa.Float(), nullable=True),
|
||||
sa.Column("velocidad", sa.Float(), nullable=True),
|
||||
sa.Column("rumbo", sa.Float(), nullable=True),
|
||||
sa.Column("precision", sa.Float(), nullable=True),
|
||||
sa.Column("satelites", sa.Integer(), nullable=True),
|
||||
sa.Column("hdop", sa.Float(), nullable=True),
|
||||
sa.Column("motor_encendido", sa.Boolean(), nullable=True),
|
||||
sa.Column("nivel_bateria", sa.Float(), nullable=True),
|
||||
sa.Column("nivel_combustible", sa.Float(), nullable=True),
|
||||
sa.Column("odometro", sa.Float(), nullable=True),
|
||||
sa.Column("temperatura", sa.Float(), nullable=True),
|
||||
sa.Column("evento", sa.String(50), nullable=True),
|
||||
sa.Column("direccion", sa.String(500), nullable=True),
|
||||
sa.Column("datos_extra", postgresql.JSONB(), nullable=True),
|
||||
sa.PrimaryKeyConstraint("tiempo", "vehiculo_id"),
|
||||
sa.ForeignKeyConstraint(["vehiculo_id"], ["vehiculos.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["dispositivo_id"], ["dispositivos.id"], ondelete="SET NULL"),
|
||||
)
|
||||
|
||||
# Convertir a hypertable de TimescaleDB
|
||||
op.execute(
|
||||
"SELECT create_hypertable('ubicaciones', 'tiempo', "
|
||||
"chunk_time_interval => INTERVAL '1 day', "
|
||||
"if_not_exists => TRUE);"
|
||||
)
|
||||
|
||||
# Índices para ubicaciones
|
||||
op.create_index("ix_ubicaciones_vehiculo_tiempo", "ubicaciones", ["vehiculo_id", "tiempo"])
|
||||
op.create_index("ix_ubicaciones_tiempo", "ubicaciones", ["tiempo"])
|
||||
|
||||
# Políticas de retención y compresión
|
||||
op.execute(
|
||||
"SELECT add_retention_policy('ubicaciones', INTERVAL '1 year', if_not_exists => TRUE);"
|
||||
)
|
||||
op.execute(
|
||||
"ALTER TABLE ubicaciones SET ("
|
||||
"timescaledb.compress, "
|
||||
"timescaledb.compress_segmentby = 'vehiculo_id'"
|
||||
");"
|
||||
)
|
||||
op.execute(
|
||||
"SELECT add_compression_policy('ubicaciones', INTERVAL '7 days', if_not_exists => TRUE);"
|
||||
)
|
||||
|
||||
# ========================================================================
|
||||
# Tabla: viajes
|
||||
# ========================================================================
|
||||
|
||||
op.create_table(
|
||||
"viajes",
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("vehiculo_id", sa.Integer(), nullable=False),
|
||||
sa.Column("conductor_id", sa.Integer(), nullable=True),
|
||||
sa.Column("inicio", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("fin", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("duracion_segundos", sa.Integer(), nullable=True),
|
||||
sa.Column("distancia_km", sa.Float(), nullable=True),
|
||||
sa.Column("velocidad_maxima", sa.Float(), nullable=True),
|
||||
sa.Column("velocidad_promedio", sa.Float(), nullable=True),
|
||||
sa.Column("latitud_inicio", sa.Float(), nullable=True),
|
||||
sa.Column("longitud_inicio", sa.Float(), nullable=True),
|
||||
sa.Column("latitud_fin", sa.Float(), nullable=True),
|
||||
sa.Column("longitud_fin", sa.Float(), nullable=True),
|
||||
sa.Column("direccion_inicio", sa.String(500), nullable=True),
|
||||
sa.Column("direccion_fin", sa.String(500), nullable=True),
|
||||
sa.Column("odometro_inicio", sa.Float(), nullable=True),
|
||||
sa.Column("odometro_fin", sa.Float(), nullable=True),
|
||||
sa.Column("combustible_consumido", sa.Float(), nullable=True),
|
||||
sa.Column("costo_combustible", sa.Float(), nullable=True),
|
||||
sa.Column("num_paradas", sa.Integer(), nullable=True, server_default="0"),
|
||||
sa.Column("num_alertas", sa.Integer(), nullable=True, server_default="0"),
|
||||
sa.Column("puntuacion_conduccion", sa.Float(), nullable=True),
|
||||
sa.Column("proposito", sa.String(100), nullable=True),
|
||||
sa.Column("notas", sa.Text(), nullable=True),
|
||||
sa.Column("ruta_simplificada", postgresql.JSONB(), nullable=True),
|
||||
sa.Column("activo", sa.Boolean(), nullable=False, server_default="true"),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.ForeignKeyConstraint(["vehiculo_id"], ["vehiculos.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["conductor_id"], ["conductores.id"], ondelete="SET NULL"),
|
||||
)
|
||||
op.create_index("ix_viajes_vehiculo_id", "viajes", ["vehiculo_id"])
|
||||
op.create_index("ix_viajes_inicio", "viajes", ["inicio"])
|
||||
op.create_index("ix_viajes_vehiculo_inicio", "viajes", ["vehiculo_id", "inicio"])
|
||||
|
||||
# ========================================================================
|
||||
# Tabla: paradas
|
||||
# ========================================================================
|
||||
|
||||
op.create_table(
|
||||
"paradas",
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("viaje_id", sa.Integer(), nullable=False),
|
||||
sa.Column("vehiculo_id", sa.Integer(), nullable=False),
|
||||
sa.Column("inicio", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("fin", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("duracion_segundos", sa.Integer(), nullable=True),
|
||||
sa.Column("latitud", sa.Float(), nullable=False),
|
||||
sa.Column("longitud", sa.Float(), nullable=False),
|
||||
sa.Column("direccion", sa.String(500), nullable=True),
|
||||
sa.Column("motor_encendido", sa.Boolean(), nullable=True),
|
||||
sa.Column("poi_id", sa.Integer(), nullable=True),
|
||||
sa.Column("geocerca_id", sa.Integer(), nullable=True),
|
||||
sa.Column("tipo", sa.String(50), nullable=True),
|
||||
sa.Column("notas", sa.Text(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.ForeignKeyConstraint(["viaje_id"], ["viajes.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["vehiculo_id"], ["vehiculos.id"], ondelete="CASCADE"),
|
||||
)
|
||||
op.create_index("ix_paradas_viaje_id", "paradas", ["viaje_id"])
|
||||
op.create_index("ix_paradas_vehiculo_id", "paradas", ["vehiculo_id"])
|
||||
|
||||
# ========================================================================
|
||||
# Tabla: tipos_alerta
|
||||
# ========================================================================
|
||||
|
||||
op.create_table(
|
||||
"tipos_alerta",
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("codigo", sa.String(50), nullable=False),
|
||||
sa.Column("nombre", sa.String(100), nullable=False),
|
||||
sa.Column("descripcion", sa.Text(), nullable=True),
|
||||
sa.Column("severidad", severidad_alerta_enum, nullable=False, server_default="media"),
|
||||
sa.Column("icono", sa.String(50), nullable=True),
|
||||
sa.Column("color", sa.String(7), nullable=True),
|
||||
sa.Column("sonido", sa.String(100), nullable=True),
|
||||
sa.Column("requiere_atencion", sa.Boolean(), nullable=False, server_default="true"),
|
||||
sa.Column("notificar_email", sa.Boolean(), nullable=False, server_default="false"),
|
||||
sa.Column("notificar_push", sa.Boolean(), nullable=False, server_default="true"),
|
||||
sa.Column("notificar_sms", sa.Boolean(), nullable=False, server_default="false"),
|
||||
sa.Column("activo", sa.Boolean(), nullable=False, server_default="true"),
|
||||
sa.Column("configuracion", postgresql.JSONB(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("codigo"),
|
||||
)
|
||||
|
||||
# ========================================================================
|
||||
# Tabla: alertas
|
||||
# ========================================================================
|
||||
|
||||
op.create_table(
|
||||
"alertas",
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("tipo_alerta_id", sa.Integer(), nullable=False),
|
||||
sa.Column("vehiculo_id", sa.Integer(), nullable=False),
|
||||
sa.Column("conductor_id", sa.Integer(), nullable=True),
|
||||
sa.Column("viaje_id", sa.Integer(), nullable=True),
|
||||
sa.Column("tiempo", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("latitud", sa.Float(), nullable=True),
|
||||
sa.Column("longitud", sa.Float(), nullable=True),
|
||||
sa.Column("direccion", sa.String(500), nullable=True),
|
||||
sa.Column("valor_detectado", sa.Float(), nullable=True),
|
||||
sa.Column("valor_limite", sa.Float(), nullable=True),
|
||||
sa.Column("mensaje", sa.Text(), nullable=True),
|
||||
sa.Column("datos_extra", postgresql.JSONB(), nullable=True),
|
||||
sa.Column("atendida", sa.Boolean(), nullable=False, server_default="false"),
|
||||
sa.Column("atendida_por_id", sa.Integer(), nullable=True),
|
||||
sa.Column("atendida_tiempo", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("comentario_atencion", sa.Text(), nullable=True),
|
||||
sa.Column("notificada", sa.Boolean(), nullable=False, server_default="false"),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.ForeignKeyConstraint(["tipo_alerta_id"], ["tipos_alerta.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["vehiculo_id"], ["vehiculos.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["conductor_id"], ["conductores.id"], ondelete="SET NULL"),
|
||||
sa.ForeignKeyConstraint(["viaje_id"], ["viajes.id"], ondelete="SET NULL"),
|
||||
sa.ForeignKeyConstraint(["atendida_por_id"], ["usuarios.id"], ondelete="SET NULL"),
|
||||
)
|
||||
op.create_index("ix_alertas_vehiculo_id", "alertas", ["vehiculo_id"])
|
||||
op.create_index("ix_alertas_tiempo", "alertas", ["tiempo"])
|
||||
op.create_index("ix_alertas_atendida", "alertas", ["atendida"])
|
||||
op.create_index("ix_alertas_tipo_tiempo", "alertas", ["tipo_alerta_id", "tiempo"])
|
||||
|
||||
# ========================================================================
|
||||
# Tabla: geocercas
|
||||
# ========================================================================
|
||||
|
||||
op.create_table(
|
||||
"geocercas",
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("nombre", sa.String(100), nullable=False),
|
||||
sa.Column("descripcion", sa.Text(), nullable=True),
|
||||
sa.Column("tipo", tipo_geocerca_enum, nullable=False),
|
||||
sa.Column("centro_latitud", sa.Float(), nullable=True),
|
||||
sa.Column("centro_longitud", sa.Float(), nullable=True),
|
||||
sa.Column("radio_metros", sa.Float(), nullable=True),
|
||||
sa.Column("coordenadas", postgresql.JSONB(), nullable=True),
|
||||
sa.Column("color", sa.String(7), nullable=True, server_default="#EF4444"),
|
||||
sa.Column("alertar_entrada", sa.Boolean(), nullable=False, server_default="true"),
|
||||
sa.Column("alertar_salida", sa.Boolean(), nullable=False, server_default="true"),
|
||||
sa.Column("velocidad_maxima", sa.Float(), nullable=True),
|
||||
sa.Column("horario_inicio", sa.Time(), nullable=True),
|
||||
sa.Column("horario_fin", sa.Time(), nullable=True),
|
||||
sa.Column("dias_semana", postgresql.ARRAY(sa.Integer()), nullable=True),
|
||||
sa.Column("activa", sa.Boolean(), nullable=False, server_default="true"),
|
||||
sa.Column("metadata", postgresql.JSONB(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index("ix_geocercas_activa", "geocercas", ["activa"])
|
||||
|
||||
# ========================================================================
|
||||
# Tabla: geocercas_vehiculos (many-to-many)
|
||||
# ========================================================================
|
||||
|
||||
op.create_table(
|
||||
"geocercas_vehiculos",
|
||||
sa.Column("geocerca_id", sa.Integer(), nullable=False),
|
||||
sa.Column("vehiculo_id", sa.Integer(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("geocerca_id", "vehiculo_id"),
|
||||
sa.ForeignKeyConstraint(["geocerca_id"], ["geocercas.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["vehiculo_id"], ["vehiculos.id"], ondelete="CASCADE"),
|
||||
)
|
||||
|
||||
# ========================================================================
|
||||
# Tabla: pois (puntos de interés)
|
||||
# ========================================================================
|
||||
|
||||
op.create_table(
|
||||
"pois",
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("nombre", sa.String(100), nullable=False),
|
||||
sa.Column("descripcion", sa.Text(), nullable=True),
|
||||
sa.Column("categoria", sa.String(50), nullable=True),
|
||||
sa.Column("latitud", sa.Float(), nullable=False),
|
||||
sa.Column("longitud", sa.Float(), nullable=False),
|
||||
sa.Column("direccion", sa.String(500), nullable=True),
|
||||
sa.Column("telefono", sa.String(20), nullable=True),
|
||||
sa.Column("email", sa.String(255), nullable=True),
|
||||
sa.Column("horario", sa.String(200), nullable=True),
|
||||
sa.Column("icono", sa.String(50), nullable=True),
|
||||
sa.Column("color", sa.String(7), nullable=True),
|
||||
sa.Column("radio_metros", sa.Float(), nullable=True, server_default="100"),
|
||||
sa.Column("activo", sa.Boolean(), nullable=False, server_default="true"),
|
||||
sa.Column("metadata", postgresql.JSONB(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index("ix_pois_categoria", "pois", ["categoria"])
|
||||
op.create_index("ix_pois_activo", "pois", ["activo"])
|
||||
|
||||
# Update paradas foreign key to pois
|
||||
op.create_foreign_key(
|
||||
"fk_paradas_poi_id",
|
||||
"paradas", "pois",
|
||||
["poi_id"], ["id"],
|
||||
ondelete="SET NULL"
|
||||
)
|
||||
op.create_foreign_key(
|
||||
"fk_paradas_geocerca_id",
|
||||
"paradas", "geocercas",
|
||||
["geocerca_id"], ["id"],
|
||||
ondelete="SET NULL"
|
||||
)
|
||||
|
||||
# ========================================================================
|
||||
# Tabla: cargas_combustible
|
||||
# ========================================================================
|
||||
|
||||
op.create_table(
|
||||
"cargas_combustible",
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("vehiculo_id", sa.Integer(), nullable=False),
|
||||
sa.Column("conductor_id", sa.Integer(), nullable=True),
|
||||
sa.Column("fecha", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("litros", sa.Float(), nullable=False),
|
||||
sa.Column("precio_litro", sa.Float(), nullable=True),
|
||||
sa.Column("costo_total", sa.Float(), nullable=True),
|
||||
sa.Column("odometro", sa.Float(), nullable=True),
|
||||
sa.Column("tanque_lleno", sa.Boolean(), nullable=True, server_default="true"),
|
||||
sa.Column("tipo_combustible", sa.String(20), nullable=True),
|
||||
sa.Column("estacion", sa.String(100), nullable=True),
|
||||
sa.Column("latitud", sa.Float(), nullable=True),
|
||||
sa.Column("longitud", sa.Float(), nullable=True),
|
||||
sa.Column("numero_factura", sa.String(50), nullable=True),
|
||||
sa.Column("foto_ticket_url", sa.String(500), nullable=True),
|
||||
sa.Column("notas", sa.Text(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.ForeignKeyConstraint(["vehiculo_id"], ["vehiculos.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["conductor_id"], ["conductores.id"], ondelete="SET NULL"),
|
||||
)
|
||||
op.create_index("ix_cargas_combustible_vehiculo_id", "cargas_combustible", ["vehiculo_id"])
|
||||
op.create_index("ix_cargas_combustible_fecha", "cargas_combustible", ["fecha"])
|
||||
|
||||
# ========================================================================
|
||||
# Tabla: tipos_mantenimiento
|
||||
# ========================================================================
|
||||
|
||||
op.create_table(
|
||||
"tipos_mantenimiento",
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("codigo", sa.String(50), nullable=False),
|
||||
sa.Column("nombre", sa.String(100), nullable=False),
|
||||
sa.Column("descripcion", sa.Text(), nullable=True),
|
||||
sa.Column("categoria", sa.String(50), nullable=True),
|
||||
sa.Column("intervalo_km", sa.Float(), nullable=True),
|
||||
sa.Column("intervalo_dias", sa.Integer(), nullable=True),
|
||||
sa.Column("costo_estimado", sa.Float(), nullable=True),
|
||||
sa.Column("duracion_estimada_horas", sa.Float(), nullable=True),
|
||||
sa.Column("activo", sa.Boolean(), nullable=False, server_default="true"),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("codigo"),
|
||||
)
|
||||
|
||||
# ========================================================================
|
||||
# Tabla: mantenimientos
|
||||
# ========================================================================
|
||||
|
||||
op.create_table(
|
||||
"mantenimientos",
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("vehiculo_id", sa.Integer(), nullable=False),
|
||||
sa.Column("tipo_mantenimiento_id", sa.Integer(), nullable=False),
|
||||
sa.Column("fecha_programada", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("fecha_realizado", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("odometro_programado", sa.Float(), nullable=True),
|
||||
sa.Column("odometro_realizado", sa.Float(), nullable=True),
|
||||
sa.Column("estado", sa.String(20), nullable=False, server_default="pendiente"),
|
||||
sa.Column("proveedor", sa.String(100), nullable=True),
|
||||
sa.Column("costo", sa.Float(), nullable=True),
|
||||
sa.Column("duracion_horas", sa.Float(), nullable=True),
|
||||
sa.Column("descripcion_trabajo", sa.Text(), nullable=True),
|
||||
sa.Column("repuestos_utilizados", postgresql.JSONB(), nullable=True),
|
||||
sa.Column("numero_factura", sa.String(50), nullable=True),
|
||||
sa.Column("documento_url", sa.String(500), nullable=True),
|
||||
sa.Column("notas", sa.Text(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.ForeignKeyConstraint(["vehiculo_id"], ["vehiculos.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["tipo_mantenimiento_id"], ["tipos_mantenimiento.id"], ondelete="CASCADE"),
|
||||
)
|
||||
op.create_index("ix_mantenimientos_vehiculo_id", "mantenimientos", ["vehiculo_id"])
|
||||
op.create_index("ix_mantenimientos_estado", "mantenimientos", ["estado"])
|
||||
op.create_index("ix_mantenimientos_fecha_programada", "mantenimientos", ["fecha_programada"])
|
||||
|
||||
# ========================================================================
|
||||
# Tabla: camaras
|
||||
# ========================================================================
|
||||
|
||||
op.create_table(
|
||||
"camaras",
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("identificador", sa.String(100), nullable=False),
|
||||
sa.Column("nombre", sa.String(100), nullable=True),
|
||||
sa.Column("vehiculo_id", sa.Integer(), nullable=True),
|
||||
sa.Column("tipo", sa.String(50), nullable=True),
|
||||
sa.Column("posicion", sa.String(50), nullable=True),
|
||||
sa.Column("resolucion", sa.String(20), nullable=True),
|
||||
sa.Column("url_stream", sa.String(500), nullable=True),
|
||||
sa.Column("url_snapshot", sa.String(500), nullable=True),
|
||||
sa.Column("activa", sa.Boolean(), nullable=False, server_default="true"),
|
||||
sa.Column("grabando", sa.Boolean(), nullable=False, server_default="false"),
|
||||
sa.Column("configuracion", postgresql.JSONB(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("identificador"),
|
||||
sa.ForeignKeyConstraint(["vehiculo_id"], ["vehiculos.id"], ondelete="SET NULL"),
|
||||
)
|
||||
op.create_index("ix_camaras_vehiculo_id", "camaras", ["vehiculo_id"])
|
||||
|
||||
# ========================================================================
|
||||
# Tabla: grabaciones
|
||||
# ========================================================================
|
||||
|
||||
op.create_table(
|
||||
"grabaciones",
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("camara_id", sa.Integer(), nullable=False),
|
||||
sa.Column("vehiculo_id", sa.Integer(), nullable=True),
|
||||
sa.Column("inicio", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("fin", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("duracion_segundos", sa.Integer(), nullable=True),
|
||||
sa.Column("tamano_bytes", sa.BigInteger(), nullable=True),
|
||||
sa.Column("archivo_url", sa.String(500), nullable=True),
|
||||
sa.Column("thumbnail_url", sa.String(500), nullable=True),
|
||||
sa.Column("estado", sa.String(20), nullable=False, server_default="grabando"),
|
||||
sa.Column("tipo", sa.String(50), nullable=True),
|
||||
sa.Column("metadata", postgresql.JSONB(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.ForeignKeyConstraint(["camara_id"], ["camaras.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["vehiculo_id"], ["vehiculos.id"], ondelete="SET NULL"),
|
||||
)
|
||||
op.create_index("ix_grabaciones_camara_id", "grabaciones", ["camara_id"])
|
||||
op.create_index("ix_grabaciones_inicio", "grabaciones", ["inicio"])
|
||||
|
||||
# ========================================================================
|
||||
# Tabla: eventos_video
|
||||
# ========================================================================
|
||||
|
||||
op.create_table(
|
||||
"eventos_video",
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("grabacion_id", sa.Integer(), nullable=True),
|
||||
sa.Column("camara_id", sa.Integer(), nullable=False),
|
||||
sa.Column("vehiculo_id", sa.Integer(), nullable=True),
|
||||
sa.Column("tiempo", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("tipo", sa.String(50), nullable=False),
|
||||
sa.Column("descripcion", sa.Text(), nullable=True),
|
||||
sa.Column("latitud", sa.Float(), nullable=True),
|
||||
sa.Column("longitud", sa.Float(), nullable=True),
|
||||
sa.Column("snapshot_url", sa.String(500), nullable=True),
|
||||
sa.Column("clip_url", sa.String(500), nullable=True),
|
||||
sa.Column("duracion_clip_segundos", sa.Integer(), nullable=True),
|
||||
sa.Column("confianza", sa.Float(), nullable=True),
|
||||
sa.Column("metadata", postgresql.JSONB(), nullable=True),
|
||||
sa.Column("revisado", sa.Boolean(), nullable=False, server_default="false"),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.ForeignKeyConstraint(["grabacion_id"], ["grabaciones.id"], ondelete="SET NULL"),
|
||||
sa.ForeignKeyConstraint(["camara_id"], ["camaras.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["vehiculo_id"], ["vehiculos.id"], ondelete="SET NULL"),
|
||||
)
|
||||
op.create_index("ix_eventos_video_camara_id", "eventos_video", ["camara_id"])
|
||||
op.create_index("ix_eventos_video_tiempo", "eventos_video", ["tiempo"])
|
||||
op.create_index("ix_eventos_video_tipo", "eventos_video", ["tipo"])
|
||||
|
||||
# ========================================================================
|
||||
# Tabla: mensajes
|
||||
# ========================================================================
|
||||
|
||||
op.create_table(
|
||||
"mensajes",
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("remitente_usuario_id", sa.Integer(), nullable=True),
|
||||
sa.Column("remitente_conductor_id", sa.Integer(), nullable=True),
|
||||
sa.Column("destinatario_usuario_id", sa.Integer(), nullable=True),
|
||||
sa.Column("destinatario_conductor_id", sa.Integer(), nullable=True),
|
||||
sa.Column("destinatario_vehiculo_id", sa.Integer(), nullable=True),
|
||||
sa.Column("asunto", sa.String(200), nullable=True),
|
||||
sa.Column("contenido", sa.Text(), nullable=False),
|
||||
sa.Column("tipo", sa.String(50), nullable=False, server_default="texto"),
|
||||
sa.Column("prioridad", prioridad_mensaje_enum, nullable=False, server_default="normal"),
|
||||
sa.Column("leido", sa.Boolean(), nullable=False, server_default="false"),
|
||||
sa.Column("leido_tiempo", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("respuesta_a_id", sa.Integer(), nullable=True),
|
||||
sa.Column("adjuntos", postgresql.JSONB(), nullable=True),
|
||||
sa.Column("metadata", postgresql.JSONB(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.ForeignKeyConstraint(["remitente_usuario_id"], ["usuarios.id"], ondelete="SET NULL"),
|
||||
sa.ForeignKeyConstraint(["remitente_conductor_id"], ["conductores.id"], ondelete="SET NULL"),
|
||||
sa.ForeignKeyConstraint(["destinatario_usuario_id"], ["usuarios.id"], ondelete="SET NULL"),
|
||||
sa.ForeignKeyConstraint(["destinatario_conductor_id"], ["conductores.id"], ondelete="SET NULL"),
|
||||
sa.ForeignKeyConstraint(["destinatario_vehiculo_id"], ["vehiculos.id"], ondelete="SET NULL"),
|
||||
sa.ForeignKeyConstraint(["respuesta_a_id"], ["mensajes.id"], ondelete="SET NULL"),
|
||||
)
|
||||
op.create_index("ix_mensajes_destinatario_usuario_id", "mensajes", ["destinatario_usuario_id"])
|
||||
op.create_index("ix_mensajes_destinatario_conductor_id", "mensajes", ["destinatario_conductor_id"])
|
||||
op.create_index("ix_mensajes_leido", "mensajes", ["leido"])
|
||||
|
||||
# ========================================================================
|
||||
# Tabla: configuracion
|
||||
# ========================================================================
|
||||
|
||||
op.create_table(
|
||||
"configuracion",
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("clave", sa.String(100), nullable=False),
|
||||
sa.Column("valor", sa.Text(), nullable=True),
|
||||
sa.Column("tipo", sa.String(20), nullable=False, server_default="string"),
|
||||
sa.Column("categoria", sa.String(50), nullable=True),
|
||||
sa.Column("descripcion", sa.Text(), nullable=True),
|
||||
sa.Column("editable", sa.Boolean(), nullable=False, server_default="true"),
|
||||
sa.Column("usuario_id", sa.Integer(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("clave", "usuario_id", name="uq_configuracion_clave_usuario"),
|
||||
sa.ForeignKeyConstraint(["usuario_id"], ["usuarios.id"], ondelete="CASCADE"),
|
||||
)
|
||||
op.create_index("ix_configuracion_clave", "configuracion", ["clave"])
|
||||
op.create_index("ix_configuracion_categoria", "configuracion", ["categoria"])
|
||||
|
||||
# ========================================================================
|
||||
# Datos iniciales: Tipos de alerta predefinidos
|
||||
# ========================================================================
|
||||
|
||||
op.execute("""
|
||||
INSERT INTO tipos_alerta (codigo, nombre, descripcion, severidad, icono, color, requiere_atencion)
|
||||
VALUES
|
||||
('EXCESO_VELOCIDAD', 'Exceso de velocidad', 'El vehículo superó el límite de velocidad configurado', 'alta', 'speed', '#EF4444', true),
|
||||
('ENTRADA_GEOCERCA', 'Entrada a geocerca', 'El vehículo entró a una zona delimitada', 'media', 'map-pin', '#3B82F6', false),
|
||||
('SALIDA_GEOCERCA', 'Salida de geocerca', 'El vehículo salió de una zona delimitada', 'media', 'map-pin', '#F59E0B', false),
|
||||
('BATERIA_BAJA', 'Batería baja', 'La batería del dispositivo está por debajo del nivel crítico', 'alta', 'battery-low', '#EF4444', true),
|
||||
('SIN_SENAL', 'Sin señal GPS', 'El dispositivo perdió señal GPS por tiempo prolongado', 'alta', 'signal-off', '#EF4444', true),
|
||||
('MOTOR_ENCENDIDO', 'Motor encendido', 'El motor del vehículo fue encendido', 'baja', 'power', '#22C55E', false),
|
||||
('MOTOR_APAGADO', 'Motor apagado', 'El motor del vehículo fue apagado', 'baja', 'power-off', '#6B7280', false),
|
||||
('PARADA_PROLONGADA', 'Parada prolongada', 'El vehículo lleva detenido más tiempo del permitido', 'media', 'clock', '#F59E0B', true),
|
||||
('ACELERACION_BRUSCA', 'Aceleración brusca', 'Se detectó una aceleración brusca', 'media', 'trending-up', '#F59E0B', false),
|
||||
('FRENADO_BRUSCO', 'Frenado brusco', 'Se detectó un frenado brusco', 'media', 'trending-down', '#F59E0B', false),
|
||||
('MANTENIMIENTO_PROXIMO', 'Mantenimiento próximo', 'El vehículo requiere mantenimiento pronto', 'media', 'tool', '#3B82F6', true),
|
||||
('LICENCIA_POR_VENCER', 'Licencia por vencer', 'La licencia del conductor está próxima a vencer', 'alta', 'id-card', '#EF4444', true),
|
||||
('COLISION_DETECTADA', 'Colisión detectada', 'Se detectó una posible colisión', 'critica', 'alert-triangle', '#DC2626', true),
|
||||
('SOS', 'Botón de pánico', 'El conductor presionó el botón de emergencia', 'critica', 'alert-circle', '#DC2626', true)
|
||||
ON CONFLICT DO NOTHING;
|
||||
""")
|
||||
|
||||
# ========================================================================
|
||||
# Datos iniciales: Tipos de mantenimiento predefinidos
|
||||
# ========================================================================
|
||||
|
||||
op.execute("""
|
||||
INSERT INTO tipos_mantenimiento (codigo, nombre, descripcion, categoria, intervalo_km, intervalo_dias)
|
||||
VALUES
|
||||
('CAMBIO_ACEITE', 'Cambio de aceite', 'Cambio de aceite y filtro de motor', 'Motor', 5000, 90),
|
||||
('CAMBIO_FILTRO_AIRE', 'Cambio de filtro de aire', 'Reemplazo del filtro de aire del motor', 'Motor', 15000, 365),
|
||||
('CAMBIO_FILTRO_COMBUSTIBLE', 'Cambio de filtro de combustible', 'Reemplazo del filtro de combustible', 'Motor', 20000, 365),
|
||||
('CAMBIO_BUJIAS', 'Cambio de bujías', 'Reemplazo de bujías de encendido', 'Motor', 40000, NULL),
|
||||
('ROTACION_LLANTAS', 'Rotación de llantas', 'Rotación de llantas para desgaste uniforme', 'Llantas', 10000, 180),
|
||||
('ALINEACION_BALANCEO', 'Alineación y balanceo', 'Alineación y balanceo de llantas', 'Llantas', 20000, 365),
|
||||
('CAMBIO_LLANTAS', 'Cambio de llantas', 'Reemplazo de llantas desgastadas', 'Llantas', 50000, NULL),
|
||||
('REVISION_FRENOS', 'Revisión de frenos', 'Inspección del sistema de frenos', 'Frenos', 20000, 180),
|
||||
('CAMBIO_BALATAS', 'Cambio de balatas', 'Reemplazo de balatas/pastillas de freno', 'Frenos', 40000, NULL),
|
||||
('CAMBIO_DISCOS', 'Cambio de discos', 'Reemplazo de discos de freno', 'Frenos', 80000, NULL),
|
||||
('REVISION_SUSPENSION', 'Revisión de suspensión', 'Inspección del sistema de suspensión', 'Suspensión', 30000, 365),
|
||||
('CAMBIO_AMORTIGUADORES', 'Cambio de amortiguadores', 'Reemplazo de amortiguadores', 'Suspensión', 80000, NULL),
|
||||
('REVISION_TRANSMISION', 'Revisión de transmisión', 'Inspección de la transmisión', 'Transmisión', 50000, 365),
|
||||
('CAMBIO_LIQUIDO_TRANSMISION', 'Cambio de líquido de transmisión', 'Reemplazo del líquido de transmisión', 'Transmisión', 60000, NULL),
|
||||
('REVISION_BATERIA', 'Revisión de batería', 'Inspección y prueba de batería', 'Eléctrico', NULL, 180),
|
||||
('CAMBIO_BATERIA', 'Cambio de batería', 'Reemplazo de batería', 'Eléctrico', NULL, 730),
|
||||
('REVISION_LUCES', 'Revisión de luces', 'Inspección del sistema de iluminación', 'Eléctrico', NULL, 90),
|
||||
('REVISION_AC', 'Revisión de A/C', 'Inspección del sistema de aire acondicionado', 'Climatización', NULL, 365),
|
||||
('RECARGA_AC', 'Recarga de A/C', 'Recarga de gas refrigerante del A/C', 'Climatización', NULL, 730),
|
||||
('VERIFICACION_VEHICULAR', 'Verificación vehicular', 'Verificación de emisiones contaminantes', 'Legal', NULL, 180),
|
||||
('REVISION_GENERAL', 'Revisión general', 'Inspección general del vehículo', 'General', 10000, 90)
|
||||
ON CONFLICT DO NOTHING;
|
||||
""")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Drop tables in reverse order
|
||||
op.drop_table("configuracion")
|
||||
op.drop_table("mensajes")
|
||||
op.drop_table("eventos_video")
|
||||
op.drop_table("grabaciones")
|
||||
op.drop_table("camaras")
|
||||
op.drop_table("mantenimientos")
|
||||
op.drop_table("tipos_mantenimiento")
|
||||
op.drop_table("cargas_combustible")
|
||||
op.drop_table("pois")
|
||||
op.drop_table("geocercas_vehiculos")
|
||||
op.drop_table("geocercas")
|
||||
op.drop_table("alertas")
|
||||
op.drop_table("tipos_alerta")
|
||||
op.drop_table("paradas")
|
||||
op.drop_table("viajes")
|
||||
op.drop_table("ubicaciones")
|
||||
op.drop_table("dispositivos")
|
||||
op.drop_table("vehiculos")
|
||||
op.drop_table("conductores")
|
||||
op.drop_table("grupos_vehiculos")
|
||||
op.drop_table("usuarios")
|
||||
|
||||
# Drop ENUMs
|
||||
op.execute("DROP TYPE IF EXISTS prioridad_mensaje;")
|
||||
op.execute("DROP TYPE IF EXISTS severidad_alerta;")
|
||||
op.execute("DROP TYPE IF EXISTS tipo_geocerca;")
|
||||
op.execute("DROP TYPE IF EXISTS estado_dispositivo;")
|
||||
op.execute("DROP TYPE IF EXISTS tipo_dispositivo;")
|
||||
op.execute("DROP TYPE IF EXISTS tipo_conductor;")
|
||||
op.execute("DROP TYPE IF EXISTS estado_vehiculo;")
|
||||
op.execute("DROP TYPE IF EXISTS tipo_vehiculo;")
|
||||
|
||||
# Drop extensions
|
||||
op.execute("DROP EXTENSION IF EXISTS postgis;")
|
||||
op.execute('DROP EXTENSION IF EXISTS "uuid-ossp";')
|
||||
# Note: TimescaleDB extension usually can't be dropped easily
|
||||
7
backend/app/__init__.py
Normal file
7
backend/app/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
||||
"""
|
||||
Adan Fleet Monitor Backend.
|
||||
|
||||
Sistema de monitoreo de flotillas GPS.
|
||||
"""
|
||||
|
||||
__version__ = "1.0.0"
|
||||
7
backend/app/api/v1/__init__.py
Normal file
7
backend/app/api/v1/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
||||
"""
|
||||
API v1 - Endpoints REST.
|
||||
"""
|
||||
|
||||
from app.api.v1.router import api_router
|
||||
|
||||
__all__ = ["api_router"]
|
||||
402
backend/app/api/v1/alertas.py
Normal file
402
backend/app/api/v1/alertas.py
Normal 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
274
backend/app/api/v1/auth.py
Normal 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"}
|
||||
411
backend/app/api/v1/conductores.py
Normal file
411
backend/app/api/v1/conductores.py
Normal 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
|
||||
]
|
||||
362
backend/app/api/v1/dispositivos.py
Normal file
362
backend/app/api/v1/dispositivos.py
Normal 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,
|
||||
)
|
||||
502
backend/app/api/v1/geocercas.py
Normal file
502
backend/app/api/v1/geocercas.py
Normal 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,
|
||||
)
|
||||
227
backend/app/api/v1/reportes.py
Normal file
227
backend/app/api/v1/reportes.py
Normal 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
|
||||
48
backend/app/api/v1/router.py
Normal file
48
backend/app/api/v1/router.py
Normal 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)
|
||||
237
backend/app/api/v1/ubicaciones.py
Normal file
237
backend/app/api/v1/ubicaciones.py
Normal 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()
|
||||
481
backend/app/api/v1/vehiculos.py
Normal file
481
backend/app/api/v1/vehiculos.py
Normal file
@@ -0,0 +1,481 @@
|
||||
"""
|
||||
Endpoints para gestión de vehículos.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.security import get_current_user
|
||||
from app.models.usuario import Usuario
|
||||
from app.models.vehiculo import Vehiculo
|
||||
from app.models.viaje import Viaje
|
||||
from app.models.alerta import Alerta
|
||||
from app.schemas.vehiculo import (
|
||||
VehiculoCreate,
|
||||
VehiculoUpdate,
|
||||
VehiculoResponse,
|
||||
VehiculoResumen,
|
||||
VehiculoConRelaciones,
|
||||
VehiculoUbicacionActual,
|
||||
VehiculoEstadisticas,
|
||||
)
|
||||
from app.schemas.base import PaginatedResponse
|
||||
from app.services.ubicacion_service import UbicacionService
|
||||
|
||||
router = APIRouter(prefix="/vehiculos", tags=["Vehiculos"])
|
||||
|
||||
|
||||
@router.get("", response_model=List[VehiculoResumen])
|
||||
async def listar_vehiculos(
|
||||
activo: Optional[bool] = None,
|
||||
en_servicio: Optional[bool] = None,
|
||||
grupo_id: Optional[int] = None,
|
||||
buscar: Optional[str] = None,
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=100),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: Usuario = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Lista todos los vehículos con filtros opcionales.
|
||||
|
||||
Args:
|
||||
activo: Filtrar por estado activo.
|
||||
en_servicio: Filtrar por en servicio.
|
||||
grupo_id: Filtrar por grupo.
|
||||
buscar: Búsqueda por nombre o placa.
|
||||
skip: Registros a saltar.
|
||||
limit: Límite de registros.
|
||||
|
||||
Returns:
|
||||
Lista de vehículos.
|
||||
"""
|
||||
query = select(Vehiculo)
|
||||
|
||||
if activo is not None:
|
||||
query = query.where(Vehiculo.activo == activo)
|
||||
if en_servicio is not None:
|
||||
query = query.where(Vehiculo.en_servicio == en_servicio)
|
||||
if grupo_id:
|
||||
query = query.where(Vehiculo.grupo_id == grupo_id)
|
||||
if buscar:
|
||||
query = query.where(
|
||||
(Vehiculo.nombre.ilike(f"%{buscar}%")) |
|
||||
(Vehiculo.placa.ilike(f"%{buscar}%"))
|
||||
)
|
||||
|
||||
query = query.offset(skip).limit(limit).order_by(Vehiculo.nombre)
|
||||
|
||||
result = await db.execute(query)
|
||||
vehiculos = result.scalars().all()
|
||||
|
||||
return [VehiculoResumen.model_validate(v) for v in vehiculos]
|
||||
|
||||
|
||||
@router.get("/ubicaciones", response_model=List[VehiculoUbicacionActual])
|
||||
async def obtener_ubicaciones_flota(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: Usuario = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Obtiene las ubicaciones actuales de todos los vehículos.
|
||||
|
||||
Returns:
|
||||
Lista de ubicaciones actuales.
|
||||
"""
|
||||
ubicacion_service = UbicacionService(db)
|
||||
return await ubicacion_service.obtener_ubicaciones_flota()
|
||||
|
||||
|
||||
@router.get("/{vehiculo_id}", response_model=VehiculoConRelaciones)
|
||||
async def obtener_vehiculo(
|
||||
vehiculo_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: Usuario = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Obtiene un vehículo por su ID con todas sus relaciones.
|
||||
|
||||
Args:
|
||||
vehiculo_id: ID del vehículo.
|
||||
|
||||
Returns:
|
||||
Vehículo con relaciones.
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(Vehiculo)
|
||||
.options(
|
||||
selectinload(Vehiculo.conductor),
|
||||
selectinload(Vehiculo.grupo),
|
||||
selectinload(Vehiculo.dispositivos),
|
||||
)
|
||||
.where(Vehiculo.id == vehiculo_id)
|
||||
)
|
||||
vehiculo = result.scalar_one_or_none()
|
||||
|
||||
if not vehiculo:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Vehículo con id {vehiculo_id} no encontrado",
|
||||
)
|
||||
|
||||
return VehiculoConRelaciones.model_validate(vehiculo)
|
||||
|
||||
|
||||
@router.post("", response_model=VehiculoResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def crear_vehiculo(
|
||||
vehiculo_data: VehiculoCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: Usuario = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Crea un nuevo vehículo.
|
||||
|
||||
Args:
|
||||
vehiculo_data: Datos del vehículo.
|
||||
|
||||
Returns:
|
||||
Vehículo creado.
|
||||
"""
|
||||
# Verificar que la placa no exista
|
||||
result = await db.execute(
|
||||
select(Vehiculo).where(Vehiculo.placa == vehiculo_data.placa)
|
||||
)
|
||||
if result.scalar_one_or_none():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"Ya existe un vehículo con la placa {vehiculo_data.placa}",
|
||||
)
|
||||
|
||||
# Verificar VIN si se proporciona
|
||||
if vehiculo_data.vin:
|
||||
result = await db.execute(
|
||||
select(Vehiculo).where(Vehiculo.vin == vehiculo_data.vin)
|
||||
)
|
||||
if result.scalar_one_or_none():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"Ya existe un vehículo con el VIN {vehiculo_data.vin}",
|
||||
)
|
||||
|
||||
vehiculo = Vehiculo(**vehiculo_data.model_dump())
|
||||
vehiculo.odometro_actual = vehiculo_data.odometro_inicial
|
||||
|
||||
db.add(vehiculo)
|
||||
await db.commit()
|
||||
await db.refresh(vehiculo)
|
||||
|
||||
return VehiculoResponse.model_validate(vehiculo)
|
||||
|
||||
|
||||
@router.put("/{vehiculo_id}", response_model=VehiculoResponse)
|
||||
async def actualizar_vehiculo(
|
||||
vehiculo_id: int,
|
||||
vehiculo_data: VehiculoUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: Usuario = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Actualiza un vehículo existente.
|
||||
|
||||
Args:
|
||||
vehiculo_id: ID del vehículo.
|
||||
vehiculo_data: Datos a actualizar.
|
||||
|
||||
Returns:
|
||||
Vehículo actualizado.
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(Vehiculo).where(Vehiculo.id == vehiculo_id)
|
||||
)
|
||||
vehiculo = result.scalar_one_or_none()
|
||||
|
||||
if not vehiculo:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Vehículo con id {vehiculo_id} no encontrado",
|
||||
)
|
||||
|
||||
# Verificar placa única si se cambia
|
||||
if vehiculo_data.placa and vehiculo_data.placa != vehiculo.placa:
|
||||
result = await db.execute(
|
||||
select(Vehiculo).where(Vehiculo.placa == vehiculo_data.placa)
|
||||
)
|
||||
if result.scalar_one_or_none():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"Ya existe un vehículo con la placa {vehiculo_data.placa}",
|
||||
)
|
||||
|
||||
update_data = vehiculo_data.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(vehiculo, field, value)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(vehiculo)
|
||||
|
||||
return VehiculoResponse.model_validate(vehiculo)
|
||||
|
||||
|
||||
@router.delete("/{vehiculo_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def eliminar_vehiculo(
|
||||
vehiculo_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: Usuario = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Elimina un vehículo (soft delete - desactiva).
|
||||
|
||||
Args:
|
||||
vehiculo_id: ID del vehículo.
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(Vehiculo).where(Vehiculo.id == vehiculo_id)
|
||||
)
|
||||
vehiculo = result.scalar_one_or_none()
|
||||
|
||||
if not vehiculo:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Vehículo con id {vehiculo_id} no encontrado",
|
||||
)
|
||||
|
||||
# Soft delete
|
||||
vehiculo.activo = False
|
||||
vehiculo.en_servicio = False
|
||||
await db.commit()
|
||||
|
||||
|
||||
@router.get("/{vehiculo_id}/ubicacion")
|
||||
async def obtener_ubicacion_actual(
|
||||
vehiculo_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: Usuario = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Obtiene la ubicación actual de un vehículo.
|
||||
|
||||
Args:
|
||||
vehiculo_id: ID del vehículo.
|
||||
|
||||
Returns:
|
||||
Última ubicación conocida.
|
||||
"""
|
||||
ubicacion_service = UbicacionService(db)
|
||||
ubicacion = await ubicacion_service.obtener_ultima_ubicacion(vehiculo_id)
|
||||
|
||||
if not ubicacion:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="No hay ubicación registrada para este vehículo",
|
||||
)
|
||||
|
||||
return ubicacion
|
||||
|
||||
|
||||
@router.get("/{vehiculo_id}/historial")
|
||||
async def obtener_historial_ubicaciones(
|
||||
vehiculo_id: int,
|
||||
desde: datetime,
|
||||
hasta: datetime,
|
||||
simplificar: bool = True,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: Usuario = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Obtiene el historial de ubicaciones de un vehículo.
|
||||
|
||||
Args:
|
||||
vehiculo_id: ID del vehículo.
|
||||
desde: Fecha/hora de inicio.
|
||||
hasta: Fecha/hora de fin.
|
||||
simplificar: Simplificar la ruta.
|
||||
|
||||
Returns:
|
||||
Historial de ubicaciones con estadísticas.
|
||||
"""
|
||||
ubicacion_service = UbicacionService(db)
|
||||
return await ubicacion_service.obtener_historial(
|
||||
vehiculo_id, desde, hasta, simplificar
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{vehiculo_id}/viajes", response_model=List[dict])
|
||||
async def obtener_viajes_vehiculo(
|
||||
vehiculo_id: int,
|
||||
desde: Optional[datetime] = None,
|
||||
hasta: Optional[datetime] = None,
|
||||
limite: int = Query(50, ge=1, le=100),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: Usuario = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Obtiene los viajes de un vehículo.
|
||||
|
||||
Args:
|
||||
vehiculo_id: ID del vehículo.
|
||||
desde: Fecha inicio (opcional).
|
||||
hasta: Fecha fin (opcional).
|
||||
limite: Límite de resultados.
|
||||
|
||||
Returns:
|
||||
Lista de viajes.
|
||||
"""
|
||||
from app.services.viaje_service import ViajeService
|
||||
|
||||
viaje_service = ViajeService(db)
|
||||
viajes = await viaje_service.obtener_viajes_vehiculo(
|
||||
vehiculo_id, desde, hasta, limite
|
||||
)
|
||||
|
||||
return [
|
||||
{
|
||||
"id": v.id,
|
||||
"inicio_tiempo": v.inicio_tiempo,
|
||||
"fin_tiempo": v.fin_tiempo,
|
||||
"inicio_direccion": v.inicio_direccion,
|
||||
"fin_direccion": v.fin_direccion,
|
||||
"distancia_km": v.distancia_km,
|
||||
"duracion_formateada": v.duracion_formateada,
|
||||
"estado": v.estado,
|
||||
}
|
||||
for v in viajes
|
||||
]
|
||||
|
||||
|
||||
@router.get("/{vehiculo_id}/alertas", response_model=List[dict])
|
||||
async def obtener_alertas_vehiculo(
|
||||
vehiculo_id: int,
|
||||
atendidas: Optional[bool] = None,
|
||||
limite: int = Query(50, ge=1, le=100),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: Usuario = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Obtiene las alertas de un vehículo.
|
||||
|
||||
Args:
|
||||
vehiculo_id: ID del vehículo.
|
||||
atendidas: Filtrar por estado de atención.
|
||||
limite: Límite de resultados.
|
||||
|
||||
Returns:
|
||||
Lista de alertas.
|
||||
"""
|
||||
query = (
|
||||
select(Alerta)
|
||||
.where(Alerta.vehiculo_id == vehiculo_id)
|
||||
.order_by(Alerta.creado_en.desc())
|
||||
.limit(limite)
|
||||
)
|
||||
|
||||
if atendidas is not None:
|
||||
query = query.where(Alerta.atendida == atendidas)
|
||||
|
||||
result = await db.execute(query)
|
||||
alertas = result.scalars().all()
|
||||
|
||||
return [
|
||||
{
|
||||
"id": a.id,
|
||||
"tipo_alerta_id": a.tipo_alerta_id,
|
||||
"severidad": a.severidad,
|
||||
"mensaje": a.mensaje,
|
||||
"creado_en": a.creado_en,
|
||||
"atendida": a.atendida,
|
||||
}
|
||||
for a in alertas
|
||||
]
|
||||
|
||||
|
||||
@router.get("/{vehiculo_id}/estadisticas", response_model=VehiculoEstadisticas)
|
||||
async def obtener_estadisticas_vehiculo(
|
||||
vehiculo_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: Usuario = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Obtiene estadísticas de un vehículo.
|
||||
|
||||
Args:
|
||||
vehiculo_id: ID del vehículo.
|
||||
|
||||
Returns:
|
||||
Estadísticas del vehículo.
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(Vehiculo).where(Vehiculo.id == vehiculo_id)
|
||||
)
|
||||
vehiculo = result.scalar_one_or_none()
|
||||
|
||||
if not vehiculo:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Vehículo con id {vehiculo_id} no encontrado",
|
||||
)
|
||||
|
||||
ahora = datetime.now(timezone.utc)
|
||||
inicio_hoy = ahora.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
inicio_semana = ahora - timedelta(days=7)
|
||||
inicio_mes = ahora - timedelta(days=30)
|
||||
|
||||
# Distancia hoy
|
||||
result = await db.execute(
|
||||
select(func.coalesce(func.sum(Viaje.distancia_km), 0))
|
||||
.where(Viaje.vehiculo_id == vehiculo_id)
|
||||
.where(Viaje.inicio_tiempo >= inicio_hoy)
|
||||
)
|
||||
distancia_hoy = result.scalar() or 0
|
||||
|
||||
# Distancia semana
|
||||
result = await db.execute(
|
||||
select(func.coalesce(func.sum(Viaje.distancia_km), 0))
|
||||
.where(Viaje.vehiculo_id == vehiculo_id)
|
||||
.where(Viaje.inicio_tiempo >= inicio_semana)
|
||||
)
|
||||
distancia_semana = result.scalar() or 0
|
||||
|
||||
# Distancia mes
|
||||
result = await db.execute(
|
||||
select(func.coalesce(func.sum(Viaje.distancia_km), 0))
|
||||
.where(Viaje.vehiculo_id == vehiculo_id)
|
||||
.where(Viaje.inicio_tiempo >= inicio_mes)
|
||||
)
|
||||
distancia_mes = result.scalar() or 0
|
||||
|
||||
# Alertas activas
|
||||
result = await db.execute(
|
||||
select(func.count(Alerta.id))
|
||||
.where(Alerta.vehiculo_id == vehiculo_id)
|
||||
.where(Alerta.atendida == False)
|
||||
)
|
||||
alertas_activas = result.scalar() or 0
|
||||
|
||||
# Alertas mes
|
||||
result = await db.execute(
|
||||
select(func.count(Alerta.id))
|
||||
.where(Alerta.vehiculo_id == vehiculo_id)
|
||||
.where(Alerta.creado_en >= inicio_mes)
|
||||
)
|
||||
alertas_mes = result.scalar() or 0
|
||||
|
||||
return VehiculoEstadisticas(
|
||||
vehiculo_id=vehiculo.id,
|
||||
nombre=vehiculo.nombre,
|
||||
placa=vehiculo.placa,
|
||||
distancia_hoy_km=float(distancia_hoy),
|
||||
distancia_semana_km=float(distancia_semana),
|
||||
distancia_mes_km=float(distancia_mes),
|
||||
distancia_total_km=vehiculo.distancia_recorrida,
|
||||
tiempo_movimiento_hoy_min=0, # TODO: Calcular
|
||||
tiempo_parado_hoy_min=0, # TODO: Calcular
|
||||
alertas_activas=alertas_activas,
|
||||
alertas_mes=alertas_mes,
|
||||
mantenimientos_vencidos=0, # TODO: Calcular
|
||||
)
|
||||
339
backend/app/api/v1/viajes.py
Normal file
339
backend/app/api/v1/viajes.py
Normal 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
|
||||
]
|
||||
14
backend/app/api/websocket/__init__.py
Normal file
14
backend/app/api/websocket/__init__.py
Normal 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",
|
||||
]
|
||||
125
backend/app/api/websocket/alertas.py
Normal file
125
backend/app/api/websocket/alertas.py
Normal 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)
|
||||
266
backend/app/api/websocket/manager.py
Normal file
266
backend/app/api/websocket/manager.py
Normal 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()
|
||||
187
backend/app/api/websocket/ubicaciones.py
Normal file
187
backend/app/api/websocket/ubicaciones.py
Normal 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)
|
||||
56
backend/app/core/__init__.py
Normal file
56
backend/app/core/__init__.py
Normal 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
177
backend/app/core/config.py
Normal 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()
|
||||
140
backend/app/core/database.py
Normal file
140
backend/app/core/database.py
Normal 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}")
|
||||
280
backend/app/core/exceptions.py
Normal file
280
backend/app/core/exceptions.py
Normal 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)
|
||||
285
backend/app/core/security.py
Normal file
285
backend/app/core/security.py
Normal 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
268
backend/app/main.py
Normal 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",
|
||||
)
|
||||
73
backend/app/models/__init__.py
Normal file
73
backend/app/models/__init__.py
Normal 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",
|
||||
]
|
||||
117
backend/app/models/alerta.py
Normal file
117
backend/app/models/alerta.py
Normal 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}')>"
|
||||
43
backend/app/models/base.py
Normal file
43
backend/app/models/base.py
Normal 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
|
||||
142
backend/app/models/camara.py
Normal file
142
backend/app/models/camara.py
Normal 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})>"
|
||||
100
backend/app/models/carga_combustible.py
Normal file
100
backend/app/models/carga_combustible.py
Normal 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})>"
|
||||
89
backend/app/models/conductor.py
Normal file
89
backend/app/models/conductor.py
Normal 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}')>"
|
||||
249
backend/app/models/configuracion.py
Normal file
249
backend/app/models/configuracion.py
Normal 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",
|
||||
},
|
||||
]
|
||||
111
backend/app/models/dispositivo.py
Normal file
111
backend/app/models/dispositivo.py
Normal 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}')>"
|
||||
156
backend/app/models/evento_video.py
Normal file
156
backend/app/models/evento_video.py
Normal 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",
|
||||
},
|
||||
]
|
||||
143
backend/app/models/geocerca.py
Normal file
143
backend/app/models/geocerca.py
Normal 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}')>"
|
||||
109
backend/app/models/grabacion.py
Normal file
109
backend/app/models/grabacion.py
Normal 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}')>"
|
||||
31
backend/app/models/grupo_vehiculos.py
Normal file
31
backend/app/models/grupo_vehiculos.py
Normal 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}')>"
|
||||
127
backend/app/models/mantenimiento.py
Normal file
127
backend/app/models/mantenimiento.py
Normal 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}')>"
|
||||
94
backend/app/models/mensaje.py
Normal file
94
backend/app/models/mensaje.py
Normal 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})>"
|
||||
111
backend/app/models/parada.py
Normal file
111
backend/app/models/parada.py
Normal 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
108
backend/app/models/poi.py
Normal 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"},
|
||||
]
|
||||
192
backend/app/models/tipo_alerta.py
Normal file
192
backend/app/models/tipo_alerta.py
Normal 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,
|
||||
},
|
||||
]
|
||||
175
backend/app/models/tipo_mantenimiento.py
Normal file
175
backend/app/models/tipo_mantenimiento.py
Normal 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,
|
||||
},
|
||||
]
|
||||
156
backend/app/models/ubicacion.py
Normal file
156
backend/app/models/ubicacion.py
Normal 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)
|
||||
34
backend/app/models/usuario.py
Normal file
34
backend/app/models/usuario.py
Normal 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}')>"
|
||||
130
backend/app/models/vehiculo.py
Normal file
130
backend/app/models/vehiculo.py
Normal 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
135
backend/app/models/viaje.py
Normal 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}')>"
|
||||
370
backend/app/schemas/__init__.py
Normal file
370
backend/app/schemas/__init__.py
Normal 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",
|
||||
]
|
||||
172
backend/app/schemas/alerta.py
Normal file
172
backend/app/schemas/alerta.py
Normal 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
|
||||
97
backend/app/schemas/base.py
Normal file
97
backend/app/schemas/base.py
Normal 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
|
||||
136
backend/app/schemas/combustible.py
Normal file
136
backend/app/schemas/combustible.py
Normal 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}]
|
||||
94
backend/app/schemas/conductor.py
Normal file
94
backend/app/schemas/conductor.py
Normal 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
|
||||
108
backend/app/schemas/configuracion.py
Normal file
108
backend/app/schemas/configuracion.py
Normal 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
|
||||
92
backend/app/schemas/dispositivo.py
Normal file
92
backend/app/schemas/dispositivo.py
Normal 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
|
||||
160
backend/app/schemas/geocerca.py
Normal file
160
backend/app/schemas/geocerca.py
Normal 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()
|
||||
52
backend/app/schemas/grupo_vehiculos.py
Normal file
52
backend/app/schemas/grupo_vehiculos.py
Normal 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()
|
||||
198
backend/app/schemas/mantenimiento.py
Normal file
198
backend/app/schemas/mantenimiento.py
Normal 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
|
||||
105
backend/app/schemas/mensaje.py
Normal file
105
backend/app/schemas/mensaje.py
Normal 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
120
backend/app/schemas/poi.py
Normal 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}]
|
||||
217
backend/app/schemas/reporte.py
Normal file
217
backend/app/schemas/reporte.py
Normal 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
|
||||
139
backend/app/schemas/ubicacion.py
Normal file
139
backend/app/schemas/ubicacion.py
Normal 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
|
||||
116
backend/app/schemas/usuario.py
Normal file
116
backend/app/schemas/usuario.py
Normal 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
|
||||
176
backend/app/schemas/vehiculo.py
Normal file
176
backend/app/schemas/vehiculo.py
Normal 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()
|
||||
168
backend/app/schemas/viaje.py
Normal file
168
backend/app/schemas/viaje.py
Normal 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()
|
||||
264
backend/app/schemas/video.py
Normal file
264
backend/app/schemas/video.py
Normal 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}]
|
||||
23
backend/app/services/__init__.py
Normal file
23
backend/app/services/__init__.py
Normal 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",
|
||||
]
|
||||
495
backend/app/services/alerta_service.py
Normal file
495
backend/app/services/alerta_service.py
Normal 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,
|
||||
}
|
||||
351
backend/app/services/geocerca_service.py
Normal file
351
backend/app/services/geocerca_service.py
Normal 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
|
||||
348
backend/app/services/notificacion_service.py
Normal file
348
backend/app/services/notificacion_service.py
Normal 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)
|
||||
529
backend/app/services/reporte_service.py
Normal file
529
backend/app/services/reporte_service.py
Normal 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
|
||||
286
backend/app/services/traccar_service.py
Normal file
286
backend/app/services/traccar_service.py
Normal 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
|
||||
489
backend/app/services/ubicacion_service.py
Normal file
489
backend/app/services/ubicacion_service.py
Normal 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
|
||||
405
backend/app/services/viaje_service.py
Normal file
405
backend/app/services/viaje_service.py
Normal 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,
|
||||
}
|
||||
411
backend/app/services/video_service.py
Normal file
411
backend/app/services/video_service.py
Normal 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
|
||||
116
backend/requirements.txt
Normal file
116
backend/requirements.txt
Normal file
@@ -0,0 +1,116 @@
|
||||
# Adan Fleet Monitor - Backend Dependencies
|
||||
# Python 3.11+
|
||||
|
||||
# =============================================================================
|
||||
# Web Framework
|
||||
# =============================================================================
|
||||
fastapi>=0.109.0,<1.0.0
|
||||
uvicorn[standard]>=0.27.0,<1.0.0
|
||||
python-multipart>=0.0.6,<1.0.0
|
||||
|
||||
# =============================================================================
|
||||
# Database
|
||||
# =============================================================================
|
||||
sqlalchemy[asyncio]>=2.0.25,<3.0.0
|
||||
asyncpg>=0.29.0,<1.0.0
|
||||
alembic>=1.13.0,<2.0.0
|
||||
|
||||
# =============================================================================
|
||||
# Data Validation
|
||||
# =============================================================================
|
||||
pydantic>=2.5.0,<3.0.0
|
||||
pydantic[email]>=2.5.0,<3.0.0
|
||||
pydantic-settings>=2.1.0,<3.0.0
|
||||
|
||||
# =============================================================================
|
||||
# Authentication & Security
|
||||
# =============================================================================
|
||||
python-jose[cryptography]>=3.3.0,<4.0.0
|
||||
passlib[bcrypt]>=1.7.4,<2.0.0
|
||||
bcrypt>=4.1.0,<5.0.0
|
||||
|
||||
# =============================================================================
|
||||
# Caching & Message Queue
|
||||
# =============================================================================
|
||||
redis>=5.0.0,<6.0.0
|
||||
aioredis>=2.0.1,<3.0.0
|
||||
|
||||
# =============================================================================
|
||||
# HTTP Client
|
||||
# =============================================================================
|
||||
httpx>=0.26.0,<1.0.0
|
||||
aiohttp>=3.9.0,<4.0.0
|
||||
|
||||
# =============================================================================
|
||||
# WebSockets
|
||||
# =============================================================================
|
||||
websockets>=12.0,<13.0
|
||||
|
||||
# =============================================================================
|
||||
# Geospatial
|
||||
# =============================================================================
|
||||
shapely>=2.0.2,<3.0.0
|
||||
geopy>=2.4.1,<3.0.0
|
||||
|
||||
# =============================================================================
|
||||
# Reports & Documents
|
||||
# =============================================================================
|
||||
jinja2>=3.1.2,<4.0.0
|
||||
weasyprint>=60.0,<70.0
|
||||
openpyxl>=3.1.2,<4.0.0
|
||||
|
||||
# =============================================================================
|
||||
# MQTT (IoT Communication)
|
||||
# =============================================================================
|
||||
aiomqtt>=2.0.0,<3.0.0
|
||||
|
||||
# =============================================================================
|
||||
# Email
|
||||
# =============================================================================
|
||||
aiosmtplib>=3.0.0,<4.0.0
|
||||
|
||||
# =============================================================================
|
||||
# Utilities
|
||||
# =============================================================================
|
||||
python-dateutil>=2.8.2,<3.0.0
|
||||
pytz>=2023.3
|
||||
orjson>=3.9.10,<4.0.0
|
||||
|
||||
# =============================================================================
|
||||
# Development & Testing
|
||||
# =============================================================================
|
||||
pytest>=7.4.0,<8.0.0
|
||||
pytest-asyncio>=0.23.0,<1.0.0
|
||||
pytest-cov>=4.1.0,<5.0.0
|
||||
httpx>=0.26.0 # For TestClient
|
||||
factory-boy>=3.3.0,<4.0.0
|
||||
faker>=22.0.0,<30.0.0
|
||||
|
||||
# =============================================================================
|
||||
# Code Quality
|
||||
# =============================================================================
|
||||
black>=23.12.0,<25.0.0
|
||||
isort>=5.13.0,<6.0.0
|
||||
flake8>=7.0.0,<8.0.0
|
||||
mypy>=1.8.0,<2.0.0
|
||||
|
||||
# =============================================================================
|
||||
# Logging & Monitoring
|
||||
# =============================================================================
|
||||
structlog>=24.1.0,<25.0.0
|
||||
sentry-sdk[fastapi]>=1.39.0,<2.0.0
|
||||
|
||||
# =============================================================================
|
||||
# Optional: Push Notifications
|
||||
# =============================================================================
|
||||
firebase-admin>=6.3.0,<7.0.0
|
||||
|
||||
# =============================================================================
|
||||
# Optional: Celery for background tasks
|
||||
# =============================================================================
|
||||
celery[redis]>=5.3.0,<6.0.0
|
||||
|
||||
# =============================================================================
|
||||
# Optional: APScheduler for scheduled tasks
|
||||
# =============================================================================
|
||||
apscheduler>=3.10.0,<4.0.0
|
||||
Reference in New Issue
Block a user