FlotillasGPS - Sistema completo de monitoreo de flotillas GPS

Sistema completo para monitoreo y gestion de flotas de vehiculos con:
- Backend FastAPI con PostgreSQL/TimescaleDB
- Frontend React con TypeScript y TailwindCSS
- App movil React Native con Expo
- Soporte para dispositivos GPS, Meshtastic y celulares
- Video streaming en vivo con MediaMTX
- Geocercas, alertas, viajes y reportes
- Autenticacion JWT y WebSockets en tiempo real

Documentacion completa y guias de usuario incluidas.
This commit is contained in:
FlotillasGPS Developer
2026-01-21 08:18:00 +00:00
commit 51d78bacf4
248 changed files with 50171 additions and 0 deletions

View File

@@ -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:*)"
]
}
}

86
.env.example Normal file
View File

@@ -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 <notificaciones@ejemplo.com>
# =============================================================================
# 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

139
.gitignore vendored Normal file
View File

@@ -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

142
README.md Normal file
View File

@@ -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.

197
backend/.env.example Normal file
View File

@@ -0,0 +1,197 @@
# =============================================================================
# Adan Fleet Monitor - Environment Variables
# =============================================================================
# Copy this file to .env and fill in your values
# NEVER commit the .env file to version control
# =============================================================================
# =============================================================================
# Application Settings
# =============================================================================
APP_NAME="Adan Fleet Monitor"
APP_VERSION="1.0.0"
ENVIRONMENT=development # development, staging, production
DEBUG=true
HOST=0.0.0.0
PORT=8000
WORKERS=4 # Number of workers for production
# =============================================================================
# API Settings
# =============================================================================
API_V1_PREFIX=/api/v1
# =============================================================================
# Database (PostgreSQL with TimescaleDB)
# =============================================================================
# Format: postgresql+asyncpg://user:password@host:port/database
DATABASE_URL=postgresql+asyncpg://adan:your_password_here@localhost:5432/adan_fleet
# Database pool settings
DB_POOL_SIZE=20
DB_MAX_OVERFLOW=10
DB_POOL_TIMEOUT=30
# =============================================================================
# Redis (Caching & Real-time)
# =============================================================================
# Format: redis://[:password@]host:port/db
REDIS_URL=redis://localhost:6379/0
# =============================================================================
# JWT Authentication
# =============================================================================
# Generate with: openssl rand -hex 32
SECRET_KEY=your_super_secret_key_change_this_in_production_minimum_32_characters
JWT_ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=30
REFRESH_TOKEN_EXPIRE_DAYS=7
# =============================================================================
# Password Hashing
# =============================================================================
PASSWORD_MIN_LENGTH=8
# =============================================================================
# CORS Settings
# =============================================================================
# Comma-separated list of allowed origins
CORS_ORIGINS=http://localhost:3000,http://localhost:5173,http://127.0.0.1:3000
CORS_ALLOW_CREDENTIALS=true
CORS_ALLOW_METHODS=GET,POST,PUT,PATCH,DELETE,OPTIONS
CORS_ALLOW_HEADERS=*
# =============================================================================
# Traccar GPS Server Integration
# =============================================================================
TRACCAR_URL=http://localhost:8082
TRACCAR_API_URL=http://localhost:8082/api
TRACCAR_USERNAME=admin
TRACCAR_PASSWORD=admin
TRACCAR_ENABLED=true
# =============================================================================
# MediaMTX Video Streaming Server
# =============================================================================
MEDIAMTX_URL=http://localhost:8554
MEDIAMTX_API_URL=http://localhost:9997
MEDIAMTX_ENABLED=false
# =============================================================================
# MQTT Broker (IoT Communication)
# =============================================================================
MQTT_BROKER=localhost
MQTT_PORT=1883
MQTT_USERNAME=
MQTT_PASSWORD=
MQTT_ENABLED=false
# Meshtastic MQTT Topic
MESHTASTIC_TOPIC=meshtastic/#
# =============================================================================
# Email (SMTP)
# =============================================================================
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_USERNAME=
SMTP_PASSWORD=
SMTP_FROM_EMAIL=noreply@example.com
SMTP_FROM_NAME="Adan Fleet Monitor"
SMTP_TLS=true
SMTP_ENABLED=false
# =============================================================================
# Push Notifications (Firebase)
# =============================================================================
FIREBASE_CREDENTIALS_PATH=
FIREBASE_ENABLED=false
# =============================================================================
# Geocoding & Maps
# =============================================================================
# OpenStreetMap Nominatim (free, rate-limited)
NOMINATIM_USER_AGENT=adan-fleet-monitor
# Google Maps API (optional, for premium geocoding)
GOOGLE_MAPS_API_KEY=
# =============================================================================
# File Storage
# =============================================================================
UPLOAD_DIR=./uploads
MAX_UPLOAD_SIZE_MB=50
# AWS S3 (optional, for cloud storage)
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_S3_BUCKET=
AWS_S3_REGION=us-east-1
# =============================================================================
# Logging
# =============================================================================
LOG_LEVEL=INFO # DEBUG, INFO, WARNING, ERROR, CRITICAL
LOG_FORMAT=json # json, text
LOG_FILE=./logs/adan.log
# =============================================================================
# Sentry (Error Tracking)
# =============================================================================
SENTRY_DSN=
SENTRY_ENVIRONMENT=development
SENTRY_ENABLED=false
# =============================================================================
# Rate Limiting
# =============================================================================
RATE_LIMIT_REQUESTS=100
RATE_LIMIT_PERIOD_SECONDS=60
# =============================================================================
# Background Tasks (Celery)
# =============================================================================
CELERY_BROKER_URL=redis://localhost:6379/1
CELERY_RESULT_BACKEND=redis://localhost:6379/2
CELERY_ENABLED=false
# =============================================================================
# Alert Settings
# =============================================================================
# Default speed limit (km/h) for new vehicles
DEFAULT_SPEED_LIMIT=120
# Time without signal before generating alert (seconds)
NO_SIGNAL_THRESHOLD_SECONDS=300
# Battery level threshold for low battery alert (%)
LOW_BATTERY_THRESHOLD=20
# Minimum stop duration to register as a stop (seconds)
MIN_STOP_DURATION_SECONDS=120
# =============================================================================
# Trip Detection
# =============================================================================
# Minimum movement to start a trip (meters)
TRIP_START_DISTANCE_METERS=100
# Minimum idle time to end a trip (seconds)
TRIP_END_IDLE_SECONDS=300
# =============================================================================
# WebSocket Settings
# =============================================================================
WS_HEARTBEAT_INTERVAL=30
WS_MAX_CONNECTIONS=1000
# =============================================================================
# Report Settings
# =============================================================================
REPORTS_OUTPUT_DIR=./reports
REPORTS_RETENTION_DAYS=90
# =============================================================================
# Timezone
# =============================================================================
TIMEZONE=America/Mexico_City

73
backend/alembic.ini Normal file
View File

@@ -0,0 +1,73 @@
# Alembic configuration file for Adan Fleet Monitor
[alembic]
# path to migration scripts
script_location = alembic
# template used to generate migration files
file_template = %%(year)d%%(month).2d%%(day).2d_%%(hour).2d%%(minute).2d%%(second).2d_%%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
prepend_sys_path = .
# timezone to use when rendering the date within the migration file
timezone = UTC
# max length of characters to apply to the "slug" field
truncate_slug_length = 40
# set to 'true' to run the environment during the 'revision' command
revision_environment = false
# set to 'true' to allow .pyc and .pyo files without a source .py file to be detected as revisions in the versions/ directory
sourceless = false
# version location specification
version_locations = %(here)s/alembic/versions
# version path separator
version_path_separator = os
# the output encoding used when revision files are written from script.py.mako
output_encoding = utf-8
# Database URL - will be overridden by env.py
sqlalchemy.url = driver://user:pass@localhost/dbname
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts.
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

137
backend/alembic/env.py Normal file
View File

