commit 51d78bacf41cea943a646dcf6705151c10ae75a3 Author: FlotillasGPS Developer Date: Wed Jan 21 08:18:00 2026 +0000 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. diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..88aedcb --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,11 @@ +{ + "permissions": { + "allow": [ + "Skill(superpowers:brainstorming)", + "WebFetch(domain:palegreen-goldfish-709023.hostingersite.com)", + "Bash(git init:*)", + "Bash(git config:*)", + "Bash(git add:*)" + ] + } +} diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..2d1316f --- /dev/null +++ b/.env.example @@ -0,0 +1,86 @@ +# ============================================================================= +# FlotillasGPS - Variables de Entorno +# ============================================================================= +# Copiar este archivo a .env y configurar los valores + +# ============================================================================= +# BASE DE DATOS +# ============================================================================= +DATABASE_URL=postgresql://flotillas:password@localhost:5432/flotillas_db +DB_POOL_SIZE=10 +DB_MAX_OVERFLOW=20 + +# ============================================================================= +# REDIS +# ============================================================================= +REDIS_URL=redis://localhost:6379 +REDIS_DB=0 + +# ============================================================================= +# SEGURIDAD +# ============================================================================= +# Generar con: openssl rand -base64 64 +JWT_SECRET=cambiar_por_clave_segura_muy_larga +ACCESS_TOKEN_EXPIRE_MINUTES=1440 +REFRESH_TOKEN_EXPIRE_DAYS=7 + +# Generar con: openssl rand -base64 32 +ENCRYPTION_KEY=cambiar_por_otra_clave_segura + +# ============================================================================= +# TRACCAR +# ============================================================================= +TRACCAR_HOST=localhost +TRACCAR_PORT=5055 + +# ============================================================================= +# VIDEO STREAMING (MediaMTX) +# ============================================================================= +MEDIAMTX_API=http://localhost:9997 +MEDIAMTX_RTSP=rtsp://localhost:8554 +MEDIAMTX_WEBRTC=http://localhost:8889 +MEDIAMTX_HLS=http://localhost:8888 +VIDEO_STORAGE_PATH=/opt/flotillas/videos +VIDEO_RETENTION_DAYS=30 + +# ============================================================================= +# MQTT (Meshtastic) +# ============================================================================= +MQTT_ENABLED=true +MQTT_HOST=localhost +MQTT_PORT=1883 +MQTT_USER=mesh_gateway +MQTT_PASSWORD=cambiar_password +MQTT_TOPIC=flotillas/mesh/# + +# ============================================================================= +# NOTIFICACIONES +# ============================================================================= +SMTP_ENABLED=false +SMTP_HOST=smtp.ejemplo.com +SMTP_PORT=587 +SMTP_USER=notificaciones@ejemplo.com +SMTP_PASSWORD=password +SMTP_FROM=FlotillasGPS + +# ============================================================================= +# DOMINIO Y URLs +# ============================================================================= +DOMAIN=flotillas.tudominio.com +API_URL=https://flotillas.tudominio.com/api +FRONTEND_URL=https://flotillas.tudominio.com + +# ============================================================================= +# CONFIGURACION +# ============================================================================= +ENVIRONMENT=production +DEBUG=false +LOG_LEVEL=info +CORS_ORIGINS=https://flotillas.tudominio.com +DEFAULT_MAX_SPEED=80 +DEFAULT_STOP_ALERT_MINUTES=30 +DEFAULT_OFFLINE_ALERT_MINUTES=15 +GPS_UPDATE_INTERVAL=10 +LOCATIONS_RETENTION_DAYS=90 +ALERTS_RETENTION_DAYS=365 +MAX_UPLOAD_SIZE=50 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9257a89 --- /dev/null +++ b/.gitignore @@ -0,0 +1,139 @@ +# ============================================================================= +# FlotillasGPS - Git Ignore +# ============================================================================= + +# ============================================================================= +# Archivos de entorno y secretos +# ============================================================================= +.env +.env.local +.env.*.local +*.pem +*.key +firebase-credentials.json +credentials.json +.credentials + +# ============================================================================= +# Python +# ============================================================================= +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg +venv/ +ENV/ +.venv/ + +# ============================================================================= +# Node.js +# ============================================================================= +node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.npm +.yarn + +# ============================================================================= +# Build outputs +# ============================================================================= +frontend/dist/ +frontend/build/ +mobile/dist/ +mobile/build/ +*.bundle.js +*.bundle.js.map + +# ============================================================================= +# IDE y editores +# ============================================================================= +.idea/ +.vscode/ +*.swp +*.swo +*~ +.project +.classpath +.settings/ +*.sublime-workspace +*.sublime-project + +# ============================================================================= +# Sistema operativo +# ============================================================================= +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db +*.bak + +# ============================================================================= +# Logs +# ============================================================================= +logs/ +*.log +npm-debug.log* + +# ============================================================================= +# Testing +# ============================================================================= +coverage/ +.coverage +htmlcov/ +.pytest_cache/ +.tox/ +.nox/ + +# ============================================================================= +# Datos locales y temporales +# ============================================================================= +*.sqlite +*.db +*.sqlite3 +tmp/ +temp/ +cache/ + +# ============================================================================= +# Videos y archivos grandes +# ============================================================================= +videos/ +*.mp4 +*.avi +*.mov +*.mkv + +# ============================================================================= +# Backups +# ============================================================================= +backups/ +*.sql +*.sql.gz +*.tar.gz +*.zip + +# ============================================================================= +# Otros +# ============================================================================= +.docker/ +docker-compose.override.yml diff --git a/README.md b/README.md new file mode 100644 index 0000000..0754204 --- /dev/null +++ b/README.md @@ -0,0 +1,142 @@ +# FlotillasGPS - Sistema de Monitoreo de Flotillas + +Sistema completo de monitoreo de flotillas vehiculares con rastreo GPS en tiempo real, video streaming, y app movil para conductores. + +## Caracteristicas Principales + +- **Rastreo GPS en Tiempo Real**: Actualizacion cada 10 segundos con visualizacion en mapa +- **Video Streaming**: Camaras en vivo con WebRTC/HLS, grabacion de eventos +- **App Movil para Conductores**: Envio de ubicacion, registro de paradas, combustible +- **Sistema de Alertas**: Exceso de velocidad, geocercas, paradas prolongadas +- **Soporte Meshtastic**: Rastreo en zonas sin cobertura celular (experimental) +- **Reportes Automatizados**: PDF/Excel de recorridos, combustible, conductores +- **Mantenimiento Predictivo**: Programacion y recordatorios de servicios + +## Arquitectura + +``` + Cloudflare Tunnel (HTTPS) + | + +------------------+------------------+ + | | + Frontend (React) Backend (FastAPI) + | | + +------------------+------------------+ + | + +------------+------------+ + | | | + PostgreSQL Redis MediaMTX + TimescaleDB (Video) + | + +----+----+ + | | + Traccar MQTT + (GPS) (Meshtastic) +``` + +## Stack Tecnologico + +| Componente | Tecnologia | +|------------|------------| +| Backend | Python 3.11 + FastAPI | +| Frontend | React 18 + TypeScript + TailwindCSS | +| App Movil | React Native | +| Base de Datos | PostgreSQL 15 + TimescaleDB | +| Cache | Redis | +| GPS Server | Traccar | +| Video | MediaMTX | +| Mensajeria | Mosquitto MQTT | + +## Requisitos del Sistema + +### Minimos (1-10 vehiculos) +- CPU: 2 cores +- RAM: 4 GB +- Disco: 40 GB SSD + 500 GB HDD (videos) + +### Recomendados (10-20 vehiculos) +- CPU: 4 cores +- RAM: 8 GB +- Disco: 60 GB SSD + 2 TB HDD (videos) + +## Instalacion Rapida + +```bash +# Clonar repositorio +git clone https://git.consultoria-as.com/tu-usuario/flotillas-gps.git +cd flotillas-gps + +# Ejecutar instalador +sudo ./deploy/scripts/install.sh +``` + +El script instalara y configurara automaticamente todos los componentes. + +## Estructura del Proyecto + +``` +flotillas-gps/ +├── backend/ # API FastAPI +├── frontend/ # Dashboard React +├── mobile/ # App React Native +├── deploy/ # Scripts de despliegue +│ ├── scripts/ # install.sh, backup.sh, etc. +│ ├── services/ # Archivos systemd +│ └── ... +└── docs/ # Documentacion + ├── guias/ # Guias de usuario + └── arquitectura/ # Documentacion tecnica +``` + +## Documentacion + +- [Guia de Instalacion](docs/guias/instalacion.md) +- [Configuracion del Sistema](docs/guias/configuracion.md) +- [Manual del Administrador](docs/guias/usuario-admin.md) +- [Manual del Conductor](docs/guias/usuario-conductor.md) +- [Referencia de API](docs/guias/api-reference.md) +- [Configuracion Meshtastic](docs/guias/meshtastic.md) +- [Configuracion de Video](docs/guias/video-streaming.md) +- [Solucion de Problemas](docs/guias/troubleshooting.md) + +## Accesos por Defecto + +Despues de la instalacion: + +- **Dashboard**: https://tu-dominio.com +- **API Docs**: https://tu-dominio.com/api/docs +- **GPS Port**: TCP 5055 + +Las credenciales se generan durante la instalacion y se muestran al final. + +## Comandos Utiles + +```bash +# Ver estado de servicios +systemctl status flotillas-api flotillas-web traccar mediamtx + +# Ver logs +journalctl -u flotillas-api -f + +# Backup manual +/opt/flotillas/scripts/backup.sh + +# Actualizar +/opt/flotillas/scripts/update.sh +``` + +## Seguridad + +- Autenticacion JWT con refresh tokens +- Cloudflare WAF y DDoS protection +- Firewall UFW (solo puerto 5055 abierto) +- Datos sensibles encriptados +- Logs de auditoria + +## Licencia + +Propietario - Todos los derechos reservados + +## Soporte + +Para soporte tecnico, contactar al administrador del sistema. diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..be98dc7 --- /dev/null +++ b/backend/.env.example @@ -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 diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100644 index 0000000..66f245e --- /dev/null +++ b/backend/alembic.ini @@ -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 diff --git a/backend/alembic/env.py b/backend/alembic/env.py new file mode 100644 index 0000000..1689dac --- /dev/null +++ b/backend/alembic/env.py @@ -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() diff --git a/backend/alembic/script.py.mako b/backend/alembic/script.py.mako new file mode 100644 index 0000000..fbc4b07 --- /dev/null +++ b/backend/alembic/script.py.mako @@ -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"} diff --git a/backend/alembic/versions/20260121_000000_001_initial_schema.py b/backend/alembic/versions/20260121_000000_001_initial_schema.py new file mode 100644 index 0000000..238f832 --- /dev/null +++ b/backend/alembic/versions/20260121_000000_001_initial_schema.py @@ -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 diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..3365c64 --- /dev/null +++ b/backend/app/__init__.py @@ -0,0 +1,7 @@ +""" +Adan Fleet Monitor Backend. + +Sistema de monitoreo de flotillas GPS. +""" + +__version__ = "1.0.0" diff --git a/backend/app/api/v1/__init__.py b/backend/app/api/v1/__init__.py new file mode 100644 index 0000000..76d7efb --- /dev/null +++ b/backend/app/api/v1/__init__.py @@ -0,0 +1,7 @@ +""" +API v1 - Endpoints REST. +""" + +from app.api.v1.router import api_router + +__all__ = ["api_router"] diff --git a/backend/app/api/v1/alertas.py b/backend/app/api/v1/alertas.py new file mode 100644 index 0000000..a9ebd55 --- /dev/null +++ b/backend/app/api/v1/alertas.py @@ -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} diff --git a/backend/app/api/v1/auth.py b/backend/app/api/v1/auth.py new file mode 100644 index 0000000..b9f533f --- /dev/null +++ b/backend/app/api/v1/auth.py @@ -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"} diff --git a/backend/app/api/v1/conductores.py b/backend/app/api/v1/conductores.py new file mode 100644 index 0000000..d8b6544 --- /dev/null +++ b/backend/app/api/v1/conductores.py @@ -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 + ] diff --git a/backend/app/api/v1/dispositivos.py b/backend/app/api/v1/dispositivos.py new file mode 100644 index 0000000..16867a0 --- /dev/null +++ b/backend/app/api/v1/dispositivos.py @@ -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, + ) diff --git a/backend/app/api/v1/geocercas.py b/backend/app/api/v1/geocercas.py new file mode 100644 index 0000000..d081339 --- /dev/null +++ b/backend/app/api/v1/geocercas.py @@ -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, + ) diff --git a/backend/app/api/v1/reportes.py b/backend/app/api/v1/reportes.py new file mode 100644 index 0000000..175c864 --- /dev/null +++ b/backend/app/api/v1/reportes.py @@ -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 diff --git a/backend/app/api/v1/router.py b/backend/app/api/v1/router.py new file mode 100644 index 0000000..e126840 --- /dev/null +++ b/backend/app/api/v1/router.py @@ -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) diff --git a/backend/app/api/v1/ubicaciones.py b/backend/app/api/v1/ubicaciones.py new file mode 100644 index 0000000..f5d33b5 --- /dev/null +++ b/backend/app/api/v1/ubicaciones.py @@ -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() diff --git a/backend/app/api/v1/vehiculos.py b/backend/app/api/v1/vehiculos.py new file mode 100644 index 0000000..4d2bf94 --- /dev/null +++ b/backend/app/api/v1/vehiculos.py @@ -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 + ) diff --git a/backend/app/api/v1/viajes.py b/backend/app/api/v1/viajes.py new file mode 100644 index 0000000..1ac8e98 --- /dev/null +++ b/backend/app/api/v1/viajes.py @@ -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 + ] diff --git a/backend/app/api/websocket/__init__.py b/backend/app/api/websocket/__init__.py new file mode 100644 index 0000000..7810524 --- /dev/null +++ b/backend/app/api/websocket/__init__.py @@ -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", +] diff --git a/backend/app/api/websocket/alertas.py b/backend/app/api/websocket/alertas.py new file mode 100644 index 0000000..046b19d --- /dev/null +++ b/backend/app/api/websocket/alertas.py @@ -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) diff --git a/backend/app/api/websocket/manager.py b/backend/app/api/websocket/manager.py new file mode 100644 index 0000000..1d88706 --- /dev/null +++ b/backend/app/api/websocket/manager.py @@ -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() diff --git a/backend/app/api/websocket/ubicaciones.py b/backend/app/api/websocket/ubicaciones.py new file mode 100644 index 0000000..8ece380 --- /dev/null +++ b/backend/app/api/websocket/ubicaciones.py @@ -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) diff --git a/backend/app/core/__init__.py b/backend/app/core/__init__.py new file mode 100644 index 0000000..57e55bf --- /dev/null +++ b/backend/app/core/__init__.py @@ -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", +] diff --git a/backend/app/core/config.py b/backend/app/core/config.py new file mode 100644 index 0000000..804647b --- /dev/null +++ b/backend/app/core/config.py @@ -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() diff --git a/backend/app/core/database.py b/backend/app/core/database.py new file mode 100644 index 0000000..9ae935f --- /dev/null +++ b/backend/app/core/database.py @@ -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}") diff --git a/backend/app/core/exceptions.py b/backend/app/core/exceptions.py new file mode 100644 index 0000000..dddd762 --- /dev/null +++ b/backend/app/core/exceptions.py @@ -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) diff --git a/backend/app/core/security.py b/backend/app/core/security.py new file mode 100644 index 0000000..30d4a3a --- /dev/null +++ b/backend/app/core/security.py @@ -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)] diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..a6c3774 --- /dev/null +++ b/backend/app/main.py @@ -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", + ) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..c4dab79 --- /dev/null +++ b/backend/app/models/__init__.py @@ -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", +] diff --git a/backend/app/models/alerta.py b/backend/app/models/alerta.py new file mode 100644 index 0000000..c25cb87 --- /dev/null +++ b/backend/app/models/alerta.py @@ -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"" diff --git a/backend/app/models/base.py b/backend/app/models/base.py new file mode 100644 index 0000000..fdf414c --- /dev/null +++ b/backend/app/models/base.py @@ -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 diff --git a/backend/app/models/camara.py b/backend/app/models/camara.py new file mode 100644 index 0000000..7f57935 --- /dev/null +++ b/backend/app/models/camara.py @@ -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"" diff --git a/backend/app/models/carga_combustible.py b/backend/app/models/carga_combustible.py new file mode 100644 index 0000000..1ad521a --- /dev/null +++ b/backend/app/models/carga_combustible.py @@ -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"" diff --git a/backend/app/models/conductor.py b/backend/app/models/conductor.py new file mode 100644 index 0000000..4d132eb --- /dev/null +++ b/backend/app/models/conductor.py @@ -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"" diff --git a/backend/app/models/configuracion.py b/backend/app/models/configuracion.py new file mode 100644 index 0000000..d1048c7 --- /dev/null +++ b/backend/app/models/configuracion.py @@ -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"" + + 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", + }, +] diff --git a/backend/app/models/dispositivo.py b/backend/app/models/dispositivo.py new file mode 100644 index 0000000..ef168f1 --- /dev/null +++ b/backend/app/models/dispositivo.py @@ -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"" diff --git a/backend/app/models/evento_video.py b/backend/app/models/evento_video.py new file mode 100644 index 0000000..1880447 --- /dev/null +++ b/backend/app/models/evento_video.py @@ -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"" + + +# 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", + }, +] diff --git a/backend/app/models/geocerca.py b/backend/app/models/geocerca.py new file mode 100644 index 0000000..930a52a --- /dev/null +++ b/backend/app/models/geocerca.py @@ -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"" diff --git a/backend/app/models/grabacion.py b/backend/app/models/grabacion.py new file mode 100644 index 0000000..3b5688e --- /dev/null +++ b/backend/app/models/grabacion.py @@ -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"" diff --git a/backend/app/models/grupo_vehiculos.py b/backend/app/models/grupo_vehiculos.py new file mode 100644 index 0000000..5e90037 --- /dev/null +++ b/backend/app/models/grupo_vehiculos.py @@ -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"" diff --git a/backend/app/models/mantenimiento.py b/backend/app/models/mantenimiento.py new file mode 100644 index 0000000..76a710f --- /dev/null +++ b/backend/app/models/mantenimiento.py @@ -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"" diff --git a/backend/app/models/mensaje.py b/backend/app/models/mensaje.py new file mode 100644 index 0000000..38d1ef9 --- /dev/null +++ b/backend/app/models/mensaje.py @@ -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"" diff --git a/backend/app/models/parada.py b/backend/app/models/parada.py new file mode 100644 index 0000000..fa91b95 --- /dev/null +++ b/backend/app/models/parada.py @@ -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"" diff --git a/backend/app/models/poi.py b/backend/app/models/poi.py new file mode 100644 index 0000000..33b973a --- /dev/null +++ b/backend/app/models/poi.py @@ -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"" + + +# 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"}, +] diff --git a/backend/app/models/tipo_alerta.py b/backend/app/models/tipo_alerta.py new file mode 100644 index 0000000..704e801 --- /dev/null +++ b/backend/app/models/tipo_alerta.py @@ -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"" + + +# 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, + }, +] diff --git a/backend/app/models/tipo_mantenimiento.py b/backend/app/models/tipo_mantenimiento.py new file mode 100644 index 0000000..752f48d --- /dev/null +++ b/backend/app/models/tipo_mantenimiento.py @@ -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"" + + +# 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, + }, +] diff --git a/backend/app/models/ubicacion.py b/backend/app/models/ubicacion.py new file mode 100644 index 0000000..573107a --- /dev/null +++ b/backend/app/models/ubicacion.py @@ -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"" + + 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) diff --git a/backend/app/models/usuario.py b/backend/app/models/usuario.py new file mode 100644 index 0000000..f9a5d33 --- /dev/null +++ b/backend/app/models/usuario.py @@ -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"" diff --git a/backend/app/models/vehiculo.py b/backend/app/models/vehiculo.py new file mode 100644 index 0000000..8ac1ea2 --- /dev/null +++ b/backend/app/models/vehiculo.py @@ -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"" diff --git a/backend/app/models/viaje.py b/backend/app/models/viaje.py new file mode 100644 index 0000000..e0b6bd5 --- /dev/null +++ b/backend/app/models/viaje.py @@ -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"" diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py new file mode 100644 index 0000000..fd07dfb --- /dev/null +++ b/backend/app/schemas/__init__.py @@ -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", +] diff --git a/backend/app/schemas/alerta.py b/backend/app/schemas/alerta.py new file mode 100644 index 0000000..ef44873 --- /dev/null +++ b/backend/app/schemas/alerta.py @@ -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 diff --git a/backend/app/schemas/base.py b/backend/app/schemas/base.py new file mode 100644 index 0000000..955675d --- /dev/null +++ b/backend/app/schemas/base.py @@ -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 diff --git a/backend/app/schemas/combustible.py b/backend/app/schemas/combustible.py new file mode 100644 index 0000000..96e95f5 --- /dev/null +++ b/backend/app/schemas/combustible.py @@ -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}] diff --git a/backend/app/schemas/conductor.py b/backend/app/schemas/conductor.py new file mode 100644 index 0000000..e7b8441 --- /dev/null +++ b/backend/app/schemas/conductor.py @@ -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 diff --git a/backend/app/schemas/configuracion.py b/backend/app/schemas/configuracion.py new file mode 100644 index 0000000..b63256e --- /dev/null +++ b/backend/app/schemas/configuracion.py @@ -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 diff --git a/backend/app/schemas/dispositivo.py b/backend/app/schemas/dispositivo.py new file mode 100644 index 0000000..22b7a06 --- /dev/null +++ b/backend/app/schemas/dispositivo.py @@ -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 diff --git a/backend/app/schemas/geocerca.py b/backend/app/schemas/geocerca.py new file mode 100644 index 0000000..33025b7 --- /dev/null +++ b/backend/app/schemas/geocerca.py @@ -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() diff --git a/backend/app/schemas/grupo_vehiculos.py b/backend/app/schemas/grupo_vehiculos.py new file mode 100644 index 0000000..ab5e73e --- /dev/null +++ b/backend/app/schemas/grupo_vehiculos.py @@ -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() diff --git a/backend/app/schemas/mantenimiento.py b/backend/app/schemas/mantenimiento.py new file mode 100644 index 0000000..733bcdb --- /dev/null +++ b/backend/app/schemas/mantenimiento.py @@ -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 diff --git a/backend/app/schemas/mensaje.py b/backend/app/schemas/mensaje.py new file mode 100644 index 0000000..c82b439 --- /dev/null +++ b/backend/app/schemas/mensaje.py @@ -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 diff --git a/backend/app/schemas/poi.py b/backend/app/schemas/poi.py new file mode 100644 index 0000000..15b6170 --- /dev/null +++ b/backend/app/schemas/poi.py @@ -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}] diff --git a/backend/app/schemas/reporte.py b/backend/app/schemas/reporte.py new file mode 100644 index 0000000..8750e16 --- /dev/null +++ b/backend/app/schemas/reporte.py @@ -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 diff --git a/backend/app/schemas/ubicacion.py b/backend/app/schemas/ubicacion.py new file mode 100644 index 0000000..64da2f7 --- /dev/null +++ b/backend/app/schemas/ubicacion.py @@ -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 diff --git a/backend/app/schemas/usuario.py b/backend/app/schemas/usuario.py new file mode 100644 index 0000000..1245ae1 --- /dev/null +++ b/backend/app/schemas/usuario.py @@ -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 diff --git a/backend/app/schemas/vehiculo.py b/backend/app/schemas/vehiculo.py new file mode 100644 index 0000000..54a9a74 --- /dev/null +++ b/backend/app/schemas/vehiculo.py @@ -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() diff --git a/backend/app/schemas/viaje.py b/backend/app/schemas/viaje.py new file mode 100644 index 0000000..9050078 --- /dev/null +++ b/backend/app/schemas/viaje.py @@ -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() diff --git a/backend/app/schemas/video.py b/backend/app/schemas/video.py new file mode 100644 index 0000000..e5bff81 --- /dev/null +++ b/backend/app/schemas/video.py @@ -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}] diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..9babb13 --- /dev/null +++ b/backend/app/services/__init__.py @@ -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", +] diff --git a/backend/app/services/alerta_service.py b/backend/app/services/alerta_service.py new file mode 100644 index 0000000..f952377 --- /dev/null +++ b/backend/app/services/alerta_service.py @@ -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, + } diff --git a/backend/app/services/geocerca_service.py b/backend/app/services/geocerca_service.py new file mode 100644 index 0000000..27124c2 --- /dev/null +++ b/backend/app/services/geocerca_service.py @@ -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 diff --git a/backend/app/services/notificacion_service.py b/backend/app/services/notificacion_service.py new file mode 100644 index 0000000..1600ce7 --- /dev/null +++ b/backend/app/services/notificacion_service.py @@ -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""" + + + + + + +
+
+

Alerta: {alerta.tipo_alerta.nombre if alerta.tipo_alerta else 'Sistema'}

+ + {alerta.severidad.upper()} + +
+
+

{alerta.mensaje}

+ +
+ Fecha/Hora: + {alerta.creado_en.strftime('%Y-%m-%d %H:%M:%S')} +
+ + {'
Vehiculo ID: ' + str(alerta.vehiculo_id) + '
' if alerta.vehiculo_id else ''} + + {'
Ubicacion: ' + str(alerta.lat) + ', ' + str(alerta.lng) + '
' if alerta.lat else ''} + + {'
Velocidad: ' + str(alerta.velocidad) + ' km/h
' if alerta.velocidad else ''} + + {f'
Descripcion: {alerta.descripcion}
' if alerta.descripcion else ''} +
+ +
+ + + """ + 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""" + + + + + + +
+
+

Recordatorio de Mantenimiento

+
+
+

Se aproxima la fecha de mantenimiento programado:

+
    +
  • Vehiculo: {vehiculo_nombre} ({vehiculo_placa})
  • +
  • Tipo: {tipo_mantenimiento}
  • +
  • Fecha programada: {fecha_programada}
  • +
+

Por favor, programe el mantenimiento con anticipacion.

+
+
+ + + """ + + return await self.enviar_email(destinatarios, asunto, html) diff --git a/backend/app/services/reporte_service.py b/backend/app/services/reporte_service.py new file mode 100644 index 0000000..7695155 --- /dev/null +++ b/backend/app/services/reporte_service.py @@ -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 diff --git a/backend/app/services/traccar_service.py b/backend/app/services/traccar_service.py new file mode 100644 index 0000000..a9607bb --- /dev/null +++ b/backend/app/services/traccar_service.py @@ -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 diff --git a/backend/app/services/ubicacion_service.py b/backend/app/services/ubicacion_service.py new file mode 100644 index 0000000..75dda63 --- /dev/null +++ b/backend/app/services/ubicacion_service.py @@ -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 diff --git a/backend/app/services/viaje_service.py b/backend/app/services/viaje_service.py new file mode 100644 index 0000000..a5031d4 --- /dev/null +++ b/backend/app/services/viaje_service.py @@ -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, + } diff --git a/backend/app/services/video_service.py b/backend/app/services/video_service.py new file mode 100644 index 0000000..92672a3 --- /dev/null +++ b/backend/app/services/video_service.py @@ -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 diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..6ce3af8 --- /dev/null +++ b/backend/requirements.txt @@ -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 diff --git a/deploy/README.md b/deploy/README.md new file mode 100644 index 0000000..6c0a8ad --- /dev/null +++ b/deploy/README.md @@ -0,0 +1,264 @@ +# Deploy - Sistema de Flotillas + +Scripts y configuraciones para desplegar el sistema de flotillas en produccion. + +## Estructura + +``` +deploy/ +├── proxmox/ # Crear VM en Proxmox VE +│ └── vm-setup.sh +├── scripts/ # Scripts de utilidad +│ ├── install.sh # Instalacion completa +│ ├── backup.sh # Backup automatico +│ ├── restore.sh # Restaurar backup +│ ├── update.sh # Actualizar aplicacion +│ ├── health-check.sh # Verificar salud +│ ├── status.sh # Estado del sistema +│ └── logs.sh # Visor de logs +├── services/ # Servicios systemd +│ ├── flotillas-api.service +│ ├── flotillas-web.service +│ ├── mediamtx.service +│ └── cloudflared.service +├── cloudflare/ # Configuracion tunnel +│ └── config.yml +├── traccar/ # Configuracion GPS +│ └── traccar.xml +├── mediamtx/ # Configuracion streaming +│ └── mediamtx.yml +└── postgres/ # Base de datos + └── init.sql +``` + +## Requisitos + +- **SO**: Ubuntu 22.04 LTS +- **RAM**: Minimo 4GB (recomendado 8GB) +- **Disco**: Minimo 50GB SSD +- **CPU**: 4 cores + +## Instalacion Rapida + +### 1. En Proxmox (opcional) + +```bash +# Crear VM automaticamente +./deploy/proxmox/vm-setup.sh --vmid 200 --name flotillas --memory 8192 +``` + +### 2. En Ubuntu + +```bash +# Clonar repositorio +git clone https://github.com/tuorg/flotillas.git /opt/flotillas +cd /opt/flotillas + +# Ejecutar instalador +sudo ./deploy/scripts/install.sh +``` + +El instalador: +- Actualiza el sistema +- Instala PostgreSQL 15 + TimescaleDB + PostGIS +- Instala Redis +- Instala Python 3.11 y Node.js 20 +- Instala Traccar GPS Server +- Instala MediaMTX para video +- Configura servicios systemd +- Configura firewall (solo puerto 5055 publico) +- Genera credenciales aleatorias + +## Post-Instalacion + +### Verificar estado + +```bash +./deploy/scripts/status.sh +./deploy/scripts/health-check.sh +``` + +### Ver logs + +```bash +./deploy/scripts/logs.sh api -f # API en tiempo real +./deploy/scripts/logs.sh traccar # Traccar GPS +./deploy/scripts/logs.sh all -f # Todos los servicios +``` + +### Configurar Cloudflare Tunnel + +1. Instalar cloudflared: +```bash +curl -L https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64.deb -o cloudflared.deb +dpkg -i cloudflared.deb +``` + +2. Autenticarse: +```bash +cloudflared tunnel login +``` + +3. Crear tunnel: +```bash +cloudflared tunnel create flotillas +``` + +4. Configurar DNS: +```bash +cloudflared tunnel route dns flotillas flotillas.tudominio.com +``` + +5. Copiar config y habilitar servicio: +```bash +mkdir -p /etc/cloudflared +cp /opt/flotillas/deploy/cloudflare/config.yml /etc/cloudflared/ +systemctl enable cloudflared +systemctl start cloudflared +``` + +## Mantenimiento + +### Backup + +```bash +# Backup manual +./deploy/scripts/backup.sh + +# Backup completo (incluye archivos) +./deploy/scripts/backup.sh --full + +# Backup y subir a S3 +./deploy/scripts/backup.sh --upload +``` + +Backups automaticos: diariamente a las 3 AM (configurado por install.sh) + +### Restaurar + +```bash +# Listar backups disponibles +./deploy/scripts/restore.sh --list + +# Restaurar ultimo backup +./deploy/scripts/restore.sh --latest + +# Restaurar backup especifico +./deploy/scripts/restore.sh --db /var/backups/flotillas/daily/flotillas_20240115_db.sql.gz +``` + +### Actualizar + +```bash +# Actualizar a ultima version +./deploy/scripts/update.sh + +# Forzar actualizacion (descarta cambios locales) +./deploy/scripts/update.sh --force + +# Solo actualizar backend +./deploy/scripts/update.sh --backend +``` + +## Servicios + +| Servicio | Puerto | Descripcion | +|----------|--------|-------------| +| flotillas-api | 8000 | Backend FastAPI | +| flotillas-web | 3000 | Frontend | +| postgresql | 5432 | Base de datos | +| redis | 6379 | Cache | +| traccar | 5055 | GPS Server | +| mediamtx | 8554/8889/8888 | Video RTSP/WebRTC/HLS | +| mosquitto | 1883 | MQTT | + +### Comandos systemd + +```bash +# Estado +systemctl status flotillas-api + +# Reiniciar +systemctl restart flotillas-api + +# Logs +journalctl -u flotillas-api -f + +# Habilitar/Deshabilitar +systemctl enable flotillas-api +systemctl disable flotillas-api +``` + +## Seguridad + +- **Firewall**: Solo puerto 5055 (GPS) esta abierto +- **Acceso web**: Via Cloudflare Tunnel (HTTPS) +- **Base de datos**: Solo acceso local +- **Redis**: Autenticacion con password +- **Fail2ban**: Proteccion contra fuerza bruta + +## Puertos + +| Puerto | Uso | Acceso | +|--------|-----|--------| +| 22 | SSH | Firewall | +| 5055 | Traccar GPS | Publico | +| 3000 | Frontend | Tunnel | +| 8000 | API | Tunnel | +| 5432 | PostgreSQL | Local | +| 6379 | Redis | Local | +| 8554 | RTSP | Tunnel | +| 8889 | WebRTC | Tunnel | +| 8888 | HLS | Tunnel | + +## Troubleshooting + +### API no inicia + +```bash +# Ver logs +journalctl -u flotillas-api -n 100 + +# Verificar puerto +ss -tlnp | grep 8000 + +# Verificar base de datos +psql -h localhost -U flotillas -d flotillas -c "SELECT 1" +``` + +### Traccar no recibe datos + +```bash +# Verificar puerto GPS +ss -tlnp | grep 5055 + +# Ver logs Traccar +tail -f /opt/traccar/logs/tracker-server.log + +# Probar conexion +nc -zv localhost 5055 +``` + +### Problemas de memoria + +```bash +# Ver uso de memoria por servicio +systemctl status flotillas-api --no-pager | grep Memory + +# Reducir workers de API +# Editar /etc/systemd/system/flotillas-api.service +# Cambiar --workers 4 a --workers 2 +systemctl daemon-reload +systemctl restart flotillas-api +``` + +## Credenciales + +Las credenciales se generan durante la instalacion y se guardan en: +- `/root/flotillas-credentials.txt` + +**IMPORTANTE**: Guardar en lugar seguro y eliminar el archivo despues. + +## Soporte + +Para soporte, crear un issue en el repositorio o contactar al equipo de desarrollo. diff --git a/deploy/cloudflare/config.yml b/deploy/cloudflare/config.yml new file mode 100644 index 0000000..1118c0d --- /dev/null +++ b/deploy/cloudflare/config.yml @@ -0,0 +1,135 @@ +# ============================================ +# Cloudflare Tunnel - Configuracion +# ============================================ +# Documentacion: https://developers.cloudflare.com/cloudflare-one/connections/connect-apps +# +# Para usar esta configuracion: +# 1. Instalar cloudflared: https://developers.cloudflare.com/cloudflare-one/connections/connect-apps/install-and-setup/installation +# 2. Autenticarse: cloudflared tunnel login +# 3. Crear tunnel: cloudflared tunnel create flotillas +# 4. Obtener el UUID del tunnel y actualizar este archivo +# 5. Crear registros DNS: cloudflared tunnel route dns flotillas flotillas.tudominio.com +# 6. Copiar credenciales a /etc/cloudflared/ +# ============================================ + +# ID del tunnel (reemplazar con tu UUID) +tunnel: TUNNEL_UUID_AQUI + +# Archivo de credenciales +credentials-file: /etc/cloudflared/TUNNEL_UUID_AQUI.json + +# Configuracion de logging +loglevel: info +logfile: /var/log/cloudflared.log + +# Metricas (opcional) +metrics: localhost:60123 + +# No auto-actualizar +no-autoupdate: true + +# Configuracion de ingress (rutas) +ingress: + # ---------------------------------------- + # API Backend - /api/* y /docs + # ---------------------------------------- + - hostname: flotillas.tudominio.com + path: /api/* + service: http://localhost:8000 + originRequest: + connectTimeout: 30s + noTLSVerify: false + + - hostname: flotillas.tudominio.com + path: /docs + service: http://localhost:8000 + + - hostname: flotillas.tudominio.com + path: /redoc + service: http://localhost:8000 + + - hostname: flotillas.tudominio.com + path: /openapi.json + service: http://localhost:8000 + + # ---------------------------------------- + # WebSocket - /ws/* + # ---------------------------------------- + - hostname: flotillas.tudominio.com + path: /ws/* + service: http://localhost:8000 + originRequest: + # Importante para WebSocket + noTLSVerify: false + # Mantener conexion abierta + keepAliveConnections: 100 + keepAliveTimeout: 90s + + # ---------------------------------------- + # Video Streaming - WebRTC/HLS + # ---------------------------------------- + - hostname: stream.flotillas.tudominio.com + path: /* + service: http://localhost:8889 + originRequest: + noTLSVerify: false + + - hostname: hls.flotillas.tudominio.com + path: /* + service: http://localhost:8888 + + # ---------------------------------------- + # API de MediaMTX (interno/admin) + # ---------------------------------------- + - hostname: mediamtx-api.flotillas.tudominio.com + path: /* + service: http://localhost:9997 + originRequest: + # Solo acceso interno + noTLSVerify: false + + # ---------------------------------------- + # Frontend Web - Todo lo demas + # ---------------------------------------- + - hostname: flotillas.tudominio.com + service: http://localhost:3000 + originRequest: + noTLSVerify: false + + # ---------------------------------------- + # Catch-all (requerido) + # ---------------------------------------- + - service: http_status:404 + +# ============================================ +# Notas de configuracion +# ============================================ +# +# DOMINIOS RECOMENDADOS: +# - flotillas.tudominio.com -> Frontend + API +# - stream.flotillas.tudominio.com -> Video WebRTC +# - hls.flotillas.tudominio.com -> Video HLS +# +# INSTALACION RAPIDA CON TOKEN: +# Si prefieres usar token en lugar de archivo de config: +# 1. Ir a Cloudflare Zero Trust Dashboard +# 2. Access -> Tunnels -> Crear tunnel +# 3. Copiar token +# 4. Ejecutar: cloudflared tunnel run --token TU_TOKEN +# +# PUERTOS EXPUESTOS A TRAVES DEL TUNNEL: +# - 3000: Frontend (serve) +# - 8000: Backend API (uvicorn) +# - 8889: MediaMTX WebRTC +# - 8888: MediaMTX HLS +# - 9997: MediaMTX API (admin) +# +# PUERTO NO EXPUESTO (acceso directo): +# - 5055: Traccar GPS (dispositivos GPS se conectan directamente) +# +# SEGURIDAD ADICIONAL: +# Configura Access Policies en Cloudflare Zero Trust para: +# - Proteger /docs y /redoc (solo administradores) +# - Proteger mediamtx-api (solo interno) +# - Requerir autenticacion para rutas sensibles +# ============================================ diff --git a/deploy/mediamtx/mediamtx.yml b/deploy/mediamtx/mediamtx.yml new file mode 100644 index 0000000..be655f5 --- /dev/null +++ b/deploy/mediamtx/mediamtx.yml @@ -0,0 +1,237 @@ +# ============================================ +# MediaMTX - Configuracion para Sistema de Flotillas +# ============================================ +# Documentacion: https://github.com/bluenviron/mediamtx +# +# MediaMTX es un servidor de streaming multimedia que soporta: +# - RTSP (recibir streams de camaras IP) +# - WebRTC (streaming en navegadores) +# - HLS (streaming adaptativo) +# - RTMP (compatibilidad con OBS, etc.) +# ============================================ + +# ======================================== +# Configuracion General +# ======================================== + +# Nivel de log: debug, info, warn, error +logLevel: info + +# Destino de logs +logDestinations: [stdout] + +# Archivo de log (si se habilita file en logDestinations) +# logFile: /var/log/mediamtx/mediamtx.log + +# Timeout de lectura/escritura +readTimeout: 10s +writeTimeout: 10s + +# Timeout de lectura para UDP +readBufferCount: 512 + +# ======================================== +# API REST +# ======================================== + +api: yes +apiAddress: 127.0.0.1:9997 + +# Metricas para Prometheus +metrics: yes +metricsAddress: 127.0.0.1:9998 + +# ======================================== +# RTSP Server +# ======================================== +# Recibe streams de camaras IP + +rtsp: yes +protocols: [udp, multicast, tcp] + +# Puertos RTSP +rtspAddress: :8554 + +# Rango de puertos UDP para RTP +rtpAddress: :8000 +rtcpAddress: :8001 + +# Multicast (opcional) +multicastIPRange: 224.1.0.0/16 +multicastRTPPort: 8002 +multicastRTCPPort: 8003 + +# ======================================== +# RTMP Server +# ======================================== +# Compatibilidad con OBS, FFmpeg, etc. + +rtmp: yes +rtmpAddress: :1935 + +# Encriptacion RTMPS (requiere certificados) +rtmpEncryption: "no" +# rtmpServerKey: server.key +# rtmpServerCert: server.crt + +# ======================================== +# HLS Server +# ======================================== +# Streaming adaptativo para navegadores antiguos + +hls: yes +hlsAddress: :8888 + +# Permitir origen cruzado (CORS) +hlsAlwaysRemux: no +hlsVariant: lowLatency +hlsSegmentCount: 7 +hlsSegmentDuration: 1s +hlsPartDuration: 200ms +hlsSegmentMaxSize: 50M +hlsAllowOrigin: '*' + +# Directorio para segmentos HLS +hlsDirectory: '' + +# ======================================== +# WebRTC Server +# ======================================== +# Streaming de baja latencia en navegadores modernos + +webrtc: yes +webrtcAddress: :8889 + +# CORS para WebRTC +webrtcAllowOrigin: '*' + +# Configuracion ICE (NAT traversal) +webrtcICEServers2: [] +# Usar servidores STUN/TURN si hay NAT +# webrtcICEServers2: +# - urls: [stun:stun.l.google.com:19302] + +# Puertos ICE UDP +webrtcICEUDPMuxAddress: :8189 +webrtcICETCPMuxAddress: :8189 + +# ======================================== +# SRT Server (Secure Reliable Transport) +# ======================================== + +srt: no +srtAddress: :8890 + +# ======================================== +# Grabacion +# ======================================== +# Guardar streams a disco + +record: no +recordPath: ./recordings/%path/%Y-%m-%d_%H-%M-%S-%f +recordFormat: fmp4 +recordPartDuration: 100ms +recordSegmentDuration: 1h +recordDeleteAfter: 24h + +# ======================================== +# Autenticacion +# ======================================== +# Proteger acceso a streams + +# Metodos de autenticacion: internal, http, jwt +authMethod: internal + +# Usuarios internos (basico) +authInternalUsers: + # Usuario admin - acceso total + - user: admin + pass: CHANGE_ME_ADMIN_PASSWORD + permissions: + - action: publish + path: '' + - action: read + path: '' + - action: playback + path: '' + - action: api + + # Usuario para publicar (camaras) + # Las camaras usan este usuario para enviar stream + - user: camera + pass: CHANGE_ME_CAMERA_PASSWORD + permissions: + - action: publish + path: '' + + # Usuario para ver (clientes) + - user: viewer + pass: CHANGE_ME_VIEWER_PASSWORD + permissions: + - action: read + path: '' + - action: playback + path: '' + + # Usuario anonimo (solo lectura, opcional) + # - user: '' + # pass: '' + # permissions: + # - action: read + # path: '' + +# ======================================== +# Paths (Streams) +# ======================================== +# Configuracion de paths/streams individuales + +paths: + # Path por defecto - permite cualquier stream + all_others: + + # Stream de ejemplo - camara fija + # camara1: + # source: rtsp://192.168.1.100:554/stream1 + # sourceOnDemand: yes + # sourceOnDemandStartTimeout: 10s + # sourceOnDemandCloseAfter: 10s + + # Stream desde FFmpeg (si necesitas transcodificar) + # stream_transcoded: + # runOnInit: ffmpeg -i rtsp://source -c:v libx264 -preset ultrafast -tune zerolatency -f rtsp rtsp://localhost:$RTSP_PORT/$MTX_PATH + # runOnInitRestart: yes + + # Stream de camara vehicular (ejemplo) + # vehiculo_001: + # source: rtsp://usuario:password@192.168.1.101:554/h264 + # sourceOnDemand: yes + # runOnDemand: '' + # runOnDemandRestart: no + # runOnDemandStartTimeout: 10s + # runOnDemandCloseAfter: 10s + +# ============================================ +# Notas de Integracion +# ============================================ +# +# PUBLICAR STREAM (desde camara o FFmpeg): +# rtsp://camera:password@servidor:8554/nombre_stream +# +# VER STREAM: +# - RTSP: rtsp://viewer:password@servidor:8554/nombre_stream +# - WebRTC: http://servidor:8889/nombre_stream +# - HLS: http://servidor:8888/nombre_stream/index.m3u8 +# +# API REST (ejemplos): +# - Listar streams: curl http://localhost:9997/v3/paths/list +# - Info de stream: curl http://localhost:9997/v3/paths/get/nombre_stream +# - Kick conexion: curl -X POST http://localhost:9997/v3/paths/kick/nombre_stream +# +# INTEGRACION CON FRONTEND: +# Usar libreria como hls.js o adaptador WebRTC para reproducir en navegador +# +# SEGURIDAD: +# 1. Cambiar passwords por defecto +# 2. En produccion, usar authMethod: http para validar contra tu API +# 3. Configurar CORS apropiadamente +# ============================================ diff --git a/deploy/proxmox/vm-setup.sh b/deploy/proxmox/vm-setup.sh new file mode 100644 index 0000000..837f88c --- /dev/null +++ b/deploy/proxmox/vm-setup.sh @@ -0,0 +1,581 @@ +#!/bin/bash +# ============================================ +# Sistema de Flotillas - Crear VM en Proxmox +# ============================================ +# Este script crea una VM en Proxmox VE lista para +# instalar el sistema de flotillas +# +# Ejecutar en el HOST de Proxmox (no en una VM) +# +# Uso: ./vm-setup.sh [--vmid ID] [--name NOMBRE] [--memory MB] [--cores N] +# +# Requisitos: +# - Proxmox VE 7.x o 8.x +# - Almacenamiento local o compartido disponible +# - Acceso a internet para descargar ISO +# ============================================ + +set -e +set -o pipefail + +# --------------------------------------------- +# Colores +# --------------------------------------------- +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' + +# --------------------------------------------- +# Configuracion por defecto +# --------------------------------------------- + +# VM +VMID="${VMID:-200}" +VM_NAME="${VM_NAME:-flotillas}" +VM_MEMORY="${VM_MEMORY:-4096}" # MB +VM_CORES="${VM_CORES:-4}" +VM_DISK_SIZE="${VM_DISK_SIZE:-50}" # GB +VM_SOCKETS="${VM_SOCKETS:-1}" + +# Red +VM_BRIDGE="${VM_BRIDGE:-vmbr0}" +VM_VLAN="${VM_VLAN:-}" # Dejar vacio si no usa VLAN +VM_IP="${VM_IP:-dhcp}" # O IP estatica: 192.168.1.100/24 +VM_GATEWAY="${VM_GATEWAY:-}" # Solo si IP estatica +VM_DNS="${VM_DNS:-8.8.8.8}" + +# Almacenamiento +STORAGE="${STORAGE:-local-lvm}" +ISO_STORAGE="${ISO_STORAGE:-local}" + +# Ubuntu +UBUNTU_VERSION="22.04.4" +UBUNTU_ISO="ubuntu-${UBUNTU_VERSION}-live-server-amd64.iso" +UBUNTU_URL="https://releases.ubuntu.com/22.04/${UBUNTU_ISO}" + +# Cloud-init (para configuracion automatica) +USE_CLOUD_INIT="${USE_CLOUD_INIT:-true}" +CI_USER="${CI_USER:-flotillas}" +CI_PASSWORD="${CI_PASSWORD:-}" # Se genera si esta vacio +CI_SSH_KEY="${CI_SSH_KEY:-}" # Ruta a archivo de clave publica + +# --------------------------------------------- +# Funciones +# --------------------------------------------- +log_info() { + echo -e "${BLUE}[INFO]${NC} $1" +} + +log_success() { + echo -e "${GREEN}[OK]${NC} $1" +} + +log_warn() { + echo -e "${YELLOW}[WARN]${NC} $1" +} + +log_error() { + echo -e "${RED}[ERROR]${NC} $1" +} + +# Generar password aleatorio +generate_password() { + openssl rand -base64 16 | tr -dc 'a-zA-Z0-9' | head -c 16 +} + +# Verificar si comando existe +command_exists() { + command -v "$1" &> /dev/null +} + +# --------------------------------------------- +# Parsear argumentos +# --------------------------------------------- +parse_args() { + while [[ $# -gt 0 ]]; do + case $1 in + --vmid) + VMID="$2" + shift 2 + ;; + --name) + VM_NAME="$2" + shift 2 + ;; + --memory) + VM_MEMORY="$2" + shift 2 + ;; + --cores) + VM_CORES="$2" + shift 2 + ;; + --disk) + VM_DISK_SIZE="$2" + shift 2 + ;; + --storage) + STORAGE="$2" + shift 2 + ;; + --bridge) + VM_BRIDGE="$2" + shift 2 + ;; + --ip) + VM_IP="$2" + shift 2 + ;; + --gateway) + VM_GATEWAY="$2" + shift 2 + ;; + --ssh-key) + CI_SSH_KEY="$2" + shift 2 + ;; + --no-cloud-init) + USE_CLOUD_INIT=false + shift + ;; + --help|-h) + show_help + exit 0 + ;; + *) + log_error "Opcion desconocida: $1" + exit 1 + ;; + esac + done +} + +show_help() { + echo "Sistema de Flotillas - Crear VM en Proxmox" + echo "" + echo "Uso: $0 [opciones]" + echo "" + echo "Opciones:" + echo " --vmid ID ID de la VM (default: 200)" + echo " --name NOMBRE Nombre de la VM (default: flotillas)" + echo " --memory MB Memoria RAM en MB (default: 4096)" + echo " --cores N Numero de cores (default: 4)" + echo " --disk GB Tamanio de disco en GB (default: 50)" + echo " --storage NAME Almacenamiento Proxmox (default: local-lvm)" + echo " --bridge NAME Bridge de red (default: vmbr0)" + echo " --ip IP/CIDR IP estatica o 'dhcp' (default: dhcp)" + echo " --gateway IP Gateway (requerido si IP estatica)" + echo " --ssh-key FILE Archivo de clave SSH publica" + echo " --no-cloud-init No usar cloud-init" + echo "" + echo "Ejemplos:" + echo " $0 --vmid 200 --name flotillas --memory 8192 --cores 4" + echo " $0 --ip 192.168.1.100/24 --gateway 192.168.1.1" +} + +# --------------------------------------------- +# Verificar requisitos +# --------------------------------------------- +check_requirements() { + log_info "Verificando requisitos..." + + # Verificar que estamos en Proxmox + if ! command_exists pvesh; then + log_error "Este script debe ejecutarse en un host Proxmox" + exit 1 + fi + + # Verificar version de Proxmox + PVE_VERSION=$(pveversion --verbose | grep "pve-manager" | awk '{print $2}') + log_info "Proxmox VE version: $PVE_VERSION" + + # Verificar que VMID no existe + if qm status $VMID &> /dev/null; then + log_error "Ya existe una VM con ID $VMID" + log_error "Usa --vmid para especificar otro ID" + exit 1 + fi + + # Verificar almacenamiento + if ! pvesm status | grep -q "^${STORAGE}"; then + log_error "Almacenamiento '$STORAGE' no encontrado" + log_error "Almacenamientos disponibles:" + pvesm status + exit 1 + fi + + # Verificar bridge de red + if ! ip link show "$VM_BRIDGE" &> /dev/null; then + log_warn "Bridge '$VM_BRIDGE' no encontrado" + log_warn "Bridges disponibles:" + ip link show type bridge + fi + + log_success "Requisitos verificados" +} + +# --------------------------------------------- +# Descargar ISO de Ubuntu +# --------------------------------------------- +download_ubuntu_iso() { + log_info "Verificando ISO de Ubuntu..." + + ISO_PATH="/var/lib/vz/template/iso/${UBUNTU_ISO}" + + if [[ -f "$ISO_PATH" ]]; then + log_success "ISO ya existe: $ISO_PATH" + return + fi + + log_info "Descargando Ubuntu ${UBUNTU_VERSION}..." + log_info "URL: $UBUNTU_URL" + + # Crear directorio si no existe + mkdir -p /var/lib/vz/template/iso + + # Descargar con wget + wget -q --show-progress -O "$ISO_PATH" "$UBUNTU_URL" + + if [[ ! -f "$ISO_PATH" ]]; then + log_error "Error al descargar ISO" + exit 1 + fi + + log_success "ISO descargada: $ISO_PATH" +} + +# --------------------------------------------- +# Descargar imagen Cloud-Init (alternativa) +# --------------------------------------------- +download_cloud_image() { + log_info "Descargando imagen Ubuntu Cloud..." + + CLOUD_IMAGE="ubuntu-22.04-server-cloudimg-amd64.img" + CLOUD_URL="https://cloud-images.ubuntu.com/jammy/current/${CLOUD_IMAGE}" + CLOUD_PATH="/var/lib/vz/template/iso/${CLOUD_IMAGE}" + + if [[ -f "$CLOUD_PATH" ]]; then + log_success "Imagen cloud ya existe" + return + fi + + wget -q --show-progress -O "$CLOUD_PATH" "$CLOUD_URL" + + log_success "Imagen descargada: $CLOUD_PATH" +} + +# --------------------------------------------- +# Crear VM +# --------------------------------------------- +create_vm() { + log_info "Creando VM..." + + # Configuracion de red + NET_CONFIG="virtio,bridge=${VM_BRIDGE}" + if [[ -n "$VM_VLAN" ]]; then + NET_CONFIG="${NET_CONFIG},tag=${VM_VLAN}" + fi + + # Crear VM base + qm create $VMID \ + --name "$VM_NAME" \ + --description "Sistema de Flotillas GPS" \ + --ostype l26 \ + --machine q35 \ + --bios ovmf \ + --cpu host \ + --sockets $VM_SOCKETS \ + --cores $VM_CORES \ + --memory $VM_MEMORY \ + --balloon 0 \ + --net0 "$NET_CONFIG" \ + --scsihw virtio-scsi-pci \ + --agent enabled=1 + + log_success "VM creada con ID: $VMID" +} + +# --------------------------------------------- +# Agregar disco +# --------------------------------------------- +add_disk() { + log_info "Creando disco de ${VM_DISK_SIZE}GB..." + + # Agregar disco SCSI + qm set $VMID \ + --scsi0 "${STORAGE}:${VM_DISK_SIZE},discard=on,ssd=1" \ + --boot order=scsi0 + + # Agregar EFI disk + qm set $VMID \ + --efidisk0 "${STORAGE}:1,efitype=4m,pre-enrolled-keys=1" + + log_success "Disco agregado" +} + +# --------------------------------------------- +# Agregar ISO o Cloud-Init +# --------------------------------------------- +add_boot_media() { + if [[ "$USE_CLOUD_INIT" == "true" ]]; then + setup_cloud_init + else + attach_iso + fi +} + +attach_iso() { + log_info "Adjuntando ISO de instalacion..." + + ISO_PATH="${ISO_STORAGE}:iso/${UBUNTU_ISO}" + + qm set $VMID \ + --ide2 "$ISO_PATH,media=cdrom" \ + --boot order="ide2;scsi0" + + log_success "ISO adjuntada" + log_warn "Deberas completar la instalacion manualmente" +} + +setup_cloud_init() { + log_info "Configurando Cloud-Init..." + + # Generar password si no se especifico + if [[ -z "$CI_PASSWORD" ]]; then + CI_PASSWORD=$(generate_password) + log_info "Password generado: $CI_PASSWORD" + fi + + # Agregar drive de cloud-init + qm set $VMID --ide2 "${STORAGE}:cloudinit" + + # Configurar cloud-init + qm set $VMID \ + --ciuser "$CI_USER" \ + --cipassword "$CI_PASSWORD" + + # Configurar red + if [[ "$VM_IP" == "dhcp" ]]; then + qm set $VMID --ipconfig0 ip=dhcp + else + qm set $VMID --ipconfig0 "ip=${VM_IP},gw=${VM_GATEWAY}" + fi + + # DNS + qm set $VMID --nameserver "$VM_DNS" + + # SSH key si se especifico + if [[ -n "$CI_SSH_KEY" ]] && [[ -f "$CI_SSH_KEY" ]]; then + qm set $VMID --sshkeys "$CI_SSH_KEY" + log_info "SSH key configurada" + fi + + # Importar imagen cloud + CLOUD_IMAGE="/var/lib/vz/template/iso/ubuntu-22.04-server-cloudimg-amd64.img" + + if [[ -f "$CLOUD_IMAGE" ]]; then + log_info "Importando imagen cloud al disco..." + qm importdisk $VMID "$CLOUD_IMAGE" $STORAGE + qm set $VMID --scsi0 "${STORAGE}:vm-${VMID}-disk-1" + qm resize $VMID scsi0 ${VM_DISK_SIZE}G + else + log_warn "Imagen cloud no encontrada, usando ISO tradicional" + attach_iso + return + fi + + log_success "Cloud-Init configurado" +} + +# --------------------------------------------- +# Configurar opciones adicionales +# --------------------------------------------- +configure_vm_options() { + log_info "Configurando opciones adicionales..." + + # Habilitar QEMU guest agent + qm set $VMID --agent enabled=1 + + # Configurar arranque automatico + qm set $VMID --onboot 1 + + # Configurar proteccion (evitar eliminacion accidental) + # qm set $VMID --protection 1 + + # Tags para organizacion + qm set $VMID --tags "flotillas,gps,produccion" + + log_success "Opciones configuradas" +} + +# --------------------------------------------- +# Crear script de post-instalacion +# --------------------------------------------- +create_post_install_script() { + log_info "Creando script de post-instalacion..." + + POST_INSTALL_DIR="/var/lib/vz/snippets" + mkdir -p "$POST_INSTALL_DIR" + + cat > "${POST_INSTALL_DIR}/flotillas-postinstall.sh" <<'SCRIPT' +#!/bin/bash +# Script de post-instalacion para Sistema de Flotillas +# Ejecutar despues de instalar Ubuntu + +set -e + +echo "=== Actualizando sistema ===" +apt-get update && apt-get upgrade -y + +echo "=== Instalando QEMU Guest Agent ===" +apt-get install -y qemu-guest-agent +systemctl enable qemu-guest-agent +systemctl start qemu-guest-agent + +echo "=== Instalando dependencias basicas ===" +apt-get install -y \ + curl \ + wget \ + git \ + htop \ + net-tools \ + ufw + +echo "=== Configurando firewall basico ===" +ufw default deny incoming +ufw default allow outgoing +ufw allow ssh +ufw allow 5055/tcp # Traccar GPS +ufw --force enable + +echo "=== Listo! ===" +echo "Ahora ejecuta el script de instalacion:" +echo " cd /opt && git clone REPO_URL flotillas" +echo " cd flotillas/deploy/scripts" +echo " sudo ./install.sh" +SCRIPT + + chmod +x "${POST_INSTALL_DIR}/flotillas-postinstall.sh" + + log_success "Script creado en: ${POST_INSTALL_DIR}/flotillas-postinstall.sh" +} + +# --------------------------------------------- +# Generar archivo de credenciales +# --------------------------------------------- +save_credentials() { + CREDS_FILE="/root/vm-${VMID}-credentials.txt" + + cat > "$CREDS_FILE" <" + echo "" + else + echo " 2. Abrir consola y completar instalacion de Ubuntu:" + echo " qm terminal $VMID" + echo "" + fi + + echo " 3. Ejecutar script de instalacion del sistema:" + echo " git clone /opt/flotillas" + echo " cd /opt/flotillas/deploy/scripts" + echo " sudo ./install.sh" + echo "" + echo -e "${GREEN}============================================${NC}" +} + +# --------------------------------------------- +# Main +# --------------------------------------------- +main() { + parse_args "$@" + + echo "" + echo -e "${BLUE}============================================${NC}" + echo -e "${BLUE} CREAR VM PARA SISTEMA DE FLOTILLAS${NC}" + echo -e "${BLUE}============================================${NC}" + echo "" + + check_requirements + + if [[ "$USE_CLOUD_INIT" == "true" ]]; then + download_cloud_image + else + download_ubuntu_iso + fi + + create_vm + add_disk + add_boot_media + configure_vm_options + create_post_install_script + save_credentials + + show_summary +} + +# Ejecutar +main "$@" diff --git a/deploy/scripts/backup.sh b/deploy/scripts/backup.sh new file mode 100644 index 0000000..2a87cdf --- /dev/null +++ b/deploy/scripts/backup.sh @@ -0,0 +1,486 @@ +#!/bin/bash +# ============================================ +# Sistema de Flotillas - Script de Backup +# ============================================ +# Realiza backup de base de datos y configuracion +# +# Uso: ./backup.sh [--full] [--upload] [--keep-days N] +# +# Opciones: +# --full Incluir backup completo de archivos +# --upload Subir a S3/remote despues del backup +# --keep-days Dias de retencion (default: 7) +# ============================================ + +set -e +set -o pipefail + +# --------------------------------------------- +# Colores +# --------------------------------------------- +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' + +# --------------------------------------------- +# Variables de Configuracion +# --------------------------------------------- +INSTALL_DIR="${INSTALL_DIR:-/opt/flotillas}" +BACKUP_DIR="${BACKUP_DIR:-/var/backups/flotillas}" +RETENTION_DAYS="${BACKUP_RETENTION_DAYS:-7}" + +# Cargar variables de entorno +if [[ -f "$INSTALL_DIR/.env" ]]; then + export $(grep -v '^#' "$INSTALL_DIR/.env" | xargs) +fi + +# Base de datos +DB_HOST="${POSTGRES_HOST:-localhost}" +DB_PORT="${POSTGRES_PORT:-5432}" +DB_NAME="${POSTGRES_DB:-flotillas}" +DB_USER="${POSTGRES_USER:-flotillas}" +DB_PASSWORD="${POSTGRES_PASSWORD:-}" + +# S3 (opcional) +S3_ENABLED="${S3_ENABLED:-false}" +S3_BUCKET="${S3_BUCKET:-}" +S3_ENDPOINT="${S3_ENDPOINT:-https://s3.amazonaws.com}" + +# Timestamp para este backup +TIMESTAMP=$(date +%Y%m%d_%H%M%S) +BACKUP_NAME="flotillas_${TIMESTAMP}" + +# Flags +FULL_BACKUP=false +UPLOAD_BACKUP=false + +# --------------------------------------------- +# Funciones +# --------------------------------------------- +log_info() { + echo -e "${BLUE}[$(date '+%Y-%m-%d %H:%M:%S')] [INFO]${NC} $1" +} + +log_success() { + echo -e "${GREEN}[$(date '+%Y-%m-%d %H:%M:%S')] [OK]${NC} $1" +} + +log_warn() { + echo -e "${YELLOW}[$(date '+%Y-%m-%d %H:%M:%S')] [WARN]${NC} $1" +} + +log_error() { + echo -e "${RED}[$(date '+%Y-%m-%d %H:%M:%S')] [ERROR]${NC} $1" +} + +# Calcular tamanio de archivo +get_file_size() { + du -h "$1" 2>/dev/null | cut -f1 +} + +# Verificar espacio disponible +check_disk_space() { + local required_gb=$1 + local free_space=$(df -BG "$BACKUP_DIR" | awk 'NR==2 {print $4}' | tr -d 'G') + + if [[ $free_space -lt $required_gb ]]; then + log_error "Espacio insuficiente: ${free_space}GB disponible, se requieren ${required_gb}GB" + return 1 + fi + return 0 +} + +# --------------------------------------------- +# Parsear argumentos +# --------------------------------------------- +parse_args() { + while [[ $# -gt 0 ]]; do + case $1 in + --full) + FULL_BACKUP=true + shift + ;; + --upload) + UPLOAD_BACKUP=true + shift + ;; + --keep-days) + RETENTION_DAYS="$2" + shift 2 + ;; + --help|-h) + echo "Uso: $0 [--full] [--upload] [--keep-days N]" + echo "" + echo "Opciones:" + echo " --full Backup completo (DB + archivos)" + echo " --upload Subir a S3 despues del backup" + echo " --keep-days Dias de retencion (default: 7)" + exit 0 + ;; + *) + log_error "Opcion desconocida: $1" + exit 1 + ;; + esac + done +} + +# --------------------------------------------- +# Crear directorio de backup +# --------------------------------------------- +prepare_backup_dir() { + log_info "Preparando directorio de backup..." + + # Crear directorio si no existe + mkdir -p "$BACKUP_DIR" + mkdir -p "$BACKUP_DIR/daily" + mkdir -p "$BACKUP_DIR/temp" + + # Verificar permisos + if [[ ! -w "$BACKUP_DIR" ]]; then + log_error "No hay permisos de escritura en $BACKUP_DIR" + exit 1 + fi + + # Verificar espacio (minimo 5GB) + check_disk_space 5 || exit 1 + + log_success "Directorio listo: $BACKUP_DIR" +} + +# --------------------------------------------- +# Backup de PostgreSQL +# --------------------------------------------- +backup_database() { + log_info "Iniciando backup de PostgreSQL..." + + local db_backup_file="$BACKUP_DIR/temp/${BACKUP_NAME}_db.sql" + local db_backup_compressed="$BACKUP_DIR/daily/${BACKUP_NAME}_db.sql.gz" + + # Configurar password para pg_dump + export PGPASSWORD="$DB_PASSWORD" + + # Realizar dump + log_info "Exportando base de datos ${DB_NAME}..." + + pg_dump \ + -h "$DB_HOST" \ + -p "$DB_PORT" \ + -U "$DB_USER" \ + -d "$DB_NAME" \ + --format=plain \ + --no-owner \ + --no-acl \ + --verbose \ + -f "$db_backup_file" 2>/dev/null + + # Comprimir + log_info "Comprimiendo backup..." + gzip -9 -c "$db_backup_file" > "$db_backup_compressed" + + # Limpiar archivo temporal + rm -f "$db_backup_file" + + # Limpiar variable de password + unset PGPASSWORD + + local size=$(get_file_size "$db_backup_compressed") + log_success "Backup de BD completado: $db_backup_compressed ($size)" + + # Backup de Traccar DB si existe + if [[ -n "${TRACCAR_DB_NAME:-}" ]]; then + log_info "Exportando base de datos Traccar..." + + local traccar_backup="$BACKUP_DIR/daily/${BACKUP_NAME}_traccar.sql.gz" + + export PGPASSWORD="$DB_PASSWORD" + + pg_dump \ + -h "$DB_HOST" \ + -p "$DB_PORT" \ + -U "$DB_USER" \ + -d "${TRACCAR_DB_NAME}" \ + --format=plain \ + --no-owner \ + --no-acl \ + 2>/dev/null | gzip -9 > "$traccar_backup" + + unset PGPASSWORD + + local traccar_size=$(get_file_size "$traccar_backup") + log_success "Backup de Traccar completado: $traccar_backup ($traccar_size)" + fi +} + +# --------------------------------------------- +# Backup de configuracion +# --------------------------------------------- +backup_config() { + log_info "Respaldando archivos de configuracion..." + + local config_backup="$BACKUP_DIR/daily/${BACKUP_NAME}_config.tar.gz" + + # Lista de archivos a respaldar + local files_to_backup=( + "$INSTALL_DIR/.env" + "$INSTALL_DIR/deploy" + "/opt/traccar/conf/traccar.xml" + "/opt/mediamtx/mediamtx.yml" + "/etc/mosquitto/conf.d/flotillas.conf" + "/etc/systemd/system/flotillas-*.service" + "/etc/systemd/system/mediamtx.service" + ) + + # Crear archivo temporal con lista de archivos existentes + local file_list=$(mktemp) + + for file in "${files_to_backup[@]}"; do + if [[ -e "$file" ]]; then + echo "$file" >> "$file_list" + fi + done + + # Crear tarball + tar -czf "$config_backup" -T "$file_list" 2>/dev/null || true + + # Limpiar + rm -f "$file_list" + + local size=$(get_file_size "$config_backup") + log_success "Backup de configuracion completado: $config_backup ($size)" +} + +# --------------------------------------------- +# Backup completo (archivos) +# --------------------------------------------- +backup_full() { + if [[ "$FULL_BACKUP" != "true" ]]; then + return + fi + + log_info "Iniciando backup completo de archivos..." + + local full_backup="$BACKUP_DIR/daily/${BACKUP_NAME}_full.tar.gz" + + # Excluir directorios grandes innecesarios + tar -czf "$full_backup" \ + --exclude="$INSTALL_DIR/backend/venv" \ + --exclude="$INSTALL_DIR/frontend/node_modules" \ + --exclude="$INSTALL_DIR/.git" \ + --exclude="*.log" \ + --exclude="*.pyc" \ + --exclude="__pycache__" \ + "$INSTALL_DIR" 2>/dev/null + + local size=$(get_file_size "$full_backup") + log_success "Backup completo: $full_backup ($size)" +} + +# --------------------------------------------- +# Rotar backups antiguos +# --------------------------------------------- +rotate_backups() { + log_info "Rotando backups antiguos (retencion: ${RETENTION_DAYS} dias)..." + + local deleted=0 + + # Encontrar y eliminar backups mas antiguos que RETENTION_DAYS + while IFS= read -r -d '' file; do + rm -f "$file" + ((deleted++)) + done < <(find "$BACKUP_DIR/daily" -type f -name "flotillas_*.gz" -mtime +${RETENTION_DAYS} -print0 2>/dev/null) + + if [[ $deleted -gt 0 ]]; then + log_info "Eliminados $deleted backups antiguos" + fi + + # Mostrar espacio usado + local space_used=$(du -sh "$BACKUP_DIR" 2>/dev/null | cut -f1) + log_info "Espacio total usado por backups: $space_used" +} + +# --------------------------------------------- +# Subir a S3 +# --------------------------------------------- +upload_to_s3() { + if [[ "$UPLOAD_BACKUP" != "true" ]]; then + return + fi + + if [[ "$S3_ENABLED" != "true" ]]; then + log_warn "S3 no esta habilitado. Configura S3_ENABLED=true en .env" + return + fi + + if ! command -v aws &> /dev/null; then + log_warn "AWS CLI no instalado. Instalando..." + pip3 install awscli -q + fi + + log_info "Subiendo backups a S3..." + + # Configurar credenciales + export AWS_ACCESS_KEY_ID="${S3_ACCESS_KEY}" + export AWS_SECRET_ACCESS_KEY="${S3_SECRET_KEY}" + + # Subir archivos del dia + for file in "$BACKUP_DIR/daily/${BACKUP_NAME}"*.gz; do + if [[ -f "$file" ]]; then + local filename=$(basename "$file") + log_info "Subiendo: $filename" + + aws s3 cp "$file" "s3://${S3_BUCKET}/backups/$(date +%Y/%m)/${filename}" \ + --endpoint-url "$S3_ENDPOINT" \ + --quiet + + log_success "Subido: $filename" + fi + done + + # Limpiar credenciales + unset AWS_ACCESS_KEY_ID + unset AWS_SECRET_ACCESS_KEY + + log_success "Backup subido a S3" +} + +# --------------------------------------------- +# Crear indice de backups +# --------------------------------------------- +create_backup_index() { + log_info "Actualizando indice de backups..." + + local index_file="$BACKUP_DIR/backup_index.txt" + + # Cabecera + cat > "$index_file" </dev/null); do + local filename=$(basename "$file") + local size=$(get_file_size "$file") + local date=$(stat -c %y "$file" 2>/dev/null | cut -d' ' -f1) + local type="unknown" + + case "$filename" in + *_db.sql.gz) type="database" ;; + *_config.tar.gz) type="config" ;; + *_full.tar.gz) type="full" ;; + *_traccar.sql.gz) type="traccar" ;; + esac + + echo "$date | $type | $filename | $size" >> "$index_file" + done + + log_success "Indice actualizado: $index_file" +} + +# --------------------------------------------- +# Verificar integridad del backup +# --------------------------------------------- +verify_backup() { + log_info "Verificando integridad de backups..." + + local errors=0 + + for file in "$BACKUP_DIR/daily/${BACKUP_NAME}"*.gz; do + if [[ -f "$file" ]]; then + if gzip -t "$file" 2>/dev/null; then + log_success "OK: $(basename "$file")" + else + log_error "CORRUPTO: $(basename "$file")" + ((errors++)) + fi + fi + done + + if [[ $errors -gt 0 ]]; then + log_error "Se encontraron $errors archivos corruptos" + return 1 + fi + + return 0 +} + +# --------------------------------------------- +# Enviar notificacion +# --------------------------------------------- +send_notification() { + local status="$1" + local message="$2" + + # Telegram (si esta configurado) + if [[ -n "${TELEGRAM_BOT_TOKEN:-}" ]] && [[ -n "${TELEGRAM_CHAT_ID:-}" ]]; then + local emoji="✅" + [[ "$status" == "error" ]] && emoji="❌" + + curl -s -X POST \ + "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \ + -d chat_id="${TELEGRAM_CHAT_ID}" \ + -d text="${emoji} Backup Flotillas: ${message}" \ + > /dev/null 2>&1 + fi +} + +# --------------------------------------------- +# Mostrar resumen +# --------------------------------------------- +show_summary() { + echo "" + log_success "==========================================" + log_success "BACKUP COMPLETADO" + log_success "==========================================" + echo "" + echo "Archivos creados:" + + for file in "$BACKUP_DIR/daily/${BACKUP_NAME}"*.gz; do + if [[ -f "$file" ]]; then + echo " - $(basename "$file") ($(get_file_size "$file"))" + fi + done + + echo "" + echo "Ubicacion: $BACKUP_DIR/daily/" + echo "Retencion: ${RETENTION_DAYS} dias" + echo "" +} + +# --------------------------------------------- +# Main +# --------------------------------------------- +main() { + parse_args "$@" + + log_info "==========================================" + log_info "INICIANDO BACKUP - $(date)" + log_info "==========================================" + + # Ejecutar pasos + prepare_backup_dir + backup_database + backup_config + backup_full + rotate_backups + verify_backup || true + upload_to_s3 + create_backup_index + show_summary + + # Notificar exito + send_notification "success" "Backup completado exitosamente" +} + +# Manejo de errores global +trap 'log_error "Backup fallido en linea $LINENO"; send_notification "error" "Backup fallido"; exit 1' ERR + +# Ejecutar +main "$@" diff --git a/deploy/scripts/health-check.sh b/deploy/scripts/health-check.sh new file mode 100644 index 0000000..2139782 --- /dev/null +++ b/deploy/scripts/health-check.sh @@ -0,0 +1,247 @@ +#!/bin/bash +# ============================================ +# Sistema de Flotillas - Health Check +# ============================================ +# Verifica el estado de todos los servicios +# +# Uso: ./health-check.sh [--verbose] [--json] +# ============================================ + +set -o pipefail + +# --------------------------------------------- +# Colores +# --------------------------------------------- +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' + +# Variables +INSTALL_DIR="${INSTALL_DIR:-/opt/flotillas}" +VERBOSE=false +JSON_OUTPUT=false +EXIT_CODE=0 + +# Cargar variables de entorno +if [[ -f "$INSTALL_DIR/.env" ]]; then + export $(grep -v '^#' "$INSTALL_DIR/.env" | xargs) +fi + +# --------------------------------------------- +# Funciones +# --------------------------------------------- +parse_args() { + while [[ $# -gt 0 ]]; do + case $1 in + --verbose|-v) VERBOSE=true; shift ;; + --json|-j) JSON_OUTPUT=true; shift ;; + --help|-h) + echo "Uso: $0 [--verbose] [--json]" + exit 0 + ;; + *) shift ;; + esac + done +} + +check_service() { + local service="$1" + local name="$2" + + if systemctl is-active --quiet "$service" 2>/dev/null; then + echo "ok" + else + echo "fail" + fi +} + +check_port() { + local port="$1" + + if nc -z localhost "$port" 2>/dev/null; then + echo "ok" + else + echo "fail" + fi +} + +check_url() { + local url="$1" + local timeout="${2:-5}" + + if curl -sf --max-time "$timeout" "$url" > /dev/null 2>&1; then + echo "ok" + else + echo "fail" + fi +} + +check_db() { + local host="${POSTGRES_HOST:-localhost}" + local port="${POSTGRES_PORT:-5432}" + local db="${POSTGRES_DB:-flotillas}" + local user="${POSTGRES_USER:-flotillas}" + + if PGPASSWORD="${POSTGRES_PASSWORD}" psql -h "$host" -p "$port" -U "$user" -d "$db" -c "SELECT 1" > /dev/null 2>&1; then + echo "ok" + else + echo "fail" + fi +} + +check_redis() { + local password="${REDIS_PASSWORD:-}" + + if [[ -n "$password" ]]; then + if redis-cli -a "$password" ping 2>/dev/null | grep -q "PONG"; then + echo "ok" + else + echo "fail" + fi + else + if redis-cli ping 2>/dev/null | grep -q "PONG"; then + echo "ok" + else + echo "fail" + fi + fi +} + +print_status() { + local name="$1" + local status="$2" + local details="$3" + + if [[ "$JSON_OUTPUT" == "true" ]]; then + return + fi + + if [[ "$status" == "ok" ]]; then + echo -e " ${GREEN}[OK]${NC} $name" + else + echo -e " ${RED}[FAIL]${NC} $name" + EXIT_CODE=1 + fi + + if [[ "$VERBOSE" == "true" ]] && [[ -n "$details" ]]; then + echo -e " $details" + fi +} + +# --------------------------------------------- +# Main +# --------------------------------------------- +main() { + parse_args "$@" + + # Resultados para JSON + declare -A results + + if [[ "$JSON_OUTPUT" != "true" ]]; then + echo "" + echo -e "${BLUE}========================================${NC}" + echo -e "${BLUE} HEALTH CHECK - Sistema de Flotillas${NC}" + echo -e "${BLUE}========================================${NC}" + echo "" + echo -e "${BLUE}Servicios Systemd:${NC}" + fi + + # Servicios systemd + results[flotillas_api]=$(check_service "flotillas-api" "API Backend") + print_status "flotillas-api" "${results[flotillas_api]}" + + results[flotillas_web]=$(check_service "flotillas-web" "Frontend Web") + print_status "flotillas-web" "${results[flotillas_web]}" + + results[postgresql]=$(check_service "postgresql" "PostgreSQL") + print_status "postgresql" "${results[postgresql]}" + + results[redis]=$(check_service "redis-server" "Redis") + print_status "redis" "${results[redis]}" + + results[traccar]=$(check_service "traccar" "Traccar GPS") + print_status "traccar" "${results[traccar]}" + + results[mediamtx]=$(check_service "mediamtx" "MediaMTX") + print_status "mediamtx" "${results[mediamtx]}" + + results[mosquitto]=$(check_service "mosquitto" "Mosquitto MQTT") + print_status "mosquitto" "${results[mosquitto]}" + + if [[ "$JSON_OUTPUT" != "true" ]]; then + echo "" + echo -e "${BLUE}Conectividad:${NC}" + fi + + # Puertos + results[port_api]=$(check_port "${API_PORT:-8000}") + print_status "API (puerto ${API_PORT:-8000})" "${results[port_api]}" + + results[port_frontend]=$(check_port "${FRONTEND_PORT:-3000}") + print_status "Frontend (puerto ${FRONTEND_PORT:-3000})" "${results[port_frontend]}" + + results[port_traccar]=$(check_port "${TRACCAR_PORT:-5055}") + print_status "Traccar (puerto ${TRACCAR_PORT:-5055})" "${results[port_traccar]}" + + results[port_rtsp]=$(check_port 8554) + print_status "MediaMTX RTSP (puerto 8554)" "${results[port_rtsp]}" + + if [[ "$JSON_OUTPUT" != "true" ]]; then + echo "" + echo -e "${BLUE}Base de Datos:${NC}" + fi + + # Base de datos + results[db_connection]=$(check_db) + print_status "PostgreSQL conexion" "${results[db_connection]}" + + results[redis_connection]=$(check_redis) + print_status "Redis conexion" "${results[redis_connection]}" + + if [[ "$JSON_OUTPUT" != "true" ]]; then + echo "" + echo -e "${BLUE}APIs:${NC}" + fi + + # APIs + results[api_health]=$(check_url "http://localhost:${API_PORT:-8000}/health") + print_status "API /health" "${results[api_health]}" + + results[mediamtx_api]=$(check_url "http://localhost:9997/v3/paths/list") + print_status "MediaMTX API" "${results[mediamtx_api]}" + + # JSON output + if [[ "$JSON_OUTPUT" == "true" ]]; then + echo "{" + echo " \"timestamp\": \"$(date -Iseconds)\"," + echo " \"status\": \"$([ $EXIT_CODE -eq 0 ] && echo 'healthy' || echo 'unhealthy')\"," + echo " \"checks\": {" + + first=true + for key in "${!results[@]}"; do + if [[ "$first" != "true" ]]; then + echo "," + fi + first=false + printf " \"%s\": \"%s\"" "$key" "${results[$key]}" + done + + echo "" + echo " }" + echo "}" + else + echo "" + if [[ $EXIT_CODE -eq 0 ]]; then + echo -e "${GREEN}Estado general: SALUDABLE${NC}" + else + echo -e "${RED}Estado general: PROBLEMAS DETECTADOS${NC}" + fi + echo "" + fi + + exit $EXIT_CODE +} + +main "$@" diff --git a/deploy/scripts/install.sh b/deploy/scripts/install.sh new file mode 100644 index 0000000..a2b69f3 --- /dev/null +++ b/deploy/scripts/install.sh @@ -0,0 +1,1080 @@ +#!/bin/bash +# ============================================ +# Sistema de Flotillas - Script de Instalacion +# ============================================ +# Este script instala y configura todo el sistema +# Ejecutar como root en Ubuntu 22.04 LTS +# +# Uso: sudo ./install.sh [--skip-db] [--skip-traccar] [--dev] +# +# Opciones: +# --skip-db No instalar PostgreSQL (usar DB externa) +# --skip-traccar No instalar Traccar +# --dev Modo desarrollo (sin optimizaciones) +# ============================================ + +set -e # Salir en caso de error +set -o pipefail # Capturar errores en pipes + +# --------------------------------------------- +# Colores para output +# --------------------------------------------- +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# --------------------------------------------- +# Variables de Configuracion +# --------------------------------------------- +INSTALL_DIR="${INSTALL_DIR:-/opt/flotillas}" +REPO_URL="${REPO_URL:-https://github.com/tuorganizacion/flotillas.git}" +REPO_BRANCH="${REPO_BRANCH:-main}" +BACKUP_DIR="${BACKUP_DIR:-/var/backups/flotillas}" + +# Versiones +POSTGRES_VERSION="15" +NODE_VERSION="20" +PYTHON_VERSION="3.11" +TRACCAR_VERSION="5.12" +MEDIAMTX_VERSION="1.5.1" + +# Puertos +API_PORT="${API_PORT:-8000}" +FRONTEND_PORT="${FRONTEND_PORT:-3000}" +TRACCAR_PORT="${TRACCAR_PORT:-5055}" + +# Flags +SKIP_DB=false +SKIP_TRACCAR=false +DEV_MODE=false + +# Archivo de credenciales generadas +CREDENTIALS_FILE="/root/flotillas-credentials.txt" + +# --------------------------------------------- +# Funciones de utilidad +# --------------------------------------------- +log_info() { + echo -e "${BLUE}[INFO]${NC} $1" +} + +log_success() { + echo -e "${GREEN}[OK]${NC} $1" +} + +log_warn() { + echo -e "${YELLOW}[WARN]${NC} $1" +} + +log_error() { + echo -e "${RED}[ERROR]${NC} $1" +} + +log_section() { + echo "" + echo -e "${GREEN}========================================${NC}" + echo -e "${GREEN} $1${NC}" + echo -e "${GREEN}========================================${NC}" + echo "" +} + +# Generar password aleatorio +generate_password() { + openssl rand -base64 32 | tr -dc 'a-zA-Z0-9' | head -c "${1:-24}" +} + +# Generar secret key +generate_secret_key() { + openssl rand -hex 32 +} + +# Verificar si comando existe +command_exists() { + command -v "$1" &> /dev/null +} + +# Verificar si servicio esta activo +service_active() { + systemctl is-active --quiet "$1" 2>/dev/null +} + +# --------------------------------------------- +# Parsear argumentos +# --------------------------------------------- +parse_args() { + while [[ $# -gt 0 ]]; do + case $1 in + --skip-db) + SKIP_DB=true + shift + ;; + --skip-traccar) + SKIP_TRACCAR=true + shift + ;; + --dev) + DEV_MODE=true + shift + ;; + --help|-h) + echo "Uso: $0 [--skip-db] [--skip-traccar] [--dev]" + echo "" + echo "Opciones:" + echo " --skip-db No instalar PostgreSQL (usar DB externa)" + echo " --skip-traccar No instalar Traccar" + echo " --dev Modo desarrollo" + exit 0 + ;; + *) + log_error "Opcion desconocida: $1" + exit 1 + ;; + esac + done +} + +# --------------------------------------------- +# Verificar requisitos +# --------------------------------------------- +check_requirements() { + log_section "Verificando Requisitos" + + # Verificar root + if [[ $EUID -ne 0 ]]; then + log_error "Este script debe ejecutarse como root" + exit 1 + fi + + # Verificar Ubuntu 22.04 + if [[ -f /etc/os-release ]]; then + . /etc/os-release + if [[ "$ID" != "ubuntu" ]] || [[ ! "$VERSION_ID" =~ ^22 ]]; then + log_warn "Este script esta optimizado para Ubuntu 22.04" + log_warn "Sistema detectado: $ID $VERSION_ID" + read -p "Continuar de todos modos? (y/N) " -n 1 -r + echo + if [[ ! $REPLY =~ ^[Yy]$ ]]; then + exit 1 + fi + fi + fi + + # Verificar memoria minima (2GB) + TOTAL_MEM=$(free -m | awk '/^Mem:/{print $2}') + if [[ $TOTAL_MEM -lt 2048 ]]; then + log_warn "Memoria RAM: ${TOTAL_MEM}MB (recomendado: 4GB+)" + else + log_success "Memoria RAM: ${TOTAL_MEM}MB" + fi + + # Verificar espacio en disco (10GB minimo) + FREE_SPACE=$(df -BG / | awk 'NR==2 {print $4}' | tr -d 'G') + if [[ $FREE_SPACE -lt 10 ]]; then + log_error "Espacio insuficiente: ${FREE_SPACE}GB (minimo: 10GB)" + exit 1 + else + log_success "Espacio en disco: ${FREE_SPACE}GB" + fi + + # Verificar conexion a internet + if ! ping -c 1 google.com &> /dev/null; then + log_error "Sin conexion a internet" + exit 1 + fi + log_success "Conexion a internet OK" +} + +# --------------------------------------------- +# Actualizar sistema +# --------------------------------------------- +update_system() { + log_section "Actualizando Sistema" + + export DEBIAN_FRONTEND=noninteractive + + log_info "Actualizando lista de paquetes..." + apt-get update -qq + + log_info "Actualizando paquetes instalados..." + apt-get upgrade -y -qq + + log_info "Instalando paquetes base..." + apt-get install -y -qq \ + curl \ + wget \ + gnupg \ + lsb-release \ + ca-certificates \ + apt-transport-https \ + software-properties-common \ + build-essential \ + git \ + unzip \ + jq \ + htop \ + net-tools \ + ufw \ + fail2ban \ + logrotate \ + cron + + log_success "Sistema actualizado" +} + +# --------------------------------------------- +# Instalar PostgreSQL + TimescaleDB + PostGIS +# --------------------------------------------- +install_postgresql() { + if [[ "$SKIP_DB" == "true" ]]; then + log_warn "Saltando instalacion de PostgreSQL (--skip-db)" + return + fi + + log_section "Instalando PostgreSQL ${POSTGRES_VERSION} + TimescaleDB + PostGIS" + + # Verificar si ya esta instalado + if command_exists psql && service_active postgresql; then + log_warn "PostgreSQL ya esta instalado" + POSTGRES_EXISTING=true + else + POSTGRES_EXISTING=false + + # Agregar repositorio PostgreSQL + log_info "Agregando repositorio PostgreSQL..." + curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc | gpg --dearmor -o /usr/share/keyrings/postgresql-keyring.gpg + echo "deb [signed-by=/usr/share/keyrings/postgresql-keyring.gpg] http://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list + + # Agregar repositorio TimescaleDB + log_info "Agregando repositorio TimescaleDB..." + curl -fsSL https://packagecloud.io/timescale/timescaledb/gpgkey | gpg --dearmor -o /usr/share/keyrings/timescaledb-keyring.gpg + echo "deb [signed-by=/usr/share/keyrings/timescaledb-keyring.gpg] https://packagecloud.io/timescale/timescaledb/ubuntu/ $(lsb_release -cs) main" > /etc/apt/sources.list.d/timescaledb.list + + apt-get update -qq + + # Instalar PostgreSQL + log_info "Instalando PostgreSQL ${POSTGRES_VERSION}..." + apt-get install -y -qq postgresql-${POSTGRES_VERSION} postgresql-contrib-${POSTGRES_VERSION} + + # Instalar PostGIS + log_info "Instalando PostGIS..." + apt-get install -y -qq postgresql-${POSTGRES_VERSION}-postgis-3 + + # Instalar TimescaleDB + log_info "Instalando TimescaleDB..." + apt-get install -y -qq timescaledb-2-postgresql-${POSTGRES_VERSION} + + # Configurar TimescaleDB + log_info "Configurando TimescaleDB..." + timescaledb-tune -yes -quiet + fi + + # Iniciar PostgreSQL + systemctl enable postgresql + systemctl start postgresql + + log_success "PostgreSQL instalado y configurado" +} + +# --------------------------------------------- +# Instalar Redis +# --------------------------------------------- +install_redis() { + log_section "Instalando Redis" + + if command_exists redis-server && service_active redis-server; then + log_warn "Redis ya esta instalado" + else + log_info "Instalando Redis..." + apt-get install -y -qq redis-server + + # Configurar Redis para produccion + log_info "Configurando Redis..." + + # Backup de config original + cp /etc/redis/redis.conf /etc/redis/redis.conf.bak + + # Configuraciones de seguridad y rendimiento + sed -i 's/^bind 127.0.0.1 ::1/bind 127.0.0.1/' /etc/redis/redis.conf + sed -i 's/^# maxmemory /maxmemory 256mb/' /etc/redis/redis.conf + sed -i 's/^# maxmemory-policy noeviction/maxmemory-policy allkeys-lru/' /etc/redis/redis.conf + fi + + systemctl enable redis-server + systemctl restart redis-server + + log_success "Redis instalado y configurado" +} + +# --------------------------------------------- +# Instalar Python 3.11 +# --------------------------------------------- +install_python() { + log_section "Instalando Python ${PYTHON_VERSION}" + + if python3.11 --version &> /dev/null; then + log_warn "Python 3.11 ya esta instalado" + else + log_info "Agregando repositorio deadsnakes..." + add-apt-repository -y ppa:deadsnakes/ppa + apt-get update -qq + + log_info "Instalando Python ${PYTHON_VERSION}..." + apt-get install -y -qq \ + python${PYTHON_VERSION} \ + python${PYTHON_VERSION}-venv \ + python${PYTHON_VERSION}-dev \ + python${PYTHON_VERSION}-distutils + fi + + # Instalar pip + if ! python3.11 -m pip --version &> /dev/null; then + log_info "Instalando pip..." + curl -sS https://bootstrap.pypa.io/get-pip.py | python3.11 + fi + + # Actualizar pip + python3.11 -m pip install --upgrade pip setuptools wheel -q + + log_success "Python ${PYTHON_VERSION} instalado" +} + +# --------------------------------------------- +# Instalar Node.js 20 +# --------------------------------------------- +install_nodejs() { + log_section "Instalando Node.js ${NODE_VERSION}" + + if node --version 2>/dev/null | grep -q "v${NODE_VERSION}"; then + log_warn "Node.js ${NODE_VERSION} ya esta instalado" + else + log_info "Instalando Node.js ${NODE_VERSION}..." + curl -fsSL https://deb.nodesource.com/setup_${NODE_VERSION}.x | bash - + apt-get install -y -qq nodejs + fi + + # Instalar pnpm (mas rapido que npm) + if ! command_exists pnpm; then + log_info "Instalando pnpm..." + npm install -g pnpm -q + fi + + # Instalar serve para frontend + if ! command_exists serve; then + log_info "Instalando serve..." + npm install -g serve -q + fi + + log_success "Node.js ${NODE_VERSION} instalado" +} + +# --------------------------------------------- +# Instalar Traccar +# --------------------------------------------- +install_traccar() { + if [[ "$SKIP_TRACCAR" == "true" ]]; then + log_warn "Saltando instalacion de Traccar (--skip-traccar)" + return + fi + + log_section "Instalando Traccar ${TRACCAR_VERSION}" + + TRACCAR_DIR="/opt/traccar" + + if [[ -d "$TRACCAR_DIR" ]] && service_active traccar; then + log_warn "Traccar ya esta instalado" + else + # Instalar Java (requerido por Traccar) + log_info "Instalando OpenJDK 17..." + apt-get install -y -qq openjdk-17-jre-headless + + # Descargar Traccar + log_info "Descargando Traccar ${TRACCAR_VERSION}..." + TRACCAR_URL="https://github.com/traccar/traccar/releases/download/v${TRACCAR_VERSION}/traccar-linux-64-${TRACCAR_VERSION}.zip" + + cd /tmp + wget -q "$TRACCAR_URL" -O traccar.zip + + # Instalar Traccar + log_info "Instalando Traccar..." + unzip -q -o traccar.zip + ./traccar.run + + rm -f traccar.zip traccar.run + fi + + log_success "Traccar instalado" +} + +# --------------------------------------------- +# Instalar MediaMTX +# --------------------------------------------- +install_mediamtx() { + log_section "Instalando MediaMTX ${MEDIAMTX_VERSION}" + + MEDIAMTX_DIR="/opt/mediamtx" + + if [[ -d "$MEDIAMTX_DIR" ]] && [[ -f "$MEDIAMTX_DIR/mediamtx" ]]; then + log_warn "MediaMTX ya esta instalado" + else + log_info "Descargando MediaMTX ${MEDIAMTX_VERSION}..." + + mkdir -p "$MEDIAMTX_DIR" + cd "$MEDIAMTX_DIR" + + ARCH=$(uname -m) + case $ARCH in + x86_64) ARCH="amd64" ;; + aarch64) ARCH="arm64v8" ;; + armv7l) ARCH="armv7" ;; + esac + + MEDIAMTX_URL="https://github.com/bluenviron/mediamtx/releases/download/v${MEDIAMTX_VERSION}/mediamtx_v${MEDIAMTX_VERSION}_linux_${ARCH}.tar.gz" + + wget -q "$MEDIAMTX_URL" -O mediamtx.tar.gz + tar -xzf mediamtx.tar.gz + rm -f mediamtx.tar.gz + + chmod +x mediamtx + fi + + log_success "MediaMTX instalado" +} + +# --------------------------------------------- +# Instalar Mosquitto MQTT +# --------------------------------------------- +install_mosquitto() { + log_section "Instalando Mosquitto MQTT" + + if command_exists mosquitto && service_active mosquitto; then + log_warn "Mosquitto ya esta instalado" + else + log_info "Instalando Mosquitto..." + apt-get install -y -qq mosquitto mosquitto-clients + + # Configuracion basica se hara despues con las credenciales + fi + + systemctl enable mosquitto + + log_success "Mosquitto instalado" +} + +# --------------------------------------------- +# Configurar base de datos +# --------------------------------------------- +configure_database() { + if [[ "$SKIP_DB" == "true" ]]; then + log_warn "Saltando configuracion de base de datos" + return + fi + + log_section "Configurando Base de Datos" + + # Generar credenciales si no existen + if [[ -z "$DB_PASSWORD" ]]; then + DB_PASSWORD=$(generate_password 24) + fi + + DB_NAME="${POSTGRES_DB:-flotillas}" + DB_USER="${POSTGRES_USER:-flotillas}" + + log_info "Creando usuario y base de datos..." + + # Crear usuario y base de datos + sudo -u postgres psql -v ON_ERROR_STOP=1 </dev/null; then + log_info "Copiando desde directorio local..." + cp -r /root/Adan/* "$INSTALL_DIR/" + else + git clone --branch "$REPO_BRANCH" "$REPO_URL" "$INSTALL_DIR" + fi + fi + + cd "$INSTALL_DIR" + + # Configurar backend + log_info "Configurando backend..." + cd "$INSTALL_DIR/backend" + + # Crear entorno virtual + python3.11 -m venv venv + source venv/bin/activate + + # Instalar dependencias + if [[ -f "requirements.txt" ]]; then + pip install -r requirements.txt -q + elif [[ -f "pyproject.toml" ]]; then + pip install -e . -q + fi + + # Instalar dependencias adicionales + pip install uvicorn[standard] gunicorn -q + + deactivate + + # Configurar frontend + log_info "Configurando frontend..." + cd "$INSTALL_DIR/frontend" + + if [[ -f "package.json" ]]; then + pnpm install --frozen-lockfile 2>/dev/null || npm install + + # Build para produccion + if [[ "$DEV_MODE" != "true" ]]; then + pnpm build 2>/dev/null || npm run build + fi + fi + + log_success "Aplicacion configurada" +} + +# --------------------------------------------- +# Generar credenciales +# --------------------------------------------- +generate_credentials() { + log_section "Generando Credenciales" + + # Generar todas las credenciales + [[ -z "$DB_PASSWORD" ]] && DB_PASSWORD=$(generate_password 24) + REDIS_PASSWORD=$(generate_password 24) + SECRET_KEY=$(generate_secret_key) + MQTT_PASSWORD=$(generate_password 16) + ADMIN_PASSWORD=$(generate_password 16) + + # Guardar en archivo seguro + cat > "$CREDENTIALS_FILE" < "$ENV_FILE" </dev/null || true + + # Reiniciar Traccar + systemctl restart traccar 2>/dev/null || true + + log_success "Traccar configurado" +} + +# --------------------------------------------- +# Configurar MediaMTX +# --------------------------------------------- +configure_mediamtx() { + log_section "Configurando MediaMTX" + + MEDIAMTX_CONFIG="/opt/mediamtx/mediamtx.yml" + + # Copiar configuracion personalizada + if [[ -f "$INSTALL_DIR/deploy/mediamtx/mediamtx.yml" ]]; then + cp "$INSTALL_DIR/deploy/mediamtx/mediamtx.yml" "$MEDIAMTX_CONFIG" + fi + + log_success "MediaMTX configurado" +} + +# --------------------------------------------- +# Configurar Mosquitto +# --------------------------------------------- +configure_mosquitto() { + log_section "Configurando Mosquitto" + + # Crear archivo de passwords + touch /etc/mosquitto/passwd + mosquitto_passwd -b /etc/mosquitto/passwd flotillas "${MQTT_PASSWORD}" + + # Configuracion + cat > /etc/mosquitto/conf.d/flotillas.conf </dev/null || true + systemctl enable flotillas-web 2>/dev/null || true + systemctl enable mediamtx 2>/dev/null || true + + # Iniciar servicios + log_info "Iniciando servicios..." + systemctl start flotillas-api 2>/dev/null || log_warn "flotillas-api no pudo iniciar (puede requerir configuracion adicional)" + systemctl start flotillas-web 2>/dev/null || log_warn "flotillas-web no pudo iniciar" + systemctl start mediamtx 2>/dev/null || log_warn "mediamtx no pudo iniciar" + + log_success "Servicios instalados" +} + +# --------------------------------------------- +# Ejecutar migraciones de BD +# --------------------------------------------- +run_migrations() { + log_section "Ejecutando Migraciones" + + cd "$INSTALL_DIR/backend" + source venv/bin/activate + + # Cargar variables de entorno + export $(grep -v '^#' "$INSTALL_DIR/.env" | xargs) + + # Ejecutar migraciones con Alembic si existe + if [[ -d "alembic" ]]; then + log_info "Ejecutando migraciones Alembic..." + alembic upgrade head 2>/dev/null || log_warn "Migraciones fallaron o no hay migraciones pendientes" + fi + + # Ejecutar init.sql si existe y no hay migraciones + if [[ ! -d "alembic" ]] && [[ -f "$INSTALL_DIR/deploy/postgres/init.sql" ]]; then + log_info "Ejecutando script init.sql..." + PGPASSWORD="${DB_PASSWORD}" psql -h localhost -U flotillas -d flotillas -f "$INSTALL_DIR/deploy/postgres/init.sql" || true + fi + + deactivate + + log_success "Migraciones completadas" +} + +# --------------------------------------------- +# Configurar firewall +# --------------------------------------------- +configure_firewall() { + log_section "Configurando Firewall" + + # Resetear UFW + ufw --force reset + + # Politica por defecto + ufw default deny incoming + ufw default allow outgoing + + # SSH (siempre permitir) + ufw allow ssh + + # Puerto GPS (Traccar) - UNICO PUERTO PUBLICO + ufw allow ${TRACCAR_PORT}/tcp comment 'Traccar GPS' + + # Puertos internos (solo localhost para desarrollo) + # En produccion, todo va por Cloudflare Tunnel + if [[ "$DEV_MODE" == "true" ]]; then + ufw allow ${API_PORT}/tcp comment 'API (dev)' + ufw allow ${FRONTEND_PORT}/tcp comment 'Frontend (dev)' + fi + + # Habilitar firewall + ufw --force enable + + log_success "Firewall configurado - Solo puerto ${TRACCAR_PORT} publico" +} + +# --------------------------------------------- +# Configurar fail2ban +# --------------------------------------------- +configure_fail2ban() { + log_section "Configurando Fail2ban" + + cat > /etc/fail2ban/jail.local < /etc/logrotate.d/flotillas < /dev/null 2>&1 || true + endscript +} +EOF + + mkdir -p /var/log/flotillas + + log_success "Logrotate configurado" +} + +# --------------------------------------------- +# Configurar cron para backups +# --------------------------------------------- +configure_cron() { + log_section "Configurando Backups Automaticos" + + # Crear cron para backup diario a las 3 AM + CRON_JOB="0 3 * * * $INSTALL_DIR/deploy/scripts/backup.sh >> /var/log/flotillas/backup.log 2>&1" + + # Agregar si no existe + (crontab -l 2>/dev/null | grep -v "backup.sh"; echo "$CRON_JOB") | crontab - + + log_success "Backup diario configurado (3 AM)" +} + +# --------------------------------------------- +# Mostrar resumen final +# --------------------------------------------- +show_summary() { + log_section "Instalacion Completada" + + echo "" + echo -e "${GREEN}============================================${NC}" + echo -e "${GREEN} SISTEMA DE FLOTILLAS INSTALADO ${NC}" + echo -e "${GREEN}============================================${NC}" + echo "" + echo -e "${BLUE}Servicios:${NC}" + echo " - API Backend: http://localhost:${API_PORT}" + echo " - Frontend: http://localhost:${FRONTEND_PORT}" + echo " - Traccar GPS: puerto ${TRACCAR_PORT}" + echo " - MediaMTX RTSP: rtsp://localhost:8554" + echo " - MediaMTX WebRTC: http://localhost:8889" + echo "" + echo -e "${BLUE}Base de Datos:${NC}" + echo " - PostgreSQL: localhost:5432" + echo " - Database: flotillas" + echo " - Usuario: flotillas" + echo "" + echo -e "${BLUE}Credenciales:${NC}" + echo " - Guardadas en: ${CREDENTIALS_FILE}" + echo "" + echo -e "${YELLOW}IMPORTANTE:${NC}" + echo " 1. Guarda las credenciales en un lugar seguro" + echo " 2. Configura Cloudflare Tunnel para acceso externo" + echo " 3. El unico puerto publico es ${TRACCAR_PORT} (GPS)" + echo "" + echo -e "${BLUE}Comandos utiles:${NC}" + echo " - Ver logs API: journalctl -u flotillas-api -f" + echo " - Reiniciar API: systemctl restart flotillas-api" + echo " - Backup manual: ${INSTALL_DIR}/deploy/scripts/backup.sh" + echo " - Actualizar: ${INSTALL_DIR}/deploy/scripts/update.sh" + echo "" + echo -e "${GREEN}============================================${NC}" + + # Mostrar credenciales en pantalla + echo "" + echo -e "${YELLOW}=== CREDENCIALES (guardar y eliminar archivo) ===${NC}" + cat "$CREDENTIALS_FILE" + echo "" +} + +# --------------------------------------------- +# Main +# --------------------------------------------- +main() { + parse_args "$@" + + echo "" + echo -e "${GREEN}============================================${NC}" + echo -e "${GREEN} INSTALADOR SISTEMA DE FLOTILLAS ${NC}" + echo -e "${GREEN}============================================${NC}" + echo "" + echo "Directorio instalacion: $INSTALL_DIR" + echo "Modo desarrollo: $DEV_MODE" + echo "Saltar DB: $SKIP_DB" + echo "Saltar Traccar: $SKIP_TRACCAR" + echo "" + + # Confirmar instalacion + read -p "Continuar con la instalacion? (Y/n) " -n 1 -r + echo + if [[ $REPLY =~ ^[Nn]$ ]]; then + echo "Instalacion cancelada" + exit 0 + fi + + # Ejecutar pasos de instalacion + check_requirements + update_system + install_postgresql + install_redis + install_python + install_nodejs + install_traccar + install_mediamtx + install_mosquitto + + generate_credentials + + configure_database + setup_application + create_env_file + + configure_traccar + configure_mediamtx + configure_mosquitto + configure_redis + + run_migrations + install_services + + configure_firewall + configure_fail2ban + configure_logrotate + configure_cron + + show_summary +} + +# Ejecutar +main "$@" diff --git a/deploy/scripts/logs.sh b/deploy/scripts/logs.sh new file mode 100644 index 0000000..0903353 --- /dev/null +++ b/deploy/scripts/logs.sh @@ -0,0 +1,133 @@ +#!/bin/bash +# ============================================ +# Sistema de Flotillas - Visor de Logs +# ============================================ +# Muestra logs de los diferentes servicios +# +# Uso: ./logs.sh [servicio] [--follow] [--lines N] +# +# Servicios: api, web, traccar, mediamtx, postgres, redis, all +# ============================================ + +# Variables +LINES="${LINES:-100}" +FOLLOW=false +SERVICE="api" + +# Colores +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' + +# --------------------------------------------- +# Parsear argumentos +# --------------------------------------------- +while [[ $# -gt 0 ]]; do + case $1 in + api|web|traccar|mediamtx|postgres|redis|mosquitto|all) + SERVICE="$1" + shift + ;; + -f|--follow) + FOLLOW=true + shift + ;; + -n|--lines) + LINES="$2" + shift 2 + ;; + --help|-h) + echo "Sistema de Flotillas - Visor de Logs" + echo "" + echo "Uso: $0 [servicio] [opciones]" + echo "" + echo "Servicios:" + echo " api - Backend FastAPI" + echo " web - Frontend" + echo " traccar - Traccar GPS" + echo " mediamtx - Video streaming" + echo " postgres - Base de datos" + echo " redis - Cache" + echo " mosquitto - MQTT" + echo " all - Todos los servicios" + echo "" + echo "Opciones:" + echo " -f, --follow Seguir logs en tiempo real" + echo " -n, --lines N Numero de lineas (default: 100)" + echo "" + echo "Ejemplos:" + echo " $0 api -f # Seguir logs de API" + echo " $0 traccar -n 500 # Ultimas 500 lineas de Traccar" + echo " $0 all -f # Todos los logs en tiempo real" + exit 0 + ;; + *) + echo "Opcion desconocida: $1" + exit 1 + ;; + esac +done + +# --------------------------------------------- +# Mostrar logs +# --------------------------------------------- +show_logs() { + local unit="$1" + local name="$2" + + echo -e "${BLUE}=== Logs de $name ===${NC}" + + local cmd="journalctl -u $unit -n $LINES --no-pager" + + if [[ "$FOLLOW" == "true" ]]; then + cmd="journalctl -u $unit -f" + fi + + $cmd 2>/dev/null || echo "Servicio no disponible o sin logs" + echo "" +} + +case $SERVICE in + api) + show_logs "flotillas-api" "API Backend" + ;; + web) + show_logs "flotillas-web" "Frontend" + ;; + traccar) + show_logs "traccar" "Traccar GPS" + # Tambien mostrar log de archivo si existe + if [[ -f "/opt/traccar/logs/tracker-server.log" ]]; then + echo -e "${BLUE}=== Log de archivo Traccar ===${NC}" + tail -n $LINES /opt/traccar/logs/tracker-server.log + fi + ;; + mediamtx) + show_logs "mediamtx" "MediaMTX" + ;; + postgres) + show_logs "postgresql" "PostgreSQL" + ;; + redis) + show_logs "redis-server" "Redis" + ;; + mosquitto) + show_logs "mosquitto" "Mosquitto MQTT" + ;; + all) + if [[ "$FOLLOW" == "true" ]]; then + echo "Mostrando todos los logs en tiempo real..." + echo "Presiona Ctrl+C para salir" + echo "" + journalctl -u flotillas-api -u flotillas-web -u traccar -u mediamtx -u mosquitto -f + else + show_logs "flotillas-api" "API Backend" + show_logs "flotillas-web" "Frontend" + show_logs "traccar" "Traccar GPS" + show_logs "mediamtx" "MediaMTX" + show_logs "mosquitto" "Mosquitto MQTT" + fi + ;; +esac diff --git a/deploy/scripts/restore.sh b/deploy/scripts/restore.sh new file mode 100644 index 0000000..f401f54 --- /dev/null +++ b/deploy/scripts/restore.sh @@ -0,0 +1,549 @@ +#!/bin/bash +# ============================================ +# Sistema de Flotillas - Script de Restauracion +# ============================================ +# Restaura backups de base de datos y configuracion +# +# Uso: ./restore.sh [--db backup.sql.gz] [--config config.tar.gz] [--list] +# +# Opciones: +# --db FILE Restaurar backup de base de datos +# --config FILE Restaurar backup de configuracion +# --list Listar backups disponibles +# --latest Restaurar el backup mas reciente +# --date YYYYMMDD Restaurar backup de fecha especifica +# ============================================ + +set -e +set -o pipefail + +# --------------------------------------------- +# Colores +# --------------------------------------------- +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' + +# --------------------------------------------- +# Variables +# --------------------------------------------- +INSTALL_DIR="${INSTALL_DIR:-/opt/flotillas}" +BACKUP_DIR="${BACKUP_DIR:-/var/backups/flotillas}" + +# Cargar variables de entorno +if [[ -f "$INSTALL_DIR/.env" ]]; then + export $(grep -v '^#' "$INSTALL_DIR/.env" | xargs) +fi + +# Base de datos +DB_HOST="${POSTGRES_HOST:-localhost}" +DB_PORT="${POSTGRES_PORT:-5432}" +DB_NAME="${POSTGRES_DB:-flotillas}" +DB_USER="${POSTGRES_USER:-flotillas}" +DB_PASSWORD="${POSTGRES_PASSWORD:-}" + +# Opciones +DB_BACKUP="" +CONFIG_BACKUP="" +LIST_ONLY=false +USE_LATEST=false +RESTORE_DATE="" + +# --------------------------------------------- +# Funciones +# --------------------------------------------- +log_info() { + echo -e "${BLUE}[INFO]${NC} $1" +} + +log_success() { + echo -e "${GREEN}[OK]${NC} $1" +} + +log_warn() { + echo -e "${YELLOW}[WARN]${NC} $1" +} + +log_error() { + echo -e "${RED}[ERROR]${NC} $1" +} + +# --------------------------------------------- +# Parsear argumentos +# --------------------------------------------- +parse_args() { + while [[ $# -gt 0 ]]; do + case $1 in + --db) + DB_BACKUP="$2" + shift 2 + ;; + --config) + CONFIG_BACKUP="$2" + shift 2 + ;; + --list) + LIST_ONLY=true + shift + ;; + --latest) + USE_LATEST=true + shift + ;; + --date) + RESTORE_DATE="$2" + shift 2 + ;; + --help|-h) + show_help + exit 0 + ;; + *) + log_error "Opcion desconocida: $1" + exit 1 + ;; + esac + done +} + +show_help() { + echo "Sistema de Flotillas - Restauracion de Backup" + echo "" + echo "Uso: $0 [opciones]" + echo "" + echo "Opciones:" + echo " --db FILE Restaurar backup de base de datos" + echo " --config FILE Restaurar backup de configuracion" + echo " --list Listar backups disponibles" + echo " --latest Restaurar el backup mas reciente" + echo " --date YYYYMMDD Restaurar backup de fecha especifica" + echo "" + echo "Ejemplos:" + echo " $0 --list" + echo " $0 --latest" + echo " $0 --date 20240115" + echo " $0 --db /var/backups/flotillas/daily/flotillas_20240115_030000_db.sql.gz" +} + +# --------------------------------------------- +# Listar backups disponibles +# --------------------------------------------- +list_backups() { + echo "" + echo -e "${BLUE}========================================${NC}" + echo -e "${BLUE} BACKUPS DISPONIBLES${NC}" + echo -e "${BLUE}========================================${NC}" + echo "" + + if [[ ! -d "$BACKUP_DIR/daily" ]]; then + log_warn "No hay backups disponibles" + return + fi + + echo -e "${GREEN}Base de Datos:${NC}" + echo "---------------------------------------------" + printf "%-40s %10s %s\n" "Archivo" "Tamanio" "Fecha" + echo "---------------------------------------------" + + for file in $(ls -t "$BACKUP_DIR/daily"/*_db.sql.gz 2>/dev/null); do + local name=$(basename "$file") + local size=$(du -h "$file" | cut -f1) + local date=$(stat -c %y "$file" | cut -d' ' -f1) + printf "%-40s %10s %s\n" "$name" "$size" "$date" + done + + echo "" + echo -e "${GREEN}Configuracion:${NC}" + echo "---------------------------------------------" + + for file in $(ls -t "$BACKUP_DIR/daily"/*_config.tar.gz 2>/dev/null); do + local name=$(basename "$file") + local size=$(du -h "$file" | cut -f1) + local date=$(stat -c %y "$file" | cut -d' ' -f1) + printf "%-40s %10s %s\n" "$name" "$size" "$date" + done + + echo "" + echo -e "${GREEN}Backups Completos:${NC}" + echo "---------------------------------------------" + + for file in $(ls -t "$BACKUP_DIR/daily"/*_full.tar.gz 2>/dev/null); do + local name=$(basename "$file") + local size=$(du -h "$file" | cut -f1) + local date=$(stat -c %y "$file" | cut -d' ' -f1) + printf "%-40s %10s %s\n" "$name" "$size" "$date" + done + + echo "" +} + +# --------------------------------------------- +# Encontrar backup mas reciente +# --------------------------------------------- +find_latest_backup() { + local type="$1" # db, config, full + + local pattern="*_${type}.*gz" + + local latest=$(ls -t "$BACKUP_DIR/daily"/$pattern 2>/dev/null | head -1) + + if [[ -z "$latest" ]]; then + log_error "No se encontro backup de tipo: $type" + return 1 + fi + + echo "$latest" +} + +# --------------------------------------------- +# Encontrar backup por fecha +# --------------------------------------------- +find_backup_by_date() { + local type="$1" + local date="$2" + + local pattern="flotillas_${date}*_${type}.*gz" + + local found=$(ls -t "$BACKUP_DIR/daily"/$pattern 2>/dev/null | head -1) + + if [[ -z "$found" ]]; then + log_error "No se encontro backup de tipo '$type' para fecha: $date" + return 1 + fi + + echo "$found" +} + +# --------------------------------------------- +# Detener servicios +# --------------------------------------------- +stop_services() { + log_info "Deteniendo servicios..." + + systemctl stop flotillas-api 2>/dev/null || true + systemctl stop flotillas-web 2>/dev/null || true + + # Esperar a que se detengan + sleep 2 + + log_success "Servicios detenidos" +} + +# --------------------------------------------- +# Iniciar servicios +# --------------------------------------------- +start_services() { + log_info "Iniciando servicios..." + + systemctl start flotillas-api 2>/dev/null || true + systemctl start flotillas-web 2>/dev/null || true + + log_success "Servicios iniciados" +} + +# --------------------------------------------- +# Restaurar base de datos +# --------------------------------------------- +restore_database() { + local backup_file="$1" + + if [[ ! -f "$backup_file" ]]; then + log_error "Archivo no encontrado: $backup_file" + return 1 + fi + + log_info "Restaurando base de datos desde: $(basename "$backup_file")" + + # Verificar integridad + log_info "Verificando integridad del archivo..." + if ! gzip -t "$backup_file" 2>/dev/null; then + log_error "El archivo de backup esta corrupto" + return 1 + fi + + # Confirmar + echo "" + echo -e "${YELLOW}ADVERTENCIA: Esto reemplazara TODOS los datos actuales${NC}" + echo -e "${YELLOW}Base de datos: ${DB_NAME}${NC}" + echo "" + read -p "Continuar? (escribir 'SI' para confirmar): " confirm + + if [[ "$confirm" != "SI" ]]; then + log_warn "Restauracion cancelada" + return 1 + fi + + stop_services + + # Exportar password + export PGPASSWORD="$DB_PASSWORD" + + # Crear backup de seguridad antes de restaurar + log_info "Creando backup de seguridad..." + local safety_backup="$BACKUP_DIR/temp/pre_restore_$(date +%Y%m%d_%H%M%S).sql.gz" + mkdir -p "$BACKUP_DIR/temp" + + pg_dump -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" 2>/dev/null | gzip > "$safety_backup" || true + + log_info "Backup de seguridad: $safety_backup" + + # Terminar conexiones activas + log_info "Cerrando conexiones activas..." + psql -h "$DB_HOST" -p "$DB_PORT" -U postgres -c " + SELECT pg_terminate_backend(pid) + FROM pg_stat_activity + WHERE datname = '${DB_NAME}' AND pid <> pg_backend_pid(); + " 2>/dev/null || true + + # Recrear base de datos + log_info "Recreando base de datos..." + + # Eliminar y recrear BD + psql -h "$DB_HOST" -p "$DB_PORT" -U postgres </dev/null; then + log_error "El archivo de backup esta corrupto" + return 1 + fi + + # Confirmar + echo "" + echo -e "${YELLOW}ADVERTENCIA: Esto sobrescribira archivos de configuracion${NC}" + echo "" + read -p "Continuar? (y/N): " -n 1 -r + echo + + if [[ ! $REPLY =~ ^[Yy]$ ]]; then + log_warn "Restauracion cancelada" + return 1 + fi + + stop_services + + # Crear directorio temporal + local temp_dir=$(mktemp -d) + + # Extraer + log_info "Extrayendo configuracion..." + tar -xzf "$backup_file" -C "$temp_dir" + + # Restaurar archivos + log_info "Restaurando archivos..." + + # .env + if [[ -f "$temp_dir$INSTALL_DIR/.env" ]]; then + cp "$temp_dir$INSTALL_DIR/.env" "$INSTALL_DIR/.env" + log_info "Restaurado: .env" + fi + + # Traccar + if [[ -f "$temp_dir/opt/traccar/conf/traccar.xml" ]]; then + cp "$temp_dir/opt/traccar/conf/traccar.xml" /opt/traccar/conf/traccar.xml + log_info "Restaurado: traccar.xml" + fi + + # MediaMTX + if [[ -f "$temp_dir/opt/mediamtx/mediamtx.yml" ]]; then + cp "$temp_dir/opt/mediamtx/mediamtx.yml" /opt/mediamtx/mediamtx.yml + log_info "Restaurado: mediamtx.yml" + fi + + # Servicios systemd + for service in $temp_dir/etc/systemd/system/flotillas-*.service; do + if [[ -f "$service" ]]; then + cp "$service" /etc/systemd/system/ + log_info "Restaurado: $(basename "$service")" + fi + done + + # Recargar systemd + systemctl daemon-reload + + # Limpiar + rm -rf "$temp_dir" + + log_success "Configuracion restaurada" + + start_services +} + +# --------------------------------------------- +# Restaurar backup completo +# --------------------------------------------- +restore_full() { + local backup_file="$1" + + if [[ ! -f "$backup_file" ]]; then + log_error "Archivo no encontrado: $backup_file" + return 1 + fi + + log_info "Restaurando backup completo desde: $(basename "$backup_file")" + + echo "" + echo -e "${RED}ADVERTENCIA: Esto reemplazara TODO el directorio de la aplicacion${NC}" + echo "" + read -p "Continuar? (escribir 'SI' para confirmar): " confirm + + if [[ "$confirm" != "SI" ]]; then + log_warn "Restauracion cancelada" + return 1 + fi + + stop_services + + # Backup actual + log_info "Respaldando instalacion actual..." + local current_backup="$BACKUP_DIR/temp/pre_restore_full_$(date +%Y%m%d_%H%M%S).tar.gz" + tar -czf "$current_backup" "$INSTALL_DIR" 2>/dev/null || true + + # Extraer + log_info "Extrayendo backup completo..." + tar -xzf "$backup_file" -C / + + log_success "Backup completo restaurado" + + # Reinstalar dependencias + log_info "Reinstalando dependencias..." + + cd "$INSTALL_DIR/backend" + source venv/bin/activate + pip install -r requirements.txt -q 2>/dev/null || pip install -e . -q + deactivate + + cd "$INSTALL_DIR/frontend" + npm install 2>/dev/null || true + + start_services +} + +# --------------------------------------------- +# Main +# --------------------------------------------- +main() { + parse_args "$@" + + # Solo listar + if [[ "$LIST_ONLY" == "true" ]]; then + list_backups + exit 0 + fi + + # Modo interactivo si no se especificaron opciones + if [[ -z "$DB_BACKUP" ]] && [[ -z "$CONFIG_BACKUP" ]] && [[ "$USE_LATEST" != "true" ]] && [[ -z "$RESTORE_DATE" ]]; then + echo "" + echo "Sistema de Flotillas - Restauracion" + echo "====================================" + echo "" + echo "Selecciona una opcion:" + echo " 1) Restaurar backup mas reciente (DB + Config)" + echo " 2) Restaurar solo base de datos" + echo " 3) Restaurar solo configuracion" + echo " 4) Listar backups disponibles" + echo " 5) Cancelar" + echo "" + read -p "Opcion: " option + + case $option in + 1) + USE_LATEST=true + ;; + 2) + list_backups + echo "" + read -p "Ingresa ruta del archivo de BD: " DB_BACKUP + ;; + 3) + list_backups + echo "" + read -p "Ingresa ruta del archivo de config: " CONFIG_BACKUP + ;; + 4) + list_backups + exit 0 + ;; + *) + echo "Cancelado" + exit 0 + ;; + esac + fi + + # Restaurar por fecha + if [[ -n "$RESTORE_DATE" ]]; then + log_info "Buscando backups para fecha: $RESTORE_DATE" + + DB_BACKUP=$(find_backup_by_date "db.sql" "$RESTORE_DATE") || exit 1 + CONFIG_BACKUP=$(find_backup_by_date "config.tar" "$RESTORE_DATE") || true + fi + + # Restaurar mas reciente + if [[ "$USE_LATEST" == "true" ]]; then + log_info "Buscando backups mas recientes..." + + DB_BACKUP=$(find_latest_backup "db.sql") || exit 1 + CONFIG_BACKUP=$(find_latest_backup "config.tar") || true + fi + + # Ejecutar restauraciones + if [[ -n "$DB_BACKUP" ]]; then + restore_database "$DB_BACKUP" + fi + + if [[ -n "$CONFIG_BACKUP" ]]; then + restore_config "$CONFIG_BACKUP" + fi + + echo "" + log_success "==========================================" + log_success "RESTAURACION COMPLETADA" + log_success "==========================================" + echo "" + echo "Verifica que los servicios esten funcionando:" + echo " systemctl status flotillas-api" + echo " systemctl status flotillas-web" + echo "" +} + +# Ejecutar +main "$@" diff --git a/deploy/scripts/status.sh b/deploy/scripts/status.sh new file mode 100644 index 0000000..adcc8ef --- /dev/null +++ b/deploy/scripts/status.sh @@ -0,0 +1,214 @@ +#!/bin/bash +# ============================================ +# Sistema de Flotillas - Estado del Sistema +# ============================================ +# Muestra informacion completa del estado +# ============================================ + +# Colores +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +CYAN='\033[0;36m' +NC='\033[0m' + +INSTALL_DIR="${INSTALL_DIR:-/opt/flotillas}" + +# Cargar variables +if [[ -f "$INSTALL_DIR/.env" ]]; then + export $(grep -v '^#' "$INSTALL_DIR/.env" | xargs) +fi + +clear + +echo "" +echo -e "${CYAN}╔══════════════════════════════════════════════════════════════╗${NC}" +echo -e "${CYAN}║ SISTEMA DE FLOTILLAS - ESTADO DEL SISTEMA ║${NC}" +echo -e "${CYAN}╚══════════════════════════════════════════════════════════════╝${NC}" +echo "" + +# --------------------------------------------- +# Informacion del servidor +# --------------------------------------------- +echo -e "${BLUE}┌─ Servidor ─────────────────────────────────────────────────┐${NC}" +echo -e "│ Hostname: $(hostname)" +echo -e "│ IP: $(hostname -I | awk '{print $1}')" +echo -e "│ Sistema: $(lsb_release -ds 2>/dev/null || cat /etc/os-release | grep PRETTY_NAME | cut -d'"' -f2)" +echo -e "│ Kernel: $(uname -r)" +echo -e "│ Uptime: $(uptime -p)" +echo -e "${BLUE}└─────────────────────────────────────────────────────────────┘${NC}" +echo "" + +# --------------------------------------------- +# Recursos del sistema +# --------------------------------------------- +echo -e "${BLUE}┌─ Recursos ─────────────────────────────────────────────────┐${NC}" + +# CPU +CPU_USAGE=$(top -bn1 | grep "Cpu(s)" | awk '{print $2}' | cut -d'%' -f1) +echo -e "│ CPU: ${CPU_USAGE}% usado" + +# Memoria +MEM_TOTAL=$(free -h | awk '/^Mem:/{print $2}') +MEM_USED=$(free -h | awk '/^Mem:/{print $3}') +MEM_PERCENT=$(free | awk '/^Mem:/{printf "%.1f", $3/$2*100}') +echo -e "│ Memoria: ${MEM_USED} / ${MEM_TOTAL} (${MEM_PERCENT}%)" + +# Disco +DISK_USAGE=$(df -h / | awk 'NR==2{print $3 " / " $2 " (" $5 ")"}') +echo -e "│ Disco: ${DISK_USAGE}" + +echo -e "${BLUE}└─────────────────────────────────────────────────────────────┘${NC}" +echo "" + +# --------------------------------------------- +# Servicios +# --------------------------------------------- +echo -e "${BLUE}┌─ Servicios ────────────────────────────────────────────────┐${NC}" + +check_service() { + local service="$1" + local name="$2" + local port="$3" + + if systemctl is-active --quiet "$service" 2>/dev/null; then + local status="${GREEN}ACTIVO${NC}" + if [[ -n "$port" ]]; then + status="$status (puerto $port)" + fi + else + local status="${RED}INACTIVO${NC}" + fi + + printf "│ %-14s %s\n" "$name:" "$status" +} + +check_service "flotillas-api" "API Backend" "${API_PORT:-8000}" +check_service "flotillas-web" "Frontend" "${FRONTEND_PORT:-3000}" +check_service "postgresql" "PostgreSQL" "5432" +check_service "redis-server" "Redis" "6379" +check_service "traccar" "Traccar GPS" "${TRACCAR_PORT:-5055}" +check_service "mediamtx" "MediaMTX" "8554" +check_service "mosquitto" "MQTT" "1883" +check_service "cloudflared" "Cloudflare" "-" + +echo -e "${BLUE}└─────────────────────────────────────────────────────────────┘${NC}" +echo "" + +# --------------------------------------------- +# Base de datos +# --------------------------------------------- +echo -e "${BLUE}┌─ Base de Datos ────────────────────────────────────────────┐${NC}" + +if systemctl is-active --quiet postgresql; then + # Tamanio de BD + DB_SIZE=$(PGPASSWORD="${POSTGRES_PASSWORD}" psql -h localhost -U "${POSTGRES_USER:-flotillas}" -d "${POSTGRES_DB:-flotillas}" -t -c "SELECT pg_size_pretty(pg_database_size(current_database()));" 2>/dev/null | xargs) + echo -e "│ Tamanio BD: ${DB_SIZE:-N/A}" + + # Conexiones activas + CONNECTIONS=$(PGPASSWORD="${POSTGRES_PASSWORD}" psql -h localhost -U "${POSTGRES_USER:-flotillas}" -d "${POSTGRES_DB:-flotillas}" -t -c "SELECT count(*) FROM pg_stat_activity WHERE datname = current_database();" 2>/dev/null | xargs) + echo -e "│ Conexiones: ${CONNECTIONS:-N/A} activas" + + # Posiciones (si existe la tabla) + POSITIONS=$(PGPASSWORD="${POSTGRES_PASSWORD}" psql -h localhost -U "${POSTGRES_USER:-flotillas}" -d "${POSTGRES_DB:-flotillas}" -t -c "SELECT COUNT(*) FROM positions;" 2>/dev/null | xargs) + if [[ -n "$POSITIONS" ]]; then + echo -e "│ Posiciones: ${POSITIONS} registros" + fi +else + echo -e "│ ${RED}PostgreSQL no esta activo${NC}" +fi + +echo -e "${BLUE}└─────────────────────────────────────────────────────────────┘${NC}" +echo "" + +# --------------------------------------------- +# Redis +# --------------------------------------------- +echo -e "${BLUE}┌─ Redis Cache ──────────────────────────────────────────────┐${NC}" + +if systemctl is-active --quiet redis-server; then + REDIS_AUTH="" + [[ -n "${REDIS_PASSWORD}" ]] && REDIS_AUTH="-a ${REDIS_PASSWORD}" + + REDIS_KEYS=$(redis-cli $REDIS_AUTH DBSIZE 2>/dev/null | awk '{print $2}') + REDIS_MEM=$(redis-cli $REDIS_AUTH INFO memory 2>/dev/null | grep used_memory_human | cut -d: -f2 | tr -d '\r') + + echo -e "│ Keys: ${REDIS_KEYS:-N/A}" + echo -e "│ Memoria: ${REDIS_MEM:-N/A}" +else + echo -e "│ ${RED}Redis no esta activo${NC}" +fi + +echo -e "${BLUE}└─────────────────────────────────────────────────────────────┘${NC}" +echo "" + +# --------------------------------------------- +# Unidades GPS activas +# --------------------------------------------- +echo -e "${BLUE}┌─ GPS / Unidades ───────────────────────────────────────────┐${NC}" + +if systemctl is-active --quiet postgresql; then + # Total de unidades + TOTAL_UNITS=$(PGPASSWORD="${POSTGRES_PASSWORD}" psql -h localhost -U "${POSTGRES_USER:-flotillas}" -d "${POSTGRES_DB:-flotillas}" -t -c "SELECT COUNT(*) FROM units;" 2>/dev/null | xargs) + + # Unidades activas (con posicion en ultimos 5 min) + ACTIVE_UNITS=$(PGPASSWORD="${POSTGRES_PASSWORD}" psql -h localhost -U "${POSTGRES_USER:-flotillas}" -d "${POSTGRES_DB:-flotillas}" -t -c "SELECT COUNT(DISTINCT unit_id) FROM positions WHERE device_time > NOW() - INTERVAL '5 minutes';" 2>/dev/null | xargs) + + echo -e "│ Total: ${TOTAL_UNITS:-0} unidades" + echo -e "│ Activas: ${ACTIVE_UNITS:-0} (ultimo 5 min)" +else + echo -e "│ ${YELLOW}No se puede obtener info de unidades${NC}" +fi + +echo -e "${BLUE}└─────────────────────────────────────────────────────────────┘${NC}" +echo "" + +# --------------------------------------------- +# Streaming +# --------------------------------------------- +echo -e "${BLUE}┌─ Video Streaming ──────────────────────────────────────────┐${NC}" + +if systemctl is-active --quiet mediamtx; then + # Obtener streams activos de MediaMTX API + STREAMS=$(curl -s http://localhost:9997/v3/paths/list 2>/dev/null | jq '.items | length' 2>/dev/null) + echo -e "│ Streams: ${STREAMS:-0} activos" + + # Endpoints + echo -e "│ RTSP: rtsp://localhost:8554" + echo -e "│ WebRTC: http://localhost:8889" + echo -e "│ HLS: http://localhost:8888" +else + echo -e "│ ${RED}MediaMTX no esta activo${NC}" +fi + +echo -e "${BLUE}└─────────────────────────────────────────────────────────────┘${NC}" +echo "" + +# --------------------------------------------- +# Ultimos errores +# --------------------------------------------- +echo -e "${BLUE}┌─ Ultimos Errores (API) ────────────────────────────────────┐${NC}" + +ERRORS=$(journalctl -u flotillas-api --since "1 hour ago" -p err --no-pager -q 2>/dev/null | tail -3) + +if [[ -z "$ERRORS" ]]; then + echo -e "│ ${GREEN}Sin errores en la ultima hora${NC}" +else + echo "$ERRORS" | while read line; do + echo "│ $line" | cut -c1-65 + done +fi + +echo -e "${BLUE}└─────────────────────────────────────────────────────────────┘${NC}" +echo "" + +# --------------------------------------------- +# Comandos utiles +# --------------------------------------------- +echo -e "${YELLOW}Comandos utiles:${NC}" +echo " ./health-check.sh - Verificar salud del sistema" +echo " ./logs.sh api -f - Ver logs de API en tiempo real" +echo " ./backup.sh - Crear backup" +echo " ./update.sh - Actualizar sistema" +echo "" diff --git a/deploy/scripts/update.sh b/deploy/scripts/update.sh new file mode 100644 index 0000000..4f15ccf --- /dev/null +++ b/deploy/scripts/update.sh @@ -0,0 +1,485 @@ +#!/bin/bash +# ============================================ +# Sistema de Flotillas - Script de Actualizacion +# ============================================ +# Actualiza la aplicacion a la ultima version +# +# Uso: ./update.sh [--branch BRANCH] [--force] [--no-backup] +# +# Opciones: +# --branch Branch a usar (default: main) +# --force Forzar actualizacion (descartar cambios locales) +# --no-backup No crear backup antes de actualizar +# --backend Solo actualizar backend +# --frontend Solo actualizar frontend +# ============================================ + +set -e +set -o pipefail + +# --------------------------------------------- +# Colores +# --------------------------------------------- +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' + +# --------------------------------------------- +# Variables +# --------------------------------------------- +INSTALL_DIR="${INSTALL_DIR:-/opt/flotillas}" +REPO_BRANCH="${REPO_BRANCH:-main}" +BACKUP_BEFORE_UPDATE=true +FORCE_UPDATE=false +UPDATE_BACKEND=true +UPDATE_FRONTEND=true + +# Cargar variables de entorno +if [[ -f "$INSTALL_DIR/.env" ]]; then + export $(grep -v '^#' "$INSTALL_DIR/.env" | xargs) +fi + +# --------------------------------------------- +# Funciones +# --------------------------------------------- +log_info() { + echo -e "${BLUE}[$(date '+%H:%M:%S')] [INFO]${NC} $1" +} + +log_success() { + echo -e "${GREEN}[$(date '+%H:%M:%S')] [OK]${NC} $1" +} + +log_warn() { + echo -e "${YELLOW}[$(date '+%H:%M:%S')] [WARN]${NC} $1" +} + +log_error() { + echo -e "${RED}[$(date '+%H:%M:%S')] [ERROR]${NC} $1" +} + +# --------------------------------------------- +# Parsear argumentos +# --------------------------------------------- +parse_args() { + while [[ $# -gt 0 ]]; do + case $1 in + --branch) + REPO_BRANCH="$2" + shift 2 + ;; + --force) + FORCE_UPDATE=true + shift + ;; + --no-backup) + BACKUP_BEFORE_UPDATE=false + shift + ;; + --backend) + UPDATE_FRONTEND=false + shift + ;; + --frontend) + UPDATE_BACKEND=false + shift + ;; + --help|-h) + echo "Uso: $0 [--branch BRANCH] [--force] [--no-backup]" + echo "" + echo "Opciones:" + echo " --branch Branch a usar (default: main)" + echo " --force Forzar (descartar cambios locales)" + echo " --no-backup No crear backup antes de actualizar" + echo " --backend Solo actualizar backend" + echo " --frontend Solo actualizar frontend" + exit 0 + ;; + *) + log_error "Opcion desconocida: $1" + exit 1 + ;; + esac + done +} + +# --------------------------------------------- +# Verificar requisitos +# --------------------------------------------- +check_requirements() { + log_info "Verificando requisitos..." + + # Verificar directorio + if [[ ! -d "$INSTALL_DIR" ]]; then + log_error "Directorio de instalacion no encontrado: $INSTALL_DIR" + exit 1 + fi + + # Verificar git + if [[ ! -d "$INSTALL_DIR/.git" ]]; then + log_error "No es un repositorio git" + exit 1 + fi + + # Verificar conexion + if ! ping -c 1 github.com &> /dev/null; then + log_error "Sin conexion a internet" + exit 1 + fi + + log_success "Requisitos OK" +} + +# --------------------------------------------- +# Crear backup +# --------------------------------------------- +create_backup() { + if [[ "$BACKUP_BEFORE_UPDATE" != "true" ]]; then + log_warn "Saltando backup (--no-backup)" + return + fi + + log_info "Creando backup antes de actualizar..." + + if [[ -f "$INSTALL_DIR/deploy/scripts/backup.sh" ]]; then + bash "$INSTALL_DIR/deploy/scripts/backup.sh" || log_warn "Backup fallo, continuando..." + else + log_warn "Script de backup no encontrado" + fi +} + +# --------------------------------------------- +# Obtener version actual +# --------------------------------------------- +get_current_version() { + cd "$INSTALL_DIR" + + # Intentar obtener version de git tag + local version=$(git describe --tags --always 2>/dev/null || echo "unknown") + + # O del commit + local commit=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown") + + echo "${version} (${commit})" +} + +# --------------------------------------------- +# Verificar cambios locales +# --------------------------------------------- +check_local_changes() { + cd "$INSTALL_DIR" + + if [[ -n $(git status --porcelain) ]]; then + if [[ "$FORCE_UPDATE" == "true" ]]; then + log_warn "Descartando cambios locales..." + git reset --hard HEAD + git clean -fd + else + log_error "Hay cambios locales sin commitear" + log_error "Usa --force para descartarlos" + git status --short + exit 1 + fi + fi +} + +# --------------------------------------------- +# Actualizar codigo +# --------------------------------------------- +update_code() { + log_info "Actualizando codigo desde repositorio..." + + cd "$INSTALL_DIR" + + # Guardar version actual + local old_version=$(get_current_version) + + # Fetch + log_info "Obteniendo cambios..." + git fetch origin + + # Verificar si hay actualizaciones + local local_hash=$(git rev-parse HEAD) + local remote_hash=$(git rev-parse origin/${REPO_BRANCH}) + + if [[ "$local_hash" == "$remote_hash" ]]; then + log_success "Ya estas en la version mas reciente" + return 0 + fi + + # Mostrar cambios pendientes + log_info "Cambios disponibles:" + git log --oneline HEAD..origin/${REPO_BRANCH} | head -10 + + # Actualizar + log_info "Aplicando cambios..." + git reset --hard origin/${REPO_BRANCH} + + local new_version=$(get_current_version) + + log_success "Codigo actualizado: $old_version -> $new_version" + + return 1 # Indica que hubo cambios +} + +# --------------------------------------------- +# Actualizar backend +# --------------------------------------------- +update_backend() { + if [[ "$UPDATE_BACKEND" != "true" ]]; then + log_info "Saltando actualizacion de backend" + return + fi + + log_info "Actualizando backend..." + + cd "$INSTALL_DIR/backend" + + # Activar entorno virtual + source venv/bin/activate + + # Actualizar dependencias + log_info "Instalando dependencias Python..." + + if [[ -f "requirements.txt" ]]; then + pip install -r requirements.txt -q --upgrade + elif [[ -f "pyproject.toml" ]]; then + pip install -e . -q --upgrade + fi + + # Ejecutar migraciones + log_info "Ejecutando migraciones..." + + if [[ -d "alembic" ]]; then + alembic upgrade head 2>/dev/null || log_warn "Sin migraciones pendientes" + fi + + deactivate + + log_success "Backend actualizado" +} + +# --------------------------------------------- +# Actualizar frontend +# --------------------------------------------- +update_frontend() { + if [[ "$UPDATE_FRONTEND" != "true" ]]; then + log_info "Saltando actualizacion de frontend" + return + fi + + log_info "Actualizando frontend..." + + cd "$INSTALL_DIR/frontend" + + # Verificar package.json + if [[ ! -f "package.json" ]]; then + log_warn "No hay package.json en frontend" + return + fi + + # Instalar dependencias + log_info "Instalando dependencias Node.js..." + + if command -v pnpm &> /dev/null; then + pnpm install --frozen-lockfile 2>/dev/null || pnpm install + else + npm ci 2>/dev/null || npm install + fi + + # Build + log_info "Compilando frontend..." + + if command -v pnpm &> /dev/null; then + pnpm build + else + npm run build + fi + + log_success "Frontend actualizado" +} + +# --------------------------------------------- +# Reiniciar servicios +# --------------------------------------------- +restart_services() { + log_info "Reiniciando servicios..." + + if [[ "$UPDATE_BACKEND" == "true" ]]; then + systemctl restart flotillas-api 2>/dev/null && log_success "flotillas-api reiniciado" || log_warn "flotillas-api no existe" + fi + + if [[ "$UPDATE_FRONTEND" == "true" ]]; then + systemctl restart flotillas-web 2>/dev/null && log_success "flotillas-web reiniciado" || log_warn "flotillas-web no existe" + fi +} + +# --------------------------------------------- +# Verificar servicios +# --------------------------------------------- +verify_services() { + log_info "Verificando servicios..." + + local all_ok=true + + # Esperar a que inicien + sleep 3 + + if [[ "$UPDATE_BACKEND" == "true" ]]; then + if systemctl is-active --quiet flotillas-api; then + log_success "flotillas-api: activo" + else + log_error "flotillas-api: inactivo" + all_ok=false + fi + fi + + if [[ "$UPDATE_FRONTEND" == "true" ]]; then + if systemctl is-active --quiet flotillas-web; then + log_success "flotillas-web: activo" + else + log_error "flotillas-web: inactivo" + all_ok=false + fi + fi + + # Verificar API health + if [[ "$UPDATE_BACKEND" == "true" ]]; then + local api_port="${API_PORT:-8000}" + if curl -s "http://localhost:${api_port}/health" > /dev/null 2>&1; then + log_success "API health check: OK" + else + log_warn "API health check: no responde (puede estar iniciando)" + fi + fi + + if [[ "$all_ok" == "false" ]]; then + log_error "Algunos servicios fallaron. Revisa los logs:" + echo " journalctl -u flotillas-api -n 50" + echo " journalctl -u flotillas-web -n 50" + return 1 + fi + + return 0 +} + +# --------------------------------------------- +# Limpiar cache +# --------------------------------------------- +clean_cache() { + log_info "Limpiando cache..." + + # Python cache + find "$INSTALL_DIR/backend" -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true + find "$INSTALL_DIR/backend" -type f -name "*.pyc" -delete 2>/dev/null || true + + # Node cache + rm -rf "$INSTALL_DIR/frontend/.next/cache" 2>/dev/null || true + + log_success "Cache limpiado" +} + +# --------------------------------------------- +# Rollback +# --------------------------------------------- +rollback() { + local previous_commit="$1" + + log_warn "Ejecutando rollback a: $previous_commit" + + cd "$INSTALL_DIR" + git reset --hard "$previous_commit" + + update_backend + update_frontend + restart_services + + log_success "Rollback completado" +} + +# --------------------------------------------- +# Mostrar resumen +# --------------------------------------------- +show_summary() { + echo "" + echo -e "${GREEN}========================================${NC}" + echo -e "${GREEN} ACTUALIZACION COMPLETADA${NC}" + echo -e "${GREEN}========================================${NC}" + echo "" + echo "Version actual: $(get_current_version)" + echo "Branch: $REPO_BRANCH" + echo "" + echo "Servicios:" + systemctl is-active flotillas-api 2>/dev/null && echo " - flotillas-api: activo" || echo " - flotillas-api: inactivo" + systemctl is-active flotillas-web 2>/dev/null && echo " - flotillas-web: activo" || echo " - flotillas-web: inactivo" + echo "" +} + +# --------------------------------------------- +# Main +# --------------------------------------------- +main() { + parse_args "$@" + + echo "" + echo -e "${BLUE}========================================${NC}" + echo -e "${BLUE} ACTUALIZANDO SISTEMA DE FLOTILLAS${NC}" + echo -e "${BLUE}========================================${NC}" + echo "" + echo "Branch: $REPO_BRANCH" + echo "Force: $FORCE_UPDATE" + echo "Backup: $BACKUP_BEFORE_UPDATE" + echo "" + + # Guardar commit actual para posible rollback + cd "$INSTALL_DIR" + PREVIOUS_COMMIT=$(git rev-parse HEAD) + + check_requirements + create_backup + check_local_changes + + # Intentar actualizar + if update_code; then + log_info "No hay actualizaciones disponibles" + exit 0 + fi + + # Actualizar componentes + update_backend || { + log_error "Fallo en backend, haciendo rollback..." + rollback "$PREVIOUS_COMMIT" + exit 1 + } + + update_frontend || { + log_error "Fallo en frontend, haciendo rollback..." + rollback "$PREVIOUS_COMMIT" + exit 1 + } + + clean_cache + restart_services + + # Verificar + if ! verify_services; then + echo "" + read -p "Hacer rollback? (y/N): " -n 1 -r + echo + + if [[ $REPLY =~ ^[Yy]$ ]]; then + rollback "$PREVIOUS_COMMIT" + exit 1 + fi + fi + + show_summary +} + +# Manejo de errores +trap 'log_error "Error en linea $LINENO"; exit 1' ERR + +# Ejecutar +main "$@" diff --git a/deploy/services/cloudflared.service b/deploy/services/cloudflared.service new file mode 100644 index 0000000..266af05 --- /dev/null +++ b/deploy/services/cloudflared.service @@ -0,0 +1,43 @@ +[Unit] +Description=Cloudflare Tunnel - Sistema de Flotillas +Documentation=https://developers.cloudflare.com/cloudflare-one/connections/connect-apps +After=network-online.target +Wants=network-online.target + +[Service] +Type=exec +User=root +Group=root + +# Directorio de configuracion +WorkingDirectory=/etc/cloudflared + +# Comando de inicio +# Opcion 1: Usando token (recomendado) +ExecStart=/usr/local/bin/cloudflared tunnel --no-autoupdate run --token ${CLOUDFLARE_TUNNEL_TOKEN} + +# Opcion 2: Usando archivo de configuracion +# ExecStart=/usr/local/bin/cloudflared tunnel --config /etc/cloudflared/config.yml run + +# Cargar variables de entorno +EnvironmentFile=/opt/flotillas/.env + +# Reinicio automatico +Restart=always +RestartSec=5 + +# Timeouts +TimeoutStartSec=60 +TimeoutStopSec=30 + +# Logging +StandardOutput=journal +StandardError=journal +SyslogIdentifier=cloudflared + +# Seguridad +NoNewPrivileges=true +PrivateTmp=true + +[Install] +WantedBy=multi-user.target diff --git a/deploy/services/flotillas-api.service b/deploy/services/flotillas-api.service new file mode 100644 index 0000000..d17dd50 --- /dev/null +++ b/deploy/services/flotillas-api.service @@ -0,0 +1,58 @@ +[Unit] +Description=Sistema de Flotillas - API Backend +Documentation=https://github.com/tuorganizacion/flotillas +After=network.target postgresql.service redis.service +Wants=postgresql.service redis.service + +[Service] +Type=exec +User=root +Group=root +WorkingDirectory=/opt/flotillas/backend + +# Cargar variables de entorno +EnvironmentFile=/opt/flotillas/.env + +# Comando de inicio +# Uvicorn con multiples workers para produccion +ExecStart=/opt/flotillas/backend/venv/bin/uvicorn \ + app.main:app \ + --host 0.0.0.0 \ + --port 8000 \ + --workers 4 \ + --loop uvloop \ + --http httptools \ + --proxy-headers \ + --forwarded-allow-ips='*' \ + --access-log \ + --log-level info + +# Reinicio automatico +Restart=always +RestartSec=5 + +# Timeouts +TimeoutStartSec=30 +TimeoutStopSec=30 + +# Limites de recursos +LimitNOFILE=65535 +LimitNPROC=4096 + +# Seguridad +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=strict +ProtectHome=true +ReadWritePaths=/opt/flotillas /var/log/flotillas /tmp + +# Logging +StandardOutput=journal +StandardError=journal +SyslogIdentifier=flotillas-api + +# Health check (systemd 253+) +# WatchdogSec=30 + +[Install] +WantedBy=multi-user.target diff --git a/deploy/services/flotillas-web.service b/deploy/services/flotillas-web.service new file mode 100644 index 0000000..fc58cfe --- /dev/null +++ b/deploy/services/flotillas-web.service @@ -0,0 +1,58 @@ +[Unit] +Description=Sistema de Flotillas - Frontend Web +Documentation=https://github.com/tuorganizacion/flotillas +After=network.target flotillas-api.service +Wants=flotillas-api.service + +[Service] +Type=exec +User=root +Group=root +WorkingDirectory=/opt/flotillas/frontend + +# Cargar variables de entorno +EnvironmentFile=/opt/flotillas/.env + +# Comando de inicio usando 'serve' para servir archivos estaticos +# Opcion 1: Usando serve (recomendado para SPA React/Vue) +ExecStart=/usr/bin/serve \ + -s dist \ + -l 3000 \ + --no-clipboard \ + --single + +# Opcion 2: Si usas Next.js en modo standalone +# ExecStart=/usr/bin/node /opt/flotillas/frontend/.next/standalone/server.js + +# Opcion 3: Si prefieres usar Node directamente +# ExecStart=/usr/bin/npx serve -s dist -l 3000 + +# Reinicio automatico +Restart=always +RestartSec=5 + +# Timeouts +TimeoutStartSec=30 +TimeoutStopSec=30 + +# Variables de entorno adicionales +Environment=NODE_ENV=production +Environment=PORT=3000 + +# Limites +LimitNOFILE=65535 + +# Seguridad +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=strict +ProtectHome=true +ReadWritePaths=/opt/flotillas + +# Logging +StandardOutput=journal +StandardError=journal +SyslogIdentifier=flotillas-web + +[Install] +WantedBy=multi-user.target diff --git a/deploy/services/mediamtx.service b/deploy/services/mediamtx.service new file mode 100644 index 0000000..0cfe6df --- /dev/null +++ b/deploy/services/mediamtx.service @@ -0,0 +1,41 @@ +[Unit] +Description=MediaMTX - Real-Time Media Server +Documentation=https://github.com/bluenviron/mediamtx +After=network.target + +[Service] +Type=exec +User=root +Group=root +WorkingDirectory=/opt/mediamtx + +# Comando de inicio +ExecStart=/opt/mediamtx/mediamtx /opt/mediamtx/mediamtx.yml + +# Reinicio automatico +Restart=always +RestartSec=5 + +# Timeouts +TimeoutStartSec=30 +TimeoutStopSec=30 + +# Limites de recursos +LimitNOFILE=65535 +LimitNPROC=4096 + +# Ajustes de red para streaming +# Permitir puertos privilegiados si es necesario +AmbientCapabilities=CAP_NET_BIND_SERVICE + +# Seguridad +NoNewPrivileges=true +PrivateTmp=true + +# Logging +StandardOutput=journal +StandardError=journal +SyslogIdentifier=mediamtx + +[Install] +WantedBy=multi-user.target diff --git a/deploy/traccar/traccar.xml b/deploy/traccar/traccar.xml new file mode 100644 index 0000000..640339f --- /dev/null +++ b/deploy/traccar/traccar.xml @@ -0,0 +1,200 @@ + + + + + + + + + + + + ./conf/default.xml + + + true + info + /opt/traccar/logs/tracker-server.log + true + + + + org.postgresql.Driver + jdbc:postgresql://localhost:5432/traccar + flotillas + POSTGRES_PASSWORD + + + true + 10 + + + + + false + + + + + + + + true + http://localhost:8000/api/v1/traccar/position + true + + + + + + true + 60 + 3 + + + + + 5055 + + + 5023 + + + 5013 + + + 5002 + + + 5001 + + + 5027 + + + 5050 + + + 5020 + + + 5046 + + + 5011 + + + 5093 + + + 5005 + + + 5006 + + + 5007 + + + 5008 + + + 5009 + + + 5190 + + + + + true + true + + + true + nominatim + https://nominatim.openstreetmap.org/reverse + + false + 50 + + + + + + + + + true + true + true + true + + + 500 + + + 10 + + + + + false + + + + + false + + + + + true + + + + + + + + true + 4 + + + 30 + + diff --git a/docs/guias/api-reference.md b/docs/guias/api-reference.md new file mode 100644 index 0000000..160700b --- /dev/null +++ b/docs/guias/api-reference.md @@ -0,0 +1,742 @@ +# Referencia de API + +Documentacion de la API REST de FlotillasGPS. + +## Informacion General + +- **Base URL**: `https://flotillas.tudominio.com/api/v1` +- **Autenticacion**: JWT Bearer Token +- **Formato**: JSON +- **Documentacion interactiva**: `https://flotillas.tudominio.com/api/docs` + +## Autenticacion + +### Login + +```http +POST /auth/login +Content-Type: application/json + +{ + "email": "admin@ejemplo.com", + "password": "tu_password" +} +``` + +**Respuesta**: +```json +{ + "access_token": "eyJ...", + "refresh_token": "eyJ...", + "token_type": "bearer", + "expires_in": 86400 +} +``` + +### Refresh Token + +```http +POST /auth/refresh +Content-Type: application/json + +{ + "refresh_token": "eyJ..." +} +``` + +### Usar Token + +Incluir en todas las peticiones: +```http +Authorization: Bearer eyJ... +``` + +--- + +## Vehiculos + +### Listar Vehiculos + +```http +GET /vehiculos +``` + +**Parametros query**: +- `activo`: boolean - Filtrar por activos +- `grupo_id`: integer - Filtrar por grupo +- `search`: string - Buscar por nombre/placa +- `page`: integer - Pagina (default: 1) +- `per_page`: integer - Items por pagina (default: 20) + +**Respuesta**: +```json +{ + "items": [ + { + "id": 1, + "nombre": "Camion-01", + "placa": "ABC-123", + "marca": "Kenworth", + "modelo": "T680", + "ano": 2021, + "tipo": "camion", + "conductor": { + "id": 1, + "nombre": "Juan Perez" + }, + "grupo": { + "id": 1, + "nombre": "Ruta Norte" + }, + "estado": "en_ruta", + "ultima_ubicacion": { + "lat": 19.4326, + "lng": -99.1332, + "velocidad": 45, + "tiempo": "2026-01-21T10:30:00Z" + } + } + ], + "total": 12, + "page": 1, + "per_page": 20 +} +``` + +### Obtener Vehiculo + +```http +GET /vehiculos/{id} +``` + +### Crear Vehiculo + +```http +POST /vehiculos +Content-Type: application/json + +{ + "nombre": "Camion-02", + "placa": "DEF-456", + "marca": "Freightliner", + "modelo": "Cascadia", + "ano": 2022, + "tipo": "camion", + "color": "Blanco", + "capacidad_carga": 20000, + "capacidad_combustible": 400, + "grupo_id": 1, + "conductor_id": 2 +} +``` + +### Actualizar Vehiculo + +```http +PUT /vehiculos/{id} +Content-Type: application/json + +{ + "nombre": "Camion-02 Actualizado", + "conductor_id": 3 +} +``` + +### Eliminar Vehiculo + +```http +DELETE /vehiculos/{id} +``` + +### Obtener Ubicacion Actual + +```http +GET /vehiculos/{id}/ubicacion +``` + +**Respuesta**: +```json +{ + "vehiculo_id": 1, + "lat": 19.4326, + "lng": -99.1332, + "velocidad": 45, + "rumbo": 180, + "altitud": 2240, + "motor_encendido": true, + "bateria": 85, + "combustible": 72, + "tiempo": "2026-01-21T10:30:00Z", + "direccion": "Av. Insurgentes Sur 1234, CDMX" +} +``` + +### Obtener Historial de Ubicaciones + +```http +GET /vehiculos/{id}/historial +``` + +**Parametros query**: +- `desde`: datetime - Fecha inicio (ISO 8601) +- `hasta`: datetime - Fecha fin +- `intervalo`: integer - Segundos entre puntos (para reducir datos) + +--- + +## Conductores + +### Listar Conductores + +```http +GET /conductores +``` + +### Crear Conductor + +```http +POST /conductores +Content-Type: application/json + +{ + "nombre": "Maria", + "apellido": "Garcia", + "telefono": "+525512345678", + "email": "maria@ejemplo.com", + "licencia_numero": "LIC-123456", + "licencia_tipo": "C", + "licencia_vencimiento": "2027-06-15" +} +``` + +### Generar Codigo de Acceso + +```http +POST /conductores/{id}/generar-codigo +``` + +**Respuesta**: +```json +{ + "codigo": "123456", + "expira": "2026-01-22T10:30:00Z" +} +``` + +### Obtener Estadisticas + +```http +GET /conductores/{id}/estadisticas +``` + +**Parametros query**: +- `periodo`: string - "hoy", "semana", "mes", "ano" + +--- + +## Ubicaciones + +### Enviar Ubicacion (desde app/dispositivo) + +```http +POST /ubicaciones +X-Device-ID: device_123 +X-API-Key: api_key_xxx + +{ + "lat": 19.4326, + "lng": -99.1332, + "velocidad": 45, + "rumbo": 180, + "altitud": 2240, + "precision": 10, + "bateria": 85 +} +``` + +### Enviar Batch de Ubicaciones + +```http +POST /ubicaciones/batch +X-Device-ID: device_123 +X-API-Key: api_key_xxx + +{ + "ubicaciones": [ + { + "lat": 19.4326, + "lng": -99.1332, + "velocidad": 45, + "tiempo": "2026-01-21T10:30:00Z" + }, + { + "lat": 19.4327, + "lng": -99.1333, + "velocidad": 47, + "tiempo": "2026-01-21T10:30:10Z" + } + ] +} +``` + +--- + +## Viajes + +### Listar Viajes + +```http +GET /viajes +``` + +**Parametros query**: +- `vehiculo_id`: integer +- `conductor_id`: integer +- `desde`: datetime +- `hasta`: datetime +- `estado`: string - "en_curso", "completado" + +### Obtener Viaje + +```http +GET /viajes/{id} +``` + +**Respuesta**: +```json +{ + "id": 1, + "vehiculo_id": 1, + "conductor_id": 1, + "inicio_tiempo": "2026-01-21T06:30:00Z", + "fin_tiempo": "2026-01-21T11:45:00Z", + "inicio_lat": 19.4326, + "inicio_lng": -99.1332, + "inicio_direccion": "Base Central", + "fin_lat": 19.5000, + "fin_lng": -99.2000, + "fin_direccion": "Cliente Norte", + "distancia_km": 89.3, + "duracion_minutos": 315, + "velocidad_promedio": 48, + "velocidad_maxima": 82, + "paradas": 3 +} +``` + +### Obtener Datos para Replay + +```http +GET /viajes/{id}/replay +``` + +**Respuesta**: +```json +{ + "viaje_id": 1, + "puntos": [ + { + "lat": 19.4326, + "lng": -99.1332, + "velocidad": 0, + "tiempo": "2026-01-21T06:30:00Z" + }, + { + "lat": 19.4330, + "lng": -99.1335, + "velocidad": 25, + "tiempo": "2026-01-21T06:30:10Z" + } + ], + "eventos": [ + { + "tipo": "alerta", + "tiempo": "2026-01-21T08:45:00Z", + "descripcion": "Exceso de velocidad: 82 km/h" + } + ], + "paradas": [ + { + "inicio": "2026-01-21T07:15:00Z", + "fin": "2026-01-21T07:30:00Z", + "lat": 19.4500, + "lng": -99.1500, + "tipo": "programada" + } + ] +} +``` + +--- + +## Alertas + +### Listar Alertas + +```http +GET /alertas +``` + +**Parametros query**: +- `atendida`: boolean +- `severidad`: string - "info", "media", "critica" +- `tipo`: string +- `vehiculo_id`: integer +- `desde`: datetime +- `hasta`: datetime + +### Obtener Alertas Pendientes + +```http +GET /alertas/pendientes +``` + +### Marcar como Atendida + +```http +PUT /alertas/{id}/atender +Content-Type: application/json + +{ + "notas": "Contactado conductor, todo OK" +} +``` + +--- + +## Geocercas + +### Listar Geocercas + +```http +GET /geocercas +``` + +### Crear Geocerca + +```http +POST /geocercas +Content-Type: application/json + +{ + "nombre": "Zona Norte", + "tipo": "poligono", + "coordenadas": [ + {"lat": 19.5, "lng": -99.2}, + {"lat": 19.5, "lng": -99.1}, + {"lat": 19.4, "lng": -99.1}, + {"lat": 19.4, "lng": -99.2} + ], + "color": "#22c55e", + "alerta_entrada": false, + "alerta_salida": true, + "velocidad_maxima": 60, + "vehiculos": [1, 2, 3] +} +``` + +### Crear Geocerca Circular + +```http +POST /geocercas +Content-Type: application/json + +{ + "nombre": "Cliente ABC", + "tipo": "circulo", + "coordenadas": [{"lat": 19.4326, "lng": -99.1332}], + "radio_metros": 500, + "alerta_entrada": true, + "alerta_salida": true +} +``` + +--- + +## Video + +### Listar Camaras + +```http +GET /video/camaras +``` + +### Obtener URL de Stream + +```http +GET /video/camaras/{id}/stream +``` + +**Respuesta**: +```json +{ + "camara_id": 1, + "webrtc_url": "http://servidor:8889/cam_1_frontal", + "hls_url": "http://servidor:8888/cam_1_frontal/index.m3u8", + "estado": "online" +} +``` + +### Listar Grabaciones + +```http +GET /video/grabaciones +``` + +**Parametros query**: +- `camara_id`: integer +- `vehiculo_id`: integer +- `tipo`: string - "continua", "evento", "manual" +- `desde`: datetime +- `hasta`: datetime + +### Solicitar Video Historico + +```http +POST /video/grabaciones/solicitar +Content-Type: application/json + +{ + "camara_id": 1, + "desde": "2026-01-21T10:00:00Z", + "hasta": "2026-01-21T10:30:00Z" +} +``` + +--- + +## Combustible + +### Registrar Carga + +```http +POST /combustible +Content-Type: application/json + +{ + "vehiculo_id": 1, + "litros": 45.5, + "precio_litro": 23.50, + "odometro": 45678, + "estacion": "Pemex Centro" +} +``` + +### Obtener Consumo + +```http +GET /combustible/consumo +``` + +**Parametros query**: +- `vehiculo_id`: integer +- `desde`: datetime +- `hasta`: datetime + +--- + +## Mantenimiento + +### Listar Mantenimientos + +```http +GET /mantenimiento +``` + +### Obtener Proximos Vencimientos + +```http +GET /mantenimiento/pendientes +``` + +### Programar Mantenimiento + +```http +POST /mantenimiento +Content-Type: application/json + +{ + "vehiculo_id": 1, + "tipo_mantenimiento_id": 1, + "fecha_programada": "2026-02-01", + "odometro_programado": 50000, + "notas": "Cambio de aceite y filtros" +} +``` + +### Completar Mantenimiento + +```http +PUT /mantenimiento/{id}/completar +Content-Type: application/json + +{ + "fecha_realizada": "2026-02-01", + "odometro_realizado": 49850, + "costo": 1500.00, + "proveedor": "Taller AutoServ", + "notas": "Se cambio aceite sintetico" +} +``` + +--- + +## Reportes + +### Obtener Datos de Dashboard + +```http +GET /reportes/dashboard +``` + +**Respuesta**: +```json +{ + "vehiculos": { + "total": 12, + "en_ruta": 8, + "detenidos": 2, + "offline": 1, + "con_alerta": 1 + }, + "hoy": { + "km_recorridos": 847, + "viajes_completados": 8, + "alertas": 5, + "combustible_cargado": 250 + }, + "tendencias": { + "km_vs_ayer": 12, + "alertas_vs_ayer": -5 + } +} +``` + +### Generar Reporte + +```http +POST /reportes/generar +Content-Type: application/json + +{ + "tipo": "recorridos", + "desde": "2026-01-01", + "hasta": "2026-01-21", + "vehiculos": [1, 2, 3], + "formato": "pdf" +} +``` + +**Respuesta**: +```json +{ + "reporte_id": 1, + "estado": "procesando", + "url": null +} +``` + +### Descargar Reporte + +```http +GET /reportes/{id}/descargar +``` + +--- + +## WebSocket + +### Conexion + +```javascript +const ws = new WebSocket('wss://flotillas.tudominio.com/ws/v1/ubicaciones'); + +ws.onopen = () => { + // Autenticar + ws.send(JSON.stringify({ + type: 'auth', + token: 'eyJ...' + })); +}; + +ws.onmessage = (event) => { + const data = JSON.parse(event.data); + console.log(data); +}; +``` + +### Eventos de Ubicacion + +```json +{ + "type": "vehiculo_ubicacion", + "vehiculo_id": 1, + "lat": 19.4326, + "lng": -99.1332, + "velocidad": 45, + "rumbo": 180, + "tiempo": "2026-01-21T10:30:00Z" +} +``` + +### Eventos de Alerta + +```json +{ + "type": "nueva_alerta", + "alerta": { + "id": 123, + "vehiculo_id": 1, + "tipo": "exceso_velocidad", + "severidad": "media", + "mensaje": "Exceso de velocidad: 87 km/h", + "tiempo": "2026-01-21T10:30:00Z" + } +} +``` + +--- + +## Codigos de Error + +| Codigo | Descripcion | +|--------|-------------| +| 400 | Bad Request - Datos invalidos | +| 401 | Unauthorized - Token invalido o expirado | +| 403 | Forbidden - Sin permisos | +| 404 | Not Found - Recurso no existe | +| 422 | Validation Error - Error de validacion | +| 429 | Too Many Requests - Rate limit excedido | +| 500 | Internal Server Error | + +**Formato de error**: +```json +{ + "detail": "Mensaje de error", + "code": "ERROR_CODE", + "errors": [ + { + "field": "email", + "message": "Email invalido" + } + ] +} +``` + +--- + +## Rate Limits + +| Endpoint | Limite | +|----------|--------| +| General | 100 req/min | +| Auth | 5 req/min | +| Ubicaciones | 60 req/min | +| Video Stream | 10 req/min | + +Headers de respuesta: +``` +X-RateLimit-Limit: 100 +X-RateLimit-Remaining: 95 +X-RateLimit-Reset: 1642771200 +``` diff --git a/docs/guias/configuracion.md b/docs/guias/configuracion.md new file mode 100644 index 0000000..2f15b1b --- /dev/null +++ b/docs/guias/configuracion.md @@ -0,0 +1,449 @@ +# Guia de Configuracion + +Configuracion detallada de todos los componentes del sistema FlotillasGPS. + +## Variables de Entorno + +El archivo `/opt/flotillas/.env` contiene todas las configuraciones del sistema. + +### Base de Datos + +```bash +# PostgreSQL +DATABASE_URL=postgresql://flotillas:PASSWORD@localhost:5432/flotillas_db + +# Conexiones maximas al pool +DB_POOL_SIZE=10 +DB_MAX_OVERFLOW=20 +``` + +### Redis + +```bash +# URL de conexion +REDIS_URL=redis://localhost:6379 + +# Base de datos (0-15) +REDIS_DB=0 +``` + +### Seguridad + +```bash +# Clave secreta para JWT (generar con: openssl rand -base64 64) +JWT_SECRET=tu_clave_muy_larga_y_segura + +# Expiracion de tokens +ACCESS_TOKEN_EXPIRE_MINUTES=1440 # 24 horas +REFRESH_TOKEN_EXPIRE_DAYS=7 + +# Clave para encriptar datos sensibles +ENCRYPTION_KEY=otra_clave_segura +``` + +### Traccar + +```bash +# Conexion a Traccar +TRACCAR_HOST=localhost +TRACCAR_PORT=5055 +TRACCAR_FORWARD_URL=http://localhost:8000/api/v1/traccar/position +``` + +### Video Streaming + +```bash +# MediaMTX +MEDIAMTX_API=http://localhost:9997 +MEDIAMTX_RTSP=rtsp://localhost:8554 +MEDIAMTX_WEBRTC=http://localhost:8889 +MEDIAMTX_HLS=http://localhost:8888 + +# Directorio de grabaciones +VIDEO_STORAGE_PATH=/opt/flotillas/videos +VIDEO_RETENTION_DAYS=30 +``` + +### MQTT (Meshtastic) + +```bash +MQTT_HOST=localhost +MQTT_PORT=1883 +MQTT_USER=mesh_gateway +MQTT_PASSWORD=password_seguro +MQTT_TOPIC=flotillas/mesh/# +``` + +### Notificaciones + +```bash +# Email (SMTP) +SMTP_HOST=smtp.tudominio.com +SMTP_PORT=587 +SMTP_USER=notificaciones@tudominio.com +SMTP_PASSWORD=password +SMTP_FROM=FlotillasGPS + +# Push Notifications (Firebase) +FIREBASE_CREDENTIALS_FILE=/opt/flotillas/firebase-credentials.json +``` + +### Dominio + +```bash +DOMAIN=flotillas.tudominio.com +API_URL=https://flotillas.tudominio.com/api +FRONTEND_URL=https://flotillas.tudominio.com +``` + +--- + +## Configuracion de Traccar + +Archivo: `/opt/traccar/conf/traccar.xml` + +```xml + + + + + org.postgresql.Driver + jdbc:postgresql://localhost:5432/flotillas_db + flotillas + TU_PASSWORD + + + false + false + + + true + http://localhost:8000/api/v1/traccar/position + true + + + 5023 + 5002 + 5001 + 5055 + + + true + info + +``` + +### Protocolos GPS Comunes + +| Protocolo | Puerto | Dispositivos | +|-----------|--------|--------------| +| osmand | 5055 | Apps moviles, GPS genericos | +| gt06 | 5023 | Concox, Wetrack, JM01 | +| tk103 | 5002 | TK103, GPS103 | +| h02 | 5013 | Sinotrack ST-901 | +| watch | 5093 | Smartwatches GPS | +| teltonika | 5027 | Teltonika FM | + +Para habilitar un protocolo adicional, agregar la linea: +```xml +PUERTO +``` + +Y abrir el puerto en el firewall: +```bash +ufw allow PUERTO/tcp +``` + +--- + +## Configuracion de MediaMTX + +Archivo: `/etc/mediamtx/mediamtx.yml` + +```yaml +# Logging +logLevel: info +logDestinations: [stdout] + +# API para control +api: yes +apiAddress: 127.0.0.1:9997 + +# RTSP Server (para recibir streams de camaras) +rtsp: yes +rtspAddress: :8554 +protocols: [tcp, udp] +rtspAuthMethods: [] + +# WebRTC Server (para dashboard) +webrtc: yes +webrtcAddress: :8889 +webrtcAllowOrigin: '*' +webrtcICEServers2: + - urls: [stun:stun.l.google.com:19302] + +# HLS Server (para app movil) +hls: yes +hlsAddress: :8888 +hlsAllowOrigin: '*' +hlsAlwaysRemux: yes +hlsSegmentCount: 3 +hlsSegmentDuration: 1s + +# Grabacion +record: no # Manejamos grabacion desde nuestra API +recordPath: /opt/flotillas/videos/%path/%Y%m%d_%H%M%S.mp4 + +# Paths (camaras) +paths: + # Patron para camaras: cam_{vehiculo_id}_{posicion} + cam~: + source: publisher + # Autenticacion para publicar + publishUser: camuser + publishPass: campass + # Autenticacion para ver (vacio = sin auth, lo manejamos con JWT) + readUser: '' + readPass: '' +``` + +### Agregar Camara Manualmente + +```bash +# Crear path para una camara +curl -X POST http://localhost:9997/v2/config/paths/add/cam_1_frontal \ + -H "Content-Type: application/json" \ + -d '{ + "source": "rtsp://usuario:password@192.168.1.100/stream1", + "sourceOnDemand": true + }' +``` + +--- + +## Configuracion de Cloudflare Tunnel + +Archivo: `/etc/cloudflared/config.yml` + +```yaml +tunnel: TU_TUNNEL_ID +credentials-file: /root/.cloudflared/TU_TUNNEL_ID.json + +ingress: + # API Backend + - hostname: flotillas.tudominio.com + path: /api/* + service: http://localhost:8000 + + # WebSocket + - hostname: flotillas.tudominio.com + path: /ws/* + service: http://localhost:8000 + + # Frontend (default) + - hostname: flotillas.tudominio.com + service: http://localhost:3000 + + # Catch-all + - service: http_status:404 +``` + +### Comandos Utiles + +```bash +# Ver estado del tunnel +cloudflared tunnel info TU_TUNNEL_ID + +# Listar tunnels +cloudflared tunnel list + +# Ver conexiones activas +cloudflared tunnel run --url http://localhost:3000 + +# Reiniciar +systemctl restart cloudflared +``` + +--- + +## Configuracion de PostgreSQL + +### Ajustes de Rendimiento + +Archivo: `/etc/postgresql/15/main/postgresql.conf` + +```ini +# Memoria (ajustar segun RAM disponible) +shared_buffers = 2GB # 25% de RAM +effective_cache_size = 6GB # 75% de RAM +work_mem = 256MB +maintenance_work_mem = 512MB + +# Conexiones +max_connections = 100 + +# WAL +wal_buffers = 64MB +checkpoint_completion_target = 0.9 + +# Logging +log_min_duration_statement = 1000 # Log queries > 1 segundo +``` + +### TimescaleDB + +```sql +-- Ver chunks de la hypertable +SELECT show_chunks('ubicaciones'); + +-- Comprimir chunks antiguos +SELECT compress_chunk(c, if_not_compressed => true) +FROM show_chunks('ubicaciones', older_than => INTERVAL '7 days') c; + +-- Configurar compresion automatica +SELECT add_compression_policy('ubicaciones', INTERVAL '7 days'); + +-- Configurar retencion automatica +SELECT add_retention_policy('ubicaciones', INTERVAL '90 days'); +``` + +--- + +## Configuracion de Redis + +Archivo: `/etc/redis/redis.conf` + +```ini +# Memoria +maxmemory 512mb +maxmemory-policy allkeys-lru + +# Persistencia +save 900 1 +save 300 10 +save 60 10000 + +# Solo conexiones locales +bind 127.0.0.1 +``` + +--- + +## Configuracion de Firewall (UFW) + +```bash +# Ver estado actual +ufw status verbose + +# Reglas recomendadas +ufw default deny incoming +ufw default allow outgoing +ufw allow ssh +ufw allow 5055/tcp # GPS Traccar + +# Si necesitas mas puertos GPS +ufw allow 5001:5099/tcp # Rango de puertos Traccar + +# Habilitar +ufw enable +``` + +--- + +## Configuracion de Systemd Services + +### flotillas-api.service + +```ini +[Unit] +Description=FlotillasGPS API Backend +After=network.target postgresql.service redis.service + +[Service] +Type=simple +User=www-data +Group=www-data +WorkingDirectory=/opt/flotillas/backend +Environment="PATH=/opt/flotillas/venv/bin" +EnvironmentFile=/opt/flotillas/.env +ExecStart=/opt/flotillas/venv/bin/uvicorn app.main:app \ + --host 127.0.0.1 \ + --port 8000 \ + --workers 4 \ + --loop uvloop \ + --http httptools +Restart=always +RestartSec=5 + +[Install] +WantedBy=multi-user.target +``` + +### flotillas-web.service + +```ini +[Unit] +Description=FlotillasGPS Web Frontend +After=network.target + +[Service] +Type=simple +User=www-data +Group=www-data +WorkingDirectory=/opt/flotillas/frontend +ExecStart=/usr/bin/npx serve -s dist -l 3000 +Restart=always +RestartSec=5 + +[Install] +WantedBy=multi-user.target +``` + +--- + +## Configuracion desde el Dashboard + +Muchas configuraciones se pueden cambiar desde **Configuracion** en el dashboard: + +### General +- Zona horaria +- Unidades (km/millas, litros/galones) +- Moneda +- Idioma + +### Alertas +- Velocidad maxima global +- Tiempo de parada para alerta (minutos) +- Tiempo offline para alerta (minutos) +- Notificaciones por email + +### Mapa +- Proveedor de mapas +- Estilo (claro/oscuro) +- Capas por defecto + +### Retencion de Datos +- Dias de ubicaciones detalladas +- Dias de ubicaciones agregadas +- Dias de grabaciones de video +- Dias de alertas + +--- + +## Aplicar Cambios + +Despues de modificar archivos de configuracion: + +```bash +# Recargar configuracion de systemd +systemctl daemon-reload + +# Reiniciar servicio especifico +systemctl restart flotillas-api + +# Reiniciar todos los servicios +systemctl restart flotillas-api flotillas-web traccar mediamtx + +# Verificar estado +systemctl status flotillas-api flotillas-web traccar mediamtx +``` diff --git a/docs/guias/instalacion.md b/docs/guias/instalacion.md new file mode 100644 index 0000000..91f1a05 --- /dev/null +++ b/docs/guias/instalacion.md @@ -0,0 +1,264 @@ +# Guia de Instalacion + +Esta guia cubre la instalacion completa del sistema FlotillasGPS en un servidor Proxmox. + +## Requisitos Previos + +### Hardware (VM en Proxmox) + +| Recurso | Minimo | Recomendado | +|---------|--------|-------------| +| CPU | 2 cores | 4 cores | +| RAM | 4 GB | 8 GB | +| Disco Sistema | 40 GB SSD | 60 GB SSD | +| Disco Videos | 500 GB HDD | 2 TB HDD | + +### Software + +- Proxmox VE 7.x o superior +- ISO Ubuntu 22.04 LTS Server + +### Red + +- IP estatica para la VM +- Puerto TCP 5055 accesible desde internet (para GPS) +- Dominio configurado (para Cloudflare Tunnel) +- Cuenta en Cloudflare (plan gratuito funciona) + +## Paso 1: Crear VM en Proxmox + +### Desde la interfaz web de Proxmox: + +1. Click en "Create VM" +2. **General**: + - Name: `flotillas-server` + - Start at boot: Si +3. **OS**: + - ISO image: ubuntu-22.04-live-server-amd64.iso +4. **System**: + - BIOS: Default + - Machine: q35 +5. **Disks**: + - Disco 1: 60 GB (SSD/local-lvm) + - Agregar disco 2: 2 TB (HDD para videos) +6. **CPU**: + - Cores: 4 + - Type: host +7. **Memory**: + - Memory: 8192 MB +8. **Network**: + - Bridge: vmbr0 + - Model: VirtIO + +### Instalar Ubuntu: + +1. Iniciar VM y seguir instalador +2. Configurar IP estatica o DHCP con reserva +3. Crear usuario `admin` +4. Instalar OpenSSH server +5. Reiniciar + +## Paso 2: Preparar el Sistema + +Conectar por SSH: + +```bash +ssh admin@IP_DE_TU_VM +``` + +Actualizar sistema: + +```bash +sudo apt update && sudo apt upgrade -y +sudo apt install -y git curl wget +``` + +## Paso 3: Clonar Repositorio + +```bash +cd /opt +sudo git clone https://git.consultoria-as.com/tu-usuario/flotillas-gps.git flotillas +sudo chown -R $USER:$USER /opt/flotillas +cd /opt/flotillas +``` + +## Paso 4: Configurar Variables + +Editar el script de instalacion para configurar tu dominio: + +```bash +nano deploy/scripts/install.sh +``` + +Modificar las variables al inicio: + +```bash +DOMAIN="flotillas.tudominio.com" # Tu dominio +ADMIN_EMAIL="admin@tudominio.com" # Email del admin +``` + +## Paso 5: Ejecutar Instalacion + +```bash +sudo ./deploy/scripts/install.sh +``` + +El script realizara automaticamente: + +1. Instalar PostgreSQL 15 + TimescaleDB +2. Instalar Redis +3. Instalar Python 3.11 y Node.js 20 +4. Instalar Traccar Server +5. Instalar MediaMTX (video streaming) +6. Instalar Mosquitto MQTT +7. Configurar la aplicacion +8. Crear servicios systemd +9. Configurar firewall +10. Generar credenciales + +**Duracion estimada: 10-15 minutos** + +## Paso 6: Configurar Cloudflare Tunnel + +### En el Dashboard de Cloudflare: + +1. Ir a **Zero Trust** > **Access** > **Tunnels** +2. Click **Create a tunnel** +3. Nombre: `flotillas` +4. Copiar el token del tunnel + +### En tu servidor: + +```bash +# El instalador ya instalo cloudflared +# Configurar con tu token +sudo cloudflared service install TOKEN_QUE_COPIASTE +``` + +### Configurar rutas en Cloudflare: + +En el dashboard del tunnel, agregar Public Hostnames: + +| Subdomain | Domain | Service | +|-----------|--------|---------| +| flotillas | tudominio.com | http://localhost:3000 | +| flotillas | tudominio.com | http://localhost:8000 (path: /api/*) | +| flotillas | tudominio.com | http://localhost:8000 (path: /ws/*) | + +## Paso 7: Verificar Instalacion + +### Verificar servicios: + +```bash +sudo systemctl status flotillas-api +sudo systemctl status flotillas-web +sudo systemctl status traccar +sudo systemctl status mediamtx +sudo systemctl status cloudflared +``` + +Todos deben mostrar `active (running)`. + +### Verificar acceso web: + +Abrir en navegador: `https://flotillas.tudominio.com` + +Deberia mostrar la pagina de login. + +### Verificar puerto GPS: + +```bash +# Desde otra maquina +nc -zv IP_DEL_SERVIDOR 5055 +``` + +Debe mostrar "Connection succeeded". + +## Paso 8: Credenciales + +Las credenciales se generaron durante la instalacion. + +Ver credenciales guardadas: + +```bash +cat /opt/flotillas/.credentials +``` + +Ejemplo de salida: + +``` +================================= +CREDENCIALES DE ACCESO +================================= +Dashboard: https://flotillas.tudominio.com +Admin Email: admin@flotillas.tudominio.com +Admin Password: xK9mN2pL5qR8 +Database Password: [guardado en .env] +================================= +``` + +**IMPORTANTE**: Guarda estas credenciales en un lugar seguro y cambia la contrasena del admin despues del primer login. + +## Paso 9: Configurar DNS para GPS + +Los dispositivos GPS necesitan conectarse al puerto 5055 de tu servidor. + +### Opcion A: IP Publica directa + +Si tu servidor tiene IP publica, configura los GPS con: +- Servidor: `IP_PUBLICA` +- Puerto: `5055` + +### Opcion B: Port forwarding + +Si el servidor esta detras de NAT: + +1. En tu router, hacer port forward del puerto 5055 TCP hacia la IP de la VM +2. Configurar GPS con tu IP publica o dominio DDNS + +### Opcion C: Dominio con registro A + +1. Crear registro A: `gps.tudominio.com` → IP_PUBLICA +2. Configurar GPS con: + - Servidor: `gps.tudominio.com` + - Puerto: `5055` + +## Solucion de Problemas + +### El servicio no inicia + +```bash +# Ver logs detallados +journalctl -u flotillas-api -n 100 --no-pager + +# Verificar configuracion +cat /opt/flotillas/.env +``` + +### No puedo acceder al dashboard + +```bash +# Verificar tunnel +cloudflared tunnel info flotillas + +# Reiniciar tunnel +sudo systemctl restart cloudflared +``` + +### Los GPS no se conectan + +```bash +# Verificar que el puerto esta abierto +sudo ufw status +sudo netstat -tlnp | grep 5055 + +# Ver logs de Traccar +journalctl -u traccar -f +``` + +## Siguientes Pasos + +1. [Configurar el sistema](configuracion.md) +2. [Agregar vehiculos y dispositivos GPS](usuario-admin.md) +3. [Configurar camaras de video](video-streaming.md) +4. [Instalar app en celulares de conductores](usuario-conductor.md) diff --git a/docs/guias/meshtastic.md b/docs/guias/meshtastic.md new file mode 100644 index 0000000..09074bb --- /dev/null +++ b/docs/guias/meshtastic.md @@ -0,0 +1,392 @@ +# Integracion Meshtastic + +Guia para configurar dispositivos Meshtastic con FlotillasGPS. + +## Que es Meshtastic + +Meshtastic es una plataforma de comunicacion mesh usando radio LoRa: + +- **Largo alcance**: 5-15+ km en condiciones ideales +- **Sin infraestructura**: No requiere torres celulares ni internet +- **Bajo costo**: Dispositivos desde $20 USD +- **Bajo consumo**: Semanas de bateria + +### Casos de Uso en Flotillas + +- Vehiculos en zonas rurales sin cobertura celular +- Operaciones en minas, campos, areas remotas +- Backup cuando falla la red celular +- Comunicacion en emergencias + +--- + +## Arquitectura + +``` + ZONA SIN COBERTURA CELULAR + ========================== + + [Vehiculo 1] [Vehiculo 2] + Meshtastic Meshtastic + | | + | Radio LoRa | + +----------+----------+ + | + v + [Nodo Relay] + (punto alto) + | + | Radio LoRa + v + + ZONA CON COBERTURA + ================== + + [Gateway] + Meshtastic + WiFi/4G + | + | MQTT / Internet + v + [Tu Servidor] + FlotillasGPS +``` + +--- + +## Hardware Recomendado + +### Para Vehiculos (Nodos Moviles) + +| Dispositivo | Precio | GPS | Pantalla | Notas | +|-------------|--------|-----|----------|-------| +| LILYGO T-Beam | $35 | Si | No | Popular, bateria 18650 | +| Heltec LoRa 32 V3 | $20 | Si | OLED | Compacto, economico | +| RAK WisBlock | $40 | Si | Opcional | Modular, bajo consumo | + +### Para Gateway (Fijo con Internet) + +| Dispositivo | Precio | Notas | +|-------------|--------|-------| +| T-Beam + RPi | $70 | DIY, flexible | +| Heltec + ESP32 | $25 | Simple, economico | +| RAK WisGate | $150 | Comercial, robusto | + +### Accesorios + +- **Antena externa**: Mayor alcance (+3-6 dB) +- **Caja impermeable**: IP65 para exterior +- **Alimentacion 12V**: Adaptador para vehiculo + +--- + +## Configuracion de Nodos + +### 1. Instalar Firmware Meshtastic + +1. Descargar [Meshtastic Flasher](https://flasher.meshtastic.org/) +2. Conectar dispositivo por USB +3. Seleccionar dispositivo y version +4. Flash + +### 2. Configuracion Basica (App Meshtastic) + +Descargar app Meshtastic (Android/iOS) y conectar por Bluetooth: + +**Device Settings**: +``` +Role: ROUTER_CLIENT +``` + +**Position Settings**: +``` +GPS Mode: Enabled +Position Broadcast Interval: 30 seconds +Smart Position: Enabled +``` + +**LoRa Settings**: +``` +Region: US (o tu region) +Modem Preset: LONG_FAST +TX Power: 20 dBm (maximo legal) +``` + +**Channel Settings**: +``` +Name: flotilla +PSK: [generar clave compartida] +``` + +### 3. Configuracion via CLI (Avanzado) + +```bash +# Instalar meshtastic CLI +pip install meshtastic + +# Conectar y configurar +meshtastic --set device.role ROUTER_CLIENT +meshtastic --set position.gps_enabled true +meshtastic --set position.position_broadcast_secs 30 +meshtastic --set lora.region US +meshtastic --set lora.modem_preset LONG_FAST +meshtastic --ch-set name flotilla --ch-index 0 +meshtastic --ch-set psk random --ch-index 0 +``` + +--- + +## Configuracion del Gateway + +El gateway es el nodo que tiene conexion a internet y envia datos al servidor. + +### Opcion 1: T-Beam + Raspberry Pi + +1. Conectar T-Beam a RPi via USB +2. Instalar meshtastic: +```bash +pip install meshtastic +``` + +3. Configurar MQTT bridge: +```bash +meshtastic --set mqtt.enabled true +meshtastic --set mqtt.address tu-servidor.com +meshtastic --set mqtt.username mesh_gateway +meshtastic --set mqtt.password tu_password +meshtastic --set mqtt.root_topic flotillas/mesh +meshtastic --set mqtt.encryption_enabled true +meshtastic --set mqtt.json_enabled true +``` + +### Opcion 2: ESP32 con WiFi + +Configurar directamente en el dispositivo: + +**MQTT Settings** (via app o CLI): +``` +MQTT Enabled: true +MQTT Server: tu-servidor.com:1883 +MQTT Username: mesh_gateway +MQTT Password: tu_password +Root Topic: flotillas/mesh +JSON Enabled: true +``` + +--- + +## Configuracion del Servidor + +### 1. Mosquitto MQTT + +Ya instalado por el script de instalacion. Verificar: + +```bash +systemctl status mosquitto +``` + +Crear usuario para gateway: +```bash +mosquitto_passwd -c /etc/mosquitto/passwd mesh_gateway +# Ingresar password +``` + +Configuracion `/etc/mosquitto/conf.d/flotillas.conf`: +``` +listener 1883 +allow_anonymous false +password_file /etc/mosquitto/passwd +``` + +Reiniciar: +```bash +systemctl restart mosquitto +``` + +### 2. Verificar Recepcion de Mensajes + +Suscribirse al topic para ver mensajes: + +```bash +mosquitto_sub -h localhost -t "flotillas/mesh/#" -u mesh_gateway -P tu_password +``` + +Deberian aparecer mensajes JSON cuando los nodos envien posicion. + +### 3. Configurar en FlotillasGPS + +Variables de entorno en `.env`: +```bash +MQTT_HOST=localhost +MQTT_PORT=1883 +MQTT_USER=mesh_gateway +MQTT_PASSWORD=tu_password +MQTT_TOPIC=flotillas/mesh/# +``` + +--- + +## Vincular Nodos a Vehiculos + +### En el Dashboard + +1. Ir a **Meshtastic** o detalle del vehiculo > **Dispositivo** +2. Click **+ Agregar dispositivo Meshtastic** +3. Ingresar: + - **Node ID**: ID del nodo (ej: !a1b2c3d4) + - **Nombre**: Identificador amigable + - **Vehiculo**: Seleccionar vehiculo +4. Click **Guardar** + +### Obtener Node ID + +En la app Meshtastic: +- Ir a Settings > Device +- El Node ID aparece como "!xxxxxxxx" + +O via CLI: +```bash +meshtastic --info | grep "Node" +``` + +--- + +## Panel Meshtastic en Dashboard + +El panel muestra: + +### Estado de Red +- Nodos totales +- Nodos online +- Nodos offline +- Calidad de senal promedio + +### Mapa de Cobertura +- Gateway +- Nodos y conexiones +- Alcance estimado + +### Lista de Nodos +| Nodo | Vehiculo | Bateria | Senal | Ultimo msg | +|------|----------|---------|-------|------------| +| !a1b2 | Camion-05 | 78% | -72dB | hace 30s | +| !c3d4 | Van-03 | 45% | -89dB | hace 45s | + +### Diagnosticos +- Mensajes por minuto +- Paquetes perdidos +- Latencia promedio + +--- + +## Topologia de Red + +### Red Simple (Linea de Vista) + +``` +[Vehiculo] <-- LoRa --> [Gateway] <-- Internet --> [Servidor] +``` + +Alcance: 5-15 km con linea de vista + +### Red con Relays + +``` +[Vehiculo lejano] + | + | LoRa + v +[Vehiculo relay] <-- LoRa --> [Gateway] + ^ + | LoRa + | +[Otro vehiculo] +``` + +Cada vehiculo puede actuar como relay si esta configurado como ROUTER_CLIENT. + +### Consejos para Mejor Cobertura + +1. **Gateway en punto alto**: Edificio, torre, cerro +2. **Antenas externas**: Mejor ganancia +3. **Evitar obstaculos**: Metal, concreto bloquean senal +4. **Vehiculos como relays**: Configurar ROUTER_CLIENT + +--- + +## Solucion de Problemas + +### Nodo no aparece en el sistema + +1. Verificar que el gateway recibe mensajes: +```bash +mosquitto_sub -h localhost -t "flotillas/mesh/#" -u mesh_gateway -P password +``` + +2. Verificar que el nodo esta en el mismo canal: + - Mismo nombre de canal + - Misma PSK (clave) + +3. Verificar alcance: El nodo debe estar dentro del alcance del gateway o de un relay. + +### Ubicaciones no se actualizan + +1. Verificar que GPS esta habilitado en el nodo: +```bash +meshtastic --info | grep "GPS" +``` + +2. Verificar intervalo de broadcast: +```bash +meshtastic --get position.position_broadcast_secs +``` + +3. El nodo debe tener fix GPS (ver LED o app) + +### Conexion MQTT falla + +1. Verificar Mosquitto: +```bash +systemctl status mosquitto +``` + +2. Probar conexion: +```bash +mosquitto_pub -h localhost -t "test" -m "hello" -u mesh_gateway -P password +``` + +3. Verificar firewall (si gateway es externo): +```bash +ufw allow 1883/tcp +``` + +### Bateria se agota rapido + +Optimizar configuracion: +```bash +# Aumentar intervalo de posicion +meshtastic --set position.position_broadcast_secs 60 + +# Reducir potencia TX +meshtastic --set lora.tx_power 17 + +# Habilitar modo ahorro +meshtastic --set power.is_power_saving true +``` + +--- + +## Limitaciones + +- **No tiempo real**: Latencia de 10-60 segundos +- **Solo ubicacion**: No video ni datos pesados +- **Dependiente de relays**: Necesita nodos intermedios para grandes distancias +- **Interferencia**: Otras redes LoRa pueden afectar + +--- + +## Recursos + +- [Documentacion Meshtastic](https://meshtastic.org/docs/) +- [Flasher Web](https://flasher.meshtastic.org/) +- [Comunidad Discord](https://discord.gg/meshtastic) +- [Mapa de Nodos](https://meshtastic.liamcottle.net/) diff --git a/docs/guias/troubleshooting.md b/docs/guias/troubleshooting.md new file mode 100644 index 0000000..27f94de --- /dev/null +++ b/docs/guias/troubleshooting.md @@ -0,0 +1,557 @@ +# Solucion de Problemas + +Guia para diagnosticar y resolver problemas comunes en FlotillasGPS. + +## Diagnostico Rapido + +### Verificar Estado de Servicios + +```bash +# Ver estado de todos los servicios +systemctl status flotillas-api flotillas-web traccar mediamtx cloudflared redis postgresql + +# Resumen rapido +for svc in flotillas-api flotillas-web traccar mediamtx cloudflared; do + echo "$svc: $(systemctl is-active $svc)" +done +``` + +### Verificar Logs + +```bash +# API Backend +journalctl -u flotillas-api -f + +# Frontend +journalctl -u flotillas-web -f + +# Traccar (GPS) +journalctl -u traccar -f + +# Cloudflare Tunnel +journalctl -u cloudflared -f +``` + +### Verificar Conectividad + +```bash +# Puerto GPS +netstat -tlnp | grep 5055 + +# Puerto API +curl http://localhost:8000/api/v1/health + +# Puerto Frontend +curl http://localhost:3000 + +# Base de datos +psql -U flotillas -d flotillas_db -c "SELECT 1" + +# Redis +redis-cli ping +``` + +--- + +## Problemas de Acceso Web + +### No puedo acceder al dashboard + +**Sintomas**: El navegador muestra error de conexion o timeout. + +**Verificar**: + +1. Estado del tunnel de Cloudflare: +```bash +systemctl status cloudflared +cloudflared tunnel info flotillas +``` + +2. Estado del frontend: +```bash +systemctl status flotillas-web +curl http://localhost:3000 +``` + +**Soluciones**: + +- Reiniciar el tunnel: +```bash +systemctl restart cloudflared +``` + +- Verificar configuracion DNS en Cloudflare dashboard + +- Verificar que el dominio apunta al tunnel correcto + +### Error 502 Bad Gateway + +**Causa**: El backend no esta respondiendo. + +**Verificar**: +```bash +systemctl status flotillas-api +curl http://localhost:8000/api/v1/health +``` + +**Soluciones**: +```bash +# Reiniciar backend +systemctl restart flotillas-api + +# Ver logs de error +journalctl -u flotillas-api -n 100 --no-pager +``` + +### Error de SSL/Certificado + +**Causa**: Problema con Cloudflare. + +**Soluciones**: + +1. En Cloudflare dashboard, verificar que SSL este en "Full" o "Full (strict)" +2. Verificar que el dominio este activo +3. Esperar propagacion DNS (hasta 24 horas) + +--- + +## Problemas con GPS + +### Los dispositivos GPS no se conectan + +**Sintomas**: Vehiculos aparecen offline, no se reciben ubicaciones. + +**Verificar**: + +1. Puerto 5055 abierto: +```bash +# Desde el servidor +netstat -tlnp | grep 5055 + +# Desde fuera (otra maquina) +nc -zv IP_SERVIDOR 5055 +``` + +2. Traccar funcionando: +```bash +systemctl status traccar +journalctl -u traccar -f +``` + +3. Firewall: +```bash +ufw status +# Debe mostrar 5055/tcp ALLOW +``` + +**Soluciones**: + +- Abrir puerto en firewall: +```bash +ufw allow 5055/tcp +``` + +- Verificar configuracion del GPS: + - IP/dominio del servidor correcto + - Puerto 5055 + - Protocolo correcto (ver manual del GPS) + +- Reiniciar Traccar: +```bash +systemctl restart traccar +``` + +### GPS conecta pero no aparece en el mapa + +**Causa**: El dispositivo no esta vinculado a un vehiculo. + +**Solucion**: + +1. Verificar que el dispositivo esta registrado en Traccar: +```bash +# Ver dispositivos en Traccar +curl http://localhost:8082/api/devices +``` + +2. Vincular el dispositivo al vehiculo desde el dashboard + +### Ubicaciones con retraso + +**Causas posibles**: + +1. Problema de red del GPS +2. Intervalo de reporte muy largo +3. Problema de procesamiento + +**Verificar**: +```bash +# Ver ubicaciones recientes en DB +psql -U flotillas -d flotillas_db -c " + SELECT vehiculo_id, tiempo, lat, lng + FROM ubicaciones + ORDER BY tiempo DESC + LIMIT 10; +" +``` + +--- + +## Problemas con la App Movil + +### La app no envia ubicacion + +**Verificar en el telefono**: + +1. Permiso de ubicacion en "Siempre" +2. GPS activado +3. Datos moviles o WiFi activo +4. App no en modo ahorro de bateria + +**Verificar en el servidor**: +```bash +# Ver ultimas ubicaciones de apps +journalctl -u flotillas-api | grep "ubicacion" | tail -20 +``` + +### App no puede conectar al servidor + +**Causas**: + +1. Sin conexion a internet +2. Token expirado +3. Dispositivo no registrado + +**Solucion**: + +1. Verificar conexion a internet +2. Cerrar sesion y volver a entrar +3. Verificar que el conductor tiene dispositivo asignado + +### Notificaciones no llegan + +**Verificar**: + +1. Permiso de notificaciones en el telefono +2. App no silenciada +3. Token de push registrado + +**En el servidor**: +```bash +# Ver logs de notificaciones +journalctl -u flotillas-api | grep "push\|notification" +``` + +--- + +## Problemas de Video + +### Camara no conecta + +**Verificar**: + +1. URL del stream correcta +2. Credenciales correctas +3. Camara accesible desde el servidor + +```bash +# Probar conexion RTSP +ffprobe rtsp://usuario:password@IP_CAMARA/stream +``` + +**Soluciones**: + +- Verificar que la camara y el servidor estan en la misma red (o hay ruta) +- Verificar puerto de la camara no bloqueado +- Probar con VLC desde otra maquina + +### Video con lag/retraso + +**Causas**: + +1. Ancho de banda insuficiente +2. Servidor sobrecargado +3. Configuracion de bitrate muy alto + +**Soluciones**: + +- Reducir calidad del stream en configuracion de camara +- Verificar uso de CPU/RAM del servidor +- Usar HLS en lugar de WebRTC para conexiones lentas + +### No se guardan grabaciones + +**Verificar**: + +1. Espacio en disco: +```bash +df -h /opt/flotillas/videos +``` + +2. Permisos: +```bash +ls -la /opt/flotillas/videos +``` + +**Solucion**: +```bash +# Liberar espacio +find /opt/flotillas/videos -name "*.mp4" -mtime +30 -delete + +# Arreglar permisos +chown -R www-data:www-data /opt/flotillas/videos +``` + +--- + +## Problemas de Base de Datos + +### Error de conexion a PostgreSQL + +```bash +# Verificar estado +systemctl status postgresql + +# Verificar que acepta conexiones +psql -U flotillas -d flotillas_db -c "SELECT 1" +``` + +**Soluciones**: +```bash +# Reiniciar PostgreSQL +systemctl restart postgresql + +# Ver logs +journalctl -u postgresql -f +``` + +### Base de datos lenta + +**Verificar**: +```bash +# Ver consultas lentas +psql -U flotillas -d flotillas_db -c " + SELECT pid, now() - pg_stat_activity.query_start AS duration, query + FROM pg_stat_activity + WHERE state != 'idle' + ORDER BY duration DESC; +" +``` + +**Soluciones**: + +1. Ejecutar VACUUM: +```bash +psql -U flotillas -d flotillas_db -c "VACUUM ANALYZE;" +``` + +2. Verificar indices: +```bash +psql -U flotillas -d flotillas_db -c "\di" +``` + +### Disco lleno por ubicaciones + +**Verificar**: +```bash +psql -U flotillas -d flotillas_db -c " + SELECT pg_size_pretty(pg_total_relation_size('ubicaciones')); +" +``` + +**Solucion**: Comprimir datos antiguos (TimescaleDB): +```bash +psql -U flotillas -d flotillas_db -c " + SELECT compress_chunk(c) + FROM show_chunks('ubicaciones', older_than => INTERVAL '7 days') c; +" +``` + +--- + +## Problemas de Rendimiento + +### Servidor lento + +**Verificar recursos**: +```bash +# CPU y RAM +htop + +# Disco +iostat -x 1 + +# Conexiones de red +ss -s +``` + +**Soluciones**: + +1. Aumentar RAM de la VM +2. Agregar mas cores de CPU +3. Usar SSD para base de datos +4. Optimizar consultas lentas + +### API responde lento + +**Verificar**: +```bash +# Tiempo de respuesta +time curl http://localhost:8000/api/v1/health + +# Workers activos +ps aux | grep uvicorn +``` + +**Soluciones**: + +1. Aumentar workers en el servicio: +```bash +# Editar /etc/systemd/system/flotillas-api.service +# Cambiar --workers 4 a --workers 8 +systemctl daemon-reload +systemctl restart flotillas-api +``` + +2. Verificar conexiones a Redis: +```bash +redis-cli info clients +``` + +--- + +## Problemas de Meshtastic + +### Nodos no aparecen + +**Verificar MQTT**: +```bash +# Estado de Mosquitto +systemctl status mosquitto + +# Suscribirse para ver mensajes +mosquitto_sub -h localhost -t "flotillas/mesh/#" -u mesh_gateway -P password +``` + +**Verificar configuracion del gateway**: +- MQTT habilitado +- Servidor y credenciales correctos +- Topic correcto + +### Ubicaciones de mesh no se guardan + +**Verificar**: +```bash +journalctl -u flotillas-api | grep "meshtastic\|mesh" +``` + +**Solucion**: Verificar que el servicio MQTT esta corriendo en el backend. + +--- + +## Backup y Restauracion + +### Backup falla + +**Verificar**: +```bash +# Espacio en disco +df -h /opt/flotillas/backups + +# Permisos +ls -la /opt/flotillas/scripts/backup.sh +``` + +**Ejecutar manualmente para ver errores**: +```bash +/opt/flotillas/scripts/backup.sh 2>&1 | tee /tmp/backup.log +``` + +### Restauracion falla + +**Verificar integridad del backup**: +```bash +gunzip -t /opt/flotillas/backups/db_FECHA.sql.gz +``` + +**Restaurar paso a paso**: +```bash +# Parar servicios +systemctl stop flotillas-api + +# Recrear base de datos +psql -U postgres -c "DROP DATABASE flotillas_db;" +psql -U postgres -c "CREATE DATABASE flotillas_db OWNER flotillas;" + +# Restaurar +gunzip -c backup.sql.gz | psql -U flotillas -d flotillas_db + +# Iniciar servicios +systemctl start flotillas-api +``` + +--- + +## Comandos Utiles de Diagnostico + +```bash +# Estado general del sistema +systemctl status flotillas-api flotillas-web traccar mediamtx cloudflared + +# Uso de recursos +htop +df -h +free -h + +# Logs en tiempo real +journalctl -u flotillas-api -f + +# Conexiones activas +ss -tlnp + +# Verificar puertos +netstat -tlnp + +# Test de API +curl -s http://localhost:8000/api/v1/health | jq + +# Test de base de datos +psql -U flotillas -d flotillas_db -c "SELECT COUNT(*) FROM vehiculos;" + +# Ultimas ubicaciones +psql -U flotillas -d flotillas_db -c " + SELECT v.nombre, u.tiempo, u.lat, u.lng, u.velocidad + FROM ubicaciones u + JOIN vehiculos v ON u.vehiculo_id = v.id + ORDER BY u.tiempo DESC + LIMIT 10; +" + +# Alertas pendientes +psql -U flotillas -d flotillas_db -c " + SELECT COUNT(*) as pendientes FROM alertas WHERE atendida = false; +" +``` + +--- + +## Contacto de Soporte + +Si no puedes resolver el problema: + +1. Recolectar informacion: +```bash +# Crear archivo de diagnostico +{ + echo "=== FECHA ===" + date + echo "=== SERVICIOS ===" + systemctl status flotillas-api flotillas-web traccar mediamtx cloudflared + echo "=== RECURSOS ===" + free -h + df -h + echo "=== LOGS RECIENTES ===" + journalctl -u flotillas-api -n 50 --no-pager +} > /tmp/diagnostico.txt +``` + +2. Enviar `diagnostico.txt` al equipo de soporte diff --git a/docs/guias/usuario-admin.md b/docs/guias/usuario-admin.md new file mode 100644 index 0000000..83254c4 --- /dev/null +++ b/docs/guias/usuario-admin.md @@ -0,0 +1,412 @@ +# Manual del Administrador + +Guia completa para administrar el sistema FlotillasGPS. + +## Acceso al Sistema + +### Iniciar Sesion + +1. Abrir `https://flotillas.tudominio.com` en el navegador +2. Ingresar email y contrasena +3. Click en "Ingresar" + +### Cambiar Contrasena + +1. Click en tu nombre (esquina superior derecha) +2. Seleccionar "Configuracion" +3. En la seccion "Seguridad", click "Cambiar contrasena" +4. Ingresar contrasena actual y nueva +5. Click "Guardar" + +--- + +## Dashboard Principal + +El dashboard muestra un resumen de tu flota: + +### KPIs Principales + +- **Total Vehiculos**: Cantidad de vehiculos registrados +- **En Ruta**: Vehiculos actualmente en movimiento +- **Detenidos**: Vehiculos detenidos (motor encendido) +- **Offline**: Vehiculos sin conexion +- **Alertas**: Alertas pendientes de atencion + +### Mapa Resumen + +Muestra la ubicacion de todos los vehiculos. Click en un vehiculo para ver detalles. + +### Alertas Recientes + +Ultimas alertas generadas. Click en "Ver todas" para ir al centro de alertas. + +### Actividad Reciente + +Timeline de eventos del dia: viajes iniciados, entregas, cargas de combustible, etc. + +--- + +## Gestion de Vehiculos + +### Agregar Vehiculo + +1. Ir a **Flota** > **Vehiculos** +2. Click en **+ Agregar** +3. Completar informacion: + - **Nombre**: Identificador interno (ej: "Camion-01") + - **Placa**: Numero de placa + - **Marca/Modelo/Ano**: Datos del vehiculo + - **Tipo**: Auto, Camioneta, Camion, Moto, etc. + - **Grupo**: Asignar a un grupo (opcional) +4. Click **Guardar** + +### Asignar Dispositivo GPS + +Despues de agregar el vehiculo: + +1. En el detalle del vehiculo, ir a pestaña **Dispositivo** +2. Click **Asignar dispositivo** +3. Seleccionar tipo: + - **Traccar**: GPS hardware tradicional + - **App Movil**: Celular del conductor + - **Meshtastic**: Dispositivo LoRa +4. Ingresar identificador del dispositivo (IMEI o ID) +5. Click **Guardar** + +### Asignar Conductor + +1. En el detalle del vehiculo, ir a pestaña **General** +2. En "Conductor asignado", seleccionar de la lista +3. Click **Guardar** + +### Ver Ubicacion en Tiempo Real + +1. Ir a **Mapa** +2. Buscar el vehiculo en la lista lateral o en el mapa +3. Click en el marcador para ver popup con: + - Velocidad actual + - Direccion aproximada + - Estado del motor + - Nivel de combustible (si disponible) + +### Ver Historial de Viajes + +1. En el detalle del vehiculo, ir a pestaña **Viajes** +2. Seleccionar rango de fechas +3. Click en un viaje para ver detalles +4. Click **Replay** para reproducir el recorrido en el mapa + +--- + +## Gestion de Conductores + +### Agregar Conductor + +1. Ir a **Flota** > **Conductores** +2. Click **+ Agregar** +3. Completar informacion: + - Nombre y apellido + - Telefono (usado para login en app) + - Email (opcional) + - Numero de licencia + - Tipo de licencia + - Vencimiento de licencia +4. Click **Guardar** + +### Generar Codigo de Acceso para App + +1. En el detalle del conductor, click **Generar codigo** +2. Se mostrara un codigo de 6 digitos +3. Compartir con el conductor para que instale la app + +### Ver Estadisticas del Conductor + +En el detalle del conductor: + +- **Km recorridos**: Este mes y total +- **Score de eficiencia**: Basado en velocidad, frenados, aceleraciones +- **Viajes completados**: Cantidad de viajes +- **Tiempo en ruta**: Horas conduciendo +- **Infracciones**: Excesos de velocidad, salidas de geocerca + +--- + +## Centro de Alertas + +### Tipos de Alertas + +| Tipo | Severidad | Descripcion | +|------|-----------|-------------| +| Exceso de velocidad | Media | Vehiculo supero limite | +| Salida de geocerca | Critica | Salio de zona permitida | +| Entrada a geocerca restringida | Critica | Entro a zona prohibida | +| Parada prolongada | Media | Detenido mas de X minutos | +| Motor encendido detenido | Baja | Motor ON sin movimiento | +| Bateria baja | Media | Bateria del GPS baja | +| Vehiculo offline | Media | Sin señal por X minutos | +| Frenado brusco | Baja | Desaceleracion fuerte | +| Aceleracion brusca | Baja | Aceleracion fuerte | + +### Atender una Alerta + +1. Ir a **Alertas** +2. Click en la alerta para ver detalles +3. Revisar ubicacion en el mapa +4. Opcionalmente, agregar una nota +5. Click **Marcar como atendida** + +### Configurar Reglas de Alertas + +1. Ir a **Configuracion** > **Alertas** +2. Ajustar parametros: + - Velocidad maxima global + - Tiempo de parada para alerta + - Tiempo offline para alerta +3. Activar/desactivar notificaciones por email + +--- + +## Geocercas + +Las geocercas son zonas geograficas que generan alertas cuando un vehiculo entra o sale. + +### Crear Geocerca + +1. Ir a **Control** > **Geocercas** +2. Click **+ Nueva** +3. En el mapa, dibujar la zona: + - **Poligono**: Click en cada vertice, doble-click para cerrar + - **Circulo**: Click en el centro, arrastrar para definir radio + - **Rectangulo**: Click y arrastrar +4. Configurar: + - **Nombre**: Identificador de la zona + - **Color**: Para visualizacion en mapa + - **Alertar al entrar**: Si/No + - **Alertar al salir**: Si/No + - **Limite de velocidad**: Velocidad maxima dentro (opcional) + - **Horario activo**: Dias y horas en que aplica +5. Seleccionar vehiculos a los que aplica +6. Click **Guardar** + +### Tipos de Uso Comunes + +- **Zona de operacion**: Alerta si el vehiculo SALE +- **Zona restringida**: Alerta si el vehiculo ENTRA +- **Clientes**: Detectar llegada/salida de clientes +- **Zonas de velocidad**: Limite de velocidad en zonas escolares, etc. + +--- + +## Video en Vivo + +### Requisitos + +- Camara compatible (dashcam con RTSP, DVR, camara IP) +- Camara conectada a la red del vehiculo +- Vehiculo con conexion de datos + +### Agregar Camara + +1. En el detalle del vehiculo, ir a pestaña **Video** +2. Click **+ Agregar camara** +3. Configurar: + - **Nombre**: Ej. "Frontal", "Interior" + - **Posicion**: Frontal, Trasera, Interior, etc. + - **Tipo**: RTSP, ONVIF, etc. + - **URL**: URL del stream (ej: rtsp://192.168.1.100/stream) + - **Usuario/Contrasena**: Si requiere autenticacion +4. Click **Probar conexion** +5. Si funciona, click **Guardar** + +### Ver Video en Vivo + +1. Ir a **Video** > **En Vivo** +2. Seleccionar layout (1, 2x2, 3x3, 4x4) +3. Click en una celda para seleccionar camara +4. Controles disponibles: + - **Pantalla completa**: Maximizar camara + - **Captura**: Tomar foto + - **Grabar**: Iniciar grabacion manual + +### Ver Grabaciones + +1. Ir a **Video** > **Grabaciones** +2. Filtrar por: + - Vehiculo + - Camara + - Fecha y hora + - Tipo (continua, evento, manual) +3. Click en una grabacion para reproducir +4. Descargar si es necesario + +--- + +## Reportes + +### Generar Reporte + +1. Ir a **Reportes** +2. Seleccionar tipo: + - **Recorridos**: Km, tiempos, rutas + - **Combustible**: Consumo, cargas, rendimiento + - **Conductores**: Desempeno, infracciones + - **Alertas**: Resumen de incidentes + - **Mantenimiento**: Servicios realizados y pendientes + - **Ejecutivo**: Resumen general +3. Configurar parametros: + - Periodo (hoy, semana, mes, personalizado) + - Vehiculos (todos o seleccion) + - Formato (PDF, Excel, CSV) +4. Click **Generar** +5. Descargar cuando este listo + +### Programar Reportes Automaticos + +1. Ir a **Reportes** > **Programados** +2. Click **+ Programar** +3. Seleccionar tipo y parametros +4. Configurar frecuencia: + - Diario (hora) + - Semanal (dia y hora) + - Mensual (dia del mes) +5. Ingresar emails de destinatarios +6. Click **Guardar** + +--- + +## Mantenimiento + +### Tipos de Mantenimiento + +Configurar en **Configuracion** > **Tipos de Mantenimiento**: + +- Cambio de aceite (cada X km o X dias) +- Rotacion de llantas +- Revision de frenos +- Afinacion +- Revision general + +### Programar Mantenimiento + +1. En detalle del vehiculo, ir a pestaña **Mantenimiento** +2. Click **+ Programar** +3. Seleccionar tipo de servicio +4. Ingresar: + - Fecha programada o Km programado + - Taller/Proveedor + - Costo estimado + - Notas +5. Click **Guardar** + +### Registrar Servicio Realizado + +1. En el mantenimiento programado, click **Completar** +2. Ingresar: + - Fecha real + - Odometro actual + - Costo real + - Notas del servicio + - Adjuntar factura (opcional) +3. Click **Guardar** + +El sistema programara automaticamente el siguiente servicio segun los intervalos configurados. + +--- + +## Combustible + +### Registrar Carga + +Los conductores pueden registrar cargas desde la app. Tambien puedes hacerlo manualmente: + +1. En detalle del vehiculo, ir a pestaña **Combustible** +2. Click **+ Registrar carga** +3. Ingresar: + - Litros + - Precio por litro + - Odometro + - Estacion (opcional) +4. Click **Guardar** + +### Ver Consumo y Rendimiento + +En la pestaña **Combustible** del vehiculo: + +- Grafico de consumo mensual +- Rendimiento promedio (km/L) +- Comparativa con otros vehiculos +- Historial de cargas + +--- + +## Mensajes a Conductores + +### Enviar Mensaje + +1. En detalle del conductor, click **Enviar mensaje** +2. O ir a **Comunicacion** > **Mensajes** +3. Seleccionar conductor(es) +4. Escribir mensaje +5. Click **Enviar** + +El conductor recibira notificacion push en la app. + +### Ver Respuestas + +Los mensajes y respuestas aparecen como conversacion en el detalle del conductor. + +--- + +## Configuracion del Sistema + +### General + +- **Zona horaria**: Importante para reportes correctos +- **Unidades**: Kilometros/Millas, Litros/Galones +- **Moneda**: Para costos de combustible y mantenimiento + +### Alertas + +- Velocidad maxima global +- Tiempo de parada para alerta +- Tiempo offline para alerta +- Notificaciones por email + +### Retencion de Datos + +- Ubicaciones detalladas: X dias +- Videos: X dias +- Alertas: X dias + +--- + +## Backup y Restauracion + +### Backup Manual + +```bash +ssh admin@servidor +/opt/flotillas/scripts/backup.sh +``` + +Los backups se guardan en `/opt/flotillas/backups/` + +### Restaurar Backup + +```bash +/opt/flotillas/scripts/restore.sh /opt/flotillas/backups/db_20260121.sql.gz +``` + +### Backups Automaticos + +Se ejecutan diariamente a las 3:00 AM. Se mantienen los ultimos 7 dias. + +--- + +## Soporte + +Para problemas tecnicos: + +1. Revisar [Solucion de Problemas](troubleshooting.md) +2. Revisar logs: `journalctl -u flotillas-api -f` +3. Contactar soporte tecnico diff --git a/docs/guias/usuario-conductor.md b/docs/guias/usuario-conductor.md new file mode 100644 index 0000000..ce2e760 --- /dev/null +++ b/docs/guias/usuario-conductor.md @@ -0,0 +1,332 @@ +# Manual del Conductor - App FlotillasGPS + +Guia completa para usar la aplicacion movil de FlotillasGPS. + +## Instalacion de la App + +### Android + +1. Abrir Play Store +2. Buscar "FlotillasGPS Conductor" +3. Instalar la aplicacion +4. Abrir la app + +### iPhone + +1. Abrir App Store +2. Buscar "FlotillasGPS Conductor" +3. Instalar la aplicacion +4. Abrir la app + +--- + +## Primer Inicio de Sesion + +### Obtener Codigo de Acceso + +Tu administrador te proporcionara: +- Tu numero de telefono registrado +- Un codigo de acceso de 6 digitos + +### Iniciar Sesion + +1. Abrir la app +2. Ingresar tu numero de telefono +3. Ingresar el codigo de 6 digitos +4. Tocar **Ingresar** + +### Permisos Necesarios + +La app te pedira permisos. Es importante aceptarlos todos: + +| Permiso | Para que se usa | +|---------|-----------------| +| Ubicacion (siempre) | Enviar tu posicion aunque la app este cerrada | +| Notificaciones | Recibir mensajes del administrador | +| Camara | Tomar fotos de tickets (opcional) | + +**IMPORTANTE**: Sin el permiso de ubicacion "siempre", la app no puede rastrear tu posicion correctamente. + +--- + +## Pantalla Principal + +Al abrir la app veras: + +### Estado del Vehiculo + +- Nombre del vehiculo asignado +- Estado de conexion GPS (verde = enviando) +- Placa + +### Boton de Viaje + +- **INICIAR VIAJE**: Toca para comenzar un viaje +- **EN VIAJE**: Muestra que estas en ruta + +### Resumen del Dia + +- Kilometros recorridos hoy +- Tiempo en ruta +- Numero de viajes +- Tu score de eficiencia + +--- + +## Iniciar un Viaje + +1. En la pantalla principal, toca **INICIAR VIAJE** +2. Confirma el inicio +3. La app comenzara a registrar tu recorrido + +Durante el viaje: +- Tu ubicacion se envia cada 10 segundos +- El administrador puede ver tu posicion en tiempo real +- Se registran velocidad, paradas y ruta + +--- + +## Durante el Viaje + +### Ver el Mapa + +La pantalla de viaje muestra: +- Tu ubicacion actual (punto azul) +- Tu ruta recorrida (linea) +- Proxima parada si hay ruta asignada + +### Registrar una Parada + +Cuando hagas una parada: + +1. Toca **PARADA** +2. Selecciona el tipo: + - Comida/Descanso + - Carga de combustible + - Entrega a cliente + - Problema mecanico + - Trafico + - Otro +3. Agrega notas si es necesario +4. Toca **CONFIRMAR** + +La parada queda registrada con: +- Hora de inicio +- Ubicacion +- Tipo de parada + +Cuando reanudes, toca **CONTINUAR VIAJE**. + +--- + +## Cargar Combustible + +Cuando cargues combustible: + +1. Durante una parada, selecciona **Carga de combustible** +2. O desde el menu, toca **Combustible** +3. Ingresa los datos: + - **Litros**: Cantidad cargada + - **Precio por litro**: Costo + - **Odometro**: Kilometraje actual + - **Estacion**: Nombre (opcional) +4. Toca **FOTO** para fotografiar el ticket +5. Toca **GUARDAR** + +Esto ayuda al administrador a: +- Calcular el rendimiento del vehiculo +- Llevar control de gastos +- Detectar consumos anomalos + +--- + +## Finalizar Viaje + +Cuando llegues a tu destino final: + +1. Toca **FINALIZAR VIAJE** +2. Confirma la finalizacion +3. Se guardara el resumen: + - Distancia total + - Tiempo de viaje + - Paradas realizadas + +--- + +## Mensajes + +### Ver Mensajes + +1. Toca el icono de mensajes o la notificacion +2. Veras la lista de mensajes del administrador +3. Toca un mensaje para leerlo completo + +### Los mensajes no leidos aparecen con punto rojo + +Tipos de mensajes comunes: +- Cambios de ruta +- Instrucciones especiales +- Recordatorios +- Avisos importantes + +--- + +## Emergencia (SOS) + +En caso de emergencia: + +1. Toca el boton **EMERGENCIA** o **SOS** +2. **Manten presionado por 3 segundos** +3. Se enviara automaticamente: + - Tu ubicacion exacta + - Alerta al administrador + - Marca de tiempo + +El administrador recibira una notificacion inmediata. + +### Cuando usar el SOS: + +- Accidente +- Robo o asalto +- Emergencia medica +- Vehiculo descompuesto en zona peligrosa +- Cualquier situacion de peligro + +### Contacto de emergencia + +La app muestra el numero de telefono de emergencia de tu empresa. Puedes tocarlo para llamar directamente. + +--- + +## Mi Perfil + +### Ver mis Estadisticas + +1. Toca **Perfil** en la barra inferior +2. Veras: + - Tu score de eficiencia (1-5 estrellas) + - Km recorridos este mes + - Viajes completados + - Porcentaje de entregas a tiempo + +### Que afecta mi Score + +| Positivo | Negativo | +|----------|----------| +| Entregas a tiempo | Excesos de velocidad | +| Conduccion suave | Frenados bruscos | +| Respetar limites | Aceleraciones fuertes | +| Registrar paradas | Desvios de ruta | + +--- + +## Configuracion de la App + +### Ajustes disponibles + +1. Toca **Perfil** > **Configuracion** + +| Opcion | Descripcion | +|--------|-------------| +| Notificaciones | Activar/desactivar alertas | +| Tema | Claro u oscuro | +| Sonidos | Sonidos de notificacion | +| Idioma | Espanol/Ingles | + +### Permisos de Ubicacion + +Si tienes problemas con el GPS: + +1. Ir a **Configuracion** del telefono +2. Buscar la app **FlotillasGPS** +3. Tocar **Permisos** +4. Asegurar que **Ubicacion** este en **Siempre permitir** + +--- + +## Uso sin Conexion a Internet + +La app funciona aunque no tengas internet: + +- Tu ubicacion se sigue registrando +- Los datos se guardan en el telefono +- Cuando vuelvas a tener conexion, se envian automaticamente + +**Maximo almacenamiento offline**: 1000 ubicaciones (~3 horas de viaje) + +--- + +## Consejos para Mejor Funcionamiento + +### Bateria + +La app usa aproximadamente 5% de bateria por hora. Para optimizar: + +- Mantener el telefono cargado en el vehiculo +- No cerrar la app manualmente +- No usar ahorradores de bateria que cierren apps + +### GPS + +Para mejor precision: + +- Colocar el telefono donde vea el cielo (parabrisas) +- No cubrir el telefono +- Mantener GPS activado + +### Datos Moviles + +Consumo aproximado: 5 MB por dia de uso continuo. + +--- + +## Preguntas Frecuentes + +### La app no envia mi ubicacion + +1. Verificar permiso de ubicacion "Siempre" +2. Verificar que GPS este activado +3. Verificar conexion a internet +4. Reiniciar la app + +### No recibo mensajes + +1. Verificar permiso de notificaciones +2. Verificar que la app no este en ahorro de bateria +3. Verificar conexion a internet + +### El boton de viaje no aparece + +Puede que no tengas un vehiculo asignado. Contacta a tu administrador. + +### Olvide mi codigo de acceso + +Contacta a tu administrador para que genere un nuevo codigo. + +### Como cambio de vehiculo + +Tu administrador debe reasignarte desde el sistema web. + +--- + +## Soporte + +Si tienes problemas con la app: + +1. Intenta reiniciar la app +2. Verifica tu conexion a internet +3. Contacta a tu administrador + +--- + +## Resumen Rapido + +| Accion | Como hacerlo | +|--------|--------------| +| Iniciar viaje | Pantalla principal > INICIAR VIAJE | +| Registrar parada | Durante viaje > PARADA | +| Cargar combustible | Durante parada > Combustible | +| Finalizar viaje | Durante viaje > FINALIZAR | +| Ver mensajes | Icono mensajes o notificacion | +| Emergencia | Boton SOS (mantener 3 seg) | +| Ver estadisticas | Perfil | diff --git a/docs/guias/video-streaming.md b/docs/guias/video-streaming.md new file mode 100644 index 0000000..085567d --- /dev/null +++ b/docs/guias/video-streaming.md @@ -0,0 +1,377 @@ +# Configuracion de Video Streaming + +Guia para configurar camaras y video streaming en FlotillasGPS. + +## Arquitectura de Video + +``` +Camaras en Vehiculos Servidor Dashboard/App + | | | + [Dashcam] --RTSP--> [MediaMTX] --WebRTC--> [Navegador] + [DVR] --RTSP--> | --HLS----> [App Movil] + [Cam IP] --RTSP--> | + | + [Grabaciones] + /opt/flotillas/videos/ +``` + +## Tipos de Camaras Soportadas + +### 1. Dashcams con RTSP + +Marcas recomendadas: +- **Viofo** (A129, A139) - Excelente calidad, WiFi +- **BlackVue** (DR900X, DR750X) - Premium, Cloud opcional +- **Thinkware** (U1000, Q800) - Buena integracion + +Requisitos: +- Soporte RTSP +- Conexion WiFi o Ethernet +- Alimentacion 12V continua + +### 2. DVR Vehiculares + +Para flotas comerciales: +- **Hikvision Mobile DVR** - 4/8 canales +- **Dahua Mobile DVR** - Industrial +- **Howen** - Economico + +Caracteristicas: +- Multiples canales (interior, exterior, lateral) +- Almacenamiento local SD/HDD +- 3G/4G integrado + +### 3. Camaras IP Genericas + +Cualquier camara con: +- Protocolo RTSP u ONVIF +- Resolucion minima 720p +- Alimentacion PoE o 12V + +### 4. Celular como Dashcam + +La app FlotillasGPS puede usar la camara del celular: +- Sin costo adicional +- Calidad depende del celular +- Consume bateria y datos + +--- + +## Configuracion de Camaras + +### Obtener URL RTSP + +Formato tipico: +``` +rtsp://usuario:password@IP:puerto/stream +``` + +Ejemplos por marca: + +| Marca | URL RTSP | +|-------|----------| +| Hikvision | `rtsp://admin:pass@IP:554/Streaming/Channels/101` | +| Dahua | `rtsp://admin:pass@IP:554/cam/realmonitor?channel=1&subtype=0` | +| Viofo | `rtsp://IP:554/live/ch00_0` | +| Generic | `rtsp://IP:554/stream1` | + +### Verificar Stream + +Antes de agregar al sistema, probar con VLC o ffprobe: + +```bash +# Con ffprobe +ffprobe rtsp://admin:pass@192.168.1.100:554/stream1 + +# Con VLC +vlc rtsp://admin:pass@192.168.1.100:554/stream1 +``` + +### Agregar Camara en el Dashboard + +1. Ir a detalle del vehiculo > pestaña **Video** +2. Click **+ Agregar camara** +3. Completar: + - **Nombre**: Ej. "Frontal", "Interior" + - **Posicion**: Frontal, Trasera, Interior, Lateral izq/der + - **Tipo**: RTSP, ONVIF + - **URL Stream**: URL RTSP completa + - **Usuario/Password**: Si requiere autenticacion +4. Click **Probar conexion** +5. Si funciona, click **Guardar** + +--- + +## Configuracion de MediaMTX + +### Configuracion Basica + +El archivo `/etc/mediamtx/mediamtx.yml` ya viene configurado. Opciones importantes: + +```yaml +# WebRTC (para dashboard web) +webrtc: yes +webrtcAddress: :8889 +webrtcAllowOrigin: '*' + +# HLS (para app movil y compatibilidad) +hls: yes +hlsAddress: :8888 +hlsSegmentDuration: 1s +hlsSegmentCount: 3 +``` + +### Agregar Camara Manualmente + +Si necesitas agregar una camara directamente: + +```bash +# Via API de MediaMTX +curl -X POST http://localhost:9997/v2/config/paths/add/cam_vehiculo1_frontal \ + -H "Content-Type: application/json" \ + -d '{ + "source": "rtsp://admin:pass@192.168.1.100:554/stream1", + "sourceOnDemand": true, + "sourceOnDemandStartTimeout": "10s", + "sourceOnDemandCloseAfter": "10s" + }' +``` + +### Verificar Streams Activos + +```bash +# Listar paths +curl http://localhost:9997/v2/paths/list + +# Ver estado de un path +curl http://localhost:9997/v2/paths/get/cam_vehiculo1_frontal +``` + +--- + +## Conexion de Red + +### Opcion 1: Router 4G en Vehiculo + +``` +[Camara] --Ethernet/WiFi--> [Router 4G] --Internet--> [Servidor] +``` + +Configuracion: +1. Router 4G con IP publica o VPN +2. Port forward del puerto RTSP al servidor +3. O usar VPN para conexion segura + +Routers recomendados: +- Teltonika RUT240/RUT955 +- Mikrotik LtAP +- Sierra Wireless + +### Opcion 2: Camara con 4G Integrado + +Camaras con SIM integrada: +- Hikvision DS-2CD6425G1 +- Dahua IPC-HFW4X31E-SE + +Ventaja: Sin router adicional +Desventaja: Mas costoso, SIM por camara + +### Opcion 3: VPN Site-to-Site + +``` +[Vehiculo] --VPN--> [Servidor] + | +[Camara con IP local] +``` + +Configuracion: +1. Router en vehiculo con cliente VPN (WireGuard/OpenVPN) +2. Servidor con servidor VPN +3. Acceso a camara via IP de VPN + +--- + +## Grabacion de Video + +### Tipos de Grabacion + +1. **Continua**: Graba todo el tiempo +2. **Por eventos**: Solo cuando hay alertas +3. **Manual**: Iniciada por el operador +4. **Programada**: En horarios especificos + +### Configuracion de Grabacion + +En el dashboard, por camara: + +- **Grabar continuo**: Si/No +- **Grabar eventos**: Si/No +- **Calidad continuo**: Baja/Media/Alta +- **Calidad eventos**: Media/Alta +- **Pre-evento**: Segundos antes del evento (buffer) +- **Post-evento**: Segundos despues del evento +- **Solo con motor encendido**: Si/No + +### Almacenamiento + +Estimacion de espacio: + +| Calidad | Bitrate | Por hora | Por dia (10h) | +|---------|---------|----------|---------------| +| Baja (480p) | 1 Mbps | 450 MB | 4.5 GB | +| Media (720p) | 2 Mbps | 900 MB | 9 GB | +| Alta (1080p) | 4 Mbps | 1.8 GB | 18 GB | + +Para 20 vehiculos con grabacion continua 720p: +- Por dia: 180 GB +- Por mes: 5.4 TB + +Recomendacion: +- Grabacion continua solo en horario laboral +- O solo grabacion por eventos + +### Retencion + +Configurar en **Configuracion** > **Retencion de datos**: + +- Videos continuos: 7-14 dias +- Videos de eventos: 30-90 dias + +Script de limpieza automatica: +```bash +# Ejecutar diariamente via cron +find /opt/flotillas/videos -name "*.mp4" -mtime +30 -delete +``` + +--- + +## Ver Video en el Dashboard + +### Video en Vivo + +1. Ir a **Video** > **En Vivo** +2. Seleccionar layout (1, 2x2, 3x3) +3. Click en celda para seleccionar camara +4. Controles: + - **Pantalla completa**: Expandir + - **Captura**: Tomar screenshot + - **Grabar**: Iniciar grabacion manual + +### Video Sincronizado con Viaje + +1. Ir a **Viajes** > seleccionar viaje > **Replay** +2. El video se sincroniza con la posicion en el mapa +3. Usar timeline para navegar + +### Buscar Grabaciones + +1. Ir a **Video** > **Grabaciones** +2. Filtrar por: + - Vehiculo + - Camara + - Fecha/hora + - Tipo (continua, evento, manual) +3. Click para reproducir +4. Descargar si es necesario + +--- + +## Eventos de Video + +### Deteccion Automatica + +El sistema puede detectar eventos basados en: + +- **Datos de GPS**: Frenado brusco, aceleracion, impacto +- **Sensores de camara**: G-sensor integrado +- **Alertas del sistema**: Al generar alerta, marca el video + +### Configurar Deteccion + +En **Configuracion** > **Video** > **Eventos**: + +- Umbral de frenado brusco: -0.5 G +- Umbral de aceleracion: 0.4 G +- Umbral de impacto: 2.0 G + +### Revisar Eventos + +1. Ir a **Video** > **Eventos** +2. Lista de eventos con severidad +3. Click para ver clip +4. Marcar como revisado +5. Agregar notas + +--- + +## Solucion de Problemas de Video + +### Camara no conecta + +1. Verificar URL RTSP es correcta +2. Verificar credenciales +3. Verificar conectividad de red +4. Probar con ffprobe: +```bash +ffprobe -v error rtsp://admin:pass@IP:554/stream +``` + +### Video con lag alto + +Posibles causas: +- Ancho de banda insuficiente +- Servidor sobrecargado +- Bitrate muy alto + +Soluciones: +- Reducir calidad en camara +- Usar sub-stream (menor resolucion) +- Verificar conexion de datos del vehiculo + +### Video se corta frecuentemente + +Posibles causas: +- Conexion 4G inestable +- Timeout de conexion + +Soluciones: +- Aumentar timeouts en MediaMTX +- Configurar reconexion automatica +- Usar buffering mas largo + +### No se guardan grabaciones + +Verificar: +```bash +# Espacio en disco +df -h /opt/flotillas/videos + +# Permisos +ls -la /opt/flotillas/videos + +# Logs de MediaMTX +journalctl -u mediamtx -f +``` + +--- + +## Seguridad + +### Autenticacion + +- URLs RTSP internas no expuestas a internet +- Acceso a video solo via dashboard autenticado +- Tokens temporales para streams + +### Encriptacion + +- RTSP puede usar RTSPS (SSL) +- WebRTC usa DTLS (encriptado por defecto) +- HLS puede usar HTTPS + +### Mejores Practicas + +1. No exponer puertos de video directamente a internet +2. Usar VPN para acceso remoto a camaras +3. Cambiar credenciales default de camaras +4. Actualizar firmware de camaras regularmente diff --git a/docs/plans/2026-01-21-flotillas-gps-design.md b/docs/plans/2026-01-21-flotillas-gps-design.md new file mode 100644 index 0000000..647a50b --- /dev/null +++ b/docs/plans/2026-01-21-flotillas-gps-design.md @@ -0,0 +1,232 @@ +# Sistema de Monitoreo de Flotillas GPS + IA + +## Resumen Ejecutivo + +Sistema completo de monitoreo de flotillas vehiculares con rastreo GPS en tiempo real, video streaming, integración con dispositivos Meshtastic, y app móvil para conductores. Diseñado para escala pequeña (1-20 vehículos) con arquitectura preparada para crecimiento futuro. + +**Fecha:** 2026-01-21 +**Estado:** Pendiente de aprobación + +--- + +## Decisiones de Diseño + +| Aspecto | Decisión | +|---------|----------| +| Escala inicial | Pequeña (1-20 vehículos) | +| Dispositivos GPS | Traccar-compatible (protocolo abierto) | +| Meshtastic | Módulo experimental/opcional | +| App móvil | React Native para conductores | +| Despliegue | VM única en Proxmox (Ubuntu 22.04) | +| Acceso web | Cloudflare Zero Trust Tunnel | +| GPS hardware | Puerto TCP 5055 abierto | +| IA/ML | Básico ahora, arquitectura preparada | +| Backend | Python/FastAPI | +| Frontend | React + TypeScript + TailwindCSS | +| Base de datos | PostgreSQL + TimescaleDB | +| Cache | Redis | +| Video | MediaMTX (RTSP/WebRTC/HLS) | +| Usuarios | Solo admin (sin multi-tenant) | + +--- + +## Arquitectura General + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ PROXMOX - VM ÚNICA │ +│ (Ubuntu 22.04 LTS) │ +│ RAM: 4-8GB | CPU: 4 cores │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ CLOUDFLARED (Tunnel) │ │ +│ │ flotillas.tudominio.com → localhost │ │ +│ └─────────────────────┬───────────────────────────────────┘ │ +│ │ │ +│ ┌──────────────┴──────────────┐ │ +│ ▼ ▼ │ +│ ┌───────────┐ ┌───────────┐ │ +│ │ React │ │ FastAPI │ │ +│ │ Frontend │ │ Backend │ │ +│ │ :3000 │ │ :8000 │ │ +│ └───────────┘ └─────┬─────┘ │ +│ │ │ +│ ┌─────────────┤ │ +│ ▼ ▼ │ +│ ┌───────────┐ ┌─────────────────────────┐ │ +│ │ Traccar │ │ PostgreSQL + TimescaleDB │ │ +│ │ Server │ │ Redis │ │ +│ │ :5055 ◄──┼──┼─────────────────────────┘ │ +│ └─────┬─────┘ │ │ +│ │ │ │ +└────────┼────────┼──────────────────────────────────────────────┘ + │ │ + ▼ │ + ┌──────────┐ │ + │ PUERTO │ │ Cloudflare Tunnel (HTTPS) + │ TCP 5055 │ │ + │ (abierto)│ │ + └────┬─────┘ │ + │ │ + ▼ ▼ + 📡 GPS 📱 App 📻 Meshtastic 💻 Dashboard + Hardware Móvil (opcional) Admin +``` + +--- + +## Modelo de Datos + +### Tablas Principales + +- **vehiculos** - Información de vehículos +- **conductores** - Datos de conductores +- **dispositivos** - GPS y dispositivos asociados +- **ubicaciones** (HYPERTABLE) - Posiciones GPS +- **viajes** - Registro de viajes +- **paradas** - Paradas durante viajes +- **alertas** - Sistema de alertas +- **geocercas** - Zonas geográficas +- **pois** - Puntos de interés +- **cargas_combustible** - Registro de cargas +- **mantenimientos** - Programación de mantenimiento +- **camaras** - Cámaras de video +- **grabaciones** - Videos almacenados +- **eventos_video** - Eventos detectados en video +- **mensajes** - Comunicación admin-conductor +- **configuracion** - Configuración del sistema +- **audit_log** - Logs de auditoría + +--- + +## API Endpoints + +### Principales + +``` +/api/v1 +├── /auth → Autenticación JWT +├── /vehiculos → CRUD vehículos +├── /conductores → CRUD conductores +├── /ubicaciones → Recepción GPS + WebSocket +├── /viajes → Gestión y replay de viajes +├── /alertas → Centro de alertas +├── /geocercas → Gestión de geocercas +├── /pois → Puntos de interés +├── /combustible → Registro de cargas +├── /mantenimiento → Programación servicios +├── /video → Streaming y grabaciones +├── /reportes → Generación de reportes +├── /meshtastic → Integración mesh (opcional) +└── /configuracion → Settings del sistema + +/ws/v1 +├── /ubicaciones → Stream tiempo real +├── /alertas → Notificaciones push +└── /video/{id} → Señalización WebRTC +``` + +--- + +## Interfaces de Usuario + +### Dashboard Web + +- **Login** - Autenticación con tema oscuro +- **Dashboard** - KPIs, mapa resumen, alertas, actividad +- **Mapa** - Vista tiempo real con filtros y capas +- **Flota** - Cards/tabla de vehículos y conductores +- **Detalle vehículo** - Info completa, viajes, estadísticas +- **Alertas** - Centro de gestión de alertas +- **Video** - Grid de cámaras en vivo +- **Grabaciones** - Búsqueda y reproducción +- **Viajes** - Lista y replay con video sincronizado +- **Geocercas** - Editor visual en mapa +- **Reportes** - Generación y programación +- **Configuración** - Settings del sistema + +### App Móvil (Conductores) + +- **Login** - Por teléfono + código +- **Home** - Estado, botón iniciar viaje, resumen +- **Viaje activo** - Mapa, próxima parada, controles +- **Paradas** - Registro con tipo y notas +- **Combustible** - Registro de cargas +- **Mensajes** - Comunicación con admin +- **Emergencia** - Botón SOS +- **Perfil** - Estadísticas y configuración +- **Cámara** - Dashcam opcional + +--- + +## Seguridad + +- JWT con refresh tokens (24h / 7 días) +- API keys únicas por dispositivo +- Rate limiting (100 req/min general, 1 req/seg ubicaciones) +- Headers de seguridad (CSP, HSTS, X-Frame-Options) +- Passwords hasheados con bcrypt +- Datos sensibles encriptados (Fernet) +- Logs de auditoría completos +- Firewall UFW + Fail2ban +- Cloudflare WAF y DDoS protection + +--- + +## Despliegue + +### Requisitos Mínimos VM + +- CPU: 4 cores +- RAM: 8 GB +- Disco Sistema: 60 GB SSD +- Disco Videos: 2 TB HDD +- OS: Ubuntu 22.04 LTS + +### Servicios + +- `flotillas-api.service` - Backend FastAPI +- `flotillas-web.service` - Frontend React +- `traccar.service` - Servidor GPS +- `mediamtx.service` - Streaming video +- `cloudflared.service` - Tunnel Cloudflare +- `postgresql.service` - Base de datos +- `redis.service` - Cache + +### Puertos + +- **5055/TCP** (abierto) - GPS devices +- **5432** (interno) - PostgreSQL +- **6379** (interno) - Redis +- **8000** (interno) - API +- **3000** (interno) - Frontend +- **8554/8888/8889** (interno) - Video + +--- + +## Entregables + +1. **Código fuente completo** (backend, frontend, mobile) +2. **Scripts de instalación automatizada** +3. **Configuraciones de servicios** +4. **Documentación técnica completa** +5. **Guías de usuario (admin y conductor)** +6. **API Reference** + +--- + +## Repositorio + +**URL:** https://git.consultoria-as.com +**Nombre:** flotillas-gps + +--- + +## Aprobación + +- [ ] Arquitectura aprobada +- [ ] Modelo de datos aprobado +- [ ] Diseño de interfaces aprobado +- [ ] Plan de despliegue aprobado +- [ ] Listo para implementación diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..26e6fb1 --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,45 @@ +# Dependencies +node_modules +npm-debug.log +yarn-debug.log +yarn-error.log + +# Build output +dist +build + +# Development files +.git +.gitignore +.env.local +.env.development +.env.test + +# IDE +.vscode +.idea +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Testing +coverage +*.test.ts +*.test.tsx +*.spec.ts +*.spec.tsx +__tests__ +jest.config.* + +# Documentation +README.md +docs +*.md + +# Docker +Dockerfile* +docker-compose* +.dockerignore diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..e571641 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,36 @@ +# Build stage +FROM node:20-alpine AS builder + +# Set working directory +WORKDIR /app + +# Copy package files +COPY package*.json ./ + +# Install dependencies +RUN npm ci + +# Copy source files +COPY . . + +# Build the application +RUN npm run build + +# Production stage +FROM nginx:alpine AS production + +# Copy custom nginx config +COPY nginx.conf /etc/nginx/conf.d/default.conf + +# Copy built files from builder stage +COPY --from=builder /app/dist /usr/share/nginx/html + +# Add healthcheck +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://localhost:80/health || exit 1 + +# Expose port +EXPOSE 80 + +# Start nginx +CMD ["nginx", "-g", "daemon off;"] diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..5ac4eba --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,19 @@ + + + + + + + + + Flotillas GPS - Sistema de Monitoreo + + + + + + +
+ + + diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..fe10fea --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,104 @@ +server { + listen 80; + listen [::]:80; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + # Gzip compression + gzip on; + gzip_vary on; + gzip_min_length 1024; + gzip_proxied expired no-cache no-store private auth; + gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml application/javascript application/json; + gzip_disable "MSIE [1-6]\."; + + # Security headers + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + + # Health check endpoint + location /health { + access_log off; + return 200 "healthy\n"; + add_header Content-Type text/plain; + } + + # Static assets - long cache + location /assets/ { + expires 1y; + add_header Cache-Control "public, immutable"; + try_files $uri =404; + } + + # Favicon and static files + location ~* \.(ico|svg|png|jpg|jpeg|gif|webp|woff|woff2|ttf|eot)$ { + expires 1M; + add_header Cache-Control "public"; + try_files $uri =404; + } + + # JavaScript and CSS with content hash + location ~* \.(js|css)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + try_files $uri =404; + } + + # API proxy (if needed - configure backend URL) + location /api/ { + proxy_pass http://backend:8000/api/; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_cache_bypass $http_upgrade; + proxy_read_timeout 86400s; + proxy_send_timeout 86400s; + } + + # WebSocket proxy + location /ws/ { + proxy_pass http://backend:8000/ws/; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 86400s; + proxy_send_timeout 86400s; + } + + # SPA fallback - serve index.html for all routes + location / { + try_files $uri $uri/ /index.html; + + # No cache for index.html + location = /index.html { + expires -1; + add_header Cache-Control "no-store, no-cache, must-revalidate"; + } + } + + # Deny access to hidden files + location ~ /\. { + deny all; + access_log off; + log_not_found off; + } + + # Error pages + error_page 404 /index.html; + error_page 500 502 503 504 /50x.html; + location = /50x.html { + root /usr/share/nginx/html; + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..f425377 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,44 @@ +{ + "name": "flotillas-gps-frontend", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", + "preview": "vite preview" + }, + "dependencies": { + "@headlessui/react": "^1.7.18", + "@heroicons/react": "^2.1.1", + "@tanstack/react-query": "^5.17.19", + "axios": "^1.6.5", + "clsx": "^2.1.0", + "date-fns": "^3.3.1", + "leaflet": "^1.9.4", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-leaflet": "^4.2.1", + "react-router-dom": "^6.21.3", + "recharts": "^2.10.4", + "zustand": "^4.5.0" + }, + "devDependencies": { + "@types/leaflet": "^1.9.8", + "@types/node": "^20.11.5", + "@types/react": "^18.2.48", + "@types/react-dom": "^18.2.18", + "@typescript-eslint/eslint-plugin": "^6.19.0", + "@typescript-eslint/parser": "^6.19.0", + "@vitejs/plugin-react": "^4.2.1", + "autoprefixer": "^10.4.17", + "eslint": "^8.56.0", + "eslint-plugin-react-hooks": "^4.6.0", + "eslint-plugin-react-refresh": "^0.4.5", + "postcss": "^8.4.33", + "tailwindcss": "^3.4.1", + "typescript": "^5.3.3", + "vite": "^5.0.12" + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..2e7af2b --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 0000000..1d84af5 --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..574b321 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,95 @@ +import { Routes, Route, Navigate } from 'react-router-dom' +import { useAuthStore } from './store/authStore' +import MainLayout from './components/layout/MainLayout' + +// Pages +import Login from './pages/Login' +import Dashboard from './pages/Dashboard' +import Mapa from './pages/Mapa' +import Vehiculos from './pages/Vehiculos' +import VehiculoDetalle from './pages/VehiculoDetalle' +import Conductores from './pages/Conductores' +import Alertas from './pages/Alertas' +import Viajes from './pages/Viajes' +import ViajeReplay from './pages/ViajeReplay' +import VideoLive from './pages/VideoLive' +import Grabaciones from './pages/Grabaciones' +import Geocercas from './pages/Geocercas' +import POIs from './pages/POIs' +import Combustible from './pages/Combustible' +import Mantenimiento from './pages/Mantenimiento' +import Reportes from './pages/Reportes' +import Configuracion from './pages/Configuracion' +import NotFound from './pages/NotFound' + +interface ProtectedRouteProps { + children: React.ReactNode +} + +function ProtectedRoute({ children }: ProtectedRouteProps) { + const isAuthenticated = useAuthStore((state) => state.isAuthenticated) + + if (!isAuthenticated) { + return + } + + return <>{children} +} + +function PublicRoute({ children }: ProtectedRouteProps) { + const isAuthenticated = useAuthStore((state) => state.isAuthenticated) + + if (isAuthenticated) { + return + } + + return <>{children} +} + +function App() { + return ( + + {/* Public routes */} + + + + } + /> + + {/* Protected routes */} + + + + } + > + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + {/* 404 */} + } /> + + ) +} + +export default App diff --git a/frontend/src/api/alertas.ts b/frontend/src/api/alertas.ts new file mode 100644 index 0000000..e82c550 --- /dev/null +++ b/frontend/src/api/alertas.ts @@ -0,0 +1,79 @@ +import { api } from './client' +import { + Alerta, + AlertaCreate, + AlertaConfiguracion, + FiltrosAlertas, + PaginatedResponse, +} from '@/types' + +export const alertasApi = { + // List alertas with pagination and filters + list: (params?: Partial & { + page?: number + pageSize?: number + }): Promise> => { + return api.get>('/alertas', params) + }, + + // Get active alerts + getActivas: (): Promise => { + return api.get('/alertas/activas') + }, + + // Get single alerta + get: (id: string): Promise => { + return api.get(`/alertas/${id}`) + }, + + // Create manual alerta + create: (data: AlertaCreate): Promise => { + return api.post('/alertas', data) + }, + + // Acknowledge alerta + reconocer: (id: string, notas?: string): Promise => { + return api.post(`/alertas/${id}/reconocer`, { notas }) + }, + + // Resolve alerta + resolver: (id: string, notas?: string): Promise => { + return api.post(`/alertas/${id}/resolver`, { notas }) + }, + + // Ignore alerta + ignorar: (id: string, notas?: string): Promise => { + return api.post(`/alertas/${id}/ignorar`, { notas }) + }, + + // Get alert count by type + getConteo: (): Promise> => { + return api.get('/alertas/conteo') + }, + + // Get alert statistics + getStats: (params?: { desde?: string; hasta?: string }): Promise<{ + total: number + porTipo: Record + porPrioridad: Record + porEstado: Record + tendencia: Array<{ fecha: string; cantidad: number }> + }> => { + return api.get('/alertas/stats', params) + }, + + // Get alert configuration + getConfiguracion: (): Promise => { + return api.get('/alertas/configuracion') + }, + + // Update alert configuration + updateConfiguracion: (config: AlertaConfiguracion[]): Promise => { + return api.put('/alertas/configuracion', { configuracion: config }) + }, + + // Mark all as read + marcarTodasLeidas: (): Promise => { + return api.post('/alertas/marcar-leidas') + }, +} diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts new file mode 100644 index 0000000..96e3fd2 --- /dev/null +++ b/frontend/src/api/auth.ts @@ -0,0 +1,42 @@ +import { api, setTokens, clearTokens } from './client' +import { AuthResponse, LoginCredentials, User } from '@/types' + +export const authApi = { + login: async (credentials: LoginCredentials): Promise => { + const response = await api.post('/auth/login', credentials) + setTokens(response.accessToken, response.refreshToken) + return response + }, + + logout: async (): Promise => { + try { + await api.post('/auth/logout') + } finally { + clearTokens() + } + }, + + getProfile: (): Promise => { + return api.get('/auth/me') + }, + + updateProfile: (data: Partial): Promise => { + return api.patch('/auth/me', data) + }, + + changePassword: (data: { currentPassword: string; newPassword: string }): Promise => { + return api.post('/auth/change-password', data) + }, + + requestPasswordReset: (email: string): Promise => { + return api.post('/auth/forgot-password', { email }) + }, + + resetPassword: (data: { token: string; password: string }): Promise => { + return api.post('/auth/reset-password', data) + }, + + refreshToken: (refreshToken: string): Promise => { + return api.post('/auth/refresh', { refreshToken }) + }, +} diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts new file mode 100644 index 0000000..39ce598 --- /dev/null +++ b/frontend/src/api/client.ts @@ -0,0 +1,152 @@ +import axios, { AxiosInstance, AxiosError, InternalAxiosRequestConfig } from 'axios' + +const API_BASE_URL = import.meta.env.VITE_API_URL || '/api/v1' + +// Create axios instance +const apiClient: AxiosInstance = axios.create({ + baseURL: API_BASE_URL, + headers: { + 'Content-Type': 'application/json', + }, + timeout: 30000, +}) + +// Token storage +const TOKEN_KEY = 'flotillas_access_token' +const REFRESH_TOKEN_KEY = 'flotillas_refresh_token' + +export const getAccessToken = (): string | null => { + return localStorage.getItem(TOKEN_KEY) +} + +export const getRefreshToken = (): string | null => { + return localStorage.getItem(REFRESH_TOKEN_KEY) +} + +export const setTokens = (accessToken: string, refreshToken: string): void => { + localStorage.setItem(TOKEN_KEY, accessToken) + localStorage.setItem(REFRESH_TOKEN_KEY, refreshToken) +} + +export const clearTokens = (): void => { + localStorage.removeItem(TOKEN_KEY) + localStorage.removeItem(REFRESH_TOKEN_KEY) +} + +// Request interceptor - Add auth token +apiClient.interceptors.request.use( + (config: InternalAxiosRequestConfig) => { + const token = getAccessToken() + if (token && config.headers) { + config.headers.Authorization = `Bearer ${token}` + } + return config + }, + (error: AxiosError) => { + return Promise.reject(error) + } +) + +// Response interceptor - Handle token refresh and errors +let isRefreshing = false +let failedQueue: Array<{ + resolve: (value?: unknown) => void + reject: (reason?: unknown) => void +}> = [] + +const processQueue = (error: Error | null, token: string | null = null) => { + failedQueue.forEach((prom) => { + if (error) { + prom.reject(error) + } else { + prom.resolve(token) + } + }) + failedQueue = [] +} + +apiClient.interceptors.response.use( + (response) => response, + async (error: AxiosError) => { + const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean } + + // Handle 401 Unauthorized + if (error.response?.status === 401 && !originalRequest._retry) { + if (isRefreshing) { + return new Promise((resolve, reject) => { + failedQueue.push({ resolve, reject }) + }) + .then((token) => { + if (originalRequest.headers) { + originalRequest.headers.Authorization = `Bearer ${token}` + } + return apiClient(originalRequest) + }) + .catch((err) => Promise.reject(err)) + } + + originalRequest._retry = true + isRefreshing = true + + const refreshToken = getRefreshToken() + + if (!refreshToken) { + clearTokens() + window.location.href = '/login' + return Promise.reject(error) + } + + try { + const response = await axios.post(`${API_BASE_URL}/auth/refresh`, { + refreshToken, + }) + + const { accessToken, refreshToken: newRefreshToken } = response.data + setTokens(accessToken, newRefreshToken) + + processQueue(null, accessToken) + + if (originalRequest.headers) { + originalRequest.headers.Authorization = `Bearer ${accessToken}` + } + + return apiClient(originalRequest) + } catch (refreshError) { + processQueue(refreshError as Error, null) + clearTokens() + window.location.href = '/login' + return Promise.reject(refreshError) + } finally { + isRefreshing = false + } + } + + // Handle other errors + const errorMessage = + (error.response?.data as { message?: string })?.message || + error.message || + 'Error de conexion' + + return Promise.reject(new Error(errorMessage)) + } +) + +export default apiClient + +// Helper functions for common HTTP methods +export const api = { + get: (url: string, params?: object) => + apiClient.get(url, { params }).then((res) => res.data), + + post: (url: string, data?: object) => + apiClient.post(url, data).then((res) => res.data), + + put: (url: string, data?: object) => + apiClient.put(url, data).then((res) => res.data), + + patch: (url: string, data?: object) => + apiClient.patch(url, data).then((res) => res.data), + + delete: (url: string) => + apiClient.delete(url).then((res) => res.data), +} diff --git a/frontend/src/api/conductores.ts b/frontend/src/api/conductores.ts new file mode 100644 index 0000000..49c6838 --- /dev/null +++ b/frontend/src/api/conductores.ts @@ -0,0 +1,68 @@ +import { api } from './client' +import { + Conductor, + ConductorCreate, + ConductorUpdate, + PaginatedResponse, +} from '@/types' + +export const conductoresApi = { + // List conductores with pagination + list: (params?: { + page?: number + pageSize?: number + busqueda?: string + estado?: string + activo?: boolean + }): Promise> => { + return api.get>('/conductores', params) + }, + + // Get all conductores + listAll: (): Promise => { + return api.get('/conductores/all') + }, + + // Get single conductor + get: (id: string): Promise => { + return api.get(`/conductores/${id}`) + }, + + // Create conductor + create: (data: ConductorCreate): Promise => { + return api.post('/conductores', data) + }, + + // Update conductor + update: (id: string, data: ConductorUpdate): Promise => { + return api.patch(`/conductores/${id}`, data) + }, + + // Delete conductor + delete: (id: string): Promise => { + return api.delete(`/conductores/${id}`) + }, + + // Get conductor stats + getStats: (id: string, periodo?: 'dia' | 'semana' | 'mes'): Promise<{ + viajes: number + kilometros: number + horasConduccion: number + alertas: number + calificacion: number + }> => { + return api.get(`/conductores/${id}/stats`, { periodo }) + }, + + // Get available conductores (not assigned to a vehicle) + getDisponibles: (): Promise => { + return api.get('/conductores/disponibles') + }, + + // Update conductor photo + updateFoto: (id: string, file: File): Promise => { + const formData = new FormData() + formData.append('foto', file) + return api.post(`/conductores/${id}/foto`, formData) + }, +} diff --git a/frontend/src/api/geocercas.ts b/frontend/src/api/geocercas.ts new file mode 100644 index 0000000..d34fb6e --- /dev/null +++ b/frontend/src/api/geocercas.ts @@ -0,0 +1,77 @@ +import { api } from './client' +import { + Geocerca, + GeocercaCreate, + PaginatedResponse, +} from '@/types' + +export const geocercasApi = { + // List geocercas + list: (params?: { + page?: number + pageSize?: number + busqueda?: string + tipo?: string + activa?: boolean + }): Promise> => { + return api.get>('/geocercas', params) + }, + + // Get all geocercas (for map display) + listAll: (): Promise => { + return api.get('/geocercas/all') + }, + + // Get single geocerca + get: (id: string): Promise => { + return api.get(`/geocercas/${id}`) + }, + + // Create geocerca + create: (data: GeocercaCreate): Promise => { + return api.post('/geocercas', data) + }, + + // Update geocerca + update: (id: string, data: Partial): Promise => { + return api.patch(`/geocercas/${id}`, data) + }, + + // Delete geocerca + delete: (id: string): Promise => { + return api.delete(`/geocercas/${id}`) + }, + + // Toggle geocerca active status + toggleActiva: (id: string): Promise => { + return api.post(`/geocercas/${id}/toggle`) + }, + + // Assign vehiculos to geocerca + assignVehiculos: (id: string, vehiculoIds: string[]): Promise => { + return api.post(`/geocercas/${id}/vehiculos`, { vehiculoIds }) + }, + + // Check if point is inside geocerca + checkPunto: (id: string, lat: number, lng: number): Promise => { + return api.post(`/geocercas/${id}/check`, { lat, lng }) + }, + + // Get vehiculos currently inside geocerca + getVehiculosDentro: (id: string): Promise => { + return api.get(`/geocercas/${id}/vehiculos-dentro`) + }, + + // Get geocerca events/history + getEventos: (id: string, params?: { + desde?: string + hasta?: string + tipo?: 'entrada' | 'salida' + }): Promise> => { + return api.get(`/geocercas/${id}/eventos`, params) + }, +} diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts new file mode 100644 index 0000000..a92a593 --- /dev/null +++ b/frontend/src/api/index.ts @@ -0,0 +1,162 @@ +export { default as apiClient, api } from './client' +export { wsClient } from './websocket' +export { authApi } from './auth' +export { vehiculosApi } from './vehiculos' +export { conductoresApi } from './conductores' +export { alertasApi } from './alertas' +export { viajesApi } from './viajes' +export { geocercasApi } from './geocercas' +export { videoApi } from './video' +export { reportesApi } from './reportes' + +// POIs API +import { api } from './client' +import { POI, POICreate, PaginatedResponse } from '@/types' + +export const poisApi = { + list: (params?: { + page?: number + pageSize?: number + categoria?: string + busqueda?: string + }): Promise> => { + return api.get>('/pois', params) + }, + + listAll: (): Promise => { + return api.get('/pois/all') + }, + + get: (id: string): Promise => { + return api.get(`/pois/${id}`) + }, + + create: (data: POICreate): Promise => { + return api.post('/pois', data) + }, + + update: (id: string, data: Partial): Promise => { + return api.patch(`/pois/${id}`, data) + }, + + delete: (id: string): Promise => { + return api.delete(`/pois/${id}`) + }, +} + +// Combustible API +import { CargaCombustible, CargaCombustibleCreate } from '@/types' + +export const combustibleApi = { + list: (params?: { + page?: number + pageSize?: number + vehiculoId?: string + desde?: string + hasta?: string + }): Promise> => { + return api.get>('/combustible', params) + }, + + get: (id: string): Promise => { + return api.get(`/combustible/${id}`) + }, + + create: (data: CargaCombustibleCreate): Promise => { + return api.post('/combustible', data) + }, + + update: (id: string, data: Partial): Promise => { + return api.patch(`/combustible/${id}`, data) + }, + + delete: (id: string): Promise => { + return api.delete(`/combustible/${id}`) + }, + + getStats: (params?: { + vehiculoId?: string + desde?: string + hasta?: string + }): Promise<{ + totalLitros: number + totalCosto: number + rendimientoPromedio: number + porVehiculo: Array<{ + vehiculoId: string + litros: number + costo: number + rendimiento: number + }> + }> => { + return api.get('/combustible/stats', params) + }, +} + +// Mantenimiento API +import { Mantenimiento, MantenimientoCreate } from '@/types' + +export const mantenimientoApi = { + list: (params?: { + page?: number + pageSize?: number + vehiculoId?: string + tipo?: string + estado?: string + desde?: string + hasta?: string + }): Promise> => { + return api.get>('/mantenimiento', params) + }, + + get: (id: string): Promise => { + return api.get(`/mantenimiento/${id}`) + }, + + create: (data: MantenimientoCreate): Promise => { + return api.post('/mantenimiento', data) + }, + + update: (id: string, data: Partial): Promise => { + return api.patch(`/mantenimiento/${id}`, data) + }, + + delete: (id: string): Promise => { + return api.delete(`/mantenimiento/${id}`) + }, + + completar: (id: string, data: { + fechaRealizada: string + costo?: number + notas?: string + }): Promise => { + return api.post(`/mantenimiento/${id}/completar`, data) + }, + + getProximos: (dias?: number): Promise => { + return api.get('/mantenimiento/proximos', { dias }) + }, + + getVencidos: (): Promise => { + return api.get('/mantenimiento/vencidos') + }, +} + +// Configuracion API +import { ConfiguracionSistema } from '@/types' + +export const configuracionApi = { + get: (): Promise => { + return api.get('/configuracion') + }, + + update: (data: Partial): Promise => { + return api.patch('/configuracion', data) + }, + + updateLogo: (file: File): Promise<{ url: string }> => { + const formData = new FormData() + formData.append('logo', file) + return api.post('/configuracion/logo', formData) + }, +} diff --git a/frontend/src/api/reportes.ts b/frontend/src/api/reportes.ts new file mode 100644 index 0000000..6f7f8b5 --- /dev/null +++ b/frontend/src/api/reportes.ts @@ -0,0 +1,89 @@ +import { api } from './client' +import { + Reporte, + ReporteConfiguracion, + ReporteTipo, + PaginatedResponse, +} from '@/types' + +export const reportesApi = { + // List generated reportes + list: (params?: { + page?: number + pageSize?: number + tipo?: ReporteTipo + estado?: string + desde?: string + hasta?: string + }): Promise> => { + return api.get>('/reportes', params) + }, + + // Get single reporte + get: (id: string): Promise => { + return api.get(`/reportes/${id}`) + }, + + // Generate new reporte + generar: (config: ReporteConfiguracion): Promise => { + return api.post('/reportes/generar', config) + }, + + // Download reporte + download: (id: string): Promise => { + return api.get(`/reportes/${id}/download`) + }, + + // Delete reporte + delete: (id: string): Promise => { + return api.delete(`/reportes/${id}`) + }, + + // Get reporte templates + getTemplates: (): Promise> => { + return api.get('/reportes/templates') + }, + + // Get scheduled reportes + getScheduled: (): Promise => { + return api.get('/reportes/programados') + }, + + // Schedule a reporte + schedule: (config: ReporteConfiguracion): Promise => { + return api.post('/reportes/programar', config) + }, + + // Update scheduled reporte + updateScheduled: (id: string, config: Partial): Promise => { + return api.patch(`/reportes/programados/${id}`, config) + }, + + // Delete scheduled reporte + deleteScheduled: (id: string): Promise => { + return api.delete(`/reportes/programados/${id}`) + }, + + // Preview reporte data (without generating file) + preview: (config: ReporteConfiguracion): Promise<{ + columnas: string[] + filas: Array> + resumen: Record + }> => { + return api.post('/reportes/preview', config) + }, + + // Get reporte statistics + getStats: (): Promise<{ + totalGenerados: number + porTipo: Record + ultimosGenerados: Reporte[] + }> => { + return api.get('/reportes/stats') + }, +} diff --git a/frontend/src/api/vehiculos.ts b/frontend/src/api/vehiculos.ts new file mode 100644 index 0000000..366ccc2 --- /dev/null +++ b/frontend/src/api/vehiculos.ts @@ -0,0 +1,84 @@ +import { api } from './client' +import { + Vehiculo, + VehiculoCreate, + VehiculoUpdate, + VehiculoStats, + PaginatedResponse, + Ubicacion, + FiltrosVehiculos, +} from '@/types' + +export const vehiculosApi = { + // List vehiculos with pagination and filters + list: (params?: Partial & { page?: number; pageSize?: number }): Promise> => { + return api.get>('/vehiculos', params) + }, + + // Get all vehiculos (for map/dashboard) + listAll: (): Promise => { + return api.get('/vehiculos/all') + }, + + // Get single vehiculo + get: (id: string): Promise => { + return api.get(`/vehiculos/${id}`) + }, + + // Create vehiculo + create: (data: VehiculoCreate): Promise => { + return api.post('/vehiculos', data) + }, + + // Update vehiculo + update: (id: string, data: VehiculoUpdate): Promise => { + return api.patch(`/vehiculos/${id}`, data) + }, + + // Delete vehiculo + delete: (id: string): Promise => { + return api.delete(`/vehiculos/${id}`) + }, + + // Get vehiculo stats + getStats: (id: string, periodo?: 'dia' | 'semana' | 'mes'): Promise => { + return api.get(`/vehiculos/${id}/stats`, { periodo }) + }, + + // Get vehiculo ubicacion history + getUbicaciones: ( + id: string, + params: { desde: string; hasta: string } + ): Promise => { + return api.get(`/vehiculos/${id}/ubicaciones`, params) + }, + + // Get current ubicacion for all vehiculos + getUbicacionesActuales: (): Promise => { + return api.get('/vehiculos/ubicaciones/actuales') + }, + + // Assign conductor to vehiculo + assignConductor: (vehiculoId: string, conductorId: string): Promise => { + return api.post(`/vehiculos/${vehiculoId}/conductor`, { conductorId }) + }, + + // Remove conductor from vehiculo + removeConductor: (vehiculoId: string): Promise => { + return api.delete(`/vehiculos/${vehiculoId}/conductor`) + }, + + // Get fleet summary stats + getFleetStats: (): Promise<{ + total: number + activos: number + inactivos: number + mantenimiento: number + enMovimiento: number + detenidos: number + sinSenal: number + alertasActivas: number + }> => { + return api.get('/vehiculos/fleet/stats') + }, +} diff --git a/frontend/src/api/viajes.ts b/frontend/src/api/viajes.ts new file mode 100644 index 0000000..70db7f0 --- /dev/null +++ b/frontend/src/api/viajes.ts @@ -0,0 +1,95 @@ +import { api } from './client' +import { + Viaje, + Parada, + EventoViaje, + ViajeReplayData, + PaginatedResponse, +} from '@/types' + +export const viajesApi = { + // List viajes with pagination + list: (params?: { + page?: number + pageSize?: number + vehiculoId?: string + conductorId?: string + estado?: string + desde?: string + hasta?: string + }): Promise> => { + return api.get>('/viajes', params) + }, + + // Get single viaje + get: (id: string): Promise => { + return api.get(`/viajes/${id}`) + }, + + // Get viaje replay data (includes route points, events, recordings) + getReplayData: (id: string): Promise => { + return api.get(`/viajes/${id}/replay`) + }, + + // Get viaje route + getRuta: (id: string): Promise> => { + return api.get(`/viajes/${id}/ruta`) + }, + + // Get viaje events + getEventos: (id: string): Promise => { + return api.get(`/viajes/${id}/eventos`) + }, + + // Get viaje paradas + getParadas: (id: string): Promise => { + return api.get(`/viajes/${id}/paradas`) + }, + + // Get current/active viajes + getActivos: (): Promise => { + return api.get('/viajes/activos') + }, + + // Start a new viaje + iniciar: (vehiculoId: string, conductorId?: string): Promise => { + return api.post('/viajes/iniciar', { vehiculoId, conductorId }) + }, + + // End a viaje + finalizar: (id: string): Promise => { + return api.post(`/viajes/${id}/finalizar`) + }, + + // Add a parada to viaje + addParada: (id: string, parada: Partial): Promise => { + return api.post(`/viajes/${id}/paradas`, parada) + }, + + // Get viaje statistics + getStats: (params?: { + vehiculoId?: string + conductorId?: string + desde?: string + hasta?: string + }): Promise<{ + totalViajes: number + distanciaTotal: number + tiempoTotal: number + velocidadPromedio: number + paradasTotal: number + combustibleTotal: number + }> => { + return api.get('/viajes/stats', params) + }, + + // Export viaje + exportar: (id: string, formato: 'pdf' | 'excel'): Promise => { + return api.get(`/viajes/${id}/exportar`, { formato }) + }, +} diff --git a/frontend/src/api/video.ts b/frontend/src/api/video.ts new file mode 100644 index 0000000..ea47845 --- /dev/null +++ b/frontend/src/api/video.ts @@ -0,0 +1,137 @@ +import { api } from './client' +import { + Camara, + Grabacion, + EventoVideo, + PaginatedResponse, +} from '@/types' + +export const videoApi = { + // ========================================== + // CAMARAS + // ========================================== + + // List camaras + listCamaras: (params?: { + vehiculoId?: string + estado?: string + activa?: boolean + }): Promise => { + return api.get('/video/camaras', params) + }, + + // Get single camara + getCamara: (id: string): Promise => { + return api.get(`/video/camaras/${id}`) + }, + + // Get camara stream URL + getStreamUrl: (id: string, tipo?: 'webrtc' | 'hls' | 'rtsp'): Promise<{ + url: string + tipo: string + expires: string + }> => { + return api.get(`/video/camaras/${id}/stream`, { tipo }) + }, + + // Start recording + startRecording: (id: string): Promise => { + return api.post(`/video/camaras/${id}/grabar/iniciar`) + }, + + // Stop recording + stopRecording: (id: string): Promise => { + return api.post(`/video/camaras/${id}/grabar/detener`) + }, + + // Take snapshot + takeSnapshot: (id: string): Promise<{ url: string }> => { + return api.post(`/video/camaras/${id}/snapshot`) + }, + + // ========================================== + // GRABACIONES + // ========================================== + + // List grabaciones + listGrabaciones: (params?: { + page?: number + pageSize?: number + camaraId?: string + vehiculoId?: string + viajeId?: string + tipo?: string + desde?: string + hasta?: string + }): Promise> => { + return api.get>('/video/grabaciones', params) + }, + + // Get single grabacion + getGrabacion: (id: string): Promise => { + return api.get(`/video/grabaciones/${id}`) + }, + + // Get grabacion playback URL + getPlaybackUrl: (id: string): Promise<{ url: string; expires: string }> => { + return api.get(`/video/grabaciones/${id}/playback`) + }, + + // Download grabacion + downloadGrabacion: (id: string): Promise => { + return api.get(`/video/grabaciones/${id}/download`) + }, + + // Delete grabacion + deleteGrabacion: (id: string): Promise => { + return api.delete(`/video/grabaciones/${id}`) + }, + + // ========================================== + // EVENTOS VIDEO + // ========================================== + + // List eventos video + listEventos: (params?: { + page?: number + pageSize?: number + camaraId?: string + vehiculoId?: string + tipo?: string + desde?: string + hasta?: string + }): Promise> => { + return api.get>('/video/eventos', params) + }, + + // Get single evento + getEvento: (id: string): Promise => { + return api.get(`/video/eventos/${id}`) + }, + + // ========================================== + // WEBRTC SIGNALING + // ========================================== + + // Get WebRTC offer + getWebRTCOffer: (camaraId: string): Promise<{ + sdp: string + type: 'offer' + iceServers: Array<{ urls: string[] }> + }> => { + return api.get(`/video/camaras/${camaraId}/webrtc/offer`) + }, + + // Send WebRTC answer + sendWebRTCAnswer: (camaraId: string, answer: { + sdp: string + type: 'answer' + }): Promise => { + return api.post(`/video/camaras/${camaraId}/webrtc/answer`, answer) + }, + + // Send ICE candidate + sendICECandidate: (camaraId: string, candidate: RTCIceCandidate): Promise => { + return api.post(`/video/camaras/${camaraId}/webrtc/ice`, candidate) + }, +} diff --git a/frontend/src/api/websocket.ts b/frontend/src/api/websocket.ts new file mode 100644 index 0000000..4a00e6f --- /dev/null +++ b/frontend/src/api/websocket.ts @@ -0,0 +1,205 @@ +import { WSMessage, WSMessageType } from '@/types' +import { getAccessToken } from './client' + +type MessageHandler = (message: WSMessage) => void +type ConnectionHandler = () => void + +interface WebSocketConfig { + url?: string + reconnectInterval?: number + maxReconnectAttempts?: number + heartbeatInterval?: number +} + +const DEFAULT_CONFIG: Required = { + url: import.meta.env.VITE_WS_URL || `ws://${window.location.host}/ws/v1`, + reconnectInterval: 3000, + maxReconnectAttempts: 10, + heartbeatInterval: 30000, +} + +class WebSocketClient { + private socket: WebSocket | null = null + private config: Required + private reconnectAttempts = 0 + private reconnectTimeout: ReturnType | null = null + private heartbeatInterval: ReturnType | null = null + private messageHandlers: Map> = new Map() + private onConnectHandlers: Set = new Set() + private onDisconnectHandlers: Set = new Set() + private isManualClose = false + + constructor(config?: WebSocketConfig) { + this.config = { ...DEFAULT_CONFIG, ...config } + } + + connect(): void { + if (this.socket?.readyState === WebSocket.OPEN) { + return + } + + const token = getAccessToken() + if (!token) { + console.warn('No auth token available for WebSocket connection') + return + } + + this.isManualClose = false + const url = `${this.config.url}?token=${token}` + + try { + this.socket = new WebSocket(url) + + this.socket.onopen = () => { + console.log('WebSocket connected') + this.reconnectAttempts = 0 + this.startHeartbeat() + this.onConnectHandlers.forEach((handler) => handler()) + } + + this.socket.onclose = (event) => { + console.log('WebSocket disconnected', event.code, event.reason) + this.stopHeartbeat() + this.onDisconnectHandlers.forEach((handler) => handler()) + + if (!this.isManualClose && this.reconnectAttempts < this.config.maxReconnectAttempts) { + this.scheduleReconnect() + } + } + + this.socket.onerror = (error) => { + console.error('WebSocket error', error) + } + + this.socket.onmessage = (event) => { + try { + const message: WSMessage = JSON.parse(event.data) + this.handleMessage(message) + } catch (error) { + console.error('Error parsing WebSocket message', error) + } + } + } catch (error) { + console.error('Error creating WebSocket connection', error) + this.scheduleReconnect() + } + } + + disconnect(): void { + this.isManualClose = true + this.stopHeartbeat() + + if (this.reconnectTimeout) { + clearTimeout(this.reconnectTimeout) + this.reconnectTimeout = null + } + + if (this.socket) { + this.socket.close(1000, 'Client disconnect') + this.socket = null + } + } + + private scheduleReconnect(): void { + if (this.reconnectTimeout) { + return + } + + this.reconnectAttempts++ + const delay = this.config.reconnectInterval * Math.min(this.reconnectAttempts, 5) + + console.log(`Scheduling WebSocket reconnect in ${delay}ms (attempt ${this.reconnectAttempts})`) + + this.reconnectTimeout = setTimeout(() => { + this.reconnectTimeout = null + this.connect() + }, delay) + } + + private startHeartbeat(): void { + this.stopHeartbeat() + this.heartbeatInterval = setInterval(() => { + this.send({ type: 'ping', payload: {}, timestamp: new Date().toISOString() }) + }, this.config.heartbeatInterval) + } + + private stopHeartbeat(): void { + if (this.heartbeatInterval) { + clearInterval(this.heartbeatInterval) + this.heartbeatInterval = null + } + } + + private handleMessage(message: WSMessage): void { + // Handle pong messages + if (message.type === 'pong') { + return + } + + // Call type-specific handlers + const typeHandlers = this.messageHandlers.get(message.type) + if (typeHandlers) { + typeHandlers.forEach((handler) => handler(message)) + } + + // Call 'all' handlers + const allHandlers = this.messageHandlers.get('all') + if (allHandlers) { + allHandlers.forEach((handler) => handler(message)) + } + } + + send(message: WSMessage): void { + if (this.socket?.readyState === WebSocket.OPEN) { + this.socket.send(JSON.stringify(message)) + } else { + console.warn('WebSocket not connected, message not sent') + } + } + + subscribe(type: WSMessageType | 'all', handler: MessageHandler): () => void { + if (!this.messageHandlers.has(type)) { + this.messageHandlers.set(type, new Set()) + } + this.messageHandlers.get(type)!.add(handler) + + return () => { + this.messageHandlers.get(type)?.delete(handler) + } + } + + onConnect(handler: ConnectionHandler): () => void { + this.onConnectHandlers.add(handler) + return () => { + this.onConnectHandlers.delete(handler) + } + } + + onDisconnect(handler: ConnectionHandler): () => void { + this.onDisconnectHandlers.add(handler) + return () => { + this.onDisconnectHandlers.delete(handler) + } + } + + get isConnected(): boolean { + return this.socket?.readyState === WebSocket.OPEN + } + + get connectionState(): 'connecting' | 'connected' | 'disconnected' { + if (!this.socket) return 'disconnected' + switch (this.socket.readyState) { + case WebSocket.CONNECTING: + return 'connecting' + case WebSocket.OPEN: + return 'connected' + default: + return 'disconnected' + } + } +} + +// Singleton instance +export const wsClient = new WebSocketClient() + +export default WebSocketClient diff --git a/frontend/src/components/alertas/AlertaCard.tsx b/frontend/src/components/alertas/AlertaCard.tsx new file mode 100644 index 0000000..f069e67 --- /dev/null +++ b/frontend/src/components/alertas/AlertaCard.tsx @@ -0,0 +1,248 @@ +import { format, formatDistanceToNow } from 'date-fns' +import { es } from 'date-fns/locale' +import { + ExclamationTriangleIcon, + BellAlertIcon, + MapPinIcon, + TruckIcon, + CheckIcon, + XMarkIcon, + EyeIcon, +} from '@heroicons/react/24/outline' +import clsx from 'clsx' +import { Alerta, AlertaTipo, AlertaPrioridad } from '@/types' +import { PriorityBadge } from '@/components/ui/Badge' +import Card from '@/components/ui/Card' +import Button from '@/components/ui/Button' + +interface AlertaCardProps { + alerta: Alerta + onReconocer?: () => void + onResolver?: () => void + onIgnorar?: () => void + onVerDetalles?: () => void + compact?: boolean +} + +// Icon mapping for alert types +const alertaIconMap: Partial> = { + exceso_velocidad: BellAlertIcon, + frenado_brusco: ExclamationTriangleIcon, + aceleracion_brusca: ExclamationTriangleIcon, + entrada_geocerca: MapPinIcon, + salida_geocerca: MapPinIcon, + sos: ExclamationTriangleIcon, + impacto: ExclamationTriangleIcon, + desconexion: ExclamationTriangleIcon, +} + +// Color mapping for priorities +const priorityColorMap: Record = { + baja: 'bg-blue-500/20 text-blue-400 border-blue-500/30', + media: 'bg-warning-500/20 text-warning-400 border-warning-500/30', + alta: 'bg-error-500/20 text-error-400 border-error-500/30', + critica: 'bg-error-500/30 text-error-300 border-error-500/50', +} + +export default function AlertaCard({ + alerta, + onReconocer, + onResolver, + onIgnorar, + onVerDetalles, + compact = false, +}: AlertaCardProps) { + const Icon = alertaIconMap[alerta.tipo] || BellAlertIcon + const timeAgo = formatDistanceToNow(new Date(alerta.timestamp), { + addSuffix: true, + locale: es, + }) + + if (compact) { + return ( +
+
+ +
+ +
+
+

{alerta.titulo}

+ +
+

{alerta.mensaje}

+
+ {alerta.vehiculo && ( + <> + + {alerta.vehiculo.placa} + | + + )} + {timeAgo} +
+
+ + {alerta.estado === 'activa' && ( +
+ + +
+ )} +
+ ) + } + + return ( + + {/* Header */} +
+
+
+ +
+
+

{alerta.titulo}

+

{timeAgo}

+
+
+ +
+ + {/* Message */} +

{alerta.mensaje}

+ + {/* Details */} +
+ {alerta.vehiculo && ( +
+ + + {alerta.vehiculo.nombre} ({alerta.vehiculo.placa}) + +
+ )} + {alerta.lat && alerta.lng && ( +
+ + + {alerta.lat.toFixed(4)}, {alerta.lng.toFixed(4)} + +
+ )} + {alerta.valor !== undefined && alerta.umbral !== undefined && ( +
+ Valor: + {alerta.valor} + / Umbral: + {alerta.umbral} +
+ )} +
+ + {/* Actions */} + {alerta.estado === 'activa' && ( +
+ + + +
+ +
+ )} + + {alerta.estado !== 'activa' && ( +
+ {alerta.estado === 'reconocida' && ( + + Reconocida por {alerta.reconocidaPor} -{' '} + {alerta.reconocidaAt && format(new Date(alerta.reconocidaAt), 'dd/MM HH:mm')} + + )} + {alerta.estado === 'resuelta' && ( + + Resuelta por {alerta.resueltaPor} -{' '} + {alerta.resueltaAt && format(new Date(alerta.resueltaAt), 'dd/MM HH:mm')} + + )} + {alerta.estado === 'ignorada' && Ignorada} +
+ )} + + ) +} diff --git a/frontend/src/components/alertas/AlertaList.tsx b/frontend/src/components/alertas/AlertaList.tsx new file mode 100644 index 0000000..f03a408 --- /dev/null +++ b/frontend/src/components/alertas/AlertaList.tsx @@ -0,0 +1,201 @@ +import { useState } from 'react' +import { + FunnelIcon, + MagnifyingGlassIcon, + BellSlashIcon, +} from '@heroicons/react/24/outline' +import clsx from 'clsx' +import { Alerta, AlertaPrioridad, AlertaEstado, AlertaTipo } from '@/types' +import AlertaCard from './AlertaCard' +import { SkeletonListItem } from '@/components/ui/Skeleton' + +interface AlertaListProps { + alertas: Alerta[] + isLoading?: boolean + onReconocer?: (id: string) => void + onResolver?: (id: string) => void + onIgnorar?: (id: string) => void + onVerDetalles?: (id: string) => void + compact?: boolean + showFilters?: boolean +} + +export default function AlertaList({ + alertas, + isLoading = false, + onReconocer, + onResolver, + onIgnorar, + onVerDetalles, + compact = false, + showFilters = true, +}: AlertaListProps) { + const [search, setSearch] = useState('') + const [prioridadFilter, setPrioridadFilter] = useState([]) + const [estadoFilter, setEstadoFilter] = useState(['activa']) + + // Filter alertas + const filteredAlertas = alertas.filter((a) => { + // Search + if (search) { + const searchLower = search.toLowerCase() + if ( + !a.titulo.toLowerCase().includes(searchLower) && + !a.mensaje.toLowerCase().includes(searchLower) + ) { + return false + } + } + + // Prioridad + if (prioridadFilter.length > 0 && !prioridadFilter.includes(a.prioridad)) { + return false + } + + // Estado + if (estadoFilter.length > 0 && !estadoFilter.includes(a.estado)) { + return false + } + + return true + }) + + // Stats + const stats = { + total: alertas.length, + activas: alertas.filter((a) => a.estado === 'activa').length, + criticas: alertas.filter((a) => a.prioridad === 'critica' && a.estado === 'activa').length, + altas: alertas.filter((a) => a.prioridad === 'alta' && a.estado === 'activa').length, + } + + const togglePrioridad = (p: AlertaPrioridad) => { + setPrioridadFilter((prev) => + prev.includes(p) ? prev.filter((x) => x !== p) : [...prev, p] + ) + } + + const toggleEstado = (e: AlertaEstado) => { + setEstadoFilter((prev) => + prev.includes(e) ? prev.filter((x) => x !== e) : [...prev, e] + ) + } + + return ( +
+ {/* Filters */} + {showFilters && ( +
+ {/* Stats */} +
+ + {stats.activas} alertas activas + + {stats.criticas > 0 && ( + <> + | + + {stats.criticas} criticas + + + )} + {stats.altas > 0 && ( + <> + | + + {stats.altas} altas + + + )} +
+ + {/* Search and filters */} +
+ {/* Search */} +
+ + setSearch(e.target.value)} + placeholder="Buscar alertas..." + className={clsx( + 'w-full pl-10 pr-4 py-2 bg-slate-800/50 border border-slate-700/50 rounded-lg', + 'text-white placeholder-slate-500', + 'focus:outline-none focus:ring-2 focus:ring-accent-500 focus:border-transparent' + )} + /> +
+ + {/* Priority filters */} +
+ {(['critica', 'alta', 'media', 'baja'] as AlertaPrioridad[]).map((p) => ( + + ))} +
+ + {/* Estado filters */} +
+ {(['activa', 'reconocida', 'resuelta'] as AlertaEstado[]).map((e) => ( + + ))} +
+
+
+ )} + + {/* Alertas list */} + {isLoading ? ( +
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+ ) : filteredAlertas.length === 0 ? ( +
+ +

No hay alertas que mostrar

+
+ ) : ( +
+ {filteredAlertas.map((alerta) => ( + onReconocer?.(alerta.id)} + onResolver={() => onResolver?.(alerta.id)} + onIgnorar={() => onIgnorar?.(alerta.id)} + onVerDetalles={() => onVerDetalles?.(alerta.id)} + /> + ))} +
+ )} +
+ ) +} diff --git a/frontend/src/components/alertas/index.ts b/frontend/src/components/alertas/index.ts new file mode 100644 index 0000000..11d2b63 --- /dev/null +++ b/frontend/src/components/alertas/index.ts @@ -0,0 +1,2 @@ +export { default as AlertaCard } from './AlertaCard' +export { default as AlertaList } from './AlertaList' diff --git a/frontend/src/components/charts/BarChart.tsx b/frontend/src/components/charts/BarChart.tsx new file mode 100644 index 0000000..debefe9 --- /dev/null +++ b/frontend/src/components/charts/BarChart.tsx @@ -0,0 +1,158 @@ +import { + BarChart as RechartsBarChart, + Bar, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, + Legend, + Cell, +} from 'recharts' +import clsx from 'clsx' + +interface BarChartProps { + data: Array> + bars: Array<{ + dataKey: string + name: string + color: string + radius?: number + }> + xAxisKey: string + height?: number + showGrid?: boolean + showLegend?: boolean + layout?: 'vertical' | 'horizontal' + className?: string +} + +export default function BarChart({ + data, + bars, + xAxisKey, + height = 300, + showGrid = true, + showLegend = false, + layout = 'horizontal', + className, +}: BarChartProps) { + const isVertical = layout === 'vertical' + + return ( +
+ + + {showGrid && ( + + )} + {isVertical ? ( + <> + + + + ) : ( + <> + + + + )} + + {showLegend && ( + ( + {value} + )} + /> + )} + {bars.map((bar) => ( + + ))} + + +
+ ) +} + +// Simple horizontal bar with colors +interface SimpleBarProps { + data: Array<{ + name: string + value: number + color: string + }> + height?: number + showValues?: boolean +} + +export function SimpleBar({ data, height = 200, showValues = true }: SimpleBarProps) { + const maxValue = Math.max(...data.map((d) => d.value)) + + return ( +
+ {data.map((item) => ( +
+
+ {item.name} + {showValues && ( + {item.value} + )} +
+
+
+
+
+ ))} +
+ ) +} diff --git a/frontend/src/components/charts/FuelGauge.tsx b/frontend/src/components/charts/FuelGauge.tsx new file mode 100644 index 0000000..e1778f9 --- /dev/null +++ b/frontend/src/components/charts/FuelGauge.tsx @@ -0,0 +1,155 @@ +import clsx from 'clsx' + +interface FuelGaugeProps { + value: number // 0-100 + maxValue?: number + label?: string + size?: 'sm' | 'md' | 'lg' + showPercentage?: boolean + className?: string +} + +export default function FuelGauge({ + value, + maxValue = 100, + label = 'Combustible', + size = 'md', + showPercentage = true, + className, +}: FuelGaugeProps) { + const percentage = Math.min(100, Math.max(0, (value / maxValue) * 100)) + + // Color based on level + const getColor = () => { + if (percentage <= 20) return { bg: 'bg-error-500', text: 'text-error-400' } + if (percentage <= 40) return { bg: 'bg-warning-500', text: 'text-warning-400' } + return { bg: 'bg-success-500', text: 'text-success-400' } + } + + const colors = getColor() + + const sizeStyles = { + sm: { height: 'h-2', text: 'text-xs', icon: 'w-4 h-4' }, + md: { height: 'h-3', text: 'text-sm', icon: 'w-5 h-5' }, + lg: { height: 'h-4', text: 'text-base', icon: 'w-6 h-6' }, + } + + const styles = sizeStyles[size] + + return ( +
+ {/* Header */} +
+
+ + + + {label} +
+ {showPercentage && ( + + {percentage.toFixed(0)}% + + )} +
+ + {/* Gauge bar */} +
+
+
+ + {/* Markers */} +
+ E + 1/4 + 1/2 + 3/4 + F +
+
+ ) +} + +// Circular gauge variant +interface CircularGaugeProps { + value: number + maxValue?: number + label?: string + size?: number + strokeWidth?: number + className?: string +} + +export function CircularGauge({ + value, + maxValue = 100, + label, + size = 120, + strokeWidth = 8, + className, +}: CircularGaugeProps) { + const percentage = Math.min(100, Math.max(0, (value / maxValue) * 100)) + const radius = (size - strokeWidth) / 2 + const circumference = 2 * Math.PI * radius + const strokeDashoffset = circumference - (percentage / 100) * circumference + + // Color based on level + const getColor = () => { + if (percentage <= 20) return '#ef4444' + if (percentage <= 40) return '#eab308' + return '#22c55e' + } + + return ( +
+ + {/* Background circle */} + + {/* Progress circle */} + + + + {/* Center content */} +
+
+

{percentage.toFixed(0)}%

+ {label &&

{label}

} +
+
+
+ ) +} diff --git a/frontend/src/components/charts/KPICard.tsx b/frontend/src/components/charts/KPICard.tsx new file mode 100644 index 0000000..6f43179 --- /dev/null +++ b/frontend/src/components/charts/KPICard.tsx @@ -0,0 +1,161 @@ +import { ReactNode } from 'react' +import { ArrowUpIcon, ArrowDownIcon } from '@heroicons/react/24/solid' +import clsx from 'clsx' +import Card from '@/components/ui/Card' + +interface KPICardProps { + title: string + value: string | number + subtitle?: string + icon?: ReactNode + trend?: { + value: number + label?: string + isPositive?: boolean + } + color?: 'default' | 'blue' | 'green' | 'yellow' | 'red' + loading?: boolean +} + +export default function KPICard({ + title, + value, + subtitle, + icon, + trend, + color = 'default', + loading = false, +}: KPICardProps) { + const colorStyles = { + default: { + icon: 'bg-slate-700 text-slate-300', + value: 'text-white', + }, + blue: { + icon: 'bg-accent-500/20 text-accent-400', + value: 'text-accent-400', + }, + green: { + icon: 'bg-success-500/20 text-success-400', + value: 'text-success-400', + }, + yellow: { + icon: 'bg-warning-500/20 text-warning-400', + value: 'text-warning-400', + }, + red: { + icon: 'bg-error-500/20 text-error-400', + value: 'text-error-400', + }, + } + + const styles = colorStyles[color] + + if (loading) { + return ( + +
+
+
+
+
+ + ) + } + + return ( + + {/* Background decoration */} + {color !== 'default' && ( +
+ )} + +
+ {/* Header */} +
+

{title}

+ {icon && ( +
+ {icon} +
+ )} +
+ + {/* Value */} +

{value}

+ + {/* Trend and subtitle */} +
+ {trend && ( + + {trend.isPositive !== false ? ( + + ) : ( + + )} + {trend.value}% + + )} + {subtitle && ( + {subtitle} + )} + {trend?.label && ( + {trend.label} + )} +
+
+ + ) +} + +// Mini KPI for dashboards +interface MiniKPIProps { + label: string + value: string | number + icon?: ReactNode + color?: 'default' | 'blue' | 'green' | 'yellow' | 'red' +} + +export function MiniKPI({ label, value, icon, color = 'default' }: MiniKPIProps) { + const dotColors = { + default: 'bg-slate-500', + blue: 'bg-accent-500', + green: 'bg-success-500', + yellow: 'bg-warning-500', + red: 'bg-error-500', + } + + return ( +
+ {icon ? ( +
+ {icon} +
+ ) : ( + + )} +
+

{value}

+

{label}

+
+
+ ) +} diff --git a/frontend/src/components/charts/LineChart.tsx b/frontend/src/components/charts/LineChart.tsx new file mode 100644 index 0000000..6729ee5 --- /dev/null +++ b/frontend/src/components/charts/LineChart.tsx @@ -0,0 +1,99 @@ +import { + LineChart as RechartsLineChart, + Line, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, + Legend, +} from 'recharts' +import clsx from 'clsx' + +interface LineChartProps { + data: Array> + lines: Array<{ + dataKey: string + name: string + color: string + strokeWidth?: number + dot?: boolean + }> + xAxisKey: string + height?: number + showGrid?: boolean + showLegend?: boolean + className?: string +} + +export default function LineChart({ + data, + lines, + xAxisKey, + height = 300, + showGrid = true, + showLegend = true, + className, +}: LineChartProps) { + return ( +
+ + + {showGrid && ( + + )} + + + + {showLegend && ( + ( + {value} + )} + /> + )} + {lines.map((line) => ( + + ))} + + +
+ ) +} diff --git a/frontend/src/components/charts/PieChart.tsx b/frontend/src/components/charts/PieChart.tsx new file mode 100644 index 0000000..6a4ea74 --- /dev/null +++ b/frontend/src/components/charts/PieChart.tsx @@ -0,0 +1,156 @@ +import { + PieChart as RechartsPieChart, + Pie, + Cell, + ResponsiveContainer, + Legend, + Tooltip, +} from 'recharts' +import clsx from 'clsx' + +interface PieChartProps { + data: Array<{ + name: string + value: number + color: string + }> + height?: number + innerRadius?: number + outerRadius?: number + showLegend?: boolean + showLabels?: boolean + className?: string +} + +export default function PieChart({ + data, + height = 300, + innerRadius = 60, + outerRadius = 100, + showLegend = true, + showLabels = false, + className, +}: PieChartProps) { + const total = data.reduce((sum, item) => sum + item.value, 0) + + return ( +
+ + + `${name} ${(percent * 100).toFixed(0)}%` + : false + } + labelLine={showLabels} + > + {data.map((entry, index) => ( + + ))} + + [ + `${value} (${((value / total) * 100).toFixed(1)}%)`, + name, + ]} + /> + {showLegend && ( + ( + {value} + )} + /> + )} + + +
+ ) +} + +// Donut chart with center label +interface DonutChartProps extends Omit { + centerLabel?: string + centerValue?: string | number +} + +export function DonutChart({ + data, + height = 200, + outerRadius = 80, + centerLabel, + centerValue, + showLegend = false, + className, +}: DonutChartProps) { + const innerRadius = outerRadius * 0.65 + + return ( +
+ + + + {data.map((entry, index) => ( + + ))} + + + {showLegend && ( + ( + {value} + )} + /> + )} + + + + {/* Center label */} + {(centerLabel || centerValue) && ( +
+
+ {centerValue && ( +

{centerValue}

+ )} + {centerLabel && ( +

{centerLabel}

+ )} +
+
+ )} +
+ ) +} diff --git a/frontend/src/components/charts/index.ts b/frontend/src/components/charts/index.ts new file mode 100644 index 0000000..96581cf --- /dev/null +++ b/frontend/src/components/charts/index.ts @@ -0,0 +1,5 @@ +export { default as KPICard, MiniKPI } from './KPICard' +export { default as LineChart } from './LineChart' +export { default as BarChart, SimpleBar } from './BarChart' +export { default as PieChart, DonutChart } from './PieChart' +export { default as FuelGauge, CircularGauge } from './FuelGauge' diff --git a/frontend/src/components/layout/Header.tsx b/frontend/src/components/layout/Header.tsx new file mode 100644 index 0000000..63d9fa9 --- /dev/null +++ b/frontend/src/components/layout/Header.tsx @@ -0,0 +1,402 @@ +import { Fragment, useState } from 'react' +import { Link } from 'react-router-dom' +import { Menu, Transition, Combobox } from '@headlessui/react' +import { + MagnifyingGlassIcon, + BellIcon, + UserCircleIcon, + Cog6ToothIcon, + ArrowRightOnRectangleIcon, + SunIcon, + MoonIcon, + ChevronDownIcon, + XMarkIcon, +} from '@heroicons/react/24/outline' +import clsx from 'clsx' +import { useAuthStore } from '@/store/authStore' +import { useAlertasStore } from '@/store/alertasStore' +import { useVehiculosStore } from '@/store/vehiculosStore' +import { CounterBadge } from '@/components/ui/Badge' + +export default function Header() { + const [searchOpen, setSearchOpen] = useState(false) + const [searchQuery, setSearchQuery] = useState('') + const { user, logout } = useAuthStore() + const alertasActivas = useAlertasStore((state) => state.alertasActivas) + const vehiculos = useVehiculosStore((state) => state.getVehiculosArray()) + + // Search results + const searchResults = searchQuery + ? vehiculos + .filter( + (v) => + v.nombre.toLowerCase().includes(searchQuery.toLowerCase()) || + v.placa.toLowerCase().includes(searchQuery.toLowerCase()) + ) + .slice(0, 5) + : [] + + return ( +
+
+ {/* Left side - Search */} +
+ {/* Desktop search */} +
+ {}}> +
+ + setSearchQuery(e.target.value)} + /> +
+ + setSearchQuery('')} + > + + {searchResults.length === 0 && searchQuery !== '' ? ( +
+ No se encontraron resultados +
+ ) : ( + searchResults.map((vehiculo) => ( + + clsx( + 'cursor-pointer select-none px-4 py-3', + active && 'bg-slate-700/50' + ) + } + > + +
+ + {vehiculo.placa.slice(0, 2)} + +
+
+

+ {vehiculo.nombre} +

+

{vehiculo.placa}

+
+ +
+ )) + )} +
+
+
+
+ + {/* Mobile search button */} + +
+ + {/* Right side - Actions */} +
+ {/* Theme toggle */} + + + {/* Notifications */} + + + {/* User menu */} + +
+
+ + {/* Mobile search overlay */} + setSearchOpen(false)} + query={searchQuery} + setQuery={setSearchQuery} + results={searchResults} + /> +
+ ) +} + +// Notifications dropdown +function NotificationsMenu({ alertas }: { alertas: Array<{ id: string; titulo: string; mensaje: string; prioridad: string }> }) { + return ( + + + + {alertas.length > 0 && ( + + + + )} + + + + +
+

Notificaciones

+

{alertas.length} alertas activas

+
+ +
+ {alertas.length === 0 ? ( +
+ No hay alertas pendientes +
+ ) : ( + alertas.slice(0, 5).map((alerta) => ( + + {({ active }) => ( + +

+ {alerta.titulo} +

+

+ {alerta.mensaje} +

+ + )} +
+ )) + )} +
+ + {alertas.length > 0 && ( +
+ + + Ver todas las alertas + + +
+ )} +
+
+
+ ) +} + +// User menu dropdown +function UserMenu({ user, onLogout }: { user: { nombre: string; email: string; rol: string } | null; onLogout: () => void }) { + return ( + + +
+ + {user?.nombre?.charAt(0) || 'U'} + +
+
+

{user?.nombre || 'Usuario'}

+

{user?.rol || 'admin'}

+
+ +
+ + + + {/* User info */} +
+

{user?.nombre}

+

{user?.email}

+
+ + {/* Menu items */} +
+ + {({ active }) => ( + + + Mi perfil + + )} + + + {({ active }) => ( + + + Configuracion + + )} + +
+ + {/* Logout */} +
+ + {({ active }) => ( + + )} + +
+
+
+
+ ) +} + +// Mobile search modal +function MobileSearch({ + isOpen, + onClose, + query, + setQuery, + results, +}: { + isOpen: boolean + onClose: () => void + query: string + setQuery: (q: string) => void + results: Array<{ id: string; nombre: string; placa: string }> +}) { + if (!isOpen) return null + + return ( +
+
+
+
+ + setQuery(e.target.value)} + placeholder="Buscar..." + className={clsx( + 'w-full pl-10 pr-4 py-3 bg-slate-800 border border-slate-700 rounded-lg', + 'text-white placeholder-slate-500', + 'focus:outline-none focus:ring-2 focus:ring-accent-500' + )} + autoFocus + /> +
+ +
+ + {/* Results */} +
+ {results.map((vehiculo) => ( + +
+ + {vehiculo.placa.slice(0, 2)} + +
+
+

{vehiculo.nombre}

+

{vehiculo.placa}

+
+ + ))} +
+
+
+ ) +} diff --git a/frontend/src/components/layout/MainLayout.tsx b/frontend/src/components/layout/MainLayout.tsx new file mode 100644 index 0000000..c76c805 --- /dev/null +++ b/frontend/src/components/layout/MainLayout.tsx @@ -0,0 +1,81 @@ +import { Outlet } from 'react-router-dom' +import clsx from 'clsx' +import Sidebar from './Sidebar' +import Header from './Header' +import { useConfigStore } from '@/store/configStore' +import { useVehiculosRealtime, useVehiculos } from '@/hooks/useVehiculos' +import { useAlertasRealtime, useAlertasActivas } from '@/hooks/useAlertas' +import { useWebSocket } from '@/hooks/useWebSocket' + +export default function MainLayout() { + const { config } = useConfigStore() + const sidebarCollapsed = config.sidebarCollapsed + + // Initialize real-time connections + useWebSocket({ autoConnect: true }) + + // Load initial data + useVehiculos() + useAlertasActivas() + + // Subscribe to real-time updates + useVehiculosRealtime() + useAlertasRealtime() + + return ( +
+ {/* Sidebar */} + + + {/* Main content area */} +
+ {/* Header */} +
+ + {/* Page content */} +
+ +
+
+
+ ) +} + +// Layout variant without sidebar (for fullscreen pages like map) +export function FullscreenLayout() { + const { config } = useConfigStore() + const sidebarCollapsed = config.sidebarCollapsed + + // Initialize real-time connections + useWebSocket({ autoConnect: true }) + + // Load initial data + useVehiculos() + useAlertasActivas() + + // Subscribe to real-time updates + useVehiculosRealtime() + useAlertasRealtime() + + return ( +
+ {/* Sidebar */} + + + {/* Main content area - no padding */} +
+ +
+
+ ) +} diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx new file mode 100644 index 0000000..17c3bf7 --- /dev/null +++ b/frontend/src/components/layout/Sidebar.tsx @@ -0,0 +1,245 @@ +import { NavLink, useLocation } from 'react-router-dom' +import { + HomeIcon, + MapIcon, + TruckIcon, + UserGroupIcon, + BellAlertIcon, + MapPinIcon, + VideoCameraIcon, + FolderIcon, + RectangleGroupIcon, + ChartBarIcon, + Cog6ToothIcon, + ChevronLeftIcon, + ChevronRightIcon, + ArrowPathIcon, + WrenchScrewdriverIcon, + BeakerIcon, +} from '@heroicons/react/24/outline' +import clsx from 'clsx' +import { useConfigStore } from '@/store/configStore' +import { CounterBadge } from '@/components/ui/Badge' +import { useAlertasStore } from '@/store/alertasStore' + +interface NavItem { + name: string + href: string + icon: typeof HomeIcon + badge?: number + children?: Omit[] +} + +const navigation: NavItem[] = [ + { name: 'Dashboard', href: '/', icon: HomeIcon }, + { name: 'Mapa', href: '/mapa', icon: MapIcon }, + { name: 'Vehiculos', href: '/vehiculos', icon: TruckIcon }, + { name: 'Conductores', href: '/conductores', icon: UserGroupIcon }, + { name: 'Alertas', href: '/alertas', icon: BellAlertIcon }, + { name: 'Viajes', href: '/viajes', icon: ArrowPathIcon }, + { + name: 'Video', + href: '/video', + icon: VideoCameraIcon, + children: [ + { name: 'En vivo', href: '/video', icon: VideoCameraIcon }, + { name: 'Grabaciones', href: '/grabaciones', icon: FolderIcon }, + ], + }, + { + name: 'Zonas', + href: '/geocercas', + icon: RectangleGroupIcon, + children: [ + { name: 'Geocercas', href: '/geocercas', icon: RectangleGroupIcon }, + { name: 'POIs', href: '/pois', icon: MapPinIcon }, + ], + }, + { name: 'Combustible', href: '/combustible', icon: BeakerIcon }, + { name: 'Mantenimiento', href: '/mantenimiento', icon: WrenchScrewdriverIcon }, + { name: 'Reportes', href: '/reportes', icon: ChartBarIcon }, +] + +const bottomNavigation: NavItem[] = [ + { name: 'Configuracion', href: '/configuracion', icon: Cog6ToothIcon }, +] + +export default function Sidebar() { + const location = useLocation() + const { config, toggleSidebar } = useConfigStore() + const { collapsed } = config.sidebarCollapsed ? { collapsed: true } : { collapsed: false } + const alertasActivas = useAlertasStore((state) => state.alertasActivas.length) + + // Add badge to alertas + const navWithBadges = navigation.map((item) => { + if (item.href === '/alertas') { + return { ...item, badge: alertasActivas } + } + return item + }) + + return ( + + ) +} + +// Nav item component +interface NavItemProps { + item: NavItem + collapsed: boolean +} + +function NavItem({ item, collapsed }: NavItemProps) { + const location = useLocation() + const isActive = location.pathname === item.href + const hasChildren = item.children && item.children.length > 0 + const isChildActive = hasChildren && item.children?.some((child) => location.pathname === child.href) + + // If collapsed, show only icon + if (collapsed) { + return ( + + clsx( + 'relative flex items-center justify-center w-full h-12 rounded-lg', + 'transition-all duration-200 group', + (linkActive || isChildActive) + ? 'bg-accent-500/20 text-accent-400' + : 'text-slate-400 hover:text-white hover:bg-slate-800' + ) + } + title={item.name} + > + + {item.badge && item.badge > 0 && ( + + + + )} + + {/* Tooltip */} +
+ {item.name} +
+
+ ) + } + + // Expanded state + return ( +
+ + clsx( + 'flex items-center gap-3 px-3 py-2.5 rounded-lg', + 'transition-all duration-200', + (linkActive || isChildActive) + ? 'bg-accent-500/20 text-accent-400' + : 'text-slate-400 hover:text-white hover:bg-slate-800' + ) + } + > + + {item.name} + {item.badge && item.badge > 0 && } + + + {/* Children */} + {hasChildren && ( +
+ {item.children!.map((child) => ( + + clsx( + 'flex items-center gap-2 px-3 py-2 rounded-lg text-sm', + 'transition-all duration-200', + isActive + ? 'text-accent-400 bg-accent-500/10' + : 'text-slate-500 hover:text-slate-300 hover:bg-slate-800/50' + ) + } + > + + {child.name} + + ))} +
+ )} +
+ ) +} diff --git a/frontend/src/components/layout/index.ts b/frontend/src/components/layout/index.ts new file mode 100644 index 0000000..6966c2b --- /dev/null +++ b/frontend/src/components/layout/index.ts @@ -0,0 +1,3 @@ +export { default as Sidebar } from './Sidebar' +export { default as Header } from './Header' +export { default as MainLayout, FullscreenLayout } from './MainLayout' diff --git a/frontend/src/components/mapa/DrawingTools.tsx b/frontend/src/components/mapa/DrawingTools.tsx new file mode 100644 index 0000000..e82fd71 --- /dev/null +++ b/frontend/src/components/mapa/DrawingTools.tsx @@ -0,0 +1,209 @@ +import { useEffect, useState, useCallback } from 'react' +import { useMap, useMapEvents, Circle, Polygon, Marker } from 'react-leaflet' +import L from 'leaflet' +import clsx from 'clsx' +import { useMapaStore } from '@/store/mapaStore' +import { Coordenadas } from '@/types' + +interface DrawingToolsProps { + onComplete?: (type: 'circulo' | 'poligono', data: { + centro?: Coordenadas + radio?: number + vertices?: Coordenadas[] + }) => void + onCancel?: () => void +} + +export default function DrawingTools({ onComplete, onCancel }: DrawingToolsProps) { + const map = useMap() + const { + herramienta, + dibujando, + puntosDibujo, + startDibujo, + addPuntoDibujo, + finishDibujo, + cancelDibujo, + } = useMapaStore() + + const [mousePosition, setMousePosition] = useState(null) + const [circleRadius, setCircleRadius] = useState(0) + + // Handle map clicks for drawing + useMapEvents({ + click: (e) => { + if (!herramienta) return + + const point = { lat: e.latlng.lat, lng: e.latlng.lng } + + if (herramienta === 'dibujar_circulo') { + if (puntosDibujo.length === 0) { + startDibujo() + addPuntoDibujo(point) + } else { + // Complete circle + const center = puntosDibujo[0] + const radius = calculateDistance(center, point) + onComplete?.('circulo', { centro: center, radio: radius }) + finishDibujo() + } + } else if (herramienta === 'dibujar_poligono') { + if (!dibujando) { + startDibujo() + } + addPuntoDibujo(point) + } + }, + dblclick: (e) => { + if (herramienta === 'dibujar_poligono' && puntosDibujo.length >= 3) { + e.originalEvent.preventDefault() + onComplete?.('poligono', { vertices: puntosDibujo }) + finishDibujo() + } + }, + mousemove: (e) => { + setMousePosition({ lat: e.latlng.lat, lng: e.latlng.lng }) + + // Update circle radius preview + if (herramienta === 'dibujar_circulo' && puntosDibujo.length === 1) { + const radius = calculateDistance(puntosDibujo[0], { + lat: e.latlng.lat, + lng: e.latlng.lng, + }) + setCircleRadius(radius) + } + }, + contextmenu: (e) => { + e.originalEvent.preventDefault() + if (dibujando) { + cancelDibujo() + onCancel?.() + } + }, + }) + + // Escape key to cancel + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape' && dibujando) { + cancelDibujo() + onCancel?.() + } + } + + document.addEventListener('keydown', handleKeyDown) + return () => document.removeEventListener('keydown', handleKeyDown) + }, [dibujando, cancelDibujo, onCancel]) + + // Calculate distance between two points in meters + const calculateDistance = useCallback( + (p1: Coordenadas, p2: Coordenadas) => { + const latlng1 = L.latLng(p1.lat, p1.lng) + const latlng2 = L.latLng(p2.lat, p2.lng) + return latlng1.distanceTo(latlng2) + }, + [] + ) + + // Point marker icon + const pointIcon = L.divIcon({ + className: 'drawing-point', + html: ` +
+ `, + iconSize: [12, 12], + iconAnchor: [6, 6], + }) + + if (!herramienta || (!dibujando && puntosDibujo.length === 0)) return null + + return ( + <> + {/* Circle preview */} + {herramienta === 'dibujar_circulo' && puntosDibujo.length === 1 && ( + + )} + + {/* Polygon preview */} + {herramienta === 'dibujar_poligono' && puntosDibujo.length >= 2 && ( + [p.lat, p.lng] as [number, number]), + ...(mousePosition ? [[mousePosition.lat, mousePosition.lng] as [number, number]] : []), + ]} + pathOptions={{ + color: '#3b82f6', + fillColor: '#3b82f6', + fillOpacity: 0.2, + weight: 2, + dashArray: '5, 5', + }} + /> + )} + + {/* Drawing points */} + {puntosDibujo.map((punto, index) => ( + + ))} + + {/* Instructions overlay */} + + + ) +} + +// Instructions panel +function DrawingInstructions({ + herramienta, + puntosDibujo, +}: { + herramienta: string + puntosDibujo: Coordenadas[] +}) { + let instruction = '' + + if (herramienta === 'dibujar_circulo') { + if (puntosDibujo.length === 0) { + instruction = 'Haz clic para definir el centro del circulo' + } else { + instruction = 'Haz clic para definir el radio del circulo' + } + } else if (herramienta === 'dibujar_poligono') { + if (puntosDibujo.length < 3) { + instruction = `Haz clic para agregar puntos (${puntosDibujo.length}/3 minimo)` + } else { + instruction = 'Doble clic para completar el poligono' + } + } + + return ( +
+
+
+ {instruction} + (Esc para cancelar) +
+
+ ) +} diff --git a/frontend/src/components/mapa/GeocercaLayer.tsx b/frontend/src/components/mapa/GeocercaLayer.tsx new file mode 100644 index 0000000..7b19bd2 --- /dev/null +++ b/frontend/src/components/mapa/GeocercaLayer.tsx @@ -0,0 +1,112 @@ +import { Circle, Polygon, Polyline, Popup, Tooltip } from 'react-leaflet' +import { Geocerca } from '@/types' + +interface GeocercaLayerProps { + geocerca: Geocerca + editable?: boolean + onClick?: () => void +} + +export default function GeocercaLayer({ + geocerca, + editable = false, + onClick, +}: GeocercaLayerProps) { + const pathOptions = { + color: geocerca.color, + fillColor: geocerca.color, + fillOpacity: 0.2, + weight: 2, + } + + const eventHandlers = { + click: () => onClick?.(), + } + + // Circle geocerca + if (geocerca.tipo === 'circulo' && geocerca.centroLat && geocerca.centroLng && geocerca.radio) { + return ( + + + {geocerca.nombre} + + + + + + ) + } + + // Polygon geocerca + if (geocerca.tipo === 'poligono' && geocerca.vertices && geocerca.vertices.length > 2) { + const positions = geocerca.vertices.map((v) => [v.lat, v.lng] as [number, number]) + + return ( + + + {geocerca.nombre} + + + + + + ) + } + + // Route geocerca + if (geocerca.tipo === 'ruta' && geocerca.vertices && geocerca.vertices.length > 1) { + const positions = geocerca.vertices.map((v) => [v.lat, v.lng] as [number, number]) + + return ( + + + {geocerca.nombre} + + + + + + ) + } + + return null +} + +// Popup content +function GeocercaPopup({ geocerca }: { geocerca: Geocerca }) { + return ( +
+

{geocerca.nombre}

+ {geocerca.descripcion && ( +

{geocerca.descripcion}

+ )} +
+
+ Tipo: + {geocerca.tipo} +
+
+ Accion: + {geocerca.accion} +
+ {geocerca.velocidadMaxima && ( +
+ Vel. max: + {geocerca.velocidadMaxima} km/h +
+ )} + {geocerca.tipo === 'circulo' && geocerca.radio && ( +
+ Radio: + {geocerca.radio} m +
+ )} +
+
+ ) +} diff --git a/frontend/src/components/mapa/MapContainer.tsx b/frontend/src/components/mapa/MapContainer.tsx new file mode 100644 index 0000000..33e8e4c --- /dev/null +++ b/frontend/src/components/mapa/MapContainer.tsx @@ -0,0 +1,223 @@ +import { useEffect, useRef, useCallback } from 'react' +import { MapContainer as LeafletMapContainer, TileLayer, useMap, useMapEvents } from 'react-leaflet' +import L from 'leaflet' +import clsx from 'clsx' +import { useMapaStore } from '@/store/mapaStore' +import VehiculoMarker from './VehiculoMarker' +import GeocercaLayer from './GeocercaLayer' +import POILayer from './POILayer' +import { useVehiculosStore } from '@/store/vehiculosStore' + +// Fix Leaflet default marker icon issue +delete (L.Icon.Default.prototype as unknown as Record)._getIconUrl +L.Icon.Default.mergeOptions({ + iconRetinaUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-icon-2x.png', + iconUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-icon.png', + shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-shadow.png', +}) + +// Map styles +const mapStyles = { + dark: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', + light: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', + satellite: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', +} + +interface MapContainerProps { + className?: string + showControls?: boolean + onVehiculoSelect?: (id: string) => void + selectedVehiculoId?: string | null +} + +export default function MapContainer({ + className, + showControls = true, + onVehiculoSelect, + selectedVehiculoId, +}: MapContainerProps) { + const { + centro, + zoom, + estilo, + capas, + geocercas, + pois, + setCentro, + setZoom, + } = useMapaStore() + + const vehiculos = useVehiculosStore((state) => state.getVehiculosArray()) + const vehiculosConUbicacion = vehiculos.filter((v) => v.ubicacion) + + return ( + + {/* Map sync component */} + + + {/* Base tile layer */} + + + {/* Vehicle markers */} + {capas.vehiculos && + vehiculosConUbicacion.map((vehiculo) => ( + onVehiculoSelect?.(vehiculo.id)} + /> + ))} + + {/* Geocercas */} + {capas.geocercas && + geocercas + .filter((g) => g.activa) + .map((geocerca) => ( + + ))} + + {/* POIs */} + {capas.pois && + pois + .filter((p) => p.activo) + .map((poi) => )} + + {/* Custom controls */} + {showControls && } + + ) +} + +// Component to sync map state with store +function MapSync({ + onCenterChange, + onZoomChange, +}: { + onCenterChange: (coords: { lat: number; lng: number }) => void + onZoomChange: (zoom: number) => void +}) { + const map = useMapEvents({ + moveend: () => { + const center = map.getCenter() + onCenterChange({ lat: center.lat, lng: center.lng }) + }, + zoomend: () => { + onZoomChange(map.getZoom()) + }, + }) + + return null +} + +// Custom map controls +function MapControls() { + const map = useMap() + const { estilo, setEstilo, capas, toggleCapa } = useMapaStore() + + const handleZoomIn = useCallback(() => { + map.zoomIn() + }, [map]) + + const handleZoomOut = useCallback(() => { + map.zoomOut() + }, [map]) + + const handleResetView = useCallback(() => { + map.setView([19.4326, -99.1332], 12) + }, [map]) + + return ( +
+ {/* Zoom controls */} +
+ +
+ +
+ + {/* Layer controls */} +
+ +
+ +
+ +
+ + {/* Style selector */} +
+ +
+
+ ) +} diff --git a/frontend/src/components/mapa/POILayer.tsx b/frontend/src/components/mapa/POILayer.tsx new file mode 100644 index 0000000..00d59f8 --- /dev/null +++ b/frontend/src/components/mapa/POILayer.tsx @@ -0,0 +1,119 @@ +import { Marker, Popup, Tooltip } from 'react-leaflet' +import L from 'leaflet' +import { POI, POICategoria } from '@/types' + +interface POILayerProps { + poi: POI + onClick?: () => void +} + +// POI category icons and colors +const categoryConfig: Record = { + oficina: { icon: 'building', color: '#3b82f6' }, + cliente: { icon: 'user', color: '#22c55e' }, + gasolinera: { icon: 'gas', color: '#ef4444' }, + taller: { icon: 'wrench', color: '#f97316' }, + estacionamiento: { icon: 'parking', color: '#8b5cf6' }, + restaurante: { icon: 'food', color: '#eab308' }, + hotel: { icon: 'bed', color: '#ec4899' }, + otro: { icon: 'pin', color: '#64748b' }, +} + +function createPOIIcon(poi: POI): L.DivIcon { + const config = categoryConfig[poi.categoria] || categoryConfig.otro + const color = poi.color || config.color + + const iconSvg = getIconSvg(config.icon) + + const html = ` +
+
+ ${iconSvg} +
+
+ ` + + return L.divIcon({ + className: 'custom-poi-marker', + html, + iconSize: [32, 32], + iconAnchor: [8, 32], + popupAnchor: [8, -32], + }) +} + +function getIconSvg(icon: string): string { + const svgMap: Record = { + building: '', + user: '', + gas: '', + wrench: '', + parking: '', + food: '', + bed: '', + pin: '', + } + + return svgMap[icon] || svgMap.pin +} + +export default function POILayer({ poi, onClick }: POILayerProps) { + const icon = createPOIIcon(poi) + + return ( + onClick?.(), + }} + > + + {poi.nombre} + + +
+

{poi.nombre}

+ {poi.descripcion && ( +

{poi.descripcion}

+ )} +
+
+ Categoria: + {poi.categoria} +
+ {poi.direccion && ( +
+ Direccion: +

{poi.direccion}

+
+ )} + {poi.telefono && ( +
+ Telefono: + {poi.telefono} +
+ )} + {poi.horario && ( +
+ Horario: + {poi.horario} +
+ )} +
+
+
+
+ ) +} diff --git a/frontend/src/components/mapa/RutaLayer.tsx b/frontend/src/components/mapa/RutaLayer.tsx new file mode 100644 index 0000000..a174b64 --- /dev/null +++ b/frontend/src/components/mapa/RutaLayer.tsx @@ -0,0 +1,189 @@ +import { Polyline, Marker, Popup } from 'react-leaflet' +import L from 'leaflet' +import { format } from 'date-fns' +import { es } from 'date-fns/locale' +import { Coordenadas } from '@/types' + +interface RutaPoint extends Coordenadas { + timestamp?: string + velocidad?: number +} + +interface RutaLayerProps { + puntos: RutaPoint[] + color?: string + showMarkers?: boolean + animated?: boolean + showStartEnd?: boolean +} + +export default function RutaLayer({ + puntos, + color = '#3b82f6', + showMarkers = false, + animated = false, + showStartEnd = true, +}: RutaLayerProps) { + if (puntos.length < 2) return null + + const positions = puntos.map((p) => [p.lat, p.lng] as [number, number]) + + // Create start/end markers + const startIcon = L.divIcon({ + className: 'custom-marker', + html: ` +
+ A +
+ `, + iconSize: [24, 24], + iconAnchor: [12, 12], + }) + + const endIcon = L.divIcon({ + className: 'custom-marker', + html: ` +
+ B +
+ `, + iconSize: [24, 24], + iconAnchor: [12, 12], + }) + + return ( + <> + {/* Route line */} + + + {/* Animated dash effect */} + {animated && ( + + )} + + {/* Start marker */} + {showStartEnd && puntos.length > 0 && ( + + +
+

Inicio

+ {puntos[0].timestamp && ( +

+ {format(new Date(puntos[0].timestamp), "d MMM yyyy, HH:mm", { + locale: es, + })} +

+ )} +

+ {puntos[0].lat.toFixed(6)}, {puntos[0].lng.toFixed(6)} +

+
+
+
+ )} + + {/* End marker */} + {showStartEnd && puntos.length > 1 && ( + + +
+

Fin

+ {puntos[puntos.length - 1].timestamp && ( +

+ {format(new Date(puntos[puntos.length - 1].timestamp), "d MMM yyyy, HH:mm", { + locale: es, + })} +

+ )} +

+ {puntos[puntos.length - 1].lat.toFixed(6)},{' '} + {puntos[puntos.length - 1].lng.toFixed(6)} +

+
+
+
+ )} + + {/* Intermediate markers */} + {showMarkers && + puntos.slice(1, -1).map((punto, index) => ( +
+ `, + iconSize: [12, 12], + iconAnchor: [6, 6], + })} + > + +
+ {punto.timestamp && ( +

+ {format(new Date(punto.timestamp), 'HH:mm:ss', { locale: es })} +

+ )} + {punto.velocidad !== undefined && ( +

+ {punto.velocidad.toFixed(0)} km/h +

+ )} +
+
+ + ))} + + ) +} diff --git a/frontend/src/components/mapa/VehiculoMarker.tsx b/frontend/src/components/mapa/VehiculoMarker.tsx new file mode 100644 index 0000000..ae73f5d --- /dev/null +++ b/frontend/src/components/mapa/VehiculoMarker.tsx @@ -0,0 +1,110 @@ +import { Marker, Popup } from 'react-leaflet' +import L from 'leaflet' +import { Vehiculo } from '@/types' +import VehiculoPopup from './VehiculoPopup' + +interface VehiculoMarkerProps { + vehiculo: Vehiculo + isSelected?: boolean + onClick?: () => void +} + +// Create custom icon based on vehicle state +function createVehicleIcon(vehiculo: Vehiculo, isSelected: boolean): L.DivIcon { + const { movimiento } = vehiculo + const rotation = vehiculo.rumbo || 0 + + // Color based on movement state + let color = '#64748b' // gray - offline/unknown + let pulseClass = '' + + switch (movimiento) { + case 'movimiento': + color = '#22c55e' // green + pulseClass = 'marker-pulse-green' + break + case 'detenido': + color = '#eab308' // yellow + break + case 'ralenti': + color = '#f97316' // orange + break + case 'sin_senal': + color = '#64748b' // gray + break + } + + // Selected state + const borderColor = isSelected ? '#3b82f6' : '#ffffff' + const size = isSelected ? 44 : 40 + + const html = ` +
+
+ + + +
+ ${ + movimiento === 'movimiento' + ? `
` + : '' + } +
+ ` + + return L.divIcon({ + className: 'custom-vehicle-marker', + html, + iconSize: [size, size], + iconAnchor: [size / 2, size / 2], + popupAnchor: [0, -size / 2], + }) +} + +export default function VehiculoMarker({ + vehiculo, + isSelected = false, + onClick, +}: VehiculoMarkerProps) { + if (!vehiculo.ubicacion) return null + + const position: [number, number] = [vehiculo.ubicacion.lat, vehiculo.ubicacion.lng] + const icon = createVehicleIcon(vehiculo, isSelected) + + return ( + onClick?.(), + }} + > + + + + + ) +} diff --git a/frontend/src/components/mapa/VehiculoPopup.tsx b/frontend/src/components/mapa/VehiculoPopup.tsx new file mode 100644 index 0000000..6ee658f --- /dev/null +++ b/frontend/src/components/mapa/VehiculoPopup.tsx @@ -0,0 +1,136 @@ +import { Link } from 'react-router-dom' +import { format } from 'date-fns' +import { es } from 'date-fns/locale' +import { + TruckIcon, + MapPinIcon, + ClockIcon, + BoltIcon, + ArrowTrendingUpIcon, + UserIcon, +} from '@heroicons/react/24/outline' +import clsx from 'clsx' +import { Vehiculo } from '@/types' +import { StatusBadge } from '@/components/ui/Badge' + +interface VehiculoPopupProps { + vehiculo: Vehiculo +} + +export default function VehiculoPopup({ vehiculo }: VehiculoPopupProps) { + const { ubicacion, conductor } = vehiculo + + // Format timestamp + const lastUpdate = ubicacion?.timestamp + ? format(new Date(ubicacion.timestamp), "d MMM, HH:mm", { locale: es }) + : 'Sin datos' + + // Movement status + const getMovimientoStatus = () => { + switch (vehiculo.movimiento) { + case 'movimiento': + return { label: 'En movimiento', status: 'online' as const } + case 'detenido': + return { label: 'Detenido', status: 'warning' as const } + case 'ralenti': + return { label: 'Ralenti', status: 'warning' as const } + case 'sin_senal': + return { label: 'Sin senal', status: 'offline' as const } + default: + return { label: 'Desconocido', status: 'offline' as const } + } + } + + const movStatus = getMovimientoStatus() + + return ( +
+ {/* Header */} +
+
+
+ +
+
+

{vehiculo.nombre}

+

{vehiculo.placa}

+
+
+ +
+ + {/* Stats */} +
+
+ + Velocidad: + {vehiculo.velocidad || 0} km/h +
+
+ + Rumbo: + {vehiculo.rumbo || 0}° +
+
+ + {/* Location */} + {ubicacion && ( +
+ +
+

+ {ubicacion.lat.toFixed(6)}, {ubicacion.lng.toFixed(6)} +

+
+ + {lastUpdate} +
+
+
+ )} + + {/* Conductor */} + {conductor && ( +
+ + Conductor: + {conductor.nombre} {conductor.apellido} +
+ )} + + {/* Actions */} +
+ + Ver detalles + + + Ver viajes + +
+
+ ) +} diff --git a/frontend/src/components/mapa/index.ts b/frontend/src/components/mapa/index.ts new file mode 100644 index 0000000..3e3684a --- /dev/null +++ b/frontend/src/components/mapa/index.ts @@ -0,0 +1,7 @@ +export { default as MapContainer } from './MapContainer' +export { default as VehiculoMarker } from './VehiculoMarker' +export { default as VehiculoPopup } from './VehiculoPopup' +export { default as GeocercaLayer } from './GeocercaLayer' +export { default as POILayer } from './POILayer' +export { default as RutaLayer } from './RutaLayer' +export { default as DrawingTools } from './DrawingTools' diff --git a/frontend/src/components/ui/Badge.tsx b/frontend/src/components/ui/Badge.tsx new file mode 100644 index 0000000..af1dc0a --- /dev/null +++ b/frontend/src/components/ui/Badge.tsx @@ -0,0 +1,185 @@ +import { ReactNode } from 'react' +import clsx from 'clsx' + +export type BadgeVariant = + | 'default' + | 'primary' + | 'success' + | 'warning' + | 'error' + | 'info' + +export type BadgeSize = 'xs' | 'sm' | 'md' + +export interface BadgeProps { + children: ReactNode + variant?: BadgeVariant + size?: BadgeSize + dot?: boolean + pulse?: boolean + className?: string +} + +const variantStyles: Record = { + default: 'bg-slate-700 text-slate-300', + primary: 'bg-accent-500/20 text-accent-400 border border-accent-500/30', + success: 'bg-success-500/20 text-success-400 border border-success-500/30', + warning: 'bg-warning-500/20 text-warning-400 border border-warning-500/30', + error: 'bg-error-500/20 text-error-400 border border-error-500/30', + info: 'bg-blue-500/20 text-blue-400 border border-blue-500/30', +} + +const dotStyles: Record = { + default: 'bg-slate-400', + primary: 'bg-accent-500', + success: 'bg-success-500', + warning: 'bg-warning-500', + error: 'bg-error-500', + info: 'bg-blue-500', +} + +const sizeStyles: Record = { + xs: 'px-1.5 py-0.5 text-xs', + sm: 'px-2 py-0.5 text-xs', + md: 'px-2.5 py-1 text-sm', +} + +export default function Badge({ + children, + variant = 'default', + size = 'sm', + dot = false, + pulse = false, + className, +}: BadgeProps) { + return ( + + {dot && ( + + {pulse && ( + + )} + + + )} + {children} + + ) +} + +// Status badge for vehiculos/dispositivos +export type StatusType = 'online' | 'offline' | 'warning' | 'error' | 'idle' + +export interface StatusBadgeProps { + status: StatusType + label?: string + size?: BadgeSize + showDot?: boolean +} + +const statusConfig: Record< + StatusType, + { variant: BadgeVariant; label: string } +> = { + online: { variant: 'success', label: 'En linea' }, + offline: { variant: 'default', label: 'Sin conexion' }, + warning: { variant: 'warning', label: 'Advertencia' }, + error: { variant: 'error', label: 'Error' }, + idle: { variant: 'info', label: 'Inactivo' }, +} + +export function StatusBadge({ + status, + label, + size = 'sm', + showDot = true, +}: StatusBadgeProps) { + const config = statusConfig[status] + return ( + + {label || config.label} + + ) +} + +// Priority badge for alertas +export type PriorityType = 'baja' | 'media' | 'alta' | 'critica' + +export interface PriorityBadgeProps { + priority: PriorityType + size?: BadgeSize +} + +const priorityConfig: Record = { + baja: { variant: 'info', label: 'Baja' }, + media: { variant: 'warning', label: 'Media' }, + alta: { variant: 'error', label: 'Alta' }, + critica: { variant: 'error', label: 'Critica' }, +} + +export function PriorityBadge({ priority, size = 'sm' }: PriorityBadgeProps) { + const config = priorityConfig[priority] + return ( + + {config.label} + + ) +} + +// Counter badge (for notifications, alerts, etc.) +export interface CounterBadgeProps { + count: number + max?: number + variant?: BadgeVariant + className?: string +} + +export function CounterBadge({ + count, + max = 99, + variant = 'error', + className, +}: CounterBadgeProps) { + if (count === 0) return null + + const displayCount = count > max ? `${max}+` : count.toString() + + return ( + + {displayCount} + + ) +} diff --git a/frontend/src/components/ui/Button.tsx b/frontend/src/components/ui/Button.tsx new file mode 100644 index 0000000..4637906 --- /dev/null +++ b/frontend/src/components/ui/Button.tsx @@ -0,0 +1,105 @@ +import { forwardRef, ButtonHTMLAttributes, ReactNode } from 'react' +import clsx from 'clsx' + +export interface ButtonProps extends ButtonHTMLAttributes { + variant?: 'primary' | 'secondary' | 'danger' | 'outline' | 'ghost' | 'success' + size?: 'xs' | 'sm' | 'md' | 'lg' + isLoading?: boolean + leftIcon?: ReactNode + rightIcon?: ReactNode + fullWidth?: boolean +} + +const Button = forwardRef( + ( + { + className, + variant = 'primary', + size = 'md', + isLoading = false, + leftIcon, + rightIcon, + fullWidth = false, + disabled, + children, + ...props + }, + ref + ) => { + const baseStyles = + 'inline-flex items-center justify-center gap-2 font-medium rounded-lg transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-background-900 disabled:opacity-50 disabled:cursor-not-allowed' + + const variantStyles = { + primary: + 'bg-accent-500 text-white hover:bg-accent-600 focus:ring-accent-500 shadow-lg shadow-accent-500/25', + secondary: + 'bg-slate-700 text-white hover:bg-slate-600 focus:ring-slate-500', + danger: + 'bg-error-500 text-white hover:bg-error-600 focus:ring-error-500 shadow-lg shadow-error-500/25', + outline: + 'border border-slate-600 text-slate-300 hover:bg-slate-800 hover:border-slate-500 focus:ring-slate-500', + ghost: + 'text-slate-300 hover:bg-slate-800 hover:text-white focus:ring-slate-500', + success: + 'bg-success-500 text-white hover:bg-success-600 focus:ring-success-500 shadow-lg shadow-success-500/25', + } + + const sizeStyles = { + xs: 'px-2.5 py-1 text-xs', + sm: 'px-3 py-1.5 text-sm', + md: 'px-4 py-2 text-sm', + lg: 'px-6 py-3 text-base', + } + + return ( + + ) + } +) + +Button.displayName = 'Button' + +export default Button diff --git a/frontend/src/components/ui/Card.tsx b/frontend/src/components/ui/Card.tsx new file mode 100644 index 0000000..5f938b5 --- /dev/null +++ b/frontend/src/components/ui/Card.tsx @@ -0,0 +1,94 @@ +import { ReactNode, HTMLAttributes, forwardRef } from 'react' +import clsx from 'clsx' + +export interface CardProps extends HTMLAttributes { + children: ReactNode + padding?: 'none' | 'sm' | 'md' | 'lg' + hover?: boolean + glow?: boolean +} + +const Card = forwardRef( + ({ className, children, padding = 'md', hover = false, glow = false, ...props }, ref) => { + const paddingStyles = { + none: '', + sm: 'p-3', + md: 'p-4', + lg: 'p-6', + } + + return ( +
+ {children} +
+ ) + } +) + +Card.displayName = 'Card' + +// Card Header +export interface CardHeaderProps extends HTMLAttributes { + title: string + subtitle?: string + action?: ReactNode +} + +export function CardHeader({ title, subtitle, action, className, ...props }: CardHeaderProps) { + return ( +
+
+

{title}

+ {subtitle &&

{subtitle}

} +
+ {action &&
{action}
} +
+ ) +} + +// Card Content +export interface CardContentProps extends HTMLAttributes { + children: ReactNode +} + +export function CardContent({ children, className, ...props }: CardContentProps) { + return ( +
+ {children} +
+ ) +} + +// Card Footer +export interface CardFooterProps extends HTMLAttributes { + children: ReactNode +} + +export function CardFooter({ children, className, ...props }: CardFooterProps) { + return ( +
+ {children} +
+ ) +} + +export default Card diff --git a/frontend/src/components/ui/Checkbox.tsx b/frontend/src/components/ui/Checkbox.tsx new file mode 100644 index 0000000..c86e920 --- /dev/null +++ b/frontend/src/components/ui/Checkbox.tsx @@ -0,0 +1,171 @@ +import { forwardRef, InputHTMLAttributes, ReactNode } from 'react' +import clsx from 'clsx' + +export interface CheckboxProps + extends Omit, 'type'> { + label?: string | ReactNode + description?: string + error?: string +} + +const Checkbox = forwardRef( + ({ className, label, description, error, id, ...props }, ref) => { + const checkboxId = id || (typeof label === 'string' ? label.toLowerCase().replace(/\s+/g, '-') : undefined) + + return ( +
+
+ +
+ {(label || description) && ( +
+ {label && ( + + )} + {description && ( +

{description}

+ )} + {error &&

{error}

} +
+ )} +
+ ) + } +) + +Checkbox.displayName = 'Checkbox' + +export default Checkbox + +// Switch component +export interface SwitchProps { + checked: boolean + onChange: (checked: boolean) => void + label?: string + description?: string + disabled?: boolean + size?: 'sm' | 'md' | 'lg' +} + +export function Switch({ + checked, + onChange, + label, + description, + disabled = false, + size = 'md', +}: SwitchProps) { + const sizeStyles = { + sm: { + track: 'w-8 h-4', + thumb: 'w-3 h-3', + translate: 'translate-x-4', + }, + md: { + track: 'w-11 h-6', + thumb: 'w-5 h-5', + translate: 'translate-x-5', + }, + lg: { + track: 'w-14 h-7', + thumb: 'w-6 h-6', + translate: 'translate-x-7', + }, + } + + return ( +
+ {(label || description) && ( +
+ {label && ( + {label} + )} + {description && ( +

{description}

+ )} +
+ )} + +
+ ) +} + +// Radio button component +export interface RadioProps + extends Omit, 'type'> { + label?: string +} + +export const Radio = forwardRef( + ({ className, label, id, ...props }, ref) => { + const radioId = id || label?.toLowerCase().replace(/\s+/g, '-') + + return ( +
+ + {label && ( + + )} +
+ ) + } +) + +Radio.displayName = 'Radio' diff --git a/frontend/src/components/ui/Dropdown.tsx b/frontend/src/components/ui/Dropdown.tsx new file mode 100644 index 0000000..a5e92ce --- /dev/null +++ b/frontend/src/components/ui/Dropdown.tsx @@ -0,0 +1,186 @@ +import { Fragment, ReactNode } from 'react' +import { Menu, Transition } from '@headlessui/react' +import { ChevronDownIcon } from '@heroicons/react/20/solid' +import clsx from 'clsx' + +export interface DropdownItem { + label: string + icon?: ReactNode + onClick?: () => void + href?: string + danger?: boolean + disabled?: boolean + divider?: boolean +} + +export interface DropdownProps { + trigger: ReactNode + items: DropdownItem[] + align?: 'left' | 'right' + width?: 'auto' | 'sm' | 'md' | 'lg' +} + +const widthStyles = { + auto: 'w-auto min-w-[160px]', + sm: 'w-40', + md: 'w-48', + lg: 'w-56', +} + +export default function Dropdown({ + trigger, + items, + align = 'right', + width = 'auto', +}: DropdownProps) { + return ( + + {trigger} + + + +
+ {items.map((item, index) => { + if (item.divider) { + return
+ } + + return ( + + {({ active }) => ( + + )} + + ) + })} +
+ + +
+ ) +} + +// Dropdown button (with default styling) +export interface DropdownButtonProps extends Omit { + label: string + icon?: ReactNode + variant?: 'primary' | 'secondary' | 'outline' + size?: 'sm' | 'md' | 'lg' +} + +export function DropdownButton({ + label, + icon, + variant = 'secondary', + size = 'md', + ...props +}: DropdownButtonProps) { + const variantStyles = { + primary: 'bg-accent-500 text-white hover:bg-accent-600', + secondary: 'bg-slate-700 text-white hover:bg-slate-600', + outline: 'border border-slate-600 text-slate-300 hover:bg-slate-800', + } + + const sizeStyles = { + sm: 'px-3 py-1.5 text-sm', + md: 'px-4 py-2 text-sm', + lg: 'px-5 py-2.5 text-base', + } + + return ( + + {icon} + {label} + + + } + /> + ) +} + +// Action menu (icon-only trigger) +export interface ActionMenuProps extends Omit { + icon?: ReactNode +} + +export function ActionMenu({ icon, ...props }: ActionMenuProps) { + return ( + + {icon || ( + + + + )} + + } + /> + ) +} diff --git a/frontend/src/components/ui/Input.tsx b/frontend/src/components/ui/Input.tsx new file mode 100644 index 0000000..7f7c57c --- /dev/null +++ b/frontend/src/components/ui/Input.tsx @@ -0,0 +1,134 @@ +import { forwardRef, InputHTMLAttributes, ReactNode } from 'react' +import clsx from 'clsx' + +export interface InputProps extends InputHTMLAttributes { + label?: string + error?: string + helperText?: string + leftIcon?: ReactNode + rightIcon?: ReactNode + fullWidth?: boolean +} + +const Input = forwardRef( + ( + { + className, + label, + error, + helperText, + leftIcon, + rightIcon, + fullWidth = true, + type = 'text', + id, + ...props + }, + ref + ) => { + const inputId = id || label?.toLowerCase().replace(/\s+/g, '-') + + return ( +
+ {label && ( + + )} +
+ {leftIcon && ( +
+ {leftIcon} +
+ )} + + {rightIcon && ( +
+ {rightIcon} +
+ )} +
+ {error &&

{error}

} + {helperText && !error && ( +

{helperText}

+ )} +
+ ) + } +) + +Input.displayName = 'Input' + +export default Input + +// Textarea component +export interface TextareaProps + extends React.TextareaHTMLAttributes { + label?: string + error?: string + helperText?: string + fullWidth?: boolean +} + +export const Textarea = forwardRef( + ( + { className, label, error, helperText, fullWidth = true, id, ...props }, + ref + ) => { + const inputId = id || label?.toLowerCase().replace(/\s+/g, '-') + + return ( +
+ {label && ( + + )} +