@@ -0,0 +1,137 @@
"""
Alembic environment configuration for Adan Fleet Monitor.
Configurado para:
- SQLAlchemy async (asyncpg)
- TimescaleDB hypertables
- Migraciones automáticas desde modelos
"""
import asyncio
from logging.config import fileConfig
from alembic import context
from sqlalchemy import pool, text
from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import async_engine_from_config
from app.core.config import settings
from app.models.base import Base
# Import all models so they are registered with Base.metadata
from app.models import (
Usuario,
GrupoVehiculos,
Conductor,
Vehiculo,
Dispositivo,
Ubicacion,
Viaje,
Parada,
TipoAlerta,
Alerta,
Geocerca,
POI,
CargaCombustible,
TipoMantenimiento,
Mantenimiento,
Camara,
Grabacion,
EventoVideo,
Mensaje,
Configuracion,
)
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# add your model's MetaData object here
target_metadata = Base.metadata
def get_url() -> str:
"""
Obtiene la URL de la base de datos desde la configuración.
"""
return settings.DATABASE_URL
def run_migrations_offline() -> None:
"""
Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = get_url()
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
compare_type=True,
compare_server_default=True,
)
with context.begin_transaction():
context.run_migrations()
def do_run_migrations(connection: Connection) -> None:
"""
Run migrations with the given connection.
"""
context.configure(
connection=connection,
target_metadata=target_metadata,
compare_type=True,
compare_server_default=True,
)
with context.begin_transaction():
context.run_migrations()
async def run_async_migrations() -> None:
"""
Run migrations in 'online' mode with async engine.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
configuration = config.get_section(config.config_ini_section)
configuration["sqlalchemy.url"] = get_url()
connectable = async_engine_from_config(
configuration,
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
def run_migrations_online() -> None:
"""
Run migrations in 'online' mode.
"""
asyncio.run(run_async_migrations())
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@@ -0,0 +1,26 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

View File

@@ -0,0 +1,847 @@
"""Initial schema with TimescaleDB support.
Revision ID: 001
Revises:
Create Date: 2026-01-21
Este migration crea:
- Extensiones PostgreSQL (TimescaleDB, PostGIS opcional)
- Todas las tablas del sistema
- Hypertable para ubicaciones (time-series)
- Índices y constraints
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = "001"
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ========================================================================
# Extensiones PostgreSQL
# ========================================================================
# TimescaleDB para series temporales
op.execute("CREATE EXTENSION IF NOT EXISTS timescaledb CASCADE;")
# PostGIS para datos geoespaciales (opcional)
op.execute("CREATE EXTENSION IF NOT EXISTS postgis;")
# UUID generation
op.execute('CREATE EXTENSION IF NOT EXISTS "uuid-ossp";')
# ========================================================================
# Tipos ENUM
# ========================================================================
tipo_vehiculo_enum = postgresql.ENUM(
"sedan", "suv", "pickup", "van", "camion", "motocicleta", "autobus", "otro",
name="tipo_vehiculo",
create_type=True,
)
tipo_vehiculo_enum.create(op.get_bind(), checkfirst=True)
estado_vehiculo_enum = postgresql.ENUM(
"activo", "inactivo", "mantenimiento", "baja",
name="estado_vehiculo",
create_type=True,
)
estado_vehiculo_enum.create(op.get_bind(), checkfirst=True)
tipo_conductor_enum = postgresql.ENUM(
"interno", "externo", "temporal",
name="tipo_conductor",
create_type=True,
)
tipo_conductor_enum.create(op.get_bind(), checkfirst=True)
tipo_dispositivo_enum = postgresql.ENUM(
"gps", "obd", "dashcam", "sensor", "meshtastic", "otro",
name="tipo_dispositivo",
create_type=True,
)
tipo_dispositivo_enum.create(op.get_bind(), checkfirst=True)
estado_dispositivo_enum = postgresql.ENUM(
"activo", "inactivo", "sin_senal", "bateria_baja",
name="estado_dispositivo",
create_type=True,
)
estado_dispositivo_enum.create(op.get_bind(), checkfirst=True)
tipo_geocerca_enum = postgresql.ENUM(
"circular", "poligonal",
name="tipo_geocerca",
create_type=True,
)
tipo_geocerca_enum.create(op.get_bind(), checkfirst=True)
severidad_alerta_enum = postgresql.ENUM(
"baja", "media", "alta", "critica",
name="severidad_alerta",
create_type=True,
)
severidad_alerta_enum.create(op.get_bind(), checkfirst=True)
prioridad_mensaje_enum = postgresql.ENUM(
"baja", "normal", "alta", "urgente",
name="prioridad_mensaje",
create_type=True,
)
prioridad_mensaje_enum.create(op.get_bind(), checkfirst=True)
# ========================================================================
# Tabla: usuarios
# ========================================================================
op.create_table(
"usuarios",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("email", sa.String(255), nullable=False),
sa.Column("hashed_password", sa.String(255), nullable=False),
sa.Column("nombre", sa.String(100), nullable=False),
sa.Column("apellido", sa.String(100), nullable=True),
sa.Column("telefono", sa.String(20), nullable=True),
sa.Column("rol", sa.String(50), nullable=False, server_default="operador"),
sa.Column("activo", sa.Boolean(), nullable=False, server_default="true"),
sa.Column("ultimo_login", sa.DateTime(timezone=True), nullable=True),
sa.Column("avatar_url", sa.String(500), nullable=True),
sa.Column("preferencias", postgresql.JSONB(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("email"),
)
op.create_index("ix_usuarios_email", "usuarios", ["email"])
op.create_index("ix_usuarios_rol", "usuarios", ["rol"])
# ========================================================================
# Tabla: grupos_vehiculos
# ========================================================================
op.create_table(
"grupos_vehiculos",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("nombre", sa.String(100), nullable=False),
sa.Column("descripcion", sa.Text(), nullable=True),
sa.Column("color", sa.String(7), nullable=True, server_default="#3B82F6"),
sa.Column("icono", sa.String(50), nullable=True),
sa.Column("activo", sa.Boolean(), nullable=False, server_default="true"),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("nombre"),
)
# ========================================================================
# Tabla: conductores
# ========================================================================
op.create_table(
"conductores",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("numero_empleado", sa.String(50), nullable=True),
sa.Column("nombre", sa.String(100), nullable=False),
sa.Column("apellido", sa.String(100), nullable=False),
sa.Column("email", sa.String(255), nullable=True),
sa.Column("telefono", sa.String(20), nullable=True),
sa.Column("licencia_numero", sa.String(50), nullable=True),
sa.Column("licencia_tipo", sa.String(20), nullable=True),
sa.Column("licencia_vigencia", sa.Date(), nullable=True),
sa.Column("fecha_nacimiento", sa.Date(), nullable=True),
sa.Column("fecha_contratacion", sa.Date(), nullable=True),
sa.Column("direccion", sa.Text(), nullable=True),
sa.Column("contacto_emergencia", sa.String(100), nullable=True),
sa.Column("telefono_emergencia", sa.String(20), nullable=True),
sa.Column("foto_url", sa.String(500), nullable=True),
sa.Column("tipo", tipo_conductor_enum, nullable=False, server_default="interno"),
sa.Column("activo", sa.Boolean(), nullable=False, server_default="true"),
sa.Column("notas", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_conductores_numero_empleado", "conductores", ["numero_empleado"])
op.create_index("ix_conductores_licencia_numero", "conductores", ["licencia_numero"])
op.create_index("ix_conductores_activo", "conductores", ["activo"])
# ========================================================================
# Tabla: vehiculos
# ========================================================================
op.create_table(
"vehiculos",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("nombre", sa.String(100), nullable=False),
sa.Column("placa", sa.String(20), nullable=False),
sa.Column("vin", sa.String(17), nullable=True),
sa.Column("marca", sa.String(50), nullable=True),
sa.Column("modelo", sa.String(50), nullable=True),
sa.Column("anio", sa.Integer(), nullable=True),
sa.Column("color", sa.String(30), nullable=True),
sa.Column("tipo", tipo_vehiculo_enum, nullable=False, server_default="sedan"),
sa.Column("capacidad_pasajeros", sa.Integer(), nullable=True),
sa.Column("capacidad_carga_kg", sa.Float(), nullable=True),
sa.Column("odometro_actual", sa.Float(), nullable=True, server_default="0"),
sa.Column("consumo_promedio", sa.Float(), nullable=True),
sa.Column("tipo_combustible", sa.String(20), nullable=True),
sa.Column("capacidad_tanque", sa.Float(), nullable=True),
sa.Column("icono", sa.String(50), nullable=True),
sa.Column("foto_url", sa.String(500), nullable=True),
sa.Column("estado", estado_vehiculo_enum, nullable=False, server_default="activo"),
sa.Column("grupo_id", sa.Integer(), nullable=True),
sa.Column("conductor_actual_id", sa.Integer(), nullable=True),
sa.Column("ultima_latitud", sa.Float(), nullable=True),
sa.Column("ultima_longitud", sa.Float(), nullable=True),
sa.Column("ultima_velocidad", sa.Float(), nullable=True),
sa.Column("ultimo_rumbo", sa.Float(), nullable=True),
sa.Column("ultima_ubicacion_tiempo", sa.DateTime(timezone=True), nullable=True),
sa.Column("motor_encendido", sa.Boolean(), nullable=True),
sa.Column("en_movimiento", sa.Boolean(), nullable=True, server_default="false"),
sa.Column("velocidad_maxima", sa.Float(), nullable=True, server_default="120"),
sa.Column("notas", sa.Text(), nullable=True),
sa.Column("metadata", postgresql.JSONB(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("placa"),
sa.ForeignKeyConstraint(["grupo_id"], ["grupos_vehiculos.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["conductor_actual_id"], ["conductores.id"], ondelete="SET NULL"),
)
op.create_index("ix_vehiculos_placa", "vehiculos", ["placa"])
op.create_index("ix_vehiculos_estado", "vehiculos", ["estado"])
op.create_index("ix_vehiculos_grupo_id", "vehiculos", ["grupo_id"])
# ========================================================================
# Tabla: dispositivos
# ========================================================================
op.create_table(
"dispositivos",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("identificador", sa.String(100), nullable=False),
sa.Column("nombre", sa.String(100), nullable=True),
sa.Column("tipo", tipo_dispositivo_enum, nullable=False, server_default="gps"),
sa.Column("marca", sa.String(50), nullable=True),
sa.Column("modelo", sa.String(50), nullable=True),
sa.Column("numero_serie", sa.String(100), nullable=True),
sa.Column("numero_sim", sa.String(20), nullable=True),
sa.Column("operador_sim", sa.String(50), nullable=True),
sa.Column("imei", sa.String(20), nullable=True),
sa.Column("firmware_version", sa.String(50), nullable=True),
sa.Column("protocolo", sa.String(50), nullable=True),
sa.Column("estado", estado_dispositivo_enum, nullable=False, server_default="activo"),
sa.Column("vehiculo_id", sa.Integer(), nullable=True),
sa.Column("ultima_comunicacion", sa.DateTime(timezone=True), nullable=True),
sa.Column("nivel_bateria", sa.Float(), nullable=True),
sa.Column("nivel_senal", sa.Float(), nullable=True),
sa.Column("configuracion", postgresql.JSONB(), nullable=True),
sa.Column("notas", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("identificador"),
sa.ForeignKeyConstraint(["vehiculo_id"], ["vehiculos.id"], ondelete="SET NULL"),
)
op.create_index("ix_dispositivos_identificador", "dispositivos", ["identificador"])
op.create_index("ix_dispositivos_vehiculo_id", "dispositivos", ["vehiculo_id"])
op.create_index("ix_dispositivos_imei", "dispositivos", ["imei"])
# ========================================================================
# Tabla: ubicaciones (TimescaleDB Hypertable)
# ========================================================================
op.create_table(
"ubicaciones",
sa.Column("tiempo", sa.DateTime(timezone=True), nullable=False),
sa.Column("vehiculo_id", sa.Integer(), nullable=False),
sa.Column("dispositivo_id", sa.Integer(), nullable=True),
sa.Column("latitud", sa.Float(), nullable=False),
sa.Column("longitud", sa.Float(), nullable=False),
sa.Column("altitud", sa.Float(), nullable=True),
sa.Column("velocidad", sa.Float(), nullable=True),
sa.Column("rumbo", sa.Float(), nullable=True),
sa.Column("precision", sa.Float(), nullable=True),
sa.Column("satelites", sa.Integer(), nullable=True),
sa.Column("hdop", sa.Float(), nullable=True),
sa.Column("motor_encendido", sa.Boolean(), nullable=True),
sa.Column("nivel_bateria", sa.Float(), nullable=True),
sa.Column("nivel_combustible", sa.Float(), nullable=True),
sa.Column("odometro", sa.Float(), nullable=True),
sa.Column("temperatura", sa.Float(), nullable=True),
sa.Column("evento", sa.String(50), nullable=True),
sa.Column("direccion", sa.String(500), nullable=True),
sa.Column("datos_extra", postgresql.JSONB(), nullable=True),
sa.PrimaryKeyConstraint("tiempo", "vehiculo_id"),
sa.ForeignKeyConstraint(["vehiculo_id"], ["vehiculos.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["dispositivo_id"], ["dispositivos.id"], ondelete="SET NULL"),
)
# Convertir a hypertable de TimescaleDB
op.execute(
"SELECT create_hypertable('ubicaciones', 'tiempo', "
"chunk_time_interval => INTERVAL '1 day', "
"if_not_exists => TRUE);"
)
# Índices para ubicaciones
op.create_index("ix_ubicaciones_vehiculo_tiempo", "ubicaciones", ["vehiculo_id", "tiempo"])
op.create_index("ix_ubicaciones_tiempo", "ubicaciones", ["tiempo"])
# Políticas de retención y compresión
op.execute(
"SELECT add_retention_policy('ubicaciones', INTERVAL '1 year', if_not_exists => TRUE);"
)
op.execute(
"ALTER TABLE ubicaciones SET ("
"timescaledb.compress, "
"timescaledb.compress_segmentby = 'vehiculo_id'"
");"
)
op.execute(
"SELECT add_compression_policy('ubicaciones', INTERVAL '7 days', if_not_exists => TRUE);"
)
# ========================================================================
# Tabla: viajes
# ========================================================================
op.create_table(
"viajes",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("vehiculo_id", sa.Integer(), nullable=False),
sa.Column("conductor_id", sa.Integer(), nullable=True),
sa.Column("inicio", sa.DateTime(timezone=True), nullable=False),
sa.Column("fin", sa.DateTime(timezone=True), nullable=True),
sa.Column("duracion_segundos", sa.Integer(), nullable=True),
sa.Column("distancia_km", sa.Float(), nullable=True),
sa.Column("velocidad_maxima", sa.Float(), nullable=True),
sa.Column("velocidad_promedio", sa.Float(), nullable=True),
sa.Column("latitud_inicio", sa.Float(), nullable=True),
sa.Column("longitud_inicio", sa.Float(), nullable=True),
sa.Column("latitud_fin", sa.Float(), nullable=True),
sa.Column("longitud_fin", sa.Float(), nullable=True),
sa.Column("direccion_inicio", sa.String(500), nullable=True),
sa.Column("direccion_fin", sa.String(500), nullable=True),
sa.Column("odometro_inicio", sa.Float(), nullable=True),
sa.Column("odometro_fin", sa.Float(), nullable=True),
sa.Column("combustible_consumido", sa.Float(), nullable=True),
sa.Column("costo_combustible", sa.Float(), nullable=True),
sa.Column("num_paradas", sa.Integer(), nullable=True, server_default="0"),
sa.Column("num_alertas", sa.Integer(), nullable=True, server_default="0"),
sa.Column("puntuacion_conduccion", sa.Float(), nullable=True),
sa.Column("proposito", sa.String(100), nullable=True),
sa.Column("notas", sa.Text(), nullable=True),
sa.Column("ruta_simplificada", postgresql.JSONB(), nullable=True),
sa.Column("activo", sa.Boolean(), nullable=False, server_default="true"),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.ForeignKeyConstraint(["vehiculo_id"], ["vehiculos.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["conductor_id"], ["conductores.id"], ondelete="SET NULL"),
)
op.create_index("ix_viajes_vehiculo_id", "viajes", ["vehiculo_id"])
op.create_index("ix_viajes_inicio", "viajes", ["inicio"])
op.create_index("ix_viajes_vehiculo_inicio", "viajes", ["vehiculo_id", "inicio"])
# ========================================================================
# Tabla: paradas
# ========================================================================
op.create_table(
"paradas",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("viaje_id", sa.Integer(), nullable=False),
sa.Column("vehiculo_id", sa.Integer(), nullable=False),
sa.Column("inicio", sa.DateTime(timezone=True), nullable=False),
sa.Column("fin", sa.DateTime(timezone=True), nullable=True),
sa.Column("duracion_segundos", sa.Integer(), nullable=True),
sa.Column("latitud", sa.Float(), nullable=False),
sa.Column("longitud", sa.Float(), nullable=False),
sa.Column("direccion", sa.String(500), nullable=True),
sa.Column("motor_encendido", sa.Boolean(), nullable=True),
sa.Column("poi_id", sa.Integer(), nullable=True),
sa.Column("geocerca_id", sa.Integer(), nullable=True),
sa.Column("tipo", sa.String(50), nullable=True),
sa.Column("notas", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.ForeignKeyConstraint(["viaje_id"], ["viajes.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["vehiculo_id"], ["vehiculos.id"], ondelete="CASCADE"),
)
op.create_index("ix_paradas_viaje_id", "paradas", ["viaje_id"])
op.create_index("ix_paradas_vehiculo_id", "paradas", ["vehiculo_id"])
# ========================================================================
# Tabla: tipos_alerta
# ========================================================================
op.create_table(
"tipos_alerta",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("codigo", sa.String(50), nullable=False),
sa.Column("nombre", sa.String(100), nullable=False),
sa.Column("descripcion", sa.Text(), nullable=True),
sa.Column("severidad", severidad_alerta_enum, nullable=False, server_default="media"),
sa.Column("icono", sa.String(50), nullable=True),
sa.Column("color", sa.String(7), nullable=True),
sa.Column("sonido", sa.String(100), nullable=True),
sa.Column("requiere_atencion", sa.Boolean(), nullable=False, server_default="true"),
sa.Column("notificar_email", sa.Boolean(), nullable=False, server_default="false"),
sa.Column("notificar_push", sa.Boolean(), nullable=False, server_default="true"),
sa.Column("notificar_sms", sa.Boolean(), nullable=False, server_default="false"),
sa.Column("activo", sa.Boolean(), nullable=False, server_default="true"),
sa.Column("configuracion", postgresql.JSONB(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("codigo"),
)
# ========================================================================
# Tabla: alertas
# ========================================================================
op.create_table(
"alertas",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("tipo_alerta_id", sa.Integer(), nullable=False),
sa.Column("vehiculo_id", sa.Integer(), nullable=False),
sa.Column("conductor_id", sa.Integer(), nullable=True),
sa.Column("viaje_id", sa.Integer(), nullable=True),
sa.Column("tiempo", sa.DateTime(timezone=True), nullable=False),
sa.Column("latitud", sa.Float(), nullable=True),
sa.Column("longitud", sa.Float(), nullable=True),
sa.Column("direccion", sa.String(500), nullable=True),
sa.Column("valor_detectado", sa.Float(), nullable=True),
sa.Column("valor_limite", sa.Float(), nullable=True),
sa.Column("mensaje", sa.Text(), nullable=True),
sa.Column("datos_extra", postgresql.JSONB(), nullable=True),
sa.Column("atendida", sa.Boolean(), nullable=False, server_default="false"),
sa.Column("atendida_por_id", sa.Integer(), nullable=True),
sa.Column("atendida_tiempo", sa.DateTime(timezone=True), nullable=True),
sa.Column("comentario_atencion", sa.Text(), nullable=True),
sa.Column("notificada", sa.Boolean(), nullable=False, server_default="false"),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.ForeignKeyConstraint(["tipo_alerta_id"], ["tipos_alerta.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["vehiculo_id"], ["vehiculos.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["conductor_id"], ["conductores.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["viaje_id"], ["viajes.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["atendida_por_id"], ["usuarios.id"], ondelete="SET NULL"),
)
op.create_index("ix_alertas_vehiculo_id", "alertas", ["vehiculo_id"])
op.create_index("ix_alertas_tiempo", "alertas", ["tiempo"])
op.create_index("ix_alertas_atendida", "alertas", ["atendida"])
op.create_index("ix_alertas_tipo_tiempo", "alertas", ["tipo_alerta_id", "tiempo"])
# ========================================================================
# Tabla: geocercas
# ========================================================================
op.create_table(
"geocercas",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("nombre", sa.String(100), nullable=False),
sa.Column("descripcion", sa.Text(), nullable=True),
sa.Column("tipo", tipo_geocerca_enum, nullable=False),
sa.Column("centro_latitud", sa.Float(), nullable=True),
sa.Column("centro_longitud", sa.Float(), nullable=True),
sa.Column("radio_metros", sa.Float(), nullable=True),
sa.Column("coordenadas", postgresql.JSONB(), nullable=True),
sa.Column("color", sa.String(7), nullable=True, server_default="#EF4444"),
sa.Column("alertar_entrada", sa.Boolean(), nullable=False, server_default="true"),
sa.Column("alertar_salida", sa.Boolean(), nullable=False, server_default="true"),
sa.Column("velocidad_maxima", sa.Float(), nullable=True),
sa.Column("horario_inicio", sa.Time(), nullable=True),
sa.Column("horario_fin", sa.Time(), nullable=True),
sa.Column("dias_semana", postgresql.ARRAY(sa.Integer()), nullable=True),
sa.Column("activa", sa.Boolean(), nullable=False, server_default="true"),
sa.Column("metadata", postgresql.JSONB(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_geocercas_activa", "geocercas", ["activa"])
# ========================================================================
# Tabla: geocercas_vehiculos (many-to-many)
# ========================================================================
op.create_table(
"geocercas_vehiculos",
sa.Column("geocerca_id", sa.Integer(), nullable=False),
sa.Column("vehiculo_id", sa.Integer(), nullable=False),
sa.PrimaryKeyConstraint("geocerca_id", "vehiculo_id"),
sa.ForeignKeyConstraint(["geocerca_id"], ["geocercas.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["vehiculo_id"], ["vehiculos.id"], ondelete="CASCADE"),
)
# ========================================================================
# Tabla: pois (puntos de interés)
# ========================================================================
op.create_table(
"pois",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("nombre", sa.String(100), nullable=False),
sa.Column("descripcion", sa.Text(), nullable=True),
sa.Column("categoria", sa.String(50), nullable=True),
sa.Column("latitud", sa.Float(), nullable=False),
sa.Column("longitud", sa.Float(), nullable=False),
sa.Column("direccion", sa.String(500), nullable=True),
sa.Column("telefono", sa.String(20), nullable=True),
sa.Column("email", sa.String(255), nullable=True),
sa.Column("horario", sa.String(200), nullable=True),
sa.Column("icono", sa.String(50), nullable=True),
sa.Column("color", sa.String(7), nullable=True),
sa.Column("radio_metros", sa.Float(), nullable=True, server_default="100"),
sa.Column("activo", sa.Boolean(), nullable=False, server_default="true"),
sa.Column("metadata", postgresql.JSONB(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_pois_categoria", "pois", ["categoria"])
op.create_index("ix_pois_activo", "pois", ["activo"])
# Update paradas foreign key to pois
op.create_foreign_key(
"fk_paradas_poi_id",
"paradas", "pois",
["poi_id"], ["id"],
ondelete="SET NULL"
)
op.create_foreign_key(
"fk_paradas_geocerca_id",
"paradas", "geocercas",
["geocerca_id"], ["id"],
ondelete="SET NULL"
)
# ========================================================================
# Tabla: cargas_combustible
# ========================================================================
op.create_table(
"cargas_combustible",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("vehiculo_id", sa.Integer(), nullable=False),
sa.Column("conductor_id", sa.Integer(), nullable=True),
sa.Column("fecha", sa.DateTime(timezone=True), nullable=False),
sa.Column("litros", sa.Float(), nullable=False),
sa.Column("precio_litro", sa.Float(), nullable=True),
sa.Column("costo_total", sa.Float(), nullable=True),
sa.Column("odometro", sa.Float(), nullable=True),
sa.Column("tanque_lleno", sa.Boolean(), nullable=True, server_default="true"),
sa.Column("tipo_combustible", sa.String(20), nullable=True),
sa.Column("estacion", sa.String(100), nullable=True),
sa.Column("latitud", sa.Float(), nullable=True),
sa.Column("longitud", sa.Float(), nullable=True),
sa.Column("numero_factura", sa.String(50), nullable=True),
sa.Column("foto_ticket_url", sa.String(500), nullable=True),
sa.Column("notas", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.ForeignKeyConstraint(["vehiculo_id"], ["vehiculos.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["conductor_id"], ["conductores.id"], ondelete="SET NULL"),
)
op.create_index("ix_cargas_combustible_vehiculo_id", "cargas_combustible", ["vehiculo_id"])
op.create_index("ix_cargas_combustible_fecha", "cargas_combustible", ["fecha"])
# ========================================================================
# Tabla: tipos_mantenimiento
# ========================================================================
op.create_table(
"tipos_mantenimiento",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("codigo", sa.String(50), nullable=False),
sa.Column("nombre", sa.String(100), nullable=False),
sa.Column("descripcion", sa.Text(), nullable=True),
sa.Column("categoria", sa.String(50), nullable=True),
sa.Column("intervalo_km", sa.Float(), nullable=True),
sa.Column("intervalo_dias", sa.Integer(), nullable=True),
sa.Column("costo_estimado", sa.Float(), nullable=True),
sa.Column("duracion_estimada_horas", sa.Float(), nullable=True),
sa.Column("activo", sa.Boolean(), nullable=False, server_default="true"),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("codigo"),
)
# ========================================================================
# Tabla: mantenimientos
# ========================================================================
op.create_table(
"mantenimientos",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("vehiculo_id", sa.Integer(), nullable=False),
sa.Column("tipo_mantenimiento_id", sa.Integer(), nullable=False),
sa.Column("fecha_programada", sa.DateTime(timezone=True), nullable=True),
sa.Column("fecha_realizado", sa.DateTime(timezone=True), nullable=True),
sa.Column("odometro_programado", sa.Float(), nullable=True),
sa.Column("odometro_realizado", sa.Float(), nullable=True),
sa.Column("estado", sa.String(20), nullable=False, server_default="pendiente"),
sa.Column("proveedor", sa.String(100), nullable=True),
sa.Column("costo", sa.Float(), nullable=True),
sa.Column("duracion_horas", sa.Float(), nullable=True),
sa.Column("descripcion_trabajo", sa.Text(), nullable=True),
sa.Column("repuestos_utilizados", postgresql.JSONB(), nullable=True),
sa.Column("numero_factura", sa.String(50), nullable=True),
sa.Column("documento_url", sa.String(500), nullable=True),
sa.Column("notas", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.ForeignKeyConstraint(["vehiculo_id"], ["vehiculos.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["tipo_mantenimiento_id"], ["tipos_mantenimiento.id"], ondelete="CASCADE"),
)
op.create_index("ix_mantenimientos_vehiculo_id", "mantenimientos", ["vehiculo_id"])
op.create_index("ix_mantenimientos_estado", "mantenimientos", ["estado"])
op.create_index("ix_mantenimientos_fecha_programada", "mantenimientos", ["fecha_programada"])
# ========================================================================
# Tabla: camaras
# ========================================================================
op.create_table(
"camaras",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("identificador", sa.String(100), nullable=False),
sa.Column("nombre", sa.String(100), nullable=True),
sa.Column("vehiculo_id", sa.Integer(), nullable=True),
sa.Column("tipo", sa.String(50), nullable=True),
sa.Column("posicion", sa.String(50), nullable=True),
sa.Column("resolucion", sa.String(20), nullable=True),
sa.Column("url_stream", sa.String(500), nullable=True),
sa.Column("url_snapshot", sa.String(500), nullable=True),
sa.Column("activa", sa.Boolean(), nullable=False, server_default="true"),
sa.Column("grabando", sa.Boolean(), nullable=False, server_default="false"),
sa.Column("configuracion", postgresql.JSONB(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("identificador"),
sa.ForeignKeyConstraint(["vehiculo_id"], ["vehiculos.id"], ondelete="SET NULL"),
)
op.create_index("ix_camaras_vehiculo_id", "camaras", ["vehiculo_id"])
# ========================================================================
# Tabla: grabaciones
# ========================================================================
op.create_table(
"grabaciones",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("camara_id", sa.Integer(), nullable=False),
sa.Column("vehiculo_id", sa.Integer(), nullable=True),
sa.Column("inicio", sa.DateTime(timezone=True), nullable=False),
sa.Column("fin", sa.DateTime(timezone=True), nullable=True),
sa.Column("duracion_segundos", sa.Integer(), nullable=True),
sa.Column("tamano_bytes", sa.BigInteger(), nullable=True),
sa.Column("archivo_url", sa.String(500), nullable=True),
sa.Column("thumbnail_url", sa.String(500), nullable=True),
sa.Column("estado", sa.String(20), nullable=False, server_default="grabando"),
sa.Column("tipo", sa.String(50), nullable=True),
sa.Column("metadata", postgresql.JSONB(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.ForeignKeyConstraint(["camara_id"], ["camaras.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["vehiculo_id"], ["vehiculos.id"], ondelete="SET NULL"),
)
op.create_index("ix_grabaciones_camara_id", "grabaciones", ["camara_id"])
op.create_index("ix_grabaciones_inicio", "grabaciones", ["inicio"])
# ========================================================================
# Tabla: eventos_video
# ========================================================================
op.create_table(
"eventos_video",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("grabacion_id", sa.Integer(), nullable=True),
sa.Column("camara_id", sa.Integer(), nullable=False),
sa.Column("vehiculo_id", sa.Integer(), nullable=True),
sa.Column("tiempo", sa.DateTime(timezone=True), nullable=False),
sa.Column("tipo", sa.String(50), nullable=False),
sa.Column("descripcion", sa.Text(), nullable=True),
sa.Column("latitud", sa.Float(), nullable=True),
sa.Column("longitud", sa.Float(), nullable=True),
sa.Column("snapshot_url", sa.String(500), nullable=True),
sa.Column("clip_url", sa.String(500), nullable=True),
sa.Column("duracion_clip_segundos", sa.Integer(), nullable=True),
sa.Column("confianza", sa.Float(), nullable=True),
sa.Column("metadata", postgresql.JSONB(), nullable=True),
sa.Column("revisado", sa.Boolean(), nullable=False, server_default="false"),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.ForeignKeyConstraint(["grabacion_id"], ["grabaciones.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["camara_id"], ["camaras.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["vehiculo_id"], ["vehiculos.id"], ondelete="SET NULL"),
)
op.create_index("ix_eventos_video_camara_id", "eventos_video", ["camara_id"])
op.create_index("ix_eventos_video_tiempo", "eventos_video", ["tiempo"])
op.create_index("ix_eventos_video_tipo", "eventos_video", ["tipo"])
# ========================================================================
# Tabla: mensajes
# ========================================================================
op.create_table(
"mensajes",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("remitente_usuario_id", sa.Integer(), nullable=True),
sa.Column("remitente_conductor_id", sa.Integer(), nullable=True),
sa.Column("destinatario_usuario_id", sa.Integer(), nullable=True),
sa.Column("destinatario_conductor_id", sa.Integer(), nullable=True),
sa.Column("destinatario_vehiculo_id", sa.Integer(), nullable=True),
sa.Column("asunto", sa.String(200), nullable=True),
sa.Column("contenido", sa.Text(), nullable=False),
sa.Column("tipo", sa.String(50), nullable=False, server_default="texto"),
sa.Column("prioridad", prioridad_mensaje_enum, nullable=False, server_default="normal"),
sa.Column("leido", sa.Boolean(), nullable=False, server_default="false"),
sa.Column("leido_tiempo", sa.DateTime(timezone=True), nullable=True),
sa.Column("respuesta_a_id", sa.Integer(), nullable=True),
sa.Column("adjuntos", postgresql.JSONB(), nullable=True),
sa.Column("metadata", postgresql.JSONB(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.ForeignKeyConstraint(["remitente_usuario_id"], ["usuarios.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["remitente_conductor_id"], ["conductores.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["destinatario_usuario_id"], ["usuarios.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["destinatario_conductor_id"], ["conductores.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["destinatario_vehiculo_id"], ["vehiculos.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["respuesta_a_id"], ["mensajes.id"], ondelete="SET NULL"),
)
op.create_index("ix_mensajes_destinatario_usuario_id", "mensajes", ["destinatario_usuario_id"])
op.create_index("ix_mensajes_destinatario_conductor_id", "mensajes", ["destinatario_conductor_id"])
op.create_index("ix_mensajes_leido", "mensajes", ["leido"])
# ========================================================================
# Tabla: configuracion
# ========================================================================
op.create_table(
"configuracion",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("clave", sa.String(100), nullable=False),
sa.Column("valor", sa.Text(), nullable=True),
sa.Column("tipo", sa.String(20), nullable=False, server_default="string"),
sa.Column("categoria", sa.String(50), nullable=True),
sa.Column("descripcion", sa.Text(), nullable=True),
sa.Column("editable", sa.Boolean(), nullable=False, server_default="true"),
sa.Column("usuario_id", sa.Integer(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("clave", "usuario_id", name="uq_configuracion_clave_usuario"),
sa.ForeignKeyConstraint(["usuario_id"], ["usuarios.id"], ondelete="CASCADE"),
)
op.create_index("ix_configuracion_clave", "configuracion", ["clave"])
op.create_index("ix_configuracion_categoria", "configuracion", ["categoria"])
# ========================================================================
# Datos iniciales: Tipos de alerta predefinidos
# ========================================================================
op.execute("""
INSERT INTO tipos_alerta (codigo, nombre, descripcion, severidad, icono, color, requiere_atencion)
VALUES
('EXCESO_VELOCIDAD', 'Exceso de velocidad', 'El vehículo superó el límite de velocidad configurado', 'alta', 'speed', '#EF4444', true),
('ENTRADA_GEOCERCA', 'Entrada a geocerca', 'El vehículo entró a una zona delimitada', 'media', 'map-pin', '#3B82F6', false),
('SALIDA_GEOCERCA', 'Salida de geocerca', 'El vehículo salió de una zona delimitada', 'media', 'map-pin', '#F59E0B', false),
('BATERIA_BAJA', 'Batería baja', 'La batería del dispositivo está por debajo del nivel crítico', 'alta', 'battery-low', '#EF4444', true),
('SIN_SENAL', 'Sin señal GPS', 'El dispositivo perdió señal GPS por tiempo prolongado', 'alta', 'signal-off', '#EF4444', true),
('MOTOR_ENCENDIDO', 'Motor encendido', 'El motor del vehículo fue encendido', 'baja', 'power', '#22C55E', false),
('MOTOR_APAGADO', 'Motor apagado', 'El motor del vehículo fue apagado', 'baja', 'power-off', '#6B7280', false),
('PARADA_PROLONGADA', 'Parada prolongada', 'El vehículo lleva detenido más tiempo del permitido', 'media', 'clock', '#F59E0B', true),
('ACELERACION_BRUSCA', 'Aceleración brusca', 'Se detectó una aceleración brusca', 'media', 'trending-up', '#F59E0B', false),
('FRENADO_BRUSCO', 'Frenado brusco', 'Se detectó un frenado brusco', 'media', 'trending-down', '#F59E0B', false),
('MANTENIMIENTO_PROXIMO', 'Mantenimiento próximo', 'El vehículo requiere mantenimiento pronto', 'media', 'tool', '#3B82F6', true),
('LICENCIA_POR_VENCER', 'Licencia por vencer', 'La licencia del conductor está próxima a vencer', 'alta', 'id-card', '#EF4444', true),
('COLISION_DETECTADA', 'Colisión detectada', 'Se detectó una posible colisión', 'critica', 'alert-triangle', '#DC2626', true),
('SOS', 'Botón de pánico', 'El conductor presionó el botón de emergencia', 'critica', 'alert-circle', '#DC2626', true)
ON CONFLICT DO NOTHING;
""")
# ========================================================================
# Datos iniciales: Tipos de mantenimiento predefinidos
# ========================================================================
op.execute("""
INSERT INTO tipos_mantenimiento (codigo, nombre, descripcion, categoria, intervalo_km, intervalo_dias)
VALUES
('CAMBIO_ACEITE', 'Cambio de aceite', 'Cambio de aceite y filtro de motor', 'Motor', 5000, 90),
('CAMBIO_FILTRO_AIRE', 'Cambio de filtro de aire', 'Reemplazo del filtro de aire del motor', 'Motor', 15000, 365),
('CAMBIO_FILTRO_COMBUSTIBLE', 'Cambio de filtro de combustible', 'Reemplazo del filtro de combustible', 'Motor', 20000, 365),
('CAMBIO_BUJIAS', 'Cambio de bujías', 'Reemplazo de bujías de encendido', 'Motor', 40000, NULL),
('ROTACION_LLANTAS', 'Rotación de llantas', 'Rotación de llantas para desgaste uniforme', 'Llantas', 10000, 180),
('ALINEACION_BALANCEO', 'Alineación y balanceo', 'Alineación y balanceo de llantas', 'Llantas', 20000, 365),
('CAMBIO_LLANTAS', 'Cambio de llantas', 'Reemplazo de llantas desgastadas', 'Llantas', 50000, NULL),
('REVISION_FRENOS', 'Revisión de frenos', 'Inspección del sistema de frenos', 'Frenos', 20000, 180),
('CAMBIO_BALATAS', 'Cambio de balatas', 'Reemplazo de balatas/pastillas de freno', 'Frenos', 40000, NULL),
('CAMBIO_DISCOS', 'Cambio de discos', 'Reemplazo de discos de freno', 'Frenos', 80000, NULL),
('REVISION_SUSPENSION', 'Revisión de suspensión', 'Inspección del sistema de suspensión', 'Suspensión', 30000, 365),
('CAMBIO_AMORTIGUADORES', 'Cambio de amortiguadores', 'Reemplazo de amortiguadores', 'Suspensión', 80000, NULL),
('REVISION_TRANSMISION', 'Revisión de transmisión', 'Inspección de la transmisión', 'Transmisión', 50000, 365),
('CAMBIO_LIQUIDO_TRANSMISION', 'Cambio de líquido de transmisión', 'Reemplazo del líquido de transmisión', 'Transmisión', 60000, NULL),
('REVISION_BATERIA', 'Revisión de batería', 'Inspección y prueba de batería', 'Eléctrico', NULL, 180),
('CAMBIO_BATERIA', 'Cambio de batería', 'Reemplazo de batería', 'Eléctrico', NULL, 730),
('REVISION_LUCES', 'Revisión de luces', 'Inspección del sistema de iluminación', 'Eléctrico', NULL, 90),
('REVISION_AC', 'Revisión de A/C', 'Inspección del sistema de aire acondicionado', 'Climatización', NULL, 365),
('RECARGA_AC', 'Recarga de A/C', 'Recarga de gas refrigerante del A/C', 'Climatización', NULL, 730),
('VERIFICACION_VEHICULAR', 'Verificación vehicular', 'Verificación de emisiones contaminantes', 'Legal', NULL, 180),
('REVISION_GENERAL', 'Revisión general', 'Inspección general del vehículo', 'General', 10000, 90)
ON CONFLICT DO NOTHING;
""")
def downgrade() -> None:
# Drop tables in reverse order
op.drop_table("configuracion")
op.drop_table("mensajes")
op.drop_table("eventos_video")
op.drop_table("grabaciones")
op.drop_table("camaras")
op.drop_table("mantenimientos")
op.drop_table("tipos_mantenimiento")
op.drop_table("cargas_combustible")
op.drop_table("pois")
op.drop_table("geocercas_vehiculos")
op.drop_table("geocercas")
op.drop_table("alertas")
op.drop_table("tipos_alerta")
op.drop_table("paradas")
op.drop_table("viajes")
op.drop_table("ubicaciones")
op.drop_table("dispositivos")
op.drop_table("vehiculos")
op.drop_table("conductores")
op.drop_table("grupos_vehiculos")
op.drop_table("usuarios")
# Drop ENUMs
op.execute("DROP TYPE IF EXISTS prioridad_mensaje;")
op.execute("DROP TYPE IF EXISTS severidad_alerta;")
op.execute("DROP TYPE IF EXISTS tipo_geocerca;")
op.execute("DROP TYPE IF EXISTS estado_dispositivo;")
op.execute("DROP TYPE IF EXISTS tipo_dispositivo;")
op.execute("DROP TYPE IF EXISTS tipo_conductor;")
op.execute("DROP TYPE IF EXISTS estado_vehiculo;")
op.execute("DROP TYPE IF EXISTS tipo_vehiculo;")
# Drop extensions
op.execute("DROP EXTENSION IF EXISTS postgis;")
op.execute('DROP EXTENSION IF EXISTS "uuid-ossp";')
# Note: TimescaleDB extension usually can't be dropped easily

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

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

View File

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

View File

@@ -0,0 +1,402 @@
"""
Endpoints para gestión de alertas.
"""
from datetime import datetime, timezone
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.core.database import get_db
from app.core.security import get_current_user
from app.models.usuario import Usuario
from app.models.alerta import Alerta
from app.models.tipo_alerta import TipoAlerta
from app.schemas.alerta import (
TipoAlertaCreate,
TipoAlertaUpdate,
TipoAlertaResponse,
AlertaCreate,
AlertaResponse,
AlertaConRelaciones,
AlertaResumen,
AlertasEstadisticas,
AlertaAtenderRequest,
)
from app.services.alerta_service import AlertaService
router = APIRouter(prefix="/alertas", tags=["Alertas"])
# ============================================================================
# Tipos de Alerta
# ============================================================================
@router.get("/tipos", response_model=List[TipoAlertaResponse])
async def listar_tipos_alerta(
activo: Optional[bool] = None,
db: AsyncSession = Depends(get_db),
current_user: Usuario = Depends(get_current_user),
):
"""
Lista todos los tipos de alerta.
Args:
activo: Filtrar por estado activo.
Returns:
Lista de tipos de alerta.
"""
query = select(TipoAlerta).order_by(TipoAlerta.prioridad)
if activo is not None:
query = query.where(TipoAlerta.activo == activo)
result = await db.execute(query)
tipos = result.scalars().all()
return [TipoAlertaResponse.model_validate(t) for t in tipos]
@router.post("/tipos", response_model=TipoAlertaResponse, status_code=status.HTTP_201_CREATED)
async def crear_tipo_alerta(
tipo_data: TipoAlertaCreate,
db: AsyncSession = Depends(get_db),
current_user: Usuario = Depends(get_current_user),
):
"""
Crea un nuevo tipo de alerta.
Args:
tipo_data: Datos del tipo de alerta.
Returns:
Tipo de alerta creado.
"""
# Verificar código único
result = await db.execute(
select(TipoAlerta).where(TipoAlerta.codigo == tipo_data.codigo)
)
if result.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Ya existe un tipo de alerta con el código {tipo_data.codigo}",
)
tipo = TipoAlerta(**tipo_data.model_dump())
db.add(tipo)
await db.commit()
await db.refresh(tipo)
return TipoAlertaResponse.model_validate(tipo)
@router.put("/tipos/{tipo_id}", response_model=TipoAlertaResponse)
async def actualizar_tipo_alerta(
tipo_id: int,
tipo_data: TipoAlertaUpdate,
db: AsyncSession = Depends(get_db),
current_user: Usuario = Depends(get_current_user),
):
"""
Actualiza un tipo de alerta.
Args:
tipo_id: ID del tipo.
tipo_data: Datos a actualizar.
Returns:
Tipo actualizado.
"""
result = await db.execute(
select(TipoAlerta).where(TipoAlerta.id == tipo_id)
)
tipo = result.scalar_one_or_none()
if not tipo:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Tipo de alerta con id {tipo_id} no encontrado",
)
update_data = tipo_data.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(tipo, field, value)
await db.commit()
await db.refresh(tipo)
return TipoAlertaResponse.model_validate(tipo)
# ============================================================================
# Alertas
# ============================================================================
@router.get("", response_model=List[AlertaResumen])
async def listar_alertas(
vehiculo_id: Optional[int] = None,
tipo_alerta_id: Optional[int] = None,
severidad: Optional[str] = None,
atendida: Optional[bool] = None,
desde: Optional[datetime] = None,
hasta: Optional[datetime] = None,
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100),
db: AsyncSession = Depends(get_db),
current_user: Usuario = Depends(get_current_user),
):
"""
Lista alertas con filtros opcionales.
Args:
vehiculo_id: Filtrar por vehículo.
tipo_alerta_id: Filtrar por tipo.
severidad: Filtrar por severidad.
atendida: Filtrar por estado de atención.
desde: Fecha inicio.
hasta: Fecha fin.
skip: Registros a saltar.
limit: Límite de registros.
Returns:
Lista de alertas.
"""
query = (
select(Alerta)
.options(selectinload(Alerta.tipo_alerta))
.order_by(Alerta.creado_en.desc())
)
if vehiculo_id:
query = query.where(Alerta.vehiculo_id == vehiculo_id)
if tipo_alerta_id:
query = query.where(Alerta.tipo_alerta_id == tipo_alerta_id)
if severidad:
query = query.where(Alerta.severidad == severidad)
if atendida is not None:
query = query.where(Alerta.atendida == atendida)
if desde:
query = query.where(Alerta.creado_en >= desde)
if hasta:
query = query.where(Alerta.creado_en <= hasta)
query = query.offset(skip).limit(limit)
result = await db.execute(query)
alertas = result.scalars().all()
return [
AlertaResumen(
id=a.id,
tipo_codigo=a.tipo_alerta.codigo if a.tipo_alerta else "",
tipo_nombre=a.tipo_alerta.nombre if a.tipo_alerta else "",
severidad=a.severidad,
mensaje=a.mensaje,
vehiculo_nombre=a.vehiculo.nombre if a.vehiculo else None,
vehiculo_placa=a.vehiculo.placa if a.vehiculo else None,
creado_en=a.creado_en,
atendida=a.atendida,
)
for a in alertas
]
@router.get("/pendientes", response_model=List[AlertaResumen])
async def listar_alertas_pendientes(
severidad: Optional[str] = None,
limit: int = Query(50, ge=1, le=100),
db: AsyncSession = Depends(get_db),
current_user: Usuario = Depends(get_current_user),
):
"""
Lista alertas pendientes de atender.
Args:
severidad: Filtrar por severidad.
limit: Límite de resultados.
Returns:
Lista de alertas pendientes.
"""
alerta_service = AlertaService(db)
alertas = await alerta_service.obtener_alertas_pendientes(
severidad=severidad,
limite=limit,
)
# Cargar relaciones
for a in alertas:
await db.refresh(a, ["tipo_alerta", "vehiculo"])
return [
AlertaResumen(
id=a.id,
tipo_codigo=a.tipo_alerta.codigo if a.tipo_alerta else "",
tipo_nombre=a.tipo_alerta.nombre if a.tipo_alerta else "",
severidad=a.severidad,
mensaje=a.mensaje,
vehiculo_nombre=a.vehiculo.nombre if a.vehiculo else None,
vehiculo_placa=a.vehiculo.placa if a.vehiculo else None,
creado_en=a.creado_en,
atendida=a.atendida,
)
for a in alertas
]
@router.get("/estadisticas", response_model=AlertasEstadisticas)
async def obtener_estadisticas_alertas(
desde: Optional[datetime] = None,
hasta: Optional[datetime] = None,
db: AsyncSession = Depends(get_db),
current_user: Usuario = Depends(get_current_user),
):
"""
Obtiene estadísticas de alertas.
Args:
desde: Fecha inicio (opcional).
hasta: Fecha fin (opcional).
Returns:
Estadísticas de alertas.
"""
alerta_service = AlertaService(db)
stats = await alerta_service.obtener_estadisticas(desde, hasta)
return AlertasEstadisticas(
total=stats["total"],
pendientes=stats["pendientes"],
atendidas=stats["atendidas"],
criticas=stats["criticas"],
altas=stats["altas"],
medias=stats["medias"],
bajas=stats["bajas"],
por_tipo=stats["por_tipo"],
por_vehiculo=[], # TODO: Agregar en servicio
)
@router.get("/{alerta_id}", response_model=AlertaConRelaciones)
async def obtener_alerta(
alerta_id: int,
db: AsyncSession = Depends(get_db),
current_user: Usuario = Depends(get_current_user),
):
"""
Obtiene una alerta por su ID.
Args:
alerta_id: ID de la alerta.
Returns:
Alerta con relaciones.
"""
result = await db.execute(
select(Alerta)
.options(
selectinload(Alerta.tipo_alerta),
selectinload(Alerta.vehiculo),
selectinload(Alerta.conductor),
)
.where(Alerta.id == alerta_id)
)
alerta = result.scalar_one_or_none()
if not alerta:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Alerta con id {alerta_id} no encontrada",
)
return AlertaConRelaciones(
id=alerta.id,
tipo_alerta_id=alerta.tipo_alerta_id,
severidad=alerta.severidad,
mensaje=alerta.mensaje,
descripcion=alerta.descripcion,
vehiculo_id=alerta.vehiculo_id,
conductor_id=alerta.conductor_id,
dispositivo_id=alerta.dispositivo_id,
lat=alerta.lat,
lng=alerta.lng,
direccion=alerta.direccion,
velocidad=alerta.velocidad,
valor=alerta.valor,
umbral=alerta.umbral,
datos_extra=alerta.datos_extra,
atendida=alerta.atendida,
atendida_por_id=alerta.atendida_por_id,
atendida_en=alerta.atendida_en,
notas_atencion=alerta.notas_atencion,
notificacion_email_enviada=alerta.notificacion_email_enviada,
notificacion_push_enviada=alerta.notificacion_push_enviada,
notificacion_sms_enviada=alerta.notificacion_sms_enviada,
creado_en=alerta.creado_en,
actualizado_en=alerta.actualizado_en,
es_critica=alerta.es_critica,
tipo_alerta=TipoAlertaResponse.model_validate(alerta.tipo_alerta),
vehiculo_nombre=alerta.vehiculo.nombre if alerta.vehiculo else None,
vehiculo_placa=alerta.vehiculo.placa if alerta.vehiculo else None,
conductor_nombre=alerta.conductor.nombre_completo if alerta.conductor else None,
)
@router.post("", response_model=AlertaResponse, status_code=status.HTTP_201_CREATED)
async def crear_alerta(
alerta_data: AlertaCreate,
db: AsyncSession = Depends(get_db),
current_user: Usuario = Depends(get_current_user),
):
"""
Crea una alerta manualmente.
Args:
alerta_data: Datos de la alerta.
Returns:
Alerta creada.
"""
alerta_service = AlertaService(db)
alerta = await alerta_service.crear_alerta(alerta_data)
return AlertaResponse.model_validate(alerta)
@router.put("/{alerta_id}/atender")
async def atender_alerta(
alerta_id: int,
request: AlertaAtenderRequest,
db: AsyncSession = Depends(get_db),
current_user: Usuario = Depends(get_current_user),
):
"""
Marca una alerta como atendida.
Args:
alerta_id: ID de la alerta.
request: Notas de atención.
Returns:
Alerta actualizada.
"""
alerta_service = AlertaService(db)
alerta = await alerta_service.marcar_atendida(
alerta_id,
current_user.id,
request.notas_atencion,
)
if not alerta:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Alerta con id {alerta_id} no encontrada",
)
return {"message": "Alerta marcada como atendida", "alerta_id": alerta_id}

274
backend/app/api/v1/auth.py Normal file
View File

@@ -0,0 +1,274 @@
"""
Endpoints de autenticación.
"""
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.security import (
create_access_token,
create_refresh_token,
decode_token,
hash_password,
verify_password,
verify_token_type,
get_current_user,
)
from app.core.config import settings
from app.models.usuario import Usuario
from app.schemas.usuario import (
LoginRequest,
LoginResponse,
RefreshTokenRequest,
TokenResponse,
UsuarioCreate,
UsuarioResponse,
UsuarioUpdate,
UsuarioUpdatePassword,
)
router = APIRouter(prefix="/auth", tags=["Autenticacion"])
@router.post("/login", response_model=LoginResponse)
async def login(
request: LoginRequest,
db: AsyncSession = Depends(get_db),
):
"""
Autentica un usuario y devuelve tokens JWT.
Args:
request: Credenciales de login.
db: Sesión de base de datos.
Returns:
Tokens de acceso y refresco.
"""
# Buscar usuario por email
result = await db.execute(
select(Usuario).where(Usuario.email == request.email)
)
user = result.scalar_one_or_none()
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Email o contraseña incorrectos",
)
if not verify_password(request.password, user.password_hash):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Email o contraseña incorrectos",
)
if not user.activo:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Usuario desactivado",
)
# Actualizar último acceso
user.ultimo_acceso = datetime.now(timezone.utc)
await db.commit()
# Generar tokens
token_data = {"sub": str(user.id), "email": user.email}
access_token = create_access_token(token_data)
refresh_token = create_refresh_token(token_data)
return LoginResponse(
access_token=access_token,
refresh_token=refresh_token,
token_type="bearer",
expires_in=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60,
user=UsuarioResponse.model_validate(user),
)
@router.post("/refresh", response_model=TokenResponse)
async def refresh_token(
request: RefreshTokenRequest,
db: AsyncSession = Depends(get_db),
):
"""
Renueva los tokens usando el refresh token.
Args:
request: Token de refresco.
db: Sesión de base de datos.
Returns:
Nuevos tokens de acceso y refresco.
"""
# Decodificar y verificar el refresh token
payload = decode_token(request.refresh_token)
verify_token_type(payload, "refresh")
user_id = payload.get("sub")
if not user_id:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token inválido",
)
# Verificar que el usuario existe y está activo
result = await db.execute(
select(Usuario).where(Usuario.id == int(user_id))
)
user = result.scalar_one_or_none()
if not user or not user.activo:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Usuario no válido",
)
# Generar nuevos tokens
token_data = {"sub": str(user.id), "email": user.email}
access_token = create_access_token(token_data)
refresh_token = create_refresh_token(token_data)
return TokenResponse(
access_token=access_token,
refresh_token=refresh_token,
token_type="bearer",
expires_in=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60,
)
@router.post("/logout")
async def logout():
"""
Cierra la sesión del usuario.
En una implementación con blacklist de tokens, aquí se
agregaría el token a la lista negra.
Returns:
Mensaje de confirmación.
"""
# TODO: Implementar blacklist de tokens en Redis
return {"message": "Sesión cerrada correctamente"}
@router.post("/register", response_model=UsuarioResponse, status_code=status.HTTP_201_CREATED)
async def register(
user_data: UsuarioCreate,
db: AsyncSession = Depends(get_db),
):
"""
Registra un nuevo usuario.
Args:
user_data: Datos del usuario a crear.
db: Sesión de base de datos.
Returns:
Usuario creado.
"""
# Verificar si el email ya existe
result = await db.execute(
select(Usuario).where(Usuario.email == user_data.email)
)
if result.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="El email ya está registrado",
)
# Crear usuario
user = Usuario(
email=user_data.email,
password_hash=hash_password(user_data.password),
nombre=user_data.nombre,
apellido=user_data.apellido,
telefono=user_data.telefono,
es_admin=user_data.es_admin,
)
db.add(user)
await db.commit()
await db.refresh(user)
return UsuarioResponse.model_validate(user)
@router.get("/me", response_model=UsuarioResponse)
async def get_current_user_info(
current_user: Usuario = Depends(get_current_user),
):
"""
Obtiene información del usuario actual.
Args:
current_user: Usuario autenticado.
Returns:
Información del usuario.
"""
return UsuarioResponse.model_validate(current_user)
@router.put("/me", response_model=UsuarioResponse)
async def update_current_user(
user_data: UsuarioUpdate,
current_user: Usuario = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""
Actualiza información del usuario actual.
Args:
user_data: Datos a actualizar.
current_user: Usuario autenticado.
db: Sesión de base de datos.
Returns:
Usuario actualizado.
"""
# Actualizar solo campos proporcionados
update_data = user_data.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(current_user, field, value)
await db.commit()
await db.refresh(current_user)
return UsuarioResponse.model_validate(current_user)
@router.put("/me/password")
async def change_password(
password_data: UsuarioUpdatePassword,
current_user: Usuario = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""
Cambia la contraseña del usuario actual.
Args:
password_data: Contraseñas actual y nueva.
current_user: Usuario autenticado.
db: Sesión de base de datos.
Returns:
Mensaje de confirmación.
"""
# Verificar contraseña actual
if not verify_password(password_data.password_actual, current_user.password_hash):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Contraseña actual incorrecta",
)
# Actualizar contraseña
current_user.password_hash = hash_password(password_data.password_nuevo)
await db.commit()
return {"message": "Contraseña actualizada correctamente"}

View File

@@ -0,0 +1,411 @@
"""
Endpoints para gestión de conductores.
"""
from datetime import datetime, timedelta, timezone
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.security import get_current_user
from app.models.usuario import Usuario
from app.models.conductor import Conductor
from app.models.viaje import Viaje
from app.models.alerta import Alerta
from app.schemas.conductor import (
ConductorCreate,
ConductorUpdate,
ConductorResponse,
ConductorResumen,
ConductorEstadisticas,
)
router = APIRouter(prefix="/conductores", tags=["Conductores"])
@router.get("", response_model=List[ConductorResumen])
async def listar_conductores(
activo: Optional[bool] = None,
buscar: Optional[str] = None,
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100),
db: AsyncSession = Depends(get_db),
current_user: Usuario = Depends(get_current_user),
):
"""
Lista todos los conductores con filtros opcionales.
Args:
activo: Filtrar por estado activo.
buscar: Búsqueda por nombre, apellido o licencia.
skip: Registros a saltar.
limit: Límite de registros.
Returns:
Lista de conductores.
"""
query = select(Conductor)
if activo is not None:
query = query.where(Conductor.activo == activo)
if buscar:
query = query.where(
(Conductor.nombre.ilike(f"%{buscar}%")) |
(Conductor.apellido.ilike(f"%{buscar}%")) |
(Conductor.licencia_numero.ilike(f"%{buscar}%"))
)
query = query.offset(skip).limit(limit).order_by(Conductor.nombre)
result = await db.execute(query)
conductores = result.scalars().all()
return [
ConductorResumen(
id=c.id,
nombre_completo=c.nombre_completo,
telefono=c.telefono,
licencia_vigente=c.licencia_vigente,
activo=c.activo,
)
for c in conductores
]
@router.get("/{conductor_id}", response_model=ConductorResponse)
async def obtener_conductor(
conductor_id: int,
db: AsyncSession = Depends(get_db),
current_user: Usuario = Depends(get_current_user),
):
"""
Obtiene un conductor por su ID.
Args:
conductor_id: ID del conductor.
Returns:
Conductor encontrado.
"""
result = await db.execute(
select(Conductor).where(Conductor.id == conductor_id)
)
conductor = result.scalar_one_or_none()
if not conductor:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Conductor con id {conductor_id} no encontrado",
)
return ConductorResponse(
id=conductor.id,
nombre=conductor.nombre,
apellido=conductor.apellido,
telefono=conductor.telefono,
email=conductor.email,
documento_tipo=conductor.documento_tipo,
documento_numero=conductor.documento_numero,
licencia_numero=conductor.licencia_numero,
licencia_tipo=conductor.licencia_tipo,
licencia_vencimiento=conductor.licencia_vencimiento,
fecha_nacimiento=conductor.fecha_nacimiento,
direccion=conductor.direccion,
contacto_emergencia=conductor.contacto_emergencia,
telefono_emergencia=conductor.telefono_emergencia,
fecha_contratacion=conductor.fecha_contratacion,
numero_empleado=conductor.numero_empleado,
foto_url=conductor.foto_url,
activo=conductor.activo,
notas=conductor.notas,
nombre_completo=conductor.nombre_completo,
licencia_vigente=conductor.licencia_vigente,
creado_en=conductor.creado_en,
actualizado_en=conductor.actualizado_en,
)
@router.post("", response_model=ConductorResponse, status_code=status.HTTP_201_CREATED)
async def crear_conductor(
conductor_data: ConductorCreate,
db: AsyncSession = Depends(get_db),
current_user: Usuario = Depends(get_current_user),
):
"""
Crea un nuevo conductor.
Args:
conductor_data: Datos del conductor.
Returns:
Conductor creado.
"""
# Verificar licencia única si se proporciona
if conductor_data.licencia_numero:
result = await db.execute(
select(Conductor).where(Conductor.licencia_numero == conductor_data.licencia_numero)
)
if result.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Ya existe un conductor con la licencia {conductor_data.licencia_numero}",
)
conductor = Conductor(**conductor_data.model_dump())
db.add(conductor)
await db.commit()
await db.refresh(conductor)
return ConductorResponse(
id=conductor.id,
nombre=conductor.nombre,
apellido=conductor.apellido,
telefono=conductor.telefono,
email=conductor.email,
documento_tipo=conductor.documento_tipo,
documento_numero=conductor.documento_numero,
licencia_numero=conductor.licencia_numero,
licencia_tipo=conductor.licencia_tipo,
licencia_vencimiento=conductor.licencia_vencimiento,
fecha_nacimiento=conductor.fecha_nacimiento,
direccion=conductor.direccion,
contacto_emergencia=conductor.contacto_emergencia,
telefono_emergencia=conductor.telefono_emergencia,
fecha_contratacion=conductor.fecha_contratacion,
numero_empleado=conductor.numero_empleado,
foto_url=conductor.foto_url,
activo=conductor.activo,
notas=conductor.notas,
nombre_completo=conductor.nombre_completo,
licencia_vigente=conductor.licencia_vigente,
creado_en=conductor.creado_en,
actualizado_en=conductor.actualizado_en,
)
@router.put("/{conductor_id}", response_model=ConductorResponse)
async def actualizar_conductor(
conductor_id: int,
conductor_data: ConductorUpdate,
db: AsyncSession = Depends(get_db),
current_user: Usuario = Depends(get_current_user),
):
"""
Actualiza un conductor existente.
Args:
conductor_id: ID del conductor.
conductor_data: Datos a actualizar.
Returns:
Conductor actualizado.
"""
result = await db.execute(
select(Conductor).where(Conductor.id == conductor_id)
)
conductor = result.scalar_one_or_none()
if not conductor:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Conductor con id {conductor_id} no encontrado",
)
# Verificar licencia única si se cambia
if conductor_data.licencia_numero and conductor_data.licencia_numero != conductor.licencia_numero:
result = await db.execute(
select(Conductor).where(Conductor.licencia_numero == conductor_data.licencia_numero)
)
if result.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Ya existe un conductor con la licencia {conductor_data.licencia_numero}",
)
update_data = conductor_data.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(conductor, field, value)
await db.commit()
await db.refresh(conductor)
return ConductorResponse(
id=conductor.id,
nombre=conductor.nombre,
apellido=conductor.apellido,
telefono=conductor.telefono,
email=conductor.email,
documento_tipo=conductor.documento_tipo,
documento_numero=conductor.documento_numero,
licencia_numero=conductor.licencia_numero,
licencia_tipo=conductor.licencia_tipo,
licencia_vencimiento=conductor.licencia_vencimiento,
fecha_nacimiento=conductor.fecha_nacimiento,
direccion=conductor.direccion,
contacto_emergencia=conductor.contacto_emergencia,
telefono_emergencia=conductor.telefono_emergencia,
fecha_contratacion=conductor.fecha_contratacion,
numero_empleado=conductor.numero_empleado,
foto_url=conductor.foto_url,
activo=conductor.activo,
notas=conductor.notas,
nombre_completo=conductor.nombre_completo,
licencia_vigente=conductor.licencia_vigente,
creado_en=conductor.creado_en,
actualizado_en=conductor.actualizado_en,
)
@router.delete("/{conductor_id}", status_code=status.HTTP_204_NO_CONTENT)
async def eliminar_conductor(
conductor_id: int,
db: AsyncSession = Depends(get_db),
current_user: Usuario = Depends(get_current_user),
):
"""
Elimina un conductor (soft delete - desactiva).
Args:
conductor_id: ID del conductor.
"""
result = await db.execute(
select(Conductor).where(Conductor.id == conductor_id)
)
conductor = result.scalar_one_or_none()
if not conductor:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Conductor con id {conductor_id} no encontrado",
)
conductor.activo = False
await db.commit()
@router.get("/{conductor_id}/estadisticas", response_model=ConductorEstadisticas)
async def obtener_estadisticas_conductor(
conductor_id: int,
db: AsyncSession = Depends(get_db),
current_user: Usuario = Depends(get_current_user),
):
"""
Obtiene estadísticas de un conductor.
Args:
conductor_id: ID del conductor.
Returns:
Estadísticas del conductor.
"""
result = await db.execute(
select(Conductor).where(Conductor.id == conductor_id)
)
conductor = result.scalar_one_or_none()
if not conductor:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Conductor con id {conductor_id} no encontrado",
)
# Total de viajes
result = await db.execute(
select(func.count(Viaje.id))
.where(Viaje.conductor_id == conductor_id)
)
total_viajes = result.scalar() or 0
# Distancia total
result = await db.execute(
select(func.coalesce(func.sum(Viaje.distancia_km), 0))
.where(Viaje.conductor_id == conductor_id)
)
distancia_total = result.scalar() or 0
# Tiempo de conducción
result = await db.execute(
select(func.coalesce(func.sum(Viaje.tiempo_movimiento_segundos), 0))
.where(Viaje.conductor_id == conductor_id)
)
tiempo_conduccion = result.scalar() or 0
# Velocidad promedio
result = await db.execute(
select(func.avg(Viaje.velocidad_promedio))
.where(Viaje.conductor_id == conductor_id)
.where(Viaje.velocidad_promedio.isnot(None))
)
velocidad_promedio = result.scalar() or 0
# Total de alertas
result = await db.execute(
select(func.count(Alerta.id))
.where(Alerta.conductor_id == conductor_id)
)
alertas_total = result.scalar() or 0
# Alertas de velocidad
from app.models.tipo_alerta import TipoAlerta
result = await db.execute(
select(func.count(Alerta.id))
.join(TipoAlerta, Alerta.tipo_alerta_id == TipoAlerta.id)
.where(Alerta.conductor_id == conductor_id)
.where(TipoAlerta.codigo == "EXCESO_VELOCIDAD")
)
alertas_velocidad = result.scalar() or 0
return ConductorEstadisticas(
conductor_id=conductor.id,
nombre_completo=conductor.nombre_completo,
total_viajes=total_viajes,
distancia_total_km=float(distancia_total),
tiempo_conduccion_horas=tiempo_conduccion / 3600,
velocidad_promedio=float(velocidad_promedio) if velocidad_promedio else 0,
alertas_total=alertas_total,
alertas_velocidad=alertas_velocidad,
)
@router.get("/licencias/por-vencer", response_model=List[ConductorResumen])
async def obtener_licencias_por_vencer(
dias: int = Query(30, ge=1, le=365),
db: AsyncSession = Depends(get_db),
current_user: Usuario = Depends(get_current_user),
):
"""
Obtiene conductores con licencias próximas a vencer.
Args:
dias: Días para considerar como "próximo a vencer".
Returns:
Lista de conductores con licencias por vencer.
"""
from datetime import date
fecha_limite = date.today() + timedelta(days=dias)
result = await db.execute(
select(Conductor)
.where(Conductor.activo == True)
.where(Conductor.licencia_vencimiento.isnot(None))
.where(Conductor.licencia_vencimiento <= fecha_limite)
.order_by(Conductor.licencia_vencimiento)
)
conductores = result.scalars().all()
return [
ConductorResumen(
id=c.id,
nombre_completo=c.nombre_completo,
telefono=c.telefono,
licencia_vigente=c.licencia_vigente,
activo=c.activo,
)
for c in conductores
]

View File

@@ -0,0 +1,362 @@
"""
Endpoints para gestión de dispositivos GPS.
"""
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.core.database import get_db
from app.core.security import get_current_user
from app.models.usuario import Usuario
from app.models.dispositivo import Dispositivo
from app.schemas.dispositivo import (
DispositivoCreate,
DispositivoUpdate,
DispositivoResponse,
DispositivoResumen,
DispositivoConVehiculo,
)
router = APIRouter(prefix="/dispositivos", tags=["Dispositivos"])
@router.get("", response_model=List[DispositivoResumen])
async def listar_dispositivos(
vehiculo_id: Optional[int] = None,
tipo: Optional[str] = None,
activo: Optional[bool] = None,
conectado: Optional[bool] = None,
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100),
db: AsyncSession = Depends(get_db),
current_user: Usuario = Depends(get_current_user),
):
"""
Lista dispositivos con filtros opcionales.
Args:
vehiculo_id: Filtrar por vehículo.
tipo: Filtrar por tipo.
activo: Filtrar por estado activo.
conectado: Filtrar por estado de conexión.
skip: Registros a saltar.
limit: Límite de registros.
Returns:
Lista de dispositivos.
"""
query = select(Dispositivo).order_by(Dispositivo.identificador)
if vehiculo_id:
query = query.where(Dispositivo.vehiculo_id == vehiculo_id)
if tipo:
query = query.where(Dispositivo.tipo == tipo)
if activo is not None:
query = query.where(Dispositivo.activo == activo)
if conectado is not None:
query = query.where(Dispositivo.conectado == conectado)
query = query.offset(skip).limit(limit)
result = await db.execute(query)
dispositivos = result.scalars().all()
return [
DispositivoResumen(
id=d.id,
identificador=d.identificador,
tipo=d.tipo,
protocolo=d.protocolo,
activo=d.activo,
conectado=d.conectado,
ultimo_contacto=d.ultimo_contacto,
bateria=d.bateria,
)
for d in dispositivos
]
@router.get("/{dispositivo_id}", response_model=DispositivoConVehiculo)
async def obtener_dispositivo(
dispositivo_id: int,
db: AsyncSession = Depends(get_db),
current_user: Usuario = Depends(get_current_user),
):
"""
Obtiene un dispositivo por su ID.
Args:
dispositivo_id: ID del dispositivo.
Returns:
Dispositivo con información del vehículo.
"""
result = await db.execute(
select(Dispositivo)
.options(selectinload(Dispositivo.vehiculo))
.where(Dispositivo.id == dispositivo_id)
)
dispositivo = result.scalar_one_or_none()
if not dispositivo:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Dispositivo con id {dispositivo_id} no encontrado",
)
return DispositivoConVehiculo(
id=dispositivo.id,
vehiculo_id=dispositivo.vehiculo_id,
tipo=dispositivo.tipo,
identificador=dispositivo.identificador,
nombre=dispositivo.nombre,
marca=dispositivo.marca,
modelo=dispositivo.modelo,
numero_serie=dispositivo.numero_serie,
telefono_sim=dispositivo.telefono_sim,
operador_sim=dispositivo.operador_sim,
iccid=dispositivo.iccid,
imei=dispositivo.imei,
protocolo=dispositivo.protocolo,
ultimo_contacto=dispositivo.ultimo_contacto,
bateria=dispositivo.bateria,
señal_gsm=dispositivo.señal_gsm,
satelites=dispositivo.satelites,
intervalo_reporte=dispositivo.intervalo_reporte,
configuracion=dispositivo.configuracion,
firmware_version=dispositivo.firmware_version,
activo=dispositivo.activo,
conectado=dispositivo.conectado,
notas=dispositivo.notas,
esta_online=dispositivo.esta_online,
creado_en=dispositivo.creado_en,
actualizado_en=dispositivo.actualizado_en,
vehiculo_nombre=dispositivo.vehiculo.nombre if dispositivo.vehiculo else None,
vehiculo_placa=dispositivo.vehiculo.placa if dispositivo.vehiculo else None,
)
@router.post("", response_model=DispositivoResponse, status_code=status.HTTP_201_CREATED)
async def crear_dispositivo(
dispositivo_data: DispositivoCreate,
db: AsyncSession = Depends(get_db),
current_user: Usuario = Depends(get_current_user),
):
"""
Crea un nuevo dispositivo.
Args:
dispositivo_data: Datos del dispositivo.
Returns:
Dispositivo creado.
"""
# Verificar identificador único
result = await db.execute(
select(Dispositivo).where(Dispositivo.identificador == dispositivo_data.identificador)
)
if result.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Ya existe un dispositivo con el identificador {dispositivo_data.identificador}",
)
# Verificar IMEI único si se proporciona
if dispositivo_data.imei:
result = await db.execute(
select(Dispositivo).where(Dispositivo.imei == dispositivo_data.imei)
)
if result.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Ya existe un dispositivo con el IMEI {dispositivo_data.imei}",
)
dispositivo = Dispositivo(**dispositivo_data.model_dump())
db.add(dispositivo)
await db.commit()
await db.refresh(dispositivo)
return DispositivoResponse(
id=dispositivo.id,
vehiculo_id=dispositivo.vehiculo_id,
tipo=dispositivo.tipo,
identificador=dispositivo.identificador,
nombre=dispositivo.nombre,
marca=dispositivo.marca,
modelo=dispositivo.modelo,
numero_serie=dispositivo.numero_serie,
telefono_sim=dispositivo.telefono_sim,
operador_sim=dispositivo.operador_sim,
iccid=dispositivo.iccid,
imei=dispositivo.imei,
protocolo=dispositivo.protocolo,
ultimo_contacto=dispositivo.ultimo_contacto,
bateria=dispositivo.bateria,
señal_gsm=dispositivo.señal_gsm,
satelites=dispositivo.satelites,
intervalo_reporte=dispositivo.intervalo_reporte,
configuracion=dispositivo.configuracion,
firmware_version=dispositivo.firmware_version,
activo=dispositivo.activo,
conectado=dispositivo.conectado,
notas=dispositivo.notas,
esta_online=dispositivo.esta_online,
creado_en=dispositivo.creado_en,
actualizado_en=dispositivo.actualizado_en,
)
@router.put("/{dispositivo_id}", response_model=DispositivoResponse)
async def actualizar_dispositivo(
dispositivo_id: int,
dispositivo_data: DispositivoUpdate,
db: AsyncSession = Depends(get_db),
current_user: Usuario = Depends(get_current_user),
):
"""
Actualiza un dispositivo.
Args:
dispositivo_id: ID del dispositivo.
dispositivo_data: Datos a actualizar.
Returns:
Dispositivo actualizado.
"""
result = await db.execute(
select(Dispositivo).where(Dispositivo.id == dispositivo_id)
)
dispositivo = result.scalar_one_or_none()
if not dispositivo:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Dispositivo con id {dispositivo_id} no encontrado",
)
update_data = dispositivo_data.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(dispositivo, field, value)
await db.commit()
await db.refresh(dispositivo)
return DispositivoResponse(
id=dispositivo.id,
vehiculo_id=dispositivo.vehiculo_id,
tipo=dispositivo.tipo,
identificador=dispositivo.identificador,
nombre=dispositivo.nombre,
marca=dispositivo.marca,
modelo=dispositivo.modelo,
numero_serie=dispositivo.numero_serie,
telefono_sim=dispositivo.telefono_sim,
operador_sim=dispositivo.operador_sim,
iccid=dispositivo.iccid,
imei=dispositivo.imei,
protocolo=dispositivo.protocolo,
ultimo_contacto=dispositivo.ultimo_contacto,
bateria=dispositivo.bateria,
señal_gsm=dispositivo.señal_gsm,
satelites=dispositivo.satelites,
intervalo_reporte=dispositivo.intervalo_reporte,
configuracion=dispositivo.configuracion,
firmware_version=dispositivo.firmware_version,
activo=dispositivo.activo,
conectado=dispositivo.conectado,
notas=dispositivo.notas,
esta_online=dispositivo.esta_online,
creado_en=dispositivo.creado_en,
actualizado_en=dispositivo.actualizado_en,
)
@router.delete("/{dispositivo_id}", status_code=status.HTTP_204_NO_CONTENT)
async def eliminar_dispositivo(
dispositivo_id: int,
db: AsyncSession = Depends(get_db),
current_user: Usuario = Depends(get_current_user),
):
"""
Elimina un dispositivo (soft delete - desactiva).
Args:
dispositivo_id: ID del dispositivo.
"""
result = await db.execute(
select(Dispositivo).where(Dispositivo.id == dispositivo_id)
)
dispositivo = result.scalar_one_or_none()
if not dispositivo:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Dispositivo con id {dispositivo_id} no encontrado",
)
dispositivo.activo = False
await db.commit()
@router.get("/por-identificador/{identificador}", response_model=DispositivoResponse)
async def obtener_dispositivo_por_identificador(
identificador: str,
db: AsyncSession = Depends(get_db),
):
"""
Obtiene un dispositivo por su identificador.
Este endpoint no requiere autenticación para
facilitar la búsqueda desde dispositivos.
Args:
identificador: Identificador del dispositivo.
Returns:
Dispositivo encontrado.
"""
result = await db.execute(
select(Dispositivo).where(Dispositivo.identificador == identificador)
)
dispositivo = result.scalar_one_or_none()
if not dispositivo:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Dispositivo con identificador {identificador} no encontrado",
)
return DispositivoResponse(
id=dispositivo.id,
vehiculo_id=dispositivo.vehiculo_id,
tipo=dispositivo.tipo,
identificador=dispositivo.identificador,
nombre=dispositivo.nombre,
marca=dispositivo.marca,
modelo=dispositivo.modelo,
numero_serie=dispositivo.numero_serie,
telefono_sim=dispositivo.telefono_sim,
operador_sim=dispositivo.operador_sim,
iccid=dispositivo.iccid,
imei=dispositivo.imei,
protocolo=dispositivo.protocolo,
ultimo_contacto=dispositivo.ultimo_contacto,
bateria=dispositivo.bateria,
señal_gsm=dispositivo.señal_gsm,
satelites=dispositivo.satelites,
intervalo_reporte=dispositivo.intervalo_reporte,
configuracion=dispositivo.configuracion,
firmware_version=dispositivo.firmware_version,
activo=dispositivo.activo,
conectado=dispositivo.conectado,
notas=dispositivo.notas,
esta_online=dispositivo.esta_online,
creado_en=dispositivo.creado_en,
actualizado_en=dispositivo.actualizado_en,
)

View File

@@ -0,0 +1,502 @@
"""
Endpoints para gestión de geocercas.
"""
import json
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.core.database import get_db
from app.core.security import get_current_user
from app.models.usuario import Usuario
from app.models.geocerca import Geocerca
from app.models.vehiculo import Vehiculo
from app.schemas.geocerca import (
GeocercaCircularCreate,
GeocercaPoligonoCreate,
GeocercaUpdate,
GeocercaResponse,
GeocercaConVehiculos,
AsignarVehiculosRequest,
VerificarPuntoRequest,
VerificarPuntoResponse,
)
from app.services.geocerca_service import GeocercaService
router = APIRouter(prefix="/geocercas", tags=["Geocercas"])
@router.get("", response_model=List[GeocercaResponse])
async def listar_geocercas(
activa: Optional[bool] = None,
tipo: Optional[str] = None,
categoria: Optional[str] = None,
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100),
db: AsyncSession = Depends(get_db),
current_user: Usuario = Depends(get_current_user),
):
"""
Lista todas las geocercas.
Args:
activa: Filtrar por estado.
tipo: Filtrar por tipo (circular/poligono).
categoria: Filtrar por categoría.
skip: Registros a saltar.
limit: Límite de registros.
Returns:
Lista de geocercas.
"""
query = select(Geocerca).order_by(Geocerca.nombre)
if activa is not None:
query = query.where(Geocerca.activa == activa)
if tipo:
query = query.where(Geocerca.tipo == tipo)
if categoria:
query = query.where(Geocerca.categoria == categoria)
query = query.offset(skip).limit(limit)
result = await db.execute(query)
geocercas = result.scalars().all()
return [
GeocercaResponse(
id=g.id,
nombre=g.nombre,
descripcion=g.descripcion,
tipo=g.tipo,
color=g.color,
opacidad=g.opacidad,
color_borde=g.color_borde,
categoria=g.categoria,
centro_lat=g.centro_lat,
centro_lng=g.centro_lng,
radio_metros=g.radio_metros,
coordenadas_json=g.coordenadas_json,
alerta_entrada=g.alerta_entrada,
alerta_salida=g.alerta_salida,
velocidad_maxima=g.velocidad_maxima,
horario_json=g.horario_json,
activa=g.activa,
aplica_todos_vehiculos=g.aplica_todos_vehiculos,
creado_en=g.creado_en,
actualizado_en=g.actualizado_en,
)
for g in geocercas
]
@router.get("/geojson")
async def obtener_geocercas_geojson(
activa: bool = True,
db: AsyncSession = Depends(get_db),
current_user: Usuario = Depends(get_current_user),
):
"""
Obtiene todas las geocercas en formato GeoJSON.
Args:
activa: Solo geocercas activas.
Returns:
FeatureCollection GeoJSON.
"""
query = select(Geocerca)
if activa:
query = query.where(Geocerca.activa == True)
result = await db.execute(query)
geocercas = result.scalars().all()
features = [g.to_geojson() for g in geocercas]
return {
"type": "FeatureCollection",
"features": features,
}
@router.get("/{geocerca_id}", response_model=GeocercaConVehiculos)
async def obtener_geocerca(
geocerca_id: int,
db: AsyncSession = Depends(get_db),
current_user: Usuario = Depends(get_current_user),
):
"""
Obtiene una geocerca por su ID.
Args:
geocerca_id: ID de la geocerca.
Returns:
Geocerca con vehículos asignados.
"""
result = await db.execute(
select(Geocerca)
.options(selectinload(Geocerca.vehiculos_asignados))
.where(Geocerca.id == geocerca_id)
)
geocerca = result.scalar_one_or_none()
if not geocerca:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Geocerca con id {geocerca_id} no encontrada",
)
from app.schemas.vehiculo import VehiculoResumen
return GeocercaConVehiculos(
id=geocerca.id,
nombre=geocerca.nombre,
descripcion=geocerca.descripcion,
tipo=geocerca.tipo,
color=geocerca.color,
opacidad=geocerca.opacidad,
color_borde=geocerca.color_borde,
categoria=geocerca.categoria,
centro_lat=geocerca.centro_lat,
centro_lng=geocerca.centro_lng,
radio_metros=geocerca.radio_metros,
coordenadas_json=geocerca.coordenadas_json,
alerta_entrada=geocerca.alerta_entrada,
alerta_salida=geocerca.alerta_salida,
velocidad_maxima=geocerca.velocidad_maxima,
horario_json=geocerca.horario_json,
activa=geocerca.activa,
aplica_todos_vehiculos=geocerca.aplica_todos_vehiculos,
creado_en=geocerca.creado_en,
actualizado_en=geocerca.actualizado_en,
vehiculos_asignados=[
VehiculoResumen.model_validate(v)
for v in geocerca.vehiculos_asignados
],
)
@router.post("/circular", response_model=GeocercaResponse, status_code=status.HTTP_201_CREATED)
async def crear_geocerca_circular(
geocerca_data: GeocercaCircularCreate,
db: AsyncSession = Depends(get_db),
current_user: Usuario = Depends(get_current_user),
):
"""
Crea una geocerca circular.
Args:
geocerca_data: Datos de la geocerca.
Returns:
Geocerca creada.
"""
geocerca = Geocerca(
nombre=geocerca_data.nombre,
descripcion=geocerca_data.descripcion,
tipo="circular",
centro_lat=geocerca_data.centro_lat,
centro_lng=geocerca_data.centro_lng,
radio_metros=geocerca_data.radio_metros,
color=geocerca_data.color,
opacidad=geocerca_data.opacidad,
color_borde=geocerca_data.color_borde,
categoria=geocerca_data.categoria,
alerta_entrada=geocerca_data.alerta_entrada,
alerta_salida=geocerca_data.alerta_salida,
velocidad_maxima=geocerca_data.velocidad_maxima,
horario_json=geocerca_data.horario_json,
)
db.add(geocerca)
await db.commit()
await db.refresh(geocerca)
# Asignar vehículos si se especificaron
if geocerca_data.vehiculos_ids:
result = await db.execute(
select(Vehiculo).where(Vehiculo.id.in_(geocerca_data.vehiculos_ids))
)
vehiculos = result.scalars().all()
geocerca.vehiculos_asignados = list(vehiculos)
await db.commit()
return GeocercaResponse(
id=geocerca.id,
nombre=geocerca.nombre,
descripcion=geocerca.descripcion,
tipo=geocerca.tipo,
color=geocerca.color,
opacidad=geocerca.opacidad,
color_borde=geocerca.color_borde,
categoria=geocerca.categoria,
centro_lat=geocerca.centro_lat,
centro_lng=geocerca.centro_lng,
radio_metros=geocerca.radio_metros,
coordenadas_json=geocerca.coordenadas_json,
alerta_entrada=geocerca.alerta_entrada,
alerta_salida=geocerca.alerta_salida,
velocidad_maxima=geocerca.velocidad_maxima,
horario_json=geocerca.horario_json,
activa=geocerca.activa,
aplica_todos_vehiculos=geocerca.aplica_todos_vehiculos,
creado_en=geocerca.creado_en,
actualizado_en=geocerca.actualizado_en,
)
@router.post("/poligono", response_model=GeocercaResponse, status_code=status.HTTP_201_CREATED)
async def crear_geocerca_poligono(
geocerca_data: GeocercaPoligonoCreate,
db: AsyncSession = Depends(get_db),
current_user: Usuario = Depends(get_current_user),
):
"""
Crea una geocerca poligonal.
Args:
geocerca_data: Datos de la geocerca.
Returns:
Geocerca creada.
"""
geocerca = Geocerca(
nombre=geocerca_data.nombre,
descripcion=geocerca_data.descripcion,
tipo="poligono",
coordenadas_json=json.dumps(geocerca_data.coordenadas),
color=geocerca_data.color,
opacidad=geocerca_data.opacidad,
color_borde=geocerca_data.color_borde,
categoria=geocerca_data.categoria,
alerta_entrada=geocerca_data.alerta_entrada,
alerta_salida=geocerca_data.alerta_salida,
velocidad_maxima=geocerca_data.velocidad_maxima,
horario_json=geocerca_data.horario_json,
)
db.add(geocerca)
await db.commit()
await db.refresh(geocerca)
# Asignar vehículos si se especificaron
if geocerca_data.vehiculos_ids:
result = await db.execute(
select(Vehiculo).where(Vehiculo.id.in_(geocerca_data.vehiculos_ids))
)
vehiculos = result.scalars().all()
geocerca.vehiculos_asignados = list(vehiculos)
await db.commit()
return GeocercaResponse(
id=geocerca.id,
nombre=geocerca.nombre,
descripcion=geocerca.descripcion,
tipo=geocerca.tipo,
color=geocerca.color,
opacidad=geocerca.opacidad,
color_borde=geocerca.color_borde,
categoria=geocerca.categoria,
centro_lat=geocerca.centro_lat,
centro_lng=geocerca.centro_lng,
radio_metros=geocerca.radio_metros,
coordenadas_json=geocerca.coordenadas_json,
alerta_entrada=geocerca.alerta_entrada,
alerta_salida=geocerca.alerta_salida,
velocidad_maxima=geocerca.velocidad_maxima,
horario_json=geocerca.horario_json,
activa=geocerca.activa,
aplica_todos_vehiculos=geocerca.aplica_todos_vehiculos,
creado_en=geocerca.creado_en,
actualizado_en=geocerca.actualizado_en,
)
@router.put("/{geocerca_id}", response_model=GeocercaResponse)
async def actualizar_geocerca(
geocerca_id: int,
geocerca_data: GeocercaUpdate,
db: AsyncSession = Depends(get_db),
current_user: Usuario = Depends(get_current_user),
):
"""
Actualiza una geocerca.
Args:
geocerca_id: ID de la geocerca.
geocerca_data: Datos a actualizar.
Returns:
Geocerca actualizada.
"""
result = await db.execute(
select(Geocerca).where(Geocerca.id == geocerca_id)
)
geocerca = result.scalar_one_or_none()
if not geocerca:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Geocerca con id {geocerca_id} no encontrada",
)
update_data = geocerca_data.model_dump(exclude_unset=True)
# Manejar coordenadas si es polígono
if "coordenadas" in update_data and update_data["coordenadas"]:
update_data["coordenadas_json"] = json.dumps(update_data.pop("coordenadas"))
for field, value in update_data.items():
if hasattr(geocerca, field):
setattr(geocerca, field, value)
await db.commit()
await db.refresh(geocerca)
return GeocercaResponse(
id=geocerca.id,
nombre=geocerca.nombre,
descripcion=geocerca.descripcion,
tipo=geocerca.tipo,
color=geocerca.color,
opacidad=geocerca.opacidad,
color_borde=geocerca.color_borde,
categoria=geocerca.categoria,
centro_lat=geocerca.centro_lat,
centro_lng=geocerca.centro_lng,
radio_metros=geocerca.radio_metros,
coordenadas_json=geocerca.coordenadas_json,
alerta_entrada=geocerca.alerta_entrada,
alerta_salida=geocerca.alerta_salida,
velocidad_maxima=geocerca.velocidad_maxima,
horario_json=geocerca.horario_json,
activa=geocerca.activa,
aplica_todos_vehiculos=geocerca.aplica_todos_vehiculos,
creado_en=geocerca.creado_en,
actualizado_en=geocerca.actualizado_en,
)
@router.delete("/{geocerca_id}", status_code=status.HTTP_204_NO_CONTENT)
async def eliminar_geocerca(
geocerca_id: int,
db: AsyncSession = Depends(get_db),
current_user: Usuario = Depends(get_current_user),
):
"""
Elimina una geocerca (soft delete - desactiva).
Args:
geocerca_id: ID de la geocerca.
"""
result = await db.execute(
select(Geocerca).where(Geocerca.id == geocerca_id)
)
geocerca = result.scalar_one_or_none()
if not geocerca:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Geocerca con id {geocerca_id} no encontrada",
)
geocerca.activa = False
await db.commit()
@router.post("/{geocerca_id}/vehiculos")
async def asignar_vehiculos(
geocerca_id: int,
request: AsignarVehiculosRequest,
db: AsyncSession = Depends(get_db),
current_user: Usuario = Depends(get_current_user),
):
"""
Asigna vehículos a una geocerca.
Args:
geocerca_id: ID de la geocerca.
request: Lista de IDs de vehículos.
Returns:
Confirmación.
"""
result = await db.execute(
select(Geocerca)
.options(selectinload(Geocerca.vehiculos_asignados))
.where(Geocerca.id == geocerca_id)
)
geocerca = result.scalar_one_or_none()
if not geocerca:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Geocerca con id {geocerca_id} no encontrada",
)
# Obtener vehículos
result = await db.execute(
select(Vehiculo).where(Vehiculo.id.in_(request.vehiculos_ids))
)
vehiculos = result.scalars().all()
if request.reemplazar:
geocerca.vehiculos_asignados = list(vehiculos)
else:
for v in vehiculos:
if v not in geocerca.vehiculos_asignados:
geocerca.vehiculos_asignados.append(v)
await db.commit()
return {
"message": "Vehículos asignados correctamente",
"total_asignados": len(geocerca.vehiculos_asignados),
}
@router.post("/{geocerca_id}/verificar", response_model=VerificarPuntoResponse)
async def verificar_punto(
geocerca_id: int,
request: VerificarPuntoRequest,
db: AsyncSession = Depends(get_db),
current_user: Usuario = Depends(get_current_user),
):
"""
Verifica si un punto está dentro de una geocerca.
Args:
geocerca_id: ID de la geocerca.
request: Coordenadas del punto.
Returns:
Resultado de la verificación.
"""
result = await db.execute(
select(Geocerca).where(Geocerca.id == geocerca_id)
)
geocerca = result.scalar_one_or_none()
if not geocerca:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Geocerca con id {geocerca_id} no encontrada",
)
geocerca_service = GeocercaService(db)
dentro, distancia = await geocerca_service.verificar_punto_en_geocerca(
request.lat, request.lng, geocerca_id
)
return VerificarPuntoResponse(
dentro=dentro,
geocerca_id=geocerca_id,
geocerca_nombre=geocerca.nombre,
distancia_metros=distancia,
)

View File

@@ -0,0 +1,227 @@
"""
Endpoints para reportes y dashboard.
"""
from datetime import datetime
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.security import get_current_user
from app.models.usuario import Usuario
from app.schemas.reporte import (
DashboardResumen,
DashboardGrafico,
ReporteRequest,
ReporteResponse,
)
from app.services.reporte_service import ReporteService
router = APIRouter(prefix="/reportes", tags=["Reportes"])
@router.get("/dashboard", response_model=DashboardResumen)
async def obtener_dashboard(
db: AsyncSession = Depends(get_db),
current_user: Usuario = Depends(get_current_user),
):
"""
Obtiene datos del dashboard principal.
Returns:
Resumen del dashboard.
"""
reporte_service = ReporteService(db)
return await reporte_service.obtener_dashboard_resumen()
@router.get("/dashboard/graficos", response_model=DashboardGrafico)
async def obtener_graficos_dashboard(
db: AsyncSession = Depends(get_db),
current_user: Usuario = Depends(get_current_user),
):
"""
Obtiene datos para gráficos del dashboard.
Returns:
Datos para gráficos.
"""
reporte_service = ReporteService(db)
return await reporte_service.obtener_dashboard_graficos()
@router.post("/generar", response_model=ReporteResponse)
async def generar_reporte(
request: ReporteRequest,
db: AsyncSession = Depends(get_db),
current_user: Usuario = Depends(get_current_user),
):
"""
Genera un reporte según los parámetros.
Args:
request: Parámetros del reporte.
Returns:
Información del reporte generado.
"""
reporte_service = ReporteService(db)
return await reporte_service.generar_reporte(request)
@router.get("/viajes")
async def reporte_viajes(
desde: datetime,
hasta: datetime,
vehiculo_id: Optional[int] = None,
formato: str = "json",
db: AsyncSession = Depends(get_db),
current_user: Usuario = Depends(get_current_user),
):
"""
Genera reporte de viajes.
Args:
desde: Fecha inicio.
hasta: Fecha fin.
vehiculo_id: Filtrar por vehículo.
formato: Formato de salida.
Returns:
Datos del reporte.
"""
reporte_service = ReporteService(db)
request = ReporteRequest(
tipo="viajes",
formato=formato if formato != "json" else "pdf",
fecha_inicio=desde,
fecha_fin=hasta,
vehiculos_ids=[vehiculo_id] if vehiculo_id else None,
)
datos = await reporte_service._recopilar_datos_reporte(request)
if formato == "json":
return datos
resultado = await reporte_service.generar_reporte(request)
return resultado
@router.get("/alertas")
async def reporte_alertas(
desde: datetime,
hasta: datetime,
vehiculo_id: Optional[int] = None,
formato: str = "json",
db: AsyncSession = Depends(get_db),
current_user: Usuario = Depends(get_current_user),
):
"""
Genera reporte de alertas.
Args:
desde: Fecha inicio.
hasta: Fecha fin.
vehiculo_id: Filtrar por vehículo.
formato: Formato de salida.
Returns:
Datos del reporte.
"""
reporte_service = ReporteService(db)
request = ReporteRequest(
tipo="alertas",
formato=formato if formato != "json" else "pdf",
fecha_inicio=desde,
fecha_fin=hasta,
vehiculos_ids=[vehiculo_id] if vehiculo_id else None,
)
datos = await reporte_service._recopilar_datos_reporte(request)
if formato == "json":
return datos
resultado = await reporte_service.generar_reporte(request)
return resultado
@router.get("/combustible")
async def reporte_combustible(
desde: datetime,
hasta: datetime,
vehiculo_id: Optional[int] = None,
formato: str = "json",
db: AsyncSession = Depends(get_db),
current_user: Usuario = Depends(get_current_user),
):
"""
Genera reporte de combustible.
Args:
desde: Fecha inicio.
hasta: Fecha fin.
vehiculo_id: Filtrar por vehículo.
formato: Formato de salida.
Returns:
Datos del reporte.
"""
reporte_service = ReporteService(db)
request = ReporteRequest(
tipo="combustible",
formato=formato if formato != "json" else "pdf",
fecha_inicio=desde,
fecha_fin=hasta,
vehiculos_ids=[vehiculo_id] if vehiculo_id else None,
)
datos = await reporte_service._recopilar_datos_reporte(request)
if formato == "json":
return datos
resultado = await reporte_service.generar_reporte(request)
return resultado
@router.get("/mantenimiento")
async def reporte_mantenimiento(
desde: datetime,
hasta: datetime,
vehiculo_id: Optional[int] = None,
formato: str = "json",
db: AsyncSession = Depends(get_db),
current_user: Usuario = Depends(get_current_user),
):
"""
Genera reporte de mantenimiento.
Args:
desde: Fecha inicio.
hasta: Fecha fin.
vehiculo_id: Filtrar por vehículo.
formato: Formato de salida.
Returns:
Datos del reporte.
"""
reporte_service = ReporteService(db)
request = ReporteRequest(
tipo="mantenimiento",
formato=formato if formato != "json" else "pdf",
fecha_inicio=desde,
fecha_fin=hasta,
vehiculos_ids=[vehiculo_id] if vehiculo_id else None,
)
datos = await reporte_service._recopilar_datos_reporte(request)
if formato == "json":
return datos
resultado = await reporte_service.generar_reporte(request)
return resultado

View File

@@ -0,0 +1,48 @@
"""
Router principal de la API v1.
Incluye todos los sub-routers de cada módulo.
"""
from fastapi import APIRouter
from app.api.v1.auth import router as auth_router
from app.api.v1.vehiculos import router as vehiculos_router
from app.api.v1.conductores import router as conductores_router
from app.api.v1.ubicaciones import router as ubicaciones_router
from app.api.v1.viajes import router as viajes_router
from app.api.v1.alertas import router as alertas_router
from app.api.v1.geocercas import router as geocercas_router
from app.api.v1.dispositivos import router as dispositivos_router
from app.api.v1.reportes import router as reportes_router
# Router principal
api_router = APIRouter()
# Incluir todos los sub-routers
api_router.include_router(auth_router)
api_router.include_router(vehiculos_router)
api_router.include_router(conductores_router)
api_router.include_router(ubicaciones_router)
api_router.include_router(viajes_router)
api_router.include_router(alertas_router)
api_router.include_router(geocercas_router)
api_router.include_router(dispositivos_router)
api_router.include_router(reportes_router)
# TODO: Agregar cuando se completen
# from app.api.v1.pois import router as pois_router
# from app.api.v1.combustible import router as combustible_router
# from app.api.v1.mantenimiento import router as mantenimiento_router
# from app.api.v1.video import router as video_router
# from app.api.v1.mensajes import router as mensajes_router
# from app.api.v1.configuracion import router as configuracion_router
# from app.api.v1.meshtastic import router as meshtastic_router
# api_router.include_router(pois_router)
# api_router.include_router(combustible_router)
# api_router.include_router(mantenimiento_router)
# api_router.include_router(video_router)
# api_router.include_router(mensajes_router)
# api_router.include_router(configuracion_router)
# api_router.include_router(meshtastic_router)

View File

@@ -0,0 +1,237 @@
"""
Endpoints para recepción y consulta de ubicaciones GPS.
"""
from datetime import datetime
from typing import Optional
from fastapi import APIRouter, Depends, Query, Request
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.security import get_current_user
from app.models.usuario import Usuario
from app.schemas.ubicacion import (
UbicacionCreate,
UbicacionBulkCreate,
UbicacionResponse,
HistorialUbicacionesResponse,
OsmAndLocationCreate,
)
from app.services.ubicacion_service import UbicacionService
from app.services.alerta_service import AlertaService
from app.services.viaje_service import ViajeService
router = APIRouter(prefix="/ubicaciones", tags=["Ubicaciones"])
@router.post("", response_model=Optional[UbicacionResponse])
async def recibir_ubicacion(
ubicacion_data: UbicacionCreate,
db: AsyncSession = Depends(get_db),
):
"""
Recibe una ubicación GPS desde la app móvil o dispositivo.
Este endpoint no requiere autenticación para facilitar
la integración con dispositivos GPS simples.
Args:
ubicacion_data: Datos de la ubicación.
Returns:
Ubicación procesada o None si se descartó.
"""
ubicacion_service = UbicacionService(db)
resultado = await ubicacion_service.procesar_ubicacion(ubicacion_data)
if resultado:
# Procesar alertas y viajes en background
alerta_service = AlertaService(db)
viaje_service = ViajeService(db)
# Verificar velocidad
if ubicacion_data.velocidad:
await alerta_service.verificar_velocidad(
resultado.vehiculo_id,
ubicacion_data.velocidad,
ubicacion_data.lat,
ubicacion_data.lng,
)
# Verificar batería
if ubicacion_data.bateria_dispositivo:
await alerta_service.verificar_bateria_baja(
resultado.vehiculo_id,
ubicacion_data.bateria_dispositivo,
ubicacion_data.lat,
ubicacion_data.lng,
)
# Procesar viaje
await viaje_service.procesar_ubicacion_viaje(
resultado.vehiculo_id,
ubicacion_data.lat,
ubicacion_data.lng,
ubicacion_data.velocidad or 0,
resultado.tiempo,
)
return resultado
@router.post("/bulk", response_model=dict)
async def recibir_ubicaciones_bulk(
data: UbicacionBulkCreate,
db: AsyncSession = Depends(get_db),
):
"""
Recibe múltiples ubicaciones en una sola petición.
Útil para sincronización de datos acumulados cuando
el dispositivo estuvo offline.
Args:
data: Lista de ubicaciones.
Returns:
Conteo de ubicaciones procesadas.
"""
ubicacion_service = UbicacionService(db)
procesadas = 0
errores = 0
for ubicacion_data in data.ubicaciones:
try:
resultado = await ubicacion_service.procesar_ubicacion(ubicacion_data)
if resultado:
procesadas += 1
except Exception:
errores += 1
return {
"total": len(data.ubicaciones),
"procesadas": procesadas,
"errores": errores,
}
@router.get("/osmand")
async def recibir_ubicacion_osmand(
request: Request,
id: str,
lat: float,
lon: float,
timestamp: Optional[int] = None,
speed: Optional[float] = None,
bearing: Optional[float] = None,
altitude: Optional[float] = None,
accuracy: Optional[float] = None,
batt: Optional[float] = None,
db: AsyncSession = Depends(get_db),
):
"""
Endpoint compatible con OsmAnd Live Tracking.
OsmAnd envía ubicaciones mediante GET con parámetros en URL.
Args:
id: Identificador del dispositivo.
lat: Latitud.
lon: Longitud.
timestamp: Unix timestamp (opcional).
speed: Velocidad en km/h (opcional).
bearing: Rumbo en grados (opcional).
altitude: Altitud en metros (opcional).
accuracy: Precisión en metros (opcional).
batt: Porcentaje de batería (opcional).
Returns:
Confirmación de recepción.
"""
ubicacion_data = UbicacionCreate(
dispositivo_id=id,
lat=lat,
lng=lon,
velocidad=speed,
rumbo=bearing,
altitud=altitude,
precision=accuracy,
bateria_dispositivo=batt,
tiempo=datetime.fromtimestamp(timestamp) if timestamp else None,
fuente="osmand",
)
ubicacion_service = UbicacionService(db)
resultado = await ubicacion_service.procesar_ubicacion(ubicacion_data)
if resultado:
return {"status": "ok"}
return {"status": "device_not_found"}
@router.get("/historial/{vehiculo_id}", response_model=HistorialUbicacionesResponse)
async def obtener_historial(
vehiculo_id: int,
desde: datetime,
hasta: datetime,
simplificar: bool = True,
intervalo_segundos: Optional[int] = None,
db: AsyncSession = Depends(get_db),
current_user: Usuario = Depends(get_current_user),
):
"""
Obtiene el historial de ubicaciones de un vehículo.
Args:
vehiculo_id: ID del vehículo.
desde: Fecha/hora de inicio.
hasta: Fecha/hora de fin.
simplificar: Simplificar ruta (Douglas-Peucker).
intervalo_segundos: Muestreo por intervalo.
Returns:
Historial con estadísticas.
"""
ubicacion_service = UbicacionService(db)
return await ubicacion_service.obtener_historial(
vehiculo_id,
desde,
hasta,
simplificar,
intervalo_segundos,
)
@router.get("/ultima/{vehiculo_id}", response_model=Optional[UbicacionResponse])
async def obtener_ultima_ubicacion(
vehiculo_id: int,
db: AsyncSession = Depends(get_db),
current_user: Usuario = Depends(get_current_user),
):
"""
Obtiene la última ubicación de un vehículo.
Args:
vehiculo_id: ID del vehículo.
Returns:
Última ubicación conocida.
"""
ubicacion_service = UbicacionService(db)
return await ubicacion_service.obtener_ultima_ubicacion(vehiculo_id)
@router.get("/flota")
async def obtener_ubicaciones_flota(
db: AsyncSession = Depends(get_db),
current_user: Usuario = Depends(get_current_user),
):
"""
Obtiene las ubicaciones actuales de toda la flota.
Returns:
Lista de ubicaciones de todos los vehículos activos.
"""
ubicacion_service = UbicacionService(db)
return await ubicacion_service.obtener_ubicaciones_flota()

View File

@@ -0,0 +1,481 @@
"""
Endpoints para gestión de vehículos.
"""
from datetime import datetime, timezone
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.core.database import get_db
from app.core.security import get_current_user
from app.models.usuario import Usuario
from app.models.vehiculo import Vehiculo
from app.models.viaje import Viaje
from app.models.alerta import Alerta
from app.schemas.vehiculo import (
VehiculoCreate,
VehiculoUpdate,
VehiculoResponse,
VehiculoResumen,
VehiculoConRelaciones,
VehiculoUbicacionActual,
VehiculoEstadisticas,
)
from app.schemas.base import PaginatedResponse
from app.services.ubicacion_service import UbicacionService
router = APIRouter(prefix="/vehiculos", tags=["Vehiculos"])
@router.get("", response_model=List[VehiculoResumen])
async def listar_vehiculos(
activo: Optional[bool] = None,
en_servicio: Optional[bool] = None,
grupo_id: Optional[int] = None,
buscar: Optional[str] = None,
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100),
db: AsyncSession = Depends(get_db),
current_user: Usuario = Depends(get_current_user),
):
"""
Lista todos los vehículos con filtros opcionales.
Args:
activo: Filtrar por estado activo.
en_servicio: Filtrar por en servicio.
grupo_id: Filtrar por grupo.
buscar: Búsqueda por nombre o placa.
skip: Registros a saltar.
limit: Límite de registros.
Returns:
Lista de vehículos.
"""
query = select(Vehiculo)
if activo is not None:
query = query.where(Vehiculo.activo == activo)
if en_servicio is not None:
query = query.where(Vehiculo.en_servicio == en_servicio)
if grupo_id:
query = query.where(Vehiculo.grupo_id == grupo_id)
if buscar:
query = query.where(
(Vehiculo.nombre.ilike(f"%{buscar}%")) |
(Vehiculo.placa.ilike(f"%{buscar}%"))
)
query = query.offset(skip).limit(limit).order_by(Vehiculo.nombre)
result = await db.execute(query)
vehiculos = result.scalars().all()
return [VehiculoResumen.model_validate(v) for v in vehiculos]
@router.get("/ubicaciones", response_model=List[VehiculoUbicacionActual])
async def obtener_ubicaciones_flota(
db: AsyncSession = Depends(get_db),
current_user: Usuario = Depends(get_current_user),
):
"""
Obtiene las ubicaciones actuales de todos los vehículos.
Returns:
Lista de ubicaciones actuales.
"""
ubicacion_service = UbicacionService(db)
return await ubicacion_service.obtener_ubicaciones_flota()
@router.get("/{vehiculo_id}", response_model=VehiculoConRelaciones)
async def obtener_vehiculo(
vehiculo_id: int,
db: AsyncSession = Depends(get_db),
current_user: Usuario = Depends(get_current_user),
):
"""
Obtiene un vehículo por su ID con todas sus relaciones.
Args:
vehiculo_id: ID del vehículo.
Returns:
Vehículo con relaciones.
"""
result = await db.execute(
select(Vehiculo)
.options(
selectinload(Vehiculo.conductor),
selectinload(Vehiculo.grupo),
selectinload(Vehiculo.dispositivos),
)
.where(Vehiculo.id == vehiculo_id)
)
vehiculo = result.scalar_one_or_none()
if not vehiculo:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Vehículo con id {vehiculo_id} no encontrado",
)
return VehiculoConRelaciones.model_validate(vehiculo)
@router.post("", response_model=VehiculoResponse, status_code=status.HTTP_201_CREATED)
async def crear_vehiculo(
vehiculo_data: VehiculoCreate,
db: AsyncSession = Depends(get_db),
current_user: Usuario = Depends(get_current_user),
):
"""
Crea un nuevo vehículo.
Args:
vehiculo_data: Datos del vehículo.
Returns:
Vehículo creado.
"""
# Verificar que la placa no exista
result = await db.execute(
select(Vehiculo).where(Vehiculo.placa == vehiculo_data.placa)
)
if result.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Ya existe un vehículo con la placa {vehiculo_data.placa}",
)
# Verificar VIN si se proporciona
if vehiculo_data.vin:
result = await db.execute(
select(Vehiculo).where(Vehiculo.vin == vehiculo_data.vin)
)
if result.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Ya existe un vehículo con el VIN {vehiculo_data.vin}",
)
vehiculo = Vehiculo(**vehiculo_data.model_dump())
vehiculo.odometro_actual = vehiculo_data.odometro_inicial
db.add(vehiculo)
await db.commit()
await db.refresh(vehiculo)
return VehiculoResponse.model_validate(vehiculo)
@router.put("/{vehiculo_id}", response_model=VehiculoResponse)
async def actualizar_vehiculo(
vehiculo_id: int,
vehiculo_data: VehiculoUpdate,
db: AsyncSession = Depends(get_db),
current_user: Usuario = Depends(get_current_user),
):
"""
Actualiza un vehículo existente.
Args:
vehiculo_id: ID del vehículo.
vehiculo_data: Datos a actualizar.
Returns:
Vehículo actualizado.
"""
result = await db.execute(
select(Vehiculo).where(Vehiculo.id == vehiculo_id)
)
vehiculo = result.scalar_one_or_none()
if not vehiculo:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Vehículo con id {vehiculo_id} no encontrado",
)
# Verificar placa única si se cambia
if vehiculo_data.placa and vehiculo_data.placa != vehiculo.placa:
result = await db.execute(
select(Vehiculo).where(Vehiculo.placa == vehiculo_data.placa)
)
if result.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Ya existe un vehículo con la placa {vehiculo_data.placa}",
)
update_data = vehiculo_data.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(vehiculo, field, value)
await db.commit()
await db.refresh(vehiculo)
return VehiculoResponse.model_validate(vehiculo)
@router.delete("/{vehiculo_id}", status_code=status.HTTP_204_NO_CONTENT)
async def eliminar_vehiculo(
vehiculo_id: int,
db: AsyncSession = Depends(get_db),
current_user: Usuario = Depends(get_current_user),
):
"""
Elimina un vehículo (soft delete - desactiva).
Args:
vehiculo_id: ID del vehículo.
"""
result = await db.execute(
select(Vehiculo).where(Vehiculo.id == vehiculo_id)
)
vehiculo = result.scalar_one_or_none()
if not vehiculo:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Vehículo con id {vehiculo_id} no encontrado",
)
# Soft delete
vehiculo.activo = False
vehiculo.en_servicio = False
await db.commit()
@router.get("/{vehiculo_id}/ubicacion")
async def obtener_ubicacion_actual(
vehiculo_id: int,
db: AsyncSession = Depends(get_db),
current_user: Usuario = Depends(get_current_user),
):
"""
Obtiene la ubicación actual de un vehículo.
Args:
vehiculo_id: ID del vehículo.
Returns:
Última ubicación conocida.
"""
ubicacion_service = UbicacionService(db)
ubicacion = await ubicacion_service.obtener_ultima_ubicacion(vehiculo_id)
if not ubicacion:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="No hay ubicación registrada para este vehículo",
)
return ubicacion
@router.get("/{vehiculo_id}/historial")
async def obtener_historial_ubicaciones(
vehiculo_id: int,
desde: datetime,
hasta: datetime,
simplificar: bool = True,
db: AsyncSession = Depends(get_db),
current_user: Usuario = Depends(get_current_user),
):
"""
Obtiene el historial de ubicaciones de un vehículo.
Args:
vehiculo_id: ID del vehículo.
desde: Fecha/hora de inicio.
hasta: Fecha/hora de fin.
simplificar: Simplificar la ruta.
Returns:
Historial de ubicaciones con estadísticas.
"""
ubicacion_service = UbicacionService(db)
return await ubicacion_service.obtener_historial(
vehiculo_id, desde, hasta, simplificar
)
@router.get("/{vehiculo_id}/viajes", response_model=List[dict])
async def obtener_viajes_vehiculo(
vehiculo_id: int,
desde: Optional[datetime] = None,
hasta: Optional[datetime] = None,
limite: int = Query(50, ge=1, le=100),
db: AsyncSession = Depends(get_db),
current_user: Usuario = Depends(get_current_user),
):
"""
Obtiene los viajes de un vehículo.
Args:
vehiculo_id: ID del vehículo.
desde: Fecha inicio (opcional).
hasta: Fecha fin (opcional).
limite: Límite de resultados.
Returns:
Lista de viajes.
"""
from app.services.viaje_service import ViajeService
viaje_service = ViajeService(db)
viajes = await viaje_service.obtener_viajes_vehiculo(
vehiculo_id, desde, hasta, limite
)
return [
{
"id": v.id,
"inicio_tiempo": v.inicio_tiempo,
"fin_tiempo": v.fin_tiempo,
"inicio_direccion": v.inicio_direccion,
"fin_direccion": v.fin_direccion,
"distancia_km": v.distancia_km,
"duracion_formateada": v.duracion_formateada,
"estado": v.estado,
}
for v in viajes
]
@router.get("/{vehiculo_id}/alertas", response_model=List[dict])
async def obtener_alertas_vehiculo(
vehiculo_id: int,
atendidas: Optional[bool] = None,
limite: int = Query(50, ge=1, le=100),
db: AsyncSession = Depends(get_db),
current_user: Usuario = Depends(get_current_user),
):
"""
Obtiene las alertas de un vehículo.
Args:
vehiculo_id: ID del vehículo.
atendidas: Filtrar por estado de atención.
limite: Límite de resultados.
Returns:
Lista de alertas.
"""
query = (
select(Alerta)
.where(Alerta.vehiculo_id == vehiculo_id)
.order_by(Alerta.creado_en.desc())
.limit(limite)
)
if atendidas is not None:
query = query.where(Alerta.atendida == atendidas)
result = await db.execute(query)
alertas = result.scalars().all()
return [
{
"id": a.id,
"tipo_alerta_id": a.tipo_alerta_id,
"severidad": a.severidad,
"mensaje": a.mensaje,
"creado_en": a.creado_en,
"atendida": a.atendida,
}
for a in alertas
]
@router.get("/{vehiculo_id}/estadisticas", response_model=VehiculoEstadisticas)
async def obtener_estadisticas_vehiculo(
vehiculo_id: int,
db: AsyncSession = Depends(get_db),
current_user: Usuario = Depends(get_current_user),
):
"""
Obtiene estadísticas de un vehículo.
Args:
vehiculo_id: ID del vehículo.
Returns:
Estadísticas del vehículo.
"""
result = await db.execute(
select(Vehiculo).where(Vehiculo.id == vehiculo_id)
)
vehiculo = result.scalar_one_or_none()
if not vehiculo:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Vehículo con id {vehiculo_id} no encontrado",
)
ahora = datetime.now(timezone.utc)
inicio_hoy = ahora.replace(hour=0, minute=0, second=0, microsecond=0)
inicio_semana = ahora - timedelta(days=7)
inicio_mes = ahora - timedelta(days=30)
# Distancia hoy
result = await db.execute(
select(func.coalesce(func.sum(Viaje.distancia_km), 0))
.where(Viaje.vehiculo_id == vehiculo_id)
.where(Viaje.inicio_tiempo >= inicio_hoy)
)
distancia_hoy = result.scalar() or 0
# Distancia semana
result = await db.execute(
select(func.coalesce(func.sum(Viaje.distancia_km), 0))
.where(Viaje.vehiculo_id == vehiculo_id)
.where(Viaje.inicio_tiempo >= inicio_semana)
)
distancia_semana = result.scalar() or 0
# Distancia mes
result = await db.execute(
select(func.coalesce(func.sum(Viaje.distancia_km), 0))
.where(Viaje.vehiculo_id == vehiculo_id)
.where(Viaje.inicio_tiempo >= inicio_mes)
)
distancia_mes = result.scalar() or 0
# Alertas activas
result = await db.execute(
select(func.count(Alerta.id))
.where(Alerta.vehiculo_id == vehiculo_id)
.where(Alerta.atendida == False)
)
alertas_activas = result.scalar() or 0
# Alertas mes
result = await db.execute(
select(func.count(Alerta.id))
.where(Alerta.vehiculo_id == vehiculo_id)
.where(Alerta.creado_en >= inicio_mes)
)
alertas_mes = result.scalar() or 0
return VehiculoEstadisticas(
vehiculo_id=vehiculo.id,
nombre=vehiculo.nombre,
placa=vehiculo.placa,
distancia_hoy_km=float(distancia_hoy),
distancia_semana_km=float(distancia_semana),
distancia_mes_km=float(distancia_mes),
distancia_total_km=vehiculo.distancia_recorrida,
tiempo_movimiento_hoy_min=0, # TODO: Calcular
tiempo_parado_hoy_min=0, # TODO: Calcular
alertas_activas=alertas_activas,
alertas_mes=alertas_mes,
mantenimientos_vencidos=0, # TODO: Calcular
)

View File

@@ -0,0 +1,339 @@
"""
Endpoints para gestión de viajes.
"""
from datetime import datetime
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.core.database import get_db
from app.core.security import get_current_user
from app.models.usuario import Usuario
from app.models.viaje import Viaje
from app.schemas.viaje import (
ViajeResponse,
ViajeResumen,
ViajeConParadas,
ViajeReplayData,
ParadaResponse,
)
from app.schemas.ubicacion import UbicacionResponse
from app.services.viaje_service import ViajeService
router = APIRouter(prefix="/viajes", tags=["Viajes"])
@router.get("", response_model=List[ViajeResumen])
async def listar_viajes(
vehiculo_id: Optional[int] = None,
conductor_id: Optional[int] = None,
estado: Optional[str] = None,
desde: Optional[datetime] = None,
hasta: Optional[datetime] = None,
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100),
db: AsyncSession = Depends(get_db),
current_user: Usuario = Depends(get_current_user),
):
"""
Lista viajes con filtros opcionales.
Args:
vehiculo_id: Filtrar por vehículo.
conductor_id: Filtrar por conductor.
estado: Filtrar por estado.
desde: Fecha inicio.
hasta: Fecha fin.
skip: Registros a saltar.
limit: Límite de registros.
Returns:
Lista de viajes.
"""
query = (
select(Viaje)
.options(
selectinload(Viaje.vehiculo),
selectinload(Viaje.conductor),
)
.order_by(Viaje.inicio_tiempo.desc())
)
if vehiculo_id:
query = query.where(Viaje.vehiculo_id == vehiculo_id)
if conductor_id:
query = query.where(Viaje.conductor_id == conductor_id)
if estado:
query = query.where(Viaje.estado == estado)
if desde:
query = query.where(Viaje.inicio_tiempo >= desde)
if hasta:
query = query.where(Viaje.inicio_tiempo <= hasta)
query = query.offset(skip).limit(limit)
result = await db.execute(query)
viajes = result.scalars().all()
return [
ViajeResumen(
id=v.id,
vehiculo_id=v.vehiculo_id,
vehiculo_nombre=v.vehiculo.nombre if v.vehiculo else None,
vehiculo_placa=v.vehiculo.placa if v.vehiculo else None,
conductor_nombre=v.conductor.nombre_completo if v.conductor else None,
inicio_tiempo=v.inicio_tiempo,
fin_tiempo=v.fin_tiempo,
inicio_direccion=v.inicio_direccion,
fin_direccion=v.fin_direccion,
distancia_km=v.distancia_km,
duracion_formateada=v.duracion_formateada,
estado=v.estado,
)
for v in viajes
]
@router.get("/{viaje_id}", response_model=ViajeConParadas)
async def obtener_viaje(
viaje_id: int,
db: AsyncSession = Depends(get_db),
current_user: Usuario = Depends(get_current_user),
):
"""
Obtiene un viaje por su ID con paradas.
Args:
viaje_id: ID del viaje.
Returns:
Viaje con paradas.
"""
result = await db.execute(
select(Viaje)
.options(
selectinload(Viaje.vehiculo),
selectinload(Viaje.conductor),
selectinload(Viaje.paradas),
)
.where(Viaje.id == viaje_id)
)
viaje = result.scalar_one_or_none()
if not viaje:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Viaje con id {viaje_id} no encontrado",
)
return ViajeConParadas(
id=viaje.id,
vehiculo_id=viaje.vehiculo_id,
conductor_id=viaje.conductor_id,
proposito=viaje.proposito,
notas=viaje.notas,
inicio_tiempo=viaje.inicio_tiempo,
fin_tiempo=viaje.fin_tiempo,
inicio_lat=viaje.inicio_lat,
inicio_lng=viaje.inicio_lng,
inicio_direccion=viaje.inicio_direccion,
fin_lat=viaje.fin_lat,
fin_lng=viaje.fin_lng,
fin_direccion=viaje.fin_direccion,
distancia_km=viaje.distancia_km,
duracion_segundos=viaje.duracion_segundos,
tiempo_movimiento_segundos=viaje.tiempo_movimiento_segundos,
tiempo_parado_segundos=viaje.tiempo_parado_segundos,
velocidad_promedio=viaje.velocidad_promedio,
velocidad_maxima=viaje.velocidad_maxima,
combustible_usado=viaje.combustible_usado,
rendimiento=viaje.rendimiento,
odometro_inicio=viaje.odometro_inicio,
odometro_fin=viaje.odometro_fin,
estado=viaje.estado,
puntos_gps=viaje.puntos_gps,
duracion_formateada=viaje.duracion_formateada,
en_curso=viaje.en_curso,
creado_en=viaje.creado_en,
actualizado_en=viaje.actualizado_en,
paradas=[
ParadaResponse(
id=p.id,
viaje_id=p.viaje_id,
vehiculo_id=p.vehiculo_id,
inicio_tiempo=p.inicio_tiempo,
fin_tiempo=p.fin_tiempo,
duracion_segundos=p.duracion_segundos,
lat=p.lat,
lng=p.lng,
direccion=p.direccion,
tipo=p.tipo,
motor_apagado=p.motor_apagado,
poi_id=p.poi_id,
geocerca_id=p.geocerca_id,
en_curso=p.en_curso,
notas=p.notas,
duracion_formateada=p.duracion_formateada,
)
for p in viaje.paradas
],
)
@router.get("/{viaje_id}/replay")
async def obtener_replay_viaje(
viaje_id: int,
db: AsyncSession = Depends(get_db),
current_user: Usuario = Depends(get_current_user),
):
"""
Obtiene datos para replay de un viaje.
Args:
viaje_id: ID del viaje.
Returns:
Viaje con ubicaciones y paradas.
"""
viaje_service = ViajeService(db)
datos = await viaje_service.obtener_replay_viaje(viaje_id)
if not datos:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Viaje con id {viaje_id} no encontrado",
)
viaje = datos["viaje"]
return {
"viaje": {
"id": viaje.id,
"vehiculo_id": viaje.vehiculo_id,
"inicio_tiempo": viaje.inicio_tiempo,
"fin_tiempo": viaje.fin_tiempo,
"inicio_lat": viaje.inicio_lat,
"inicio_lng": viaje.inicio_lng,
"fin_lat": viaje.fin_lat,
"fin_lng": viaje.fin_lng,
"distancia_km": viaje.distancia_km,
"duracion_formateada": viaje.duracion_formateada,
"estado": viaje.estado,
},
"ubicaciones": [
{
"tiempo": u.tiempo,
"lat": u.lat,
"lng": u.lng,
"velocidad": u.velocidad,
"rumbo": u.rumbo,
"motor_encendido": u.motor_encendido,
}
for u in datos["ubicaciones"]
],
"paradas": [
{
"id": p.id,
"inicio_tiempo": p.inicio_tiempo,
"fin_tiempo": p.fin_tiempo,
"duracion_formateada": p.duracion_formateada,
"lat": p.lat,
"lng": p.lng,
"direccion": p.direccion,
"tipo": p.tipo,
}
for p in datos["paradas"]
],
}
@router.get("/{viaje_id}/geojson")
async def obtener_viaje_geojson(
viaje_id: int,
db: AsyncSession = Depends(get_db),
current_user: Usuario = Depends(get_current_user),
):
"""
Obtiene la ruta de un viaje en formato GeoJSON.
Args:
viaje_id: ID del viaje.
Returns:
GeoJSON LineString de la ruta.
"""
viaje_service = ViajeService(db)
datos = await viaje_service.obtener_replay_viaje(viaje_id)
if not datos:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Viaje con id {viaje_id} no encontrado",
)
viaje = datos["viaje"]
ubicaciones = datos["ubicaciones"]
# Crear LineString
coordinates = [[u.lng, u.lat] for u in ubicaciones]
return {
"type": "Feature",
"geometry": {
"type": "LineString",
"coordinates": coordinates,
},
"properties": {
"viaje_id": viaje.id,
"vehiculo_id": viaje.vehiculo_id,
"inicio_tiempo": viaje.inicio_tiempo.isoformat(),
"fin_tiempo": viaje.fin_tiempo.isoformat() if viaje.fin_tiempo else None,
"distancia_km": viaje.distancia_km,
"estado": viaje.estado,
},
}
@router.get("/activos/lista", response_model=List[ViajeResumen])
async def listar_viajes_activos(
db: AsyncSession = Depends(get_db),
current_user: Usuario = Depends(get_current_user),
):
"""
Lista viajes actualmente en curso.
Returns:
Lista de viajes en curso.
"""
result = await db.execute(
select(Viaje)
.options(
selectinload(Viaje.vehiculo),
selectinload(Viaje.conductor),
)
.where(Viaje.estado == "en_curso")
.order_by(Viaje.inicio_tiempo.desc())
)
viajes = result.scalars().all()
return [
ViajeResumen(
id=v.id,
vehiculo_id=v.vehiculo_id,
vehiculo_nombre=v.vehiculo.nombre if v.vehiculo else None,
vehiculo_placa=v.vehiculo.placa if v.vehiculo else None,
conductor_nombre=v.conductor.nombre_completo if v.conductor else None,
inicio_tiempo=v.inicio_tiempo,
fin_tiempo=v.fin_tiempo,
inicio_direccion=v.inicio_direccion,
fin_direccion=v.fin_direccion,
distancia_km=v.distancia_km,
duracion_formateada=v.duracion_formateada,
estado=v.estado,
)
for v in viajes
]

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

116
backend/requirements.txt Normal file
View File

@@ -0,0 +1,116 @@
# Adan Fleet Monitor - Backend Dependencies
# Python 3.11+
# =============================================================================
# Web Framework
# =============================================================================
fastapi>=0.109.0,<1.0.0
uvicorn[standard]>=0.27.0,<1.0.0
python-multipart>=0.0.6,<1.0.0
# =============================================================================
# Database
# =============================================================================
sqlalchemy[asyncio]>=2.0.25,<3.0.0
asyncpg>=0.29.0,<1.0.0
alembic>=1.13.0,<2.0.0
# =============================================================================
# Data Validation
# =============================================================================
pydantic>=2.5.0,<3.0.0
pydantic[email]>=2.5.0,<3.0.0
pydantic-settings>=2.1.0,<3.0.0
# =============================================================================
# Authentication & Security
# =============================================================================
python-jose[cryptography]>=3.3.0,<4.0.0
passlib[bcrypt]>=1.7.4,<2.0.0
bcrypt>=4.1.0,<5.0.0
# =============================================================================
# Caching & Message Queue
# =============================================================================
redis>=5.0.0,<6.0.0
aioredis>=2.0.1,<3.0.0
# =============================================================================
# HTTP Client
# =============================================================================
httpx>=0.26.0,<1.0.0
aiohttp>=3.9.0,<4.0.0
# =============================================================================
# WebSockets
# =============================================================================
websockets>=12.0,<13.0
# =============================================================================
# Geospatial
# =============================================================================
shapely>=2.0.2,<3.0.0
geopy>=2.4.1,<3.0.0
# =============================================================================
# Reports & Documents
# =============================================================================
jinja2>=3.1.2,<4.0.0
weasyprint>=60.0,<70.0
openpyxl>=3.1.2,<4.0.0
# =============================================================================
# MQTT (IoT Communication)
# =============================================================================
aiomqtt>=2.0.0,<3.0.0
# =============================================================================
# Email
# =============================================================================
aiosmtplib>=3.0.0,<4.0.0
# =============================================================================
# Utilities
# =============================================================================
python-dateutil>=2.8.2,<3.0.0
pytz>=2023.3
orjson>=3.9.10,<4.0.0
# =============================================================================
# Development & Testing
# =============================================================================
pytest>=7.4.0,<8.0.0
pytest-asyncio>=0.23.0,<1.0.0
pytest-cov>=4.1.0,<5.0.0
httpx>=0.26.0 # For TestClient
factory-boy>=3.3.0,<4.0.0
faker>=22.0.0,<30.0.0
# =============================================================================
# Code Quality
# =============================================================================
black>=23.12.0,<25.0.0
isort>=5.13.0,<6.0.0
flake8>=7.0.0,<8.0.0
mypy>=1.8.0,<2.0.0
# =============================================================================
# Logging & Monitoring
# =============================================================================
structlog>=24.1.0,<25.0.0
sentry-sdk[fastapi]>=1.39.0,<2.0.0
# =============================================================================
# Optional: Push Notifications
# =============================================================================
firebase-admin>=6.3.0,<7.0.0
# =============================================================================
# Optional: Celery for background tasks
# =============================================================================
celery[redis]>=5.3.0,<6.0.0
# =============================================================================
# Optional: APScheduler for scheduled tasks
# =============================================================================
apscheduler>=3.10.0,<4.0.0

264
deploy/README.md Normal file
View File

@@ -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.

View File

@@ -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
# ============================================

View File

@@ -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
# ============================================

581
deploy/proxmox/vm-setup.sh Normal file
View File

@@ -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" <<EOF
# ============================================
# Credenciales VM Sistema de Flotillas
# ============================================
# Generadas: $(date)
VM_ID: $VMID
VM_NAME: $VM_NAME
VM_IP: $VM_IP
# Acceso SSH
Usuario: $CI_USER
Password: $CI_PASSWORD
# Despues de iniciar la VM:
# 1. Conectarse: ssh ${CI_USER}@IP_DE_LA_VM
# 2. Ejecutar script de instalacion
# Comandos utiles:
# - Iniciar VM: qm start $VMID
# - Consola: qm terminal $VMID
# - Detener: qm shutdown $VMID
# - Estado: qm status $VMID
EOF
chmod 600 "$CREDS_FILE"
log_success "Credenciales guardadas en: $CREDS_FILE"
}
# ---------------------------------------------
# Mostrar resumen
# ---------------------------------------------
show_summary() {
echo ""
echo -e "${GREEN}============================================${NC}"
echo -e "${GREEN} VM CREADA EXITOSAMENTE${NC}"
echo -e "${GREEN}============================================${NC}"
echo ""
echo -e "${BLUE}Configuracion:${NC}"
echo " ID: $VMID"
echo " Nombre: $VM_NAME"
echo " Memoria: ${VM_MEMORY}MB"
echo " Cores: $VM_CORES"
echo " Disco: ${VM_DISK_SIZE}GB"
echo " Red: $VM_BRIDGE"
echo ""
if [[ "$USE_CLOUD_INIT" == "true" ]]; then
echo -e "${BLUE}Cloud-Init:${NC}"
echo " Usuario: $CI_USER"
echo " Password: $CI_PASSWORD"
echo " IP: $VM_IP"
echo ""
fi
echo -e "${BLUE}Siguientes pasos:${NC}"
echo " 1. Iniciar la VM:"
echo " qm start $VMID"
echo ""
if [[ "$USE_CLOUD_INIT" == "true" ]]; then
echo " 2. Esperar a que inicie y conectarse:"
echo " ssh ${CI_USER}@<IP_DE_LA_VM>"
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 <REPO_URL> /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 "$@"

486
deploy/scripts/backup.sh Normal file
View File

@@ -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" <<EOF
# Indice de Backups - Sistema de Flotillas
# Generado: $(date)
# Retencion: ${RETENTION_DAYS} dias
#
# Formato: fecha | tipo | archivo | tamanio
#
EOF
# Listar backups
for file in $(ls -t "$BACKUP_DIR/daily"/*.gz 2>/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 "$@"

View File

@@ -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 "$@"

1080
deploy/scripts/install.sh Normal file

File diff suppressed because it is too large Load Diff

133
deploy/scripts/logs.sh Normal file
View File

@@ -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

549
deploy/scripts/restore.sh Normal file
View File

@@ -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 <<EOF
DROP DATABASE IF EXISTS ${DB_NAME};
CREATE DATABASE ${DB_NAME} OWNER ${DB_USER};
EOF
# Conectar y crear extensiones
psql -h "$DB_HOST" -p "$DB_PORT" -U postgres -d "$DB_NAME" <<EOF
CREATE EXTENSION IF NOT EXISTS postgis;
CREATE EXTENSION IF NOT EXISTS timescaledb CASCADE;
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
GRANT ALL ON DATABASE ${DB_NAME} TO ${DB_USER};
GRANT ALL ON SCHEMA public TO ${DB_USER};
EOF
# Restaurar datos
log_info "Restaurando datos..."
gunzip -c "$backup_file" | psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -q
unset PGPASSWORD
log_success "Base de datos restaurada exitosamente"
start_services
}
# ---------------------------------------------
# Restaurar configuracion
# ---------------------------------------------
restore_config() {
local backup_file="$1"
if [[ ! -f "$backup_file" ]]; then
log_error "Archivo no encontrado: $backup_file"
return 1
fi
log_info "Restaurando configuracion desde: $(basename "$backup_file")"
# Verificar integridad
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 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 "$@"

214
deploy/scripts/status.sh Normal file
View File

@@ -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 ""

485
deploy/scripts/update.sh Normal file
View File

@@ -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 "$@"

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

200
deploy/traccar/traccar.xml Normal file
View File

@@ -0,0 +1,200 @@
<?xml version='1.0' encoding='UTF-8'?>
<!--
============================================
Traccar Server - Configuracion para Sistema de Flotillas
============================================
Documentacion: https://www.traccar.org/configuration-file/
Esta configuracion:
- Usa PostgreSQL como base de datos
- Forward de posiciones a API del sistema
- Deshabilita interfaz web (usamos nuestra propia UI)
- Solo habilita protocolos GPS comunes
============================================
-->
<!DOCTYPE properties SYSTEM 'http://java.sun.com/dtd/properties.dtd'>
<properties>
<!-- ========================================
Configuracion General
======================================== -->
<!-- Modo de operacion -->
<entry key='config.default'>./conf/default.xml</entry>
<!-- Logging -->
<entry key='logger.enable'>true</entry>
<entry key='logger.level'>info</entry>
<entry key='logger.file'>/opt/traccar/logs/tracker-server.log</entry>
<entry key='logger.rotate'>true</entry>
<!-- ========================================
Base de Datos PostgreSQL
======================================== -->
<entry key='database.driver'>org.postgresql.Driver</entry>
<entry key='database.url'>jdbc:postgresql://localhost:5432/traccar</entry>
<entry key='database.user'>flotillas</entry>
<entry key='database.password'>POSTGRES_PASSWORD</entry>
<!-- Pool de conexiones -->
<entry key='database.checkConnection'>true</entry>
<entry key='database.maxPoolSize'>10</entry>
<!-- ========================================
Interfaz Web (DESHABILITADA)
======================================== -->
<!-- No usamos la web UI de Traccar, usamos nuestra propia -->
<entry key='web.enable'>false</entry>
<!-- Si necesitas habilitarla temporalmente para debug: -->
<!-- <entry key='web.enable'>true</entry> -->
<!-- <entry key='web.port'>8082</entry> -->
<!-- ========================================
Forward de Posiciones a API
======================================== -->
<!-- Enviar cada posicion a nuestro backend -->
<entry key='forward.enable'>true</entry>
<entry key='forward.url'>http://localhost:8000/api/v1/traccar/position</entry>
<entry key='forward.json'>true</entry>
<!-- Headers adicionales (si se necesita auth) -->
<!-- <entry key='forward.header.Authorization'>Bearer TOKEN</entry> -->
<!-- Reintentos en caso de fallo -->
<entry key='forward.retryEnable'>true</entry>
<entry key='forward.retryDelay'>60</entry>
<entry key='forward.retryCount'>3</entry>
<!-- ========================================
Protocolos GPS Habilitados
======================================== -->
<!-- Puerto principal - Protocolo Osmand (Android/iOS apps) -->
<entry key='osmand.port'>5055</entry>
<!-- GT06 - Dispositivos chinos comunes -->
<entry key='gt06.port'>5023</entry>
<!-- H02 - Coban y similares -->
<entry key='h02.port'>5013</entry>
<!-- TK103 - Muy comun -->
<entry key='tk103.port'>5002</entry>
<!-- GPS103 -->
<entry key='gps103.port'>5001</entry>
<!-- Teltonika - Profesionales -->
<entry key='teltonika.port'>5027</entry>
<!-- Queclink -->
<entry key='queclink.port'>5050</entry>
<!-- Meitrack -->
<entry key='meitrack.port'>5020</entry>
<!-- Ruptela -->
<entry key='ruptela.port'>5046</entry>
<!-- Suntech -->
<entry key='suntech.port'>5011</entry>
<!-- Watch (smartwatches GPS) -->
<entry key='watch.port'>5093</entry>
<!-- T55 -->
<entry key='t55.port'>5005</entry>
<!-- Xexun -->
<entry key='xexun.port'>5006</entry>
<!-- TotemAT -->
<entry key='totem.port'>5007</entry>
<!-- Enfora -->
<entry key='enfora.port'>5008</entry>
<!-- Meiligao -->
<entry key='meiligao.port'>5009</entry>
<!-- Protocolo Traccar (app oficial) -->
<entry key='traccar.port'>5190</entry>
<!-- ========================================
Procesamiento de Eventos
======================================== -->
<!-- Eventos a procesar -->
<entry key='event.enable'>true</entry>
<entry key='event.ignoreDuplicateAlerts'>true</entry>
<!-- Geocodificacion (obtener direcciones) -->
<entry key='geocoder.enable'>true</entry>
<entry key='geocoder.type'>nominatim</entry>
<entry key='geocoder.url'>https://nominatim.openstreetmap.org/reverse</entry>
<entry key='geocoder.key'></entry>
<entry key='geocoder.processInvalidPositions'>false</entry>
<entry key='geocoder.reuseDistance'>50</entry>
<!-- Alternativamente usar Google (requiere API key) -->
<!-- <entry key='geocoder.type'>google</entry> -->
<!-- <entry key='geocoder.key'>TU_API_KEY</entry> -->
<!-- ========================================
Filtros de Posicion
======================================== -->
<!-- Filtrar posiciones invalidas -->
<entry key='filter.enable'>true</entry>
<entry key='filter.invalid'>true</entry>
<entry key='filter.zero'>true</entry>
<entry key='filter.duplicate'>true</entry>
<!-- Filtrar por velocidad maxima (km/h) -->
<entry key='filter.maxSpeed'>500</entry>
<!-- Distancia minima entre posiciones (metros) -->
<entry key='filter.distance'>10</entry>
<!-- ========================================
Notificaciones
======================================== -->
<!-- Las notificaciones las maneja nuestro backend -->
<entry key='notificator.enable'>false</entry>
<!-- ========================================
API REST de Traccar (DESHABILITADA)
======================================== -->
<!-- No exponemos API de Traccar, usamos nuestra propia -->
<entry key='api.enable'>false</entry>
<!-- ========================================
Seguridad
======================================== -->
<!-- Registro de nuevos dispositivos -->
<entry key='database.registerUnknown'>true</entry>
<!-- Dispositivos deben existir antes de enviar datos -->
<!-- <entry key='database.registerUnknown'>false</entry> -->
<!-- ========================================
Rendimiento
======================================== -->
<!-- Threads para procesamiento -->
<entry key='processing.computedAttributes.enabled'>true</entry>
<entry key='processing.computedAttributes.threads'>4</entry>
<!-- Cache -->
<entry key='database.positionsHistoryDays'>30</entry>
</properties>

742
docs/guias/api-reference.md Normal file
View File

@@ -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
```

449
docs/guias/configuracion.md Normal file
View File

@@ -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 <notificaciones@tudominio.com>
# 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
<?xml version='1.0' encoding='UTF-8'?>
<!DOCTYPE properties SYSTEM 'http://java.sun.com/dtd/properties.dtd'>
<properties>
<!-- Base de datos -->
<entry key='database.driver'>org.postgresql.Driver</entry>
<entry key='database.url'>jdbc:postgresql://localhost:5432/flotillas_db</entry>
<entry key='database.user'>flotillas</entry>
<entry key='database.password'>TU_PASSWORD</entry>
<!-- Deshabilitar web UI de Traccar (usamos nuestro dashboard) -->
<entry key='web.enable'>false</entry>
<entry key='api.enable'>false</entry>
<!-- Forward de posiciones a nuestra API -->
<entry key='forward.enable'>true</entry>
<entry key='forward.url'>http://localhost:8000/api/v1/traccar/position</entry>
<entry key='forward.json'>true</entry>
<!-- Protocolos habilitados (agregar segun tus GPS) -->
<entry key='gt06.port'>5023</entry>
<entry key='tk103.port'>5002</entry>
<entry key='gps103.port'>5001</entry>
<entry key='osmand.port'>5055</entry>
<!-- Logging -->
<entry key='logger.enable'>true</entry>
<entry key='logger.level'>info</entry>
</properties>
```
### 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
<entry key='PROTOCOLO.port'>PUERTO</entry>
```
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
```

264
docs/guias/instalacion.md Normal file
View File

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

Some files were not shown because too many files have changed in this diff Show More