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:
11
.claude/settings.local.json
Normal file
11
.claude/settings.local.json
Normal 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
86
.env.example
Normal 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
139
.gitignore
vendored
Normal 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
142
README.md
Normal 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
197
backend/.env.example
Normal file
@@ -0,0 +1,197 @@
|
||||
# =============================================================================
|
||||
# Adan Fleet Monitor - Environment Variables
|
||||
# =============================================================================
|
||||
# Copy this file to .env and fill in your values
|
||||
# NEVER commit the .env file to version control
|
||||
# =============================================================================
|
||||
|
||||
# =============================================================================
|
||||
# Application Settings
|
||||
# =============================================================================
|
||||
APP_NAME="Adan Fleet Monitor"
|
||||
APP_VERSION="1.0.0"
|
||||
ENVIRONMENT=development # development, staging, production
|
||||
DEBUG=true
|
||||
HOST=0.0.0.0
|
||||
PORT=8000
|
||||
WORKERS=4 # Number of workers for production
|
||||
|
||||
# =============================================================================
|
||||
# API Settings
|
||||
# =============================================================================
|
||||
API_V1_PREFIX=/api/v1
|
||||
|
||||
# =============================================================================
|
||||
# Database (PostgreSQL with TimescaleDB)
|
||||
# =============================================================================
|
||||
# Format: postgresql+asyncpg://user:password@host:port/database
|
||||
DATABASE_URL=postgresql+asyncpg://adan:your_password_here@localhost:5432/adan_fleet
|
||||
|
||||
# Database pool settings
|
||||
DB_POOL_SIZE=20
|
||||
DB_MAX_OVERFLOW=10
|
||||
DB_POOL_TIMEOUT=30
|
||||
|
||||
# =============================================================================
|
||||
# Redis (Caching & Real-time)
|
||||
# =============================================================================
|
||||
# Format: redis://[:password@]host:port/db
|
||||
REDIS_URL=redis://localhost:6379/0
|
||||
|
||||
# =============================================================================
|
||||
# JWT Authentication
|
||||
# =============================================================================
|
||||
# Generate with: openssl rand -hex 32
|
||||
SECRET_KEY=your_super_secret_key_change_this_in_production_minimum_32_characters
|
||||
JWT_ALGORITHM=HS256
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES=30
|
||||
REFRESH_TOKEN_EXPIRE_DAYS=7
|
||||
|
||||
# =============================================================================
|
||||
# Password Hashing
|
||||
# =============================================================================
|
||||
PASSWORD_MIN_LENGTH=8
|
||||
|
||||
# =============================================================================
|
||||
# CORS Settings
|
||||
# =============================================================================
|
||||
# Comma-separated list of allowed origins
|
||||
CORS_ORIGINS=http://localhost:3000,http://localhost:5173,http://127.0.0.1:3000
|
||||
CORS_ALLOW_CREDENTIALS=true
|
||||
CORS_ALLOW_METHODS=GET,POST,PUT,PATCH,DELETE,OPTIONS
|
||||
CORS_ALLOW_HEADERS=*
|
||||
|
||||
# =============================================================================
|
||||
# Traccar GPS Server Integration
|
||||
# =============================================================================
|
||||
TRACCAR_URL=http://localhost:8082
|
||||
TRACCAR_API_URL=http://localhost:8082/api
|
||||
TRACCAR_USERNAME=admin
|
||||
TRACCAR_PASSWORD=admin
|
||||
TRACCAR_ENABLED=true
|
||||
|
||||
# =============================================================================
|
||||
# MediaMTX Video Streaming Server
|
||||
# =============================================================================
|
||||
MEDIAMTX_URL=http://localhost:8554
|
||||
MEDIAMTX_API_URL=http://localhost:9997
|
||||
MEDIAMTX_ENABLED=false
|
||||
|
||||
# =============================================================================
|
||||
# MQTT Broker (IoT Communication)
|
||||
# =============================================================================
|
||||
MQTT_BROKER=localhost
|
||||
MQTT_PORT=1883
|
||||
MQTT_USERNAME=
|
||||
MQTT_PASSWORD=
|
||||
MQTT_ENABLED=false
|
||||
|
||||
# Meshtastic MQTT Topic
|
||||
MESHTASTIC_TOPIC=meshtastic/#
|
||||
|
||||
# =============================================================================
|
||||
# Email (SMTP)
|
||||
# =============================================================================
|
||||
SMTP_HOST=smtp.gmail.com
|
||||
SMTP_PORT=587
|
||||
SMTP_USERNAME=
|
||||
SMTP_PASSWORD=
|
||||
SMTP_FROM_EMAIL=noreply@example.com
|
||||
SMTP_FROM_NAME="Adan Fleet Monitor"
|
||||
SMTP_TLS=true
|
||||
SMTP_ENABLED=false
|
||||
|
||||
# =============================================================================
|
||||
# Push Notifications (Firebase)
|
||||
# =============================================================================
|
||||
FIREBASE_CREDENTIALS_PATH=
|
||||
FIREBASE_ENABLED=false
|
||||
|
||||
# =============================================================================
|
||||
# Geocoding & Maps
|
||||
# =============================================================================
|
||||
# OpenStreetMap Nominatim (free, rate-limited)
|
||||
NOMINATIM_USER_AGENT=adan-fleet-monitor
|
||||
|
||||
# Google Maps API (optional, for premium geocoding)
|
||||
GOOGLE_MAPS_API_KEY=
|
||||
|
||||
# =============================================================================
|
||||
# File Storage
|
||||
# =============================================================================
|
||||
UPLOAD_DIR=./uploads
|
||||
MAX_UPLOAD_SIZE_MB=50
|
||||
|
||||
# AWS S3 (optional, for cloud storage)
|
||||
AWS_ACCESS_KEY_ID=
|
||||
AWS_SECRET_ACCESS_KEY=
|
||||
AWS_S3_BUCKET=
|
||||
AWS_S3_REGION=us-east-1
|
||||
|
||||
# =============================================================================
|
||||
# Logging
|
||||
# =============================================================================
|
||||
LOG_LEVEL=INFO # DEBUG, INFO, WARNING, ERROR, CRITICAL
|
||||
LOG_FORMAT=json # json, text
|
||||
LOG_FILE=./logs/adan.log
|
||||
|
||||
# =============================================================================
|
||||
# Sentry (Error Tracking)
|
||||
# =============================================================================
|
||||
SENTRY_DSN=
|
||||
SENTRY_ENVIRONMENT=development
|
||||
SENTRY_ENABLED=false
|
||||
|
||||
# =============================================================================
|
||||
# Rate Limiting
|
||||
# =============================================================================
|
||||
RATE_LIMIT_REQUESTS=100
|
||||
RATE_LIMIT_PERIOD_SECONDS=60
|
||||
|
||||
# =============================================================================
|
||||
# Background Tasks (Celery)
|
||||
# =============================================================================
|
||||
CELERY_BROKER_URL=redis://localhost:6379/1
|
||||
CELERY_RESULT_BACKEND=redis://localhost:6379/2
|
||||
CELERY_ENABLED=false
|
||||
|
||||
# =============================================================================
|
||||
# Alert Settings
|
||||
# =============================================================================
|
||||
# Default speed limit (km/h) for new vehicles
|
||||
DEFAULT_SPEED_LIMIT=120
|
||||
|
||||
# Time without signal before generating alert (seconds)
|
||||
NO_SIGNAL_THRESHOLD_SECONDS=300
|
||||
|
||||
# Battery level threshold for low battery alert (%)
|
||||
LOW_BATTERY_THRESHOLD=20
|
||||
|
||||
# Minimum stop duration to register as a stop (seconds)
|
||||
MIN_STOP_DURATION_SECONDS=120
|
||||
|
||||
# =============================================================================
|
||||
# Trip Detection
|
||||
# =============================================================================
|
||||
# Minimum movement to start a trip (meters)
|
||||
TRIP_START_DISTANCE_METERS=100
|
||||
|
||||
# Minimum idle time to end a trip (seconds)
|
||||
TRIP_END_IDLE_SECONDS=300
|
||||
|
||||
# =============================================================================
|
||||
# WebSocket Settings
|
||||
# =============================================================================
|
||||
WS_HEARTBEAT_INTERVAL=30
|
||||
WS_MAX_CONNECTIONS=1000
|
||||
|
||||
# =============================================================================
|
||||
# Report Settings
|
||||
# =============================================================================
|
||||
REPORTS_OUTPUT_DIR=./reports
|
||||
REPORTS_RETENTION_DAYS=90
|
||||
|
||||
# =============================================================================
|
||||
# Timezone
|
||||
# =============================================================================
|
||||
TIMEZONE=America/Mexico_City
|
||||
73
backend/alembic.ini
Normal file
73
backend/alembic.ini
Normal file
@@ -0,0 +1,73 @@
|
||||
# Alembic configuration file for Adan Fleet Monitor
|
||||
|
||||
[alembic]
|
||||
# path to migration scripts
|
||||
script_location = alembic
|
||||
|
||||
# template used to generate migration files
|
||||
file_template = %%(year)d%%(month).2d%%(day).2d_%%(hour).2d%%(minute).2d%%(second).2d_%%(rev)s_%%(slug)s
|
||||
|
||||
# sys.path path, will be prepended to sys.path if present.
|
||||
prepend_sys_path = .
|
||||
|
||||
# timezone to use when rendering the date within the migration file
|
||||
timezone = UTC
|
||||
|
||||
# max length of characters to apply to the "slug" field
|
||||
truncate_slug_length = 40
|
||||
|
||||
# set to 'true' to run the environment during the 'revision' command
|
||||
revision_environment = false
|
||||
|
||||
# set to 'true' to allow .pyc and .pyo files without a source .py file to be detected as revisions in the versions/ directory
|
||||
sourceless = false
|
||||
|
||||
# version location specification
|
||||
version_locations = %(here)s/alembic/versions
|
||||
|
||||
# version path separator
|
||||
version_path_separator = os
|
||||
|
||||
# the output encoding used when revision files are written from script.py.mako
|
||||
output_encoding = utf-8
|
||||
|
||||
# Database URL - will be overridden by env.py
|
||||
sqlalchemy.url = driver://user:pass@localhost/dbname
|
||||
|
||||
[post_write_hooks]
|
||||
# post_write_hooks defines scripts or Python functions that are run
|
||||
# on newly generated revision scripts.
|
||||
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
137
backend/alembic/env.py
Normal file
137
backend/alembic/env.py
Normal file
@@ -0,0 +1,137 @@
|
||||
"""
|
||||
Alembic environment configuration for Adan Fleet Monitor.
|
||||
|
||||
Configurado para:
|
||||
- SQLAlchemy async (asyncpg)
|
||||
- TimescaleDB hypertables
|
||||
- Migraciones automáticas desde modelos
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from logging.config import fileConfig
|
||||
|
||||
from alembic import context
|
||||
from sqlalchemy import pool, text
|
||||
from sqlalchemy.engine import Connection
|
||||
from sqlalchemy.ext.asyncio import async_engine_from_config
|
||||
|
||||
from app.core.config import settings
|
||||
from app.models.base import Base
|
||||
|
||||
# Import all models so they are registered with Base.metadata
|
||||
from app.models import (
|
||||
Usuario,
|
||||
GrupoVehiculos,
|
||||
Conductor,
|
||||
Vehiculo,
|
||||
Dispositivo,
|
||||
Ubicacion,
|
||||
Viaje,
|
||||
Parada,
|
||||
TipoAlerta,
|
||||
Alerta,
|
||||
Geocerca,
|
||||
POI,
|
||||
CargaCombustible,
|
||||
TipoMantenimiento,
|
||||
Mantenimiento,
|
||||
Camara,
|
||||
Grabacion,
|
||||
EventoVideo,
|
||||
Mensaje,
|
||||
Configuracion,
|
||||
)
|
||||
|
||||
# this is the Alembic Config object, which provides
|
||||
# access to the values within the .ini file in use.
|
||||
config = context.config
|
||||
|
||||
# Interpret the config file for Python logging.
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
# add your model's MetaData object here
|
||||
target_metadata = Base.metadata
|
||||
|
||||
|
||||
def get_url() -> str:
|
||||
"""
|
||||
Obtiene la URL de la base de datos desde la configuración.
|
||||
"""
|
||||
return settings.DATABASE_URL
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
"""
|
||||
Run migrations in 'offline' mode.
|
||||
|
||||
This configures the context with just a URL
|
||||
and not an Engine, though an Engine is acceptable
|
||||
here as well. By skipping the Engine creation
|
||||
we don't even need a DBAPI to be available.
|
||||
|
||||
Calls to context.execute() here emit the given string to the
|
||||
script output.
|
||||
"""
|
||||
url = get_url()
|
||||
context.configure(
|
||||
url=url,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
compare_type=True,
|
||||
compare_server_default=True,
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def do_run_migrations(connection: Connection) -> None:
|
||||
"""
|
||||
Run migrations with the given connection.
|
||||
"""
|
||||
context.configure(
|
||||
connection=connection,
|
||||
target_metadata=target_metadata,
|
||||
compare_type=True,
|
||||
compare_server_default=True,
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
async def run_async_migrations() -> None:
|
||||
"""
|
||||
Run migrations in 'online' mode with async engine.
|
||||
|
||||
In this scenario we need to create an Engine
|
||||
and associate a connection with the context.
|
||||
"""
|
||||
configuration = config.get_section(config.config_ini_section)
|
||||
configuration["sqlalchemy.url"] = get_url()
|
||||
|
||||
connectable = async_engine_from_config(
|
||||
configuration,
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
|
||||
async with connectable.connect() as connection:
|
||||
await connection.run_sync(do_run_migrations)
|
||||
|
||||
await connectable.dispose()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
"""
|
||||
Run migrations in 'online' mode.
|
||||
"""
|
||||
asyncio.run(run_async_migrations())
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
26
backend/alembic/script.py.mako
Normal file
26
backend/alembic/script.py.mako
Normal file
@@ -0,0 +1,26 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = ${repr(up_revision)}
|
||||
down_revision: Union[str, None] = ${repr(down_revision)}
|
||||
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
${downgrades if downgrades else "pass"}
|
||||
847
backend/alembic/versions/20260121_000000_001_initial_schema.py
Normal file
847
backend/alembic/versions/20260121_000000_001_initial_schema.py
Normal file
@@ -0,0 +1,847 @@
|
||||
"""Initial schema with TimescaleDB support.
|
||||
|
||||
Revision ID: 001
|
||||
Revises:
|
||||
Create Date: 2026-01-21
|
||||
|
||||
Este migration crea:
|
||||
- Extensiones PostgreSQL (TimescaleDB, PostGIS opcional)
|
||||
- Todas las tablas del sistema
|
||||
- Hypertable para ubicaciones (time-series)
|
||||
- Índices y constraints
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "001"
|
||||
down_revision: Union[str, None] = None
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ========================================================================
|
||||
# Extensiones PostgreSQL
|
||||
# ========================================================================
|
||||
|
||||
# TimescaleDB para series temporales
|
||||
op.execute("CREATE EXTENSION IF NOT EXISTS timescaledb CASCADE;")
|
||||
|
||||
# PostGIS para datos geoespaciales (opcional)
|
||||
op.execute("CREATE EXTENSION IF NOT EXISTS postgis;")
|
||||
|
||||
# UUID generation
|
||||
op.execute('CREATE EXTENSION IF NOT EXISTS "uuid-ossp";')
|
||||
|
||||
# ========================================================================
|
||||
# Tipos ENUM
|
||||
# ========================================================================
|
||||
|
||||
tipo_vehiculo_enum = postgresql.ENUM(
|
||||
"sedan", "suv", "pickup", "van", "camion", "motocicleta", "autobus", "otro",
|
||||
name="tipo_vehiculo",
|
||||
create_type=True,
|
||||
)
|
||||
tipo_vehiculo_enum.create(op.get_bind(), checkfirst=True)
|
||||
|
||||
estado_vehiculo_enum = postgresql.ENUM(
|
||||
"activo", "inactivo", "mantenimiento", "baja",
|
||||
name="estado_vehiculo",
|
||||
create_type=True,
|
||||
)
|
||||
estado_vehiculo_enum.create(op.get_bind(), checkfirst=True)
|
||||
|
||||
tipo_conductor_enum = postgresql.ENUM(
|
||||
"interno", "externo", "temporal",
|
||||
name="tipo_conductor",
|
||||
create_type=True,
|
||||
)
|
||||
tipo_conductor_enum.create(op.get_bind(), checkfirst=True)
|
||||
|
||||
tipo_dispositivo_enum = postgresql.ENUM(
|
||||
"gps", "obd", "dashcam", "sensor", "meshtastic", "otro",
|
||||
name="tipo_dispositivo",
|
||||
create_type=True,
|
||||
)
|
||||
tipo_dispositivo_enum.create(op.get_bind(), checkfirst=True)
|
||||
|
||||
estado_dispositivo_enum = postgresql.ENUM(
|
||||
"activo", "inactivo", "sin_senal", "bateria_baja",
|
||||
name="estado_dispositivo",
|
||||
create_type=True,
|
||||
)
|
||||
estado_dispositivo_enum.create(op.get_bind(), checkfirst=True)
|
||||
|
||||
tipo_geocerca_enum = postgresql.ENUM(
|
||||
"circular", "poligonal",
|
||||
name="tipo_geocerca",
|
||||
create_type=True,
|
||||
)
|
||||
tipo_geocerca_enum.create(op.get_bind(), checkfirst=True)
|
||||
|
||||
severidad_alerta_enum = postgresql.ENUM(
|
||||
"baja", "media", "alta", "critica",
|
||||
name="severidad_alerta",
|
||||
create_type=True,
|
||||
)
|
||||
severidad_alerta_enum.create(op.get_bind(), checkfirst=True)
|
||||
|
||||
prioridad_mensaje_enum = postgresql.ENUM(
|
||||
"baja", "normal", "alta", "urgente",
|
||||
name="prioridad_mensaje",
|
||||
create_type=True,
|
||||
)
|
||||
prioridad_mensaje_enum.create(op.get_bind(), checkfirst=True)
|
||||
|
||||
# ========================================================================
|
||||
# Tabla: usuarios
|
||||
# ========================================================================
|
||||
|
||||
op.create_table(
|
||||
"usuarios",
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("email", sa.String(255), nullable=False),
|
||||
sa.Column("hashed_password", sa.String(255), nullable=False),
|
||||
sa.Column("nombre", sa.String(100), nullable=False),
|
||||
sa.Column("apellido", sa.String(100), nullable=True),
|
||||
sa.Column("telefono", sa.String(20), nullable=True),
|
||||
sa.Column("rol", sa.String(50), nullable=False, server_default="operador"),
|
||||
sa.Column("activo", sa.Boolean(), nullable=False, server_default="true"),
|
||||
sa.Column("ultimo_login", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("avatar_url", sa.String(500), nullable=True),
|
||||
sa.Column("preferencias", postgresql.JSONB(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("email"),
|
||||
)
|
||||
op.create_index("ix_usuarios_email", "usuarios", ["email"])
|
||||
op.create_index("ix_usuarios_rol", "usuarios", ["rol"])
|
||||
|
||||
# ========================================================================
|
||||
# Tabla: grupos_vehiculos
|
||||
# ========================================================================
|
||||
|
||||
op.create_table(
|
||||
"grupos_vehiculos",
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("nombre", sa.String(100), nullable=False),
|
||||
sa.Column("descripcion", sa.Text(), nullable=True),
|
||||
sa.Column("color", sa.String(7), nullable=True, server_default="#3B82F6"),
|
||||
sa.Column("icono", sa.String(50), nullable=True),
|
||||
sa.Column("activo", sa.Boolean(), nullable=False, server_default="true"),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("nombre"),
|
||||
)
|
||||
|
||||
# ========================================================================
|
||||
# Tabla: conductores
|
||||
# ========================================================================
|
||||
|
||||
op.create_table(
|
||||
"conductores",
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("numero_empleado", sa.String(50), nullable=True),
|
||||
sa.Column("nombre", sa.String(100), nullable=False),
|
||||
sa.Column("apellido", sa.String(100), nullable=False),
|
||||
sa.Column("email", sa.String(255), nullable=True),
|
||||
sa.Column("telefono", sa.String(20), nullable=True),
|
||||
sa.Column("licencia_numero", sa.String(50), nullable=True),
|
||||
sa.Column("licencia_tipo", sa.String(20), nullable=True),
|
||||
sa.Column("licencia_vigencia", sa.Date(), nullable=True),
|
||||
sa.Column("fecha_nacimiento", sa.Date(), nullable=True),
|
||||
sa.Column("fecha_contratacion", sa.Date(), nullable=True),
|
||||
sa.Column("direccion", sa.Text(), nullable=True),
|
||||
sa.Column("contacto_emergencia", sa.String(100), nullable=True),
|
||||
sa.Column("telefono_emergencia", sa.String(20), nullable=True),
|
||||
sa.Column("foto_url", sa.String(500), nullable=True),
|
||||
sa.Column("tipo", tipo_conductor_enum, nullable=False, server_default="interno"),
|
||||
sa.Column("activo", sa.Boolean(), nullable=False, server_default="true"),
|
||||
sa.Column("notas", sa.Text(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index("ix_conductores_numero_empleado", "conductores", ["numero_empleado"])
|
||||
op.create_index("ix_conductores_licencia_numero", "conductores", ["licencia_numero"])
|
||||
op.create_index("ix_conductores_activo", "conductores", ["activo"])
|
||||
|
||||
# ========================================================================
|
||||
# Tabla: vehiculos
|
||||
# ========================================================================
|
||||
|
||||
op.create_table(
|
||||
"vehiculos",
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("nombre", sa.String(100), nullable=False),
|
||||
sa.Column("placa", sa.String(20), nullable=False),
|
||||
sa.Column("vin", sa.String(17), nullable=True),
|
||||
sa.Column("marca", sa.String(50), nullable=True),
|
||||
sa.Column("modelo", sa.String(50), nullable=True),
|
||||
sa.Column("anio", sa.Integer(), nullable=True),
|
||||
sa.Column("color", sa.String(30), nullable=True),
|
||||
sa.Column("tipo", tipo_vehiculo_enum, nullable=False, server_default="sedan"),
|
||||
sa.Column("capacidad_pasajeros", sa.Integer(), nullable=True),
|
||||
sa.Column("capacidad_carga_kg", sa.Float(), nullable=True),
|
||||
sa.Column("odometro_actual", sa.Float(), nullable=True, server_default="0"),
|
||||
sa.Column("consumo_promedio", sa.Float(), nullable=True),
|
||||
sa.Column("tipo_combustible", sa.String(20), nullable=True),
|
||||
sa.Column("capacidad_tanque", sa.Float(), nullable=True),
|
||||
sa.Column("icono", sa.String(50), nullable=True),
|
||||
sa.Column("foto_url", sa.String(500), nullable=True),
|
||||
sa.Column("estado", estado_vehiculo_enum, nullable=False, server_default="activo"),
|
||||
sa.Column("grupo_id", sa.Integer(), nullable=True),
|
||||
sa.Column("conductor_actual_id", sa.Integer(), nullable=True),
|
||||
sa.Column("ultima_latitud", sa.Float(), nullable=True),
|
||||
sa.Column("ultima_longitud", sa.Float(), nullable=True),
|
||||
sa.Column("ultima_velocidad", sa.Float(), nullable=True),
|
||||
sa.Column("ultimo_rumbo", sa.Float(), nullable=True),
|
||||
sa.Column("ultima_ubicacion_tiempo", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("motor_encendido", sa.Boolean(), nullable=True),
|
||||
sa.Column("en_movimiento", sa.Boolean(), nullable=True, server_default="false"),
|
||||
sa.Column("velocidad_maxima", sa.Float(), nullable=True, server_default="120"),
|
||||
sa.Column("notas", sa.Text(), nullable=True),
|
||||
sa.Column("metadata", postgresql.JSONB(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("placa"),
|
||||
sa.ForeignKeyConstraint(["grupo_id"], ["grupos_vehiculos.id"], ondelete="SET NULL"),
|
||||
sa.ForeignKeyConstraint(["conductor_actual_id"], ["conductores.id"], ondelete="SET NULL"),
|
||||
)
|
||||
op.create_index("ix_vehiculos_placa", "vehiculos", ["placa"])
|
||||
op.create_index("ix_vehiculos_estado", "vehiculos", ["estado"])
|
||||
op.create_index("ix_vehiculos_grupo_id", "vehiculos", ["grupo_id"])
|
||||
|
||||
# ========================================================================
|
||||
# Tabla: dispositivos
|
||||
# ========================================================================
|
||||
|
||||
op.create_table(
|
||||
"dispositivos",
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("identificador", sa.String(100), nullable=False),
|
||||
sa.Column("nombre", sa.String(100), nullable=True),
|
||||
sa.Column("tipo", tipo_dispositivo_enum, nullable=False, server_default="gps"),
|
||||
sa.Column("marca", sa.String(50), nullable=True),
|
||||
sa.Column("modelo", sa.String(50), nullable=True),
|
||||
sa.Column("numero_serie", sa.String(100), nullable=True),
|
||||
sa.Column("numero_sim", sa.String(20), nullable=True),
|
||||
sa.Column("operador_sim", sa.String(50), nullable=True),
|
||||
sa.Column("imei", sa.String(20), nullable=True),
|
||||
sa.Column("firmware_version", sa.String(50), nullable=True),
|
||||
sa.Column("protocolo", sa.String(50), nullable=True),
|
||||
sa.Column("estado", estado_dispositivo_enum, nullable=False, server_default="activo"),
|
||||
sa.Column("vehiculo_id", sa.Integer(), nullable=True),
|
||||
sa.Column("ultima_comunicacion", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("nivel_bateria", sa.Float(), nullable=True),
|
||||
sa.Column("nivel_senal", sa.Float(), nullable=True),
|
||||
sa.Column("configuracion", postgresql.JSONB(), nullable=True),
|
||||
sa.Column("notas", sa.Text(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("identificador"),
|
||||
sa.ForeignKeyConstraint(["vehiculo_id"], ["vehiculos.id"], ondelete="SET NULL"),
|
||||
)
|
||||
op.create_index("ix_dispositivos_identificador", "dispositivos", ["identificador"])
|
||||
op.create_index("ix_dispositivos_vehiculo_id", "dispositivos", ["vehiculo_id"])
|
||||
op.create_index("ix_dispositivos_imei", "dispositivos", ["imei"])
|
||||
|
||||
# ========================================================================
|
||||
# Tabla: ubicaciones (TimescaleDB Hypertable)
|
||||
# ========================================================================
|
||||
|
||||
op.create_table(
|
||||
"ubicaciones",
|
||||
sa.Column("tiempo", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("vehiculo_id", sa.Integer(), nullable=False),
|
||||
sa.Column("dispositivo_id", sa.Integer(), nullable=True),
|
||||
sa.Column("latitud", sa.Float(), nullable=False),
|
||||
sa.Column("longitud", sa.Float(), nullable=False),
|
||||
sa.Column("altitud", sa.Float(), nullable=True),
|
||||
sa.Column("velocidad", sa.Float(), nullable=True),
|
||||
sa.Column("rumbo", sa.Float(), nullable=True),
|
||||
sa.Column("precision", sa.Float(), nullable=True),
|
||||
sa.Column("satelites", sa.Integer(), nullable=True),
|
||||
sa.Column("hdop", sa.Float(), nullable=True),
|
||||
sa.Column("motor_encendido", sa.Boolean(), nullable=True),
|
||||
sa.Column("nivel_bateria", sa.Float(), nullable=True),
|
||||
sa.Column("nivel_combustible", sa.Float(), nullable=True),
|
||||
sa.Column("odometro", sa.Float(), nullable=True),
|
||||
sa.Column("temperatura", sa.Float(), nullable=True),
|
||||
sa.Column("evento", sa.String(50), nullable=True),
|
||||
sa.Column("direccion", sa.String(500), nullable=True),
|
||||
sa.Column("datos_extra", postgresql.JSONB(), nullable=True),
|
||||
sa.PrimaryKeyConstraint("tiempo", "vehiculo_id"),
|
||||
sa.ForeignKeyConstraint(["vehiculo_id"], ["vehiculos.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["dispositivo_id"], ["dispositivos.id"], ondelete="SET NULL"),
|
||||
)
|
||||
|
||||
# Convertir a hypertable de TimescaleDB
|
||||
op.execute(
|
||||
"SELECT create_hypertable('ubicaciones', 'tiempo', "
|
||||
"chunk_time_interval => INTERVAL '1 day', "
|
||||
"if_not_exists => TRUE);"
|
||||
)
|
||||
|
||||
# Índices para ubicaciones
|
||||
op.create_index("ix_ubicaciones_vehiculo_tiempo", "ubicaciones", ["vehiculo_id", "tiempo"])
|
||||
op.create_index("ix_ubicaciones_tiempo", "ubicaciones", ["tiempo"])
|
||||
|
||||
# Políticas de retención y compresión
|
||||
op.execute(
|
||||
"SELECT add_retention_policy('ubicaciones', INTERVAL '1 year', if_not_exists => TRUE);"
|
||||
)
|
||||
op.execute(
|
||||
"ALTER TABLE ubicaciones SET ("
|
||||
"timescaledb.compress, "
|
||||
"timescaledb.compress_segmentby = 'vehiculo_id'"
|
||||
");"
|
||||
)
|
||||
op.execute(
|
||||
"SELECT add_compression_policy('ubicaciones', INTERVAL '7 days', if_not_exists => TRUE);"
|
||||
)
|
||||
|
||||
# ========================================================================
|
||||
# Tabla: viajes
|
||||
# ========================================================================
|
||||
|
||||
op.create_table(
|
||||
"viajes",
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("vehiculo_id", sa.Integer(), nullable=False),
|
||||
sa.Column("conductor_id", sa.Integer(), nullable=True),
|
||||
sa.Column("inicio", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("fin", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("duracion_segundos", sa.Integer(), nullable=True),
|
||||
sa.Column("distancia_km", sa.Float(), nullable=True),
|
||||
sa.Column("velocidad_maxima", sa.Float(), nullable=True),
|
||||
sa.Column("velocidad_promedio", sa.Float(), nullable=True),
|
||||
sa.Column("latitud_inicio", sa.Float(), nullable=True),
|
||||
sa.Column("longitud_inicio", sa.Float(), nullable=True),
|
||||
sa.Column("latitud_fin", sa.Float(), nullable=True),
|
||||
sa.Column("longitud_fin", sa.Float(), nullable=True),
|
||||
sa.Column("direccion_inicio", sa.String(500), nullable=True),
|
||||
sa.Column("direccion_fin", sa.String(500), nullable=True),
|
||||
sa.Column("odometro_inicio", sa.Float(), nullable=True),
|
||||
sa.Column("odometro_fin", sa.Float(), nullable=True),
|
||||
sa.Column("combustible_consumido", sa.Float(), nullable=True),
|
||||
sa.Column("costo_combustible", sa.Float(), nullable=True),
|
||||
sa.Column("num_paradas", sa.Integer(), nullable=True, server_default="0"),
|
||||
sa.Column("num_alertas", sa.Integer(), nullable=True, server_default="0"),
|
||||
sa.Column("puntuacion_conduccion", sa.Float(), nullable=True),
|
||||
sa.Column("proposito", sa.String(100), nullable=True),
|
||||
sa.Column("notas", sa.Text(), nullable=True),
|
||||
sa.Column("ruta_simplificada", postgresql.JSONB(), nullable=True),
|
||||
sa.Column("activo", sa.Boolean(), nullable=False, server_default="true"),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.ForeignKeyConstraint(["vehiculo_id"], ["vehiculos.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["conductor_id"], ["conductores.id"], ondelete="SET NULL"),
|
||||
)
|
||||
op.create_index("ix_viajes_vehiculo_id", "viajes", ["vehiculo_id"])
|
||||
op.create_index("ix_viajes_inicio", "viajes", ["inicio"])
|
||||
op.create_index("ix_viajes_vehiculo_inicio", "viajes", ["vehiculo_id", "inicio"])
|
||||
|
||||
# ========================================================================
|
||||
# Tabla: paradas
|
||||
# ========================================================================
|
||||
|
||||
op.create_table(
|
||||
"paradas",
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("viaje_id", sa.Integer(), nullable=False),
|
||||
sa.Column("vehiculo_id", sa.Integer(), nullable=False),
|
||||
sa.Column("inicio", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("fin", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("duracion_segundos", sa.Integer(), nullable=True),
|
||||
sa.Column("latitud", sa.Float(), nullable=False),
|
||||
sa.Column("longitud", sa.Float(), nullable=False),
|
||||
sa.Column("direccion", sa.String(500), nullable=True),
|
||||
sa.Column("motor_encendido", sa.Boolean(), nullable=True),
|
||||
sa.Column("poi_id", sa.Integer(), nullable=True),
|
||||
sa.Column("geocerca_id", sa.Integer(), nullable=True),
|
||||
sa.Column("tipo", sa.String(50), nullable=True),
|
||||
sa.Column("notas", sa.Text(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.ForeignKeyConstraint(["viaje_id"], ["viajes.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["vehiculo_id"], ["vehiculos.id"], ondelete="CASCADE"),
|
||||
)
|
||||
op.create_index("ix_paradas_viaje_id", "paradas", ["viaje_id"])
|
||||
op.create_index("ix_paradas_vehiculo_id", "paradas", ["vehiculo_id"])
|
||||
|
||||
# ========================================================================
|
||||
# Tabla: tipos_alerta
|
||||
# ========================================================================
|
||||
|
||||
op.create_table(
|
||||
"tipos_alerta",
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("codigo", sa.String(50), nullable=False),
|
||||
sa.Column("nombre", sa.String(100), nullable=False),
|
||||
sa.Column("descripcion", sa.Text(), nullable=True),
|
||||
sa.Column("severidad", severidad_alerta_enum, nullable=False, server_default="media"),
|
||||
sa.Column("icono", sa.String(50), nullable=True),
|
||||
sa.Column("color", sa.String(7), nullable=True),
|
||||
sa.Column("sonido", sa.String(100), nullable=True),
|
||||
sa.Column("requiere_atencion", sa.Boolean(), nullable=False, server_default="true"),
|
||||
sa.Column("notificar_email", sa.Boolean(), nullable=False, server_default="false"),
|
||||
sa.Column("notificar_push", sa.Boolean(), nullable=False, server_default="true"),
|
||||
sa.Column("notificar_sms", sa.Boolean(), nullable=False, server_default="false"),
|
||||
sa.Column("activo", sa.Boolean(), nullable=False, server_default="true"),
|
||||
sa.Column("configuracion", postgresql.JSONB(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("codigo"),
|
||||
)
|
||||
|
||||
# ========================================================================
|
||||
# Tabla: alertas
|
||||
# ========================================================================
|
||||
|
||||
op.create_table(
|
||||
"alertas",
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("tipo_alerta_id", sa.Integer(), nullable=False),
|
||||
sa.Column("vehiculo_id", sa.Integer(), nullable=False),
|
||||
sa.Column("conductor_id", sa.Integer(), nullable=True),
|
||||
sa.Column("viaje_id", sa.Integer(), nullable=True),
|
||||
sa.Column("tiempo", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("latitud", sa.Float(), nullable=True),
|
||||
sa.Column("longitud", sa.Float(), nullable=True),
|
||||
sa.Column("direccion", sa.String(500), nullable=True),
|
||||
sa.Column("valor_detectado", sa.Float(), nullable=True),
|
||||
sa.Column("valor_limite", sa.Float(), nullable=True),
|
||||
sa.Column("mensaje", sa.Text(), nullable=True),
|
||||
sa.Column("datos_extra", postgresql.JSONB(), nullable=True),
|
||||
sa.Column("atendida", sa.Boolean(), nullable=False, server_default="false"),
|
||||
sa.Column("atendida_por_id", sa.Integer(), nullable=True),
|
||||
sa.Column("atendida_tiempo", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("comentario_atencion", sa.Text(), nullable=True),
|
||||
sa.Column("notificada", sa.Boolean(), nullable=False, server_default="false"),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.ForeignKeyConstraint(["tipo_alerta_id"], ["tipos_alerta.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["vehiculo_id"], ["vehiculos.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["conductor_id"], ["conductores.id"], ondelete="SET NULL"),
|
||||
sa.ForeignKeyConstraint(["viaje_id"], ["viajes.id"], ondelete="SET NULL"),
|
||||
sa.ForeignKeyConstraint(["atendida_por_id"], ["usuarios.id"], ondelete="SET NULL"),
|
||||
)
|
||||
op.create_index("ix_alertas_vehiculo_id", "alertas", ["vehiculo_id"])
|
||||
op.create_index("ix_alertas_tiempo", "alertas", ["tiempo"])
|
||||
op.create_index("ix_alertas_atendida", "alertas", ["atendida"])
|
||||
op.create_index("ix_alertas_tipo_tiempo", "alertas", ["tipo_alerta_id", "tiempo"])
|
||||
|
||||
# ========================================================================
|
||||
# Tabla: geocercas
|
||||
# ========================================================================
|
||||
|
||||
op.create_table(
|
||||
"geocercas",
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("nombre", sa.String(100), nullable=False),
|
||||
sa.Column("descripcion", sa.Text(), nullable=True),
|
||||
sa.Column("tipo", tipo_geocerca_enum, nullable=False),
|
||||
sa.Column("centro_latitud", sa.Float(), nullable=True),
|
||||
sa.Column("centro_longitud", sa.Float(), nullable=True),
|
||||
sa.Column("radio_metros", sa.Float(), nullable=True),
|
||||
sa.Column("coordenadas", postgresql.JSONB(), nullable=True),
|
||||
sa.Column("color", sa.String(7), nullable=True, server_default="#EF4444"),
|
||||
sa.Column("alertar_entrada", sa.Boolean(), nullable=False, server_default="true"),
|
||||
sa.Column("alertar_salida", sa.Boolean(), nullable=False, server_default="true"),
|
||||
sa.Column("velocidad_maxima", sa.Float(), nullable=True),
|
||||
sa.Column("horario_inicio", sa.Time(), nullable=True),
|
||||
sa.Column("horario_fin", sa.Time(), nullable=True),
|
||||
sa.Column("dias_semana", postgresql.ARRAY(sa.Integer()), nullable=True),
|
||||
sa.Column("activa", sa.Boolean(), nullable=False, server_default="true"),
|
||||
sa.Column("metadata", postgresql.JSONB(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index("ix_geocercas_activa", "geocercas", ["activa"])
|
||||
|
||||
# ========================================================================
|
||||
# Tabla: geocercas_vehiculos (many-to-many)
|
||||
# ========================================================================
|
||||
|
||||
op.create_table(
|
||||
"geocercas_vehiculos",
|
||||
sa.Column("geocerca_id", sa.Integer(), nullable=False),
|
||||
sa.Column("vehiculo_id", sa.Integer(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("geocerca_id", "vehiculo_id"),
|
||||
sa.ForeignKeyConstraint(["geocerca_id"], ["geocercas.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["vehiculo_id"], ["vehiculos.id"], ondelete="CASCADE"),
|
||||
)
|
||||
|
||||
# ========================================================================
|
||||
# Tabla: pois (puntos de interés)
|
||||
# ========================================================================
|
||||
|
||||
op.create_table(
|
||||
"pois",
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("nombre", sa.String(100), nullable=False),
|
||||
sa.Column("descripcion", sa.Text(), nullable=True),
|
||||
sa.Column("categoria", sa.String(50), nullable=True),
|
||||
sa.Column("latitud", sa.Float(), nullable=False),
|
||||
sa.Column("longitud", sa.Float(), nullable=False),
|
||||
sa.Column("direccion", sa.String(500), nullable=True),
|
||||
sa.Column("telefono", sa.String(20), nullable=True),
|
||||
sa.Column("email", sa.String(255), nullable=True),
|
||||
sa.Column("horario", sa.String(200), nullable=True),
|
||||
sa.Column("icono", sa.String(50), nullable=True),
|
||||
sa.Column("color", sa.String(7), nullable=True),
|
||||
sa.Column("radio_metros", sa.Float(), nullable=True, server_default="100"),
|
||||
sa.Column("activo", sa.Boolean(), nullable=False, server_default="true"),
|
||||
sa.Column("metadata", postgresql.JSONB(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index("ix_pois_categoria", "pois", ["categoria"])
|
||||
op.create_index("ix_pois_activo", "pois", ["activo"])
|
||||
|
||||
# Update paradas foreign key to pois
|
||||
op.create_foreign_key(
|
||||
"fk_paradas_poi_id",
|
||||
"paradas", "pois",
|
||||
["poi_id"], ["id"],
|
||||
ondelete="SET NULL"
|
||||
)
|
||||
op.create_foreign_key(
|
||||
"fk_paradas_geocerca_id",
|
||||
"paradas", "geocercas",
|
||||
["geocerca_id"], ["id"],
|
||||
ondelete="SET NULL"
|
||||
)
|
||||
|
||||
# ========================================================================
|
||||
# Tabla: cargas_combustible
|
||||
# ========================================================================
|
||||
|
||||
op.create_table(
|
||||
"cargas_combustible",
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("vehiculo_id", sa.Integer(), nullable=False),
|
||||
sa.Column("conductor_id", sa.Integer(), nullable=True),
|
||||
sa.Column("fecha", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("litros", sa.Float(), nullable=False),
|
||||
sa.Column("precio_litro", sa.Float(), nullable=True),
|
||||
sa.Column("costo_total", sa.Float(), nullable=True),
|
||||
sa.Column("odometro", sa.Float(), nullable=True),
|
||||
sa.Column("tanque_lleno", sa.Boolean(), nullable=True, server_default="true"),
|
||||
sa.Column("tipo_combustible", sa.String(20), nullable=True),
|
||||
sa.Column("estacion", sa.String(100), nullable=True),
|
||||
sa.Column("latitud", sa.Float(), nullable=True),
|
||||
sa.Column("longitud", sa.Float(), nullable=True),
|
||||
sa.Column("numero_factura", sa.String(50), nullable=True),
|
||||
sa.Column("foto_ticket_url", sa.String(500), nullable=True),
|
||||
sa.Column("notas", sa.Text(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.ForeignKeyConstraint(["vehiculo_id"], ["vehiculos.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["conductor_id"], ["conductores.id"], ondelete="SET NULL"),
|
||||
)
|
||||
op.create_index("ix_cargas_combustible_vehiculo_id", "cargas_combustible", ["vehiculo_id"])
|
||||
op.create_index("ix_cargas_combustible_fecha", "cargas_combustible", ["fecha"])
|
||||
|
||||
# ========================================================================
|
||||
# Tabla: tipos_mantenimiento
|
||||
# ========================================================================
|
||||
|
||||
op.create_table(
|
||||
"tipos_mantenimiento",
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("codigo", sa.String(50), nullable=False),
|
||||
sa.Column("nombre", sa.String(100), nullable=False),
|
||||
sa.Column("descripcion", sa.Text(), nullable=True),
|
||||
sa.Column("categoria", sa.String(50), nullable=True),
|
||||
sa.Column("intervalo_km", sa.Float(), nullable=True),
|
||||
sa.Column("intervalo_dias", sa.Integer(), nullable=True),
|
||||
sa.Column("costo_estimado", sa.Float(), nullable=True),
|
||||
sa.Column("duracion_estimada_horas", sa.Float(), nullable=True),
|
||||
sa.Column("activo", sa.Boolean(), nullable=False, server_default="true"),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("codigo"),
|
||||
)
|
||||
|
||||
# ========================================================================
|
||||
# Tabla: mantenimientos
|
||||
# ========================================================================
|
||||
|
||||
op.create_table(
|
||||
"mantenimientos",
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("vehiculo_id", sa.Integer(), nullable=False),
|
||||
sa.Column("tipo_mantenimiento_id", sa.Integer(), nullable=False),
|
||||
sa.Column("fecha_programada", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("fecha_realizado", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("odometro_programado", sa.Float(), nullable=True),
|
||||
sa.Column("odometro_realizado", sa.Float(), nullable=True),
|
||||
sa.Column("estado", sa.String(20), nullable=False, server_default="pendiente"),
|
||||
sa.Column("proveedor", sa.String(100), nullable=True),
|
||||
sa.Column("costo", sa.Float(), nullable=True),
|
||||
sa.Column("duracion_horas", sa.Float(), nullable=True),
|
||||
sa.Column("descripcion_trabajo", sa.Text(), nullable=True),
|
||||
sa.Column("repuestos_utilizados", postgresql.JSONB(), nullable=True),
|
||||
sa.Column("numero_factura", sa.String(50), nullable=True),
|
||||
sa.Column("documento_url", sa.String(500), nullable=True),
|
||||
sa.Column("notas", sa.Text(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.ForeignKeyConstraint(["vehiculo_id"], ["vehiculos.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["tipo_mantenimiento_id"], ["tipos_mantenimiento.id"], ondelete="CASCADE"),
|
||||
)
|
||||
op.create_index("ix_mantenimientos_vehiculo_id", "mantenimientos", ["vehiculo_id"])
|
||||
op.create_index("ix_mantenimientos_estado", "mantenimientos", ["estado"])
|
||||
op.create_index("ix_mantenimientos_fecha_programada", "mantenimientos", ["fecha_programada"])
|
||||
|
||||
# ========================================================================
|
||||
# Tabla: camaras
|
||||
# ========================================================================
|
||||
|
||||
op.create_table(
|
||||
"camaras",
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("identificador", sa.String(100), nullable=False),
|
||||
sa.Column("nombre", sa.String(100), nullable=True),
|
||||
sa.Column("vehiculo_id", sa.Integer(), nullable=True),
|
||||
sa.Column("tipo", sa.String(50), nullable=True),
|
||||
sa.Column("posicion", sa.String(50), nullable=True),
|
||||
sa.Column("resolucion", sa.String(20), nullable=True),
|
||||
sa.Column("url_stream", sa.String(500), nullable=True),
|
||||
sa.Column("url_snapshot", sa.String(500), nullable=True),
|
||||
sa.Column("activa", sa.Boolean(), nullable=False, server_default="true"),
|
||||
sa.Column("grabando", sa.Boolean(), nullable=False, server_default="false"),
|
||||
sa.Column("configuracion", postgresql.JSONB(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("identificador"),
|
||||
sa.ForeignKeyConstraint(["vehiculo_id"], ["vehiculos.id"], ondelete="SET NULL"),
|
||||
)
|
||||
op.create_index("ix_camaras_vehiculo_id", "camaras", ["vehiculo_id"])
|
||||
|
||||
# ========================================================================
|
||||
# Tabla: grabaciones
|
||||
# ========================================================================
|
||||
|
||||
op.create_table(
|
||||
"grabaciones",
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("camara_id", sa.Integer(), nullable=False),
|
||||
sa.Column("vehiculo_id", sa.Integer(), nullable=True),
|
||||
sa.Column("inicio", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("fin", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("duracion_segundos", sa.Integer(), nullable=True),
|
||||
sa.Column("tamano_bytes", sa.BigInteger(), nullable=True),
|
||||
sa.Column("archivo_url", sa.String(500), nullable=True),
|
||||
sa.Column("thumbnail_url", sa.String(500), nullable=True),
|
||||
sa.Column("estado", sa.String(20), nullable=False, server_default="grabando"),
|
||||
sa.Column("tipo", sa.String(50), nullable=True),
|
||||
sa.Column("metadata", postgresql.JSONB(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.ForeignKeyConstraint(["camara_id"], ["camaras.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["vehiculo_id"], ["vehiculos.id"], ondelete="SET NULL"),
|
||||
)
|
||||
op.create_index("ix_grabaciones_camara_id", "grabaciones", ["camara_id"])
|
||||
op.create_index("ix_grabaciones_inicio", "grabaciones", ["inicio"])
|
||||
|
||||
# ========================================================================
|
||||
# Tabla: eventos_video
|
||||
# ========================================================================
|
||||
|
||||
op.create_table(
|
||||
"eventos_video",
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("grabacion_id", sa.Integer(), nullable=True),
|
||||
sa.Column("camara_id", sa.Integer(), nullable=False),
|
||||
sa.Column("vehiculo_id", sa.Integer(), nullable=True),
|
||||
sa.Column("tiempo", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("tipo", sa.String(50), nullable=False),
|
||||
sa.Column("descripcion", sa.Text(), nullable=True),
|
||||
sa.Column("latitud", sa.Float(), nullable=True),
|
||||
sa.Column("longitud", sa.Float(), nullable=True),
|
||||
sa.Column("snapshot_url", sa.String(500), nullable=True),
|
||||
sa.Column("clip_url", sa.String(500), nullable=True),
|
||||
sa.Column("duracion_clip_segundos", sa.Integer(), nullable=True),
|
||||
sa.Column("confianza", sa.Float(), nullable=True),
|
||||
sa.Column("metadata", postgresql.JSONB(), nullable=True),
|
||||
sa.Column("revisado", sa.Boolean(), nullable=False, server_default="false"),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.ForeignKeyConstraint(["grabacion_id"], ["grabaciones.id"], ondelete="SET NULL"),
|
||||
sa.ForeignKeyConstraint(["camara_id"], ["camaras.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["vehiculo_id"], ["vehiculos.id"], ondelete="SET NULL"),
|
||||
)
|
||||
op.create_index("ix_eventos_video_camara_id", "eventos_video", ["camara_id"])
|
||||
op.create_index("ix_eventos_video_tiempo", "eventos_video", ["tiempo"])
|
||||
op.create_index("ix_eventos_video_tipo", "eventos_video", ["tipo"])
|
||||
|
||||
# ========================================================================
|
||||
# Tabla: mensajes
|
||||
# ========================================================================
|
||||
|
||||
op.create_table(
|
||||
"mensajes",
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("remitente_usuario_id", sa.Integer(), nullable=True),
|
||||
sa.Column("remitente_conductor_id", sa.Integer(), nullable=True),
|
||||
sa.Column("destinatario_usuario_id", sa.Integer(), nullable=True),
|
||||
sa.Column("destinatario_conductor_id", sa.Integer(), nullable=True),
|
||||
sa.Column("destinatario_vehiculo_id", sa.Integer(), nullable=True),
|
||||
sa.Column("asunto", sa.String(200), nullable=True),
|
||||
sa.Column("contenido", sa.Text(), nullable=False),
|
||||
sa.Column("tipo", sa.String(50), nullable=False, server_default="texto"),
|
||||
sa.Column("prioridad", prioridad_mensaje_enum, nullable=False, server_default="normal"),
|
||||
sa.Column("leido", sa.Boolean(), nullable=False, server_default="false"),
|
||||
sa.Column("leido_tiempo", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("respuesta_a_id", sa.Integer(), nullable=True),
|
||||
sa.Column("adjuntos", postgresql.JSONB(), nullable=True),
|
||||
sa.Column("metadata", postgresql.JSONB(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.ForeignKeyConstraint(["remitente_usuario_id"], ["usuarios.id"], ondelete="SET NULL"),
|
||||
sa.ForeignKeyConstraint(["remitente_conductor_id"], ["conductores.id"], ondelete="SET NULL"),
|
||||
sa.ForeignKeyConstraint(["destinatario_usuario_id"], ["usuarios.id"], ondelete="SET NULL"),
|
||||
sa.ForeignKeyConstraint(["destinatario_conductor_id"], ["conductores.id"], ondelete="SET NULL"),
|
||||
sa.ForeignKeyConstraint(["destinatario_vehiculo_id"], ["vehiculos.id"], ondelete="SET NULL"),
|
||||
sa.ForeignKeyConstraint(["respuesta_a_id"], ["mensajes.id"], ondelete="SET NULL"),
|
||||
)
|
||||
op.create_index("ix_mensajes_destinatario_usuario_id", "mensajes", ["destinatario_usuario_id"])
|
||||
op.create_index("ix_mensajes_destinatario_conductor_id", "mensajes", ["destinatario_conductor_id"])
|
||||
op.create_index("ix_mensajes_leido", "mensajes", ["leido"])
|
||||
|
||||
# ========================================================================
|
||||
# Tabla: configuracion
|
||||
# ========================================================================
|
||||
|
||||
op.create_table(
|
||||
"configuracion",
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("clave", sa.String(100), nullable=False),
|
||||
sa.Column("valor", sa.Text(), nullable=True),
|
||||
sa.Column("tipo", sa.String(20), nullable=False, server_default="string"),
|
||||
sa.Column("categoria", sa.String(50), nullable=True),
|
||||
sa.Column("descripcion", sa.Text(), nullable=True),
|
||||
sa.Column("editable", sa.Boolean(), nullable=False, server_default="true"),
|
||||
sa.Column("usuario_id", sa.Integer(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("clave", "usuario_id", name="uq_configuracion_clave_usuario"),
|
||||
sa.ForeignKeyConstraint(["usuario_id"], ["usuarios.id"], ondelete="CASCADE"),
|
||||
)
|
||||
op.create_index("ix_configuracion_clave", "configuracion", ["clave"])
|
||||
op.create_index("ix_configuracion_categoria", "configuracion", ["categoria"])
|
||||
|
||||
# ========================================================================
|
||||
# Datos iniciales: Tipos de alerta predefinidos
|
||||
# ========================================================================
|
||||
|
||||
op.execute("""
|
||||
INSERT INTO tipos_alerta (codigo, nombre, descripcion, severidad, icono, color, requiere_atencion)
|
||||
VALUES
|
||||
('EXCESO_VELOCIDAD', 'Exceso de velocidad', 'El vehículo superó el límite de velocidad configurado', 'alta', 'speed', '#EF4444', true),
|
||||
('ENTRADA_GEOCERCA', 'Entrada a geocerca', 'El vehículo entró a una zona delimitada', 'media', 'map-pin', '#3B82F6', false),
|
||||
('SALIDA_GEOCERCA', 'Salida de geocerca', 'El vehículo salió de una zona delimitada', 'media', 'map-pin', '#F59E0B', false),
|
||||
('BATERIA_BAJA', 'Batería baja', 'La batería del dispositivo está por debajo del nivel crítico', 'alta', 'battery-low', '#EF4444', true),
|
||||
('SIN_SENAL', 'Sin señal GPS', 'El dispositivo perdió señal GPS por tiempo prolongado', 'alta', 'signal-off', '#EF4444', true),
|
||||
('MOTOR_ENCENDIDO', 'Motor encendido', 'El motor del vehículo fue encendido', 'baja', 'power', '#22C55E', false),
|
||||
('MOTOR_APAGADO', 'Motor apagado', 'El motor del vehículo fue apagado', 'baja', 'power-off', '#6B7280', false),
|
||||
('PARADA_PROLONGADA', 'Parada prolongada', 'El vehículo lleva detenido más tiempo del permitido', 'media', 'clock', '#F59E0B', true),
|
||||
('ACELERACION_BRUSCA', 'Aceleración brusca', 'Se detectó una aceleración brusca', 'media', 'trending-up', '#F59E0B', false),
|
||||
('FRENADO_BRUSCO', 'Frenado brusco', 'Se detectó un frenado brusco', 'media', 'trending-down', '#F59E0B', false),
|
||||
('MANTENIMIENTO_PROXIMO', 'Mantenimiento próximo', 'El vehículo requiere mantenimiento pronto', 'media', 'tool', '#3B82F6', true),
|
||||
('LICENCIA_POR_VENCER', 'Licencia por vencer', 'La licencia del conductor está próxima a vencer', 'alta', 'id-card', '#EF4444', true),
|
||||
('COLISION_DETECTADA', 'Colisión detectada', 'Se detectó una posible colisión', 'critica', 'alert-triangle', '#DC2626', true),
|
||||
('SOS', 'Botón de pánico', 'El conductor presionó el botón de emergencia', 'critica', 'alert-circle', '#DC2626', true)
|
||||
ON CONFLICT DO NOTHING;
|
||||
""")
|
||||
|
||||
# ========================================================================
|
||||
# Datos iniciales: Tipos de mantenimiento predefinidos
|
||||
# ========================================================================
|
||||
|
||||
op.execute("""
|
||||
INSERT INTO tipos_mantenimiento (codigo, nombre, descripcion, categoria, intervalo_km, intervalo_dias)
|
||||
VALUES
|
||||
('CAMBIO_ACEITE', 'Cambio de aceite', 'Cambio de aceite y filtro de motor', 'Motor', 5000, 90),
|
||||
('CAMBIO_FILTRO_AIRE', 'Cambio de filtro de aire', 'Reemplazo del filtro de aire del motor', 'Motor', 15000, 365),
|
||||
('CAMBIO_FILTRO_COMBUSTIBLE', 'Cambio de filtro de combustible', 'Reemplazo del filtro de combustible', 'Motor', 20000, 365),
|
||||
('CAMBIO_BUJIAS', 'Cambio de bujías', 'Reemplazo de bujías de encendido', 'Motor', 40000, NULL),
|
||||
('ROTACION_LLANTAS', 'Rotación de llantas', 'Rotación de llantas para desgaste uniforme', 'Llantas', 10000, 180),
|
||||
('ALINEACION_BALANCEO', 'Alineación y balanceo', 'Alineación y balanceo de llantas', 'Llantas', 20000, 365),
|
||||
('CAMBIO_LLANTAS', 'Cambio de llantas', 'Reemplazo de llantas desgastadas', 'Llantas', 50000, NULL),
|
||||
('REVISION_FRENOS', 'Revisión de frenos', 'Inspección del sistema de frenos', 'Frenos', 20000, 180),
|
||||
('CAMBIO_BALATAS', 'Cambio de balatas', 'Reemplazo de balatas/pastillas de freno', 'Frenos', 40000, NULL),
|
||||
('CAMBIO_DISCOS', 'Cambio de discos', 'Reemplazo de discos de freno', 'Frenos', 80000, NULL),
|
||||
('REVISION_SUSPENSION', 'Revisión de suspensión', 'Inspección del sistema de suspensión', 'Suspensión', 30000, 365),
|
||||
('CAMBIO_AMORTIGUADORES', 'Cambio de amortiguadores', 'Reemplazo de amortiguadores', 'Suspensión', 80000, NULL),
|
||||
('REVISION_TRANSMISION', 'Revisión de transmisión', 'Inspección de la transmisión', 'Transmisión', 50000, 365),
|
||||
('CAMBIO_LIQUIDO_TRANSMISION', 'Cambio de líquido de transmisión', 'Reemplazo del líquido de transmisión', 'Transmisión', 60000, NULL),
|
||||
('REVISION_BATERIA', 'Revisión de batería', 'Inspección y prueba de batería', 'Eléctrico', NULL, 180),
|
||||
('CAMBIO_BATERIA', 'Cambio de batería', 'Reemplazo de batería', 'Eléctrico', NULL, 730),
|
||||
('REVISION_LUCES', 'Revisión de luces', 'Inspección del sistema de iluminación', 'Eléctrico', NULL, 90),
|
||||
('REVISION_AC', 'Revisión de A/C', 'Inspección del sistema de aire acondicionado', 'Climatización', NULL, 365),
|
||||
('RECARGA_AC', 'Recarga de A/C', 'Recarga de gas refrigerante del A/C', 'Climatización', NULL, 730),
|
||||
('VERIFICACION_VEHICULAR', 'Verificación vehicular', 'Verificación de emisiones contaminantes', 'Legal', NULL, 180),
|
||||
('REVISION_GENERAL', 'Revisión general', 'Inspección general del vehículo', 'General', 10000, 90)
|
||||
ON CONFLICT DO NOTHING;
|
||||
""")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Drop tables in reverse order
|
||||
op.drop_table("configuracion")
|
||||
op.drop_table("mensajes")
|
||||
op.drop_table("eventos_video")
|
||||
op.drop_table("grabaciones")
|
||||
op.drop_table("camaras")
|
||||
op.drop_table("mantenimientos")
|
||||
op.drop_table("tipos_mantenimiento")
|
||||
op.drop_table("cargas_combustible")
|
||||
op.drop_table("pois")
|
||||
op.drop_table("geocercas_vehiculos")
|
||||
op.drop_table("geocercas")
|
||||
op.drop_table("alertas")
|
||||
op.drop_table("tipos_alerta")
|
||||
op.drop_table("paradas")
|
||||
op.drop_table("viajes")
|
||||
op.drop_table("ubicaciones")
|
||||
op.drop_table("dispositivos")
|
||||
op.drop_table("vehiculos")
|
||||
op.drop_table("conductores")
|
||||
op.drop_table("grupos_vehiculos")
|
||||
op.drop_table("usuarios")
|
||||
|
||||
# Drop ENUMs
|
||||
op.execute("DROP TYPE IF EXISTS prioridad_mensaje;")
|
||||
op.execute("DROP TYPE IF EXISTS severidad_alerta;")
|
||||
op.execute("DROP TYPE IF EXISTS tipo_geocerca;")
|
||||
op.execute("DROP TYPE IF EXISTS estado_dispositivo;")
|
||||
op.execute("DROP TYPE IF EXISTS tipo_dispositivo;")
|
||||
op.execute("DROP TYPE IF EXISTS tipo_conductor;")
|
||||
op.execute("DROP TYPE IF EXISTS estado_vehiculo;")
|
||||
op.execute("DROP TYPE IF EXISTS tipo_vehiculo;")
|
||||
|
||||
# Drop extensions
|
||||
op.execute("DROP EXTENSION IF EXISTS postgis;")
|
||||
op.execute('DROP EXTENSION IF EXISTS "uuid-ossp";')
|
||||
# Note: TimescaleDB extension usually can't be dropped easily
|
||||
7
backend/app/__init__.py
Normal file
7
backend/app/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
||||
"""
|
||||
Adan Fleet Monitor Backend.
|
||||
|
||||
Sistema de monitoreo de flotillas GPS.
|
||||
"""
|
||||
|
||||
__version__ = "1.0.0"
|
||||
7
backend/app/api/v1/__init__.py
Normal file
7
backend/app/api/v1/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
||||
"""
|
||||
API v1 - Endpoints REST.
|
||||
"""
|
||||
|
||||
from app.api.v1.router import api_router
|
||||
|
||||
__all__ = ["api_router"]
|
||||
402
backend/app/api/v1/alertas.py
Normal file
402
backend/app/api/v1/alertas.py
Normal file
@@ -0,0 +1,402 @@
|
||||
"""
|
||||
Endpoints para gestión de alertas.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.security import get_current_user
|
||||
from app.models.usuario import Usuario
|
||||
from app.models.alerta import Alerta
|
||||
from app.models.tipo_alerta import TipoAlerta
|
||||
from app.schemas.alerta import (
|
||||
TipoAlertaCreate,
|
||||
TipoAlertaUpdate,
|
||||
TipoAlertaResponse,
|
||||
AlertaCreate,
|
||||
AlertaResponse,
|
||||
AlertaConRelaciones,
|
||||
AlertaResumen,
|
||||
AlertasEstadisticas,
|
||||
AlertaAtenderRequest,
|
||||
)
|
||||
from app.services.alerta_service import AlertaService
|
||||
|
||||
router = APIRouter(prefix="/alertas", tags=["Alertas"])
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Tipos de Alerta
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@router.get("/tipos", response_model=List[TipoAlertaResponse])
|
||||
async def listar_tipos_alerta(
|
||||
activo: Optional[bool] = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: Usuario = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Lista todos los tipos de alerta.
|
||||
|
||||
Args:
|
||||
activo: Filtrar por estado activo.
|
||||
|
||||
Returns:
|
||||
Lista de tipos de alerta.
|
||||
"""
|
||||
query = select(TipoAlerta).order_by(TipoAlerta.prioridad)
|
||||
|
||||
if activo is not None:
|
||||
query = query.where(TipoAlerta.activo == activo)
|
||||
|
||||
result = await db.execute(query)
|
||||
tipos = result.scalars().all()
|
||||
|
||||
return [TipoAlertaResponse.model_validate(t) for t in tipos]
|
||||
|
||||
|
||||
@router.post("/tipos", response_model=TipoAlertaResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def crear_tipo_alerta(
|
||||
tipo_data: TipoAlertaCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: Usuario = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Crea un nuevo tipo de alerta.
|
||||
|
||||
Args:
|
||||
tipo_data: Datos del tipo de alerta.
|
||||
|
||||
Returns:
|
||||
Tipo de alerta creado.
|
||||
"""
|
||||
# Verificar código único
|
||||
result = await db.execute(
|
||||
select(TipoAlerta).where(TipoAlerta.codigo == tipo_data.codigo)
|
||||
)
|
||||
if result.scalar_one_or_none():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"Ya existe un tipo de alerta con el código {tipo_data.codigo}",
|
||||
)
|
||||
|
||||
tipo = TipoAlerta(**tipo_data.model_dump())
|
||||
db.add(tipo)
|
||||
await db.commit()
|
||||
await db.refresh(tipo)
|
||||
|
||||
return TipoAlertaResponse.model_validate(tipo)
|
||||
|
||||
|
||||
@router.put("/tipos/{tipo_id}", response_model=TipoAlertaResponse)
|
||||
async def actualizar_tipo_alerta(
|
||||
tipo_id: int,
|
||||
tipo_data: TipoAlertaUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: Usuario = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Actualiza un tipo de alerta.
|
||||
|
||||
Args:
|
||||
tipo_id: ID del tipo.
|
||||
tipo_data: Datos a actualizar.
|
||||
|
||||
Returns:
|
||||
Tipo actualizado.
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(TipoAlerta).where(TipoAlerta.id == tipo_id)
|
||||
)
|
||||
tipo = result.scalar_one_or_none()
|
||||
|
||||
if not tipo:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Tipo de alerta con id {tipo_id} no encontrado",
|
||||
)
|
||||
|
||||
update_data = tipo_data.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(tipo, field, value)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(tipo)
|
||||
|
||||
return TipoAlertaResponse.model_validate(tipo)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Alertas
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@router.get("", response_model=List[AlertaResumen])
|
||||
async def listar_alertas(
|
||||
vehiculo_id: Optional[int] = None,
|
||||
tipo_alerta_id: Optional[int] = None,
|
||||
severidad: Optional[str] = None,
|
||||
atendida: Optional[bool] = None,
|
||||
desde: Optional[datetime] = None,
|
||||
hasta: Optional[datetime] = None,
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=100),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: Usuario = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Lista alertas con filtros opcionales.
|
||||
|
||||
Args:
|
||||
vehiculo_id: Filtrar por vehículo.
|
||||
tipo_alerta_id: Filtrar por tipo.
|
||||
severidad: Filtrar por severidad.
|
||||
atendida: Filtrar por estado de atención.
|
||||
desde: Fecha inicio.
|
||||
hasta: Fecha fin.
|
||||
skip: Registros a saltar.
|
||||
limit: Límite de registros.
|
||||
|
||||
Returns:
|
||||
Lista de alertas.
|
||||
"""
|
||||
query = (
|
||||
select(Alerta)
|
||||
.options(selectinload(Alerta.tipo_alerta))
|
||||
.order_by(Alerta.creado_en.desc())
|
||||
)
|
||||
|
||||
if vehiculo_id:
|
||||
query = query.where(Alerta.vehiculo_id == vehiculo_id)
|
||||
if tipo_alerta_id:
|
||||
query = query.where(Alerta.tipo_alerta_id == tipo_alerta_id)
|
||||
if severidad:
|
||||
query = query.where(Alerta.severidad == severidad)
|
||||
if atendida is not None:
|
||||
query = query.where(Alerta.atendida == atendida)
|
||||
if desde:
|
||||
query = query.where(Alerta.creado_en >= desde)
|
||||
if hasta:
|
||||
query = query.where(Alerta.creado_en <= hasta)
|
||||
|
||||
query = query.offset(skip).limit(limit)
|
||||
|
||||
result = await db.execute(query)
|
||||
alertas = result.scalars().all()
|
||||
|
||||
return [
|
||||
AlertaResumen(
|
||||
id=a.id,
|
||||
tipo_codigo=a.tipo_alerta.codigo if a.tipo_alerta else "",
|
||||
tipo_nombre=a.tipo_alerta.nombre if a.tipo_alerta else "",
|
||||
severidad=a.severidad,
|
||||
mensaje=a.mensaje,
|
||||
vehiculo_nombre=a.vehiculo.nombre if a.vehiculo else None,
|
||||
vehiculo_placa=a.vehiculo.placa if a.vehiculo else None,
|
||||
creado_en=a.creado_en,
|
||||
atendida=a.atendida,
|
||||
)
|
||||
for a in alertas
|
||||
]
|
||||
|
||||
|
||||
@router.get("/pendientes", response_model=List[AlertaResumen])
|
||||
async def listar_alertas_pendientes(
|
||||
severidad: Optional[str] = None,
|
||||
limit: int = Query(50, ge=1, le=100),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: Usuario = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Lista alertas pendientes de atender.
|
||||
|
||||
Args:
|
||||
severidad: Filtrar por severidad.
|
||||
limit: Límite de resultados.
|
||||
|
||||
Returns:
|
||||
Lista de alertas pendientes.
|
||||
"""
|
||||
alerta_service = AlertaService(db)
|
||||
alertas = await alerta_service.obtener_alertas_pendientes(
|
||||
severidad=severidad,
|
||||
limite=limit,
|
||||
)
|
||||
|
||||
# Cargar relaciones
|
||||
for a in alertas:
|
||||
await db.refresh(a, ["tipo_alerta", "vehiculo"])
|
||||
|
||||
return [
|
||||
AlertaResumen(
|
||||
id=a.id,
|
||||
tipo_codigo=a.tipo_alerta.codigo if a.tipo_alerta else "",
|
||||
tipo_nombre=a.tipo_alerta.nombre if a.tipo_alerta else "",
|
||||
severidad=a.severidad,
|
||||
mensaje=a.mensaje,
|
||||
vehiculo_nombre=a.vehiculo.nombre if a.vehiculo else None,
|
||||
vehiculo_placa=a.vehiculo.placa if a.vehiculo else None,
|
||||
creado_en=a.creado_en,
|
||||
atendida=a.atendida,
|
||||
)
|
||||
for a in alertas
|
||||
]
|
||||
|
||||
|
||||
@router.get("/estadisticas", response_model=AlertasEstadisticas)
|
||||
async def obtener_estadisticas_alertas(
|
||||
desde: Optional[datetime] = None,
|
||||
hasta: Optional[datetime] = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: Usuario = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Obtiene estadísticas de alertas.
|
||||
|
||||
Args:
|
||||
desde: Fecha inicio (opcional).
|
||||
hasta: Fecha fin (opcional).
|
||||
|
||||
Returns:
|
||||
Estadísticas de alertas.
|
||||
"""
|
||||
alerta_service = AlertaService(db)
|
||||
stats = await alerta_service.obtener_estadisticas(desde, hasta)
|
||||
|
||||
return AlertasEstadisticas(
|
||||
total=stats["total"],
|
||||
pendientes=stats["pendientes"],
|
||||
atendidas=stats["atendidas"],
|
||||
criticas=stats["criticas"],
|
||||
altas=stats["altas"],
|
||||
medias=stats["medias"],
|
||||
bajas=stats["bajas"],
|
||||
por_tipo=stats["por_tipo"],
|
||||
por_vehiculo=[], # TODO: Agregar en servicio
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{alerta_id}", response_model=AlertaConRelaciones)
|
||||
async def obtener_alerta(
|
||||
alerta_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: Usuario = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Obtiene una alerta por su ID.
|
||||
|
||||
Args:
|
||||
alerta_id: ID de la alerta.
|
||||
|
||||
Returns:
|
||||
Alerta con relaciones.
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(Alerta)
|
||||
.options(
|
||||
selectinload(Alerta.tipo_alerta),
|
||||
selectinload(Alerta.vehiculo),
|
||||
selectinload(Alerta.conductor),
|
||||
)
|
||||
.where(Alerta.id == alerta_id)
|
||||
)
|
||||
alerta = result.scalar_one_or_none()
|
||||
|
||||
if not alerta:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Alerta con id {alerta_id} no encontrada",
|
||||
)
|
||||
|
||||
return AlertaConRelaciones(
|
||||
id=alerta.id,
|
||||
tipo_alerta_id=alerta.tipo_alerta_id,
|
||||
severidad=alerta.severidad,
|
||||
mensaje=alerta.mensaje,
|
||||
descripcion=alerta.descripcion,
|
||||
vehiculo_id=alerta.vehiculo_id,
|
||||
conductor_id=alerta.conductor_id,
|
||||
dispositivo_id=alerta.dispositivo_id,
|
||||
lat=alerta.lat,
|
||||
lng=alerta.lng,
|
||||
direccion=alerta.direccion,
|
||||
velocidad=alerta.velocidad,
|
||||
valor=alerta.valor,
|
||||
umbral=alerta.umbral,
|
||||
datos_extra=alerta.datos_extra,
|
||||
atendida=alerta.atendida,
|
||||
atendida_por_id=alerta.atendida_por_id,
|
||||
atendida_en=alerta.atendida_en,
|
||||
notas_atencion=alerta.notas_atencion,
|
||||
notificacion_email_enviada=alerta.notificacion_email_enviada,
|
||||
notificacion_push_enviada=alerta.notificacion_push_enviada,
|
||||
notificacion_sms_enviada=alerta.notificacion_sms_enviada,
|
||||
creado_en=alerta.creado_en,
|
||||
actualizado_en=alerta.actualizado_en,
|
||||
es_critica=alerta.es_critica,
|
||||
tipo_alerta=TipoAlertaResponse.model_validate(alerta.tipo_alerta),
|
||||
vehiculo_nombre=alerta.vehiculo.nombre if alerta.vehiculo else None,
|
||||
vehiculo_placa=alerta.vehiculo.placa if alerta.vehiculo else None,
|
||||
conductor_nombre=alerta.conductor.nombre_completo if alerta.conductor else None,
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=AlertaResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def crear_alerta(
|
||||
alerta_data: AlertaCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: Usuario = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Crea una alerta manualmente.
|
||||
|
||||
Args:
|
||||
alerta_data: Datos de la alerta.
|
||||
|
||||
Returns:
|
||||
Alerta creada.
|
||||
"""
|
||||
alerta_service = AlertaService(db)
|
||||
alerta = await alerta_service.crear_alerta(alerta_data)
|
||||
|
||||
return AlertaResponse.model_validate(alerta)
|
||||
|
||||
|
||||
@router.put("/{alerta_id}/atender")
|
||||
async def atender_alerta(
|
||||
alerta_id: int,
|
||||
request: AlertaAtenderRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: Usuario = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Marca una alerta como atendida.
|
||||
|
||||
Args:
|
||||
alerta_id: ID de la alerta.
|
||||
request: Notas de atención.
|
||||
|
||||
Returns:
|
||||
Alerta actualizada.
|
||||
"""
|
||||
alerta_service = AlertaService(db)
|
||||
alerta = await alerta_service.marcar_atendida(
|
||||
alerta_id,
|
||||
current_user.id,
|
||||
request.notas_atencion,
|
||||
)
|
||||
|
||||
if not alerta:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Alerta con id {alerta_id} no encontrada",
|
||||
)
|
||||
|
||||
return {"message": "Alerta marcada como atendida", "alerta_id": alerta_id}
|
||||
274
backend/app/api/v1/auth.py
Normal file
274
backend/app/api/v1/auth.py
Normal file
@@ -0,0 +1,274 @@
|
||||
"""
|
||||
Endpoints de autenticación.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.security import (
|
||||
create_access_token,
|
||||
create_refresh_token,
|
||||
decode_token,
|
||||
hash_password,
|
||||
verify_password,
|
||||
verify_token_type,
|
||||
get_current_user,
|
||||
)
|
||||
from app.core.config import settings
|
||||
from app.models.usuario import Usuario
|
||||
from app.schemas.usuario import (
|
||||
LoginRequest,
|
||||
LoginResponse,
|
||||
RefreshTokenRequest,
|
||||
TokenResponse,
|
||||
UsuarioCreate,
|
||||
UsuarioResponse,
|
||||
UsuarioUpdate,
|
||||
UsuarioUpdatePassword,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["Autenticacion"])
|
||||
|
||||
|
||||
@router.post("/login", response_model=LoginResponse)
|
||||
async def login(
|
||||
request: LoginRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Autentica un usuario y devuelve tokens JWT.
|
||||
|
||||
Args:
|
||||
request: Credenciales de login.
|
||||
db: Sesión de base de datos.
|
||||
|
||||
Returns:
|
||||
Tokens de acceso y refresco.
|
||||
"""
|
||||
# Buscar usuario por email
|
||||
result = await db.execute(
|
||||
select(Usuario).where(Usuario.email == request.email)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Email o contraseña incorrectos",
|
||||
)
|
||||
|
||||
if not verify_password(request.password, user.password_hash):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Email o contraseña incorrectos",
|
||||
)
|
||||
|
||||
if not user.activo:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Usuario desactivado",
|
||||
)
|
||||
|
||||
# Actualizar último acceso
|
||||
user.ultimo_acceso = datetime.now(timezone.utc)
|
||||
await db.commit()
|
||||
|
||||
# Generar tokens
|
||||
token_data = {"sub": str(user.id), "email": user.email}
|
||||
access_token = create_access_token(token_data)
|
||||
refresh_token = create_refresh_token(token_data)
|
||||
|
||||
return LoginResponse(
|
||||
access_token=access_token,
|
||||
refresh_token=refresh_token,
|
||||
token_type="bearer",
|
||||
expires_in=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60,
|
||||
user=UsuarioResponse.model_validate(user),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/refresh", response_model=TokenResponse)
|
||||
async def refresh_token(
|
||||
request: RefreshTokenRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Renueva los tokens usando el refresh token.
|
||||
|
||||
Args:
|
||||
request: Token de refresco.
|
||||
db: Sesión de base de datos.
|
||||
|
||||
Returns:
|
||||
Nuevos tokens de acceso y refresco.
|
||||
"""
|
||||
# Decodificar y verificar el refresh token
|
||||
payload = decode_token(request.refresh_token)
|
||||
verify_token_type(payload, "refresh")
|
||||
|
||||
user_id = payload.get("sub")
|
||||
if not user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Token inválido",
|
||||
)
|
||||
|
||||
# Verificar que el usuario existe y está activo
|
||||
result = await db.execute(
|
||||
select(Usuario).where(Usuario.id == int(user_id))
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if not user or not user.activo:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Usuario no válido",
|
||||
)
|
||||
|
||||
# Generar nuevos tokens
|
||||
token_data = {"sub": str(user.id), "email": user.email}
|
||||
access_token = create_access_token(token_data)
|
||||
refresh_token = create_refresh_token(token_data)
|
||||
|
||||
return TokenResponse(
|
||||
access_token=access_token,
|
||||
refresh_token=refresh_token,
|
||||
token_type="bearer",
|
||||
expires_in=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
async def logout():
|
||||
"""
|
||||
Cierra la sesión del usuario.
|
||||
|
||||
En una implementación con blacklist de tokens, aquí se
|
||||
agregaría el token a la lista negra.
|
||||
|
||||
Returns:
|
||||
Mensaje de confirmación.
|
||||
"""
|
||||
# TODO: Implementar blacklist de tokens en Redis
|
||||
return {"message": "Sesión cerrada correctamente"}
|
||||
|
||||
|
||||
@router.post("/register", response_model=UsuarioResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def register(
|
||||
user_data: UsuarioCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Registra un nuevo usuario.
|
||||
|
||||
Args:
|
||||
user_data: Datos del usuario a crear.
|
||||
db: Sesión de base de datos.
|
||||
|
||||
Returns:
|
||||
Usuario creado.
|
||||
"""
|
||||
# Verificar si el email ya existe
|
||||
result = await db.execute(
|
||||
select(Usuario).where(Usuario.email == user_data.email)
|
||||
)
|
||||
if result.scalar_one_or_none():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="El email ya está registrado",
|
||||
)
|
||||
|
||||
# Crear usuario
|
||||
user = Usuario(
|
||||
email=user_data.email,
|
||||
password_hash=hash_password(user_data.password),
|
||||
nombre=user_data.nombre,
|
||||
apellido=user_data.apellido,
|
||||
telefono=user_data.telefono,
|
||||
es_admin=user_data.es_admin,
|
||||
)
|
||||
|
||||
db.add(user)
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
return UsuarioResponse.model_validate(user)
|
||||
|
||||
|
||||
@router.get("/me", response_model=UsuarioResponse)
|
||||
async def get_current_user_info(
|
||||
current_user: Usuario = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Obtiene información del usuario actual.
|
||||
|
||||
Args:
|
||||
current_user: Usuario autenticado.
|
||||
|
||||
Returns:
|
||||
Información del usuario.
|
||||
"""
|
||||
return UsuarioResponse.model_validate(current_user)
|
||||
|
||||
|
||||
@router.put("/me", response_model=UsuarioResponse)
|
||||
async def update_current_user(
|
||||
user_data: UsuarioUpdate,
|
||||
current_user: Usuario = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Actualiza información del usuario actual.
|
||||
|
||||
Args:
|
||||
user_data: Datos a actualizar.
|
||||
current_user: Usuario autenticado.
|
||||
db: Sesión de base de datos.
|
||||
|
||||
Returns:
|
||||
Usuario actualizado.
|
||||
"""
|
||||
# Actualizar solo campos proporcionados
|
||||
update_data = user_data.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(current_user, field, value)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(current_user)
|
||||
|
||||
return UsuarioResponse.model_validate(current_user)
|
||||
|
||||
|
||||
@router.put("/me/password")
|
||||
async def change_password(
|
||||
password_data: UsuarioUpdatePassword,
|
||||
current_user: Usuario = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Cambia la contraseña del usuario actual.
|
||||
|
||||
Args:
|
||||
password_data: Contraseñas actual y nueva.
|
||||
current_user: Usuario autenticado.
|
||||
db: Sesión de base de datos.
|
||||
|
||||
Returns:
|
||||
Mensaje de confirmación.
|
||||
"""
|
||||
# Verificar contraseña actual
|
||||
if not verify_password(password_data.password_actual, current_user.password_hash):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Contraseña actual incorrecta",
|
||||
)
|
||||
|
||||
# Actualizar contraseña
|
||||
current_user.password_hash = hash_password(password_data.password_nuevo)
|
||||
await db.commit()
|
||||
|
||||
return {"message": "Contraseña actualizada correctamente"}
|
||||
411
backend/app/api/v1/conductores.py
Normal file
411
backend/app/api/v1/conductores.py
Normal file
@@ -0,0 +1,411 @@
|
||||
"""
|
||||
Endpoints para gestión de conductores.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.security import get_current_user
|
||||
from app.models.usuario import Usuario
|
||||
from app.models.conductor import Conductor
|
||||
from app.models.viaje import Viaje
|
||||
from app.models.alerta import Alerta
|
||||
from app.schemas.conductor import (
|
||||
ConductorCreate,
|
||||
ConductorUpdate,
|
||||
ConductorResponse,
|
||||
ConductorResumen,
|
||||
ConductorEstadisticas,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/conductores", tags=["Conductores"])
|
||||
|
||||
|
||||
@router.get("", response_model=List[ConductorResumen])
|
||||
async def listar_conductores(
|
||||
activo: Optional[bool] = None,
|
||||
buscar: Optional[str] = None,
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=100),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: Usuario = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Lista todos los conductores con filtros opcionales.
|
||||
|
||||
Args:
|
||||
activo: Filtrar por estado activo.
|
||||
buscar: Búsqueda por nombre, apellido o licencia.
|
||||
skip: Registros a saltar.
|
||||
limit: Límite de registros.
|
||||
|
||||
Returns:
|
||||
Lista de conductores.
|
||||
"""
|
||||
query = select(Conductor)
|
||||
|
||||
if activo is not None:
|
||||
query = query.where(Conductor.activo == activo)
|
||||
if buscar:
|
||||
query = query.where(
|
||||
(Conductor.nombre.ilike(f"%{buscar}%")) |
|
||||
(Conductor.apellido.ilike(f"%{buscar}%")) |
|
||||
(Conductor.licencia_numero.ilike(f"%{buscar}%"))
|
||||
)
|
||||
|
||||
query = query.offset(skip).limit(limit).order_by(Conductor.nombre)
|
||||
|
||||
result = await db.execute(query)
|
||||
conductores = result.scalars().all()
|
||||
|
||||
return [
|
||||
ConductorResumen(
|
||||
id=c.id,
|
||||
nombre_completo=c.nombre_completo,
|
||||
telefono=c.telefono,
|
||||
licencia_vigente=c.licencia_vigente,
|
||||
activo=c.activo,
|
||||
)
|
||||
for c in conductores
|
||||
]
|
||||
|
||||
|
||||
@router.get("/{conductor_id}", response_model=ConductorResponse)
|
||||
async def obtener_conductor(
|
||||
conductor_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: Usuario = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Obtiene un conductor por su ID.
|
||||
|
||||
Args:
|
||||
conductor_id: ID del conductor.
|
||||
|
||||
Returns:
|
||||
Conductor encontrado.
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(Conductor).where(Conductor.id == conductor_id)
|
||||
)
|
||||
conductor = result.scalar_one_or_none()
|
||||
|
||||
if not conductor:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Conductor con id {conductor_id} no encontrado",
|
||||
)
|
||||
|
||||
return ConductorResponse(
|
||||
id=conductor.id,
|
||||
nombre=conductor.nombre,
|
||||
apellido=conductor.apellido,
|
||||
telefono=conductor.telefono,
|
||||
email=conductor.email,
|
||||
documento_tipo=conductor.documento_tipo,
|
||||
documento_numero=conductor.documento_numero,
|
||||
licencia_numero=conductor.licencia_numero,
|
||||
licencia_tipo=conductor.licencia_tipo,
|
||||
licencia_vencimiento=conductor.licencia_vencimiento,
|
||||
fecha_nacimiento=conductor.fecha_nacimiento,
|
||||
direccion=conductor.direccion,
|
||||
contacto_emergencia=conductor.contacto_emergencia,
|
||||
telefono_emergencia=conductor.telefono_emergencia,
|
||||
fecha_contratacion=conductor.fecha_contratacion,
|
||||
numero_empleado=conductor.numero_empleado,
|
||||
foto_url=conductor.foto_url,
|
||||
activo=conductor.activo,
|
||||
notas=conductor.notas,
|
||||
nombre_completo=conductor.nombre_completo,
|
||||
licencia_vigente=conductor.licencia_vigente,
|
||||
creado_en=conductor.creado_en,
|
||||
actualizado_en=conductor.actualizado_en,
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=ConductorResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def crear_conductor(
|
||||
conductor_data: ConductorCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: Usuario = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Crea un nuevo conductor.
|
||||
|
||||
Args:
|
||||
conductor_data: Datos del conductor.
|
||||
|
||||
Returns:
|
||||
Conductor creado.
|
||||
"""
|
||||
# Verificar licencia única si se proporciona
|
||||
if conductor_data.licencia_numero:
|
||||
result = await db.execute(
|
||||
select(Conductor).where(Conductor.licencia_numero == conductor_data.licencia_numero)
|
||||
)
|
||||
if result.scalar_one_or_none():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"Ya existe un conductor con la licencia {conductor_data.licencia_numero}",
|
||||
)
|
||||
|
||||
conductor = Conductor(**conductor_data.model_dump())
|
||||
|
||||
db.add(conductor)
|
||||
await db.commit()
|
||||
await db.refresh(conductor)
|
||||
|
||||
return ConductorResponse(
|
||||
id=conductor.id,
|
||||
nombre=conductor.nombre,
|
||||
apellido=conductor.apellido,
|
||||
telefono=conductor.telefono,
|
||||
email=conductor.email,
|
||||
documento_tipo=conductor.documento_tipo,
|
||||
documento_numero=conductor.documento_numero,
|
||||
licencia_numero=conductor.licencia_numero,
|
||||
licencia_tipo=conductor.licencia_tipo,
|
||||
licencia_vencimiento=conductor.licencia_vencimiento,
|
||||
fecha_nacimiento=conductor.fecha_nacimiento,
|
||||
direccion=conductor.direccion,
|
||||
contacto_emergencia=conductor.contacto_emergencia,
|
||||
telefono_emergencia=conductor.telefono_emergencia,
|
||||
fecha_contratacion=conductor.fecha_contratacion,
|
||||
numero_empleado=conductor.numero_empleado,
|
||||
foto_url=conductor.foto_url,
|
||||
activo=conductor.activo,
|
||||
notas=conductor.notas,
|
||||
nombre_completo=conductor.nombre_completo,
|
||||
licencia_vigente=conductor.licencia_vigente,
|
||||
creado_en=conductor.creado_en,
|
||||
actualizado_en=conductor.actualizado_en,
|
||||
)
|
||||
|
||||
|
||||
@router.put("/{conductor_id}", response_model=ConductorResponse)
|
||||
async def actualizar_conductor(
|
||||
conductor_id: int,
|
||||
conductor_data: ConductorUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: Usuario = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Actualiza un conductor existente.
|
||||
|
||||
Args:
|
||||
conductor_id: ID del conductor.
|
||||
conductor_data: Datos a actualizar.
|
||||
|
||||
Returns:
|
||||
Conductor actualizado.
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(Conductor).where(Conductor.id == conductor_id)
|
||||
)
|
||||
conductor = result.scalar_one_or_none()
|
||||
|
||||
if not conductor:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Conductor con id {conductor_id} no encontrado",
|
||||
)
|
||||
|
||||
# Verificar licencia única si se cambia
|
||||
if conductor_data.licencia_numero and conductor_data.licencia_numero != conductor.licencia_numero:
|
||||
result = await db.execute(
|
||||
select(Conductor).where(Conductor.licencia_numero == conductor_data.licencia_numero)
|
||||
)
|
||||
if result.scalar_one_or_none():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"Ya existe un conductor con la licencia {conductor_data.licencia_numero}",
|
||||
)
|
||||
|
||||
update_data = conductor_data.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(conductor, field, value)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(conductor)
|
||||
|
||||
return ConductorResponse(
|
||||
id=conductor.id,
|
||||
nombre=conductor.nombre,
|
||||
apellido=conductor.apellido,
|
||||
telefono=conductor.telefono,
|
||||
email=conductor.email,
|
||||
documento_tipo=conductor.documento_tipo,
|
||||
documento_numero=conductor.documento_numero,
|
||||
licencia_numero=conductor.licencia_numero,
|
||||
licencia_tipo=conductor.licencia_tipo,
|
||||
licencia_vencimiento=conductor.licencia_vencimiento,
|
||||
fecha_nacimiento=conductor.fecha_nacimiento,
|
||||
direccion=conductor.direccion,
|
||||
contacto_emergencia=conductor.contacto_emergencia,
|
||||
telefono_emergencia=conductor.telefono_emergencia,
|
||||
fecha_contratacion=conductor.fecha_contratacion,
|
||||
numero_empleado=conductor.numero_empleado,
|
||||
foto_url=conductor.foto_url,
|
||||
activo=conductor.activo,
|
||||
notas=conductor.notas,
|
||||
nombre_completo=conductor.nombre_completo,
|
||||
licencia_vigente=conductor.licencia_vigente,
|
||||
creado_en=conductor.creado_en,
|
||||
actualizado_en=conductor.actualizado_en,
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{conductor_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def eliminar_conductor(
|
||||
conductor_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: Usuario = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Elimina un conductor (soft delete - desactiva).
|
||||
|
||||
Args:
|
||||
conductor_id: ID del conductor.
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(Conductor).where(Conductor.id == conductor_id)
|
||||
)
|
||||
conductor = result.scalar_one_or_none()
|
||||
|
||||
if not conductor:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Conductor con id {conductor_id} no encontrado",
|
||||
)
|
||||
|
||||
conductor.activo = False
|
||||
await db.commit()
|
||||
|
||||
|
||||
@router.get("/{conductor_id}/estadisticas", response_model=ConductorEstadisticas)
|
||||
async def obtener_estadisticas_conductor(
|
||||
conductor_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: Usuario = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Obtiene estadísticas de un conductor.
|
||||
|
||||
Args:
|
||||
conductor_id: ID del conductor.
|
||||
|
||||
Returns:
|
||||
Estadísticas del conductor.
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(Conductor).where(Conductor.id == conductor_id)
|
||||
)
|
||||
conductor = result.scalar_one_or_none()
|
||||
|
||||
if not conductor:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Conductor con id {conductor_id} no encontrado",
|
||||
)
|
||||
|
||||
# Total de viajes
|
||||
result = await db.execute(
|
||||
select(func.count(Viaje.id))
|
||||
.where(Viaje.conductor_id == conductor_id)
|
||||
)
|
||||
total_viajes = result.scalar() or 0
|
||||
|
||||
# Distancia total
|
||||
result = await db.execute(
|
||||
select(func.coalesce(func.sum(Viaje.distancia_km), 0))
|
||||
.where(Viaje.conductor_id == conductor_id)
|
||||
)
|
||||
distancia_total = result.scalar() or 0
|
||||
|
||||
# Tiempo de conducción
|
||||
result = await db.execute(
|
||||
select(func.coalesce(func.sum(Viaje.tiempo_movimiento_segundos), 0))
|
||||
.where(Viaje.conductor_id == conductor_id)
|
||||
)
|
||||
tiempo_conduccion = result.scalar() or 0
|
||||
|
||||
# Velocidad promedio
|
||||
result = await db.execute(
|
||||
select(func.avg(Viaje.velocidad_promedio))
|
||||
.where(Viaje.conductor_id == conductor_id)
|
||||
.where(Viaje.velocidad_promedio.isnot(None))
|
||||
)
|
||||
velocidad_promedio = result.scalar() or 0
|
||||
|
||||
# Total de alertas
|
||||
result = await db.execute(
|
||||
select(func.count(Alerta.id))
|
||||
.where(Alerta.conductor_id == conductor_id)
|
||||
)
|
||||
alertas_total = result.scalar() or 0
|
||||
|
||||
# Alertas de velocidad
|
||||
from app.models.tipo_alerta import TipoAlerta
|
||||
result = await db.execute(
|
||||
select(func.count(Alerta.id))
|
||||
.join(TipoAlerta, Alerta.tipo_alerta_id == TipoAlerta.id)
|
||||
.where(Alerta.conductor_id == conductor_id)
|
||||
.where(TipoAlerta.codigo == "EXCESO_VELOCIDAD")
|
||||
)
|
||||
alertas_velocidad = result.scalar() or 0
|
||||
|
||||
return ConductorEstadisticas(
|
||||
conductor_id=conductor.id,
|
||||
nombre_completo=conductor.nombre_completo,
|
||||
total_viajes=total_viajes,
|
||||
distancia_total_km=float(distancia_total),
|
||||
tiempo_conduccion_horas=tiempo_conduccion / 3600,
|
||||
velocidad_promedio=float(velocidad_promedio) if velocidad_promedio else 0,
|
||||
alertas_total=alertas_total,
|
||||
alertas_velocidad=alertas_velocidad,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/licencias/por-vencer", response_model=List[ConductorResumen])
|
||||
async def obtener_licencias_por_vencer(
|
||||
dias: int = Query(30, ge=1, le=365),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: Usuario = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Obtiene conductores con licencias próximas a vencer.
|
||||
|
||||
Args:
|
||||
dias: Días para considerar como "próximo a vencer".
|
||||
|
||||
Returns:
|
||||
Lista de conductores con licencias por vencer.
|
||||
"""
|
||||
from datetime import date
|
||||
|
||||
fecha_limite = date.today() + timedelta(days=dias)
|
||||
|
||||
result = await db.execute(
|
||||
select(Conductor)
|
||||
.where(Conductor.activo == True)
|
||||
.where(Conductor.licencia_vencimiento.isnot(None))
|
||||
.where(Conductor.licencia_vencimiento <= fecha_limite)
|
||||
.order_by(Conductor.licencia_vencimiento)
|
||||
)
|
||||
conductores = result.scalars().all()
|
||||
|
||||
return [
|
||||
ConductorResumen(
|
||||
id=c.id,
|
||||
nombre_completo=c.nombre_completo,
|
||||
telefono=c.telefono,
|
||||
licencia_vigente=c.licencia_vigente,
|
||||
activo=c.activo,
|
||||
)
|
||||
for c in conductores
|
||||
]
|
||||
362
backend/app/api/v1/dispositivos.py
Normal file
362
backend/app/api/v1/dispositivos.py
Normal file
@@ -0,0 +1,362 @@
|
||||
"""
|
||||
Endpoints para gestión de dispositivos GPS.
|
||||
"""
|
||||
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.security import get_current_user
|
||||
from app.models.usuario import Usuario
|
||||
from app.models.dispositivo import Dispositivo
|
||||
from app.schemas.dispositivo import (
|
||||
DispositivoCreate,
|
||||
DispositivoUpdate,
|
||||
DispositivoResponse,
|
||||
DispositivoResumen,
|
||||
DispositivoConVehiculo,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/dispositivos", tags=["Dispositivos"])
|
||||
|
||||
|
||||
@router.get("", response_model=List[DispositivoResumen])
|
||||
async def listar_dispositivos(
|
||||
vehiculo_id: Optional[int] = None,
|
||||
tipo: Optional[str] = None,
|
||||
activo: Optional[bool] = None,
|
||||
conectado: Optional[bool] = None,
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=100),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: Usuario = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Lista dispositivos con filtros opcionales.
|
||||
|
||||
Args:
|
||||
vehiculo_id: Filtrar por vehículo.
|
||||
tipo: Filtrar por tipo.
|
||||
activo: Filtrar por estado activo.
|
||||
conectado: Filtrar por estado de conexión.
|
||||
skip: Registros a saltar.
|
||||
limit: Límite de registros.
|
||||
|
||||
Returns:
|
||||
Lista de dispositivos.
|
||||
"""
|
||||
query = select(Dispositivo).order_by(Dispositivo.identificador)
|
||||
|
||||
if vehiculo_id:
|
||||
query = query.where(Dispositivo.vehiculo_id == vehiculo_id)
|
||||
if tipo:
|
||||
query = query.where(Dispositivo.tipo == tipo)
|
||||
if activo is not None:
|
||||
query = query.where(Dispositivo.activo == activo)
|
||||
if conectado is not None:
|
||||
query = query.where(Dispositivo.conectado == conectado)
|
||||
|
||||
query = query.offset(skip).limit(limit)
|
||||
|
||||
result = await db.execute(query)
|
||||
dispositivos = result.scalars().all()
|
||||
|
||||
return [
|
||||
DispositivoResumen(
|
||||
id=d.id,
|
||||
identificador=d.identificador,
|
||||
tipo=d.tipo,
|
||||
protocolo=d.protocolo,
|
||||
activo=d.activo,
|
||||
conectado=d.conectado,
|
||||
ultimo_contacto=d.ultimo_contacto,
|
||||
bateria=d.bateria,
|
||||
)
|
||||
for d in dispositivos
|
||||
]
|
||||
|
||||
|
||||
@router.get("/{dispositivo_id}", response_model=DispositivoConVehiculo)
|
||||
async def obtener_dispositivo(
|
||||
dispositivo_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: Usuario = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Obtiene un dispositivo por su ID.
|
||||
|
||||
Args:
|
||||
dispositivo_id: ID del dispositivo.
|
||||
|
||||
Returns:
|
||||
Dispositivo con información del vehículo.
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(Dispositivo)
|
||||
.options(selectinload(Dispositivo.vehiculo))
|
||||
.where(Dispositivo.id == dispositivo_id)
|
||||
)
|
||||
dispositivo = result.scalar_one_or_none()
|
||||
|
||||
if not dispositivo:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Dispositivo con id {dispositivo_id} no encontrado",
|
||||
)
|
||||
|
||||
return DispositivoConVehiculo(
|
||||
id=dispositivo.id,
|
||||
vehiculo_id=dispositivo.vehiculo_id,
|
||||
tipo=dispositivo.tipo,
|
||||
identificador=dispositivo.identificador,
|
||||
nombre=dispositivo.nombre,
|
||||
marca=dispositivo.marca,
|
||||
modelo=dispositivo.modelo,
|
||||
numero_serie=dispositivo.numero_serie,
|
||||
telefono_sim=dispositivo.telefono_sim,
|
||||
operador_sim=dispositivo.operador_sim,
|
||||
iccid=dispositivo.iccid,
|
||||
imei=dispositivo.imei,
|
||||
protocolo=dispositivo.protocolo,
|
||||
ultimo_contacto=dispositivo.ultimo_contacto,
|
||||
bateria=dispositivo.bateria,
|
||||
señal_gsm=dispositivo.señal_gsm,
|
||||
satelites=dispositivo.satelites,
|
||||
intervalo_reporte=dispositivo.intervalo_reporte,
|
||||
configuracion=dispositivo.configuracion,
|
||||
firmware_version=dispositivo.firmware_version,
|
||||
activo=dispositivo.activo,
|
||||
conectado=dispositivo.conectado,
|
||||
notas=dispositivo.notas,
|
||||
esta_online=dispositivo.esta_online,
|
||||
creado_en=dispositivo.creado_en,
|
||||
actualizado_en=dispositivo.actualizado_en,
|
||||
vehiculo_nombre=dispositivo.vehiculo.nombre if dispositivo.vehiculo else None,
|
||||
vehiculo_placa=dispositivo.vehiculo.placa if dispositivo.vehiculo else None,
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=DispositivoResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def crear_dispositivo(
|
||||
dispositivo_data: DispositivoCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: Usuario = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Crea un nuevo dispositivo.
|
||||
|
||||
Args:
|
||||
dispositivo_data: Datos del dispositivo.
|
||||
|
||||
Returns:
|
||||
Dispositivo creado.
|
||||
"""
|
||||
# Verificar identificador único
|
||||
result = await db.execute(
|
||||
select(Dispositivo).where(Dispositivo.identificador == dispositivo_data.identificador)
|
||||
)
|
||||
if result.scalar_one_or_none():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"Ya existe un dispositivo con el identificador {dispositivo_data.identificador}",
|
||||
)
|
||||
|
||||
# Verificar IMEI único si se proporciona
|
||||
if dispositivo_data.imei:
|
||||
result = await db.execute(
|
||||
select(Dispositivo).where(Dispositivo.imei == dispositivo_data.imei)
|
||||
)
|
||||
if result.scalar_one_or_none():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"Ya existe un dispositivo con el IMEI {dispositivo_data.imei}",
|
||||
)
|
||||
|
||||
dispositivo = Dispositivo(**dispositivo_data.model_dump())
|
||||
|
||||
db.add(dispositivo)
|
||||
await db.commit()
|
||||
await db.refresh(dispositivo)
|
||||
|
||||
return DispositivoResponse(
|
||||
id=dispositivo.id,
|
||||
vehiculo_id=dispositivo.vehiculo_id,
|
||||
tipo=dispositivo.tipo,
|
||||
identificador=dispositivo.identificador,
|
||||
nombre=dispositivo.nombre,
|
||||
marca=dispositivo.marca,
|
||||
modelo=dispositivo.modelo,
|
||||
numero_serie=dispositivo.numero_serie,
|
||||
telefono_sim=dispositivo.telefono_sim,
|
||||
operador_sim=dispositivo.operador_sim,
|
||||
iccid=dispositivo.iccid,
|
||||
imei=dispositivo.imei,
|
||||
protocolo=dispositivo.protocolo,
|
||||
ultimo_contacto=dispositivo.ultimo_contacto,
|
||||
bateria=dispositivo.bateria,
|
||||
señal_gsm=dispositivo.señal_gsm,
|
||||
satelites=dispositivo.satelites,
|
||||
intervalo_reporte=dispositivo.intervalo_reporte,
|
||||
configuracion=dispositivo.configuracion,
|
||||
firmware_version=dispositivo.firmware_version,
|
||||
activo=dispositivo.activo,
|
||||
conectado=dispositivo.conectado,
|
||||
notas=dispositivo.notas,
|
||||
esta_online=dispositivo.esta_online,
|
||||
creado_en=dispositivo.creado_en,
|
||||
actualizado_en=dispositivo.actualizado_en,
|
||||
)
|
||||
|
||||
|
||||
@router.put("/{dispositivo_id}", response_model=DispositivoResponse)
|
||||
async def actualizar_dispositivo(
|
||||
dispositivo_id: int,
|
||||
dispositivo_data: DispositivoUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: Usuario = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Actualiza un dispositivo.
|
||||
|
||||
Args:
|
||||
dispositivo_id: ID del dispositivo.
|
||||
dispositivo_data: Datos a actualizar.
|
||||
|
||||
Returns:
|
||||
Dispositivo actualizado.
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(Dispositivo).where(Dispositivo.id == dispositivo_id)
|
||||
)
|
||||
dispositivo = result.scalar_one_or_none()
|
||||
|
||||
if not dispositivo:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Dispositivo con id {dispositivo_id} no encontrado",
|
||||
)
|
||||
|
||||
update_data = dispositivo_data.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(dispositivo, field, value)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(dispositivo)
|
||||
|
||||
return DispositivoResponse(
|
||||
id=dispositivo.id,
|
||||
vehiculo_id=dispositivo.vehiculo_id,
|
||||
tipo=dispositivo.tipo,
|
||||
identificador=dispositivo.identificador,
|
||||
nombre=dispositivo.nombre,
|
||||
marca=dispositivo.marca,
|
||||
modelo=dispositivo.modelo,
|
||||
numero_serie=dispositivo.numero_serie,
|
||||
telefono_sim=dispositivo.telefono_sim,
|
||||
operador_sim=dispositivo.operador_sim,
|
||||
iccid=dispositivo.iccid,
|
||||
imei=dispositivo.imei,
|
||||
protocolo=dispositivo.protocolo,
|
||||
ultimo_contacto=dispositivo.ultimo_contacto,
|
||||
bateria=dispositivo.bateria,
|
||||
señal_gsm=dispositivo.señal_gsm,
|
||||
satelites=dispositivo.satelites,
|
||||
intervalo_reporte=dispositivo.intervalo_reporte,
|
||||
configuracion=dispositivo.configuracion,
|
||||
firmware_version=dispositivo.firmware_version,
|
||||
activo=dispositivo.activo,
|
||||
conectado=dispositivo.conectado,
|
||||
notas=dispositivo.notas,
|
||||
esta_online=dispositivo.esta_online,
|
||||
creado_en=dispositivo.creado_en,
|
||||
actualizado_en=dispositivo.actualizado_en,
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{dispositivo_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def eliminar_dispositivo(
|
||||
dispositivo_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: Usuario = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Elimina un dispositivo (soft delete - desactiva).
|
||||
|
||||
Args:
|
||||
dispositivo_id: ID del dispositivo.
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(Dispositivo).where(Dispositivo.id == dispositivo_id)
|
||||
)
|
||||
dispositivo = result.scalar_one_or_none()
|
||||
|
||||
if not dispositivo:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Dispositivo con id {dispositivo_id} no encontrado",
|
||||
)
|
||||
|
||||
dispositivo.activo = False
|
||||
await db.commit()
|
||||
|
||||
|
||||
@router.get("/por-identificador/{identificador}", response_model=DispositivoResponse)
|
||||
async def obtener_dispositivo_por_identificador(
|
||||
identificador: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Obtiene un dispositivo por su identificador.
|
||||
|
||||
Este endpoint no requiere autenticación para
|
||||
facilitar la búsqueda desde dispositivos.
|
||||
|
||||
Args:
|
||||
identificador: Identificador del dispositivo.
|
||||
|
||||
Returns:
|
||||
Dispositivo encontrado.
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(Dispositivo).where(Dispositivo.identificador == identificador)
|
||||
)
|
||||
dispositivo = result.scalar_one_or_none()
|
||||
|
||||
if not dispositivo:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Dispositivo con identificador {identificador} no encontrado",
|
||||
)
|
||||
|
||||
return DispositivoResponse(
|
||||
id=dispositivo.id,
|
||||
vehiculo_id=dispositivo.vehiculo_id,
|
||||
tipo=dispositivo.tipo,
|
||||
identificador=dispositivo.identificador,
|
||||
nombre=dispositivo.nombre,
|
||||
marca=dispositivo.marca,
|
||||
modelo=dispositivo.modelo,
|
||||
numero_serie=dispositivo.numero_serie,
|
||||
telefono_sim=dispositivo.telefono_sim,
|
||||
operador_sim=dispositivo.operador_sim,
|
||||
iccid=dispositivo.iccid,
|
||||
imei=dispositivo.imei,
|
||||
protocolo=dispositivo.protocolo,
|
||||
ultimo_contacto=dispositivo.ultimo_contacto,
|
||||
bateria=dispositivo.bateria,
|
||||
señal_gsm=dispositivo.señal_gsm,
|
||||
satelites=dispositivo.satelites,
|
||||
intervalo_reporte=dispositivo.intervalo_reporte,
|
||||
configuracion=dispositivo.configuracion,
|
||||
firmware_version=dispositivo.firmware_version,
|
||||
activo=dispositivo.activo,
|
||||
conectado=dispositivo.conectado,
|
||||
notas=dispositivo.notas,
|
||||
esta_online=dispositivo.esta_online,
|
||||
creado_en=dispositivo.creado_en,
|
||||
actualizado_en=dispositivo.actualizado_en,
|
||||
)
|
||||
502
backend/app/api/v1/geocercas.py
Normal file
502
backend/app/api/v1/geocercas.py
Normal file
@@ -0,0 +1,502 @@
|
||||
"""
|
||||
Endpoints para gestión de geocercas.
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.security import get_current_user
|
||||
from app.models.usuario import Usuario
|
||||
from app.models.geocerca import Geocerca
|
||||
from app.models.vehiculo import Vehiculo
|
||||
from app.schemas.geocerca import (
|
||||
GeocercaCircularCreate,
|
||||
GeocercaPoligonoCreate,
|
||||
GeocercaUpdate,
|
||||
GeocercaResponse,
|
||||
GeocercaConVehiculos,
|
||||
AsignarVehiculosRequest,
|
||||
VerificarPuntoRequest,
|
||||
VerificarPuntoResponse,
|
||||
)
|
||||
from app.services.geocerca_service import GeocercaService
|
||||
|
||||
router = APIRouter(prefix="/geocercas", tags=["Geocercas"])
|
||||
|
||||
|
||||
@router.get("", response_model=List[GeocercaResponse])
|
||||
async def listar_geocercas(
|
||||
activa: Optional[bool] = None,
|
||||
tipo: Optional[str] = None,
|
||||
categoria: Optional[str] = None,
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=100),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: Usuario = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Lista todas las geocercas.
|
||||
|
||||
Args:
|
||||
activa: Filtrar por estado.
|
||||
tipo: Filtrar por tipo (circular/poligono).
|
||||
categoria: Filtrar por categoría.
|
||||
skip: Registros a saltar.
|
||||
limit: Límite de registros.
|
||||
|
||||
Returns:
|
||||
Lista de geocercas.
|
||||
"""
|
||||
query = select(Geocerca).order_by(Geocerca.nombre)
|
||||
|
||||
if activa is not None:
|
||||
query = query.where(Geocerca.activa == activa)
|
||||
if tipo:
|
||||
query = query.where(Geocerca.tipo == tipo)
|
||||
if categoria:
|
||||
query = query.where(Geocerca.categoria == categoria)
|
||||
|
||||
query = query.offset(skip).limit(limit)
|
||||
|
||||
result = await db.execute(query)
|
||||
geocercas = result.scalars().all()
|
||||
|
||||
return [
|
||||
GeocercaResponse(
|
||||
id=g.id,
|
||||
nombre=g.nombre,
|
||||
descripcion=g.descripcion,
|
||||
tipo=g.tipo,
|
||||
color=g.color,
|
||||
opacidad=g.opacidad,
|
||||
color_borde=g.color_borde,
|
||||
categoria=g.categoria,
|
||||
centro_lat=g.centro_lat,
|
||||
centro_lng=g.centro_lng,
|
||||
radio_metros=g.radio_metros,
|
||||
coordenadas_json=g.coordenadas_json,
|
||||
alerta_entrada=g.alerta_entrada,
|
||||
alerta_salida=g.alerta_salida,
|
||||
velocidad_maxima=g.velocidad_maxima,
|
||||
horario_json=g.horario_json,
|
||||
activa=g.activa,
|
||||
aplica_todos_vehiculos=g.aplica_todos_vehiculos,
|
||||
creado_en=g.creado_en,
|
||||
actualizado_en=g.actualizado_en,
|
||||
)
|
||||
for g in geocercas
|
||||
]
|
||||
|
||||
|
||||
@router.get("/geojson")
|
||||
async def obtener_geocercas_geojson(
|
||||
activa: bool = True,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: Usuario = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Obtiene todas las geocercas en formato GeoJSON.
|
||||
|
||||
Args:
|
||||
activa: Solo geocercas activas.
|
||||
|
||||
Returns:
|
||||
FeatureCollection GeoJSON.
|
||||
"""
|
||||
query = select(Geocerca)
|
||||
if activa:
|
||||
query = query.where(Geocerca.activa == True)
|
||||
|
||||
result = await db.execute(query)
|
||||
geocercas = result.scalars().all()
|
||||
|
||||
features = [g.to_geojson() for g in geocercas]
|
||||
|
||||
return {
|
||||
"type": "FeatureCollection",
|
||||
"features": features,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{geocerca_id}", response_model=GeocercaConVehiculos)
|
||||
async def obtener_geocerca(
|
||||
geocerca_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: Usuario = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Obtiene una geocerca por su ID.
|
||||
|
||||
Args:
|
||||
geocerca_id: ID de la geocerca.
|
||||
|
||||
Returns:
|
||||
Geocerca con vehículos asignados.
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(Geocerca)
|
||||
.options(selectinload(Geocerca.vehiculos_asignados))
|
||||
.where(Geocerca.id == geocerca_id)
|
||||
)
|
||||
geocerca = result.scalar_one_or_none()
|
||||
|
||||
if not geocerca:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Geocerca con id {geocerca_id} no encontrada",
|
||||
)
|
||||
|
||||
from app.schemas.vehiculo import VehiculoResumen
|
||||
|
||||
return GeocercaConVehiculos(
|
||||
id=geocerca.id,
|
||||
nombre=geocerca.nombre,
|
||||
descripcion=geocerca.descripcion,
|
||||
tipo=geocerca.tipo,
|
||||
color=geocerca.color,
|
||||
opacidad=geocerca.opacidad,
|
||||
color_borde=geocerca.color_borde,
|
||||
categoria=geocerca.categoria,
|
||||
centro_lat=geocerca.centro_lat,
|
||||
centro_lng=geocerca.centro_lng,
|
||||
radio_metros=geocerca.radio_metros,
|
||||
coordenadas_json=geocerca.coordenadas_json,
|
||||
alerta_entrada=geocerca.alerta_entrada,
|
||||
alerta_salida=geocerca.alerta_salida,
|
||||
velocidad_maxima=geocerca.velocidad_maxima,
|
||||
horario_json=geocerca.horario_json,
|
||||
activa=geocerca.activa,
|
||||
aplica_todos_vehiculos=geocerca.aplica_todos_vehiculos,
|
||||
creado_en=geocerca.creado_en,
|
||||
actualizado_en=geocerca.actualizado_en,
|
||||
vehiculos_asignados=[
|
||||
VehiculoResumen.model_validate(v)
|
||||
for v in geocerca.vehiculos_asignados
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@router.post("/circular", response_model=GeocercaResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def crear_geocerca_circular(
|
||||
geocerca_data: GeocercaCircularCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: Usuario = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Crea una geocerca circular.
|
||||
|
||||
Args:
|
||||
geocerca_data: Datos de la geocerca.
|
||||
|
||||
Returns:
|
||||
Geocerca creada.
|
||||
"""
|
||||
geocerca = Geocerca(
|
||||
nombre=geocerca_data.nombre,
|
||||
descripcion=geocerca_data.descripcion,
|
||||
tipo="circular",
|
||||
centro_lat=geocerca_data.centro_lat,
|
||||
centro_lng=geocerca_data.centro_lng,
|
||||
radio_metros=geocerca_data.radio_metros,
|
||||
color=geocerca_data.color,
|
||||
opacidad=geocerca_data.opacidad,
|
||||
color_borde=geocerca_data.color_borde,
|
||||
categoria=geocerca_data.categoria,
|
||||
alerta_entrada=geocerca_data.alerta_entrada,
|
||||
alerta_salida=geocerca_data.alerta_salida,
|
||||
velocidad_maxima=geocerca_data.velocidad_maxima,
|
||||
horario_json=geocerca_data.horario_json,
|
||||
)
|
||||
|
||||
db.add(geocerca)
|
||||
await db.commit()
|
||||
await db.refresh(geocerca)
|
||||
|
||||
# Asignar vehículos si se especificaron
|
||||
if geocerca_data.vehiculos_ids:
|
||||
result = await db.execute(
|
||||
select(Vehiculo).where(Vehiculo.id.in_(geocerca_data.vehiculos_ids))
|
||||
)
|
||||
vehiculos = result.scalars().all()
|
||||
geocerca.vehiculos_asignados = list(vehiculos)
|
||||
await db.commit()
|
||||
|
||||
return GeocercaResponse(
|
||||
id=geocerca.id,
|
||||
nombre=geocerca.nombre,
|
||||
descripcion=geocerca.descripcion,
|
||||
tipo=geocerca.tipo,
|
||||
color=geocerca.color,
|
||||
opacidad=geocerca.opacidad,
|
||||
color_borde=geocerca.color_borde,
|
||||
categoria=geocerca.categoria,
|
||||
centro_lat=geocerca.centro_lat,
|
||||
centro_lng=geocerca.centro_lng,
|
||||
radio_metros=geocerca.radio_metros,
|
||||
coordenadas_json=geocerca.coordenadas_json,
|
||||
alerta_entrada=geocerca.alerta_entrada,
|
||||
alerta_salida=geocerca.alerta_salida,
|
||||
velocidad_maxima=geocerca.velocidad_maxima,
|
||||
horario_json=geocerca.horario_json,
|
||||
activa=geocerca.activa,
|
||||
aplica_todos_vehiculos=geocerca.aplica_todos_vehiculos,
|
||||
creado_en=geocerca.creado_en,
|
||||
actualizado_en=geocerca.actualizado_en,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/poligono", response_model=GeocercaResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def crear_geocerca_poligono(
|
||||
geocerca_data: GeocercaPoligonoCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: Usuario = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Crea una geocerca poligonal.
|
||||
|
||||
Args:
|
||||
geocerca_data: Datos de la geocerca.
|
||||
|
||||
Returns:
|
||||
Geocerca creada.
|
||||
"""
|
||||
geocerca = Geocerca(
|
||||
nombre=geocerca_data.nombre,
|
||||
descripcion=geocerca_data.descripcion,
|
||||
tipo="poligono",
|
||||
coordenadas_json=json.dumps(geocerca_data.coordenadas),
|
||||
color=geocerca_data.color,
|
||||
opacidad=geocerca_data.opacidad,
|
||||
color_borde=geocerca_data.color_borde,
|
||||
categoria=geocerca_data.categoria,
|
||||
alerta_entrada=geocerca_data.alerta_entrada,
|
||||
alerta_salida=geocerca_data.alerta_salida,
|
||||
velocidad_maxima=geocerca_data.velocidad_maxima,
|
||||
horario_json=geocerca_data.horario_json,
|
||||
)
|
||||
|
||||
db.add(geocerca)
|
||||
await db.commit()
|
||||
await db.refresh(geocerca)
|
||||
|
||||
# Asignar vehículos si se especificaron
|
||||
if geocerca_data.vehiculos_ids:
|
||||
result = await db.execute(
|
||||
select(Vehiculo).where(Vehiculo.id.in_(geocerca_data.vehiculos_ids))
|
||||
)
|
||||
vehiculos = result.scalars().all()
|
||||
geocerca.vehiculos_asignados = list(vehiculos)
|
||||
await db.commit()
|
||||
|
||||
return GeocercaResponse(
|
||||
id=geocerca.id,
|
||||
nombre=geocerca.nombre,
|
||||
descripcion=geocerca.descripcion,
|
||||
tipo=geocerca.tipo,
|
||||
color=geocerca.color,
|
||||
opacidad=geocerca.opacidad,
|
||||
color_borde=geocerca.color_borde,
|
||||
categoria=geocerca.categoria,
|
||||
centro_lat=geocerca.centro_lat,
|
||||
centro_lng=geocerca.centro_lng,
|
||||
radio_metros=geocerca.radio_metros,
|
||||
coordenadas_json=geocerca.coordenadas_json,
|
||||
alerta_entrada=geocerca.alerta_entrada,
|
||||
alerta_salida=geocerca.alerta_salida,
|
||||
velocidad_maxima=geocerca.velocidad_maxima,
|
||||
horario_json=geocerca.horario_json,
|
||||
activa=geocerca.activa,
|
||||
aplica_todos_vehiculos=geocerca.aplica_todos_vehiculos,
|
||||
creado_en=geocerca.creado_en,
|
||||
actualizado_en=geocerca.actualizado_en,
|
||||
)
|
||||
|
||||
|
||||
@router.put("/{geocerca_id}", response_model=GeocercaResponse)
|
||||
async def actualizar_geocerca(
|
||||
geocerca_id: int,
|
||||
geocerca_data: GeocercaUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: Usuario = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Actualiza una geocerca.
|
||||
|
||||
Args:
|
||||
geocerca_id: ID de la geocerca.
|
||||
geocerca_data: Datos a actualizar.
|
||||
|
||||
Returns:
|
||||
Geocerca actualizada.
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(Geocerca).where(Geocerca.id == geocerca_id)
|
||||
)
|
||||
geocerca = result.scalar_one_or_none()
|
||||
|
||||
if not geocerca:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Geocerca con id {geocerca_id} no encontrada",
|
||||
)
|
||||
|
||||
update_data = geocerca_data.model_dump(exclude_unset=True)
|
||||
|
||||
# Manejar coordenadas si es polígono
|
||||
if "coordenadas" in update_data and update_data["coordenadas"]:
|
||||
update_data["coordenadas_json"] = json.dumps(update_data.pop("coordenadas"))
|
||||
|
||||
for field, value in update_data.items():
|
||||
if hasattr(geocerca, field):
|
||||
setattr(geocerca, field, value)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(geocerca)
|
||||
|
||||
return GeocercaResponse(
|
||||
id=geocerca.id,
|
||||
nombre=geocerca.nombre,
|
||||
descripcion=geocerca.descripcion,
|
||||
tipo=geocerca.tipo,
|
||||
color=geocerca.color,
|
||||
opacidad=geocerca.opacidad,
|
||||
color_borde=geocerca.color_borde,
|
||||
categoria=geocerca.categoria,
|
||||
centro_lat=geocerca.centro_lat,
|
||||
centro_lng=geocerca.centro_lng,
|
||||
radio_metros=geocerca.radio_metros,
|
||||
coordenadas_json=geocerca.coordenadas_json,
|
||||
alerta_entrada=geocerca.alerta_entrada,
|
||||
alerta_salida=geocerca.alerta_salida,
|
||||
velocidad_maxima=geocerca.velocidad_maxima,
|
||||
horario_json=geocerca.horario_json,
|
||||
activa=geocerca.activa,
|
||||
aplica_todos_vehiculos=geocerca.aplica_todos_vehiculos,
|
||||
creado_en=geocerca.creado_en,
|
||||
actualizado_en=geocerca.actualizado_en,
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{geocerca_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def eliminar_geocerca(
|
||||
geocerca_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: Usuario = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Elimina una geocerca (soft delete - desactiva).
|
||||
|
||||
Args:
|
||||
geocerca_id: ID de la geocerca.
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(Geocerca).where(Geocerca.id == geocerca_id)
|
||||
)
|
||||
geocerca = result.scalar_one_or_none()
|
||||
|
||||
if not geocerca:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Geocerca con id {geocerca_id} no encontrada",
|
||||
)
|
||||
|
||||
geocerca.activa = False
|
||||
await db.commit()
|
||||
|
||||
|
||||
@router.post("/{geocerca_id}/vehiculos")
|
||||
async def asignar_vehiculos(
|
||||
geocerca_id: int,
|
||||
request: AsignarVehiculosRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: Usuario = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Asigna vehículos a una geocerca.
|
||||
|
||||
Args:
|
||||
geocerca_id: ID de la geocerca.
|
||||
request: Lista de IDs de vehículos.
|
||||
|
||||
Returns:
|
||||
Confirmación.
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(Geocerca)
|
||||
.options(selectinload(Geocerca.vehiculos_asignados))
|
||||
.where(Geocerca.id == geocerca_id)
|
||||
)
|
||||
geocerca = result.scalar_one_or_none()
|
||||
|
||||
if not geocerca:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Geocerca con id {geocerca_id} no encontrada",
|
||||
)
|
||||
|
||||
# Obtener vehículos
|
||||
result = await db.execute(
|
||||
select(Vehiculo).where(Vehiculo.id.in_(request.vehiculos_ids))
|
||||
)
|
||||
vehiculos = result.scalars().all()
|
||||
|
||||
if request.reemplazar:
|
||||
geocerca.vehiculos_asignados = list(vehiculos)
|
||||
else:
|
||||
for v in vehiculos:
|
||||
if v not in geocerca.vehiculos_asignados:
|
||||
geocerca.vehiculos_asignados.append(v)
|
||||
|
||||
await db.commit()
|
||||
|
||||
return {
|
||||
"message": "Vehículos asignados correctamente",
|
||||
"total_asignados": len(geocerca.vehiculos_asignados),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/{geocerca_id}/verificar", response_model=VerificarPuntoResponse)
|
||||
async def verificar_punto(
|
||||
geocerca_id: int,
|
||||
request: VerificarPuntoRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: Usuario = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Verifica si un punto está dentro de una geocerca.
|
||||
|
||||
Args:
|
||||
geocerca_id: ID de la geocerca.
|
||||
request: Coordenadas del punto.
|
||||
|
||||
Returns:
|
||||
Resultado de la verificación.
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(Geocerca).where(Geocerca.id == geocerca_id)
|
||||
)
|
||||
geocerca = result.scalar_one_or_none()
|
||||
|
||||
if not geocerca:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Geocerca con id {geocerca_id} no encontrada",
|
||||
)
|
||||
|
||||
geocerca_service = GeocercaService(db)
|
||||
dentro, distancia = await geocerca_service.verificar_punto_en_geocerca(
|
||||
request.lat, request.lng, geocerca_id
|
||||
)
|
||||
|
||||
return VerificarPuntoResponse(
|
||||
dentro=dentro,
|
||||
geocerca_id=geocerca_id,
|
||||
geocerca_nombre=geocerca.nombre,
|
||||
distancia_metros=distancia,
|
||||
)
|
||||
227
backend/app/api/v1/reportes.py
Normal file
227
backend/app/api/v1/reportes.py
Normal file
@@ -0,0 +1,227 @@
|
||||
"""
|
||||
Endpoints para reportes y dashboard.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.security import get_current_user
|
||||
from app.models.usuario import Usuario
|
||||
from app.schemas.reporte import (
|
||||
DashboardResumen,
|
||||
DashboardGrafico,
|
||||
ReporteRequest,
|
||||
ReporteResponse,
|
||||
)
|
||||
from app.services.reporte_service import ReporteService
|
||||
|
||||
router = APIRouter(prefix="/reportes", tags=["Reportes"])
|
||||
|
||||
|
||||
@router.get("/dashboard", response_model=DashboardResumen)
|
||||
async def obtener_dashboard(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: Usuario = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Obtiene datos del dashboard principal.
|
||||
|
||||
Returns:
|
||||
Resumen del dashboard.
|
||||
"""
|
||||
reporte_service = ReporteService(db)
|
||||
return await reporte_service.obtener_dashboard_resumen()
|
||||
|
||||
|
||||
@router.get("/dashboard/graficos", response_model=DashboardGrafico)
|
||||
async def obtener_graficos_dashboard(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: Usuario = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Obtiene datos para gráficos del dashboard.
|
||||
|
||||
Returns:
|
||||
Datos para gráficos.
|
||||
"""
|
||||
reporte_service = ReporteService(db)
|
||||
return await reporte_service.obtener_dashboard_graficos()
|
||||
|
||||
|
||||
@router.post("/generar", response_model=ReporteResponse)
|
||||
async def generar_reporte(
|
||||
request: ReporteRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: Usuario = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Genera un reporte según los parámetros.
|
||||
|
||||
Args:
|
||||
request: Parámetros del reporte.
|
||||
|
||||
Returns:
|
||||
Información del reporte generado.
|
||||
"""
|
||||
reporte_service = ReporteService(db)
|
||||
return await reporte_service.generar_reporte(request)
|
||||
|
||||
|
||||
@router.get("/viajes")
|
||||
async def reporte_viajes(
|
||||
desde: datetime,
|
||||
hasta: datetime,
|
||||
vehiculo_id: Optional[int] = None,
|
||||
formato: str = "json",
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: Usuario = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Genera reporte de viajes.
|
||||
|
||||
Args:
|
||||
desde: Fecha inicio.
|
||||
hasta: Fecha fin.
|
||||
vehiculo_id: Filtrar por vehículo.
|
||||
formato: Formato de salida.
|
||||
|
||||
Returns:
|
||||
Datos del reporte.
|
||||
"""
|
||||
reporte_service = ReporteService(db)
|
||||
request = ReporteRequest(
|
||||
tipo="viajes",
|
||||
formato=formato if formato != "json" else "pdf",
|
||||
fecha_inicio=desde,
|
||||
fecha_fin=hasta,
|
||||
vehiculos_ids=[vehiculo_id] if vehiculo_id else None,
|
||||
)
|
||||
|
||||
datos = await reporte_service._recopilar_datos_reporte(request)
|
||||
|
||||
if formato == "json":
|
||||
return datos
|
||||
|
||||
resultado = await reporte_service.generar_reporte(request)
|
||||
return resultado
|
||||
|
||||
|
||||
@router.get("/alertas")
|
||||
async def reporte_alertas(
|
||||
desde: datetime,
|
||||
hasta: datetime,
|
||||
vehiculo_id: Optional[int] = None,
|
||||
formato: str = "json",
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: Usuario = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Genera reporte de alertas.
|
||||
|
||||
Args:
|
||||
desde: Fecha inicio.
|
||||
hasta: Fecha fin.
|
||||
vehiculo_id: Filtrar por vehículo.
|
||||
formato: Formato de salida.
|
||||
|
||||
Returns:
|
||||
Datos del reporte.
|
||||
"""
|
||||
reporte_service = ReporteService(db)
|
||||
request = ReporteRequest(
|
||||
tipo="alertas",
|
||||
formato=formato if formato != "json" else "pdf",
|
||||
fecha_inicio=desde,
|
||||
fecha_fin=hasta,
|
||||
vehiculos_ids=[vehiculo_id] if vehiculo_id else None,
|
||||
)
|
||||
|
||||
datos = await reporte_service._recopilar_datos_reporte(request)
|
||||
|
||||
if formato == "json":
|
||||
return datos
|
||||
|
||||
resultado = await reporte_service.generar_reporte(request)
|
||||
return resultado
|
||||
|
||||
|
||||
@router.get("/combustible")
|
||||
async def reporte_combustible(
|
||||
desde: datetime,
|
||||
hasta: datetime,
|
||||
vehiculo_id: Optional[int] = None,
|
||||
formato: str = "json",
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: Usuario = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Genera reporte de combustible.
|
||||
|
||||
Args:
|
||||
desde: Fecha inicio.
|
||||
hasta: Fecha fin.
|
||||
vehiculo_id: Filtrar por vehículo.
|
||||
formato: Formato de salida.
|
||||
|
||||
Returns:
|
||||
Datos del reporte.
|
||||
"""
|
||||
reporte_service = ReporteService(db)
|
||||
request = ReporteRequest(
|
||||
tipo="combustible",
|
||||
formato=formato if formato != "json" else "pdf",
|
||||
fecha_inicio=desde,
|
||||
fecha_fin=hasta,
|
||||
vehiculos_ids=[vehiculo_id] if vehiculo_id else None,
|
||||
)
|
||||
|
||||
datos = await reporte_service._recopilar_datos_reporte(request)
|
||||
|
||||
if formato == "json":
|
||||
return datos
|
||||
|
||||
resultado = await reporte_service.generar_reporte(request)
|
||||
return resultado
|
||||
|
||||
|
||||
@router.get("/mantenimiento")
|
||||
async def reporte_mantenimiento(
|
||||
desde: datetime,
|
||||
hasta: datetime,
|
||||
vehiculo_id: Optional[int] = None,
|
||||
formato: str = "json",
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: Usuario = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Genera reporte de mantenimiento.
|
||||
|
||||
Args:
|
||||
desde: Fecha inicio.
|
||||
hasta: Fecha fin.
|
||||
vehiculo_id: Filtrar por vehículo.
|
||||
formato: Formato de salida.
|
||||
|
||||
Returns:
|
||||
Datos del reporte.
|
||||
"""
|
||||
reporte_service = ReporteService(db)
|
||||
request = ReporteRequest(
|
||||
tipo="mantenimiento",
|
||||
formato=formato if formato != "json" else "pdf",
|
||||
fecha_inicio=desde,
|
||||
fecha_fin=hasta,
|
||||
vehiculos_ids=[vehiculo_id] if vehiculo_id else None,
|
||||
)
|
||||
|
||||
datos = await reporte_service._recopilar_datos_reporte(request)
|
||||
|
||||
if formato == "json":
|
||||
return datos
|
||||
|
||||
resultado = await reporte_service.generar_reporte(request)
|
||||
return resultado
|
||||
48
backend/app/api/v1/router.py
Normal file
48
backend/app/api/v1/router.py
Normal file
@@ -0,0 +1,48 @@
|
||||
"""
|
||||
Router principal de la API v1.
|
||||
|
||||
Incluye todos los sub-routers de cada módulo.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.v1.auth import router as auth_router
|
||||
from app.api.v1.vehiculos import router as vehiculos_router
|
||||
from app.api.v1.conductores import router as conductores_router
|
||||
from app.api.v1.ubicaciones import router as ubicaciones_router
|
||||
from app.api.v1.viajes import router as viajes_router
|
||||
from app.api.v1.alertas import router as alertas_router
|
||||
from app.api.v1.geocercas import router as geocercas_router
|
||||
from app.api.v1.dispositivos import router as dispositivos_router
|
||||
from app.api.v1.reportes import router as reportes_router
|
||||
|
||||
# Router principal
|
||||
api_router = APIRouter()
|
||||
|
||||
# Incluir todos los sub-routers
|
||||
api_router.include_router(auth_router)
|
||||
api_router.include_router(vehiculos_router)
|
||||
api_router.include_router(conductores_router)
|
||||
api_router.include_router(ubicaciones_router)
|
||||
api_router.include_router(viajes_router)
|
||||
api_router.include_router(alertas_router)
|
||||
api_router.include_router(geocercas_router)
|
||||
api_router.include_router(dispositivos_router)
|
||||
api_router.include_router(reportes_router)
|
||||
|
||||
# TODO: Agregar cuando se completen
|
||||
# from app.api.v1.pois import router as pois_router
|
||||
# from app.api.v1.combustible import router as combustible_router
|
||||
# from app.api.v1.mantenimiento import router as mantenimiento_router
|
||||
# from app.api.v1.video import router as video_router
|
||||
# from app.api.v1.mensajes import router as mensajes_router
|
||||
# from app.api.v1.configuracion import router as configuracion_router
|
||||
# from app.api.v1.meshtastic import router as meshtastic_router
|
||||
|
||||
# api_router.include_router(pois_router)
|
||||
# api_router.include_router(combustible_router)
|
||||
# api_router.include_router(mantenimiento_router)
|
||||
# api_router.include_router(video_router)
|
||||
# api_router.include_router(mensajes_router)
|
||||
# api_router.include_router(configuracion_router)
|
||||
# api_router.include_router(meshtastic_router)
|
||||
237
backend/app/api/v1/ubicaciones.py
Normal file
237
backend/app/api/v1/ubicaciones.py
Normal file
@@ -0,0 +1,237 @@
|
||||
"""
|
||||
Endpoints para recepción y consulta de ubicaciones GPS.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, Request
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.security import get_current_user
|
||||
from app.models.usuario import Usuario
|
||||
from app.schemas.ubicacion import (
|
||||
UbicacionCreate,
|
||||
UbicacionBulkCreate,
|
||||
UbicacionResponse,
|
||||
HistorialUbicacionesResponse,
|
||||
OsmAndLocationCreate,
|
||||
)
|
||||
from app.services.ubicacion_service import UbicacionService
|
||||
from app.services.alerta_service import AlertaService
|
||||
from app.services.viaje_service import ViajeService
|
||||
|
||||
router = APIRouter(prefix="/ubicaciones", tags=["Ubicaciones"])
|
||||
|
||||
|
||||
@router.post("", response_model=Optional[UbicacionResponse])
|
||||
async def recibir_ubicacion(
|
||||
ubicacion_data: UbicacionCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Recibe una ubicación GPS desde la app móvil o dispositivo.
|
||||
|
||||
Este endpoint no requiere autenticación para facilitar
|
||||
la integración con dispositivos GPS simples.
|
||||
|
||||
Args:
|
||||
ubicacion_data: Datos de la ubicación.
|
||||
|
||||
Returns:
|
||||
Ubicación procesada o None si se descartó.
|
||||
"""
|
||||
ubicacion_service = UbicacionService(db)
|
||||
resultado = await ubicacion_service.procesar_ubicacion(ubicacion_data)
|
||||
|
||||
if resultado:
|
||||
# Procesar alertas y viajes en background
|
||||
alerta_service = AlertaService(db)
|
||||
viaje_service = ViajeService(db)
|
||||
|
||||
# Verificar velocidad
|
||||
if ubicacion_data.velocidad:
|
||||
await alerta_service.verificar_velocidad(
|
||||
resultado.vehiculo_id,
|
||||
ubicacion_data.velocidad,
|
||||
ubicacion_data.lat,
|
||||
ubicacion_data.lng,
|
||||
)
|
||||
|
||||
# Verificar batería
|
||||
if ubicacion_data.bateria_dispositivo:
|
||||
await alerta_service.verificar_bateria_baja(
|
||||
resultado.vehiculo_id,
|
||||
ubicacion_data.bateria_dispositivo,
|
||||
ubicacion_data.lat,
|
||||
ubicacion_data.lng,
|
||||
)
|
||||
|
||||
# Procesar viaje
|
||||
await viaje_service.procesar_ubicacion_viaje(
|
||||
resultado.vehiculo_id,
|
||||
ubicacion_data.lat,
|
||||
ubicacion_data.lng,
|
||||
ubicacion_data.velocidad or 0,
|
||||
resultado.tiempo,
|
||||
)
|
||||
|
||||
return resultado
|
||||
|
||||
|
||||
@router.post("/bulk", response_model=dict)
|
||||
async def recibir_ubicaciones_bulk(
|
||||
data: UbicacionBulkCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Recibe múltiples ubicaciones en una sola petición.
|
||||
|
||||
Útil para sincronización de datos acumulados cuando
|
||||
el dispositivo estuvo offline.
|
||||
|
||||
Args:
|
||||
data: Lista de ubicaciones.
|
||||
|
||||
Returns:
|
||||
Conteo de ubicaciones procesadas.
|
||||
"""
|
||||
ubicacion_service = UbicacionService(db)
|
||||
procesadas = 0
|
||||
errores = 0
|
||||
|
||||
for ubicacion_data in data.ubicaciones:
|
||||
try:
|
||||
resultado = await ubicacion_service.procesar_ubicacion(ubicacion_data)
|
||||
if resultado:
|
||||
procesadas += 1
|
||||
except Exception:
|
||||
errores += 1
|
||||
|
||||
return {
|
||||
"total": len(data.ubicaciones),
|
||||
"procesadas": procesadas,
|
||||
"errores": errores,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/osmand")
|
||||
async def recibir_ubicacion_osmand(
|
||||
request: Request,
|
||||
id: str,
|
||||
lat: float,
|
||||
lon: float,
|
||||
timestamp: Optional[int] = None,
|
||||
speed: Optional[float] = None,
|
||||
bearing: Optional[float] = None,
|
||||
altitude: Optional[float] = None,
|
||||
accuracy: Optional[float] = None,
|
||||
batt: Optional[float] = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Endpoint compatible con OsmAnd Live Tracking.
|
||||
|
||||
OsmAnd envía ubicaciones mediante GET con parámetros en URL.
|
||||
|
||||
Args:
|
||||
id: Identificador del dispositivo.
|
||||
lat: Latitud.
|
||||
lon: Longitud.
|
||||
timestamp: Unix timestamp (opcional).
|
||||
speed: Velocidad en km/h (opcional).
|
||||
bearing: Rumbo en grados (opcional).
|
||||
altitude: Altitud en metros (opcional).
|
||||
accuracy: Precisión en metros (opcional).
|
||||
batt: Porcentaje de batería (opcional).
|
||||
|
||||
Returns:
|
||||
Confirmación de recepción.
|
||||
"""
|
||||
ubicacion_data = UbicacionCreate(
|
||||
dispositivo_id=id,
|
||||
lat=lat,
|
||||
lng=lon,
|
||||
velocidad=speed,
|
||||
rumbo=bearing,
|
||||
altitud=altitude,
|
||||
precision=accuracy,
|
||||
bateria_dispositivo=batt,
|
||||
tiempo=datetime.fromtimestamp(timestamp) if timestamp else None,
|
||||
fuente="osmand",
|
||||
)
|
||||
|
||||
ubicacion_service = UbicacionService(db)
|
||||
resultado = await ubicacion_service.procesar_ubicacion(ubicacion_data)
|
||||
|
||||
if resultado:
|
||||
return {"status": "ok"}
|
||||
return {"status": "device_not_found"}
|
||||
|
||||
|
||||
@router.get("/historial/{vehiculo_id}", response_model=HistorialUbicacionesResponse)
|
||||
async def obtener_historial(
|
||||
vehiculo_id: int,
|
||||
desde: datetime,
|
||||
hasta: datetime,
|
||||
simplificar: bool = True,
|
||||
intervalo_segundos: Optional[int] = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: Usuario = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Obtiene el historial de ubicaciones de un vehículo.
|
||||
|
||||
Args:
|
||||
vehiculo_id: ID del vehículo.
|
||||
desde: Fecha/hora de inicio.
|
||||
hasta: Fecha/hora de fin.
|
||||
simplificar: Simplificar ruta (Douglas-Peucker).
|
||||
intervalo_segundos: Muestreo por intervalo.
|
||||
|
||||
Returns:
|
||||
Historial con estadísticas.
|
||||
"""
|
||||
ubicacion_service = UbicacionService(db)
|
||||
return await ubicacion_service.obtener_historial(
|
||||
vehiculo_id,
|
||||
desde,
|
||||
hasta,
|
||||
simplificar,
|
||||
intervalo_segundos,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/ultima/{vehiculo_id}", response_model=Optional[UbicacionResponse])
|
||||
async def obtener_ultima_ubicacion(
|
||||
vehiculo_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: Usuario = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Obtiene la última ubicación de un vehículo.
|
||||
|
||||
Args:
|
||||
vehiculo_id: ID del vehículo.
|
||||
|
||||
Returns:
|
||||
Última ubicación conocida.
|
||||
"""
|
||||
ubicacion_service = UbicacionService(db)
|
||||
return await ubicacion_service.obtener_ultima_ubicacion(vehiculo_id)
|
||||
|
||||
|
||||
@router.get("/flota")
|
||||
async def obtener_ubicaciones_flota(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: Usuario = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Obtiene las ubicaciones actuales de toda la flota.
|
||||
|
||||
Returns:
|
||||
Lista de ubicaciones de todos los vehículos activos.
|
||||
"""
|
||||
ubicacion_service = UbicacionService(db)
|
||||
return await ubicacion_service.obtener_ubicaciones_flota()
|
||||
481
backend/app/api/v1/vehiculos.py
Normal file
481
backend/app/api/v1/vehiculos.py
Normal file
@@ -0,0 +1,481 @@
|
||||
"""
|
||||
Endpoints para gestión de vehículos.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.security import get_current_user
|
||||
from app.models.usuario import Usuario
|
||||
from app.models.vehiculo import Vehiculo
|
||||
from app.models.viaje import Viaje
|
||||
from app.models.alerta import Alerta
|
||||
from app.schemas.vehiculo import (
|
||||
VehiculoCreate,
|
||||
VehiculoUpdate,
|
||||
VehiculoResponse,
|
||||
VehiculoResumen,
|
||||
VehiculoConRelaciones,
|
||||
VehiculoUbicacionActual,
|
||||
VehiculoEstadisticas,
|
||||
)
|
||||
from app.schemas.base import PaginatedResponse
|
||||
from app.services.ubicacion_service import UbicacionService
|
||||
|
||||
router = APIRouter(prefix="/vehiculos", tags=["Vehiculos"])
|
||||
|
||||
|
||||
@router.get("", response_model=List[VehiculoResumen])
|
||||
async def listar_vehiculos(
|
||||
activo: Optional[bool] = None,
|
||||
en_servicio: Optional[bool] = None,
|
||||
grupo_id: Optional[int] = None,
|
||||
buscar: Optional[str] = None,
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=100),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: Usuario = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Lista todos los vehículos con filtros opcionales.
|
||||
|
||||
Args:
|
||||
activo: Filtrar por estado activo.
|
||||
en_servicio: Filtrar por en servicio.
|
||||
grupo_id: Filtrar por grupo.
|
||||
buscar: Búsqueda por nombre o placa.
|
||||
skip: Registros a saltar.
|
||||
limit: Límite de registros.
|
||||
|
||||
Returns:
|
||||
Lista de vehículos.
|
||||
"""
|
||||
query = select(Vehiculo)
|
||||
|
||||
if activo is not None:
|
||||
query = query.where(Vehiculo.activo == activo)
|
||||
if en_servicio is not None:
|
||||
query = query.where(Vehiculo.en_servicio == en_servicio)
|
||||
if grupo_id:
|
||||
query = query.where(Vehiculo.grupo_id == grupo_id)
|
||||
if buscar:
|
||||
query = query.where(
|
||||
(Vehiculo.nombre.ilike(f"%{buscar}%")) |
|
||||
(Vehiculo.placa.ilike(f"%{buscar}%"))
|
||||
)
|
||||
|
||||
query = query.offset(skip).limit(limit).order_by(Vehiculo.nombre)
|
||||
|
||||
result = await db.execute(query)
|
||||
vehiculos = result.scalars().all()
|
||||
|
||||
return [VehiculoResumen.model_validate(v) for v in vehiculos]
|
||||
|
||||
|
||||
@router.get("/ubicaciones", response_model=List[VehiculoUbicacionActual])
|
||||
async def obtener_ubicaciones_flota(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: Usuario = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Obtiene las ubicaciones actuales de todos los vehículos.
|
||||
|
||||
Returns:
|
||||
Lista de ubicaciones actuales.
|
||||
"""
|
||||
ubicacion_service = UbicacionService(db)
|
||||
return await ubicacion_service.obtener_ubicaciones_flota()
|
||||
|
||||
|
||||
@router.get("/{vehiculo_id}", response_model=VehiculoConRelaciones)
|
||||
async def obtener_vehiculo(
|
||||
vehiculo_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: Usuario = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Obtiene un vehículo por su ID con todas sus relaciones.
|
||||
|
||||
Args:
|
||||
vehiculo_id: ID del vehículo.
|
||||
|
||||
Returns:
|
||||
Vehículo con relaciones.
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(Vehiculo)
|
||||
.options(
|
||||
selectinload(Vehiculo.conductor),
|
||||
selectinload(Vehiculo.grupo),
|
||||
selectinload(Vehiculo.dispositivos),
|
||||
)
|
||||
.where(Vehiculo.id == vehiculo_id)
|
||||
)
|
||||
vehiculo = result.scalar_one_or_none()
|
||||
|
||||
if not vehiculo:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Vehículo con id {vehiculo_id} no encontrado",
|
||||
)
|
||||
|
||||
return VehiculoConRelaciones.model_validate(vehiculo)
|
||||
|
||||
|
||||
@router.post("", response_model=VehiculoResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def crear_vehiculo(
|
||||
vehiculo_data: VehiculoCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: Usuario = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Crea un nuevo vehículo.
|
||||
|
||||
Args:
|
||||
vehiculo_data: Datos del vehículo.
|
||||
|
||||
Returns:
|
||||
Vehículo creado.
|
||||
"""
|
||||
# Verificar que la placa no exista
|
||||
result = await db.execute(
|
||||
select(Vehiculo).where(Vehiculo.placa == vehiculo_data.placa)
|
||||
)
|
||||
if result.scalar_one_or_none():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"Ya existe un vehículo con la placa {vehiculo_data.placa}",
|
||||
)
|
||||
|
||||
# Verificar VIN si se proporciona
|
||||
if vehiculo_data.vin:
|
||||
result = await db.execute(
|
||||
select(Vehiculo).where(Vehiculo.vin == vehiculo_data.vin)
|
||||
)
|
||||
if result.scalar_one_or_none():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"Ya existe un vehículo con el VIN {vehiculo_data.vin}",
|
||||
)
|
||||
|
||||
vehiculo = Vehiculo(**vehiculo_data.model_dump())
|
||||
vehiculo.odometro_actual = vehiculo_data.odometro_inicial
|
||||
|
||||
db.add(vehiculo)
|
||||
await db.commit()
|
||||
await db.refresh(vehiculo)
|
||||
|
||||
return VehiculoResponse.model_validate(vehiculo)
|
||||
|
||||
|
||||
@router.put("/{vehiculo_id}", response_model=VehiculoResponse)
|
||||
async def actualizar_vehiculo(
|
||||
vehiculo_id: int,
|
||||
vehiculo_data: VehiculoUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: Usuario = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Actualiza un vehículo existente.
|
||||
|
||||
Args:
|
||||
vehiculo_id: ID del vehículo.
|
||||
vehiculo_data: Datos a actualizar.
|
||||
|
||||
Returns:
|
||||
Vehículo actualizado.
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(Vehiculo).where(Vehiculo.id == vehiculo_id)
|
||||
)
|
||||
vehiculo = result.scalar_one_or_none()
|
||||
|
||||
if not vehiculo:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Vehículo con id {vehiculo_id} no encontrado",
|
||||
)
|
||||
|
||||
# Verificar placa única si se cambia
|
||||
if vehiculo_data.placa and vehiculo_data.placa != vehiculo.placa:
|
||||
result = await db.execute(
|
||||
select(Vehiculo).where(Vehiculo.placa == vehiculo_data.placa)
|
||||
)
|
||||
if result.scalar_one_or_none():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"Ya existe un vehículo con la placa {vehiculo_data.placa}",
|
||||
)
|
||||
|
||||
update_data = vehiculo_data.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(vehiculo, field, value)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(vehiculo)
|
||||
|
||||
return VehiculoResponse.model_validate(vehiculo)
|
||||
|
||||
|
||||
@router.delete("/{vehiculo_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def eliminar_vehiculo(
|
||||
vehiculo_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: Usuario = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Elimina un vehículo (soft delete - desactiva).
|
||||
|
||||
Args:
|
||||
vehiculo_id: ID del vehículo.
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(Vehiculo).where(Vehiculo.id == vehiculo_id)
|
||||
)
|
||||
vehiculo = result.scalar_one_or_none()
|
||||
|
||||
if not vehiculo:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Vehículo con id {vehiculo_id} no encontrado",
|
||||
)
|
||||
|
||||
# Soft delete
|
||||
vehiculo.activo = False
|
||||
vehiculo.en_servicio = False
|
||||
await db.commit()
|
||||
|
||||
|
||||
@router.get("/{vehiculo_id}/ubicacion")
|
||||
async def obtener_ubicacion_actual(
|
||||
vehiculo_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: Usuario = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Obtiene la ubicación actual de un vehículo.
|
||||
|
||||
Args:
|
||||
vehiculo_id: ID del vehículo.
|
||||
|
||||
Returns:
|
||||
Última ubicación conocida.
|
||||
"""
|
||||
ubicacion_service = UbicacionService(db)
|
||||
ubicacion = await ubicacion_service.obtener_ultima_ubicacion(vehiculo_id)
|
||||
|
||||
if not ubicacion:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="No hay ubicación registrada para este vehículo",
|
||||
)
|
||||
|
||||
return ubicacion
|
||||
|
||||
|
||||
@router.get("/{vehiculo_id}/historial")
|
||||
async def obtener_historial_ubicaciones(
|
||||
vehiculo_id: int,
|
||||
desde: datetime,
|
||||
hasta: datetime,
|
||||
simplificar: bool = True,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: Usuario = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Obtiene el historial de ubicaciones de un vehículo.
|
||||
|
||||
Args:
|
||||
vehiculo_id: ID del vehículo.
|
||||
desde: Fecha/hora de inicio.
|
||||
hasta: Fecha/hora de fin.
|
||||
simplificar: Simplificar la ruta.
|
||||
|
||||
Returns:
|
||||
Historial de ubicaciones con estadísticas.
|
||||
"""
|
||||
ubicacion_service = UbicacionService(db)
|
||||
return await ubicacion_service.obtener_historial(
|
||||
vehiculo_id, desde, hasta, simplificar
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{vehiculo_id}/viajes", response_model=List[dict])
|
||||
async def obtener_viajes_vehiculo(
|
||||
vehiculo_id: int,
|
||||
desde: Optional[datetime] = None,
|
||||
hasta: Optional[datetime] = None,
|
||||
limite: int = Query(50, ge=1, le=100),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: Usuario = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Obtiene los viajes de un vehículo.
|
||||
|
||||
Args:
|
||||
vehiculo_id: ID del vehículo.
|
||||
desde: Fecha inicio (opcional).
|
||||
hasta: Fecha fin (opcional).
|
||||
limite: Límite de resultados.
|
||||
|
||||
Returns:
|
||||
Lista de viajes.
|
||||
"""
|
||||
from app.services.viaje_service import ViajeService
|
||||
|
||||
viaje_service = ViajeService(db)
|
||||
viajes = await viaje_service.obtener_viajes_vehiculo(
|
||||
vehiculo_id, desde, hasta, limite
|
||||
)
|
||||
|
||||
return [
|
||||
{
|
||||
"id": v.id,
|
||||
"inicio_tiempo": v.inicio_tiempo,
|
||||
"fin_tiempo": v.fin_tiempo,
|
||||
"inicio_direccion": v.inicio_direccion,
|
||||
"fin_direccion": v.fin_direccion,
|
||||
"distancia_km": v.distancia_km,
|
||||
"duracion_formateada": v.duracion_formateada,
|
||||
"estado": v.estado,
|
||||
}
|
||||
for v in viajes
|
||||
]
|
||||
|
||||
|
||||
@router.get("/{vehiculo_id}/alertas", response_model=List[dict])
|
||||
async def obtener_alertas_vehiculo(
|
||||
vehiculo_id: int,
|
||||
atendidas: Optional[bool] = None,
|
||||
limite: int = Query(50, ge=1, le=100),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: Usuario = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Obtiene las alertas de un vehículo.
|
||||
|
||||
Args:
|
||||
vehiculo_id: ID del vehículo.
|
||||
atendidas: Filtrar por estado de atención.
|
||||
limite: Límite de resultados.
|
||||
|
||||
Returns:
|
||||
Lista de alertas.
|
||||
"""
|
||||
query = (
|
||||
select(Alerta)
|
||||
.where(Alerta.vehiculo_id == vehiculo_id)
|
||||
.order_by(Alerta.creado_en.desc())
|
||||
.limit(limite)
|
||||
)
|
||||
|
||||
if atendidas is not None:
|
||||
query = query.where(Alerta.atendida == atendidas)
|
||||
|
||||
result = await db.execute(query)
|
||||
alertas = result.scalars().all()
|
||||
|
||||
return [
|
||||
{
|
||||
"id": a.id,
|
||||
"tipo_alerta_id": a.tipo_alerta_id,
|
||||
"severidad": a.severidad,
|
||||
"mensaje": a.mensaje,
|
||||
"creado_en": a.creado_en,
|
||||
"atendida": a.atendida,
|
||||
}
|
||||
for a in alertas
|
||||
]
|
||||
|
||||
|
||||
@router.get("/{vehiculo_id}/estadisticas", response_model=VehiculoEstadisticas)
|
||||
async def obtener_estadisticas_vehiculo(
|
||||
vehiculo_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: Usuario = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Obtiene estadísticas de un vehículo.
|
||||
|
||||
Args:
|
||||
vehiculo_id: ID del vehículo.
|
||||
|
||||
Returns:
|
||||
Estadísticas del vehículo.
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(Vehiculo).where(Vehiculo.id == vehiculo_id)
|
||||
)
|
||||
vehiculo = result.scalar_one_or_none()
|
||||
|
||||
if not vehiculo:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Vehículo con id {vehiculo_id} no encontrado",
|
||||
)
|
||||
|
||||
ahora = datetime.now(timezone.utc)
|
||||
inicio_hoy = ahora.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
inicio_semana = ahora - timedelta(days=7)
|
||||
inicio_mes = ahora - timedelta(days=30)
|
||||
|
||||
# Distancia hoy
|
||||
result = await db.execute(
|
||||
select(func.coalesce(func.sum(Viaje.distancia_km), 0))
|
||||
.where(Viaje.vehiculo_id == vehiculo_id)
|
||||
.where(Viaje.inicio_tiempo >= inicio_hoy)
|
||||
)
|
||||
distancia_hoy = result.scalar() or 0
|
||||
|
||||
# Distancia semana
|
||||
result = await db.execute(
|
||||
select(func.coalesce(func.sum(Viaje.distancia_km), 0))
|
||||
.where(Viaje.vehiculo_id == vehiculo_id)
|
||||
.where(Viaje.inicio_tiempo >= inicio_semana)
|
||||
)
|
||||
distancia_semana = result.scalar() or 0
|
||||
|
||||
# Distancia mes
|
||||
result = await db.execute(
|
||||
select(func.coalesce(func.sum(Viaje.distancia_km), 0))
|
||||
.where(Viaje.vehiculo_id == vehiculo_id)
|
||||
.where(Viaje.inicio_tiempo >= inicio_mes)
|
||||
)
|
||||
distancia_mes = result.scalar() or 0
|
||||
|
||||
# Alertas activas
|
||||
result = await db.execute(
|
||||
select(func.count(Alerta.id))
|
||||
.where(Alerta.vehiculo_id == vehiculo_id)
|
||||
.where(Alerta.atendida == False)
|
||||
)
|
||||
alertas_activas = result.scalar() or 0
|
||||
|
||||
# Alertas mes
|
||||
result = await db.execute(
|
||||
select(func.count(Alerta.id))
|
||||
.where(Alerta.vehiculo_id == vehiculo_id)
|
||||
.where(Alerta.creado_en >= inicio_mes)
|
||||
)
|
||||
alertas_mes = result.scalar() or 0
|
||||
|
||||
return VehiculoEstadisticas(
|
||||
vehiculo_id=vehiculo.id,
|
||||
nombre=vehiculo.nombre,
|
||||
placa=vehiculo.placa,
|
||||
distancia_hoy_km=float(distancia_hoy),
|
||||
distancia_semana_km=float(distancia_semana),
|
||||
distancia_mes_km=float(distancia_mes),
|
||||
distancia_total_km=vehiculo.distancia_recorrida,
|
||||
tiempo_movimiento_hoy_min=0, # TODO: Calcular
|
||||
tiempo_parado_hoy_min=0, # TODO: Calcular
|
||||
alertas_activas=alertas_activas,
|
||||
alertas_mes=alertas_mes,
|
||||
mantenimientos_vencidos=0, # TODO: Calcular
|
||||
)
|
||||
339
backend/app/api/v1/viajes.py
Normal file
339
backend/app/api/v1/viajes.py
Normal file
@@ -0,0 +1,339 @@
|
||||
"""
|
||||
Endpoints para gestión de viajes.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.security import get_current_user
|
||||
from app.models.usuario import Usuario
|
||||
from app.models.viaje import Viaje
|
||||
from app.schemas.viaje import (
|
||||
ViajeResponse,
|
||||
ViajeResumen,
|
||||
ViajeConParadas,
|
||||
ViajeReplayData,
|
||||
ParadaResponse,
|
||||
)
|
||||
from app.schemas.ubicacion import UbicacionResponse
|
||||
from app.services.viaje_service import ViajeService
|
||||
|
||||
router = APIRouter(prefix="/viajes", tags=["Viajes"])
|
||||
|
||||
|
||||
@router.get("", response_model=List[ViajeResumen])
|
||||
async def listar_viajes(
|
||||
vehiculo_id: Optional[int] = None,
|
||||
conductor_id: Optional[int] = None,
|
||||
estado: Optional[str] = None,
|
||||
desde: Optional[datetime] = None,
|
||||
hasta: Optional[datetime] = None,
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=100),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: Usuario = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Lista viajes con filtros opcionales.
|
||||
|
||||
Args:
|
||||
vehiculo_id: Filtrar por vehículo.
|
||||
conductor_id: Filtrar por conductor.
|
||||
estado: Filtrar por estado.
|
||||
desde: Fecha inicio.
|
||||
hasta: Fecha fin.
|
||||
skip: Registros a saltar.
|
||||
limit: Límite de registros.
|
||||
|
||||
Returns:
|
||||
Lista de viajes.
|
||||
"""
|
||||
query = (
|
||||
select(Viaje)
|
||||
.options(
|
||||
selectinload(Viaje.vehiculo),
|
||||
selectinload(Viaje.conductor),
|
||||
)
|
||||
.order_by(Viaje.inicio_tiempo.desc())
|
||||
)
|
||||
|
||||
if vehiculo_id:
|
||||
query = query.where(Viaje.vehiculo_id == vehiculo_id)
|
||||
if conductor_id:
|
||||
query = query.where(Viaje.conductor_id == conductor_id)
|
||||
if estado:
|
||||
query = query.where(Viaje.estado == estado)
|
||||
if desde:
|
||||
query = query.where(Viaje.inicio_tiempo >= desde)
|
||||
if hasta:
|
||||
query = query.where(Viaje.inicio_tiempo <= hasta)
|
||||
|
||||
query = query.offset(skip).limit(limit)
|
||||
|
||||
result = await db.execute(query)
|
||||
viajes = result.scalars().all()
|
||||
|
||||
return [
|
||||
ViajeResumen(
|
||||
id=v.id,
|
||||
vehiculo_id=v.vehiculo_id,
|
||||
vehiculo_nombre=v.vehiculo.nombre if v.vehiculo else None,
|
||||
vehiculo_placa=v.vehiculo.placa if v.vehiculo else None,
|
||||
conductor_nombre=v.conductor.nombre_completo if v.conductor else None,
|
||||
inicio_tiempo=v.inicio_tiempo,
|
||||
fin_tiempo=v.fin_tiempo,
|
||||
inicio_direccion=v.inicio_direccion,
|
||||
fin_direccion=v.fin_direccion,
|
||||
distancia_km=v.distancia_km,
|
||||
duracion_formateada=v.duracion_formateada,
|
||||
estado=v.estado,
|
||||
)
|
||||
for v in viajes
|
||||
]
|
||||
|
||||
|
||||
@router.get("/{viaje_id}", response_model=ViajeConParadas)
|
||||
async def obtener_viaje(
|
||||
viaje_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: Usuario = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Obtiene un viaje por su ID con paradas.
|
||||
|
||||
Args:
|
||||
viaje_id: ID del viaje.
|
||||
|
||||
Returns:
|
||||
Viaje con paradas.
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(Viaje)
|
||||
.options(
|
||||
selectinload(Viaje.vehiculo),
|
||||
selectinload(Viaje.conductor),
|
||||
selectinload(Viaje.paradas),
|
||||
)
|
||||
.where(Viaje.id == viaje_id)
|
||||
)
|
||||
viaje = result.scalar_one_or_none()
|
||||
|
||||
if not viaje:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Viaje con id {viaje_id} no encontrado",
|
||||
)
|
||||
|
||||
return ViajeConParadas(
|
||||
id=viaje.id,
|
||||
vehiculo_id=viaje.vehiculo_id,
|
||||
conductor_id=viaje.conductor_id,
|
||||
proposito=viaje.proposito,
|
||||
notas=viaje.notas,
|
||||
inicio_tiempo=viaje.inicio_tiempo,
|
||||
fin_tiempo=viaje.fin_tiempo,
|
||||
inicio_lat=viaje.inicio_lat,
|
||||
inicio_lng=viaje.inicio_lng,
|
||||
inicio_direccion=viaje.inicio_direccion,
|
||||
fin_lat=viaje.fin_lat,
|
||||
fin_lng=viaje.fin_lng,
|
||||
fin_direccion=viaje.fin_direccion,
|
||||
distancia_km=viaje.distancia_km,
|
||||
duracion_segundos=viaje.duracion_segundos,
|
||||
tiempo_movimiento_segundos=viaje.tiempo_movimiento_segundos,
|
||||
tiempo_parado_segundos=viaje.tiempo_parado_segundos,
|
||||
velocidad_promedio=viaje.velocidad_promedio,
|
||||
velocidad_maxima=viaje.velocidad_maxima,
|
||||
combustible_usado=viaje.combustible_usado,
|
||||
rendimiento=viaje.rendimiento,
|
||||
odometro_inicio=viaje.odometro_inicio,
|
||||
odometro_fin=viaje.odometro_fin,
|
||||
estado=viaje.estado,
|
||||
puntos_gps=viaje.puntos_gps,
|
||||
duracion_formateada=viaje.duracion_formateada,
|
||||
en_curso=viaje.en_curso,
|
||||
creado_en=viaje.creado_en,
|
||||
actualizado_en=viaje.actualizado_en,
|
||||
paradas=[
|
||||
ParadaResponse(
|
||||
id=p.id,
|
||||
viaje_id=p.viaje_id,
|
||||
vehiculo_id=p.vehiculo_id,
|
||||
inicio_tiempo=p.inicio_tiempo,
|
||||
fin_tiempo=p.fin_tiempo,
|
||||
duracion_segundos=p.duracion_segundos,
|
||||
lat=p.lat,
|
||||
lng=p.lng,
|
||||
direccion=p.direccion,
|
||||
tipo=p.tipo,
|
||||
motor_apagado=p.motor_apagado,
|
||||
poi_id=p.poi_id,
|
||||
geocerca_id=p.geocerca_id,
|
||||
en_curso=p.en_curso,
|
||||
notas=p.notas,
|
||||
duracion_formateada=p.duracion_formateada,
|
||||
)
|
||||
for p in viaje.paradas
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{viaje_id}/replay")
|
||||
async def obtener_replay_viaje(
|
||||
viaje_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: Usuario = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Obtiene datos para replay de un viaje.
|
||||
|
||||
Args:
|
||||
viaje_id: ID del viaje.
|
||||
|
||||
Returns:
|
||||
Viaje con ubicaciones y paradas.
|
||||
"""
|
||||
viaje_service = ViajeService(db)
|
||||
datos = await viaje_service.obtener_replay_viaje(viaje_id)
|
||||
|
||||
if not datos:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Viaje con id {viaje_id} no encontrado",
|
||||
)
|
||||
|
||||
viaje = datos["viaje"]
|
||||
|
||||
return {
|
||||
"viaje": {
|
||||
"id": viaje.id,
|
||||
"vehiculo_id": viaje.vehiculo_id,
|
||||
"inicio_tiempo": viaje.inicio_tiempo,
|
||||
"fin_tiempo": viaje.fin_tiempo,
|
||||
"inicio_lat": viaje.inicio_lat,
|
||||
"inicio_lng": viaje.inicio_lng,
|
||||
"fin_lat": viaje.fin_lat,
|
||||
"fin_lng": viaje.fin_lng,
|
||||
"distancia_km": viaje.distancia_km,
|
||||
"duracion_formateada": viaje.duracion_formateada,
|
||||
"estado": viaje.estado,
|
||||
},
|
||||
"ubicaciones": [
|
||||
{
|
||||
"tiempo": u.tiempo,
|
||||
"lat": u.lat,
|
||||
"lng": u.lng,
|
||||
"velocidad": u.velocidad,
|
||||
"rumbo": u.rumbo,
|
||||
"motor_encendido": u.motor_encendido,
|
||||
}
|
||||
for u in datos["ubicaciones"]
|
||||
],
|
||||
"paradas": [
|
||||
{
|
||||
"id": p.id,
|
||||
"inicio_tiempo": p.inicio_tiempo,
|
||||
"fin_tiempo": p.fin_tiempo,
|
||||
"duracion_formateada": p.duracion_formateada,
|
||||
"lat": p.lat,
|
||||
"lng": p.lng,
|
||||
"direccion": p.direccion,
|
||||
"tipo": p.tipo,
|
||||
}
|
||||
for p in datos["paradas"]
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{viaje_id}/geojson")
|
||||
async def obtener_viaje_geojson(
|
||||
viaje_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: Usuario = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Obtiene la ruta de un viaje en formato GeoJSON.
|
||||
|
||||
Args:
|
||||
viaje_id: ID del viaje.
|
||||
|
||||
Returns:
|
||||
GeoJSON LineString de la ruta.
|
||||
"""
|
||||
viaje_service = ViajeService(db)
|
||||
datos = await viaje_service.obtener_replay_viaje(viaje_id)
|
||||
|
||||
if not datos:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Viaje con id {viaje_id} no encontrado",
|
||||
)
|
||||
|
||||
viaje = datos["viaje"]
|
||||
ubicaciones = datos["ubicaciones"]
|
||||
|
||||
# Crear LineString
|
||||
coordinates = [[u.lng, u.lat] for u in ubicaciones]
|
||||
|
||||
return {
|
||||
"type": "Feature",
|
||||
"geometry": {
|
||||
"type": "LineString",
|
||||
"coordinates": coordinates,
|
||||
},
|
||||
"properties": {
|
||||
"viaje_id": viaje.id,
|
||||
"vehiculo_id": viaje.vehiculo_id,
|
||||
"inicio_tiempo": viaje.inicio_tiempo.isoformat(),
|
||||
"fin_tiempo": viaje.fin_tiempo.isoformat() if viaje.fin_tiempo else None,
|
||||
"distancia_km": viaje.distancia_km,
|
||||
"estado": viaje.estado,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.get("/activos/lista", response_model=List[ViajeResumen])
|
||||
async def listar_viajes_activos(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: Usuario = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Lista viajes actualmente en curso.
|
||||
|
||||
Returns:
|
||||
Lista de viajes en curso.
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(Viaje)
|
||||
.options(
|
||||
selectinload(Viaje.vehiculo),
|
||||
selectinload(Viaje.conductor),
|
||||
)
|
||||
.where(Viaje.estado == "en_curso")
|
||||
.order_by(Viaje.inicio_tiempo.desc())
|
||||
)
|
||||
viajes = result.scalars().all()
|
||||
|
||||
return [
|
||||
ViajeResumen(
|
||||
id=v.id,
|
||||
vehiculo_id=v.vehiculo_id,
|
||||
vehiculo_nombre=v.vehiculo.nombre if v.vehiculo else None,
|
||||
vehiculo_placa=v.vehiculo.placa if v.vehiculo else None,
|
||||
conductor_nombre=v.conductor.nombre_completo if v.conductor else None,
|
||||
inicio_tiempo=v.inicio_tiempo,
|
||||
fin_tiempo=v.fin_tiempo,
|
||||
inicio_direccion=v.inicio_direccion,
|
||||
fin_direccion=v.fin_direccion,
|
||||
distancia_km=v.distancia_km,
|
||||
duracion_formateada=v.duracion_formateada,
|
||||
estado=v.estado,
|
||||
)
|
||||
for v in viajes
|
||||
]
|
||||
14
backend/app/api/websocket/__init__.py
Normal file
14
backend/app/api/websocket/__init__.py
Normal file
@@ -0,0 +1,14 @@
|
||||
"""
|
||||
Módulo WebSocket - Endpoints para comunicación en tiempo real.
|
||||
"""
|
||||
|
||||
from app.api.websocket.manager import manager, ConnectionManager
|
||||
from app.api.websocket.ubicaciones import router as ubicaciones_router
|
||||
from app.api.websocket.alertas import router as alertas_router
|
||||
|
||||
__all__ = [
|
||||
"manager",
|
||||
"ConnectionManager",
|
||||
"ubicaciones_router",
|
||||
"alertas_router",
|
||||
]
|
||||
125
backend/app/api/websocket/alertas.py
Normal file
125
backend/app/api/websocket/alertas.py
Normal file
@@ -0,0 +1,125 @@
|
||||
"""
|
||||
WebSocket endpoint para alertas en tiempo real.
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Query, WebSocket, WebSocketDisconnect
|
||||
|
||||
from app.api.websocket.manager import manager
|
||||
from app.core.security import decode_token
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
async def get_user_from_token(token: Optional[str]) -> Optional[int]:
|
||||
"""
|
||||
Obtiene el ID de usuario desde un token JWT.
|
||||
|
||||
Args:
|
||||
token: Token JWT.
|
||||
|
||||
Returns:
|
||||
ID del usuario o None.
|
||||
"""
|
||||
if not token:
|
||||
return None
|
||||
try:
|
||||
payload = decode_token(token)
|
||||
return int(payload.get("sub"))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
@router.websocket("/ws/alertas")
|
||||
async def websocket_alertas(
|
||||
websocket: WebSocket,
|
||||
token: Optional[str] = Query(None),
|
||||
):
|
||||
"""
|
||||
WebSocket para recibir alertas en tiempo real.
|
||||
|
||||
Recibe todas las alertas generadas en el sistema.
|
||||
|
||||
Args:
|
||||
websocket: Conexión WebSocket.
|
||||
token: Token JWT para autenticación (opcional).
|
||||
"""
|
||||
user_id = await get_user_from_token(token)
|
||||
|
||||
await manager.connect(websocket, "alertas", user_id)
|
||||
|
||||
# Enviar confirmación
|
||||
await websocket.send_json({
|
||||
"type": "connected",
|
||||
"channel": "alerts",
|
||||
})
|
||||
|
||||
try:
|
||||
while True:
|
||||
data = await websocket.receive_text()
|
||||
|
||||
try:
|
||||
message = json.loads(data)
|
||||
|
||||
if message.get("action") == "ping":
|
||||
await websocket.send_json({"type": "pong"})
|
||||
|
||||
elif message.get("action") == "acknowledge":
|
||||
# Cliente confirma recepción de alerta
|
||||
alert_id = message.get("alert_id")
|
||||
await websocket.send_json({
|
||||
"type": "acknowledged",
|
||||
"alert_id": alert_id,
|
||||
})
|
||||
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
except WebSocketDisconnect:
|
||||
await manager.disconnect(websocket, "alertas", user_id)
|
||||
|
||||
|
||||
@router.websocket("/ws/alertas/vehiculo/{vehiculo_id}")
|
||||
async def websocket_alertas_vehiculo(
|
||||
websocket: WebSocket,
|
||||
vehiculo_id: int,
|
||||
token: Optional[str] = Query(None),
|
||||
):
|
||||
"""
|
||||
WebSocket para alertas de un vehículo específico.
|
||||
|
||||
Args:
|
||||
websocket: Conexión WebSocket.
|
||||
vehiculo_id: ID del vehículo.
|
||||
token: Token JWT para autenticación (opcional).
|
||||
"""
|
||||
user_id = await get_user_from_token(token)
|
||||
|
||||
await manager.connect(websocket, "alertas", user_id)
|
||||
await manager.subscribe_vehicle(websocket, vehiculo_id)
|
||||
|
||||
# Enviar confirmación
|
||||
await websocket.send_json({
|
||||
"type": "connected",
|
||||
"channel": "vehicle_alerts",
|
||||
"vehicle_id": vehiculo_id,
|
||||
})
|
||||
|
||||
try:
|
||||
while True:
|
||||
data = await websocket.receive_text()
|
||||
|
||||
try:
|
||||
message = json.loads(data)
|
||||
|
||||
if message.get("action") == "ping":
|
||||
await websocket.send_json({"type": "pong"})
|
||||
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
except WebSocketDisconnect:
|
||||
await manager.unsubscribe_vehicle(websocket, vehiculo_id)
|
||||
await manager.disconnect(websocket, "alertas", user_id)
|
||||
266
backend/app/api/websocket/manager.py
Normal file
266
backend/app/api/websocket/manager.py
Normal file
@@ -0,0 +1,266 @@
|
||||
"""
|
||||
Gestor de conexiones WebSocket.
|
||||
|
||||
Maneja las conexiones de clientes WebSocket para
|
||||
actualizaciones en tiempo real.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from typing import Dict, List, Optional, Set
|
||||
|
||||
from fastapi import WebSocket
|
||||
|
||||
|
||||
class ConnectionManager:
|
||||
"""
|
||||
Gestor de conexiones WebSocket.
|
||||
|
||||
Mantiene un registro de conexiones activas y permite
|
||||
enviar mensajes a clientes específicos o a todos.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""Inicializa el gestor de conexiones."""
|
||||
# Conexiones activas por tipo de suscripción
|
||||
self._connections: Dict[str, Set[WebSocket]] = {
|
||||
"ubicaciones": set(),
|
||||
"alertas": set(),
|
||||
"vehiculos": set(),
|
||||
}
|
||||
# Conexiones por usuario
|
||||
self._user_connections: Dict[int, Set[WebSocket]] = {}
|
||||
# Suscripciones a vehículos específicos
|
||||
self._vehicle_subscriptions: Dict[int, Set[WebSocket]] = {}
|
||||
# Lock para operaciones thread-safe
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def connect(
|
||||
self,
|
||||
websocket: WebSocket,
|
||||
channel: str = "ubicaciones",
|
||||
user_id: Optional[int] = None,
|
||||
) -> None:
|
||||
"""
|
||||
Acepta una nueva conexión WebSocket.
|
||||
|
||||
Args:
|
||||
websocket: Conexión WebSocket.
|
||||
channel: Canal de suscripción.
|
||||
user_id: ID del usuario (opcional).
|
||||
"""
|
||||
await websocket.accept()
|
||||
|
||||
async with self._lock:
|
||||
# Agregar a conexiones del canal
|
||||
if channel in self._connections:
|
||||
self._connections[channel].add(websocket)
|
||||
|
||||
# Agregar a conexiones del usuario
|
||||
if user_id:
|
||||
if user_id not in self._user_connections:
|
||||
self._user_connections[user_id] = set()
|
||||
self._user_connections[user_id].add(websocket)
|
||||
|
||||
async def disconnect(
|
||||
self,
|
||||
websocket: WebSocket,
|
||||
channel: str = "ubicaciones",
|
||||
user_id: Optional[int] = None,
|
||||
) -> None:
|
||||
"""
|
||||
Desconecta un WebSocket.
|
||||
|
||||
Args:
|
||||
websocket: Conexión WebSocket.
|
||||
channel: Canal de suscripción.
|
||||
user_id: ID del usuario (opcional).
|
||||
"""
|
||||
async with self._lock:
|
||||
# Remover de conexiones del canal
|
||||
if channel in self._connections:
|
||||
self._connections[channel].discard(websocket)
|
||||
|
||||
# Remover de conexiones del usuario
|
||||
if user_id and user_id in self._user_connections:
|
||||
self._user_connections[user_id].discard(websocket)
|
||||
if not self._user_connections[user_id]:
|
||||
del self._user_connections[user_id]
|
||||
|
||||
# Remover de suscripciones de vehículos
|
||||
for vehicle_id in list(self._vehicle_subscriptions.keys()):
|
||||
self._vehicle_subscriptions[vehicle_id].discard(websocket)
|
||||
if not self._vehicle_subscriptions[vehicle_id]:
|
||||
del self._vehicle_subscriptions[vehicle_id]
|
||||
|
||||
async def subscribe_vehicle(
|
||||
self,
|
||||
websocket: WebSocket,
|
||||
vehicle_id: int,
|
||||
) -> None:
|
||||
"""
|
||||
Suscribe un WebSocket a actualizaciones de un vehículo específico.
|
||||
|
||||
Args:
|
||||
websocket: Conexión WebSocket.
|
||||
vehicle_id: ID del vehículo.
|
||||
"""
|
||||
async with self._lock:
|
||||
if vehicle_id not in self._vehicle_subscriptions:
|
||||
self._vehicle_subscriptions[vehicle_id] = set()
|
||||
self._vehicle_subscriptions[vehicle_id].add(websocket)
|
||||
|
||||
async def unsubscribe_vehicle(
|
||||
self,
|
||||
websocket: WebSocket,
|
||||
vehicle_id: int,
|
||||
) -> None:
|
||||
"""
|
||||
Desuscribe un WebSocket de un vehículo.
|
||||
|
||||
Args:
|
||||
websocket: Conexión WebSocket.
|
||||
vehicle_id: ID del vehículo.
|
||||
"""
|
||||
async with self._lock:
|
||||
if vehicle_id in self._vehicle_subscriptions:
|
||||
self._vehicle_subscriptions[vehicle_id].discard(websocket)
|
||||
|
||||
async def broadcast(
|
||||
self,
|
||||
message: dict,
|
||||
channel: str = "ubicaciones",
|
||||
) -> None:
|
||||
"""
|
||||
Envía un mensaje a todos los clientes de un canal.
|
||||
|
||||
Args:
|
||||
message: Mensaje a enviar.
|
||||
channel: Canal de destino.
|
||||
"""
|
||||
if channel not in self._connections:
|
||||
return
|
||||
|
||||
message_json = json.dumps(message, default=str)
|
||||
disconnected = []
|
||||
|
||||
for websocket in self._connections[channel]:
|
||||
try:
|
||||
await websocket.send_text(message_json)
|
||||
except Exception:
|
||||
disconnected.append(websocket)
|
||||
|
||||
# Limpiar conexiones desconectadas
|
||||
for ws in disconnected:
|
||||
await self.disconnect(ws, channel)
|
||||
|
||||
async def broadcast_vehicle_update(
|
||||
self,
|
||||
vehicle_id: int,
|
||||
data: dict,
|
||||
) -> None:
|
||||
"""
|
||||
Envía actualización a suscriptores de un vehículo específico.
|
||||
|
||||
Args:
|
||||
vehicle_id: ID del vehículo.
|
||||
data: Datos a enviar.
|
||||
"""
|
||||
message = {
|
||||
"type": "vehicle_update",
|
||||
"vehicle_id": vehicle_id,
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"data": data,
|
||||
}
|
||||
message_json = json.dumps(message, default=str)
|
||||
|
||||
# Enviar a suscriptores del vehículo
|
||||
if vehicle_id in self._vehicle_subscriptions:
|
||||
disconnected = []
|
||||
for websocket in self._vehicle_subscriptions[vehicle_id]:
|
||||
try:
|
||||
await websocket.send_text(message_json)
|
||||
except Exception:
|
||||
disconnected.append(websocket)
|
||||
|
||||
for ws in disconnected:
|
||||
await self.unsubscribe_vehicle(ws, vehicle_id)
|
||||
|
||||
# También enviar al canal general de ubicaciones
|
||||
await self.broadcast(message, "ubicaciones")
|
||||
|
||||
async def send_to_user(
|
||||
self,
|
||||
user_id: int,
|
||||
message: dict,
|
||||
) -> None:
|
||||
"""
|
||||
Envía un mensaje a todas las conexiones de un usuario.
|
||||
|
||||
Args:
|
||||
user_id: ID del usuario.
|
||||
message: Mensaje a enviar.
|
||||
"""
|
||||
if user_id not in self._user_connections:
|
||||
return
|
||||
|
||||
message_json = json.dumps(message, default=str)
|
||||
disconnected = []
|
||||
|
||||
for websocket in self._user_connections[user_id]:
|
||||
try:
|
||||
await websocket.send_text(message_json)
|
||||
except Exception:
|
||||
disconnected.append(websocket)
|
||||
|
||||
# Limpiar conexiones desconectadas
|
||||
for ws in disconnected:
|
||||
await self.disconnect(ws, user_id=user_id)
|
||||
|
||||
async def send_alert(
|
||||
self,
|
||||
alert_data: dict,
|
||||
) -> None:
|
||||
"""
|
||||
Envía una alerta a todos los clientes suscritos.
|
||||
|
||||
Args:
|
||||
alert_data: Datos de la alerta.
|
||||
"""
|
||||
message = {
|
||||
"type": "alert",
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"data": alert_data,
|
||||
}
|
||||
await self.broadcast(message, "alertas")
|
||||
|
||||
def get_connection_count(self) -> dict:
|
||||
"""
|
||||
Obtiene el conteo de conexiones activas.
|
||||
|
||||
Returns:
|
||||
Dict con conteo por canal.
|
||||
"""
|
||||
return {
|
||||
channel: len(connections)
|
||||
for channel, connections in self._connections.items()
|
||||
}
|
||||
|
||||
def get_vehicle_subscribers(self, vehicle_id: int) -> int:
|
||||
"""
|
||||
Obtiene el número de suscriptores de un vehículo.
|
||||
|
||||
Args:
|
||||
vehicle_id: ID del vehículo.
|
||||
|
||||
Returns:
|
||||
Número de suscriptores.
|
||||
"""
|
||||
if vehicle_id in self._vehicle_subscriptions:
|
||||
return len(self._vehicle_subscriptions[vehicle_id])
|
||||
return 0
|
||||
|
||||
|
||||
# Instancia global del gestor de conexiones
|
||||
manager = ConnectionManager()
|
||||
187
backend/app/api/websocket/ubicaciones.py
Normal file
187
backend/app/api/websocket/ubicaciones.py
Normal file
@@ -0,0 +1,187 @@
|
||||
"""
|
||||
WebSocket endpoint para ubicaciones en tiempo real.
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, WebSocket, WebSocketDisconnect
|
||||
from jose import JWTError
|
||||
|
||||
from app.api.websocket.manager import manager
|
||||
from app.core.security import decode_token
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
async def get_user_from_token(token: Optional[str]) -> Optional[int]:
|
||||
"""
|
||||
Obtiene el ID de usuario desde un token JWT.
|
||||
|
||||
Args:
|
||||
token: Token JWT.
|
||||
|
||||
Returns:
|
||||
ID del usuario o None.
|
||||
"""
|
||||
if not token:
|
||||
return None
|
||||
try:
|
||||
payload = decode_token(token)
|
||||
return int(payload.get("sub"))
|
||||
except (JWTError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
@router.websocket("/ws/ubicaciones")
|
||||
async def websocket_ubicaciones(
|
||||
websocket: WebSocket,
|
||||
token: Optional[str] = Query(None),
|
||||
):
|
||||
"""
|
||||
WebSocket para recibir actualizaciones de ubicaciones.
|
||||
|
||||
Permite suscribirse a:
|
||||
- Todas las ubicaciones de la flota
|
||||
- Vehículos específicos
|
||||
|
||||
Args:
|
||||
websocket: Conexión WebSocket.
|
||||
token: Token JWT para autenticación (opcional).
|
||||
"""
|
||||
user_id = await get_user_from_token(token)
|
||||
|
||||
await manager.connect(websocket, "ubicaciones", user_id)
|
||||
|
||||
try:
|
||||
while True:
|
||||
# Recibir mensajes del cliente
|
||||
data = await websocket.receive_text()
|
||||
|
||||
try:
|
||||
message = json.loads(data)
|
||||
action = message.get("action")
|
||||
|
||||
if action == "subscribe_vehicle":
|
||||
# Suscribirse a un vehículo específico
|
||||
vehicle_id = message.get("vehicle_id")
|
||||
if vehicle_id:
|
||||
await manager.subscribe_vehicle(websocket, vehicle_id)
|
||||
await websocket.send_json({
|
||||
"type": "subscribed",
|
||||
"vehicle_id": vehicle_id,
|
||||
})
|
||||
|
||||
elif action == "unsubscribe_vehicle":
|
||||
# Desuscribirse de un vehículo
|
||||
vehicle_id = message.get("vehicle_id")
|
||||
if vehicle_id:
|
||||
await manager.unsubscribe_vehicle(websocket, vehicle_id)
|
||||
await websocket.send_json({
|
||||
"type": "unsubscribed",
|
||||
"vehicle_id": vehicle_id,
|
||||
})
|
||||
|
||||
elif action == "ping":
|
||||
# Responder ping para keepalive
|
||||
await websocket.send_json({"type": "pong"})
|
||||
|
||||
except json.JSONDecodeError:
|
||||
await websocket.send_json({
|
||||
"type": "error",
|
||||
"message": "Invalid JSON",
|
||||
})
|
||||
|
||||
except WebSocketDisconnect:
|
||||
await manager.disconnect(websocket, "ubicaciones", user_id)
|
||||
|
||||
|
||||
@router.websocket("/ws/vehiculo/{vehiculo_id}")
|
||||
async def websocket_vehiculo(
|
||||
websocket: WebSocket,
|
||||
vehiculo_id: int,
|
||||
token: Optional[str] = Query(None),
|
||||
):
|
||||
"""
|
||||
WebSocket para seguir un vehículo específico.
|
||||
|
||||
Args:
|
||||
websocket: Conexión WebSocket.
|
||||
vehiculo_id: ID del vehículo a seguir.
|
||||
token: Token JWT para autenticación (opcional).
|
||||
"""
|
||||
user_id = await get_user_from_token(token)
|
||||
|
||||
await manager.connect(websocket, "ubicaciones", user_id)
|
||||
await manager.subscribe_vehicle(websocket, vehiculo_id)
|
||||
|
||||
# Enviar confirmación de suscripción
|
||||
await websocket.send_json({
|
||||
"type": "connected",
|
||||
"vehicle_id": vehiculo_id,
|
||||
})
|
||||
|
||||
try:
|
||||
while True:
|
||||
data = await websocket.receive_text()
|
||||
|
||||
try:
|
||||
message = json.loads(data)
|
||||
|
||||
if message.get("action") == "ping":
|
||||
await websocket.send_json({"type": "pong"})
|
||||
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
except WebSocketDisconnect:
|
||||
await manager.unsubscribe_vehicle(websocket, vehiculo_id)
|
||||
await manager.disconnect(websocket, "ubicaciones", user_id)
|
||||
|
||||
|
||||
@router.websocket("/ws/flota")
|
||||
async def websocket_flota(
|
||||
websocket: WebSocket,
|
||||
token: Optional[str] = Query(None),
|
||||
):
|
||||
"""
|
||||
WebSocket para monitoreo de toda la flota.
|
||||
|
||||
Recibe actualizaciones de todos los vehículos activos.
|
||||
|
||||
Args:
|
||||
websocket: Conexión WebSocket.
|
||||
token: Token JWT para autenticación (opcional).
|
||||
"""
|
||||
user_id = await get_user_from_token(token)
|
||||
|
||||
await manager.connect(websocket, "ubicaciones", user_id)
|
||||
|
||||
# Enviar confirmación
|
||||
await websocket.send_json({
|
||||
"type": "connected",
|
||||
"channel": "fleet",
|
||||
})
|
||||
|
||||
try:
|
||||
while True:
|
||||
data = await websocket.receive_text()
|
||||
|
||||
try:
|
||||
message = json.loads(data)
|
||||
|
||||
if message.get("action") == "ping":
|
||||
await websocket.send_json({"type": "pong"})
|
||||
|
||||
elif message.get("action") == "request_status":
|
||||
# Enviar estado actual de conexiones
|
||||
await websocket.send_json({
|
||||
"type": "status",
|
||||
"connections": manager.get_connection_count(),
|
||||
})
|
||||
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
except WebSocketDisconnect:
|
||||
await manager.disconnect(websocket, "ubicaciones", user_id)
|
||||
56
backend/app/core/__init__.py
Normal file
56
backend/app/core/__init__.py
Normal file
@@ -0,0 +1,56 @@
|
||||
"""
|
||||
Módulo core - Configuración, seguridad y utilidades base.
|
||||
"""
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.database import Base, get_db, engine, async_session_factory
|
||||
from app.core.security import (
|
||||
hash_password,
|
||||
verify_password,
|
||||
create_access_token,
|
||||
create_refresh_token,
|
||||
decode_token,
|
||||
get_current_user,
|
||||
get_current_active_admin,
|
||||
CurrentUser,
|
||||
CurrentAdmin,
|
||||
)
|
||||
from app.core.exceptions import (
|
||||
AdanException,
|
||||
NotFoundError,
|
||||
AlreadyExistsError,
|
||||
ValidationError,
|
||||
AuthenticationError,
|
||||
AuthorizationError,
|
||||
ExternalServiceError,
|
||||
register_exception_handlers,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# Config
|
||||
"settings",
|
||||
# Database
|
||||
"Base",
|
||||
"get_db",
|
||||
"engine",
|
||||
"async_session_factory",
|
||||
# Security
|
||||
"hash_password",
|
||||
"verify_password",
|
||||
"create_access_token",
|
||||
"create_refresh_token",
|
||||
"decode_token",
|
||||
"get_current_user",
|
||||
"get_current_active_admin",
|
||||
"CurrentUser",
|
||||
"CurrentAdmin",
|
||||
# Exceptions
|
||||
"AdanException",
|
||||
"NotFoundError",
|
||||
"AlreadyExistsError",
|
||||
"ValidationError",
|
||||
"AuthenticationError",
|
||||
"AuthorizationError",
|
||||
"ExternalServiceError",
|
||||
"register_exception_handlers",
|
||||
]
|
||||
177
backend/app/core/config.py
Normal file
177
backend/app/core/config.py
Normal file
@@ -0,0 +1,177 @@
|
||||
"""
|
||||
Configuración central de la aplicación.
|
||||
|
||||
Utiliza Pydantic BaseSettings para cargar variables de entorno
|
||||
con validación de tipos y valores por defecto.
|
||||
"""
|
||||
|
||||
from functools import lru_cache
|
||||
from typing import Any, List, Optional
|
||||
|
||||
from pydantic import PostgresDsn, field_validator, model_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Configuración de la aplicación cargada desde variables de entorno."""
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
case_sensitive=False,
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
# Aplicación
|
||||
APP_NAME: str = "Adan Fleet Monitor"
|
||||
APP_VERSION: str = "1.0.0"
|
||||
DEBUG: bool = False
|
||||
ENVIRONMENT: str = "development"
|
||||
API_V1_PREFIX: str = "/api/v1"
|
||||
|
||||
# Servidor
|
||||
HOST: str = "0.0.0.0"
|
||||
PORT: int = 8000
|
||||
WORKERS: int = 4
|
||||
|
||||
# Base de datos PostgreSQL/TimescaleDB
|
||||
POSTGRES_HOST: str = "localhost"
|
||||
POSTGRES_PORT: int = 5432
|
||||
POSTGRES_USER: str = "adan"
|
||||
POSTGRES_PASSWORD: str = "adan_secret"
|
||||
POSTGRES_DB: str = "adan_fleet"
|
||||
DATABASE_URL: Optional[str] = None
|
||||
DATABASE_POOL_SIZE: int = 20
|
||||
DATABASE_MAX_OVERFLOW: int = 10
|
||||
|
||||
@model_validator(mode="after")
|
||||
def build_database_url(self) -> "Settings":
|
||||
"""Construye la URL de conexión a la base de datos."""
|
||||
if not self.DATABASE_URL:
|
||||
self.DATABASE_URL = (
|
||||
f"postgresql+asyncpg://{self.POSTGRES_USER}:{self.POSTGRES_PASSWORD}"
|
||||
f"@{self.POSTGRES_HOST}:{self.POSTGRES_PORT}/{self.POSTGRES_DB}"
|
||||
)
|
||||
return self
|
||||
|
||||
# Redis
|
||||
REDIS_HOST: str = "localhost"
|
||||
REDIS_PORT: int = 6379
|
||||
REDIS_DB: int = 0
|
||||
REDIS_PASSWORD: Optional[str] = None
|
||||
REDIS_URL: Optional[str] = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def build_redis_url(self) -> "Settings":
|
||||
"""Construye la URL de conexión a Redis."""
|
||||
if not self.REDIS_URL:
|
||||
password_part = f":{self.REDIS_PASSWORD}@" if self.REDIS_PASSWORD else ""
|
||||
self.REDIS_URL = f"redis://{password_part}{self.REDIS_HOST}:{self.REDIS_PORT}/{self.REDIS_DB}"
|
||||
return self
|
||||
|
||||
# Seguridad JWT
|
||||
SECRET_KEY: str = "your-super-secret-key-change-in-production"
|
||||
ALGORITHM: str = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
|
||||
REFRESH_TOKEN_EXPIRE_DAYS: int = 7
|
||||
|
||||
# CORS
|
||||
CORS_ORIGINS: List[str] = ["http://localhost:3000", "http://localhost:5173"]
|
||||
CORS_ALLOW_CREDENTIALS: bool = True
|
||||
CORS_ALLOW_METHODS: List[str] = ["*"]
|
||||
CORS_ALLOW_HEADERS: List[str] = ["*"]
|
||||
|
||||
@field_validator("CORS_ORIGINS", mode="before")
|
||||
@classmethod
|
||||
def parse_cors_origins(cls, v: Any) -> List[str]:
|
||||
"""Parsea los orígenes CORS desde string separado por comas."""
|
||||
if isinstance(v, str):
|
||||
return [origin.strip() for origin in v.split(",")]
|
||||
return v
|
||||
|
||||
# Traccar Integration
|
||||
TRACCAR_HOST: str = "localhost"
|
||||
TRACCAR_PORT: int = 5055
|
||||
TRACCAR_API_URL: str = "http://localhost:8082/api"
|
||||
TRACCAR_USERNAME: Optional[str] = None
|
||||
TRACCAR_PASSWORD: Optional[str] = None
|
||||
|
||||
# MediaMTX Video Server
|
||||
MEDIAMTX_HOST: str = "localhost"
|
||||
MEDIAMTX_API_PORT: int = 9997
|
||||
MEDIAMTX_RTSP_PORT: int = 8554
|
||||
MEDIAMTX_WEBRTC_PORT: int = 8889
|
||||
|
||||
# Meshtastic
|
||||
MESHTASTIC_ENABLED: bool = False
|
||||
MESHTASTIC_SERIAL_PORT: Optional[str] = None
|
||||
MESHTASTIC_TCP_HOST: Optional[str] = None
|
||||
MESHTASTIC_TCP_PORT: int = 4403
|
||||
|
||||
# MQTT (para dispositivos IoT)
|
||||
MQTT_ENABLED: bool = False
|
||||
MQTT_HOST: str = "localhost"
|
||||
MQTT_PORT: int = 1883
|
||||
MQTT_USERNAME: Optional[str] = None
|
||||
MQTT_PASSWORD: Optional[str] = None
|
||||
MQTT_TOPIC_LOCATIONS: str = "adan/locations/#"
|
||||
MQTT_TOPIC_ALERTS: str = "adan/alerts/#"
|
||||
|
||||
# Email (notificaciones)
|
||||
SMTP_HOST: str = "localhost"
|
||||
SMTP_PORT: int = 587
|
||||
SMTP_USER: Optional[str] = None
|
||||
SMTP_PASSWORD: Optional[str] = None
|
||||
SMTP_FROM_EMAIL: str = "noreply@adan-fleet.com"
|
||||
SMTP_FROM_NAME: str = "Adan Fleet Monitor"
|
||||
SMTP_TLS: bool = True
|
||||
|
||||
# Push Notifications (Firebase)
|
||||
FIREBASE_CREDENTIALS_PATH: Optional[str] = None
|
||||
FIREBASE_ENABLED: bool = False
|
||||
|
||||
# Almacenamiento de archivos
|
||||
UPLOAD_DIR: str = "/var/lib/adan/uploads"
|
||||
MAX_UPLOAD_SIZE_MB: int = 100
|
||||
ALLOWED_IMAGE_TYPES: List[str] = ["image/jpeg", "image/png", "image/webp"]
|
||||
ALLOWED_VIDEO_TYPES: List[str] = ["video/mp4", "video/webm"]
|
||||
|
||||
# Reportes
|
||||
REPORTS_DIR: str = "/var/lib/adan/reports"
|
||||
REPORT_RETENTION_DAYS: int = 90
|
||||
|
||||
# Geocoding
|
||||
GEOCODING_PROVIDER: str = "nominatim" # nominatim, google, mapbox
|
||||
GOOGLE_MAPS_API_KEY: Optional[str] = None
|
||||
MAPBOX_ACCESS_TOKEN: Optional[str] = None
|
||||
|
||||
# Alertas y umbrales
|
||||
ALERT_SPEED_LIMIT_DEFAULT: int = 120 # km/h
|
||||
ALERT_IDLE_MINUTES: int = 15
|
||||
ALERT_BATTERY_LOW_PERCENT: int = 20
|
||||
ALERT_NO_SIGNAL_MINUTES: int = 30
|
||||
|
||||
# Limpieza de datos
|
||||
LOCATION_RETENTION_DAYS: int = 365
|
||||
ALERT_RETENTION_DAYS: int = 180
|
||||
VIDEO_RETENTION_DAYS: int = 30
|
||||
|
||||
# Logging
|
||||
LOG_LEVEL: str = "INFO"
|
||||
LOG_FORMAT: str = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
||||
LOG_FILE: Optional[str] = None
|
||||
|
||||
|
||||
@lru_cache()
|
||||
def get_settings() -> Settings:
|
||||
"""
|
||||
Obtiene la instancia de configuración (singleton cacheado).
|
||||
|
||||
Returns:
|
||||
Settings: Instancia de configuración de la aplicación.
|
||||
"""
|
||||
return Settings()
|
||||
|
||||
|
||||
# Instancia global de configuración
|
||||
settings = get_settings()
|
||||
140
backend/app/core/database.py
Normal file
140
backend/app/core/database.py
Normal file
@@ -0,0 +1,140 @@
|
||||
"""
|
||||
Configuración de conexión a la base de datos PostgreSQL/TimescaleDB.
|
||||
|
||||
Proporciona:
|
||||
- Engine async para SQLAlchemy
|
||||
- Session factory async
|
||||
- Dependency para obtener sesiones en endpoints
|
||||
- Base declarativa para modelos
|
||||
"""
|
||||
|
||||
from typing import AsyncGenerator
|
||||
|
||||
from sqlalchemy.ext.asyncio import (
|
||||
AsyncSession,
|
||||
async_sessionmaker,
|
||||
create_async_engine,
|
||||
)
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
from sqlalchemy.pool import NullPool
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
"""Clase base para todos los modelos SQLAlchemy."""
|
||||
pass
|
||||
|
||||
|
||||
# Configuración del engine según el entorno
|
||||
if settings.ENVIRONMENT == "testing":
|
||||
# En testing usamos NullPool para evitar problemas con conexiones
|
||||
engine = create_async_engine(
|
||||
settings.DATABASE_URL,
|
||||
echo=settings.DEBUG,
|
||||
poolclass=NullPool,
|
||||
)
|
||||
else:
|
||||
# En producción usamos pool de conexiones
|
||||
engine = create_async_engine(
|
||||
settings.DATABASE_URL,
|
||||
echo=settings.DEBUG,
|
||||
pool_size=settings.DATABASE_POOL_SIZE,
|
||||
max_overflow=settings.DATABASE_MAX_OVERFLOW,
|
||||
pool_pre_ping=True, # Verifica conexiones antes de usar
|
||||
pool_recycle=3600, # Recicla conexiones cada hora
|
||||
)
|
||||
|
||||
# Factory de sesiones async
|
||||
async_session_factory = async_sessionmaker(
|
||||
engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
autocommit=False,
|
||||
autoflush=False,
|
||||
)
|
||||
|
||||
|
||||
async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
||||
"""
|
||||
Dependency que proporciona una sesión de base de datos.
|
||||
|
||||
Yields:
|
||||
AsyncSession: Sesión de base de datos para usar en el endpoint.
|
||||
|
||||
Example:
|
||||
@router.get("/items")
|
||||
async def get_items(db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(select(Item))
|
||||
return result.scalars().all()
|
||||
"""
|
||||
async with async_session_factory() as session:
|
||||
try:
|
||||
yield session
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
finally:
|
||||
await session.close()
|
||||
|
||||
|
||||
async def init_db() -> None:
|
||||
"""
|
||||
Inicializa la base de datos creando todas las tablas.
|
||||
|
||||
Nota: En producción se recomienda usar Alembic para migraciones.
|
||||
Esta función es útil para desarrollo y testing.
|
||||
"""
|
||||
async with engine.begin() as conn:
|
||||
# Importar todos los modelos para que SQLAlchemy los conozca
|
||||
from app.models import ( # noqa: F401
|
||||
alerta,
|
||||
camara,
|
||||
carga_combustible,
|
||||
conductor,
|
||||
configuracion,
|
||||
dispositivo,
|
||||
evento_video,
|
||||
geocerca,
|
||||
grabacion,
|
||||
grupo_vehiculos,
|
||||
mantenimiento,
|
||||
mensaje,
|
||||
parada,
|
||||
poi,
|
||||
tipo_alerta,
|
||||
tipo_mantenimiento,
|
||||
ubicacion,
|
||||
usuario,
|
||||
vehiculo,
|
||||
viaje,
|
||||
)
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
|
||||
async def close_db() -> None:
|
||||
"""
|
||||
Cierra el pool de conexiones a la base de datos.
|
||||
|
||||
Debe llamarse al apagar la aplicación para liberar recursos.
|
||||
"""
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def check_db_connection() -> bool:
|
||||
"""
|
||||
Verifica que la conexión a la base de datos funcione.
|
||||
|
||||
Returns:
|
||||
bool: True si la conexión es exitosa.
|
||||
|
||||
Raises:
|
||||
Exception: Si no se puede conectar a la base de datos.
|
||||
"""
|
||||
try:
|
||||
async with engine.connect() as conn:
|
||||
await conn.execute("SELECT 1")
|
||||
return True
|
||||
except Exception as e:
|
||||
raise Exception(f"Error conectando a la base de datos: {e}")
|
||||
280
backend/app/core/exceptions.py
Normal file
280
backend/app/core/exceptions.py
Normal file
@@ -0,0 +1,280 @@
|
||||
"""
|
||||
Excepciones personalizadas para la aplicación.
|
||||
|
||||
Define excepciones específicas del dominio y handlers
|
||||
para convertirlas en respuestas HTTP apropiadas.
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from fastapi import HTTPException, Request, status
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
|
||||
class AdanException(Exception):
|
||||
"""Excepción base para todas las excepciones de la aplicación."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
code: str = "ADAN_ERROR",
|
||||
details: Optional[Dict[str, Any]] = None,
|
||||
):
|
||||
self.message = message
|
||||
self.code = code
|
||||
self.details = details or {}
|
||||
super().__init__(self.message)
|
||||
|
||||
|
||||
class NotFoundError(AdanException):
|
||||
"""Recurso no encontrado."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
resource: str,
|
||||
identifier: Any = None,
|
||||
details: Optional[Dict[str, Any]] = None,
|
||||
):
|
||||
message = f"{resource} no encontrado"
|
||||
if identifier:
|
||||
message = f"{resource} con id '{identifier}' no encontrado"
|
||||
super().__init__(message, "NOT_FOUND", details)
|
||||
self.resource = resource
|
||||
self.identifier = identifier
|
||||
|
||||
|
||||
class AlreadyExistsError(AdanException):
|
||||
"""El recurso ya existe."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
resource: str,
|
||||
field: str,
|
||||
value: Any,
|
||||
details: Optional[Dict[str, Any]] = None,
|
||||
):
|
||||
message = f"{resource} con {field}='{value}' ya existe"
|
||||
super().__init__(message, "ALREADY_EXISTS", details)
|
||||
self.resource = resource
|
||||
self.field = field
|
||||
self.value = value
|
||||
|
||||
|
||||
class ValidationError(AdanException):
|
||||
"""Error de validación de datos."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
field: Optional[str] = None,
|
||||
details: Optional[Dict[str, Any]] = None,
|
||||
):
|
||||
super().__init__(message, "VALIDATION_ERROR", details)
|
||||
self.field = field
|
||||
|
||||
|
||||
class AuthenticationError(AdanException):
|
||||
"""Error de autenticación."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str = "Credenciales inválidas",
|
||||
details: Optional[Dict[str, Any]] = None,
|
||||
):
|
||||
super().__init__(message, "AUTHENTICATION_ERROR", details)
|
||||
|
||||
|
||||
class AuthorizationError(AdanException):
|
||||
"""Error de autorización (permisos insuficientes)."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str = "No tiene permisos para realizar esta acción",
|
||||
details: Optional[Dict[str, Any]] = None,
|
||||
):
|
||||
super().__init__(message, "AUTHORIZATION_ERROR", details)
|
||||
|
||||
|
||||
class ExternalServiceError(AdanException):
|
||||
"""Error al comunicarse con un servicio externo."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
service: str,
|
||||
message: str,
|
||||
details: Optional[Dict[str, Any]] = None,
|
||||
):
|
||||
full_message = f"Error en servicio {service}: {message}"
|
||||
super().__init__(full_message, "EXTERNAL_SERVICE_ERROR", details)
|
||||
self.service = service
|
||||
|
||||
|
||||
class GeocercaViolationError(AdanException):
|
||||
"""Violación de geocerca detectada."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
geocerca_id: int,
|
||||
geocerca_nombre: str,
|
||||
tipo_violacion: str, # 'entrada' o 'salida'
|
||||
vehiculo_id: int,
|
||||
details: Optional[Dict[str, Any]] = None,
|
||||
):
|
||||
message = f"Vehículo {vehiculo_id} {tipo_violacion} de geocerca '{geocerca_nombre}'"
|
||||
super().__init__(message, "GEOCERCA_VIOLATION", details)
|
||||
self.geocerca_id = geocerca_id
|
||||
self.geocerca_nombre = geocerca_nombre
|
||||
self.tipo_violacion = tipo_violacion
|
||||
self.vehiculo_id = vehiculo_id
|
||||
|
||||
|
||||
class SpeedLimitExceededError(AdanException):
|
||||
"""Límite de velocidad excedido."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
vehiculo_id: int,
|
||||
velocidad: float,
|
||||
limite: float,
|
||||
details: Optional[Dict[str, Any]] = None,
|
||||
):
|
||||
message = f"Vehículo {vehiculo_id} excedió límite de velocidad: {velocidad} km/h (límite: {limite} km/h)"
|
||||
super().__init__(message, "SPEED_LIMIT_EXCEEDED", details)
|
||||
self.vehiculo_id = vehiculo_id
|
||||
self.velocidad = velocidad
|
||||
self.limite = limite
|
||||
|
||||
|
||||
class DeviceConnectionError(AdanException):
|
||||
"""Error de conexión con dispositivo."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
dispositivo_id: int,
|
||||
message: str,
|
||||
details: Optional[Dict[str, Any]] = None,
|
||||
):
|
||||
full_message = f"Error de conexión con dispositivo {dispositivo_id}: {message}"
|
||||
super().__init__(full_message, "DEVICE_CONNECTION_ERROR", details)
|
||||
self.dispositivo_id = dispositivo_id
|
||||
|
||||
|
||||
class VideoStreamError(AdanException):
|
||||
"""Error con stream de video."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
camara_id: int,
|
||||
message: str,
|
||||
details: Optional[Dict[str, Any]] = None,
|
||||
):
|
||||
full_message = f"Error de video en cámara {camara_id}: {message}"
|
||||
super().__init__(full_message, "VIDEO_STREAM_ERROR", details)
|
||||
self.camara_id = camara_id
|
||||
|
||||
|
||||
class MaintenanceRequiredError(AdanException):
|
||||
"""Mantenimiento requerido para el vehículo."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
vehiculo_id: int,
|
||||
tipo_mantenimiento: str,
|
||||
details: Optional[Dict[str, Any]] = None,
|
||||
):
|
||||
message = f"Vehículo {vehiculo_id} requiere mantenimiento: {tipo_mantenimiento}"
|
||||
super().__init__(message, "MAINTENANCE_REQUIRED", details)
|
||||
self.vehiculo_id = vehiculo_id
|
||||
self.tipo_mantenimiento = tipo_mantenimiento
|
||||
|
||||
|
||||
class DatabaseError(AdanException):
|
||||
"""Error de base de datos."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
operation: str,
|
||||
message: str,
|
||||
details: Optional[Dict[str, Any]] = None,
|
||||
):
|
||||
full_message = f"Error de base de datos en {operation}: {message}"
|
||||
super().__init__(full_message, "DATABASE_ERROR", details)
|
||||
self.operation = operation
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Exception Handlers para FastAPI
|
||||
# ============================================================================
|
||||
|
||||
|
||||
async def adan_exception_handler(request: Request, exc: AdanException) -> JSONResponse:
|
||||
"""Handler para excepciones base de Adan."""
|
||||
status_code = status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
|
||||
if isinstance(exc, NotFoundError):
|
||||
status_code = status.HTTP_404_NOT_FOUND
|
||||
elif isinstance(exc, AlreadyExistsError):
|
||||
status_code = status.HTTP_409_CONFLICT
|
||||
elif isinstance(exc, ValidationError):
|
||||
status_code = status.HTTP_422_UNPROCESSABLE_ENTITY
|
||||
elif isinstance(exc, AuthenticationError):
|
||||
status_code = status.HTTP_401_UNAUTHORIZED
|
||||
elif isinstance(exc, AuthorizationError):
|
||||
status_code = status.HTTP_403_FORBIDDEN
|
||||
elif isinstance(exc, ExternalServiceError):
|
||||
status_code = status.HTTP_502_BAD_GATEWAY
|
||||
|
||||
return JSONResponse(
|
||||
status_code=status_code,
|
||||
content={
|
||||
"error": {
|
||||
"code": exc.code,
|
||||
"message": exc.message,
|
||||
"details": exc.details,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def http_exception_handler(request: Request, exc: HTTPException) -> JSONResponse:
|
||||
"""Handler para HTTPException estándar de FastAPI."""
|
||||
return JSONResponse(
|
||||
status_code=exc.status_code,
|
||||
content={
|
||||
"error": {
|
||||
"code": "HTTP_ERROR",
|
||||
"message": exc.detail,
|
||||
"details": {},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def general_exception_handler(request: Request, exc: Exception) -> JSONResponse:
|
||||
"""Handler para excepciones no capturadas."""
|
||||
# En producción, loguear el error completo pero no exponerlo al cliente
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
content={
|
||||
"error": {
|
||||
"code": "INTERNAL_ERROR",
|
||||
"message": "Error interno del servidor",
|
||||
"details": {},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def register_exception_handlers(app) -> None:
|
||||
"""
|
||||
Registra los handlers de excepciones en la aplicación FastAPI.
|
||||
|
||||
Args:
|
||||
app: Instancia de FastAPI.
|
||||
"""
|
||||
app.add_exception_handler(AdanException, adan_exception_handler)
|
||||
app.add_exception_handler(HTTPException, http_exception_handler)
|
||||
app.add_exception_handler(Exception, general_exception_handler)
|
||||
285
backend/app/core/security.py
Normal file
285
backend/app/core/security.py
Normal file
@@ -0,0 +1,285 @@
|
||||
"""
|
||||
Módulo de seguridad para autenticación y autorización.
|
||||
|
||||
Proporciona:
|
||||
- Generación y verificación de tokens JWT
|
||||
- Hashing de contraseñas con bcrypt
|
||||
- Dependencies para obtener el usuario actual
|
||||
- Encriptación de datos sensibles
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Annotated, Any, Optional
|
||||
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from jose import JWTError, jwt
|
||||
from passlib.context import CryptContext
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.database import get_db
|
||||
|
||||
# Contexto de hashing para contraseñas
|
||||
pwd_context = CryptContext(
|
||||
schemes=["bcrypt"],
|
||||
deprecated="auto",
|
||||
bcrypt__rounds=12, # Factor de costo para bcrypt
|
||||
)
|
||||
|
||||
# Esquema de autenticación Bearer
|
||||
bearer_scheme = HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
"""
|
||||
Genera un hash seguro de una contraseña.
|
||||
|
||||
Args:
|
||||
password: Contraseña en texto plano.
|
||||
|
||||
Returns:
|
||||
str: Hash bcrypt de la contraseña.
|
||||
"""
|
||||
return pwd_context.hash(password)
|
||||
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
"""
|
||||
Verifica una contraseña contra su hash.
|
||||
|
||||
Args:
|
||||
plain_password: Contraseña en texto plano a verificar.
|
||||
hashed_password: Hash almacenado de la contraseña.
|
||||
|
||||
Returns:
|
||||
bool: True si la contraseña coincide.
|
||||
"""
|
||||
return pwd_context.verify(plain_password, hashed_password)
|
||||
|
||||
|
||||
def create_access_token(
|
||||
data: dict[str, Any],
|
||||
expires_delta: Optional[timedelta] = None,
|
||||
) -> str:
|
||||
"""
|
||||
Crea un token JWT de acceso.
|
||||
|
||||
Args:
|
||||
data: Datos a incluir en el payload del token.
|
||||
expires_delta: Tiempo de expiración personalizado.
|
||||
|
||||
Returns:
|
||||
str: Token JWT codificado.
|
||||
"""
|
||||
to_encode = data.copy()
|
||||
expire = datetime.now(timezone.utc) + (
|
||||
expires_delta or timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
)
|
||||
to_encode.update({
|
||||
"exp": expire,
|
||||
"iat": datetime.now(timezone.utc),
|
||||
"type": "access",
|
||||
})
|
||||
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
|
||||
|
||||
|
||||
def create_refresh_token(
|
||||
data: dict[str, Any],
|
||||
expires_delta: Optional[timedelta] = None,
|
||||
) -> str:
|
||||
"""
|
||||
Crea un token JWT de refresco.
|
||||
|
||||
Args:
|
||||
data: Datos a incluir en el payload del token.
|
||||
expires_delta: Tiempo de expiración personalizado.
|
||||
|
||||
Returns:
|
||||
str: Token JWT de refresco codificado.
|
||||
"""
|
||||
to_encode = data.copy()
|
||||
expire = datetime.now(timezone.utc) + (
|
||||
expires_delta or timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS)
|
||||
)
|
||||
to_encode.update({
|
||||
"exp": expire,
|
||||
"iat": datetime.now(timezone.utc),
|
||||
"type": "refresh",
|
||||
})
|
||||
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
|
||||
|
||||
|
||||
def decode_token(token: str) -> dict[str, Any]:
|
||||
"""
|
||||
Decodifica y valida un token JWT.
|
||||
|
||||
Args:
|
||||
token: Token JWT a decodificar.
|
||||
|
||||
Returns:
|
||||
dict: Payload del token decodificado.
|
||||
|
||||
Raises:
|
||||
HTTPException: Si el token es inválido o ha expirado.
|
||||
"""
|
||||
try:
|
||||
payload = jwt.decode(
|
||||
token,
|
||||
settings.SECRET_KEY,
|
||||
algorithms=[settings.ALGORITHM],
|
||||
)
|
||||
return payload
|
||||
except JWTError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=f"Token inválido: {str(e)}",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
|
||||
def verify_token_type(payload: dict[str, Any], expected_type: str) -> None:
|
||||
"""
|
||||
Verifica que el token sea del tipo esperado.
|
||||
|
||||
Args:
|
||||
payload: Payload del token decodificado.
|
||||
expected_type: Tipo esperado ('access' o 'refresh').
|
||||
|
||||
Raises:
|
||||
HTTPException: Si el tipo de token no coincide.
|
||||
"""
|
||||
token_type = payload.get("type")
|
||||
if token_type != expected_type:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=f"Tipo de token inválido. Se esperaba '{expected_type}'",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
credentials: Annotated[Optional[HTTPAuthorizationCredentials], Depends(bearer_scheme)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
):
|
||||
"""
|
||||
Dependency que obtiene el usuario actual desde el token JWT.
|
||||
|
||||
Args:
|
||||
credentials: Credenciales Bearer del header Authorization.
|
||||
db: Sesión de base de datos.
|
||||
|
||||
Returns:
|
||||
Usuario: Modelo del usuario autenticado.
|
||||
|
||||
Raises:
|
||||
HTTPException: Si no hay token, es inválido, o el usuario no existe.
|
||||
"""
|
||||
if credentials is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="No se proporcionaron credenciales",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
payload = decode_token(credentials.credentials)
|
||||
verify_token_type(payload, "access")
|
||||
|
||||
user_id = payload.get("sub")
|
||||
if user_id is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Token inválido: falta el identificador de usuario",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
# Importar aquí para evitar importación circular
|
||||
from app.models.usuario import Usuario
|
||||
|
||||
result = await db.execute(select(Usuario).where(Usuario.id == int(user_id)))
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if user is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Usuario no encontrado",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
if not user.activo:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Usuario desactivado",
|
||||
)
|
||||
|
||||
return user
|
||||
|
||||
|
||||
async def get_current_active_admin(
|
||||
current_user: Annotated[Any, Depends(get_current_user)],
|
||||
):
|
||||
"""
|
||||
Dependency que verifica que el usuario actual sea administrador.
|
||||
|
||||
Args:
|
||||
current_user: Usuario actual obtenido del token.
|
||||
|
||||
Returns:
|
||||
Usuario: Usuario administrador verificado.
|
||||
|
||||
Raises:
|
||||
HTTPException: Si el usuario no es administrador.
|
||||
"""
|
||||
if not current_user.es_admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Se requieren permisos de administrador",
|
||||
)
|
||||
return current_user
|
||||
|
||||
|
||||
def encrypt_sensitive_data(data: str) -> str:
|
||||
"""
|
||||
Encripta datos sensibles (ej: contraseñas de cámaras).
|
||||
|
||||
Por simplicidad, usa el mismo mecanismo de hash.
|
||||
En producción, usar Fernet o similar para encriptación reversible.
|
||||
|
||||
Args:
|
||||
data: Datos a encriptar.
|
||||
|
||||
Returns:
|
||||
str: Datos encriptados.
|
||||
"""
|
||||
# Para datos que necesitan ser recuperados (como passwords de cámaras),
|
||||
# se debería usar encriptación simétrica (Fernet)
|
||||
# Por ahora, usamos base64 + XOR simple como placeholder
|
||||
# TODO: Implementar encriptación Fernet apropiada
|
||||
import base64
|
||||
key = settings.SECRET_KEY[:32].encode()
|
||||
data_bytes = data.encode()
|
||||
encrypted = bytes(a ^ b for a, b in zip(data_bytes, key * (len(data_bytes) // len(key) + 1)))
|
||||
return base64.b64encode(encrypted).decode()
|
||||
|
||||
|
||||
def decrypt_sensitive_data(encrypted_data: str) -> str:
|
||||
"""
|
||||
Desencripta datos sensibles.
|
||||
|
||||
Args:
|
||||
encrypted_data: Datos encriptados.
|
||||
|
||||
Returns:
|
||||
str: Datos desencriptados.
|
||||
"""
|
||||
import base64
|
||||
key = settings.SECRET_KEY[:32].encode()
|
||||
encrypted_bytes = base64.b64decode(encrypted_data.encode())
|
||||
decrypted = bytes(a ^ b for a, b in zip(encrypted_bytes, key * (len(encrypted_bytes) // len(key) + 1)))
|
||||
return decrypted.decode()
|
||||
|
||||
|
||||
# Type aliases para uso en endpoints
|
||||
CurrentUser = Annotated[Any, Depends(get_current_user)]
|
||||
CurrentAdmin = Annotated[Any, Depends(get_current_active_admin)]
|
||||
268
backend/app/main.py
Normal file
268
backend/app/main.py
Normal file
@@ -0,0 +1,268 @@
|
||||
"""
|
||||
Aplicación principal FastAPI para Adan Fleet Monitor.
|
||||
|
||||
Sistema de monitoreo de flotillas GPS con soporte para:
|
||||
- Tracking en tiempo real
|
||||
- Gestión de vehículos y conductores
|
||||
- Alertas y geocercas
|
||||
- Video vigilancia
|
||||
- Reportes y análisis
|
||||
"""
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.middleware.gzip import GZipMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.database import close_db, init_db
|
||||
from app.core.exceptions import register_exception_handlers
|
||||
from app.api.v1 import api_router
|
||||
from app.api.websocket import ubicaciones_router, alertas_router
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""
|
||||
Manejador del ciclo de vida de la aplicación.
|
||||
|
||||
Ejecuta código de inicialización al arrancar y
|
||||
limpieza al cerrar la aplicación.
|
||||
"""
|
||||
# Startup
|
||||
print(f"Starting {settings.APP_NAME} v{settings.APP_VERSION}")
|
||||
print(f"Environment: {settings.ENVIRONMENT}")
|
||||
print(f"Debug mode: {settings.DEBUG}")
|
||||
|
||||
# Inicializar base de datos (crear tablas si no existen)
|
||||
if settings.ENVIRONMENT == "development":
|
||||
try:
|
||||
await init_db()
|
||||
print("Database initialized")
|
||||
except Exception as e:
|
||||
print(f"Warning: Could not initialize database: {e}")
|
||||
|
||||
yield # La aplicación se ejecuta aquí
|
||||
|
||||
# Shutdown
|
||||
print("Shutting down...")
|
||||
await close_db()
|
||||
print("Database connections closed")
|
||||
|
||||
|
||||
# Crear aplicación FastAPI
|
||||
app = FastAPI(
|
||||
title=settings.APP_NAME,
|
||||
description="""
|
||||
## Adan Fleet Monitor API
|
||||
|
||||
Sistema de monitoreo de flotillas GPS.
|
||||
|
||||
### Funcionalidades principales:
|
||||
- **Tracking en tiempo real** de vehículos
|
||||
- **Gestión de flota**: vehículos, conductores, dispositivos
|
||||
- **Alertas inteligentes**: velocidad, geocercas, batería
|
||||
- **Viajes automáticos**: detección de inicio/fin
|
||||
- **Geocercas**: zonas circulares y poligonales
|
||||
- **Video vigilancia**: integración con cámaras
|
||||
- **Reportes**: PDF, Excel, dashboards
|
||||
|
||||
### WebSocket endpoints:
|
||||
- `/ws/ubicaciones` - Ubicaciones en tiempo real
|
||||
- `/ws/alertas` - Alertas en tiempo real
|
||||
- `/ws/vehiculo/{id}` - Seguimiento de un vehículo
|
||||
- `/ws/flota` - Monitoreo de toda la flota
|
||||
""",
|
||||
version=settings.APP_VERSION,
|
||||
docs_url="/docs" if settings.DEBUG else None,
|
||||
redoc_url="/redoc" if settings.DEBUG else None,
|
||||
openapi_url="/openapi.json" if settings.DEBUG else None,
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
# ============================================================================
|
||||
# Middlewares
|
||||
# ============================================================================
|
||||
|
||||
# CORS
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.CORS_ORIGINS,
|
||||
allow_credentials=settings.CORS_ALLOW_CREDENTIALS,
|
||||
allow_methods=settings.CORS_ALLOW_METHODS,
|
||||
allow_headers=settings.CORS_ALLOW_HEADERS,
|
||||
)
|
||||
|
||||
# Compresión GZip
|
||||
app.add_middleware(GZipMiddleware, minimum_size=1000)
|
||||
|
||||
|
||||
# Middleware de logging de requests
|
||||
@app.middleware("http")
|
||||
async def log_requests(request: Request, call_next):
|
||||
"""
|
||||
Middleware para logging de requests.
|
||||
|
||||
Registra el tiempo de respuesta de cada request.
|
||||
"""
|
||||
start_time = datetime.now(timezone.utc)
|
||||
|
||||
response = await call_next(request)
|
||||
|
||||
# Calcular tiempo de procesamiento
|
||||
process_time = (datetime.now(timezone.utc) - start_time).total_seconds()
|
||||
response.headers["X-Process-Time"] = str(process_time)
|
||||
|
||||
# Log en modo debug
|
||||
if settings.DEBUG:
|
||||
print(
|
||||
f"{request.method} {request.url.path} "
|
||||
f"- {response.status_code} "
|
||||
f"- {process_time:.3f}s"
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
# Middleware de seguridad
|
||||
@app.middleware("http")
|
||||
async def security_headers(request: Request, call_next):
|
||||
"""
|
||||
Middleware para agregar headers de seguridad.
|
||||
"""
|
||||
response = await call_next(request)
|
||||
|
||||
# Headers de seguridad
|
||||
response.headers["X-Content-Type-Options"] = "nosniff"
|
||||
response.headers["X-Frame-Options"] = "DENY"
|
||||
response.headers["X-XSS-Protection"] = "1; mode=block"
|
||||
|
||||
if not settings.DEBUG:
|
||||
response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
|
||||
|
||||
return response
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Registrar exception handlers
|
||||
# ============================================================================
|
||||
|
||||
register_exception_handlers(app)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Routers
|
||||
# ============================================================================
|
||||
|
||||
# API REST v1
|
||||
app.include_router(api_router, prefix=settings.API_V1_PREFIX)
|
||||
|
||||
# WebSocket endpoints
|
||||
app.include_router(ubicaciones_router)
|
||||
app.include_router(alertas_router)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Endpoints base
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@app.get("/", tags=["Root"])
|
||||
async def root():
|
||||
"""
|
||||
Endpoint raíz de la API.
|
||||
|
||||
Returns:
|
||||
Información básica de la API.
|
||||
"""
|
||||
return {
|
||||
"name": settings.APP_NAME,
|
||||
"version": settings.APP_VERSION,
|
||||
"status": "online",
|
||||
"environment": settings.ENVIRONMENT,
|
||||
"docs": "/docs" if settings.DEBUG else "disabled",
|
||||
}
|
||||
|
||||
|
||||
@app.get("/health", tags=["Health"])
|
||||
async def health_check():
|
||||
"""
|
||||
Health check endpoint.
|
||||
|
||||
Verifica el estado de la aplicación y sus dependencias.
|
||||
|
||||
Returns:
|
||||
Estado de salud de la aplicación.
|
||||
"""
|
||||
health = {
|
||||
"status": "healthy",
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"version": settings.APP_VERSION,
|
||||
"checks": {
|
||||
"api": "ok",
|
||||
},
|
||||
}
|
||||
|
||||
# Verificar base de datos
|
||||
try:
|
||||
from app.core.database import check_db_connection
|
||||
await check_db_connection()
|
||||
health["checks"]["database"] = "ok"
|
||||
except Exception as e:
|
||||
health["checks"]["database"] = f"error: {str(e)}"
|
||||
health["status"] = "degraded"
|
||||
|
||||
# Verificar Redis (si está configurado)
|
||||
if settings.REDIS_URL:
|
||||
try:
|
||||
import aioredis
|
||||
redis = await aioredis.from_url(settings.REDIS_URL)
|
||||
await redis.ping()
|
||||
await redis.close()
|
||||
health["checks"]["redis"] = "ok"
|
||||
except Exception as e:
|
||||
health["checks"]["redis"] = f"error: {str(e)}"
|
||||
health["status"] = "degraded"
|
||||
|
||||
return health
|
||||
|
||||
|
||||
@app.get("/ready", tags=["Health"])
|
||||
async def readiness_check():
|
||||
"""
|
||||
Readiness check para Kubernetes.
|
||||
|
||||
Verifica si la aplicación está lista para recibir tráfico.
|
||||
|
||||
Returns:
|
||||
Estado de preparación.
|
||||
"""
|
||||
try:
|
||||
from app.core.database import check_db_connection
|
||||
await check_db_connection()
|
||||
return {"ready": True}
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
status_code=503,
|
||||
content={"ready": False, "reason": "Database not ready"},
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Entry point para desarrollo
|
||||
# ============================================================================
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
|
||||
uvicorn.run(
|
||||
"app.main:app",
|
||||
host=settings.HOST,
|
||||
port=settings.PORT,
|
||||
reload=settings.DEBUG,
|
||||
workers=1 if settings.DEBUG else settings.WORKERS,
|
||||
log_level="debug" if settings.DEBUG else "info",
|
||||
)
|
||||
73
backend/app/models/__init__.py
Normal file
73
backend/app/models/__init__.py
Normal file
@@ -0,0 +1,73 @@
|
||||
"""
|
||||
Módulo de modelos SQLAlchemy.
|
||||
|
||||
Exporta todos los modelos para facilitar importaciones
|
||||
y asegurar que SQLAlchemy los registre correctamente.
|
||||
"""
|
||||
|
||||
from app.models.base import TimestampMixin, SoftDeleteMixin
|
||||
from app.models.usuario import Usuario
|
||||
from app.models.grupo_vehiculos import GrupoVehiculos
|
||||
from app.models.conductor import Conductor
|
||||
from app.models.vehiculo import Vehiculo
|
||||
from app.models.dispositivo import Dispositivo
|
||||
from app.models.ubicacion import Ubicacion
|
||||
from app.models.viaje import Viaje
|
||||
from app.models.parada import Parada
|
||||
from app.models.tipo_alerta import TipoAlerta, TIPOS_ALERTA_DEFAULT
|
||||
from app.models.alerta import Alerta
|
||||
from app.models.geocerca import Geocerca, geocerca_vehiculo
|
||||
from app.models.poi import POI, CATEGORIAS_POI
|
||||
from app.models.carga_combustible import CargaCombustible
|
||||
from app.models.tipo_mantenimiento import TipoMantenimiento, TIPOS_MANTENIMIENTO_DEFAULT
|
||||
from app.models.mantenimiento import Mantenimiento
|
||||
from app.models.camara import Camara
|
||||
from app.models.grabacion import Grabacion
|
||||
from app.models.evento_video import EventoVideo, TIPOS_EVENTO_VIDEO
|
||||
from app.models.mensaje import Mensaje
|
||||
from app.models.configuracion import Configuracion, CONFIGURACIONES_DEFAULT
|
||||
|
||||
__all__ = [
|
||||
# Base
|
||||
"TimestampMixin",
|
||||
"SoftDeleteMixin",
|
||||
# Usuarios
|
||||
"Usuario",
|
||||
# Grupos
|
||||
"GrupoVehiculos",
|
||||
# Conductores
|
||||
"Conductor",
|
||||
# Vehículos
|
||||
"Vehiculo",
|
||||
"Dispositivo",
|
||||
# Ubicaciones
|
||||
"Ubicacion",
|
||||
# Viajes
|
||||
"Viaje",
|
||||
"Parada",
|
||||
# Alertas
|
||||
"TipoAlerta",
|
||||
"TIPOS_ALERTA_DEFAULT",
|
||||
"Alerta",
|
||||
# Geocercas y POIs
|
||||
"Geocerca",
|
||||
"geocerca_vehiculo",
|
||||
"POI",
|
||||
"CATEGORIAS_POI",
|
||||
# Combustible
|
||||
"CargaCombustible",
|
||||
# Mantenimiento
|
||||
"TipoMantenimiento",
|
||||
"TIPOS_MANTENIMIENTO_DEFAULT",
|
||||
"Mantenimiento",
|
||||
# Video
|
||||
"Camara",
|
||||
"Grabacion",
|
||||
"EventoVideo",
|
||||
"TIPOS_EVENTO_VIDEO",
|
||||
# Mensajes
|
||||
"Mensaje",
|
||||
# Configuración
|
||||
"Configuracion",
|
||||
"CONFIGURACIONES_DEFAULT",
|
||||
]
|
||||
117
backend/app/models/alerta.py
Normal file
117
backend/app/models/alerta.py
Normal file
@@ -0,0 +1,117 @@
|
||||
"""
|
||||
Modelo de Alerta para registrar eventos y notificaciones.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
DateTime,
|
||||
Float,
|
||||
ForeignKey,
|
||||
Index,
|
||||
String,
|
||||
Text,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.core.database import Base
|
||||
from app.models.base import TimestampMixin
|
||||
|
||||
|
||||
class Alerta(Base, TimestampMixin):
|
||||
"""Modelo de alerta/evento del sistema."""
|
||||
|
||||
__tablename__ = "alertas"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
|
||||
# Relaciones
|
||||
vehiculo_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("vehiculos.id", ondelete="CASCADE"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
conductor_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("conductores.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
tipo_alerta_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("tipos_alerta.id", ondelete="RESTRICT"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
dispositivo_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("dispositivos.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
)
|
||||
|
||||
# Severidad (puede sobrescribir la del tipo)
|
||||
severidad: Mapped[str] = mapped_column(
|
||||
String(20),
|
||||
default="media",
|
||||
nullable=False,
|
||||
) # baja, media, alta, critica
|
||||
|
||||
# Mensaje descriptivo
|
||||
mensaje: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||
descripcion: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
# Ubicación donde ocurrió
|
||||
lat: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
lng: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
direccion: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
|
||||
# Datos adicionales
|
||||
velocidad: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
valor: Mapped[float | None] = mapped_column(Float, nullable=True) # Valor que disparó la alerta
|
||||
umbral: Mapped[float | None] = mapped_column(Float, nullable=True) # Umbral configurado
|
||||
datos_extra: Mapped[str | None] = mapped_column(Text, nullable=True) # JSON con datos adicionales
|
||||
|
||||
# Estado de atención
|
||||
atendida: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
atendida_por_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("usuarios.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
)
|
||||
atendida_en: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
notas_atencion: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
# Notificaciones enviadas
|
||||
notificacion_email_enviada: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
notificacion_push_enviada: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
notificacion_sms_enviada: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
|
||||
# Relaciones ORM
|
||||
vehiculo: Mapped["Vehiculo | None"] = relationship(
|
||||
"Vehiculo",
|
||||
back_populates="alertas",
|
||||
lazy="selectin",
|
||||
)
|
||||
conductor: Mapped["Conductor | None"] = relationship(
|
||||
"Conductor",
|
||||
back_populates="alertas",
|
||||
lazy="selectin",
|
||||
)
|
||||
tipo_alerta: Mapped["TipoAlerta"] = relationship(
|
||||
"TipoAlerta",
|
||||
back_populates="alertas",
|
||||
lazy="selectin",
|
||||
)
|
||||
|
||||
# Índices
|
||||
__table_args__ = (
|
||||
Index("idx_alertas_vehiculo_creado", "vehiculo_id", "creado_en"),
|
||||
Index("idx_alertas_atendida", "atendida"),
|
||||
Index("idx_alertas_severidad", "severidad"),
|
||||
Index("idx_alertas_tipo_creado", "tipo_alerta_id", "creado_en"),
|
||||
)
|
||||
|
||||
@property
|
||||
def es_critica(self) -> bool:
|
||||
"""Verifica si la alerta es crítica."""
|
||||
return self.severidad == "critica"
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Alerta(id={self.id}, tipo_id={self.tipo_alerta_id}, severidad='{self.severidad}')>"
|
||||
43
backend/app/models/base.py
Normal file
43
backend/app/models/base.py
Normal file
@@ -0,0 +1,43 @@
|
||||
"""
|
||||
Clases y mixins base para los modelos SQLAlchemy.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import DateTime, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class TimestampMixin:
|
||||
"""Mixin que agrega campos de timestamp (creado_en, actualizado_en)."""
|
||||
|
||||
creado_en: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
default=lambda: datetime.now(timezone.utc),
|
||||
server_default=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
actualizado_en: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
default=lambda: datetime.now(timezone.utc),
|
||||
server_default=func.now(),
|
||||
onupdate=lambda: datetime.now(timezone.utc),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
|
||||
class SoftDeleteMixin:
|
||||
"""Mixin para soft delete (eliminado_en en lugar de borrar físicamente)."""
|
||||
|
||||
eliminado_en: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=True,
|
||||
default=None,
|
||||
)
|
||||
|
||||
@property
|
||||
def is_deleted(self) -> bool:
|
||||
"""Verifica si el registro está eliminado."""
|
||||
return self.eliminado_en is not None
|
||||
142
backend/app/models/camara.py
Normal file
142
backend/app/models/camara.py
Normal file
@@ -0,0 +1,142 @@
|
||||
"""
|
||||
Modelo de Cámara para video vigilancia en vehículos.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
String,
|
||||
Text,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.core.database import Base
|
||||
from app.models.base import TimestampMixin
|
||||
|
||||
|
||||
class Camara(Base, TimestampMixin):
|
||||
"""Modelo de cámara instalada en un vehículo."""
|
||||
|
||||
__tablename__ = "camaras"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
|
||||
# Relación con vehículo
|
||||
vehiculo_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("vehiculos.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
|
||||
# Identificación
|
||||
nombre: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
posicion: Mapped[str] = mapped_column(
|
||||
String(50),
|
||||
default="frontal",
|
||||
nullable=False,
|
||||
) # frontal, trasera, interior, lateral_izq, lateral_der
|
||||
|
||||
# Tipo de cámara
|
||||
tipo: Mapped[str] = mapped_column(
|
||||
String(50),
|
||||
default="ip",
|
||||
nullable=False,
|
||||
) # ip, dashcam, mdvr, usb
|
||||
|
||||
# Información del hardware
|
||||
marca: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||
modelo: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||
numero_serie: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
resolucion: Mapped[str | None] = mapped_column(String(20), nullable=True) # 1080p, 720p, 4K
|
||||
|
||||
# Conexión de streaming
|
||||
url_stream: Mapped[str | None] = mapped_column(String(500), nullable=True) # URL RTSP/RTMP
|
||||
puerto: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
protocolo: Mapped[str] = mapped_column(
|
||||
String(20),
|
||||
default="rtsp",
|
||||
nullable=False,
|
||||
) # rtsp, rtmp, hls, webrtc
|
||||
|
||||
# Autenticación
|
||||
usuario: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
password_encrypted: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
|
||||
# Configuración de MediaMTX
|
||||
mediamtx_path: Mapped[str | None] = mapped_column(String(100), nullable=True) # Path en MediaMTX
|
||||
|
||||
# Estado
|
||||
estado: Mapped[str] = mapped_column(
|
||||
String(20),
|
||||
default="desconectada",
|
||||
nullable=False,
|
||||
) # conectada, desconectada, grabando, error
|
||||
activa: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
|
||||
# Última conexión
|
||||
ultima_conexion: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
# Configuración de grabación
|
||||
grabacion_continua: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
grabacion_evento: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) # Grabar en eventos
|
||||
duracion_pre_evento: Mapped[int] = mapped_column(Integer, default=10, nullable=False) # Segundos antes
|
||||
duracion_post_evento: Mapped[int] = mapped_column(Integer, default=20, nullable=False) # Segundos después
|
||||
|
||||
# Detección de eventos (AI/ADAS)
|
||||
deteccion_colision: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
deteccion_distraccion: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
deteccion_fatiga: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
deteccion_cambio_carril: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
|
||||
# Notas
|
||||
notas: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
# Relaciones ORM
|
||||
vehiculo: Mapped["Vehiculo"] = relationship(
|
||||
"Vehiculo",
|
||||
back_populates="camaras",
|
||||
lazy="selectin",
|
||||
)
|
||||
grabaciones: Mapped[list["Grabacion"]] = relationship(
|
||||
"Grabacion",
|
||||
back_populates="camara",
|
||||
lazy="dynamic",
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
eventos_video: Mapped[list["EventoVideo"]] = relationship(
|
||||
"EventoVideo",
|
||||
back_populates="camara",
|
||||
lazy="dynamic",
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
|
||||
# Índices
|
||||
__table_args__ = (
|
||||
Index("idx_camaras_vehiculo", "vehiculo_id"),
|
||||
Index("idx_camaras_estado", "estado"),
|
||||
)
|
||||
|
||||
@property
|
||||
def url_stream_completa(self) -> str | None:
|
||||
"""Construye la URL completa de streaming."""
|
||||
if not self.url_stream:
|
||||
return None
|
||||
if self.usuario and self.password_encrypted:
|
||||
# Desencriptar password y construir URL con autenticación
|
||||
from app.core.security import decrypt_sensitive_data
|
||||
try:
|
||||
password = decrypt_sensitive_data(self.password_encrypted)
|
||||
# Insertar credenciales en URL RTSP
|
||||
if self.url_stream.startswith("rtsp://"):
|
||||
return self.url_stream.replace("rtsp://", f"rtsp://{self.usuario}:{password}@")
|
||||
except Exception:
|
||||
pass
|
||||
return self.url_stream
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Camara(id={self.id}, nombre='{self.nombre}', vehiculo_id={self.vehiculo_id})>"
|
||||
100
backend/app/models/carga_combustible.py
Normal file
100
backend/app/models/carga_combustible.py
Normal file
@@ -0,0 +1,100 @@
|
||||
"""
|
||||
Modelo de Carga de Combustible para registrar recargas de combustible.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import (
|
||||
DateTime,
|
||||
Float,
|
||||
ForeignKey,
|
||||
Index,
|
||||
String,
|
||||
Text,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.core.database import Base
|
||||
from app.models.base import TimestampMixin
|
||||
|
||||
|
||||
class CargaCombustible(Base, TimestampMixin):
|
||||
"""Modelo para registrar cargas de combustible de los vehículos."""
|
||||
|
||||
__tablename__ = "cargas_combustible"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
|
||||
# Relaciones
|
||||
vehiculo_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("vehiculos.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
conductor_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("conductores.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
|
||||
# Fecha y hora de la carga
|
||||
fecha: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
|
||||
# Cantidad y precio
|
||||
litros: Mapped[float] = mapped_column(Float, nullable=False)
|
||||
precio_litro: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
total: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
|
||||
# Tipo de combustible
|
||||
tipo_combustible: Mapped[str | None] = mapped_column(String(20), nullable=True) # gasolina, diesel, premium
|
||||
|
||||
# Odómetro al momento de la carga
|
||||
odometro: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
|
||||
# Estación de servicio
|
||||
estacion: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
estacion_direccion: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
|
||||
# Ubicación de la carga
|
||||
lat: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
lng: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
|
||||
# Tanque lleno (para cálculo de rendimiento)
|
||||
tanque_lleno: Mapped[bool] = mapped_column(default=True, nullable=False)
|
||||
|
||||
# Método de pago
|
||||
metodo_pago: Mapped[str | None] = mapped_column(String(50), nullable=True) # efectivo, tarjeta, vales
|
||||
numero_factura: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||
|
||||
# Notas
|
||||
notas: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
# Relaciones ORM
|
||||
vehiculo: Mapped["Vehiculo"] = relationship(
|
||||
"Vehiculo",
|
||||
back_populates="cargas_combustible",
|
||||
lazy="selectin",
|
||||
)
|
||||
conductor: Mapped["Conductor | None"] = relationship(
|
||||
"Conductor",
|
||||
back_populates="cargas_combustible",
|
||||
lazy="selectin",
|
||||
)
|
||||
|
||||
# Índices
|
||||
__table_args__ = (
|
||||
Index("idx_cargas_vehiculo_fecha", "vehiculo_id", "fecha"),
|
||||
)
|
||||
|
||||
@property
|
||||
def rendimiento_calculado(self) -> float | None:
|
||||
"""
|
||||
Calcula el rendimiento en km/litro si hay datos suficientes.
|
||||
|
||||
Este cálculo requiere la carga anterior para comparar odómetros.
|
||||
Se implementa en el servicio de combustible.
|
||||
"""
|
||||
return None # Se calcula en el servicio
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<CargaCombustible(id={self.id}, vehiculo_id={self.vehiculo_id}, litros={self.litros})>"
|
||||
89
backend/app/models/conductor.py
Normal file
89
backend/app/models/conductor.py
Normal file
@@ -0,0 +1,89 @@
|
||||
"""
|
||||
Modelo de Conductor para gestión de operadores de vehículos.
|
||||
"""
|
||||
|
||||
from datetime import date, datetime
|
||||
|
||||
from sqlalchemy import Boolean, Date, DateTime, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.core.database import Base
|
||||
from app.models.base import TimestampMixin
|
||||
|
||||
|
||||
class Conductor(Base, TimestampMixin):
|
||||
"""Modelo de conductor/operador de vehículo."""
|
||||
|
||||
__tablename__ = "conductores"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
nombre: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
apellido: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
telefono: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
email: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
|
||||
|
||||
# Documento de identidad
|
||||
documento_tipo: Mapped[str | None] = mapped_column(String(20), nullable=True) # DNI, INE, etc.
|
||||
documento_numero: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||
|
||||
# Licencia de conducir
|
||||
licencia_numero: Mapped[str | None] = mapped_column(String(50), nullable=True, unique=True)
|
||||
licencia_tipo: Mapped[str | None] = mapped_column(String(20), nullable=True) # A, B, C, D, E
|
||||
licencia_vencimiento: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
|
||||
# Información personal
|
||||
foto_url: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
fecha_nacimiento: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
direccion: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
contacto_emergencia: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
telefono_emergencia: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
|
||||
# Información laboral
|
||||
fecha_contratacion: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
numero_empleado: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||
|
||||
# Estado
|
||||
activo: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
notas: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
# Relaciones
|
||||
vehiculos: Mapped[list["Vehiculo"]] = relationship(
|
||||
"Vehiculo",
|
||||
back_populates="conductor",
|
||||
lazy="selectin",
|
||||
)
|
||||
viajes: Mapped[list["Viaje"]] = relationship(
|
||||
"Viaje",
|
||||
back_populates="conductor",
|
||||
lazy="dynamic",
|
||||
)
|
||||
alertas: Mapped[list["Alerta"]] = relationship(
|
||||
"Alerta",
|
||||
back_populates="conductor",
|
||||
lazy="dynamic",
|
||||
)
|
||||
cargas_combustible: Mapped[list["CargaCombustible"]] = relationship(
|
||||
"CargaCombustible",
|
||||
back_populates="conductor",
|
||||
lazy="dynamic",
|
||||
)
|
||||
mensajes: Mapped[list["Mensaje"]] = relationship(
|
||||
"Mensaje",
|
||||
back_populates="conductor",
|
||||
lazy="dynamic",
|
||||
)
|
||||
|
||||
@property
|
||||
def nombre_completo(self) -> str:
|
||||
"""Retorna el nombre completo del conductor."""
|
||||
return f"{self.nombre} {self.apellido}"
|
||||
|
||||
@property
|
||||
def licencia_vigente(self) -> bool:
|
||||
"""Verifica si la licencia está vigente."""
|
||||
if not self.licencia_vencimiento:
|
||||
return False
|
||||
return self.licencia_vencimiento >= date.today()
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Conductor(id={self.id}, nombre='{self.nombre_completo}')>"
|
||||
249
backend/app/models/configuracion.py
Normal file
249
backend/app/models/configuracion.py
Normal file
@@ -0,0 +1,249 @@
|
||||
"""
|
||||
Modelo de Configuración para almacenar settings del sistema.
|
||||
"""
|
||||
|
||||
from sqlalchemy import String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.database import Base
|
||||
from app.models.base import TimestampMixin
|
||||
|
||||
|
||||
class Configuracion(Base, TimestampMixin):
|
||||
"""
|
||||
Modelo para almacenar configuraciones del sistema.
|
||||
|
||||
Permite guardar configuraciones dinámicas sin necesidad
|
||||
de reiniciar la aplicación.
|
||||
"""
|
||||
|
||||
__tablename__ = "configuraciones"
|
||||
|
||||
clave: Mapped[str] = mapped_column(String(100), primary_key=True)
|
||||
valor_json: Mapped[str] = mapped_column(Text, nullable=False) # Valor en formato JSON
|
||||
categoria: Mapped[str] = mapped_column(
|
||||
String(50),
|
||||
default="general",
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
descripcion: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
# Tipo de dato para validación
|
||||
tipo_dato: Mapped[str] = mapped_column(
|
||||
String(20),
|
||||
default="string",
|
||||
nullable=False,
|
||||
) # string, number, boolean, json, array
|
||||
|
||||
# Si la configuración es sensible (no mostrar en logs)
|
||||
sensible: Mapped[bool] = mapped_column(default=False, nullable=False)
|
||||
|
||||
# Si puede ser modificada desde la UI
|
||||
editable: Mapped[bool] = mapped_column(default=True, nullable=False)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Configuracion(clave='{self.clave}', categoria='{self.categoria}')>"
|
||||
|
||||
def get_value(self):
|
||||
"""Parsea y retorna el valor según su tipo."""
|
||||
import json
|
||||
if self.tipo_dato == "string":
|
||||
return json.loads(self.valor_json)
|
||||
elif self.tipo_dato == "number":
|
||||
return float(json.loads(self.valor_json))
|
||||
elif self.tipo_dato == "boolean":
|
||||
return bool(json.loads(self.valor_json))
|
||||
else:
|
||||
return json.loads(self.valor_json)
|
||||
|
||||
|
||||
# Configuraciones por defecto del sistema
|
||||
CONFIGURACIONES_DEFAULT = [
|
||||
# Alertas
|
||||
{
|
||||
"clave": "alerta_velocidad_maxima",
|
||||
"valor_json": "120",
|
||||
"categoria": "alertas",
|
||||
"descripcion": "Velocidad máxima permitida (km/h) antes de generar alerta",
|
||||
"tipo_dato": "number",
|
||||
},
|
||||
{
|
||||
"clave": "alerta_parada_minutos",
|
||||
"valor_json": "15",
|
||||
"categoria": "alertas",
|
||||
"descripcion": "Minutos de parada para considerar como parada prolongada",
|
||||
"tipo_dato": "number",
|
||||
},
|
||||
{
|
||||
"clave": "alerta_bateria_minima",
|
||||
"valor_json": "20",
|
||||
"categoria": "alertas",
|
||||
"descripcion": "Porcentaje mínimo de batería antes de alertar",
|
||||
"tipo_dato": "number",
|
||||
},
|
||||
{
|
||||
"clave": "alerta_sin_señal_minutos",
|
||||
"valor_json": "30",
|
||||
"categoria": "alertas",
|
||||
"descripcion": "Minutos sin señal para generar alerta",
|
||||
"tipo_dato": "number",
|
||||
},
|
||||
{
|
||||
"clave": "alerta_motor_encendido_minutos",
|
||||
"valor_json": "10",
|
||||
"categoria": "alertas",
|
||||
"descripcion": "Minutos con motor encendido sin movimiento para alertar",
|
||||
"tipo_dato": "number",
|
||||
},
|
||||
|
||||
# Viajes
|
||||
{
|
||||
"clave": "viaje_velocidad_minima",
|
||||
"valor_json": "5",
|
||||
"categoria": "viajes",
|
||||
"descripcion": "Velocidad mínima (km/h) para considerar movimiento",
|
||||
"tipo_dato": "number",
|
||||
},
|
||||
{
|
||||
"clave": "viaje_parada_minutos",
|
||||
"valor_json": "5",
|
||||
"categoria": "viajes",
|
||||
"descripcion": "Minutos de parada para finalizar un viaje automáticamente",
|
||||
"tipo_dato": "number",
|
||||
},
|
||||
|
||||
# Paradas
|
||||
{
|
||||
"clave": "parada_duracion_minima",
|
||||
"valor_json": "120",
|
||||
"categoria": "paradas",
|
||||
"descripcion": "Segundos mínimos para registrar una parada",
|
||||
"tipo_dato": "number",
|
||||
},
|
||||
|
||||
# Combustible
|
||||
{
|
||||
"clave": "combustible_precio_gasolina",
|
||||
"valor_json": "22.50",
|
||||
"categoria": "combustible",
|
||||
"descripcion": "Precio por defecto del litro de gasolina",
|
||||
"tipo_dato": "number",
|
||||
},
|
||||
{
|
||||
"clave": "combustible_precio_diesel",
|
||||
"valor_json": "23.80",
|
||||
"categoria": "combustible",
|
||||
"descripcion": "Precio por defecto del litro de diesel",
|
||||
"tipo_dato": "number",
|
||||
},
|
||||
|
||||
# Mantenimiento
|
||||
{
|
||||
"clave": "mantenimiento_recordatorio_dias",
|
||||
"valor_json": "7",
|
||||
"categoria": "mantenimiento",
|
||||
"descripcion": "Días de anticipación para recordatorio de mantenimiento",
|
||||
"tipo_dato": "number",
|
||||
},
|
||||
{
|
||||
"clave": "mantenimiento_recordatorio_km",
|
||||
"valor_json": "500",
|
||||
"categoria": "mantenimiento",
|
||||
"descripcion": "Km de anticipación para recordatorio de mantenimiento",
|
||||
"tipo_dato": "number",
|
||||
},
|
||||
|
||||
# Notificaciones
|
||||
{
|
||||
"clave": "notificaciones_email_habilitado",
|
||||
"valor_json": "true",
|
||||
"categoria": "notificaciones",
|
||||
"descripcion": "Habilitar notificaciones por email",
|
||||
"tipo_dato": "boolean",
|
||||
},
|
||||
{
|
||||
"clave": "notificaciones_push_habilitado",
|
||||
"valor_json": "true",
|
||||
"categoria": "notificaciones",
|
||||
"descripcion": "Habilitar notificaciones push",
|
||||
"tipo_dato": "boolean",
|
||||
},
|
||||
{
|
||||
"clave": "notificaciones_destinatarios",
|
||||
"valor_json": '["admin@adan-fleet.com"]',
|
||||
"categoria": "notificaciones",
|
||||
"descripcion": "Lista de emails para notificaciones críticas",
|
||||
"tipo_dato": "array",
|
||||
"sensible": True,
|
||||
},
|
||||
|
||||
# Mapas
|
||||
{
|
||||
"clave": "mapa_centro_lat",
|
||||
"valor_json": "19.4326",
|
||||
"categoria": "mapas",
|
||||
"descripcion": "Latitud del centro del mapa por defecto",
|
||||
"tipo_dato": "number",
|
||||
},
|
||||
{
|
||||
"clave": "mapa_centro_lng",
|
||||
"valor_json": "-99.1332",
|
||||
"categoria": "mapas",
|
||||
"descripcion": "Longitud del centro del mapa por defecto",
|
||||
"tipo_dato": "number",
|
||||
},
|
||||
{
|
||||
"clave": "mapa_zoom_default",
|
||||
"valor_json": "12",
|
||||
"categoria": "mapas",
|
||||
"descripcion": "Nivel de zoom inicial del mapa",
|
||||
"tipo_dato": "number",
|
||||
},
|
||||
|
||||
# Retención de datos
|
||||
{
|
||||
"clave": "retencion_ubicaciones_dias",
|
||||
"valor_json": "365",
|
||||
"categoria": "retencion",
|
||||
"descripcion": "Días de retención de ubicaciones GPS",
|
||||
"tipo_dato": "number",
|
||||
},
|
||||
{
|
||||
"clave": "retencion_alertas_dias",
|
||||
"valor_json": "180",
|
||||
"categoria": "retencion",
|
||||
"descripcion": "Días de retención de alertas",
|
||||
"tipo_dato": "number",
|
||||
},
|
||||
{
|
||||
"clave": "retencion_videos_dias",
|
||||
"valor_json": "30",
|
||||
"categoria": "retencion",
|
||||
"descripcion": "Días de retención de videos",
|
||||
"tipo_dato": "number",
|
||||
},
|
||||
|
||||
# General
|
||||
{
|
||||
"clave": "empresa_nombre",
|
||||
"valor_json": '"Adan Fleet"',
|
||||
"categoria": "general",
|
||||
"descripcion": "Nombre de la empresa",
|
||||
"tipo_dato": "string",
|
||||
},
|
||||
{
|
||||
"clave": "empresa_logo_url",
|
||||
"valor_json": '""',
|
||||
"categoria": "general",
|
||||
"descripcion": "URL del logo de la empresa",
|
||||
"tipo_dato": "string",
|
||||
},
|
||||
{
|
||||
"clave": "zona_horaria",
|
||||
"valor_json": '"America/Mexico_City"',
|
||||
"categoria": "general",
|
||||
"descripcion": "Zona horaria del sistema",
|
||||
"tipo_dato": "string",
|
||||
},
|
||||
]
|
||||
111
backend/app/models/dispositivo.py
Normal file
111
backend/app/models/dispositivo.py
Normal file
@@ -0,0 +1,111 @@
|
||||
"""
|
||||
Modelo de Dispositivo GPS/Tracker para vehículos.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
DateTime,
|
||||
Float,
|
||||
ForeignKey,
|
||||
Integer,
|
||||
String,
|
||||
Text,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.core.database import Base
|
||||
from app.models.base import TimestampMixin
|
||||
|
||||
|
||||
class Dispositivo(Base, TimestampMixin):
|
||||
"""Modelo de dispositivo GPS/tracker instalado en un vehículo."""
|
||||
|
||||
__tablename__ = "dispositivos"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
|
||||
# Relación con vehículo
|
||||
vehiculo_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("vehiculos.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
|
||||
# Tipo de dispositivo
|
||||
tipo: Mapped[str] = mapped_column(
|
||||
String(50),
|
||||
nullable=False,
|
||||
default="gps",
|
||||
) # gps, obd, meshtastic, smartphone
|
||||
|
||||
# Identificación
|
||||
identificador: Mapped[str] = mapped_column(
|
||||
String(100),
|
||||
unique=True,
|
||||
nullable=False,
|
||||
index=True,
|
||||
) # ID único del dispositivo
|
||||
nombre: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
marca: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||
modelo: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||
numero_serie: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
|
||||
# Información de SIM
|
||||
telefono_sim: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
operador_sim: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||
iccid: Mapped[str | None] = mapped_column(String(25), nullable=True) # ID de la SIM
|
||||
|
||||
# IMEI (para dispositivos celulares)
|
||||
imei: Mapped[str | None] = mapped_column(String(20), nullable=True, unique=True)
|
||||
|
||||
# Protocolo de comunicación
|
||||
protocolo: Mapped[str] = mapped_column(
|
||||
String(50),
|
||||
default="osmand",
|
||||
nullable=False,
|
||||
) # osmand, traccar, gt06, meshtastic, mqtt
|
||||
|
||||
# Estado de conexión
|
||||
ultimo_contacto: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
bateria: Mapped[float | None] = mapped_column(Float, nullable=True) # Porcentaje 0-100
|
||||
señal_gsm: Mapped[int | None] = mapped_column(Integer, nullable=True) # Nivel de señal
|
||||
satelites: Mapped[int | None] = mapped_column(Integer, nullable=True) # Satélites GPS
|
||||
|
||||
# Configuración del dispositivo
|
||||
intervalo_reporte: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
default=30,
|
||||
nullable=False,
|
||||
) # Segundos entre reportes
|
||||
configuracion: Mapped[str | None] = mapped_column(Text, nullable=True) # JSON con config adicional
|
||||
|
||||
# Firmware
|
||||
firmware_version: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||
|
||||
# Estado
|
||||
activo: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
conectado: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
|
||||
# Notas
|
||||
notas: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
# Relaciones ORM
|
||||
vehiculo: Mapped["Vehiculo"] = relationship(
|
||||
"Vehiculo",
|
||||
back_populates="dispositivos",
|
||||
lazy="selectin",
|
||||
)
|
||||
|
||||
@property
|
||||
def esta_online(self) -> bool:
|
||||
"""Verifica si el dispositivo está online (último contacto < 5 minutos)."""
|
||||
if not self.ultimo_contacto:
|
||||
return False
|
||||
from datetime import timezone, timedelta
|
||||
tiempo_limite = datetime.now(timezone.utc) - timedelta(minutes=5)
|
||||
return self.ultimo_contacto > tiempo_limite
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Dispositivo(id={self.id}, identificador='{self.identificador}', tipo='{self.tipo}')>"
|
||||
156
backend/app/models/evento_video.py
Normal file
156
backend/app/models/evento_video.py
Normal file
@@ -0,0 +1,156 @@
|
||||
"""
|
||||
Modelo de Evento de Video para registrar eventos detectados por cámaras.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
DateTime,
|
||||
Float,
|
||||
ForeignKey,
|
||||
Index,
|
||||
String,
|
||||
Text,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.core.database import Base
|
||||
from app.models.base import TimestampMixin
|
||||
|
||||
|
||||
class EventoVideo(Base, TimestampMixin):
|
||||
"""Modelo para eventos detectados por cámaras (AI/ADAS)."""
|
||||
|
||||
__tablename__ = "eventos_video"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
|
||||
# Relaciones
|
||||
camara_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("camaras.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
vehiculo_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("vehiculos.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
|
||||
# Tipo de evento
|
||||
tipo: Mapped[str] = mapped_column(
|
||||
String(50),
|
||||
nullable=False,
|
||||
) # colision, distraccion, fatiga, cambio_carril, exceso_velocidad, objeto_detectado
|
||||
|
||||
# Severidad
|
||||
severidad: Mapped[str] = mapped_column(
|
||||
String(20),
|
||||
default="media",
|
||||
nullable=False,
|
||||
) # baja, media, alta, critica
|
||||
|
||||
# Tiempo del evento
|
||||
tiempo: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
|
||||
# Ubicación
|
||||
lat: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
lng: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
velocidad: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
|
||||
# Descripción
|
||||
descripcion: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
# Confianza de la detección (si es detección AI)
|
||||
confianza: Mapped[float | None] = mapped_column(Float, nullable=True) # 0-100%
|
||||
|
||||
# Datos adicionales (JSON)
|
||||
datos_extra: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
# Estado de revisión
|
||||
revisado: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
revisado_por_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("usuarios.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
)
|
||||
revisado_en: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
notas_revision: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
# Falso positivo
|
||||
falso_positivo: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
|
||||
# Snapshot del momento
|
||||
snapshot_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
|
||||
# Clip de video asociado
|
||||
clip_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
clip_duracion: Mapped[int | None] = mapped_column(default=None, nullable=True) # segundos
|
||||
|
||||
# Relaciones ORM
|
||||
camara: Mapped["Camara"] = relationship(
|
||||
"Camara",
|
||||
back_populates="eventos_video",
|
||||
lazy="selectin",
|
||||
)
|
||||
|
||||
# Índices
|
||||
__table_args__ = (
|
||||
Index("idx_eventos_video_camara_tiempo", "camara_id", "tiempo"),
|
||||
Index("idx_eventos_video_vehiculo_tiempo", "vehiculo_id", "tiempo"),
|
||||
Index("idx_eventos_video_tipo", "tipo"),
|
||||
Index("idx_eventos_video_revisado", "revisado"),
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<EventoVideo(id={self.id}, tipo='{self.tipo}', severidad='{self.severidad}')>"
|
||||
|
||||
|
||||
# Tipos de eventos de video predefinidos
|
||||
TIPOS_EVENTO_VIDEO = [
|
||||
{
|
||||
"codigo": "COLISION_FRONTAL",
|
||||
"nombre": "Posible colisión frontal",
|
||||
"severidad": "critica",
|
||||
},
|
||||
{
|
||||
"codigo": "DISTRACCION_CONDUCTOR",
|
||||
"nombre": "Distracción del conductor",
|
||||
"severidad": "alta",
|
||||
},
|
||||
{
|
||||
"codigo": "FATIGA_CONDUCTOR",
|
||||
"nombre": "Fatiga del conductor",
|
||||
"severidad": "alta",
|
||||
},
|
||||
{
|
||||
"codigo": "CAMBIO_CARRIL_PELIGROSO",
|
||||
"nombre": "Cambio de carril peligroso",
|
||||
"severidad": "media",
|
||||
},
|
||||
{
|
||||
"codigo": "SEGUIMIENTO_CERCANO",
|
||||
"nombre": "Seguimiento muy cercano",
|
||||
"severidad": "media",
|
||||
},
|
||||
{
|
||||
"codigo": "PEATON_DETECTADO",
|
||||
"nombre": "Peatón detectado",
|
||||
"severidad": "media",
|
||||
},
|
||||
{
|
||||
"codigo": "USO_CELULAR",
|
||||
"nombre": "Uso de celular",
|
||||
"severidad": "alta",
|
||||
},
|
||||
{
|
||||
"codigo": "SIN_CINTURON",
|
||||
"nombre": "Sin cinturón de seguridad",
|
||||
"severidad": "media",
|
||||
},
|
||||
{
|
||||
"codigo": "FUMANDO",
|
||||
"nombre": "Conductor fumando",
|
||||
"severidad": "baja",
|
||||
},
|
||||
]
|
||||
143
backend/app/models/geocerca.py
Normal file
143
backend/app/models/geocerca.py
Normal file
@@ -0,0 +1,143 @@
|
||||
"""
|
||||
Modelo de Geocerca para delimitar zonas geográficas.
|
||||
"""
|
||||
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
Float,
|
||||
Index,
|
||||
Integer,
|
||||
String,
|
||||
Text,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.core.database import Base
|
||||
from app.models.base import TimestampMixin
|
||||
|
||||
|
||||
# Tabla de asociación para geocercas y vehículos
|
||||
from sqlalchemy import Table, Column, ForeignKey
|
||||
|
||||
geocerca_vehiculo = Table(
|
||||
"geocerca_vehiculo",
|
||||
Base.metadata,
|
||||
Column("geocerca_id", Integer, ForeignKey("geocercas.id", ondelete="CASCADE"), primary_key=True),
|
||||
Column("vehiculo_id", Integer, ForeignKey("vehiculos.id", ondelete="CASCADE"), primary_key=True),
|
||||
)
|
||||
|
||||
|
||||
class Geocerca(Base, TimestampMixin):
|
||||
"""
|
||||
Modelo de geocerca (zona geográfica delimitada).
|
||||
|
||||
Soporta dos tipos de geometría:
|
||||
- circular: definida por un punto central y radio
|
||||
- poligono: definida por una lista de coordenadas
|
||||
"""
|
||||
|
||||
__tablename__ = "geocercas"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
|
||||
# Identificación
|
||||
nombre: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
descripcion: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
# Tipo de geometría
|
||||
tipo: Mapped[str] = mapped_column(
|
||||
String(20),
|
||||
default="circular",
|
||||
nullable=False,
|
||||
) # circular, poligono
|
||||
|
||||
# Para geocercas circulares
|
||||
centro_lat: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
centro_lng: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
radio_metros: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
|
||||
# Para geocercas poligonales (JSON array de coordenadas)
|
||||
# Formato: [[lat1, lng1], [lat2, lng2], ...]
|
||||
coordenadas_json: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
# Visualización
|
||||
color: Mapped[str] = mapped_column(String(7), default="#3B82F6", nullable=False)
|
||||
opacidad: Mapped[float] = mapped_column(Float, default=0.3, nullable=False)
|
||||
color_borde: Mapped[str] = mapped_column(String(7), default="#1D4ED8", nullable=False)
|
||||
|
||||
# Configuración de alertas
|
||||
alerta_entrada: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
alerta_salida: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
velocidad_maxima: Mapped[float | None] = mapped_column(Float, nullable=True) # km/h dentro de la geocerca
|
||||
|
||||
# Horario de activación (opcional)
|
||||
# Formato JSON: {"dias": [1,2,3,4,5], "hora_inicio": "08:00", "hora_fin": "18:00"}
|
||||
horario_json: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
# Categoría
|
||||
categoria: Mapped[str | None] = mapped_column(String(50), nullable=True) # oficina, cliente, zona_riesgo, etc.
|
||||
|
||||
# Estado
|
||||
activa: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
|
||||
# Vehículos asignados (many-to-many)
|
||||
# Si está vacío, aplica a todos los vehículos
|
||||
vehiculos_asignados: Mapped[list["Vehiculo"]] = relationship(
|
||||
"Vehiculo",
|
||||
secondary=geocerca_vehiculo,
|
||||
lazy="selectin",
|
||||
)
|
||||
|
||||
# Índices
|
||||
__table_args__ = (
|
||||
Index("idx_geocercas_activa", "activa"),
|
||||
Index("idx_geocercas_tipo", "tipo"),
|
||||
)
|
||||
|
||||
@property
|
||||
def aplica_todos_vehiculos(self) -> bool:
|
||||
"""Verifica si la geocerca aplica a todos los vehículos."""
|
||||
return len(self.vehiculos_asignados) == 0
|
||||
|
||||
def to_geojson(self) -> dict:
|
||||
"""Convierte la geocerca a formato GeoJSON."""
|
||||
import json
|
||||
|
||||
if self.tipo == "circular":
|
||||
return {
|
||||
"type": "Feature",
|
||||
"geometry": {
|
||||
"type": "Point",
|
||||
"coordinates": [self.centro_lng, self.centro_lat],
|
||||
},
|
||||
"properties": {
|
||||
"id": self.id,
|
||||
"nombre": self.nombre,
|
||||
"tipo": self.tipo,
|
||||
"radio_metros": self.radio_metros,
|
||||
"color": self.color,
|
||||
},
|
||||
}
|
||||
else:
|
||||
coords = json.loads(self.coordenadas_json) if self.coordenadas_json else []
|
||||
# GeoJSON usa [lng, lat], no [lat, lng]
|
||||
coords_geojson = [[c[1], c[0]] for c in coords]
|
||||
# Cerrar el polígono si no está cerrado
|
||||
if coords_geojson and coords_geojson[0] != coords_geojson[-1]:
|
||||
coords_geojson.append(coords_geojson[0])
|
||||
return {
|
||||
"type": "Feature",
|
||||
"geometry": {
|
||||
"type": "Polygon",
|
||||
"coordinates": [coords_geojson],
|
||||
},
|
||||
"properties": {
|
||||
"id": self.id,
|
||||
"nombre": self.nombre,
|
||||
"tipo": self.tipo,
|
||||
"color": self.color,
|
||||
},
|
||||
}
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Geocerca(id={self.id}, nombre='{self.nombre}', tipo='{self.tipo}')>"
|
||||
109
backend/app/models/grabacion.py
Normal file
109
backend/app/models/grabacion.py
Normal file
@@ -0,0 +1,109 @@
|
||||
"""
|
||||
Modelo de Grabación para almacenar videos de cámaras.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import (
|
||||
DateTime,
|
||||
Float,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
String,
|
||||
Text,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.core.database import Base
|
||||
from app.models.base import TimestampMixin
|
||||
|
||||
|
||||
class Grabacion(Base, TimestampMixin):
|
||||
"""Modelo para almacenar grabaciones de video."""
|
||||
|
||||
__tablename__ = "grabaciones"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
|
||||
# Relaciones
|
||||
camara_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("camaras.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
vehiculo_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("vehiculos.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
|
||||
# Tiempo
|
||||
inicio_tiempo: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
fin_tiempo: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
duracion_segundos: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
|
||||
# Archivo
|
||||
archivo_url: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||
archivo_nombre: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
tamaño_mb: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
formato: Mapped[str] = mapped_column(String(10), default="mp4", nullable=False) # mp4, webm, mkv
|
||||
resolucion: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
|
||||
# Tipo de grabación
|
||||
tipo: Mapped[str] = mapped_column(
|
||||
String(50),
|
||||
default="continua",
|
||||
nullable=False,
|
||||
) # continua, evento, manual, snapshot
|
||||
|
||||
# Evento asociado (si es grabación por evento)
|
||||
evento_video_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("eventos_video.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
)
|
||||
|
||||
# Ubicación al inicio de la grabación
|
||||
lat: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
lng: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
|
||||
# Estado
|
||||
estado: Mapped[str] = mapped_column(
|
||||
String(20),
|
||||
default="disponible",
|
||||
nullable=False,
|
||||
) # grabando, procesando, disponible, error, eliminado
|
||||
|
||||
# Thumbnail
|
||||
thumbnail_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
|
||||
# Notas
|
||||
notas: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
# Relaciones ORM
|
||||
camara: Mapped["Camara"] = relationship(
|
||||
"Camara",
|
||||
back_populates="grabaciones",
|
||||
lazy="selectin",
|
||||
)
|
||||
|
||||
# Índices
|
||||
__table_args__ = (
|
||||
Index("idx_grabaciones_camara_inicio", "camara_id", "inicio_tiempo"),
|
||||
Index("idx_grabaciones_vehiculo_inicio", "vehiculo_id", "inicio_tiempo"),
|
||||
Index("idx_grabaciones_tipo", "tipo"),
|
||||
)
|
||||
|
||||
@property
|
||||
def duracion_formateada(self) -> str:
|
||||
"""Retorna la duración en formato legible."""
|
||||
if not self.duracion_segundos:
|
||||
return "N/A"
|
||||
minutos = self.duracion_segundos // 60
|
||||
segundos = self.duracion_segundos % 60
|
||||
if minutos > 0:
|
||||
return f"{minutos}m {segundos}s"
|
||||
return f"{segundos}s"
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Grabacion(id={self.id}, camara_id={self.camara_id}, tipo='{self.tipo}')>"
|
||||
31
backend/app/models/grupo_vehiculos.py
Normal file
31
backend/app/models/grupo_vehiculos.py
Normal file
@@ -0,0 +1,31 @@
|
||||
"""
|
||||
Modelo de Grupo de Vehículos para organizar la flota.
|
||||
"""
|
||||
|
||||
from sqlalchemy import String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.core.database import Base
|
||||
from app.models.base import TimestampMixin
|
||||
|
||||
|
||||
class GrupoVehiculos(Base, TimestampMixin):
|
||||
"""Modelo para agrupar vehículos (ej: Reparto Norte, Ejecutivos, etc.)."""
|
||||
|
||||
__tablename__ = "grupos_vehiculos"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
nombre: Mapped[str] = mapped_column(String(100), nullable=False, unique=True)
|
||||
descripcion: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
color: Mapped[str] = mapped_column(String(7), default="#3B82F6", nullable=False) # Hex color
|
||||
icono: Mapped[str | None] = mapped_column(String(50), nullable=True) # Nombre del icono
|
||||
|
||||
# Relaciones
|
||||
vehiculos: Mapped[list["Vehiculo"]] = relationship(
|
||||
"Vehiculo",
|
||||
back_populates="grupo",
|
||||
lazy="selectin",
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<GrupoVehiculos(id={self.id}, nombre='{self.nombre}')>"
|
||||
127
backend/app/models/mantenimiento.py
Normal file
127
backend/app/models/mantenimiento.py
Normal file
@@ -0,0 +1,127 @@
|
||||
"""
|
||||
Modelo de Mantenimiento para registrar servicios de mantenimiento.
|
||||
"""
|
||||
|
||||
from datetime import date, datetime
|
||||
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
Date,
|
||||
DateTime,
|
||||
Float,
|
||||
ForeignKey,
|
||||
Index,
|
||||
String,
|
||||
Text,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.core.database import Base
|
||||
from app.models.base import TimestampMixin
|
||||
|
||||
|
||||
class Mantenimiento(Base, TimestampMixin):
|
||||
"""Modelo para registrar mantenimientos de vehículos."""
|
||||
|
||||
__tablename__ = "mantenimientos"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
|
||||
# Relaciones
|
||||
vehiculo_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("vehiculos.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
tipo_mantenimiento_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("tipos_mantenimiento.id", ondelete="RESTRICT"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
|
||||
# Estado
|
||||
estado: Mapped[str] = mapped_column(
|
||||
String(20),
|
||||
default="programado",
|
||||
nullable=False,
|
||||
) # programado, en_proceso, completado, cancelado, vencido
|
||||
|
||||
# Fechas
|
||||
fecha_programada: Mapped[date] = mapped_column(Date, nullable=False)
|
||||
fecha_realizada: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
|
||||
# Odómetro
|
||||
odometro_programado: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
odometro_realizado: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
|
||||
# Costos
|
||||
costo_estimado: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
costo_real: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
costo_mano_obra: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
costo_refacciones: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
|
||||
# Proveedor
|
||||
proveedor: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
proveedor_direccion: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
proveedor_telefono: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
|
||||
# Documentación
|
||||
numero_factura: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||
numero_orden: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||
|
||||
# Detalles
|
||||
descripcion: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
trabajos_realizados: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
refacciones_usadas: Mapped[str | None] = mapped_column(Text, nullable=True) # JSON array
|
||||
|
||||
# Notas
|
||||
notas: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
# Técnico responsable
|
||||
tecnico: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
|
||||
# Próximo mantenimiento (para calcular el siguiente)
|
||||
proximo_km: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
proxima_fecha: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
|
||||
# Archivos adjuntos (JSON array de URLs)
|
||||
archivos_adjuntos: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
# Recordatorios enviados
|
||||
recordatorio_enviado: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
|
||||
# Relaciones ORM
|
||||
vehiculo: Mapped["Vehiculo"] = relationship(
|
||||
"Vehiculo",
|
||||
back_populates="mantenimientos",
|
||||
lazy="selectin",
|
||||
)
|
||||
tipo_mantenimiento: Mapped["TipoMantenimiento"] = relationship(
|
||||
"TipoMantenimiento",
|
||||
back_populates="mantenimientos",
|
||||
lazy="selectin",
|
||||
)
|
||||
|
||||
# Índices
|
||||
__table_args__ = (
|
||||
Index("idx_mantenimientos_vehiculo_fecha", "vehiculo_id", "fecha_programada"),
|
||||
Index("idx_mantenimientos_estado", "estado"),
|
||||
Index("idx_mantenimientos_fecha_prog", "fecha_programada"),
|
||||
)
|
||||
|
||||
@property
|
||||
def esta_vencido(self) -> bool:
|
||||
"""Verifica si el mantenimiento está vencido."""
|
||||
if self.estado in ["completado", "cancelado"]:
|
||||
return False
|
||||
return self.fecha_programada < date.today()
|
||||
|
||||
@property
|
||||
def dias_para_vencimiento(self) -> int | None:
|
||||
"""Calcula los días restantes para el vencimiento."""
|
||||
if self.estado in ["completado", "cancelado"]:
|
||||
return None
|
||||
return (self.fecha_programada - date.today()).days
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Mantenimiento(id={self.id}, vehiculo_id={self.vehiculo_id}, estado='{self.estado}')>"
|
||||
94
backend/app/models/mensaje.py
Normal file
94
backend/app/models/mensaje.py
Normal file
@@ -0,0 +1,94 @@
|
||||
"""
|
||||
Modelo de Mensaje para comunicación con conductores.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Index,
|
||||
String,
|
||||
Text,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.core.database import Base
|
||||
from app.models.base import TimestampMixin
|
||||
|
||||
|
||||
class Mensaje(Base, TimestampMixin):
|
||||
"""Modelo para mensajes entre administradores y conductores."""
|
||||
|
||||
__tablename__ = "mensajes"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
|
||||
# Conductor asociado
|
||||
conductor_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("conductores.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
|
||||
# Dirección del mensaje
|
||||
de_admin: Mapped[bool] = mapped_column(Boolean, nullable=False) # True = admin->conductor, False = conductor->admin
|
||||
|
||||
# Usuario admin que envió/recibió (si aplica)
|
||||
usuario_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("usuarios.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
)
|
||||
|
||||
# Contenido
|
||||
asunto: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
||||
contenido: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
|
||||
# Tipo de mensaje
|
||||
tipo: Mapped[str] = mapped_column(
|
||||
String(20),
|
||||
default="texto",
|
||||
nullable=False,
|
||||
) # texto, alerta, instruccion, emergencia
|
||||
|
||||
# Prioridad
|
||||
prioridad: Mapped[str] = mapped_column(
|
||||
String(20),
|
||||
default="normal",
|
||||
nullable=False,
|
||||
) # baja, normal, alta, urgente
|
||||
|
||||
# Estado de lectura
|
||||
leido: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
leido_en: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
# Archivos adjuntos (JSON array de URLs)
|
||||
adjuntos: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
# Respuesta a otro mensaje
|
||||
respuesta_a_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("mensajes.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
)
|
||||
|
||||
# Mensaje eliminado (soft delete)
|
||||
eliminado_por_admin: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
eliminado_por_conductor: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
|
||||
# Relaciones ORM
|
||||
conductor: Mapped["Conductor"] = relationship(
|
||||
"Conductor",
|
||||
back_populates="mensajes",
|
||||
lazy="selectin",
|
||||
)
|
||||
|
||||
# Índices
|
||||
__table_args__ = (
|
||||
Index("idx_mensajes_conductor_creado", "conductor_id", "creado_en"),
|
||||
Index("idx_mensajes_leido", "leido"),
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
direccion = "admin->conductor" if self.de_admin else "conductor->admin"
|
||||
return f"<Mensaje(id={self.id}, {direccion}, conductor_id={self.conductor_id})>"
|
||||
111
backend/app/models/parada.py
Normal file
111
backend/app/models/parada.py
Normal file
@@ -0,0 +1,111 @@
|
||||
"""
|
||||
Modelo de Parada para registrar detenciones durante viajes.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
DateTime,
|
||||
Float,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
String,
|
||||
Text,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class Parada(Base):
|
||||
"""
|
||||
Modelo de parada/detención de un vehículo durante un viaje.
|
||||
|
||||
Se registra cuando el vehículo permanece detenido por más
|
||||
de un tiempo mínimo configurado (ej: 2 minutos).
|
||||
"""
|
||||
|
||||
__tablename__ = "paradas"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
|
||||
# Relaciones
|
||||
viaje_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("viajes.id", ondelete="CASCADE"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
vehiculo_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("vehiculos.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
|
||||
# Tiempo
|
||||
inicio_tiempo: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
fin_tiempo: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
duracion_segundos: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
|
||||
# Ubicación
|
||||
lat: Mapped[float] = mapped_column(Float, nullable=False)
|
||||
lng: Mapped[float] = mapped_column(Float, nullable=False)
|
||||
direccion: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
|
||||
# Clasificación
|
||||
tipo: Mapped[str] = mapped_column(
|
||||
String(50),
|
||||
default="desconocido",
|
||||
nullable=False,
|
||||
) # desconocido, entrega, carga, descanso, trafico, cliente, otro
|
||||
|
||||
# Estado del vehículo durante la parada
|
||||
motor_apagado: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
|
||||
|
||||
# POI asociado (si aplica)
|
||||
poi_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("pois.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
)
|
||||
|
||||
# Geocerca asociada (si aplica)
|
||||
geocerca_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("geocercas.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
)
|
||||
|
||||
# Estado (para paradas en curso)
|
||||
en_curso: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
|
||||
# Notas
|
||||
notas: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
# Relaciones ORM
|
||||
viaje: Mapped["Viaje | None"] = relationship(
|
||||
"Viaje",
|
||||
back_populates="paradas",
|
||||
lazy="selectin",
|
||||
)
|
||||
|
||||
# Índices
|
||||
__table_args__ = (
|
||||
Index("idx_paradas_vehiculo_inicio", "vehiculo_id", "inicio_tiempo"),
|
||||
Index("idx_paradas_en_curso", "en_curso"),
|
||||
)
|
||||
|
||||
@property
|
||||
def duracion_formateada(self) -> str:
|
||||
"""Retorna la duración en formato legible."""
|
||||
if not self.duracion_segundos:
|
||||
if self.en_curso:
|
||||
return "En curso"
|
||||
return "N/A"
|
||||
horas = self.duracion_segundos // 3600
|
||||
minutos = (self.duracion_segundos % 3600) // 60
|
||||
if horas > 0:
|
||||
return f"{horas}h {minutos}m"
|
||||
return f"{minutos}m"
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Parada(id={self.id}, vehiculo_id={self.vehiculo_id}, tipo='{self.tipo}')>"
|
||||
108
backend/app/models/poi.py
Normal file
108
backend/app/models/poi.py
Normal file
@@ -0,0 +1,108 @@
|
||||
"""
|
||||
Modelo de POI (Punto de Interés) para marcar ubicaciones importantes.
|
||||
"""
|
||||
|
||||
from sqlalchemy import Boolean, Float, Index, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.database import Base
|
||||
from app.models.base import TimestampMixin
|
||||
|
||||
|
||||
class POI(Base, TimestampMixin):
|
||||
"""
|
||||
Modelo de Punto de Interés.
|
||||
|
||||
Representa ubicaciones importantes como clientes, proveedores,
|
||||
estaciones de servicio, talleres, etc.
|
||||
"""
|
||||
|
||||
__tablename__ = "pois"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
|
||||
# Identificación
|
||||
nombre: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
descripcion: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
# Categoría
|
||||
categoria: Mapped[str] = mapped_column(
|
||||
String(50),
|
||||
default="otro",
|
||||
nullable=False,
|
||||
) # cliente, proveedor, gasolinera, taller, oficina, almacen, otro
|
||||
|
||||
# Ubicación
|
||||
lat: Mapped[float] = mapped_column(Float, nullable=False)
|
||||
lng: Mapped[float] = mapped_column(Float, nullable=False)
|
||||
direccion: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
ciudad: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
estado: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
codigo_postal: Mapped[str | None] = mapped_column(String(10), nullable=True)
|
||||
|
||||
# Radio de proximidad (para detectar llegadas)
|
||||
radio_metros: Mapped[float] = mapped_column(Float, default=100.0, nullable=False)
|
||||
|
||||
# Contacto
|
||||
telefono: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
email: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
contacto_nombre: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
|
||||
# Horario (JSON)
|
||||
# Formato: {"lunes": {"apertura": "09:00", "cierre": "18:00"}, ...}
|
||||
horario_json: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
# Visualización
|
||||
icono: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||
color: Mapped[str] = mapped_column(String(7), default="#10B981", nullable=False)
|
||||
|
||||
# Código externo (para integración con otros sistemas)
|
||||
codigo_externo: Mapped[str | None] = mapped_column(String(50), nullable=True, index=True)
|
||||
|
||||
# Estado
|
||||
activo: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
|
||||
# Notas
|
||||
notas: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
# Índices
|
||||
__table_args__ = (
|
||||
Index("idx_pois_coords", "lat", "lng"),
|
||||
Index("idx_pois_categoria", "categoria"),
|
||||
Index("idx_pois_activo", "activo"),
|
||||
)
|
||||
|
||||
def to_geojson(self) -> dict:
|
||||
"""Convierte el POI a formato GeoJSON."""
|
||||
return {
|
||||
"type": "Feature",
|
||||
"geometry": {
|
||||
"type": "Point",
|
||||
"coordinates": [self.lng, self.lat],
|
||||
},
|
||||
"properties": {
|
||||
"id": self.id,
|
||||
"nombre": self.nombre,
|
||||
"categoria": self.categoria,
|
||||
"direccion": self.direccion,
|
||||
"telefono": self.telefono,
|
||||
"icono": self.icono,
|
||||
"color": self.color,
|
||||
},
|
||||
}
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<POI(id={self.id}, nombre='{self.nombre}', categoria='{self.categoria}')>"
|
||||
|
||||
|
||||
# Categorías predefinidas de POIs
|
||||
CATEGORIAS_POI = [
|
||||
{"codigo": "cliente", "nombre": "Cliente", "icono": "building", "color": "#3B82F6"},
|
||||
{"codigo": "proveedor", "nombre": "Proveedor", "icono": "truck", "color": "#8B5CF6"},
|
||||
{"codigo": "gasolinera", "nombre": "Gasolinera", "icono": "fuel", "color": "#F59E0B"},
|
||||
{"codigo": "taller", "nombre": "Taller", "icono": "wrench", "color": "#6B7280"},
|
||||
{"codigo": "oficina", "nombre": "Oficina", "icono": "briefcase", "color": "#10B981"},
|
||||
{"codigo": "almacen", "nombre": "Almacén", "icono": "warehouse", "color": "#EC4899"},
|
||||
{"codigo": "estacionamiento", "nombre": "Estacionamiento", "icono": "parking", "color": "#06B6D4"},
|
||||
{"codigo": "otro", "nombre": "Otro", "icono": "map-pin", "color": "#6B7280"},
|
||||
]
|
||||
192
backend/app/models/tipo_alerta.py
Normal file
192
backend/app/models/tipo_alerta.py
Normal file
@@ -0,0 +1,192 @@
|
||||
"""
|
||||
Modelo de Tipo de Alerta para definir categorías de alertas.
|
||||
"""
|
||||
|
||||
from sqlalchemy import Boolean, Integer, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.core.database import Base
|
||||
from app.models.base import TimestampMixin
|
||||
|
||||
|
||||
class TipoAlerta(Base, TimestampMixin):
|
||||
"""Modelo para definir tipos/categorías de alertas del sistema."""
|
||||
|
||||
__tablename__ = "tipos_alerta"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
|
||||
# Identificación
|
||||
codigo: Mapped[str] = mapped_column(String(50), unique=True, nullable=False, index=True)
|
||||
nombre: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
descripcion: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
# Configuración
|
||||
severidad_default: Mapped[str] = mapped_column(
|
||||
String(20),
|
||||
default="media",
|
||||
nullable=False,
|
||||
) # baja, media, alta, critica
|
||||
icono: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||
color: Mapped[str] = mapped_column(String(7), default="#EF4444", nullable=False)
|
||||
|
||||
# Notificaciones
|
||||
notificar_email: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
notificar_push: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
notificar_sms: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
|
||||
# Estado
|
||||
activo: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
|
||||
# Prioridad para ordenamiento
|
||||
prioridad: Mapped[int] = mapped_column(Integer, default=50, nullable=False)
|
||||
|
||||
# Relaciones ORM
|
||||
alertas: Mapped[list["Alerta"]] = relationship(
|
||||
"Alerta",
|
||||
back_populates="tipo_alerta",
|
||||
lazy="dynamic",
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<TipoAlerta(id={self.id}, codigo='{self.codigo}', nombre='{self.nombre}')>"
|
||||
|
||||
|
||||
# Tipos de alerta predefinidos
|
||||
TIPOS_ALERTA_DEFAULT = [
|
||||
{
|
||||
"codigo": "EXCESO_VELOCIDAD",
|
||||
"nombre": "Exceso de velocidad",
|
||||
"descripcion": "El vehículo superó el límite de velocidad configurado",
|
||||
"severidad_default": "media",
|
||||
"icono": "speed",
|
||||
"color": "#F59E0B",
|
||||
"prioridad": 40,
|
||||
},
|
||||
{
|
||||
"codigo": "ENTRADA_GEOCERCA",
|
||||
"nombre": "Entrada a geocerca",
|
||||
"descripcion": "El vehículo entró a una zona delimitada",
|
||||
"severidad_default": "baja",
|
||||
"icono": "map-pin",
|
||||
"color": "#10B981",
|
||||
"prioridad": 60,
|
||||
},
|
||||
{
|
||||
"codigo": "SALIDA_GEOCERCA",
|
||||
"nombre": "Salida de geocerca",
|
||||
"descripcion": "El vehículo salió de una zona delimitada",
|
||||
"severidad_default": "baja",
|
||||
"icono": "map-pin-off",
|
||||
"color": "#F59E0B",
|
||||
"prioridad": 60,
|
||||
},
|
||||
{
|
||||
"codigo": "PARADA_PROLONGADA",
|
||||
"nombre": "Parada prolongada",
|
||||
"descripcion": "El vehículo ha permanecido detenido por tiempo excesivo",
|
||||
"severidad_default": "baja",
|
||||
"icono": "clock",
|
||||
"color": "#6B7280",
|
||||
"prioridad": 70,
|
||||
},
|
||||
{
|
||||
"codigo": "BATERIA_BAJA",
|
||||
"nombre": "Batería baja",
|
||||
"descripcion": "El dispositivo GPS tiene batería baja",
|
||||
"severidad_default": "media",
|
||||
"icono": "battery-low",
|
||||
"color": "#EF4444",
|
||||
"prioridad": 30,
|
||||
},
|
||||
{
|
||||
"codigo": "SIN_SEÑAL",
|
||||
"nombre": "Sin señal GPS",
|
||||
"descripcion": "El vehículo no ha reportado ubicación en el tiempo configurado",
|
||||
"severidad_default": "alta",
|
||||
"icono": "signal-off",
|
||||
"color": "#EF4444",
|
||||
"prioridad": 20,
|
||||
},
|
||||
{
|
||||
"codigo": "MOTOR_ENCENDIDO_PROLONGADO",
|
||||
"nombre": "Motor encendido sin movimiento",
|
||||
"descripcion": "El vehículo tiene el motor encendido pero no se mueve",
|
||||
"severidad_default": "baja",
|
||||
"icono": "engine",
|
||||
"color": "#F59E0B",
|
||||
"prioridad": 80,
|
||||
},
|
||||
{
|
||||
"codigo": "MANTENIMIENTO_PROXIMO",
|
||||
"nombre": "Mantenimiento próximo",
|
||||
"descripcion": "El vehículo tiene un mantenimiento programado próximamente",
|
||||
"severidad_default": "baja",
|
||||
"icono": "wrench",
|
||||
"color": "#3B82F6",
|
||||
"prioridad": 90,
|
||||
},
|
||||
{
|
||||
"codigo": "MANTENIMIENTO_VENCIDO",
|
||||
"nombre": "Mantenimiento vencido",
|
||||
"descripcion": "El vehículo tiene un mantenimiento vencido",
|
||||
"severidad_default": "alta",
|
||||
"icono": "alert-triangle",
|
||||
"color": "#EF4444",
|
||||
"prioridad": 10,
|
||||
},
|
||||
{
|
||||
"codigo": "ACELERACION_BRUSCA",
|
||||
"nombre": "Aceleración brusca",
|
||||
"descripcion": "Se detectó una aceleración brusca",
|
||||
"severidad_default": "media",
|
||||
"icono": "trending-up",
|
||||
"color": "#F59E0B",
|
||||
"prioridad": 50,
|
||||
},
|
||||
{
|
||||
"codigo": "FRENADO_BRUSCO",
|
||||
"nombre": "Frenado brusco",
|
||||
"descripcion": "Se detectó un frenado brusco",
|
||||
"severidad_default": "media",
|
||||
"icono": "trending-down",
|
||||
"color": "#F59E0B",
|
||||
"prioridad": 50,
|
||||
},
|
||||
{
|
||||
"codigo": "COLISION",
|
||||
"nombre": "Posible colisión",
|
||||
"descripcion": "Se detectó un impacto que podría indicar una colisión",
|
||||
"severidad_default": "critica",
|
||||
"icono": "alert-octagon",
|
||||
"color": "#DC2626",
|
||||
"prioridad": 1,
|
||||
},
|
||||
{
|
||||
"codigo": "BOTON_PANICO",
|
||||
"nombre": "Botón de pánico",
|
||||
"descripcion": "El conductor presionó el botón de pánico",
|
||||
"severidad_default": "critica",
|
||||
"icono": "alert-circle",
|
||||
"color": "#DC2626",
|
||||
"prioridad": 1,
|
||||
},
|
||||
{
|
||||
"codigo": "FUERA_HORARIO",
|
||||
"nombre": "Uso fuera de horario",
|
||||
"descripcion": "El vehículo está en uso fuera del horario permitido",
|
||||
"severidad_default": "media",
|
||||
"icono": "calendar-x",
|
||||
"color": "#F59E0B",
|
||||
"prioridad": 45,
|
||||
},
|
||||
{
|
||||
"codigo": "COMBUSTIBLE_BAJO",
|
||||
"nombre": "Combustible bajo",
|
||||
"descripcion": "El nivel de combustible es bajo",
|
||||
"severidad_default": "media",
|
||||
"icono": "fuel",
|
||||
"color": "#F59E0B",
|
||||
"prioridad": 55,
|
||||
},
|
||||
]
|
||||
175
backend/app/models/tipo_mantenimiento.py
Normal file
175
backend/app/models/tipo_mantenimiento.py
Normal file
@@ -0,0 +1,175 @@
|
||||
"""
|
||||
Modelo de Tipo de Mantenimiento para definir categorías de mantenimiento.
|
||||
"""
|
||||
|
||||
from sqlalchemy import Boolean, Float, Integer, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.core.database import Base
|
||||
from app.models.base import TimestampMixin
|
||||
|
||||
|
||||
class TipoMantenimiento(Base, TimestampMixin):
|
||||
"""Modelo para definir tipos de mantenimiento de vehículos."""
|
||||
|
||||
__tablename__ = "tipos_mantenimiento"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
|
||||
# Identificación
|
||||
nombre: Mapped[str] = mapped_column(String(100), nullable=False, unique=True)
|
||||
descripcion: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
codigo: Mapped[str | None] = mapped_column(String(20), nullable=True, unique=True)
|
||||
|
||||
# Categoría
|
||||
categoria: Mapped[str] = mapped_column(
|
||||
String(50),
|
||||
default="preventivo",
|
||||
nullable=False,
|
||||
) # preventivo, correctivo, predictivo
|
||||
|
||||
# Intervalos de mantenimiento
|
||||
intervalo_km: Mapped[int | None] = mapped_column(Integer, nullable=True) # Cada X km
|
||||
intervalo_dias: Mapped[int | None] = mapped_column(Integer, nullable=True) # Cada X días
|
||||
|
||||
# Costo estimado
|
||||
costo_estimado: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
|
||||
# Duración estimada (en horas)
|
||||
duracion_estimada_horas: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
|
||||
# Prioridad
|
||||
prioridad: Mapped[int] = mapped_column(Integer, default=50, nullable=False) # 1 = más urgente
|
||||
|
||||
# Requiere inmovilización del vehículo
|
||||
requiere_inmovilizacion: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
|
||||
# Estado
|
||||
activo: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
|
||||
# Relaciones ORM
|
||||
mantenimientos: Mapped[list["Mantenimiento"]] = relationship(
|
||||
"Mantenimiento",
|
||||
back_populates="tipo_mantenimiento",
|
||||
lazy="dynamic",
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<TipoMantenimiento(id={self.id}, nombre='{self.nombre}')>"
|
||||
|
||||
|
||||
# Tipos de mantenimiento predefinidos
|
||||
TIPOS_MANTENIMIENTO_DEFAULT = [
|
||||
{
|
||||
"nombre": "Cambio de aceite",
|
||||
"codigo": "ACEITE",
|
||||
"descripcion": "Cambio de aceite de motor y filtro",
|
||||
"categoria": "preventivo",
|
||||
"intervalo_km": 10000,
|
||||
"intervalo_dias": 180,
|
||||
"costo_estimado": 1500.0,
|
||||
"duracion_estimada_horas": 0.5,
|
||||
"prioridad": 30,
|
||||
},
|
||||
{
|
||||
"nombre": "Cambio de filtros",
|
||||
"codigo": "FILTROS",
|
||||
"descripcion": "Cambio de filtros de aire, combustible y cabina",
|
||||
"categoria": "preventivo",
|
||||
"intervalo_km": 20000,
|
||||
"intervalo_dias": 365,
|
||||
"costo_estimado": 800.0,
|
||||
"duracion_estimada_horas": 1.0,
|
||||
"prioridad": 40,
|
||||
},
|
||||
{
|
||||
"nombre": "Rotación de llantas",
|
||||
"codigo": "ROTACION_LLANTAS",
|
||||
"descripcion": "Rotación y balanceo de llantas",
|
||||
"categoria": "preventivo",
|
||||
"intervalo_km": 15000,
|
||||
"intervalo_dias": None,
|
||||
"costo_estimado": 500.0,
|
||||
"duracion_estimada_horas": 0.5,
|
||||
"prioridad": 50,
|
||||
},
|
||||
{
|
||||
"nombre": "Cambio de llantas",
|
||||
"codigo": "CAMBIO_LLANTAS",
|
||||
"descripcion": "Reemplazo de llantas",
|
||||
"categoria": "preventivo",
|
||||
"intervalo_km": 60000,
|
||||
"intervalo_dias": None,
|
||||
"costo_estimado": 8000.0,
|
||||
"duracion_estimada_horas": 1.5,
|
||||
"prioridad": 35,
|
||||
},
|
||||
{
|
||||
"nombre": "Revisión de frenos",
|
||||
"codigo": "FRENOS",
|
||||
"descripcion": "Inspección y ajuste del sistema de frenos",
|
||||
"categoria": "preventivo",
|
||||
"intervalo_km": 30000,
|
||||
"intervalo_dias": 365,
|
||||
"costo_estimado": 2000.0,
|
||||
"duracion_estimada_horas": 2.0,
|
||||
"prioridad": 20,
|
||||
},
|
||||
{
|
||||
"nombre": "Cambio de banda de distribución",
|
||||
"codigo": "BANDA_DIST",
|
||||
"descripcion": "Reemplazo de banda o cadena de distribución",
|
||||
"categoria": "preventivo",
|
||||
"intervalo_km": 100000,
|
||||
"intervalo_dias": None,
|
||||
"costo_estimado": 5000.0,
|
||||
"duracion_estimada_horas": 4.0,
|
||||
"prioridad": 15,
|
||||
"requiere_inmovilizacion": True,
|
||||
},
|
||||
{
|
||||
"nombre": "Servicio mayor",
|
||||
"codigo": "SERVICIO_MAYOR",
|
||||
"descripcion": "Servicio de mantenimiento completo",
|
||||
"categoria": "preventivo",
|
||||
"intervalo_km": 50000,
|
||||
"intervalo_dias": None,
|
||||
"costo_estimado": 10000.0,
|
||||
"duracion_estimada_horas": 8.0,
|
||||
"prioridad": 25,
|
||||
"requiere_inmovilizacion": True,
|
||||
},
|
||||
{
|
||||
"nombre": "Afinación",
|
||||
"codigo": "AFINACION",
|
||||
"descripcion": "Afinación de motor",
|
||||
"categoria": "preventivo",
|
||||
"intervalo_km": 30000,
|
||||
"intervalo_dias": 365,
|
||||
"costo_estimado": 2500.0,
|
||||
"duracion_estimada_horas": 2.0,
|
||||
"prioridad": 35,
|
||||
},
|
||||
{
|
||||
"nombre": "Verificación vehicular",
|
||||
"codigo": "VERIFICACION",
|
||||
"descripcion": "Verificación de emisiones",
|
||||
"categoria": "preventivo",
|
||||
"intervalo_km": None,
|
||||
"intervalo_dias": 180,
|
||||
"costo_estimado": 500.0,
|
||||
"duracion_estimada_horas": 0.5,
|
||||
"prioridad": 45,
|
||||
},
|
||||
{
|
||||
"nombre": "Reparación general",
|
||||
"codigo": "REPARACION",
|
||||
"descripcion": "Reparación no programada",
|
||||
"categoria": "correctivo",
|
||||
"intervalo_km": None,
|
||||
"intervalo_dias": None,
|
||||
"costo_estimado": None,
|
||||
"duracion_estimada_horas": None,
|
||||
"prioridad": 10,
|
||||
},
|
||||
]
|
||||
156
backend/app/models/ubicacion.py
Normal file
156
backend/app/models/ubicacion.py
Normal file
@@ -0,0 +1,156 @@
|
||||
"""
|
||||
Modelo de Ubicación para almacenar datos GPS.
|
||||
|
||||
Utiliza TimescaleDB hypertable para almacenamiento eficiente
|
||||
de series temporales de ubicaciones.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
DateTime,
|
||||
Float,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
String,
|
||||
event,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class Ubicacion(Base):
|
||||
"""
|
||||
Modelo de ubicación GPS.
|
||||
|
||||
Esta tabla está diseñada para ser una hypertable de TimescaleDB,
|
||||
optimizada para almacenar millones de registros de ubicación.
|
||||
"""
|
||||
|
||||
__tablename__ = "ubicaciones"
|
||||
|
||||
# Clave primaria compuesta: tiempo + vehiculo_id
|
||||
tiempo: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
primary_key=True,
|
||||
nullable=False,
|
||||
)
|
||||
vehiculo_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("vehiculos.id", ondelete="CASCADE"),
|
||||
primary_key=True,
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
# Coordenadas
|
||||
lat: Mapped[float] = mapped_column(Float, nullable=False)
|
||||
lng: Mapped[float] = mapped_column(Float, nullable=False)
|
||||
altitud: Mapped[float | None] = mapped_column(Float, nullable=True) # metros sobre nivel del mar
|
||||
|
||||
# Movimiento
|
||||
velocidad: Mapped[float | None] = mapped_column(Float, nullable=True) # km/h
|
||||
rumbo: Mapped[float | None] = mapped_column(Float, nullable=True) # grados (0-360)
|
||||
|
||||
# Precisión
|
||||
precision: Mapped[float | None] = mapped_column(Float, nullable=True) # metros
|
||||
hdop: Mapped[float | None] = mapped_column(Float, nullable=True) # Horizontal Dilution of Precision
|
||||
|
||||
# Información GPS
|
||||
satelites: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
fuente: Mapped[str] = mapped_column(
|
||||
String(20),
|
||||
default="gps",
|
||||
nullable=False,
|
||||
) # gps, network, fused, meshtastic
|
||||
|
||||
# Estado del dispositivo
|
||||
bateria_dispositivo: Mapped[float | None] = mapped_column(Float, nullable=True) # porcentaje
|
||||
bateria_vehiculo: Mapped[float | None] = mapped_column(Float, nullable=True) # voltaje
|
||||
|
||||
# Estado del vehículo
|
||||
motor_encendido: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
|
||||
odometro: Mapped[float | None] = mapped_column(Float, nullable=True) # km
|
||||
|
||||
# Sensores OBD (opcional)
|
||||
rpm: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
temperatura_motor: Mapped[float | None] = mapped_column(Float, nullable=True) # Celsius
|
||||
nivel_combustible: Mapped[float | None] = mapped_column(Float, nullable=True) # porcentaje
|
||||
|
||||
# Índices para consultas frecuentes
|
||||
__table_args__ = (
|
||||
# Índice espacial aproximado para consultas por área
|
||||
Index("idx_ubicaciones_coords", "lat", "lng"),
|
||||
# Índice para consultas por vehículo en un rango de tiempo
|
||||
Index("idx_ubicaciones_vehiculo_tiempo", "vehiculo_id", "tiempo"),
|
||||
# Índice para encontrar paradas (velocidad 0)
|
||||
Index("idx_ubicaciones_velocidad", "velocidad"),
|
||||
# Configuración para TimescaleDB
|
||||
{
|
||||
"timescaledb_hypertable": {
|
||||
"time_column_name": "tiempo",
|
||||
"chunk_time_interval": "1 day",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Ubicacion(vehiculo_id={self.vehiculo_id}, tiempo={self.tiempo}, lat={self.lat}, lng={self.lng})>"
|
||||
|
||||
def to_geojson(self) -> dict:
|
||||
"""Convierte la ubicación a formato GeoJSON Point."""
|
||||
return {
|
||||
"type": "Feature",
|
||||
"geometry": {
|
||||
"type": "Point",
|
||||
"coordinates": [self.lng, self.lat],
|
||||
},
|
||||
"properties": {
|
||||
"vehiculo_id": self.vehiculo_id,
|
||||
"tiempo": self.tiempo.isoformat(),
|
||||
"velocidad": self.velocidad,
|
||||
"rumbo": self.rumbo,
|
||||
"motor_encendido": self.motor_encendido,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# Función para crear la hypertable en TimescaleDB
|
||||
# Se ejecuta después de crear la tabla
|
||||
def create_hypertable(target, connection, **kw):
|
||||
"""Crea la hypertable de TimescaleDB después de crear la tabla."""
|
||||
# Esta función se ejecutará solo si TimescaleDB está instalado
|
||||
try:
|
||||
connection.execute(
|
||||
"""
|
||||
SELECT create_hypertable(
|
||||
'ubicaciones',
|
||||
'tiempo',
|
||||
if_not_exists => TRUE,
|
||||
chunk_time_interval => INTERVAL '1 day'
|
||||
);
|
||||
"""
|
||||
)
|
||||
# Habilitar compresión después de 7 días
|
||||
connection.execute(
|
||||
"""
|
||||
ALTER TABLE ubicaciones SET (
|
||||
timescaledb.compress,
|
||||
timescaledb.compress_segmentby = 'vehiculo_id'
|
||||
);
|
||||
"""
|
||||
)
|
||||
# Política de compresión automática
|
||||
connection.execute(
|
||||
"""
|
||||
SELECT add_compression_policy('ubicaciones', INTERVAL '7 days', if_not_exists => TRUE);
|
||||
"""
|
||||
)
|
||||
except Exception:
|
||||
# Si TimescaleDB no está instalado, continuar sin hypertable
|
||||
pass
|
||||
|
||||
|
||||
# Registrar evento para crear hypertable
|
||||
event.listen(Ubicacion.__table__, "after_create", create_hypertable)
|
||||
34
backend/app/models/usuario.py
Normal file
34
backend/app/models/usuario.py
Normal file
@@ -0,0 +1,34 @@
|
||||
"""
|
||||
Modelo de Usuario para autenticación y autorización.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.core.database import Base
|
||||
from app.models.base import TimestampMixin
|
||||
|
||||
|
||||
class Usuario(Base, TimestampMixin):
|
||||
"""Modelo de usuario del sistema."""
|
||||
|
||||
__tablename__ = "usuarios"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True)
|
||||
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
nombre: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
apellido: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
telefono: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
avatar_url: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
es_admin: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
activo: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
ultimo_acceso: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
# Configuraciones del usuario en JSON
|
||||
preferencias: Mapped[str | None] = mapped_column(Text, nullable=True) # JSON string
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Usuario(id={self.id}, email='{self.email}', nombre='{self.nombre}')>"
|
||||
130
backend/app/models/vehiculo.py
Normal file
130
backend/app/models/vehiculo.py
Normal file
@@ -0,0 +1,130 @@
|
||||
"""
|
||||
Modelo de Vehículo para gestión de la flota.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
DateTime,
|
||||
Float,
|
||||
ForeignKey,
|
||||
Integer,
|
||||
String,
|
||||
Text,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.core.database import Base
|
||||
from app.models.base import TimestampMixin
|
||||
|
||||
|
||||
class Vehiculo(Base, TimestampMixin):
|
||||
"""Modelo de vehículo de la flota."""
|
||||
|
||||
__tablename__ = "vehiculos"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
|
||||
# Identificación
|
||||
nombre: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
placa: Mapped[str] = mapped_column(String(20), unique=True, nullable=False, index=True)
|
||||
vin: Mapped[str | None] = mapped_column(String(17), unique=True, nullable=True) # Vehicle Identification Number
|
||||
numero_economico: Mapped[str | None] = mapped_column(String(50), nullable=True) # Número interno
|
||||
|
||||
# Características del vehículo
|
||||
marca: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||
modelo: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||
año: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
color: Mapped[str | None] = mapped_column(String(30), nullable=True)
|
||||
tipo: Mapped[str | None] = mapped_column(String(50), nullable=True) # Sedan, SUV, Camión, etc.
|
||||
|
||||
# Capacidades
|
||||
capacidad_carga_kg: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
capacidad_pasajeros: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
capacidad_combustible_litros: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
tipo_combustible: Mapped[str | None] = mapped_column(String(20), nullable=True) # Gasolina, Diesel, Eléctrico
|
||||
|
||||
# Odómetro
|
||||
odometro_inicial: Mapped[float] = mapped_column(Float, default=0.0, nullable=False)
|
||||
odometro_actual: Mapped[float] = mapped_column(Float, default=0.0, nullable=False)
|
||||
|
||||
# Visualización
|
||||
icono: Mapped[str | None] = mapped_column(String(50), nullable=True) # Nombre del icono en el mapa
|
||||
color_marcador: Mapped[str] = mapped_column(String(7), default="#3B82F6", nullable=False) # Hex color
|
||||
|
||||
# Relaciones
|
||||
conductor_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("conductores.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
grupo_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("grupos_vehiculos.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
|
||||
# Estado
|
||||
activo: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
en_servicio: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
notas: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
# Última ubicación conocida (para consultas rápidas)
|
||||
ultima_lat: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
ultima_lng: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
ultima_velocidad: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
ultimo_rumbo: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
ultima_ubicacion_tiempo: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
motor_encendido: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
|
||||
|
||||
# Relaciones ORM
|
||||
conductor: Mapped["Conductor | None"] = relationship(
|
||||
"Conductor",
|
||||
back_populates="vehiculos",
|
||||
lazy="selectin",
|
||||
)
|
||||
grupo: Mapped["GrupoVehiculos | None"] = relationship(
|
||||
"GrupoVehiculos",
|
||||
back_populates="vehiculos",
|
||||
lazy="selectin",
|
||||
)
|
||||
dispositivos: Mapped[list["Dispositivo"]] = relationship(
|
||||
"Dispositivo",
|
||||
back_populates="vehiculo",
|
||||
lazy="selectin",
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
viajes: Mapped[list["Viaje"]] = relationship(
|
||||
"Viaje",
|
||||
back_populates="vehiculo",
|
||||
lazy="dynamic",
|
||||
)
|
||||
alertas: Mapped[list["Alerta"]] = relationship(
|
||||
"Alerta",
|
||||
back_populates="vehiculo",
|
||||
lazy="dynamic",
|
||||
)
|
||||
cargas_combustible: Mapped[list["CargaCombustible"]] = relationship(
|
||||
"CargaCombustible",
|
||||
back_populates="vehiculo",
|
||||
lazy="dynamic",
|
||||
)
|
||||
mantenimientos: Mapped[list["Mantenimiento"]] = relationship(
|
||||
"Mantenimiento",
|
||||
back_populates="vehiculo",
|
||||
lazy="dynamic",
|
||||
)
|
||||
camaras: Mapped[list["Camara"]] = relationship(
|
||||
"Camara",
|
||||
back_populates="vehiculo",
|
||||
lazy="selectin",
|
||||
)
|
||||
|
||||
@property
|
||||
def distancia_recorrida(self) -> float:
|
||||
"""Calcula la distancia total recorrida."""
|
||||
return self.odometro_actual - self.odometro_inicial
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Vehiculo(id={self.id}, placa='{self.placa}', nombre='{self.nombre}')>"
|
||||
135
backend/app/models/viaje.py
Normal file
135
backend/app/models/viaje.py
Normal file
@@ -0,0 +1,135 @@
|
||||
"""
|
||||
Modelo de Viaje para registrar trayectos de vehículos.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import (
|
||||
DateTime,
|
||||
Float,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
String,
|
||||
Text,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.core.database import Base
|
||||
from app.models.base import TimestampMixin
|
||||
|
||||
|
||||
class Viaje(Base, TimestampMixin):
|
||||
"""
|
||||
Modelo de viaje/trayecto de un vehículo.
|
||||
|
||||
Un viaje se define desde que el vehículo inicia movimiento
|
||||
hasta que se detiene por un período prolongado.
|
||||
"""
|
||||
|
||||
__tablename__ = "viajes"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
|
||||
# Relaciones
|
||||
vehiculo_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("vehiculos.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
conductor_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("conductores.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
|
||||
# Tiempo
|
||||
inicio_tiempo: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
fin_tiempo: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
# Ubicación de inicio
|
||||
inicio_lat: Mapped[float] = mapped_column(Float, nullable=False)
|
||||
inicio_lng: Mapped[float] = mapped_column(Float, nullable=False)
|
||||
inicio_direccion: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
|
||||
# Ubicación de fin
|
||||
fin_lat: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
fin_lng: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
fin_direccion: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
|
||||
# Estadísticas de distancia
|
||||
distancia_km: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
|
||||
# Estadísticas de tiempo
|
||||
duracion_segundos: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
tiempo_movimiento_segundos: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
tiempo_parado_segundos: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
|
||||
# Estadísticas de velocidad
|
||||
velocidad_promedio: Mapped[float | None] = mapped_column(Float, nullable=True) # km/h
|
||||
velocidad_maxima: Mapped[float | None] = mapped_column(Float, nullable=True) # km/h
|
||||
|
||||
# Combustible
|
||||
combustible_usado: Mapped[float | None] = mapped_column(Float, nullable=True) # litros
|
||||
rendimiento: Mapped[float | None] = mapped_column(Float, nullable=True) # km/litro
|
||||
|
||||
# Odómetro
|
||||
odometro_inicio: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
odometro_fin: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
|
||||
# Estado
|
||||
estado: Mapped[str] = mapped_column(
|
||||
String(20),
|
||||
default="en_curso",
|
||||
nullable=False,
|
||||
) # en_curso, completado, cancelado
|
||||
|
||||
# Notas
|
||||
proposito: Mapped[str | None] = mapped_column(String(100), nullable=True) # Trabajo, personal, etc.
|
||||
notas: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
# Número de puntos GPS registrados
|
||||
puntos_gps: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
|
||||
# Relaciones ORM
|
||||
vehiculo: Mapped["Vehiculo"] = relationship(
|
||||
"Vehiculo",
|
||||
back_populates="viajes",
|
||||
lazy="selectin",
|
||||
)
|
||||
conductor: Mapped["Conductor | None"] = relationship(
|
||||
"Conductor",
|
||||
back_populates="viajes",
|
||||
lazy="selectin",
|
||||
)
|
||||
paradas: Mapped[list["Parada"]] = relationship(
|
||||
"Parada",
|
||||
back_populates="viaje",
|
||||
lazy="selectin",
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
|
||||
# Índices
|
||||
__table_args__ = (
|
||||
Index("idx_viajes_vehiculo_inicio", "vehiculo_id", "inicio_tiempo"),
|
||||
Index("idx_viajes_estado", "estado"),
|
||||
)
|
||||
|
||||
@property
|
||||
def duracion_formateada(self) -> str:
|
||||
"""Retorna la duración en formato legible (ej: 2h 30m)."""
|
||||
if not self.duracion_segundos:
|
||||
return "N/A"
|
||||
horas = self.duracion_segundos // 3600
|
||||
minutos = (self.duracion_segundos % 3600) // 60
|
||||
if horas > 0:
|
||||
return f"{horas}h {minutos}m"
|
||||
return f"{minutos}m"
|
||||
|
||||
@property
|
||||
def en_curso(self) -> bool:
|
||||
"""Verifica si el viaje está en curso."""
|
||||
return self.estado == "en_curso"
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Viaje(id={self.id}, vehiculo_id={self.vehiculo_id}, estado='{self.estado}')>"
|
||||
370
backend/app/schemas/__init__.py
Normal file
370
backend/app/schemas/__init__.py
Normal file
@@ -0,0 +1,370 @@
|
||||
"""
|
||||
Módulo de schemas Pydantic.
|
||||
|
||||
Exporta todos los schemas para facilitar importaciones.
|
||||
"""
|
||||
|
||||
from app.schemas.base import (
|
||||
BaseSchema,
|
||||
TimestampSchema,
|
||||
PaginatedResponse,
|
||||
MessageResponse,
|
||||
ErrorResponse,
|
||||
GeoJSONPoint,
|
||||
GeoJSONFeature,
|
||||
GeoJSONFeatureCollection,
|
||||
CoordenadasSchema,
|
||||
RangoFechasSchema,
|
||||
)
|
||||
|
||||
from app.schemas.usuario import (
|
||||
UsuarioCreate,
|
||||
UsuarioUpdate,
|
||||
UsuarioUpdatePassword,
|
||||
UsuarioResponse,
|
||||
LoginRequest,
|
||||
LoginResponse,
|
||||
RefreshTokenRequest,
|
||||
TokenResponse,
|
||||
)
|
||||
|
||||
from app.schemas.grupo_vehiculos import (
|
||||
GrupoVehiculosCreate,
|
||||
GrupoVehiculosUpdate,
|
||||
GrupoVehiculosResponse,
|
||||
GrupoVehiculosConVehiculos,
|
||||
)
|
||||
|
||||
from app.schemas.conductor import (
|
||||
ConductorCreate,
|
||||
ConductorUpdate,
|
||||
ConductorResponse,
|
||||
ConductorResumen,
|
||||
ConductorEstadisticas,
|
||||
)
|
||||
|
||||
from app.schemas.vehiculo import (
|
||||
VehiculoCreate,
|
||||
VehiculoUpdate,
|
||||
VehiculoResponse,
|
||||
VehiculoResumen,
|
||||
VehiculoConRelaciones,
|
||||
VehiculoUbicacionActual,
|
||||
VehiculoEstadisticas,
|
||||
)
|
||||
|
||||
from app.schemas.dispositivo import (
|
||||
DispositivoCreate,
|
||||
DispositivoUpdate,
|
||||
DispositivoResponse,
|
||||
DispositivoResumen,
|
||||
DispositivoConVehiculo,
|
||||
)
|
||||
|
||||
from app.schemas.ubicacion import (
|
||||
UbicacionCreate,
|
||||
UbicacionBulkCreate,
|
||||
UbicacionResponse,
|
||||
UbicacionConVehiculo,
|
||||
HistorialUbicacionesRequest,
|
||||
HistorialUbicacionesResponse,
|
||||
OsmAndLocationCreate,
|
||||
TraccarLocationCreate,
|
||||
)
|
||||
|
||||
from app.schemas.viaje import (
|
||||
ViajeCreate,
|
||||
ViajeUpdate,
|
||||
ViajeResponse,
|
||||
ViajeResumen,
|
||||
ViajeConParadas,
|
||||
ViajeReplayData,
|
||||
ParadaCreate,
|
||||
ParadaUpdate,
|
||||
ParadaResponse,
|
||||
ParadaResumen,
|
||||
)
|
||||
|
||||
from app.schemas.alerta import (
|
||||
TipoAlertaCreate,
|
||||
TipoAlertaUpdate,
|
||||
TipoAlertaResponse,
|
||||
AlertaCreate,
|
||||
AlertaUpdate,
|
||||
AlertaResponse,
|
||||
AlertaConTipo,
|
||||
AlertaConRelaciones,
|
||||
AlertaResumen,
|
||||
AlertasEstadisticas,
|
||||
AlertaAtenderRequest,
|
||||
)
|
||||
|
||||
from app.schemas.geocerca import (
|
||||
GeocercaCircularCreate,
|
||||
GeocercaPoligonoCreate,
|
||||
GeocercaUpdate,
|
||||
GeocercaResponse,
|
||||
GeocercaConVehiculos,
|
||||
GeocercaGeoJSON,
|
||||
AsignarVehiculosRequest,
|
||||
VerificarPuntoRequest,
|
||||
VerificarPuntoResponse,
|
||||
)
|
||||
|
||||
from app.schemas.poi import (
|
||||
POICreate,
|
||||
POIUpdate,
|
||||
POIResponse,
|
||||
POIResumen,
|
||||
POICercano,
|
||||
BuscarPOIsCercanosRequest,
|
||||
BuscarPOIsCercanosResponse,
|
||||
CategoriasPOIResponse,
|
||||
)
|
||||
|
||||
from app.schemas.combustible import (
|
||||
CargaCombustibleCreate,
|
||||
CargaCombustibleUpdate,
|
||||
CargaCombustibleResponse,
|
||||
CargaCombustibleConRelaciones,
|
||||
RendimientoCombustible,
|
||||
ReporteConsumoVehiculo,
|
||||
ReporteConsumoFlota,
|
||||
)
|
||||
|
||||
from app.schemas.mantenimiento import (
|
||||
TipoMantenimientoCreate,
|
||||
TipoMantenimientoUpdate,
|
||||
TipoMantenimientoResponse,
|
||||
MantenimientoCreate,
|
||||
MantenimientoUpdate,
|
||||
MantenimientoResponse,
|
||||
MantenimientoConRelaciones,
|
||||
MantenimientoResumen,
|
||||
ProximosMantenimientos,
|
||||
CompletarMantenimientoRequest,
|
||||
)
|
||||
|
||||
from app.schemas.video import (
|
||||
CamaraCreate,
|
||||
CamaraUpdate,
|
||||
CamaraResponse,
|
||||
CamaraConVehiculo,
|
||||
CamaraStreamURL,
|
||||
GrabacionCreate,
|
||||
GrabacionResponse,
|
||||
GrabacionResumen,
|
||||
EventoVideoCreate,
|
||||
EventoVideoUpdate,
|
||||
EventoVideoResponse,
|
||||
EventoVideoConRelaciones,
|
||||
EventoVideoResumen,
|
||||
TiposEventoVideoResponse,
|
||||
)
|
||||
|
||||
from app.schemas.mensaje import (
|
||||
MensajeCreate,
|
||||
MensajeEnviarAConductores,
|
||||
MensajeUpdate,
|
||||
MensajeResponse,
|
||||
MensajeConConductor,
|
||||
MensajeResumen,
|
||||
ConversacionConductor,
|
||||
MensajesNoLeidosResponse,
|
||||
ResponderMensajeRequest,
|
||||
)
|
||||
|
||||
from app.schemas.configuracion import (
|
||||
ConfiguracionCreate,
|
||||
ConfiguracionUpdate,
|
||||
ConfiguracionResponse,
|
||||
ConfiguracionResumen,
|
||||
ConfiguracionesPorCategoria,
|
||||
ConfiguracionesResponse,
|
||||
ActualizarConfiguracionesRequest,
|
||||
ConfiguracionesAlertasResponse,
|
||||
ConfiguracionesViajesResponse,
|
||||
ConfiguracionesNotificacionesResponse,
|
||||
ConfiguracionesMapaResponse,
|
||||
)
|
||||
|
||||
from app.schemas.reporte import (
|
||||
DashboardResumen,
|
||||
DashboardGrafico,
|
||||
ReporteRequest,
|
||||
ReporteResponse,
|
||||
ReporteViajesResumen,
|
||||
ReporteAlertasResumen,
|
||||
ReporteCombustibleResumen,
|
||||
ReporteMantenimientoResumen,
|
||||
ReporteUbicacionesResumen,
|
||||
EstadisticasFlota,
|
||||
KPIsFlota,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# Base
|
||||
"BaseSchema",
|
||||
"TimestampSchema",
|
||||
"PaginatedResponse",
|
||||
"MessageResponse",
|
||||
"ErrorResponse",
|
||||
"GeoJSONPoint",
|
||||
"GeoJSONFeature",
|
||||
"GeoJSONFeatureCollection",
|
||||
"CoordenadasSchema",
|
||||
"RangoFechasSchema",
|
||||
# Usuario
|
||||
"UsuarioCreate",
|
||||
"UsuarioUpdate",
|
||||
"UsuarioUpdatePassword",
|
||||
"UsuarioResponse",
|
||||
"LoginRequest",
|
||||
"LoginResponse",
|
||||
"RefreshTokenRequest",
|
||||
"TokenResponse",
|
||||
# Grupo Vehículos
|
||||
"GrupoVehiculosCreate",
|
||||
"GrupoVehiculosUpdate",
|
||||
"GrupoVehiculosResponse",
|
||||
"GrupoVehiculosConVehiculos",
|
||||
# Conductor
|
||||
"ConductorCreate",
|
||||
"ConductorUpdate",
|
||||
"ConductorResponse",
|
||||
"ConductorResumen",
|
||||
"ConductorEstadisticas",
|
||||
# Vehículo
|
||||
"VehiculoCreate",
|
||||
"VehiculoUpdate",
|
||||
"VehiculoResponse",
|
||||
"VehiculoResumen",
|
||||
"VehiculoConRelaciones",
|
||||
"VehiculoUbicacionActual",
|
||||
"VehiculoEstadisticas",
|
||||
# Dispositivo
|
||||
"DispositivoCreate",
|
||||
"DispositivoUpdate",
|
||||
"DispositivoResponse",
|
||||
"DispositivoResumen",
|
||||
"DispositivoConVehiculo",
|
||||
# Ubicación
|
||||
"UbicacionCreate",
|
||||
"UbicacionBulkCreate",
|
||||
"UbicacionResponse",
|
||||
"UbicacionConVehiculo",
|
||||
"HistorialUbicacionesRequest",
|
||||
"HistorialUbicacionesResponse",
|
||||
"OsmAndLocationCreate",
|
||||
"TraccarLocationCreate",
|
||||
# Viaje
|
||||
"ViajeCreate",
|
||||
"ViajeUpdate",
|
||||
"ViajeResponse",
|
||||
"ViajeResumen",
|
||||
"ViajeConParadas",
|
||||
"ViajeReplayData",
|
||||
"ParadaCreate",
|
||||
"ParadaUpdate",
|
||||
"ParadaResponse",
|
||||
"ParadaResumen",
|
||||
# Alerta
|
||||
"TipoAlertaCreate",
|
||||
"TipoAlertaUpdate",
|
||||
"TipoAlertaResponse",
|
||||
"AlertaCreate",
|
||||
"AlertaUpdate",
|
||||
"AlertaResponse",
|
||||
"AlertaConTipo",
|
||||
"AlertaConRelaciones",
|
||||
"AlertaResumen",
|
||||
"AlertasEstadisticas",
|
||||
"AlertaAtenderRequest",
|
||||
# Geocerca
|
||||
"GeocercaCircularCreate",
|
||||
"GeocercaPoligonoCreate",
|
||||
"GeocercaUpdate",
|
||||
"GeocercaResponse",
|
||||
"GeocercaConVehiculos",
|
||||
"GeocercaGeoJSON",
|
||||
"AsignarVehiculosRequest",
|
||||
"VerificarPuntoRequest",
|
||||
"VerificarPuntoResponse",
|
||||
# POI
|
||||
"POICreate",
|
||||
"POIUpdate",
|
||||
"POIResponse",
|
||||
"POIResumen",
|
||||
"POICercano",
|
||||
"BuscarPOIsCercanosRequest",
|
||||
"BuscarPOIsCercanosResponse",
|
||||
"CategoriasPOIResponse",
|
||||
# Combustible
|
||||
"CargaCombustibleCreate",
|
||||
"CargaCombustibleUpdate",
|
||||
"CargaCombustibleResponse",
|
||||
"CargaCombustibleConRelaciones",
|
||||
"RendimientoCombustible",
|
||||
"ReporteConsumoVehiculo",
|
||||
"ReporteConsumoFlota",
|
||||
# Mantenimiento
|
||||
"TipoMantenimientoCreate",
|
||||
"TipoMantenimientoUpdate",
|
||||
"TipoMantenimientoResponse",
|
||||
"MantenimientoCreate",
|
||||
"MantenimientoUpdate",
|
||||
"MantenimientoResponse",
|
||||
"MantenimientoConRelaciones",
|
||||
"MantenimientoResumen",
|
||||
"ProximosMantenimientos",
|
||||
"CompletarMantenimientoRequest",
|
||||
# Video
|
||||
"CamaraCreate",
|
||||
"CamaraUpdate",
|
||||
"CamaraResponse",
|
||||
"CamaraConVehiculo",
|
||||
"CamaraStreamURL",
|
||||
"GrabacionCreate",
|
||||
"GrabacionResponse",
|
||||
"GrabacionResumen",
|
||||
"EventoVideoCreate",
|
||||
"EventoVideoUpdate",
|
||||
"EventoVideoResponse",
|
||||
"EventoVideoConRelaciones",
|
||||
"EventoVideoResumen",
|
||||
"TiposEventoVideoResponse",
|
||||
# Mensaje
|
||||
"MensajeCreate",
|
||||
"MensajeEnviarAConductores",
|
||||
"MensajeUpdate",
|
||||
"MensajeResponse",
|
||||
"MensajeConConductor",
|
||||
"MensajeResumen",
|
||||
"ConversacionConductor",
|
||||
"MensajesNoLeidosResponse",
|
||||
"ResponderMensajeRequest",
|
||||
# Configuración
|
||||
"ConfiguracionCreate",
|
||||
"ConfiguracionUpdate",
|
||||
"ConfiguracionResponse",
|
||||
"ConfiguracionResumen",
|
||||
"ConfiguracionesPorCategoria",
|
||||
"ConfiguracionesResponse",
|
||||
"ActualizarConfiguracionesRequest",
|
||||
"ConfiguracionesAlertasResponse",
|
||||
"ConfiguracionesViajesResponse",
|
||||
"ConfiguracionesNotificacionesResponse",
|
||||
"ConfiguracionesMapaResponse",
|
||||
# Reportes
|
||||
"DashboardResumen",
|
||||
"DashboardGrafico",
|
||||
"ReporteRequest",
|
||||
"ReporteResponse",
|
||||
"ReporteViajesResumen",
|
||||
"ReporteAlertasResumen",
|
||||
"ReporteCombustibleResumen",
|
||||
"ReporteMantenimientoResumen",
|
||||
"ReporteUbicacionesResumen",
|
||||
"EstadisticasFlota",
|
||||
"KPIsFlota",
|
||||
]
|
||||
172
backend/app/schemas/alerta.py
Normal file
172
backend/app/schemas/alerta.py
Normal file
@@ -0,0 +1,172 @@
|
||||
"""
|
||||
Schemas Pydantic para Alerta y Tipo de Alerta.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from app.schemas.base import BaseSchema, TimestampSchema
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Schemas de Tipo de Alerta
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class TipoAlertaBase(BaseSchema):
|
||||
"""Schema base de tipo de alerta."""
|
||||
|
||||
codigo: str = Field(..., min_length=2, max_length=50)
|
||||
nombre: str = Field(..., min_length=2, max_length=100)
|
||||
descripcion: Optional[str] = None
|
||||
severidad_default: str = Field(default="media", pattern="^(baja|media|alta|critica)$")
|
||||
icono: Optional[str] = Field(None, max_length=50)
|
||||
color: str = Field(default="#EF4444", pattern=r"^#[0-9A-Fa-f]{6}$")
|
||||
|
||||
|
||||
class TipoAlertaCreate(TipoAlertaBase):
|
||||
"""Schema para crear tipo de alerta."""
|
||||
|
||||
notificar_email: bool = False
|
||||
notificar_push: bool = True
|
||||
notificar_sms: bool = False
|
||||
prioridad: int = Field(default=50, ge=1, le=100)
|
||||
|
||||
|
||||
class TipoAlertaUpdate(BaseSchema):
|
||||
"""Schema para actualizar tipo de alerta."""
|
||||
|
||||
nombre: Optional[str] = Field(None, min_length=2, max_length=100)
|
||||
descripcion: Optional[str] = None
|
||||
severidad_default: Optional[str] = Field(None, pattern="^(baja|media|alta|critica)$")
|
||||
icono: Optional[str] = Field(None, max_length=50)
|
||||
color: Optional[str] = Field(None, pattern=r"^#[0-9A-Fa-f]{6}$")
|
||||
notificar_email: Optional[bool] = None
|
||||
notificar_push: Optional[bool] = None
|
||||
notificar_sms: Optional[bool] = None
|
||||
prioridad: Optional[int] = Field(None, ge=1, le=100)
|
||||
activo: Optional[bool] = None
|
||||
|
||||
|
||||
class TipoAlertaResponse(TipoAlertaBase, TimestampSchema):
|
||||
"""Schema de respuesta de tipo de alerta."""
|
||||
|
||||
id: int
|
||||
notificar_email: bool
|
||||
notificar_push: bool
|
||||
notificar_sms: bool
|
||||
prioridad: int
|
||||
activo: bool
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Schemas de Alerta
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class AlertaBase(BaseSchema):
|
||||
"""Schema base de alerta."""
|
||||
|
||||
tipo_alerta_id: int
|
||||
severidad: str = Field(default="media", pattern="^(baja|media|alta|critica)$")
|
||||
mensaje: str = Field(..., min_length=5, max_length=500)
|
||||
descripcion: Optional[str] = None
|
||||
|
||||
|
||||
class AlertaCreate(AlertaBase):
|
||||
"""Schema para crear alerta manualmente."""
|
||||
|
||||
vehiculo_id: Optional[int] = None
|
||||
conductor_id: Optional[int] = None
|
||||
dispositivo_id: Optional[int] = None
|
||||
lat: Optional[float] = Field(None, ge=-90, le=90)
|
||||
lng: Optional[float] = Field(None, ge=-180, le=180)
|
||||
direccion: Optional[str] = Field(None, max_length=255)
|
||||
velocidad: Optional[float] = Field(None, ge=0)
|
||||
valor: Optional[float] = None
|
||||
umbral: Optional[float] = None
|
||||
datos_extra: Optional[str] = None # JSON
|
||||
|
||||
|
||||
class AlertaUpdate(BaseSchema):
|
||||
"""Schema para actualizar alerta (marcar atendida)."""
|
||||
|
||||
atendida: Optional[bool] = None
|
||||
notas_atencion: Optional[str] = None
|
||||
|
||||
|
||||
class AlertaResponse(AlertaBase, TimestampSchema):
|
||||
"""Schema de respuesta de alerta."""
|
||||
|
||||
id: int
|
||||
vehiculo_id: Optional[int] = None
|
||||
conductor_id: Optional[int] = None
|
||||
dispositivo_id: Optional[int] = None
|
||||
lat: Optional[float] = None
|
||||
lng: Optional[float] = None
|
||||
direccion: Optional[str] = None
|
||||
velocidad: Optional[float] = None
|
||||
valor: Optional[float] = None
|
||||
umbral: Optional[float] = None
|
||||
datos_extra: Optional[str] = None
|
||||
atendida: bool
|
||||
atendida_por_id: Optional[int] = None
|
||||
atendida_en: Optional[datetime] = None
|
||||
notas_atencion: Optional[str] = None
|
||||
notificacion_email_enviada: bool
|
||||
notificacion_push_enviada: bool
|
||||
notificacion_sms_enviada: bool
|
||||
|
||||
# Calculado
|
||||
es_critica: bool
|
||||
|
||||
|
||||
class AlertaConTipo(AlertaResponse):
|
||||
"""Schema de alerta con información del tipo."""
|
||||
|
||||
tipo_alerta: TipoAlertaResponse
|
||||
|
||||
|
||||
class AlertaConRelaciones(AlertaResponse):
|
||||
"""Schema de alerta con todas las relaciones."""
|
||||
|
||||
tipo_alerta: TipoAlertaResponse
|
||||
vehiculo_nombre: Optional[str] = None
|
||||
vehiculo_placa: Optional[str] = None
|
||||
conductor_nombre: Optional[str] = None
|
||||
|
||||
|
||||
class AlertaResumen(BaseSchema):
|
||||
"""Schema resumido de alerta para listas."""
|
||||
|
||||
id: int
|
||||
tipo_codigo: str
|
||||
tipo_nombre: str
|
||||
severidad: str
|
||||
mensaje: str
|
||||
vehiculo_nombre: Optional[str] = None
|
||||
vehiculo_placa: Optional[str] = None
|
||||
creado_en: datetime
|
||||
atendida: bool
|
||||
|
||||
|
||||
class AlertasEstadisticas(BaseSchema):
|
||||
"""Estadísticas de alertas."""
|
||||
|
||||
total: int
|
||||
pendientes: int
|
||||
atendidas: int
|
||||
criticas: int
|
||||
altas: int
|
||||
medias: int
|
||||
bajas: int
|
||||
por_tipo: List[dict] # [{codigo, nombre, cantidad}]
|
||||
por_vehiculo: List[dict] # [{vehiculo_id, nombre, cantidad}]
|
||||
|
||||
|
||||
class AlertaAtenderRequest(BaseSchema):
|
||||
"""Schema para marcar alerta como atendida."""
|
||||
|
||||
notas_atencion: Optional[str] = None
|
||||
97
backend/app/schemas/base.py
Normal file
97
backend/app/schemas/base.py
Normal file
@@ -0,0 +1,97 @@
|
||||
"""
|
||||
Schemas base y utilidades comunes para Pydantic.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Generic, List, Optional, TypeVar
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
class BaseSchema(BaseModel):
|
||||
"""Schema base con configuración común."""
|
||||
|
||||
model_config = ConfigDict(
|
||||
from_attributes=True,
|
||||
populate_by_name=True,
|
||||
use_enum_values=True,
|
||||
json_encoders={datetime: lambda v: v.isoformat()},
|
||||
)
|
||||
|
||||
|
||||
class TimestampSchema(BaseSchema):
|
||||
"""Schema con campos de timestamp."""
|
||||
|
||||
creado_en: datetime
|
||||
actualizado_en: datetime
|
||||
|
||||
|
||||
# Type variable para paginación genérica
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
class PaginatedResponse(BaseModel, Generic[T]):
|
||||
"""Respuesta paginada genérica."""
|
||||
|
||||
items: List[T]
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
pages: int
|
||||
|
||||
@property
|
||||
def has_next(self) -> bool:
|
||||
return self.page < self.pages
|
||||
|
||||
@property
|
||||
def has_prev(self) -> bool:
|
||||
return self.page > 1
|
||||
|
||||
|
||||
class MessageResponse(BaseModel):
|
||||
"""Respuesta simple con mensaje."""
|
||||
|
||||
message: str
|
||||
success: bool = True
|
||||
|
||||
|
||||
class ErrorResponse(BaseModel):
|
||||
"""Respuesta de error."""
|
||||
|
||||
error: dict
|
||||
|
||||
|
||||
class GeoJSONPoint(BaseModel):
|
||||
"""Schema para punto GeoJSON."""
|
||||
|
||||
type: str = "Point"
|
||||
coordinates: List[float] # [lng, lat]
|
||||
|
||||
|
||||
class GeoJSONFeature(BaseModel):
|
||||
"""Schema para feature GeoJSON."""
|
||||
|
||||
type: str = "Feature"
|
||||
geometry: dict
|
||||
properties: dict
|
||||
|
||||
|
||||
class GeoJSONFeatureCollection(BaseModel):
|
||||
"""Schema para colección de features GeoJSON."""
|
||||
|
||||
type: str = "FeatureCollection"
|
||||
features: List[GeoJSONFeature]
|
||||
|
||||
|
||||
class CoordenadasSchema(BaseModel):
|
||||
"""Schema para coordenadas simples."""
|
||||
|
||||
lat: float
|
||||
lng: float
|
||||
|
||||
|
||||
class RangoFechasSchema(BaseModel):
|
||||
"""Schema para filtros de rango de fechas."""
|
||||
|
||||
desde: Optional[datetime] = None
|
||||
hasta: Optional[datetime] = None
|
||||
136
backend/app/schemas/combustible.py
Normal file
136
backend/app/schemas/combustible.py
Normal file
@@ -0,0 +1,136 @@
|
||||
"""
|
||||
Schemas Pydantic para Carga de Combustible.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from app.schemas.base import BaseSchema, TimestampSchema
|
||||
|
||||
|
||||
class CargaCombustibleBase(BaseSchema):
|
||||
"""Schema base de carga de combustible."""
|
||||
|
||||
vehiculo_id: int
|
||||
fecha: datetime
|
||||
litros: float = Field(..., gt=0)
|
||||
precio_litro: Optional[float] = Field(None, ge=0)
|
||||
tipo_combustible: Optional[str] = Field(None, max_length=20)
|
||||
|
||||
|
||||
class CargaCombustibleCreate(CargaCombustibleBase):
|
||||
"""Schema para crear carga de combustible."""
|
||||
|
||||
conductor_id: Optional[int] = None
|
||||
total: Optional[float] = Field(None, ge=0)
|
||||
odometro: Optional[float] = Field(None, ge=0)
|
||||
estacion: Optional[str] = Field(None, max_length=100)
|
||||
estacion_direccion: Optional[str] = Field(None, max_length=255)
|
||||
lat: Optional[float] = Field(None, ge=-90, le=90)
|
||||
lng: Optional[float] = Field(None, ge=-180, le=180)
|
||||
tanque_lleno: bool = True
|
||||
metodo_pago: Optional[str] = Field(None, max_length=50)
|
||||
numero_factura: Optional[str] = Field(None, max_length=50)
|
||||
notas: Optional[str] = None
|
||||
|
||||
|
||||
class CargaCombustibleUpdate(BaseSchema):
|
||||
"""Schema para actualizar carga de combustible."""
|
||||
|
||||
fecha: Optional[datetime] = None
|
||||
litros: Optional[float] = Field(None, gt=0)
|
||||
precio_litro: Optional[float] = Field(None, ge=0)
|
||||
total: Optional[float] = Field(None, ge=0)
|
||||
tipo_combustible: Optional[str] = Field(None, max_length=20)
|
||||
odometro: Optional[float] = Field(None, ge=0)
|
||||
estacion: Optional[str] = Field(None, max_length=100)
|
||||
estacion_direccion: Optional[str] = Field(None, max_length=255)
|
||||
tanque_lleno: Optional[bool] = None
|
||||
metodo_pago: Optional[str] = Field(None, max_length=50)
|
||||
numero_factura: Optional[str] = Field(None, max_length=50)
|
||||
notas: Optional[str] = None
|
||||
|
||||
|
||||
class CargaCombustibleResponse(CargaCombustibleBase, TimestampSchema):
|
||||
"""Schema de respuesta de carga de combustible."""
|
||||
|
||||
id: int
|
||||
conductor_id: Optional[int] = None
|
||||
total: Optional[float] = None
|
||||
odometro: Optional[float] = None
|
||||
estacion: Optional[str] = None
|
||||
estacion_direccion: Optional[str] = None
|
||||
lat: Optional[float] = None
|
||||
lng: Optional[float] = None
|
||||
tanque_lleno: bool
|
||||
metodo_pago: Optional[str] = None
|
||||
numero_factura: Optional[str] = None
|
||||
notas: Optional[str] = None
|
||||
|
||||
|
||||
class CargaCombustibleConRelaciones(CargaCombustibleResponse):
|
||||
"""Schema con información del vehículo y conductor."""
|
||||
|
||||
vehiculo_nombre: Optional[str] = None
|
||||
vehiculo_placa: Optional[str] = None
|
||||
conductor_nombre: Optional[str] = None
|
||||
|
||||
|
||||
class RendimientoCombustible(BaseSchema):
|
||||
"""Schema para rendimiento de combustible entre cargas."""
|
||||
|
||||
carga_id: int
|
||||
fecha: datetime
|
||||
litros: float
|
||||
distancia_km: float
|
||||
rendimiento_km_litro: float
|
||||
costo_por_km: Optional[float] = None
|
||||
|
||||
|
||||
class ReporteConsumoVehiculo(BaseSchema):
|
||||
"""Schema para reporte de consumo de un vehículo."""
|
||||
|
||||
vehiculo_id: int
|
||||
vehiculo_nombre: str
|
||||
vehiculo_placa: str
|
||||
periodo_inicio: datetime
|
||||
periodo_fin: datetime
|
||||
|
||||
# Totales
|
||||
total_litros: float
|
||||
total_cargas: int
|
||||
total_costo: float
|
||||
distancia_recorrida_km: float
|
||||
|
||||
# Promedios
|
||||
rendimiento_promedio: float # km/litro
|
||||
costo_promedio_litro: float
|
||||
costo_por_km: float
|
||||
|
||||
# Detalle de cargas
|
||||
cargas: List[CargaCombustibleResponse]
|
||||
|
||||
|
||||
class ReporteConsumoFlota(BaseSchema):
|
||||
"""Schema para reporte de consumo de toda la flota."""
|
||||
|
||||
periodo_inicio: datetime
|
||||
periodo_fin: datetime
|
||||
|
||||
# Totales flota
|
||||
total_litros: float
|
||||
total_cargas: int
|
||||
total_costo: float
|
||||
total_vehiculos: int
|
||||
|
||||
# Promedios flota
|
||||
rendimiento_promedio_flota: float
|
||||
costo_promedio_flota: float
|
||||
|
||||
# Por vehículo
|
||||
por_vehiculo: List[dict] # [{vehiculo_id, nombre, placa, litros, costo, rendimiento}]
|
||||
|
||||
# Por tipo de combustible
|
||||
por_tipo_combustible: List[dict] # [{tipo, litros, costo}]
|
||||
94
backend/app/schemas/conductor.py
Normal file
94
backend/app/schemas/conductor.py
Normal file
@@ -0,0 +1,94 @@
|
||||
"""
|
||||
Schemas Pydantic para Conductor.
|
||||
"""
|
||||
|
||||
from datetime import date
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import EmailStr, Field
|
||||
|
||||
from app.schemas.base import BaseSchema, TimestampSchema
|
||||
|
||||
|
||||
class ConductorBase(BaseSchema):
|
||||
"""Schema base de conductor."""
|
||||
|
||||
nombre: str = Field(..., min_length=2, max_length=100)
|
||||
apellido: str = Field(..., min_length=2, max_length=100)
|
||||
telefono: Optional[str] = Field(None, max_length=20)
|
||||
email: Optional[EmailStr] = None
|
||||
documento_tipo: Optional[str] = Field(None, max_length=20)
|
||||
documento_numero: Optional[str] = Field(None, max_length=50)
|
||||
licencia_numero: Optional[str] = Field(None, max_length=50)
|
||||
licencia_tipo: Optional[str] = Field(None, max_length=20)
|
||||
licencia_vencimiento: Optional[date] = None
|
||||
fecha_nacimiento: Optional[date] = None
|
||||
direccion: Optional[str] = None
|
||||
contacto_emergencia: Optional[str] = Field(None, max_length=100)
|
||||
telefono_emergencia: Optional[str] = Field(None, max_length=20)
|
||||
fecha_contratacion: Optional[date] = None
|
||||
numero_empleado: Optional[str] = Field(None, max_length=50)
|
||||
|
||||
|
||||
class ConductorCreate(ConductorBase):
|
||||
"""Schema para crear conductor."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class ConductorUpdate(BaseSchema):
|
||||
"""Schema para actualizar conductor."""
|
||||
|
||||
nombre: Optional[str] = Field(None, min_length=2, max_length=100)
|
||||
apellido: Optional[str] = Field(None, min_length=2, max_length=100)
|
||||
telefono: Optional[str] = Field(None, max_length=20)
|
||||
email: Optional[EmailStr] = None
|
||||
documento_tipo: Optional[str] = Field(None, max_length=20)
|
||||
documento_numero: Optional[str] = Field(None, max_length=50)
|
||||
licencia_numero: Optional[str] = Field(None, max_length=50)
|
||||
licencia_tipo: Optional[str] = Field(None, max_length=20)
|
||||
licencia_vencimiento: Optional[date] = None
|
||||
foto_url: Optional[str] = None
|
||||
fecha_nacimiento: Optional[date] = None
|
||||
direccion: Optional[str] = None
|
||||
contacto_emergencia: Optional[str] = Field(None, max_length=100)
|
||||
telefono_emergencia: Optional[str] = Field(None, max_length=20)
|
||||
fecha_contratacion: Optional[date] = None
|
||||
numero_empleado: Optional[str] = Field(None, max_length=50)
|
||||
activo: Optional[bool] = None
|
||||
notas: Optional[str] = None
|
||||
|
||||
|
||||
class ConductorResponse(ConductorBase, TimestampSchema):
|
||||
"""Schema de respuesta de conductor."""
|
||||
|
||||
id: int
|
||||
foto_url: Optional[str] = None
|
||||
activo: bool
|
||||
notas: Optional[str] = None
|
||||
nombre_completo: str
|
||||
licencia_vigente: bool
|
||||
|
||||
|
||||
class ConductorResumen(BaseSchema):
|
||||
"""Schema resumido de conductor."""
|
||||
|
||||
id: int
|
||||
nombre_completo: str
|
||||
telefono: Optional[str] = None
|
||||
licencia_vigente: bool
|
||||
activo: bool
|
||||
|
||||
|
||||
class ConductorEstadisticas(BaseSchema):
|
||||
"""Estadísticas de un conductor."""
|
||||
|
||||
conductor_id: int
|
||||
nombre_completo: str
|
||||
total_viajes: int
|
||||
distancia_total_km: float
|
||||
tiempo_conduccion_horas: float
|
||||
velocidad_promedio: float
|
||||
alertas_total: int
|
||||
alertas_velocidad: int
|
||||
calificacion: Optional[float] = None # Score calculado
|
||||
108
backend/app/schemas/configuracion.py
Normal file
108
backend/app/schemas/configuracion.py
Normal file
@@ -0,0 +1,108 @@
|
||||
"""
|
||||
Schemas Pydantic para Configuración.
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from app.schemas.base import BaseSchema, TimestampSchema
|
||||
|
||||
|
||||
class ConfiguracionBase(BaseSchema):
|
||||
"""Schema base de configuración."""
|
||||
|
||||
clave: str = Field(..., min_length=2, max_length=100)
|
||||
categoria: str = Field(default="general", max_length=50)
|
||||
descripcion: Optional[str] = None
|
||||
|
||||
|
||||
class ConfiguracionCreate(ConfiguracionBase):
|
||||
"""Schema para crear configuración."""
|
||||
|
||||
valor: Any # Se convertirá a JSON
|
||||
tipo_dato: str = Field(default="string", pattern="^(string|number|boolean|json|array)$")
|
||||
sensible: bool = False
|
||||
editable: bool = True
|
||||
|
||||
|
||||
class ConfiguracionUpdate(BaseSchema):
|
||||
"""Schema para actualizar configuración."""
|
||||
|
||||
valor: Any
|
||||
descripcion: Optional[str] = None
|
||||
|
||||
|
||||
class ConfiguracionResponse(ConfiguracionBase, TimestampSchema):
|
||||
"""Schema de respuesta de configuración."""
|
||||
|
||||
valor_json: str
|
||||
tipo_dato: str
|
||||
sensible: bool
|
||||
editable: bool
|
||||
|
||||
# Valor parseado
|
||||
valor: Optional[Any] = None
|
||||
|
||||
|
||||
class ConfiguracionResumen(BaseSchema):
|
||||
"""Schema resumido de configuración."""
|
||||
|
||||
clave: str
|
||||
categoria: str
|
||||
valor: Any
|
||||
tipo_dato: str
|
||||
editable: bool
|
||||
|
||||
|
||||
class ConfiguracionesPorCategoria(BaseSchema):
|
||||
"""Schema con configuraciones agrupadas por categoría."""
|
||||
|
||||
categoria: str
|
||||
configuraciones: List[ConfiguracionResumen]
|
||||
|
||||
|
||||
class ConfiguracionesResponse(BaseSchema):
|
||||
"""Schema de respuesta con todas las configuraciones."""
|
||||
|
||||
categorias: List[str]
|
||||
configuraciones: Dict[str, List[ConfiguracionResumen]]
|
||||
|
||||
|
||||
class ActualizarConfiguracionesRequest(BaseSchema):
|
||||
"""Schema para actualizar múltiples configuraciones."""
|
||||
|
||||
configuraciones: Dict[str, Any] # {clave: valor}
|
||||
|
||||
|
||||
class ConfiguracionesAlertasResponse(BaseSchema):
|
||||
"""Schema específico para configuraciones de alertas."""
|
||||
|
||||
velocidad_maxima: int
|
||||
parada_minutos: int
|
||||
bateria_minima: int
|
||||
sin_señal_minutos: int
|
||||
motor_encendido_minutos: int
|
||||
|
||||
|
||||
class ConfiguracionesViajesResponse(BaseSchema):
|
||||
"""Schema específico para configuraciones de viajes."""
|
||||
|
||||
velocidad_minima: float
|
||||
parada_minutos: int
|
||||
|
||||
|
||||
class ConfiguracionesNotificacionesResponse(BaseSchema):
|
||||
"""Schema específico para configuraciones de notificaciones."""
|
||||
|
||||
email_habilitado: bool
|
||||
push_habilitado: bool
|
||||
destinatarios: List[str]
|
||||
|
||||
|
||||
class ConfiguracionesMapaResponse(BaseSchema):
|
||||
"""Schema específico para configuraciones de mapa."""
|
||||
|
||||
centro_lat: float
|
||||
centro_lng: float
|
||||
zoom_default: int
|
||||
92
backend/app/schemas/dispositivo.py
Normal file
92
backend/app/schemas/dispositivo.py
Normal file
@@ -0,0 +1,92 @@
|
||||
"""
|
||||
Schemas Pydantic para Dispositivo GPS/Tracker.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from app.schemas.base import BaseSchema, TimestampSchema
|
||||
|
||||
|
||||
class DispositivoBase(BaseSchema):
|
||||
"""Schema base de dispositivo."""
|
||||
|
||||
tipo: str = Field(default="gps", max_length=50)
|
||||
identificador: str = Field(..., min_length=1, max_length=100)
|
||||
nombre: Optional[str] = Field(None, max_length=100)
|
||||
marca: Optional[str] = Field(None, max_length=50)
|
||||
modelo: Optional[str] = Field(None, max_length=50)
|
||||
numero_serie: Optional[str] = Field(None, max_length=100)
|
||||
telefono_sim: Optional[str] = Field(None, max_length=20)
|
||||
operador_sim: Optional[str] = Field(None, max_length=50)
|
||||
iccid: Optional[str] = Field(None, max_length=25)
|
||||
imei: Optional[str] = Field(None, max_length=20)
|
||||
protocolo: str = Field(default="osmand", max_length=50)
|
||||
intervalo_reporte: int = Field(default=30, ge=1, le=3600)
|
||||
|
||||
|
||||
class DispositivoCreate(DispositivoBase):
|
||||
"""Schema para crear dispositivo."""
|
||||
|
||||
vehiculo_id: int
|
||||
|
||||
|
||||
class DispositivoUpdate(BaseSchema):
|
||||
"""Schema para actualizar dispositivo."""
|
||||
|
||||
tipo: Optional[str] = Field(None, max_length=50)
|
||||
nombre: Optional[str] = Field(None, max_length=100)
|
||||
marca: Optional[str] = Field(None, max_length=50)
|
||||
modelo: Optional[str] = Field(None, max_length=50)
|
||||
numero_serie: Optional[str] = Field(None, max_length=100)
|
||||
telefono_sim: Optional[str] = Field(None, max_length=20)
|
||||
operador_sim: Optional[str] = Field(None, max_length=50)
|
||||
iccid: Optional[str] = Field(None, max_length=25)
|
||||
imei: Optional[str] = Field(None, max_length=20)
|
||||
protocolo: Optional[str] = Field(None, max_length=50)
|
||||
intervalo_reporte: Optional[int] = Field(None, ge=1, le=3600)
|
||||
configuracion: Optional[str] = None
|
||||
firmware_version: Optional[str] = Field(None, max_length=50)
|
||||
activo: Optional[bool] = None
|
||||
notas: Optional[str] = None
|
||||
|
||||
|
||||
class DispositivoResponse(DispositivoBase, TimestampSchema):
|
||||
"""Schema de respuesta de dispositivo."""
|
||||
|
||||
id: int
|
||||
vehiculo_id: int
|
||||
ultimo_contacto: Optional[datetime] = None
|
||||
bateria: Optional[float] = None
|
||||
señal_gsm: Optional[int] = None
|
||||
satelites: Optional[int] = None
|
||||
configuracion: Optional[str] = None
|
||||
firmware_version: Optional[str] = None
|
||||
activo: bool
|
||||
conectado: bool
|
||||
notas: Optional[str] = None
|
||||
|
||||
# Calculado
|
||||
esta_online: bool
|
||||
|
||||
|
||||
class DispositivoResumen(BaseSchema):
|
||||
"""Schema resumido de dispositivo."""
|
||||
|
||||
id: int
|
||||
identificador: str
|
||||
tipo: str
|
||||
protocolo: str
|
||||
activo: bool
|
||||
conectado: bool
|
||||
ultimo_contacto: Optional[datetime] = None
|
||||
bateria: Optional[float] = None
|
||||
|
||||
|
||||
class DispositivoConVehiculo(DispositivoResponse):
|
||||
"""Schema de dispositivo con información del vehículo."""
|
||||
|
||||
vehiculo_nombre: Optional[str] = None
|
||||
vehiculo_placa: Optional[str] = None
|
||||
160
backend/app/schemas/geocerca.py
Normal file
160
backend/app/schemas/geocerca.py
Normal file
@@ -0,0 +1,160 @@
|
||||
"""
|
||||
Schemas Pydantic para Geocerca.
|
||||
"""
|
||||
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import Field, field_validator
|
||||
|
||||
from app.schemas.base import BaseSchema, TimestampSchema
|
||||
|
||||
|
||||
class GeocercaBase(BaseSchema):
|
||||
"""Schema base de geocerca."""
|
||||
|
||||
nombre: str = Field(..., min_length=2, max_length=100)
|
||||
descripcion: Optional[str] = None
|
||||
tipo: str = Field(default="circular", pattern="^(circular|poligono)$")
|
||||
color: str = Field(default="#3B82F6", pattern=r"^#[0-9A-Fa-f]{6}$")
|
||||
opacidad: float = Field(default=0.3, ge=0, le=1)
|
||||
color_borde: str = Field(default="#1D4ED8", pattern=r"^#[0-9A-Fa-f]{6}$")
|
||||
categoria: Optional[str] = Field(None, max_length=50)
|
||||
|
||||
|
||||
class GeocercaCircularCreate(GeocercaBase):
|
||||
"""Schema para crear geocerca circular."""
|
||||
|
||||
tipo: str = "circular"
|
||||
centro_lat: float = Field(..., ge=-90, le=90)
|
||||
centro_lng: float = Field(..., ge=-180, le=180)
|
||||
radio_metros: float = Field(..., gt=0, le=100000)
|
||||
|
||||
# Configuración de alertas
|
||||
alerta_entrada: bool = True
|
||||
alerta_salida: bool = True
|
||||
velocidad_maxima: Optional[float] = Field(None, ge=0)
|
||||
|
||||
# Horario (opcional, JSON)
|
||||
horario_json: Optional[str] = None
|
||||
|
||||
# Vehículos asignados (opcional, vacío = todos)
|
||||
vehiculos_ids: Optional[List[int]] = None
|
||||
|
||||
|
||||
class GeocercaPoligonoCreate(GeocercaBase):
|
||||
"""Schema para crear geocerca poligonal."""
|
||||
|
||||
tipo: str = "poligono"
|
||||
coordenadas: List[List[float]] # [[lat, lng], [lat, lng], ...]
|
||||
|
||||
# Configuración de alertas
|
||||
alerta_entrada: bool = True
|
||||
alerta_salida: bool = True
|
||||
velocidad_maxima: Optional[float] = Field(None, ge=0)
|
||||
|
||||
# Horario (opcional, JSON)
|
||||
horario_json: Optional[str] = None
|
||||
|
||||
# Vehículos asignados (opcional, vacío = todos)
|
||||
vehiculos_ids: Optional[List[int]] = None
|
||||
|
||||
@field_validator("coordenadas")
|
||||
@classmethod
|
||||
def validate_coordenadas(cls, v: List[List[float]]) -> List[List[float]]:
|
||||
if len(v) < 3:
|
||||
raise ValueError("Un polígono debe tener al menos 3 puntos")
|
||||
for coord in v:
|
||||
if len(coord) != 2:
|
||||
raise ValueError("Cada coordenada debe tener [lat, lng]")
|
||||
if not (-90 <= coord[0] <= 90):
|
||||
raise ValueError("Latitud debe estar entre -90 y 90")
|
||||
if not (-180 <= coord[1] <= 180):
|
||||
raise ValueError("Longitud debe estar entre -180 y 180")
|
||||
return v
|
||||
|
||||
|
||||
class GeocercaUpdate(BaseSchema):
|
||||
"""Schema para actualizar geocerca."""
|
||||
|
||||
nombre: Optional[str] = Field(None, min_length=2, max_length=100)
|
||||
descripcion: Optional[str] = None
|
||||
color: Optional[str] = Field(None, pattern=r"^#[0-9A-Fa-f]{6}$")
|
||||
opacidad: Optional[float] = Field(None, ge=0, le=1)
|
||||
color_borde: Optional[str] = Field(None, pattern=r"^#[0-9A-Fa-f]{6}$")
|
||||
categoria: Optional[str] = Field(None, max_length=50)
|
||||
|
||||
# Para circular
|
||||
centro_lat: Optional[float] = Field(None, ge=-90, le=90)
|
||||
centro_lng: Optional[float] = Field(None, ge=-180, le=180)
|
||||
radio_metros: Optional[float] = Field(None, gt=0, le=100000)
|
||||
|
||||
# Para polígono
|
||||
coordenadas: Optional[List[List[float]]] = None
|
||||
|
||||
# Configuración
|
||||
alerta_entrada: Optional[bool] = None
|
||||
alerta_salida: Optional[bool] = None
|
||||
velocidad_maxima: Optional[float] = Field(None, ge=0)
|
||||
horario_json: Optional[str] = None
|
||||
activa: Optional[bool] = None
|
||||
|
||||
|
||||
class GeocercaResponse(GeocercaBase, TimestampSchema):
|
||||
"""Schema de respuesta de geocerca."""
|
||||
|
||||
id: int
|
||||
centro_lat: Optional[float] = None
|
||||
centro_lng: Optional[float] = None
|
||||
radio_metros: Optional[float] = None
|
||||
coordenadas_json: Optional[str] = None
|
||||
alerta_entrada: bool
|
||||
alerta_salida: bool
|
||||
velocidad_maxima: Optional[float] = None
|
||||
horario_json: Optional[str] = None
|
||||
activa: bool
|
||||
|
||||
# Calculado
|
||||
aplica_todos_vehiculos: bool
|
||||
|
||||
|
||||
class GeocercaConVehiculos(GeocercaResponse):
|
||||
"""Schema de geocerca con lista de vehículos asignados."""
|
||||
|
||||
vehiculos_asignados: List["VehiculoResumen"] = []
|
||||
|
||||
|
||||
class GeocercaGeoJSON(BaseSchema):
|
||||
"""Schema de geocerca en formato GeoJSON."""
|
||||
|
||||
type: str = "Feature"
|
||||
geometry: dict
|
||||
properties: dict
|
||||
|
||||
|
||||
class AsignarVehiculosRequest(BaseSchema):
|
||||
"""Schema para asignar vehículos a una geocerca."""
|
||||
|
||||
vehiculos_ids: List[int]
|
||||
reemplazar: bool = False # True = reemplaza todos, False = agrega a existentes
|
||||
|
||||
|
||||
class VerificarPuntoRequest(BaseSchema):
|
||||
"""Schema para verificar si un punto está dentro de una geocerca."""
|
||||
|
||||
lat: float = Field(..., ge=-90, le=90)
|
||||
lng: float = Field(..., ge=-180, le=180)
|
||||
|
||||
|
||||
class VerificarPuntoResponse(BaseSchema):
|
||||
"""Schema de respuesta de verificación de punto."""
|
||||
|
||||
dentro: bool
|
||||
geocerca_id: int
|
||||
geocerca_nombre: str
|
||||
distancia_metros: Optional[float] = None # Distancia al borde si está fuera
|
||||
|
||||
|
||||
# Import fix
|
||||
from app.schemas.vehiculo import VehiculoResumen # noqa: E402
|
||||
|
||||
GeocercaConVehiculos.model_rebuild()
|
||||
52
backend/app/schemas/grupo_vehiculos.py
Normal file
52
backend/app/schemas/grupo_vehiculos.py
Normal file
@@ -0,0 +1,52 @@
|
||||
"""
|
||||
Schemas Pydantic para Grupo de Vehículos.
|
||||
"""
|
||||
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from app.schemas.base import BaseSchema, TimestampSchema
|
||||
|
||||
|
||||
class GrupoVehiculosBase(BaseSchema):
|
||||
"""Schema base de grupo de vehículos."""
|
||||
|
||||
nombre: str = Field(..., min_length=2, max_length=100)
|
||||
descripcion: Optional[str] = None
|
||||
color: str = Field(default="#3B82F6", pattern=r"^#[0-9A-Fa-f]{6}$")
|
||||
icono: Optional[str] = Field(None, max_length=50)
|
||||
|
||||
|
||||
class GrupoVehiculosCreate(GrupoVehiculosBase):
|
||||
"""Schema para crear grupo de vehículos."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class GrupoVehiculosUpdate(BaseSchema):
|
||||
"""Schema para actualizar grupo de vehículos."""
|
||||
|
||||
nombre: Optional[str] = Field(None, min_length=2, max_length=100)
|
||||
descripcion: Optional[str] = None
|
||||
color: Optional[str] = Field(None, pattern=r"^#[0-9A-Fa-f]{6}$")
|
||||
icono: Optional[str] = Field(None, max_length=50)
|
||||
|
||||
|
||||
class GrupoVehiculosResponse(GrupoVehiculosBase, TimestampSchema):
|
||||
"""Schema de respuesta de grupo de vehículos."""
|
||||
|
||||
id: int
|
||||
cantidad_vehiculos: Optional[int] = None
|
||||
|
||||
|
||||
class GrupoVehiculosConVehiculos(GrupoVehiculosResponse):
|
||||
"""Schema con lista de vehículos del grupo."""
|
||||
|
||||
vehiculos: List["VehiculoResumen"] = []
|
||||
|
||||
|
||||
# Import circular fix
|
||||
from app.schemas.vehiculo import VehiculoResumen # noqa: E402
|
||||
|
||||
GrupoVehiculosConVehiculos.model_rebuild()
|
||||
198
backend/app/schemas/mantenimiento.py
Normal file
198
backend/app/schemas/mantenimiento.py
Normal file
@@ -0,0 +1,198 @@
|
||||
"""
|
||||
Schemas Pydantic para Mantenimiento y Tipo de Mantenimiento.
|
||||
"""
|
||||
|
||||
from datetime import date, datetime
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from app.schemas.base import BaseSchema, TimestampSchema
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Schemas de Tipo de Mantenimiento
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class TipoMantenimientoBase(BaseSchema):
|
||||
"""Schema base de tipo de mantenimiento."""
|
||||
|
||||
nombre: str = Field(..., min_length=2, max_length=100)
|
||||
descripcion: Optional[str] = None
|
||||
codigo: Optional[str] = Field(None, max_length=20)
|
||||
categoria: str = Field(default="preventivo", pattern="^(preventivo|correctivo|predictivo)$")
|
||||
|
||||
|
||||
class TipoMantenimientoCreate(TipoMantenimientoBase):
|
||||
"""Schema para crear tipo de mantenimiento."""
|
||||
|
||||
intervalo_km: Optional[int] = Field(None, gt=0)
|
||||
intervalo_dias: Optional[int] = Field(None, gt=0)
|
||||
costo_estimado: Optional[float] = Field(None, ge=0)
|
||||
duracion_estimada_horas: Optional[float] = Field(None, ge=0)
|
||||
prioridad: int = Field(default=50, ge=1, le=100)
|
||||
requiere_inmovilizacion: bool = False
|
||||
|
||||
|
||||
class TipoMantenimientoUpdate(BaseSchema):
|
||||
"""Schema para actualizar tipo de mantenimiento."""
|
||||
|
||||
nombre: Optional[str] = Field(None, min_length=2, max_length=100)
|
||||
descripcion: Optional[str] = None
|
||||
codigo: Optional[str] = Field(None, max_length=20)
|
||||
categoria: Optional[str] = Field(None, pattern="^(preventivo|correctivo|predictivo)$")
|
||||
intervalo_km: Optional[int] = Field(None, gt=0)
|
||||
intervalo_dias: Optional[int] = Field(None, gt=0)
|
||||
costo_estimado: Optional[float] = Field(None, ge=0)
|
||||
duracion_estimada_horas: Optional[float] = Field(None, ge=0)
|
||||
prioridad: Optional[int] = Field(None, ge=1, le=100)
|
||||
requiere_inmovilizacion: Optional[bool] = None
|
||||
activo: Optional[bool] = None
|
||||
|
||||
|
||||
class TipoMantenimientoResponse(TipoMantenimientoBase, TimestampSchema):
|
||||
"""Schema de respuesta de tipo de mantenimiento."""
|
||||
|
||||
id: int
|
||||
intervalo_km: Optional[int] = None
|
||||
intervalo_dias: Optional[int] = None
|
||||
costo_estimado: Optional[float] = None
|
||||
duracion_estimada_horas: Optional[float] = None
|
||||
prioridad: int
|
||||
requiere_inmovilizacion: bool
|
||||
activo: bool
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Schemas de Mantenimiento
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class MantenimientoBase(BaseSchema):
|
||||
"""Schema base de mantenimiento."""
|
||||
|
||||
vehiculo_id: int
|
||||
tipo_mantenimiento_id: int
|
||||
fecha_programada: date
|
||||
|
||||
|
||||
class MantenimientoCreate(MantenimientoBase):
|
||||
"""Schema para crear/programar mantenimiento."""
|
||||
|
||||
odometro_programado: Optional[float] = Field(None, ge=0)
|
||||
costo_estimado: Optional[float] = Field(None, ge=0)
|
||||
proveedor: Optional[str] = Field(None, max_length=100)
|
||||
proveedor_direccion: Optional[str] = Field(None, max_length=255)
|
||||
proveedor_telefono: Optional[str] = Field(None, max_length=20)
|
||||
descripcion: Optional[str] = None
|
||||
notas: Optional[str] = None
|
||||
|
||||
|
||||
class MantenimientoUpdate(BaseSchema):
|
||||
"""Schema para actualizar mantenimiento."""
|
||||
|
||||
estado: Optional[str] = Field(None, pattern="^(programado|en_proceso|completado|cancelado|vencido)$")
|
||||
fecha_programada: Optional[date] = None
|
||||
fecha_realizada: Optional[date] = None
|
||||
odometro_programado: Optional[float] = Field(None, ge=0)
|
||||
odometro_realizado: Optional[float] = Field(None, ge=0)
|
||||
costo_estimado: Optional[float] = Field(None, ge=0)
|
||||
costo_real: Optional[float] = Field(None, ge=0)
|
||||
costo_mano_obra: Optional[float] = Field(None, ge=0)
|
||||
costo_refacciones: Optional[float] = Field(None, ge=0)
|
||||
proveedor: Optional[str] = Field(None, max_length=100)
|
||||
proveedor_direccion: Optional[str] = Field(None, max_length=255)
|
||||
proveedor_telefono: Optional[str] = Field(None, max_length=20)
|
||||
numero_factura: Optional[str] = Field(None, max_length=50)
|
||||
numero_orden: Optional[str] = Field(None, max_length=50)
|
||||
descripcion: Optional[str] = None
|
||||
trabajos_realizados: Optional[str] = None
|
||||
refacciones_usadas: Optional[str] = None
|
||||
tecnico: Optional[str] = Field(None, max_length=100)
|
||||
proximo_km: Optional[float] = Field(None, ge=0)
|
||||
proxima_fecha: Optional[date] = None
|
||||
notas: Optional[str] = None
|
||||
|
||||
|
||||
class MantenimientoResponse(MantenimientoBase, TimestampSchema):
|
||||
"""Schema de respuesta de mantenimiento."""
|
||||
|
||||
id: int
|
||||
estado: str
|
||||
fecha_realizada: Optional[date] = None
|
||||
odometro_programado: Optional[float] = None
|
||||
odometro_realizado: Optional[float] = None
|
||||
costo_estimado: Optional[float] = None
|
||||
costo_real: Optional[float] = None
|
||||
costo_mano_obra: Optional[float] = None
|
||||
costo_refacciones: Optional[float] = None
|
||||
proveedor: Optional[str] = None
|
||||
proveedor_direccion: Optional[str] = None
|
||||
proveedor_telefono: Optional[str] = None
|
||||
numero_factura: Optional[str] = None
|
||||
numero_orden: Optional[str] = None
|
||||
descripcion: Optional[str] = None
|
||||
trabajos_realizados: Optional[str] = None
|
||||
refacciones_usadas: Optional[str] = None
|
||||
tecnico: Optional[str] = None
|
||||
proximo_km: Optional[float] = None
|
||||
proxima_fecha: Optional[date] = None
|
||||
archivos_adjuntos: Optional[str] = None
|
||||
recordatorio_enviado: bool
|
||||
notas: Optional[str] = None
|
||||
|
||||
# Calculados
|
||||
esta_vencido: bool
|
||||
dias_para_vencimiento: Optional[int] = None
|
||||
|
||||
|
||||
class MantenimientoConRelaciones(MantenimientoResponse):
|
||||
"""Schema con información del vehículo y tipo."""
|
||||
|
||||
vehiculo_nombre: Optional[str] = None
|
||||
vehiculo_placa: Optional[str] = None
|
||||
tipo_mantenimiento_nombre: Optional[str] = None
|
||||
tipo_mantenimiento_categoria: Optional[str] = None
|
||||
|
||||
|
||||
class MantenimientoResumen(BaseSchema):
|
||||
"""Schema resumido de mantenimiento."""
|
||||
|
||||
id: int
|
||||
vehiculo_id: int
|
||||
vehiculo_nombre: str
|
||||
vehiculo_placa: str
|
||||
tipo_mantenimiento_nombre: str
|
||||
estado: str
|
||||
fecha_programada: date
|
||||
dias_para_vencimiento: Optional[int] = None
|
||||
esta_vencido: bool
|
||||
|
||||
|
||||
class ProximosMantenimientos(BaseSchema):
|
||||
"""Schema para próximos mantenimientos."""
|
||||
|
||||
vencidos: List[MantenimientoResumen]
|
||||
proximos_7_dias: List[MantenimientoResumen]
|
||||
proximos_30_dias: List[MantenimientoResumen]
|
||||
|
||||
|
||||
class CompletarMantenimientoRequest(BaseSchema):
|
||||
"""Schema para completar un mantenimiento."""
|
||||
|
||||
fecha_realizada: date
|
||||
odometro_realizado: Optional[float] = Field(None, ge=0)
|
||||
costo_real: Optional[float] = Field(None, ge=0)
|
||||
costo_mano_obra: Optional[float] = Field(None, ge=0)
|
||||
costo_refacciones: Optional[float] = Field(None, ge=0)
|
||||
trabajos_realizados: Optional[str] = None
|
||||
refacciones_usadas: Optional[str] = None
|
||||
tecnico: Optional[str] = Field(None, max_length=100)
|
||||
numero_factura: Optional[str] = Field(None, max_length=50)
|
||||
notas: Optional[str] = None
|
||||
|
||||
# Próximo mantenimiento
|
||||
programar_siguiente: bool = False
|
||||
proximo_km: Optional[float] = Field(None, ge=0)
|
||||
proxima_fecha: Optional[date] = None
|
||||
105
backend/app/schemas/mensaje.py
Normal file
105
backend/app/schemas/mensaje.py
Normal file
@@ -0,0 +1,105 @@
|
||||
"""
|
||||
Schemas Pydantic para Mensaje.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from app.schemas.base import BaseSchema, TimestampSchema
|
||||
|
||||
|
||||
class MensajeBase(BaseSchema):
|
||||
"""Schema base de mensaje."""
|
||||
|
||||
asunto: Optional[str] = Field(None, max_length=200)
|
||||
contenido: str = Field(..., min_length=1)
|
||||
|
||||
|
||||
class MensajeCreate(MensajeBase):
|
||||
"""Schema para crear/enviar mensaje."""
|
||||
|
||||
conductor_id: int
|
||||
tipo: str = Field(default="texto", pattern="^(texto|alerta|instruccion|emergencia)$")
|
||||
prioridad: str = Field(default="normal", pattern="^(baja|normal|alta|urgente)$")
|
||||
adjuntos: Optional[List[str]] = None # Lista de URLs
|
||||
|
||||
|
||||
class MensajeEnviarAConductores(BaseSchema):
|
||||
"""Schema para enviar mensaje a múltiples conductores."""
|
||||
|
||||
conductores_ids: List[int]
|
||||
asunto: Optional[str] = Field(None, max_length=200)
|
||||
contenido: str = Field(..., min_length=1)
|
||||
tipo: str = Field(default="texto", pattern="^(texto|alerta|instruccion|emergencia)$")
|
||||
prioridad: str = Field(default="normal", pattern="^(baja|normal|alta|urgente)$")
|
||||
|
||||
|
||||
class MensajeUpdate(BaseSchema):
|
||||
"""Schema para actualizar mensaje."""
|
||||
|
||||
leido: Optional[bool] = None
|
||||
eliminado_por_admin: Optional[bool] = None
|
||||
eliminado_por_conductor: Optional[bool] = None
|
||||
|
||||
|
||||
class MensajeResponse(MensajeBase, TimestampSchema):
|
||||
"""Schema de respuesta de mensaje."""
|
||||
|
||||
id: int
|
||||
conductor_id: int
|
||||
de_admin: bool
|
||||
usuario_id: Optional[int] = None
|
||||
tipo: str
|
||||
prioridad: str
|
||||
leido: bool
|
||||
leido_en: Optional[datetime] = None
|
||||
adjuntos: Optional[str] = None
|
||||
respuesta_a_id: Optional[int] = None
|
||||
eliminado_por_admin: bool
|
||||
eliminado_por_conductor: bool
|
||||
|
||||
|
||||
class MensajeConConductor(MensajeResponse):
|
||||
"""Schema con información del conductor."""
|
||||
|
||||
conductor_nombre: Optional[str] = None
|
||||
usuario_nombre: Optional[str] = None
|
||||
|
||||
|
||||
class MensajeResumen(BaseSchema):
|
||||
"""Schema resumido de mensaje."""
|
||||
|
||||
id: int
|
||||
conductor_id: int
|
||||
conductor_nombre: str
|
||||
de_admin: bool
|
||||
asunto: Optional[str] = None
|
||||
tipo: str
|
||||
prioridad: str
|
||||
leido: bool
|
||||
creado_en: datetime
|
||||
|
||||
|
||||
class ConversacionConductor(BaseSchema):
|
||||
"""Schema para conversación con un conductor."""
|
||||
|
||||
conductor_id: int
|
||||
conductor_nombre: str
|
||||
mensajes: List[MensajeResponse]
|
||||
no_leidos: int
|
||||
|
||||
|
||||
class MensajesNoLeidosResponse(BaseSchema):
|
||||
"""Schema con conteo de mensajes no leídos."""
|
||||
|
||||
total_no_leidos: int
|
||||
por_conductor: List[dict] # [{conductor_id, nombre, cantidad}]
|
||||
|
||||
|
||||
class ResponderMensajeRequest(BaseSchema):
|
||||
"""Schema para responder a un mensaje."""
|
||||
|
||||
contenido: str = Field(..., min_length=1)
|
||||
adjuntos: Optional[List[str]] = None
|
||||
120
backend/app/schemas/poi.py
Normal file
120
backend/app/schemas/poi.py
Normal file
@@ -0,0 +1,120 @@
|
||||
"""
|
||||
Schemas Pydantic para POI (Punto de Interés).
|
||||
"""
|
||||
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import EmailStr, Field
|
||||
|
||||
from app.schemas.base import BaseSchema, TimestampSchema
|
||||
|
||||
|
||||
class POIBase(BaseSchema):
|
||||
"""Schema base de POI."""
|
||||
|
||||
nombre: str = Field(..., min_length=2, max_length=100)
|
||||
descripcion: Optional[str] = None
|
||||
categoria: str = Field(default="otro", max_length=50)
|
||||
lat: float = Field(..., ge=-90, le=90)
|
||||
lng: float = Field(..., ge=-180, le=180)
|
||||
direccion: Optional[str] = Field(None, max_length=255)
|
||||
ciudad: Optional[str] = Field(None, max_length=100)
|
||||
estado: Optional[str] = Field(None, max_length=100)
|
||||
codigo_postal: Optional[str] = Field(None, max_length=10)
|
||||
radio_metros: float = Field(default=100.0, gt=0, le=10000)
|
||||
|
||||
|
||||
class POICreate(POIBase):
|
||||
"""Schema para crear POI."""
|
||||
|
||||
telefono: Optional[str] = Field(None, max_length=20)
|
||||
email: Optional[EmailStr] = None
|
||||
contacto_nombre: Optional[str] = Field(None, max_length=100)
|
||||
horario_json: Optional[str] = None
|
||||
icono: Optional[str] = Field(None, max_length=50)
|
||||
color: str = Field(default="#10B981", pattern=r"^#[0-9A-Fa-f]{6}$")
|
||||
codigo_externo: Optional[str] = Field(None, max_length=50)
|
||||
notas: Optional[str] = None
|
||||
|
||||
|
||||
class POIUpdate(BaseSchema):
|
||||
"""Schema para actualizar POI."""
|
||||
|
||||
nombre: Optional[str] = Field(None, min_length=2, max_length=100)
|
||||
descripcion: Optional[str] = None
|
||||
categoria: Optional[str] = Field(None, max_length=50)
|
||||
lat: Optional[float] = Field(None, ge=-90, le=90)
|
||||
lng: Optional[float] = Field(None, ge=-180, le=180)
|
||||
direccion: Optional[str] = Field(None, max_length=255)
|
||||
ciudad: Optional[str] = Field(None, max_length=100)
|
||||
estado: Optional[str] = Field(None, max_length=100)
|
||||
codigo_postal: Optional[str] = Field(None, max_length=10)
|
||||
radio_metros: Optional[float] = Field(None, gt=0, le=10000)
|
||||
telefono: Optional[str] = Field(None, max_length=20)
|
||||
email: Optional[EmailStr] = None
|
||||
contacto_nombre: Optional[str] = Field(None, max_length=100)
|
||||
horario_json: Optional[str] = None
|
||||
icono: Optional[str] = Field(None, max_length=50)
|
||||
color: Optional[str] = Field(None, pattern=r"^#[0-9A-Fa-f]{6}$")
|
||||
codigo_externo: Optional[str] = Field(None, max_length=50)
|
||||
activo: Optional[bool] = None
|
||||
notas: Optional[str] = None
|
||||
|
||||
|
||||
class POIResponse(POIBase, TimestampSchema):
|
||||
"""Schema de respuesta de POI."""
|
||||
|
||||
id: int
|
||||
telefono: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
contacto_nombre: Optional[str] = None
|
||||
horario_json: Optional[str] = None
|
||||
icono: Optional[str] = None
|
||||
color: str
|
||||
codigo_externo: Optional[str] = None
|
||||
activo: bool
|
||||
notas: Optional[str] = None
|
||||
|
||||
|
||||
class POIResumen(BaseSchema):
|
||||
"""Schema resumido de POI."""
|
||||
|
||||
id: int
|
||||
nombre: str
|
||||
categoria: str
|
||||
lat: float
|
||||
lng: float
|
||||
icono: Optional[str] = None
|
||||
color: str
|
||||
|
||||
|
||||
class POICercano(POIResumen):
|
||||
"""Schema de POI con distancia."""
|
||||
|
||||
distancia_metros: float
|
||||
|
||||
|
||||
class BuscarPOIsCercanosRequest(BaseSchema):
|
||||
"""Schema para buscar POIs cercanos."""
|
||||
|
||||
lat: float = Field(..., ge=-90, le=90)
|
||||
lng: float = Field(..., ge=-180, le=180)
|
||||
radio_metros: float = Field(default=1000, gt=0, le=50000)
|
||||
categoria: Optional[str] = None
|
||||
limite: int = Field(default=10, ge=1, le=100)
|
||||
|
||||
|
||||
class BuscarPOIsCercanosResponse(BaseSchema):
|
||||
"""Schema de respuesta de búsqueda de POIs cercanos."""
|
||||
|
||||
centro_lat: float
|
||||
centro_lng: float
|
||||
radio_metros: float
|
||||
total: int
|
||||
pois: List[POICercano]
|
||||
|
||||
|
||||
class CategoriasPOIResponse(BaseSchema):
|
||||
"""Schema de respuesta con categorías de POI."""
|
||||
|
||||
categorias: List[dict] # [{codigo, nombre, icono, color}]
|
||||
217
backend/app/schemas/reporte.py
Normal file
217
backend/app/schemas/reporte.py
Normal file
@@ -0,0 +1,217 @@
|
||||
"""
|
||||
Schemas Pydantic para Reportes y Dashboard.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from app.schemas.base import BaseSchema
|
||||
|
||||
|
||||
class DashboardResumen(BaseSchema):
|
||||
"""Schema para datos del dashboard principal."""
|
||||
|
||||
# Contadores
|
||||
total_vehiculos: int
|
||||
vehiculos_activos: int
|
||||
vehiculos_en_movimiento: int
|
||||
vehiculos_detenidos: int
|
||||
vehiculos_sin_señal: int
|
||||
|
||||
total_conductores: int
|
||||
conductores_activos: int
|
||||
|
||||
# Alertas
|
||||
alertas_pendientes: int
|
||||
alertas_criticas: int
|
||||
alertas_hoy: int
|
||||
|
||||
# Viajes de hoy
|
||||
viajes_hoy: int
|
||||
distancia_hoy_km: float
|
||||
|
||||
# Mantenimiento
|
||||
mantenimientos_vencidos: int
|
||||
mantenimientos_proximos: int
|
||||
|
||||
# Última actualización
|
||||
actualizado_en: datetime
|
||||
|
||||
|
||||
class DashboardGrafico(BaseSchema):
|
||||
"""Schema para datos de gráficos del dashboard."""
|
||||
|
||||
# Distancia por día (últimos 7 días)
|
||||
distancia_diaria: List[dict] # [{fecha, km}]
|
||||
|
||||
# Viajes por día (últimos 7 días)
|
||||
viajes_diarios: List[dict] # [{fecha, cantidad}]
|
||||
|
||||
# Alertas por tipo (últimos 7 días)
|
||||
alertas_por_tipo: List[dict] # [{tipo, cantidad}]
|
||||
|
||||
# Consumo de combustible (últimos 30 días)
|
||||
consumo_combustible: List[dict] # [{fecha, litros}]
|
||||
|
||||
|
||||
class ReporteRequest(BaseSchema):
|
||||
"""Schema para solicitar generación de reporte."""
|
||||
|
||||
tipo: str = Field(
|
||||
...,
|
||||
pattern="^(viajes|alertas|combustible|mantenimiento|ubicaciones|resumen)$"
|
||||
)
|
||||
formato: str = Field(default="pdf", pattern="^(pdf|excel|csv)$")
|
||||
fecha_inicio: datetime
|
||||
fecha_fin: datetime
|
||||
vehiculos_ids: Optional[List[int]] = None # None = todos
|
||||
conductores_ids: Optional[List[int]] = None
|
||||
parametros: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
class ReporteResponse(BaseSchema):
|
||||
"""Schema de respuesta de generación de reporte."""
|
||||
|
||||
id: str # UUID del reporte
|
||||
tipo: str
|
||||
formato: str
|
||||
estado: str # pendiente, procesando, completado, error
|
||||
archivo_url: Optional[str] = None
|
||||
creado_en: datetime
|
||||
completado_en: Optional[datetime] = None
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
class ReporteViajesResumen(BaseSchema):
|
||||
"""Schema para reporte de viajes."""
|
||||
|
||||
periodo_inicio: datetime
|
||||
periodo_fin: datetime
|
||||
total_viajes: int
|
||||
distancia_total_km: float
|
||||
tiempo_total_conduccion: str
|
||||
velocidad_promedio: float
|
||||
|
||||
por_vehiculo: List[dict]
|
||||
por_conductor: List[dict]
|
||||
viajes: List[dict]
|
||||
|
||||
|
||||
class ReporteAlertasResumen(BaseSchema):
|
||||
"""Schema para reporte de alertas."""
|
||||
|
||||
periodo_inicio: datetime
|
||||
periodo_fin: datetime
|
||||
total_alertas: int
|
||||
atendidas: int
|
||||
pendientes: int
|
||||
|
||||
por_tipo: List[dict]
|
||||
por_severidad: List[dict]
|
||||
por_vehiculo: List[dict]
|
||||
alertas: List[dict]
|
||||
|
||||
|
||||
class ReporteCombustibleResumen(BaseSchema):
|
||||
"""Schema para reporte de combustible."""
|
||||
|
||||
periodo_inicio: datetime
|
||||
periodo_fin: datetime
|
||||
total_litros: float
|
||||
total_costo: float
|
||||
rendimiento_promedio: float
|
||||
|
||||
por_vehiculo: List[dict]
|
||||
cargas: List[dict]
|
||||
|
||||
|
||||
class ReporteMantenimientoResumen(BaseSchema):
|
||||
"""Schema para reporte de mantenimiento."""
|
||||
|
||||
periodo_inicio: datetime
|
||||
periodo_fin: datetime
|
||||
total_mantenimientos: int
|
||||
completados: int
|
||||
pendientes: int
|
||||
vencidos: int
|
||||
costo_total: float
|
||||
|
||||
por_tipo: List[dict]
|
||||
por_vehiculo: List[dict]
|
||||
mantenimientos: List[dict]
|
||||
|
||||
|
||||
class ReporteUbicacionesResumen(BaseSchema):
|
||||
"""Schema para reporte de ubicaciones/recorridos."""
|
||||
|
||||
periodo_inicio: datetime
|
||||
periodo_fin: datetime
|
||||
vehiculo_id: int
|
||||
vehiculo_nombre: str
|
||||
total_puntos: int
|
||||
distancia_km: float
|
||||
|
||||
# Ruta en GeoJSON
|
||||
ruta_geojson: dict
|
||||
|
||||
|
||||
class EstadisticasFlota(BaseSchema):
|
||||
"""Schema para estadísticas generales de la flota."""
|
||||
|
||||
periodo: str # diario, semanal, mensual
|
||||
|
||||
# Distancia
|
||||
distancia_total_km: float
|
||||
distancia_promedio_vehiculo_km: float
|
||||
|
||||
# Tiempo
|
||||
tiempo_conduccion_total_horas: float
|
||||
tiempo_ocioso_total_horas: float
|
||||
|
||||
# Combustible
|
||||
combustible_total_litros: float
|
||||
costo_combustible_total: float
|
||||
rendimiento_promedio: float
|
||||
|
||||
# Alertas
|
||||
alertas_total: int
|
||||
alertas_por_vehiculo_promedio: float
|
||||
|
||||
# Mantenimiento
|
||||
costo_mantenimiento_total: float
|
||||
|
||||
# Top vehículos
|
||||
top_distancia: List[dict] # [{vehiculo_id, nombre, km}]
|
||||
top_alertas: List[dict] # [{vehiculo_id, nombre, cantidad}]
|
||||
top_combustible: List[dict] # [{vehiculo_id, nombre, litros}]
|
||||
|
||||
|
||||
class KPIsFlota(BaseSchema):
|
||||
"""Schema para KPIs de la flota."""
|
||||
|
||||
# Utilización
|
||||
porcentaje_utilizacion: float # % de vehículos en uso
|
||||
horas_promedio_uso_diario: float
|
||||
|
||||
# Eficiencia
|
||||
km_por_litro_flota: float
|
||||
costo_por_km: float
|
||||
|
||||
# Seguridad
|
||||
alertas_por_1000km: float
|
||||
excesos_velocidad_por_1000km: float
|
||||
|
||||
# Mantenimiento
|
||||
porcentaje_mantenimientos_a_tiempo: float
|
||||
costo_mantenimiento_por_km: float
|
||||
|
||||
# Disponibilidad
|
||||
porcentaje_disponibilidad: float # % de tiempo operativo
|
||||
|
||||
# Comparación con periodo anterior
|
||||
variacion_km: float # % vs periodo anterior
|
||||
variacion_combustible: float
|
||||
variacion_alertas: float
|
||||
variacion_costo: float
|
||||
139
backend/app/schemas/ubicacion.py
Normal file
139
backend/app/schemas/ubicacion.py
Normal file
@@ -0,0 +1,139 @@
|
||||
"""
|
||||
Schemas Pydantic para Ubicación GPS.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from app.schemas.base import BaseSchema, GeoJSONFeature
|
||||
|
||||
|
||||
class UbicacionBase(BaseSchema):
|
||||
"""Schema base de ubicación."""
|
||||
|
||||
lat: float = Field(..., ge=-90, le=90)
|
||||
lng: float = Field(..., ge=-180, le=180)
|
||||
velocidad: Optional[float] = Field(None, ge=0)
|
||||
rumbo: Optional[float] = Field(None, ge=0, le=360)
|
||||
altitud: Optional[float] = None
|
||||
precision: Optional[float] = Field(None, ge=0)
|
||||
satelites: Optional[int] = Field(None, ge=0)
|
||||
|
||||
|
||||
class UbicacionCreate(UbicacionBase):
|
||||
"""Schema para crear/recibir ubicación."""
|
||||
|
||||
vehiculo_id: Optional[int] = None # Puede venir por identificador de dispositivo
|
||||
dispositivo_id: Optional[str] = None # Identificador del dispositivo
|
||||
tiempo: Optional[datetime] = None # Si no se envía, usa timestamp del servidor
|
||||
fuente: str = Field(default="gps", max_length=20)
|
||||
bateria_dispositivo: Optional[float] = Field(None, ge=0, le=100)
|
||||
bateria_vehiculo: Optional[float] = None
|
||||
motor_encendido: Optional[bool] = None
|
||||
odometro: Optional[float] = Field(None, ge=0)
|
||||
hdop: Optional[float] = None
|
||||
|
||||
# Datos OBD opcionales
|
||||
rpm: Optional[int] = Field(None, ge=0)
|
||||
temperatura_motor: Optional[float] = None
|
||||
nivel_combustible: Optional[float] = Field(None, ge=0, le=100)
|
||||
|
||||
|
||||
class UbicacionBulkCreate(BaseSchema):
|
||||
"""Schema para recibir múltiples ubicaciones."""
|
||||
|
||||
ubicaciones: List[UbicacionCreate]
|
||||
|
||||
|
||||
class UbicacionResponse(UbicacionBase):
|
||||
"""Schema de respuesta de ubicación."""
|
||||
|
||||
tiempo: datetime
|
||||
vehiculo_id: int
|
||||
fuente: str
|
||||
bateria_dispositivo: Optional[float] = None
|
||||
motor_encendido: Optional[bool] = None
|
||||
odometro: Optional[float] = None
|
||||
|
||||
|
||||
class UbicacionConVehiculo(UbicacionResponse):
|
||||
"""Schema de ubicación con información del vehículo."""
|
||||
|
||||
vehiculo_nombre: str
|
||||
vehiculo_placa: str
|
||||
vehiculo_color: str
|
||||
|
||||
|
||||
class HistorialUbicacionesRequest(BaseSchema):
|
||||
"""Schema para solicitar historial de ubicaciones."""
|
||||
|
||||
vehiculo_id: int
|
||||
desde: datetime
|
||||
hasta: datetime
|
||||
simplificar: bool = True # Simplificar ruta con Douglas-Peucker
|
||||
intervalo_segundos: Optional[int] = None # Muestreo por intervalo
|
||||
|
||||
|
||||
class HistorialUbicacionesResponse(BaseSchema):
|
||||
"""Schema de respuesta de historial de ubicaciones."""
|
||||
|
||||
vehiculo_id: int
|
||||
desde: datetime
|
||||
hasta: datetime
|
||||
total_puntos: int
|
||||
distancia_km: float
|
||||
tiempo_movimiento_segundos: int
|
||||
velocidad_promedio: Optional[float] = None
|
||||
velocidad_maxima: Optional[float] = None
|
||||
ubicaciones: List[UbicacionResponse]
|
||||
|
||||
|
||||
class UbicacionGeoJSON(GeoJSONFeature):
|
||||
"""Schema de ubicación en formato GeoJSON."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class RutaGeoJSON(BaseSchema):
|
||||
"""Schema de ruta completa en formato GeoJSON LineString."""
|
||||
|
||||
type: str = "Feature"
|
||||
geometry: dict # LineString
|
||||
properties: dict
|
||||
|
||||
|
||||
# Schema para recibir ubicaciones de OsmAnd/Traccar
|
||||
class OsmAndLocationCreate(BaseSchema):
|
||||
"""Schema para ubicaciones recibidas de OsmAnd."""
|
||||
|
||||
id: str # Device identifier
|
||||
lat: float
|
||||
lon: float
|
||||
timestamp: Optional[int] = None # Unix timestamp
|
||||
speed: Optional[float] = None # km/h
|
||||
bearing: Optional[float] = None # degrees
|
||||
altitude: Optional[float] = None # meters
|
||||
accuracy: Optional[float] = None # meters
|
||||
batt: Optional[float] = None # battery percentage
|
||||
|
||||
|
||||
class TraccarLocationCreate(BaseSchema):
|
||||
"""Schema para ubicaciones recibidas de Traccar."""
|
||||
|
||||
id: int # Device ID in Traccar
|
||||
deviceId: int
|
||||
protocol: str
|
||||
serverTime: datetime
|
||||
deviceTime: datetime
|
||||
fixTime: datetime
|
||||
valid: bool
|
||||
latitude: float
|
||||
longitude: float
|
||||
altitude: Optional[float] = None
|
||||
speed: Optional[float] = None # knots
|
||||
course: Optional[float] = None
|
||||
address: Optional[str] = None
|
||||
accuracy: Optional[float] = None
|
||||
attributes: Optional[dict] = None
|
||||
116
backend/app/schemas/usuario.py
Normal file
116
backend/app/schemas/usuario.py
Normal file
@@ -0,0 +1,116 @@
|
||||
"""
|
||||
Schemas Pydantic para Usuario.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, EmailStr, Field, field_validator
|
||||
|
||||
from app.schemas.base import BaseSchema, TimestampSchema
|
||||
|
||||
|
||||
class UsuarioBase(BaseSchema):
|
||||
"""Schema base de usuario."""
|
||||
|
||||
email: EmailStr
|
||||
nombre: str = Field(..., min_length=2, max_length=100)
|
||||
apellido: Optional[str] = Field(None, max_length=100)
|
||||
telefono: Optional[str] = Field(None, max_length=20)
|
||||
|
||||
|
||||
class UsuarioCreate(UsuarioBase):
|
||||
"""Schema para crear usuario."""
|
||||
|
||||
password: str = Field(..., min_length=8, max_length=100)
|
||||
es_admin: bool = False
|
||||
|
||||
@field_validator("password")
|
||||
@classmethod
|
||||
def validate_password(cls, v: str) -> str:
|
||||
if len(v) < 8:
|
||||
raise ValueError("La contraseña debe tener al menos 8 caracteres")
|
||||
if not any(c.isupper() for c in v):
|
||||
raise ValueError("La contraseña debe tener al menos una mayúscula")
|
||||
if not any(c.islower() for c in v):
|
||||
raise ValueError("La contraseña debe tener al menos una minúscula")
|
||||
if not any(c.isdigit() for c in v):
|
||||
raise ValueError("La contraseña debe tener al menos un número")
|
||||
return v
|
||||
|
||||
|
||||
class UsuarioUpdate(BaseSchema):
|
||||
"""Schema para actualizar usuario."""
|
||||
|
||||
nombre: Optional[str] = Field(None, min_length=2, max_length=100)
|
||||
apellido: Optional[str] = Field(None, max_length=100)
|
||||
telefono: Optional[str] = Field(None, max_length=20)
|
||||
avatar_url: Optional[str] = None
|
||||
preferencias: Optional[str] = None
|
||||
|
||||
|
||||
class UsuarioUpdatePassword(BaseModel):
|
||||
"""Schema para cambiar contraseña."""
|
||||
|
||||
password_actual: str
|
||||
password_nuevo: str = Field(..., min_length=8, max_length=100)
|
||||
|
||||
@field_validator("password_nuevo")
|
||||
@classmethod
|
||||
def validate_password(cls, v: str) -> str:
|
||||
if len(v) < 8:
|
||||
raise ValueError("La contraseña debe tener al menos 8 caracteres")
|
||||
return v
|
||||
|
||||
|
||||
class UsuarioResponse(UsuarioBase, TimestampSchema):
|
||||
"""Schema de respuesta de usuario."""
|
||||
|
||||
id: int
|
||||
es_admin: bool
|
||||
activo: bool
|
||||
ultimo_acceso: Optional[datetime] = None
|
||||
avatar_url: Optional[str] = None
|
||||
|
||||
|
||||
class UsuarioInDB(UsuarioResponse):
|
||||
"""Schema interno con hash de password."""
|
||||
|
||||
password_hash: str
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Schemas de Autenticación
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
"""Schema para solicitud de login."""
|
||||
|
||||
email: EmailStr
|
||||
password: str
|
||||
|
||||
|
||||
class LoginResponse(BaseModel):
|
||||
"""Schema de respuesta de login."""
|
||||
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
token_type: str = "bearer"
|
||||
expires_in: int
|
||||
user: UsuarioResponse
|
||||
|
||||
|
||||
class RefreshTokenRequest(BaseModel):
|
||||
"""Schema para refresh token."""
|
||||
|
||||
refresh_token: str
|
||||
|
||||
|
||||
class TokenResponse(BaseModel):
|
||||
"""Schema de respuesta de tokens."""
|
||||
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
token_type: str = "bearer"
|
||||
expires_in: int
|
||||
176
backend/app/schemas/vehiculo.py
Normal file
176
backend/app/schemas/vehiculo.py
Normal file
@@ -0,0 +1,176 @@
|
||||
"""
|
||||
Schemas Pydantic para Vehículo.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from app.schemas.base import BaseSchema, TimestampSchema
|
||||
|
||||
|
||||
class VehiculoBase(BaseSchema):
|
||||
"""Schema base de vehículo."""
|
||||
|
||||
nombre: str = Field(..., min_length=2, max_length=100)
|
||||
placa: str = Field(..., min_length=2, max_length=20)
|
||||
vin: Optional[str] = Field(None, max_length=17)
|
||||
numero_economico: Optional[str] = Field(None, max_length=50)
|
||||
marca: Optional[str] = Field(None, max_length=50)
|
||||
modelo: Optional[str] = Field(None, max_length=50)
|
||||
año: Optional[int] = Field(None, ge=1900, le=2100)
|
||||
color: Optional[str] = Field(None, max_length=30)
|
||||
tipo: Optional[str] = Field(None, max_length=50)
|
||||
capacidad_carga_kg: Optional[float] = Field(None, ge=0)
|
||||
capacidad_pasajeros: Optional[int] = Field(None, ge=0)
|
||||
capacidad_combustible_litros: Optional[float] = Field(None, ge=0)
|
||||
tipo_combustible: Optional[str] = Field(None, max_length=20)
|
||||
odometro_inicial: float = Field(default=0.0, ge=0)
|
||||
|
||||
|
||||
class VehiculoCreate(VehiculoBase):
|
||||
"""Schema para crear vehículo."""
|
||||
|
||||
conductor_id: Optional[int] = None
|
||||
grupo_id: Optional[int] = None
|
||||
icono: Optional[str] = Field(None, max_length=50)
|
||||
color_marcador: str = Field(default="#3B82F6", pattern=r"^#[0-9A-Fa-f]{6}$")
|
||||
|
||||
|
||||
class VehiculoUpdate(BaseSchema):
|
||||
"""Schema para actualizar vehículo."""
|
||||
|
||||
nombre: Optional[str] = Field(None, min_length=2, max_length=100)
|
||||
placa: Optional[str] = Field(None, min_length=2, max_length=20)
|
||||
vin: Optional[str] = Field(None, max_length=17)
|
||||
numero_economico: Optional[str] = Field(None, max_length=50)
|
||||
marca: Optional[str] = Field(None, max_length=50)
|
||||
modelo: Optional[str] = Field(None, max_length=50)
|
||||
año: Optional[int] = Field(None, ge=1900, le=2100)
|
||||
color: Optional[str] = Field(None, max_length=30)
|
||||
tipo: Optional[str] = Field(None, max_length=50)
|
||||
capacidad_carga_kg: Optional[float] = Field(None, ge=0)
|
||||
capacidad_pasajeros: Optional[int] = Field(None, ge=0)
|
||||
capacidad_combustible_litros: Optional[float] = Field(None, ge=0)
|
||||
tipo_combustible: Optional[str] = Field(None, max_length=20)
|
||||
conductor_id: Optional[int] = None
|
||||
grupo_id: Optional[int] = None
|
||||
icono: Optional[str] = Field(None, max_length=50)
|
||||
color_marcador: Optional[str] = Field(None, pattern=r"^#[0-9A-Fa-f]{6}$")
|
||||
activo: Optional[bool] = None
|
||||
en_servicio: Optional[bool] = None
|
||||
notas: Optional[str] = None
|
||||
|
||||
|
||||
class VehiculoResponse(VehiculoBase, TimestampSchema):
|
||||
"""Schema de respuesta de vehículo."""
|
||||
|
||||
id: int
|
||||
odometro_actual: float
|
||||
icono: Optional[str] = None
|
||||
color_marcador: str
|
||||
conductor_id: Optional[int] = None
|
||||
grupo_id: Optional[int] = None
|
||||
activo: bool
|
||||
en_servicio: bool
|
||||
notas: Optional[str] = None
|
||||
|
||||
# Última ubicación
|
||||
ultima_lat: Optional[float] = None
|
||||
ultima_lng: Optional[float] = None
|
||||
ultima_velocidad: Optional[float] = None
|
||||
ultimo_rumbo: Optional[float] = None
|
||||
ultima_ubicacion_tiempo: Optional[datetime] = None
|
||||
motor_encendido: Optional[bool] = None
|
||||
|
||||
# Calculados
|
||||
distancia_recorrida: float
|
||||
|
||||
|
||||
class VehiculoResumen(BaseSchema):
|
||||
"""Schema resumido de vehículo para listas."""
|
||||
|
||||
id: int
|
||||
nombre: str
|
||||
placa: str
|
||||
marca: Optional[str] = None
|
||||
modelo: Optional[str] = None
|
||||
color_marcador: str
|
||||
activo: bool
|
||||
en_servicio: bool
|
||||
|
||||
# Estado actual
|
||||
ultima_lat: Optional[float] = None
|
||||
ultima_lng: Optional[float] = None
|
||||
ultima_velocidad: Optional[float] = None
|
||||
motor_encendido: Optional[bool] = None
|
||||
ultima_ubicacion_tiempo: Optional[datetime] = None
|
||||
|
||||
|
||||
class VehiculoConRelaciones(VehiculoResponse):
|
||||
"""Schema de vehículo con relaciones expandidas."""
|
||||
|
||||
conductor: Optional["ConductorResumen"] = None
|
||||
grupo: Optional["GrupoVehiculosResponse"] = None
|
||||
dispositivos: List["DispositivoResumen"] = []
|
||||
|
||||
|
||||
class VehiculoUbicacionActual(BaseSchema):
|
||||
"""Schema para ubicación actual de vehículo (dashboard/mapa)."""
|
||||
|
||||
id: int
|
||||
nombre: str
|
||||
placa: str
|
||||
color_marcador: str
|
||||
icono: Optional[str] = None
|
||||
|
||||
# Ubicación
|
||||
lat: Optional[float] = None
|
||||
lng: Optional[float] = None
|
||||
velocidad: Optional[float] = None
|
||||
rumbo: Optional[float] = None
|
||||
tiempo: Optional[datetime] = None
|
||||
|
||||
# Estado
|
||||
motor_encendido: Optional[bool] = None
|
||||
en_movimiento: bool = False
|
||||
conductor_nombre: Optional[str] = None
|
||||
|
||||
|
||||
class VehiculoEstadisticas(BaseSchema):
|
||||
"""Estadísticas de un vehículo."""
|
||||
|
||||
vehiculo_id: int
|
||||
nombre: str
|
||||
placa: str
|
||||
|
||||
# Distancia
|
||||
distancia_hoy_km: float
|
||||
distancia_semana_km: float
|
||||
distancia_mes_km: float
|
||||
distancia_total_km: float
|
||||
|
||||
# Tiempo
|
||||
tiempo_movimiento_hoy_min: int
|
||||
tiempo_parado_hoy_min: int
|
||||
|
||||
# Combustible
|
||||
consumo_mes_litros: Optional[float] = None
|
||||
rendimiento_km_litro: Optional[float] = None
|
||||
|
||||
# Alertas
|
||||
alertas_activas: int
|
||||
alertas_mes: int
|
||||
|
||||
# Mantenimiento
|
||||
proximo_mantenimiento: Optional[datetime] = None
|
||||
mantenimientos_vencidos: int
|
||||
|
||||
|
||||
# Import circular fix
|
||||
from app.schemas.conductor import ConductorResumen # noqa: E402
|
||||
from app.schemas.grupo_vehiculos import GrupoVehiculosResponse # noqa: E402
|
||||
from app.schemas.dispositivo import DispositivoResumen # noqa: E402
|
||||
|
||||
VehiculoConRelaciones.model_rebuild()
|
||||
168
backend/app/schemas/viaje.py
Normal file
168
backend/app/schemas/viaje.py
Normal file
@@ -0,0 +1,168 @@
|
||||
"""
|
||||
Schemas Pydantic para Viaje y Parada.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from app.schemas.base import BaseSchema, TimestampSchema
|
||||
|
||||
|
||||
class ViajeBase(BaseSchema):
|
||||
"""Schema base de viaje."""
|
||||
|
||||
vehiculo_id: int
|
||||
conductor_id: Optional[int] = None
|
||||
proposito: Optional[str] = Field(None, max_length=100)
|
||||
notas: Optional[str] = None
|
||||
|
||||
|
||||
class ViajeCreate(ViajeBase):
|
||||
"""Schema para crear viaje manualmente."""
|
||||
|
||||
inicio_tiempo: datetime
|
||||
inicio_lat: float
|
||||
inicio_lng: float
|
||||
inicio_direccion: Optional[str] = None
|
||||
|
||||
|
||||
class ViajeUpdate(BaseSchema):
|
||||
"""Schema para actualizar viaje."""
|
||||
|
||||
conductor_id: Optional[int] = None
|
||||
proposito: Optional[str] = Field(None, max_length=100)
|
||||
notas: Optional[str] = None
|
||||
estado: Optional[str] = Field(None, pattern="^(en_curso|completado|cancelado)$")
|
||||
|
||||
|
||||
class ViajeResponse(ViajeBase, TimestampSchema):
|
||||
"""Schema de respuesta de viaje."""
|
||||
|
||||
id: int
|
||||
inicio_tiempo: datetime
|
||||
fin_tiempo: Optional[datetime] = None
|
||||
inicio_lat: float
|
||||
inicio_lng: float
|
||||
inicio_direccion: Optional[str] = None
|
||||
fin_lat: Optional[float] = None
|
||||
fin_lng: Optional[float] = None
|
||||
fin_direccion: Optional[str] = None
|
||||
distancia_km: Optional[float] = None
|
||||
duracion_segundos: Optional[int] = None
|
||||
tiempo_movimiento_segundos: Optional[int] = None
|
||||
tiempo_parado_segundos: Optional[int] = None
|
||||
velocidad_promedio: Optional[float] = None
|
||||
velocidad_maxima: Optional[float] = None
|
||||
combustible_usado: Optional[float] = None
|
||||
rendimiento: Optional[float] = None
|
||||
odometro_inicio: Optional[float] = None
|
||||
odometro_fin: Optional[float] = None
|
||||
estado: str
|
||||
puntos_gps: int
|
||||
|
||||
# Calculados
|
||||
duracion_formateada: str
|
||||
en_curso: bool
|
||||
|
||||
|
||||
class ViajeResumen(BaseSchema):
|
||||
"""Schema resumido de viaje para listas."""
|
||||
|
||||
id: int
|
||||
vehiculo_id: int
|
||||
vehiculo_nombre: Optional[str] = None
|
||||
vehiculo_placa: Optional[str] = None
|
||||
conductor_nombre: Optional[str] = None
|
||||
inicio_tiempo: datetime
|
||||
fin_tiempo: Optional[datetime] = None
|
||||
inicio_direccion: Optional[str] = None
|
||||
fin_direccion: Optional[str] = None
|
||||
distancia_km: Optional[float] = None
|
||||
duracion_formateada: str
|
||||
estado: str
|
||||
|
||||
|
||||
class ViajeConParadas(ViajeResponse):
|
||||
"""Schema de viaje con lista de paradas."""
|
||||
|
||||
paradas: List["ParadaResponse"] = []
|
||||
|
||||
|
||||
class ViajeReplayData(BaseSchema):
|
||||
"""Schema para datos de replay de viaje."""
|
||||
|
||||
viaje: ViajeResponse
|
||||
ubicaciones: List["UbicacionResponse"]
|
||||
paradas: List["ParadaResponse"]
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Schemas de Parada
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class ParadaBase(BaseSchema):
|
||||
"""Schema base de parada."""
|
||||
|
||||
lat: float = Field(..., ge=-90, le=90)
|
||||
lng: float = Field(..., ge=-180, le=180)
|
||||
tipo: str = Field(default="desconocido", max_length=50)
|
||||
notas: Optional[str] = None
|
||||
|
||||
|
||||
class ParadaCreate(ParadaBase):
|
||||
"""Schema para crear parada manualmente."""
|
||||
|
||||
viaje_id: Optional[int] = None
|
||||
vehiculo_id: int
|
||||
inicio_tiempo: datetime
|
||||
fin_tiempo: Optional[datetime] = None
|
||||
direccion: Optional[str] = Field(None, max_length=255)
|
||||
motor_apagado: Optional[bool] = None
|
||||
|
||||
|
||||
class ParadaUpdate(BaseSchema):
|
||||
"""Schema para actualizar parada."""
|
||||
|
||||
tipo: Optional[str] = Field(None, max_length=50)
|
||||
direccion: Optional[str] = Field(None, max_length=255)
|
||||
notas: Optional[str] = None
|
||||
motor_apagado: Optional[bool] = None
|
||||
|
||||
|
||||
class ParadaResponse(ParadaBase):
|
||||
"""Schema de respuesta de parada."""
|
||||
|
||||
id: int
|
||||
viaje_id: Optional[int] = None
|
||||
vehiculo_id: int
|
||||
inicio_tiempo: datetime
|
||||
fin_tiempo: Optional[datetime] = None
|
||||
duracion_segundos: Optional[int] = None
|
||||
direccion: Optional[str] = None
|
||||
motor_apagado: Optional[bool] = None
|
||||
poi_id: Optional[int] = None
|
||||
geocerca_id: Optional[int] = None
|
||||
en_curso: bool
|
||||
|
||||
# Calculado
|
||||
duracion_formateada: str
|
||||
|
||||
|
||||
class ParadaResumen(BaseSchema):
|
||||
"""Schema resumido de parada."""
|
||||
|
||||
id: int
|
||||
vehiculo_id: int
|
||||
inicio_tiempo: datetime
|
||||
duracion_formateada: str
|
||||
tipo: str
|
||||
direccion: Optional[str] = None
|
||||
|
||||
|
||||
# Import fix
|
||||
from app.schemas.ubicacion import UbicacionResponse # noqa: E402
|
||||
|
||||
ViajeReplayData.model_rebuild()
|
||||
264
backend/app/schemas/video.py
Normal file
264
backend/app/schemas/video.py
Normal file
@@ -0,0 +1,264 @@
|
||||
"""
|
||||
Schemas Pydantic para Cámara, Grabación y Evento de Video.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from app.schemas.base import BaseSchema, TimestampSchema
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Schemas de Cámara
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class CamaraBase(BaseSchema):
|
||||
"""Schema base de cámara."""
|
||||
|
||||
nombre: str = Field(..., min_length=2, max_length=100)
|
||||
posicion: str = Field(default="frontal", max_length=50)
|
||||
tipo: str = Field(default="ip", max_length=50)
|
||||
|
||||
|
||||
class CamaraCreate(CamaraBase):
|
||||
"""Schema para crear cámara."""
|
||||
|
||||
vehiculo_id: int
|
||||
marca: Optional[str] = Field(None, max_length=50)
|
||||
modelo: Optional[str] = Field(None, max_length=50)
|
||||
numero_serie: Optional[str] = Field(None, max_length=100)
|
||||
resolucion: Optional[str] = Field(None, max_length=20)
|
||||
url_stream: Optional[str] = Field(None, max_length=500)
|
||||
puerto: Optional[int] = Field(None, ge=1, le=65535)
|
||||
protocolo: str = Field(default="rtsp", max_length=20)
|
||||
usuario: Optional[str] = Field(None, max_length=100)
|
||||
password: Optional[str] = Field(None, max_length=100) # Se encriptará
|
||||
mediamtx_path: Optional[str] = Field(None, max_length=100)
|
||||
grabacion_continua: bool = False
|
||||
grabacion_evento: bool = True
|
||||
duracion_pre_evento: int = Field(default=10, ge=0, le=60)
|
||||
duracion_post_evento: int = Field(default=20, ge=0, le=120)
|
||||
deteccion_colision: bool = False
|
||||
deteccion_distraccion: bool = False
|
||||
deteccion_fatiga: bool = False
|
||||
deteccion_cambio_carril: bool = False
|
||||
notas: Optional[str] = None
|
||||
|
||||
|
||||
class CamaraUpdate(BaseSchema):
|
||||
"""Schema para actualizar cámara."""
|
||||
|
||||
nombre: Optional[str] = Field(None, min_length=2, max_length=100)
|
||||
posicion: Optional[str] = Field(None, max_length=50)
|
||||
tipo: Optional[str] = Field(None, max_length=50)
|
||||
marca: Optional[str] = Field(None, max_length=50)
|
||||
modelo: Optional[str] = Field(None, max_length=50)
|
||||
numero_serie: Optional[str] = Field(None, max_length=100)
|
||||
resolucion: Optional[str] = Field(None, max_length=20)
|
||||
url_stream: Optional[str] = Field(None, max_length=500)
|
||||
puerto: Optional[int] = Field(None, ge=1, le=65535)
|
||||
protocolo: Optional[str] = Field(None, max_length=20)
|
||||
usuario: Optional[str] = Field(None, max_length=100)
|
||||
password: Optional[str] = Field(None, max_length=100)
|
||||
mediamtx_path: Optional[str] = Field(None, max_length=100)
|
||||
grabacion_continua: Optional[bool] = None
|
||||
grabacion_evento: Optional[bool] = None
|
||||
duracion_pre_evento: Optional[int] = Field(None, ge=0, le=60)
|
||||
duracion_post_evento: Optional[int] = Field(None, ge=0, le=120)
|
||||
deteccion_colision: Optional[bool] = None
|
||||
deteccion_distraccion: Optional[bool] = None
|
||||
deteccion_fatiga: Optional[bool] = None
|
||||
deteccion_cambio_carril: Optional[bool] = None
|
||||
activa: Optional[bool] = None
|
||||
notas: Optional[str] = None
|
||||
|
||||
|
||||
class CamaraResponse(CamaraBase, TimestampSchema):
|
||||
"""Schema de respuesta de cámara."""
|
||||
|
||||
id: int
|
||||
vehiculo_id: int
|
||||
marca: Optional[str] = None
|
||||
modelo: Optional[str] = None
|
||||
numero_serie: Optional[str] = None
|
||||
resolucion: Optional[str] = None
|
||||
url_stream: Optional[str] = None
|
||||
puerto: Optional[int] = None
|
||||
protocolo: str
|
||||
usuario: Optional[str] = None
|
||||
# password no se expone
|
||||
mediamtx_path: Optional[str] = None
|
||||
estado: str
|
||||
activa: bool
|
||||
ultima_conexion: Optional[datetime] = None
|
||||
grabacion_continua: bool
|
||||
grabacion_evento: bool
|
||||
duracion_pre_evento: int
|
||||
duracion_post_evento: int
|
||||
deteccion_colision: bool
|
||||
deteccion_distraccion: bool
|
||||
deteccion_fatiga: bool
|
||||
deteccion_cambio_carril: bool
|
||||
notas: Optional[str] = None
|
||||
|
||||
|
||||
class CamaraConVehiculo(CamaraResponse):
|
||||
"""Schema de cámara con información del vehículo."""
|
||||
|
||||
vehiculo_nombre: Optional[str] = None
|
||||
vehiculo_placa: Optional[str] = None
|
||||
|
||||
|
||||
class CamaraStreamURL(BaseSchema):
|
||||
"""Schema con URLs de streaming de una cámara."""
|
||||
|
||||
camara_id: int
|
||||
camara_nombre: str
|
||||
rtsp_url: Optional[str] = None
|
||||
hls_url: Optional[str] = None
|
||||
webrtc_url: Optional[str] = None
|
||||
estado: str
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Schemas de Grabación
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class GrabacionBase(BaseSchema):
|
||||
"""Schema base de grabación."""
|
||||
|
||||
camara_id: int
|
||||
vehiculo_id: int
|
||||
inicio_tiempo: datetime
|
||||
tipo: str = Field(default="continua", max_length=50)
|
||||
|
||||
|
||||
class GrabacionCreate(GrabacionBase):
|
||||
"""Schema para crear registro de grabación."""
|
||||
|
||||
archivo_url: str = Field(..., max_length=500)
|
||||
archivo_nombre: str = Field(..., max_length=255)
|
||||
formato: str = Field(default="mp4", max_length=10)
|
||||
|
||||
|
||||
class GrabacionResponse(GrabacionBase, TimestampSchema):
|
||||
"""Schema de respuesta de grabación."""
|
||||
|
||||
id: int
|
||||
fin_tiempo: Optional[datetime] = None
|
||||
duracion_segundos: Optional[int] = None
|
||||
archivo_url: str
|
||||
archivo_nombre: str
|
||||
tamaño_mb: Optional[float] = None
|
||||
formato: str
|
||||
resolucion: Optional[str] = None
|
||||
evento_video_id: Optional[int] = None
|
||||
lat: Optional[float] = None
|
||||
lng: Optional[float] = None
|
||||
estado: str
|
||||
thumbnail_url: Optional[str] = None
|
||||
notas: Optional[str] = None
|
||||
|
||||
# Calculado
|
||||
duracion_formateada: str
|
||||
|
||||
|
||||
class GrabacionResumen(BaseSchema):
|
||||
"""Schema resumido de grabación."""
|
||||
|
||||
id: int
|
||||
camara_id: int
|
||||
vehiculo_id: int
|
||||
inicio_tiempo: datetime
|
||||
duracion_formateada: str
|
||||
tipo: str
|
||||
thumbnail_url: Optional[str] = None
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Schemas de Evento de Video
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class EventoVideoBase(BaseSchema):
|
||||
"""Schema base de evento de video."""
|
||||
|
||||
camara_id: int
|
||||
vehiculo_id: int
|
||||
tipo: str = Field(..., max_length=50)
|
||||
severidad: str = Field(default="media", pattern="^(baja|media|alta|critica)$")
|
||||
tiempo: datetime
|
||||
|
||||
|
||||
class EventoVideoCreate(EventoVideoBase):
|
||||
"""Schema para crear evento de video."""
|
||||
|
||||
lat: Optional[float] = Field(None, ge=-90, le=90)
|
||||
lng: Optional[float] = Field(None, ge=-180, le=180)
|
||||
velocidad: Optional[float] = Field(None, ge=0)
|
||||
descripcion: Optional[str] = None
|
||||
confianza: Optional[float] = Field(None, ge=0, le=100)
|
||||
datos_extra: Optional[str] = None
|
||||
snapshot_url: Optional[str] = Field(None, max_length=500)
|
||||
clip_url: Optional[str] = Field(None, max_length=500)
|
||||
clip_duracion: Optional[int] = Field(None, ge=0)
|
||||
|
||||
|
||||
class EventoVideoUpdate(BaseSchema):
|
||||
"""Schema para actualizar evento de video."""
|
||||
|
||||
revisado: Optional[bool] = None
|
||||
notas_revision: Optional[str] = None
|
||||
falso_positivo: Optional[bool] = None
|
||||
|
||||
|
||||
class EventoVideoResponse(EventoVideoBase, TimestampSchema):
|
||||
"""Schema de respuesta de evento de video."""
|
||||
|
||||
id: int
|
||||
lat: Optional[float] = None
|
||||
lng: Optional[float] = None
|
||||
velocidad: Optional[float] = None
|
||||
descripcion: Optional[str] = None
|
||||
confianza: Optional[float] = None
|
||||
datos_extra: Optional[str] = None
|
||||
revisado: bool
|
||||
revisado_por_id: Optional[int] = None
|
||||
revisado_en: Optional[datetime] = None
|
||||
notas_revision: Optional[str] = None
|
||||
falso_positivo: bool
|
||||
snapshot_url: Optional[str] = None
|
||||
clip_url: Optional[str] = None
|
||||
clip_duracion: Optional[int] = None
|
||||
|
||||
|
||||
class EventoVideoConRelaciones(EventoVideoResponse):
|
||||
"""Schema con información de cámara y vehículo."""
|
||||
|
||||
camara_nombre: Optional[str] = None
|
||||
vehiculo_nombre: Optional[str] = None
|
||||
vehiculo_placa: Optional[str] = None
|
||||
|
||||
|
||||
class EventoVideoResumen(BaseSchema):
|
||||
"""Schema resumido de evento de video."""
|
||||
|
||||
id: int
|
||||
tipo: str
|
||||
severidad: str
|
||||
tiempo: datetime
|
||||
vehiculo_nombre: str
|
||||
camara_nombre: str
|
||||
revisado: bool
|
||||
falso_positivo: bool
|
||||
snapshot_url: Optional[str] = None
|
||||
|
||||
|
||||
class TiposEventoVideoResponse(BaseSchema):
|
||||
"""Schema con tipos de eventos de video disponibles."""
|
||||
|
||||
tipos: List[dict] # [{codigo, nombre, severidad}]
|
||||
23
backend/app/services/__init__.py
Normal file
23
backend/app/services/__init__.py
Normal file
@@ -0,0 +1,23 @@
|
||||
"""
|
||||
Módulo de servicios de lógica de negocio.
|
||||
"""
|
||||
|
||||
from app.services.ubicacion_service import UbicacionService
|
||||
from app.services.geocerca_service import GeocercaService
|
||||
from app.services.alerta_service import AlertaService
|
||||
from app.services.viaje_service import ViajeService
|
||||
from app.services.traccar_service import TraccarService
|
||||
from app.services.video_service import VideoService
|
||||
from app.services.reporte_service import ReporteService
|
||||
from app.services.notificacion_service import NotificacionService
|
||||
|
||||
__all__ = [
|
||||
"UbicacionService",
|
||||
"GeocercaService",
|
||||
"AlertaService",
|
||||
"ViajeService",
|
||||
"TraccarService",
|
||||
"VideoService",
|
||||
"ReporteService",
|
||||
"NotificacionService",
|
||||
]
|
||||
495
backend/app/services/alerta_service.py
Normal file
495
backend/app/services/alerta_service.py
Normal file
@@ -0,0 +1,495 @@
|
||||
"""
|
||||
Servicio para gestión y generación de alertas.
|
||||
|
||||
Motor de reglas que detecta y genera alertas basándose
|
||||
en ubicaciones, velocidad, geocercas, batería, etc.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import List, Optional
|
||||
|
||||
from sqlalchemy import and_, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import settings
|
||||
from app.models.alerta import Alerta
|
||||
from app.models.tipo_alerta import TipoAlerta
|
||||
from app.models.vehiculo import Vehiculo
|
||||
from app.schemas.alerta import AlertaCreate, AlertaResponse
|
||||
from app.services.geocerca_service import GeocercaService
|
||||
|
||||
|
||||
class AlertaService:
|
||||
"""Servicio para gestión de alertas."""
|
||||
|
||||
# Cache de tipos de alerta (código -> id)
|
||||
_tipos_alerta_cache: dict = {}
|
||||
|
||||
def __init__(self, db: AsyncSession):
|
||||
"""
|
||||
Inicializa el servicio.
|
||||
|
||||
Args:
|
||||
db: Sesión de base de datos async.
|
||||
"""
|
||||
self.db = db
|
||||
self.geocerca_service = GeocercaService(db)
|
||||
|
||||
async def _obtener_tipo_alerta_id(self, codigo: str) -> Optional[int]:
|
||||
"""
|
||||
Obtiene el ID de un tipo de alerta por su código.
|
||||
|
||||
Args:
|
||||
codigo: Código del tipo de alerta.
|
||||
|
||||
Returns:
|
||||
ID del tipo de alerta o None.
|
||||
"""
|
||||
if codigo in self._tipos_alerta_cache:
|
||||
return self._tipos_alerta_cache[codigo]
|
||||
|
||||
result = await self.db.execute(
|
||||
select(TipoAlerta).where(TipoAlerta.codigo == codigo)
|
||||
)
|
||||
tipo = result.scalar_one_or_none()
|
||||
|
||||
if tipo:
|
||||
self._tipos_alerta_cache[codigo] = tipo.id
|
||||
return tipo.id
|
||||
|
||||
return None
|
||||
|
||||
async def verificar_velocidad(
|
||||
self,
|
||||
vehiculo_id: int,
|
||||
velocidad: float,
|
||||
lat: float,
|
||||
lng: float,
|
||||
limite_general: float = None,
|
||||
) -> Optional[Alerta]:
|
||||
"""
|
||||
Verifica si la velocidad excede el límite.
|
||||
|
||||
Args:
|
||||
vehiculo_id: ID del vehículo.
|
||||
velocidad: Velocidad actual en km/h.
|
||||
lat: Latitud actual.
|
||||
lng: Longitud actual.
|
||||
limite_general: Límite de velocidad general (si no, usa config).
|
||||
|
||||
Returns:
|
||||
Alerta creada si excede el límite, None si no.
|
||||
"""
|
||||
limite = limite_general or settings.ALERT_SPEED_LIMIT_DEFAULT
|
||||
|
||||
if velocidad <= limite:
|
||||
return None
|
||||
|
||||
tipo_alerta_id = await self._obtener_tipo_alerta_id("EXCESO_VELOCIDAD")
|
||||
if not tipo_alerta_id:
|
||||
return None
|
||||
|
||||
# Verificar si ya existe una alerta reciente (últimos 5 minutos)
|
||||
tiempo_limite = datetime.now(timezone.utc) - timedelta(minutes=5)
|
||||
result = await self.db.execute(
|
||||
select(Alerta)
|
||||
.where(
|
||||
and_(
|
||||
Alerta.vehiculo_id == vehiculo_id,
|
||||
Alerta.tipo_alerta_id == tipo_alerta_id,
|
||||
Alerta.creado_en >= tiempo_limite,
|
||||
)
|
||||
)
|
||||
)
|
||||
if result.scalar_one_or_none():
|
||||
return None # Ya existe una alerta reciente
|
||||
|
||||
# Crear alerta
|
||||
alerta = Alerta(
|
||||
vehiculo_id=vehiculo_id,
|
||||
tipo_alerta_id=tipo_alerta_id,
|
||||
severidad="media" if velocidad < limite * 1.2 else "alta",
|
||||
mensaje=f"Exceso de velocidad: {velocidad:.1f} km/h (límite: {limite} km/h)",
|
||||
lat=lat,
|
||||
lng=lng,
|
||||
velocidad=velocidad,
|
||||
valor=velocidad,
|
||||
umbral=limite,
|
||||
)
|
||||
|
||||
self.db.add(alerta)
|
||||
await self.db.commit()
|
||||
await self.db.refresh(alerta)
|
||||
|
||||
return alerta
|
||||
|
||||
async def verificar_geocercas(
|
||||
self,
|
||||
vehiculo_id: int,
|
||||
lat: float,
|
||||
lng: float,
|
||||
estado_anterior: dict = None,
|
||||
) -> List[Alerta]:
|
||||
"""
|
||||
Verifica transiciones de entrada/salida de geocercas.
|
||||
|
||||
Args:
|
||||
vehiculo_id: ID del vehículo.
|
||||
lat: Latitud actual.
|
||||
lng: Longitud actual.
|
||||
estado_anterior: Estado de geocercas anterior {geocerca_id: dentro}.
|
||||
|
||||
Returns:
|
||||
Lista de alertas generadas.
|
||||
"""
|
||||
alertas = []
|
||||
estado_anterior = estado_anterior or {}
|
||||
|
||||
resultados = await self.geocerca_service.verificar_todas_geocercas(
|
||||
lat, lng, vehiculo_id
|
||||
)
|
||||
|
||||
for r in resultados:
|
||||
geocerca_id = r["geocerca_id"]
|
||||
dentro = r["dentro"]
|
||||
estaba_dentro = estado_anterior.get(geocerca_id, None)
|
||||
|
||||
# Entrada a geocerca
|
||||
if dentro and not estaba_dentro and r["alerta_entrada"]:
|
||||
tipo_id = await self._obtener_tipo_alerta_id("ENTRADA_GEOCERCA")
|
||||
if tipo_id:
|
||||
alerta = Alerta(
|
||||
vehiculo_id=vehiculo_id,
|
||||
tipo_alerta_id=tipo_id,
|
||||
severidad="baja",
|
||||
mensaje=f"Entrada a geocerca: {r['geocerca_nombre']}",
|
||||
lat=lat,
|
||||
lng=lng,
|
||||
)
|
||||
self.db.add(alerta)
|
||||
alertas.append(alerta)
|
||||
|
||||
# Salida de geocerca
|
||||
elif not dentro and estaba_dentro and r["alerta_salida"]:
|
||||
tipo_id = await self._obtener_tipo_alerta_id("SALIDA_GEOCERCA")
|
||||
if tipo_id:
|
||||
alerta = Alerta(
|
||||
vehiculo_id=vehiculo_id,
|
||||
tipo_alerta_id=tipo_id,
|
||||
severidad="media",
|
||||
mensaje=f"Salida de geocerca: {r['geocerca_nombre']}",
|
||||
lat=lat,
|
||||
lng=lng,
|
||||
)
|
||||
self.db.add(alerta)
|
||||
alertas.append(alerta)
|
||||
|
||||
if alertas:
|
||||
await self.db.commit()
|
||||
|
||||
return alertas
|
||||
|
||||
async def verificar_bateria_baja(
|
||||
self,
|
||||
vehiculo_id: int,
|
||||
bateria: float,
|
||||
lat: float,
|
||||
lng: float,
|
||||
dispositivo_id: int = None,
|
||||
) -> Optional[Alerta]:
|
||||
"""
|
||||
Verifica si la batería del dispositivo está baja.
|
||||
|
||||
Args:
|
||||
vehiculo_id: ID del vehículo.
|
||||
bateria: Porcentaje de batería.
|
||||
lat: Latitud actual.
|
||||
lng: Longitud actual.
|
||||
dispositivo_id: ID del dispositivo (opcional).
|
||||
|
||||
Returns:
|
||||
Alerta creada si la batería está baja.
|
||||
"""
|
||||
if bateria > settings.ALERT_BATTERY_LOW_PERCENT:
|
||||
return None
|
||||
|
||||
tipo_alerta_id = await self._obtener_tipo_alerta_id("BATERIA_BAJA")
|
||||
if not tipo_alerta_id:
|
||||
return None
|
||||
|
||||
# Verificar si ya existe una alerta reciente (últimas 2 horas)
|
||||
tiempo_limite = datetime.now(timezone.utc) - timedelta(hours=2)
|
||||
result = await self.db.execute(
|
||||
select(Alerta)
|
||||
.where(
|
||||
and_(
|
||||
Alerta.vehiculo_id == vehiculo_id,
|
||||
Alerta.tipo_alerta_id == tipo_alerta_id,
|
||||
Alerta.creado_en >= tiempo_limite,
|
||||
)
|
||||
)
|
||||
)
|
||||
if result.scalar_one_or_none():
|
||||
return None
|
||||
|
||||
severidad = "alta" if bateria < 10 else "media"
|
||||
|
||||
alerta = Alerta(
|
||||
vehiculo_id=vehiculo_id,
|
||||
dispositivo_id=dispositivo_id,
|
||||
tipo_alerta_id=tipo_alerta_id,
|
||||
severidad=severidad,
|
||||
mensaje=f"Batería baja del dispositivo: {bateria:.0f}%",
|
||||
lat=lat,
|
||||
lng=lng,
|
||||
valor=bateria,
|
||||
umbral=settings.ALERT_BATTERY_LOW_PERCENT,
|
||||
)
|
||||
|
||||
self.db.add(alerta)
|
||||
await self.db.commit()
|
||||
await self.db.refresh(alerta)
|
||||
|
||||
return alerta
|
||||
|
||||
async def verificar_sin_señal(self) -> List[Alerta]:
|
||||
"""
|
||||
Verifica vehículos que no han reportado ubicación.
|
||||
|
||||
Busca vehículos activos cuya última ubicación sea mayor
|
||||
al tiempo configurado.
|
||||
|
||||
Returns:
|
||||
Lista de alertas generadas.
|
||||
"""
|
||||
alertas = []
|
||||
tiempo_limite = datetime.now(timezone.utc) - timedelta(
|
||||
minutes=settings.ALERT_NO_SIGNAL_MINUTES
|
||||
)
|
||||
|
||||
result = await self.db.execute(
|
||||
select(Vehiculo)
|
||||
.where(Vehiculo.activo == True)
|
||||
.where(Vehiculo.en_servicio == True)
|
||||
.where(Vehiculo.ultima_ubicacion_tiempo < tiempo_limite)
|
||||
)
|
||||
vehiculos = result.scalars().all()
|
||||
|
||||
tipo_alerta_id = await self._obtener_tipo_alerta_id("SIN_SEÑAL")
|
||||
if not tipo_alerta_id:
|
||||
return alertas
|
||||
|
||||
for v in vehiculos:
|
||||
# Verificar si ya existe una alerta reciente (últimas 2 horas)
|
||||
tiempo_alerta_limite = datetime.now(timezone.utc) - timedelta(hours=2)
|
||||
result = await self.db.execute(
|
||||
select(Alerta)
|
||||
.where(
|
||||
and_(
|
||||
Alerta.vehiculo_id == v.id,
|
||||
Alerta.tipo_alerta_id == tipo_alerta_id,
|
||||
Alerta.creado_en >= tiempo_alerta_limite,
|
||||
)
|
||||
)
|
||||
)
|
||||
if result.scalar_one_or_none():
|
||||
continue
|
||||
|
||||
minutos_sin_señal = int(
|
||||
(datetime.now(timezone.utc) - v.ultima_ubicacion_tiempo).total_seconds() / 60
|
||||
)
|
||||
|
||||
alerta = Alerta(
|
||||
vehiculo_id=v.id,
|
||||
tipo_alerta_id=tipo_alerta_id,
|
||||
severidad="alta",
|
||||
mensaje=f"Sin señal GPS por {minutos_sin_señal} minutos",
|
||||
lat=v.ultima_lat,
|
||||
lng=v.ultima_lng,
|
||||
valor=minutos_sin_señal,
|
||||
umbral=settings.ALERT_NO_SIGNAL_MINUTES,
|
||||
)
|
||||
|
||||
self.db.add(alerta)
|
||||
alertas.append(alerta)
|
||||
|
||||
if alertas:
|
||||
await self.db.commit()
|
||||
|
||||
return alertas
|
||||
|
||||
async def crear_alerta(
|
||||
self,
|
||||
alerta_data: AlertaCreate,
|
||||
) -> Alerta:
|
||||
"""
|
||||
Crea una alerta manualmente.
|
||||
|
||||
Args:
|
||||
alerta_data: Datos de la alerta.
|
||||
|
||||
Returns:
|
||||
Alerta creada.
|
||||
"""
|
||||
alerta = Alerta(
|
||||
vehiculo_id=alerta_data.vehiculo_id,
|
||||
conductor_id=alerta_data.conductor_id,
|
||||
tipo_alerta_id=alerta_data.tipo_alerta_id,
|
||||
dispositivo_id=alerta_data.dispositivo_id,
|
||||
severidad=alerta_data.severidad,
|
||||
mensaje=alerta_data.mensaje,
|
||||
descripcion=alerta_data.descripcion,
|
||||
lat=alerta_data.lat,
|
||||
lng=alerta_data.lng,
|
||||
direccion=alerta_data.direccion,
|
||||
velocidad=alerta_data.velocidad,
|
||||
valor=alerta_data.valor,
|
||||
umbral=alerta_data.umbral,
|
||||
datos_extra=alerta_data.datos_extra,
|
||||
)
|
||||
|
||||
self.db.add(alerta)
|
||||
await self.db.commit()
|
||||
await self.db.refresh(alerta)
|
||||
|
||||
return alerta
|
||||
|
||||
async def marcar_atendida(
|
||||
self,
|
||||
alerta_id: int,
|
||||
usuario_id: int,
|
||||
notas: str = None,
|
||||
) -> Optional[Alerta]:
|
||||
"""
|
||||
Marca una alerta como atendida.
|
||||
|
||||
Args:
|
||||
alerta_id: ID de la alerta.
|
||||
usuario_id: ID del usuario que atiende.
|
||||
notas: Notas de atención (opcional).
|
||||
|
||||
Returns:
|
||||
Alerta actualizada o None si no existe.
|
||||
"""
|
||||
result = await self.db.execute(
|
||||
select(Alerta).where(Alerta.id == alerta_id)
|
||||
)
|
||||
alerta = result.scalar_one_or_none()
|
||||
|
||||
if not alerta:
|
||||
return None
|
||||
|
||||
alerta.atendida = True
|
||||
alerta.atendida_por_id = usuario_id
|
||||
alerta.atendida_en = datetime.now(timezone.utc)
|
||||
alerta.notas_atencion = notas
|
||||
|
||||
await self.db.commit()
|
||||
await self.db.refresh(alerta)
|
||||
|
||||
return alerta
|
||||
|
||||
async def obtener_alertas_pendientes(
|
||||
self,
|
||||
vehiculo_id: int = None,
|
||||
severidad: str = None,
|
||||
limite: int = 50,
|
||||
) -> List[Alerta]:
|
||||
"""
|
||||
Obtiene alertas pendientes de atender.
|
||||
|
||||
Args:
|
||||
vehiculo_id: Filtrar por vehículo (opcional).
|
||||
severidad: Filtrar por severidad (opcional).
|
||||
limite: Límite de resultados.
|
||||
|
||||
Returns:
|
||||
Lista de alertas pendientes.
|
||||
"""
|
||||
query = (
|
||||
select(Alerta)
|
||||
.where(Alerta.atendida == False)
|
||||
.order_by(
|
||||
Alerta.severidad.desc(), # Críticas primero
|
||||
Alerta.creado_en.desc()
|
||||
)
|
||||
.limit(limite)
|
||||
)
|
||||
|
||||
if vehiculo_id:
|
||||
query = query.where(Alerta.vehiculo_id == vehiculo_id)
|
||||
|
||||
if severidad:
|
||||
query = query.where(Alerta.severidad == severidad)
|
||||
|
||||
result = await self.db.execute(query)
|
||||
return result.scalars().all()
|
||||
|
||||
async def obtener_estadisticas(
|
||||
self,
|
||||
desde: datetime = None,
|
||||
hasta: datetime = None,
|
||||
) -> dict:
|
||||
"""
|
||||
Obtiene estadísticas de alertas.
|
||||
|
||||
Args:
|
||||
desde: Fecha inicio (opcional).
|
||||
hasta: Fecha fin (opcional).
|
||||
|
||||
Returns:
|
||||
Diccionario con estadísticas.
|
||||
"""
|
||||
desde = desde or (datetime.now(timezone.utc) - timedelta(days=30))
|
||||
hasta = hasta or datetime.now(timezone.utc)
|
||||
|
||||
# Total de alertas
|
||||
result = await self.db.execute(
|
||||
select(func.count(Alerta.id))
|
||||
.where(Alerta.creado_en >= desde)
|
||||
.where(Alerta.creado_en <= hasta)
|
||||
)
|
||||
total = result.scalar()
|
||||
|
||||
# Pendientes
|
||||
result = await self.db.execute(
|
||||
select(func.count(Alerta.id))
|
||||
.where(Alerta.atendida == False)
|
||||
.where(Alerta.creado_en >= desde)
|
||||
.where(Alerta.creado_en <= hasta)
|
||||
)
|
||||
pendientes = result.scalar()
|
||||
|
||||
# Por severidad
|
||||
result = await self.db.execute(
|
||||
select(Alerta.severidad, func.count(Alerta.id))
|
||||
.where(Alerta.creado_en >= desde)
|
||||
.where(Alerta.creado_en <= hasta)
|
||||
.group_by(Alerta.severidad)
|
||||
)
|
||||
por_severidad = {row[0]: row[1] for row in result.all()}
|
||||
|
||||
# Por tipo
|
||||
result = await self.db.execute(
|
||||
select(TipoAlerta.codigo, TipoAlerta.nombre, func.count(Alerta.id))
|
||||
.join(TipoAlerta, Alerta.tipo_alerta_id == TipoAlerta.id)
|
||||
.where(Alerta.creado_en >= desde)
|
||||
.where(Alerta.creado_en <= hasta)
|
||||
.group_by(TipoAlerta.codigo, TipoAlerta.nombre)
|
||||
.order_by(func.count(Alerta.id).desc())
|
||||
)
|
||||
por_tipo = [
|
||||
{"codigo": row[0], "nombre": row[1], "cantidad": row[2]}
|
||||
for row in result.all()
|
||||
]
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"pendientes": pendientes,
|
||||
"atendidas": total - pendientes,
|
||||
"criticas": por_severidad.get("critica", 0),
|
||||
"altas": por_severidad.get("alta", 0),
|
||||
"medias": por_severidad.get("media", 0),
|
||||
"bajas": por_severidad.get("baja", 0),
|
||||
"por_tipo": por_tipo,
|
||||
}
|
||||
351
backend/app/services/geocerca_service.py
Normal file
351
backend/app/services/geocerca_service.py
Normal file
@@ -0,0 +1,351 @@
|
||||
"""
|
||||
Servicio para gestión de geocercas.
|
||||
|
||||
Proporciona funcionalidades para verificar si un punto está dentro
|
||||
de una geocerca y calcular distancias.
|
||||
"""
|
||||
|
||||
import json
|
||||
import math
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.geocerca import Geocerca
|
||||
|
||||
|
||||
class GeocercaService:
|
||||
"""Servicio para operaciones con geocercas."""
|
||||
|
||||
def __init__(self, db: AsyncSession):
|
||||
"""
|
||||
Inicializa el servicio.
|
||||
|
||||
Args:
|
||||
db: Sesión de base de datos async.
|
||||
"""
|
||||
self.db = db
|
||||
|
||||
async def verificar_punto_en_geocerca(
|
||||
self,
|
||||
lat: float,
|
||||
lng: float,
|
||||
geocerca_id: int,
|
||||
) -> Tuple[bool, Optional[float]]:
|
||||
"""
|
||||
Verifica si un punto está dentro de una geocerca.
|
||||
|
||||
Args:
|
||||
lat: Latitud del punto.
|
||||
lng: Longitud del punto.
|
||||
geocerca_id: ID de la geocerca.
|
||||
|
||||
Returns:
|
||||
Tupla (está_dentro, distancia_al_borde_metros).
|
||||
distancia es None si está dentro, o la distancia al borde si está fuera.
|
||||
"""
|
||||
result = await self.db.execute(
|
||||
select(Geocerca).where(Geocerca.id == geocerca_id)
|
||||
)
|
||||
geocerca = result.scalar_one_or_none()
|
||||
|
||||
if not geocerca:
|
||||
return False, None
|
||||
|
||||
if geocerca.tipo == "circular":
|
||||
return self._punto_en_circulo(
|
||||
lat, lng,
|
||||
geocerca.centro_lat, geocerca.centro_lng,
|
||||
geocerca.radio_metros
|
||||
)
|
||||
else:
|
||||
coordenadas = json.loads(geocerca.coordenadas_json) if geocerca.coordenadas_json else []
|
||||
return self._punto_en_poligono(lat, lng, coordenadas)
|
||||
|
||||
async def obtener_geocercas_activas_para_vehiculo(
|
||||
self,
|
||||
vehiculo_id: int,
|
||||
) -> List[Geocerca]:
|
||||
"""
|
||||
Obtiene las geocercas activas aplicables a un vehículo.
|
||||
|
||||
Args:
|
||||
vehiculo_id: ID del vehículo.
|
||||
|
||||
Returns:
|
||||
Lista de geocercas aplicables.
|
||||
"""
|
||||
# Geocercas sin vehículos asignados (aplican a todos)
|
||||
# o con este vehículo asignado
|
||||
result = await self.db.execute(
|
||||
select(Geocerca)
|
||||
.where(Geocerca.activa == True)
|
||||
)
|
||||
todas_geocercas = result.scalars().all()
|
||||
|
||||
geocercas_aplicables = []
|
||||
for g in todas_geocercas:
|
||||
# Si no tiene vehículos asignados, aplica a todos
|
||||
if not g.vehiculos_asignados:
|
||||
geocercas_aplicables.append(g)
|
||||
# Si tiene vehículos asignados, verificar si incluye este
|
||||
elif any(v.id == vehiculo_id for v in g.vehiculos_asignados):
|
||||
geocercas_aplicables.append(g)
|
||||
|
||||
return geocercas_aplicables
|
||||
|
||||
async def verificar_todas_geocercas(
|
||||
self,
|
||||
lat: float,
|
||||
lng: float,
|
||||
vehiculo_id: int,
|
||||
) -> List[dict]:
|
||||
"""
|
||||
Verifica un punto contra todas las geocercas aplicables.
|
||||
|
||||
Args:
|
||||
lat: Latitud del punto.
|
||||
lng: Longitud del punto.
|
||||
vehiculo_id: ID del vehículo.
|
||||
|
||||
Returns:
|
||||
Lista de geocercas con información de si está dentro o fuera.
|
||||
"""
|
||||
geocercas = await self.obtener_geocercas_activas_para_vehiculo(vehiculo_id)
|
||||
resultados = []
|
||||
|
||||
for g in geocercas:
|
||||
if g.tipo == "circular":
|
||||
dentro, distancia = self._punto_en_circulo(
|
||||
lat, lng,
|
||||
g.centro_lat, g.centro_lng,
|
||||
g.radio_metros
|
||||
)
|
||||
else:
|
||||
coordenadas = json.loads(g.coordenadas_json) if g.coordenadas_json else []
|
||||
dentro, distancia = self._punto_en_poligono(lat, lng, coordenadas)
|
||||
|
||||
resultados.append({
|
||||
"geocerca_id": g.id,
|
||||
"geocerca_nombre": g.nombre,
|
||||
"dentro": dentro,
|
||||
"distancia_metros": distancia,
|
||||
"alerta_entrada": g.alerta_entrada,
|
||||
"alerta_salida": g.alerta_salida,
|
||||
"velocidad_maxima": g.velocidad_maxima,
|
||||
})
|
||||
|
||||
return resultados
|
||||
|
||||
def _punto_en_circulo(
|
||||
self,
|
||||
lat: float,
|
||||
lng: float,
|
||||
centro_lat: float,
|
||||
centro_lng: float,
|
||||
radio_metros: float,
|
||||
) -> Tuple[bool, Optional[float]]:
|
||||
"""
|
||||
Verifica si un punto está dentro de un círculo.
|
||||
|
||||
Args:
|
||||
lat, lng: Coordenadas del punto.
|
||||
centro_lat, centro_lng: Centro del círculo.
|
||||
radio_metros: Radio del círculo.
|
||||
|
||||
Returns:
|
||||
(está_dentro, distancia_al_borde).
|
||||
"""
|
||||
distancia = self._distancia_haversine(lat, lng, centro_lat, centro_lng)
|
||||
distancia_metros = distancia * 1000 # km a metros
|
||||
|
||||
dentro = distancia_metros <= radio_metros
|
||||
|
||||
if dentro:
|
||||
return True, None
|
||||
else:
|
||||
return False, distancia_metros - radio_metros
|
||||
|
||||
def _punto_en_poligono(
|
||||
self,
|
||||
lat: float,
|
||||
lng: float,
|
||||
coordenadas: List[List[float]],
|
||||
) -> Tuple[bool, Optional[float]]:
|
||||
"""
|
||||
Verifica si un punto está dentro de un polígono.
|
||||
|
||||
Usa el algoritmo ray casting.
|
||||
|
||||
Args:
|
||||
lat, lng: Coordenadas del punto.
|
||||
coordenadas: Lista de coordenadas [[lat, lng], ...].
|
||||
|
||||
Returns:
|
||||
(está_dentro, distancia_al_borde).
|
||||
"""
|
||||
if not coordenadas or len(coordenadas) < 3:
|
||||
return False, None
|
||||
|
||||
n = len(coordenadas)
|
||||
dentro = False
|
||||
|
||||
j = n - 1
|
||||
for i in range(n):
|
||||
yi, xi = coordenadas[i][0], coordenadas[i][1]
|
||||
yj, xj = coordenadas[j][0], coordenadas[j][1]
|
||||
|
||||
if ((yi > lat) != (yj > lat)) and (
|
||||
lng < (xj - xi) * (lat - yi) / (yj - yi) + xi
|
||||
):
|
||||
dentro = not dentro
|
||||
|
||||
j = i
|
||||
|
||||
if dentro:
|
||||
return True, None
|
||||
else:
|
||||
# Calcular distancia al borde más cercano
|
||||
distancia_min = float('inf')
|
||||
for i in range(n):
|
||||
j = (i + 1) % n
|
||||
d = self._distancia_punto_segmento(
|
||||
lat, lng,
|
||||
coordenadas[i][0], coordenadas[i][1],
|
||||
coordenadas[j][0], coordenadas[j][1]
|
||||
)
|
||||
if d < distancia_min:
|
||||
distancia_min = d
|
||||
|
||||
return False, distancia_min * 1000 # km a metros
|
||||
|
||||
def _distancia_haversine(
|
||||
self,
|
||||
lat1: float,
|
||||
lng1: float,
|
||||
lat2: float,
|
||||
lng2: float,
|
||||
) -> float:
|
||||
"""
|
||||
Calcula la distancia entre dos puntos usando Haversine.
|
||||
|
||||
Args:
|
||||
lat1, lng1: Primer punto.
|
||||
lat2, lng2: Segundo punto.
|
||||
|
||||
Returns:
|
||||
Distancia en kilómetros.
|
||||
"""
|
||||
R = 6371 # Radio de la Tierra en km
|
||||
|
||||
lat1_rad = math.radians(lat1)
|
||||
lat2_rad = math.radians(lat2)
|
||||
dlat = math.radians(lat2 - lat1)
|
||||
dlng = math.radians(lng2 - lng1)
|
||||
|
||||
a = (
|
||||
math.sin(dlat / 2) ** 2
|
||||
+ math.cos(lat1_rad) * math.cos(lat2_rad) * math.sin(dlng / 2) ** 2
|
||||
)
|
||||
c = 2 * math.asin(math.sqrt(a))
|
||||
|
||||
return R * c
|
||||
|
||||
def _distancia_punto_segmento(
|
||||
self,
|
||||
px: float,
|
||||
py: float,
|
||||
x1: float,
|
||||
y1: float,
|
||||
x2: float,
|
||||
y2: float,
|
||||
) -> float:
|
||||
"""
|
||||
Calcula la distancia de un punto a un segmento de línea.
|
||||
|
||||
Args:
|
||||
px, py: Punto.
|
||||
x1, y1, x2, y2: Extremos del segmento.
|
||||
|
||||
Returns:
|
||||
Distancia en kilómetros.
|
||||
"""
|
||||
# Longitud del segmento al cuadrado
|
||||
l2 = (x2 - x1) ** 2 + (y2 - y1) ** 2
|
||||
|
||||
if l2 == 0:
|
||||
# El segmento es un punto
|
||||
return self._distancia_haversine(px, py, x1, y1)
|
||||
|
||||
# Proyección del punto sobre la línea
|
||||
t = max(0, min(1, ((px - x1) * (x2 - x1) + (py - y1) * (y2 - y1)) / l2))
|
||||
|
||||
# Punto más cercano en el segmento
|
||||
proj_x = x1 + t * (x2 - x1)
|
||||
proj_y = y1 + t * (y2 - y1)
|
||||
|
||||
return self._distancia_haversine(px, py, proj_x, proj_y)
|
||||
|
||||
@staticmethod
|
||||
def calcular_area_poligono(coordenadas: List[List[float]]) -> float:
|
||||
"""
|
||||
Calcula el área de un polígono en metros cuadrados.
|
||||
|
||||
Args:
|
||||
coordenadas: Lista de coordenadas [[lat, lng], ...].
|
||||
|
||||
Returns:
|
||||
Área en metros cuadrados.
|
||||
"""
|
||||
if len(coordenadas) < 3:
|
||||
return 0.0
|
||||
|
||||
# Usar la fórmula del topógrafo (Shoelace) con conversión a metros
|
||||
n = len(coordenadas)
|
||||
area = 0.0
|
||||
|
||||
# Factor de conversión aproximado para grados a metros
|
||||
# (varía según la latitud)
|
||||
lat_media = sum(c[0] for c in coordenadas) / n
|
||||
m_per_deg_lat = 111132.92 - 559.82 * math.cos(2 * math.radians(lat_media))
|
||||
m_per_deg_lng = 111412.84 * math.cos(math.radians(lat_media))
|
||||
|
||||
for i in range(n):
|
||||
j = (i + 1) % n
|
||||
xi = coordenadas[i][1] * m_per_deg_lng
|
||||
yi = coordenadas[i][0] * m_per_deg_lat
|
||||
xj = coordenadas[j][1] * m_per_deg_lng
|
||||
yj = coordenadas[j][0] * m_per_deg_lat
|
||||
|
||||
area += xi * yj - xj * yi
|
||||
|
||||
return abs(area) / 2
|
||||
|
||||
@staticmethod
|
||||
def calcular_perimetro_poligono(coordenadas: List[List[float]]) -> float:
|
||||
"""
|
||||
Calcula el perímetro de un polígono en metros.
|
||||
|
||||
Args:
|
||||
coordenadas: Lista de coordenadas [[lat, lng], ...].
|
||||
|
||||
Returns:
|
||||
Perímetro en metros.
|
||||
"""
|
||||
if len(coordenadas) < 2:
|
||||
return 0.0
|
||||
|
||||
servicio = GeocercaService(None) # Solo para usar método estático
|
||||
perimetro = 0.0
|
||||
n = len(coordenadas)
|
||||
|
||||
for i in range(n):
|
||||
j = (i + 1) % n
|
||||
d = servicio._distancia_haversine(
|
||||
coordenadas[i][0], coordenadas[i][1],
|
||||
coordenadas[j][0], coordenadas[j][1]
|
||||
)
|
||||
perimetro += d * 1000 # km a metros
|
||||
|
||||
return perimetro
|
||||
348
backend/app/services/notificacion_service.py
Normal file
348
backend/app/services/notificacion_service.py
Normal file
@@ -0,0 +1,348 @@
|
||||
"""
|
||||
Servicio para envío de notificaciones.
|
||||
|
||||
Maneja el envío de notificaciones por email, push y SMS.
|
||||
"""
|
||||
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.text import MIMEText
|
||||
from typing import List, Optional
|
||||
|
||||
import aiosmtplib
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import settings
|
||||
from app.models.alerta import Alerta
|
||||
|
||||
|
||||
class NotificacionService:
|
||||
"""Servicio para envío de notificaciones."""
|
||||
|
||||
def __init__(self, db: AsyncSession = None):
|
||||
"""
|
||||
Inicializa el servicio.
|
||||
|
||||
Args:
|
||||
db: Sesión de base de datos async (opcional).
|
||||
"""
|
||||
self.db = db
|
||||
|
||||
async def enviar_notificacion_alerta(
|
||||
self,
|
||||
alerta: Alerta,
|
||||
destinatarios_email: List[str] = None,
|
||||
) -> dict:
|
||||
"""
|
||||
Envía notificaciones para una alerta.
|
||||
|
||||
Args:
|
||||
alerta: Alerta a notificar.
|
||||
destinatarios_email: Lista de emails (opcional, usa config si no se especifica).
|
||||
|
||||
Returns:
|
||||
Resultado del envío.
|
||||
"""
|
||||
resultado = {
|
||||
"email_enviado": False,
|
||||
"push_enviado": False,
|
||||
"sms_enviado": False,
|
||||
}
|
||||
|
||||
# Determinar si enviar cada tipo de notificación
|
||||
tipo_alerta = alerta.tipo_alerta
|
||||
|
||||
if tipo_alerta.notificar_email:
|
||||
resultado["email_enviado"] = await self.enviar_email_alerta(
|
||||
alerta,
|
||||
destinatarios_email,
|
||||
)
|
||||
|
||||
if tipo_alerta.notificar_push:
|
||||
resultado["push_enviado"] = await self.enviar_push_alerta(alerta)
|
||||
|
||||
if tipo_alerta.notificar_sms:
|
||||
resultado["sms_enviado"] = await self.enviar_sms_alerta(alerta)
|
||||
|
||||
# Actualizar estado de notificaciones en la alerta
|
||||
if self.db:
|
||||
alerta.notificacion_email_enviada = resultado["email_enviado"]
|
||||
alerta.notificacion_push_enviada = resultado["push_enviado"]
|
||||
alerta.notificacion_sms_enviada = resultado["sms_enviado"]
|
||||
await self.db.commit()
|
||||
|
||||
return resultado
|
||||
|
||||
async def enviar_email_alerta(
|
||||
self,
|
||||
alerta: Alerta,
|
||||
destinatarios: List[str] = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Envía notificación de alerta por email.
|
||||
|
||||
Args:
|
||||
alerta: Alerta a notificar.
|
||||
destinatarios: Lista de emails.
|
||||
|
||||
Returns:
|
||||
True si se envió correctamente.
|
||||
"""
|
||||
if not settings.SMTP_HOST or not settings.SMTP_USER:
|
||||
return False
|
||||
|
||||
destinatarios = destinatarios or [settings.SMTP_FROM_EMAIL]
|
||||
|
||||
# Crear mensaje
|
||||
mensaje = MIMEMultipart("alternative")
|
||||
mensaje["Subject"] = f"[{alerta.severidad.upper()}] {alerta.mensaje[:50]}"
|
||||
mensaje["From"] = f"{settings.SMTP_FROM_NAME} <{settings.SMTP_FROM_EMAIL}>"
|
||||
mensaje["To"] = ", ".join(destinatarios)
|
||||
|
||||
# Contenido HTML
|
||||
html_content = self._crear_html_alerta(alerta)
|
||||
mensaje.attach(MIMEText(html_content, "html"))
|
||||
|
||||
# Contenido texto plano
|
||||
text_content = self._crear_texto_alerta(alerta)
|
||||
mensaje.attach(MIMEText(text_content, "plain"))
|
||||
|
||||
try:
|
||||
async with aiosmtplib.SMTP(
|
||||
hostname=settings.SMTP_HOST,
|
||||
port=settings.SMTP_PORT,
|
||||
use_tls=settings.SMTP_TLS,
|
||||
) as smtp:
|
||||
await smtp.login(settings.SMTP_USER, settings.SMTP_PASSWORD)
|
||||
await smtp.send_message(mensaje)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error enviando email: {e}")
|
||||
return False
|
||||
|
||||
async def enviar_push_alerta(
|
||||
self,
|
||||
alerta: Alerta,
|
||||
) -> bool:
|
||||
"""
|
||||
Envía notificación push de alerta.
|
||||
|
||||
Args:
|
||||
alerta: Alerta a notificar.
|
||||
|
||||
Returns:
|
||||
True si se envió correctamente.
|
||||
"""
|
||||
if not settings.FIREBASE_ENABLED:
|
||||
return False
|
||||
|
||||
# TODO: Implementar con Firebase Cloud Messaging
|
||||
# from firebase_admin import messaging
|
||||
#
|
||||
# message = messaging.Message(
|
||||
# notification=messaging.Notification(
|
||||
# title=f"Alerta: {alerta.tipo_alerta.nombre}",
|
||||
# body=alerta.mensaje,
|
||||
# ),
|
||||
# topic="alertas",
|
||||
# )
|
||||
# messaging.send(message)
|
||||
|
||||
return False
|
||||
|
||||
async def enviar_sms_alerta(
|
||||
self,
|
||||
alerta: Alerta,
|
||||
) -> bool:
|
||||
"""
|
||||
Envía notificación SMS de alerta.
|
||||
|
||||
Args:
|
||||
alerta: Alerta a notificar.
|
||||
|
||||
Returns:
|
||||
True si se envió correctamente.
|
||||
"""
|
||||
# TODO: Implementar con Twilio u otro proveedor SMS
|
||||
return False
|
||||
|
||||
async def enviar_email(
|
||||
self,
|
||||
destinatarios: List[str],
|
||||
asunto: str,
|
||||
contenido_html: str,
|
||||
contenido_texto: str = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Envía un email genérico.
|
||||
|
||||
Args:
|
||||
destinatarios: Lista de emails.
|
||||
asunto: Asunto del email.
|
||||
contenido_html: Contenido HTML.
|
||||
contenido_texto: Contenido texto plano (opcional).
|
||||
|
||||
Returns:
|
||||
True si se envió correctamente.
|
||||
"""
|
||||
if not settings.SMTP_HOST or not settings.SMTP_USER:
|
||||
return False
|
||||
|
||||
mensaje = MIMEMultipart("alternative")
|
||||
mensaje["Subject"] = asunto
|
||||
mensaje["From"] = f"{settings.SMTP_FROM_NAME} <{settings.SMTP_FROM_EMAIL}>"
|
||||
mensaje["To"] = ", ".join(destinatarios)
|
||||
|
||||
mensaje.attach(MIMEText(contenido_html, "html"))
|
||||
if contenido_texto:
|
||||
mensaje.attach(MIMEText(contenido_texto, "plain"))
|
||||
|
||||
try:
|
||||
async with aiosmtplib.SMTP(
|
||||
hostname=settings.SMTP_HOST,
|
||||
port=settings.SMTP_PORT,
|
||||
use_tls=settings.SMTP_TLS,
|
||||
) as smtp:
|
||||
await smtp.login(settings.SMTP_USER, settings.SMTP_PASSWORD)
|
||||
await smtp.send_message(mensaje)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error enviando email: {e}")
|
||||
return False
|
||||
|
||||
def _crear_html_alerta(self, alerta: Alerta) -> str:
|
||||
"""Crea el contenido HTML para el email de alerta."""
|
||||
color_severidad = {
|
||||
"baja": "#10B981",
|
||||
"media": "#F59E0B",
|
||||
"alta": "#EF4444",
|
||||
"critica": "#DC2626",
|
||||
}
|
||||
|
||||
color = color_severidad.get(alerta.severidad, "#6B7280")
|
||||
|
||||
html = f"""
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<style>
|
||||
body {{ font-family: Arial, sans-serif; line-height: 1.6; }}
|
||||
.container {{ max-width: 600px; margin: 0 auto; padding: 20px; }}
|
||||
.header {{ background-color: {color}; color: white; padding: 20px; border-radius: 8px 8px 0 0; }}
|
||||
.content {{ background-color: #f9fafb; padding: 20px; border: 1px solid #e5e7eb; }}
|
||||
.footer {{ padding: 10px; text-align: center; color: #6b7280; font-size: 12px; }}
|
||||
.badge {{ display: inline-block; padding: 4px 8px; border-radius: 4px; font-size: 12px; }}
|
||||
.info-row {{ margin: 10px 0; }}
|
||||
.label {{ color: #6b7280; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h2 style="margin: 0;">Alerta: {alerta.tipo_alerta.nombre if alerta.tipo_alerta else 'Sistema'}</h2>
|
||||
<span class="badge" style="background-color: rgba(255,255,255,0.2);">
|
||||
{alerta.severidad.upper()}
|
||||
</span>
|
||||
</div>
|
||||
<div class="content">
|
||||
<p><strong>{alerta.mensaje}</strong></p>
|
||||
|
||||
<div class="info-row">
|
||||
<span class="label">Fecha/Hora:</span>
|
||||
{alerta.creado_en.strftime('%Y-%m-%d %H:%M:%S')}
|
||||
</div>
|
||||
|
||||
{'<div class="info-row"><span class="label">Vehiculo ID:</span> ' + str(alerta.vehiculo_id) + '</div>' if alerta.vehiculo_id else ''}
|
||||
|
||||
{'<div class="info-row"><span class="label">Ubicacion:</span> ' + str(alerta.lat) + ', ' + str(alerta.lng) + '</div>' if alerta.lat else ''}
|
||||
|
||||
{'<div class="info-row"><span class="label">Velocidad:</span> ' + str(alerta.velocidad) + ' km/h</div>' if alerta.velocidad else ''}
|
||||
|
||||
{f'<div class="info-row"><span class="label">Descripcion:</span> {alerta.descripcion}</div>' if alerta.descripcion else ''}
|
||||
</div>
|
||||
<div class="footer">
|
||||
<p>Este es un mensaje automatico de {settings.APP_NAME}</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
return html
|
||||
|
||||
def _crear_texto_alerta(self, alerta: Alerta) -> str:
|
||||
"""Crea el contenido de texto plano para el email de alerta."""
|
||||
texto = f"""
|
||||
ALERTA: {alerta.tipo_alerta.nombre if alerta.tipo_alerta else 'Sistema'}
|
||||
Severidad: {alerta.severidad.upper()}
|
||||
|
||||
{alerta.mensaje}
|
||||
|
||||
Fecha/Hora: {alerta.creado_en.strftime('%Y-%m-%d %H:%M:%S')}
|
||||
"""
|
||||
if alerta.vehiculo_id:
|
||||
texto += f"Vehiculo ID: {alerta.vehiculo_id}\n"
|
||||
if alerta.lat and alerta.lng:
|
||||
texto += f"Ubicacion: {alerta.lat}, {alerta.lng}\n"
|
||||
if alerta.velocidad:
|
||||
texto += f"Velocidad: {alerta.velocidad} km/h\n"
|
||||
|
||||
texto += f"\n--\nEste es un mensaje automatico de {settings.APP_NAME}"
|
||||
return texto
|
||||
|
||||
async def enviar_recordatorio_mantenimiento(
|
||||
self,
|
||||
vehiculo_nombre: str,
|
||||
vehiculo_placa: str,
|
||||
tipo_mantenimiento: str,
|
||||
fecha_programada: str,
|
||||
destinatarios: List[str],
|
||||
) -> bool:
|
||||
"""
|
||||
Envía recordatorio de mantenimiento por email.
|
||||
|
||||
Args:
|
||||
vehiculo_nombre: Nombre del vehículo.
|
||||
vehiculo_placa: Placa del vehículo.
|
||||
tipo_mantenimiento: Tipo de mantenimiento.
|
||||
fecha_programada: Fecha programada.
|
||||
destinatarios: Lista de emails.
|
||||
|
||||
Returns:
|
||||
True si se envió correctamente.
|
||||
"""
|
||||
asunto = f"Recordatorio: Mantenimiento próximo - {vehiculo_placa}"
|
||||
|
||||
html = f"""
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<style>
|
||||
body {{ font-family: Arial, sans-serif; line-height: 1.6; }}
|
||||
.container {{ max-width: 600px; margin: 0 auto; padding: 20px; }}
|
||||
.header {{ background-color: #3B82F6; color: white; padding: 20px; border-radius: 8px 8px 0 0; }}
|
||||
.content {{ background-color: #f9fafb; padding: 20px; border: 1px solid #e5e7eb; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h2>Recordatorio de Mantenimiento</h2>
|
||||
</div>
|
||||
<div class="content">
|
||||
<p>Se aproxima la fecha de mantenimiento programado:</p>
|
||||
<ul>
|
||||
<li><strong>Vehiculo:</strong> {vehiculo_nombre} ({vehiculo_placa})</li>
|
||||
<li><strong>Tipo:</strong> {tipo_mantenimiento}</li>
|
||||
<li><strong>Fecha programada:</strong> {fecha_programada}</li>
|
||||
</ul>
|
||||
<p>Por favor, programe el mantenimiento con anticipacion.</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
return await self.enviar_email(destinatarios, asunto, html)
|
||||
529
backend/app/services/reporte_service.py
Normal file
529
backend/app/services/reporte_service.py
Normal file
@@ -0,0 +1,529 @@
|
||||
"""
|
||||
Servicio para generación de reportes.
|
||||
|
||||
Genera reportes en PDF y Excel para diferentes tipos de datos.
|
||||
"""
|
||||
|
||||
import io
|
||||
import json
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from sqlalchemy import and_, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import settings
|
||||
from app.models.alerta import Alerta
|
||||
from app.models.carga_combustible import CargaCombustible
|
||||
from app.models.mantenimiento import Mantenimiento
|
||||
from app.models.ubicacion import Ubicacion
|
||||
from app.models.vehiculo import Vehiculo
|
||||
from app.models.viaje import Viaje
|
||||
from app.schemas.reporte import (
|
||||
DashboardGrafico,
|
||||
DashboardResumen,
|
||||
ReporteRequest,
|
||||
ReporteResponse,
|
||||
)
|
||||
|
||||
|
||||
class ReporteService:
|
||||
"""Servicio para generación de reportes."""
|
||||
|
||||
def __init__(self, db: AsyncSession):
|
||||
"""
|
||||
Inicializa el servicio.
|
||||
|
||||
Args:
|
||||
db: Sesión de base de datos async.
|
||||
"""
|
||||
self.db = db
|
||||
|
||||
async def obtener_dashboard_resumen(self) -> DashboardResumen:
|
||||
"""
|
||||
Obtiene el resumen para el dashboard principal.
|
||||
|
||||
Returns:
|
||||
Datos del dashboard.
|
||||
"""
|
||||
ahora = datetime.now(timezone.utc)
|
||||
inicio_hoy = ahora.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
|
||||
# Contadores de vehículos
|
||||
result = await self.db.execute(
|
||||
select(func.count(Vehiculo.id)).where(Vehiculo.activo == True)
|
||||
)
|
||||
total_vehiculos = result.scalar()
|
||||
|
||||
result = await self.db.execute(
|
||||
select(func.count(Vehiculo.id))
|
||||
.where(Vehiculo.activo == True)
|
||||
.where(Vehiculo.en_servicio == True)
|
||||
)
|
||||
vehiculos_activos = result.scalar()
|
||||
|
||||
# Vehículos en movimiento (velocidad > 5 km/h, última ubicación < 5 min)
|
||||
tiempo_reciente = ahora - timedelta(minutes=5)
|
||||
result = await self.db.execute(
|
||||
select(func.count(Vehiculo.id))
|
||||
.where(Vehiculo.activo == True)
|
||||
.where(Vehiculo.ultima_velocidad > 5)
|
||||
.where(Vehiculo.ultima_ubicacion_tiempo >= tiempo_reciente)
|
||||
)
|
||||
vehiculos_en_movimiento = result.scalar()
|
||||
|
||||
# Vehículos detenidos (velocidad < 5, ubicación reciente)
|
||||
result = await self.db.execute(
|
||||
select(func.count(Vehiculo.id))
|
||||
.where(Vehiculo.activo == True)
|
||||
.where(Vehiculo.ultima_velocidad <= 5)
|
||||
.where(Vehiculo.ultima_ubicacion_tiempo >= tiempo_reciente)
|
||||
)
|
||||
vehiculos_detenidos = result.scalar()
|
||||
|
||||
# Sin señal (última ubicación > 30 min)
|
||||
tiempo_sin_señal = ahora - timedelta(minutes=30)
|
||||
result = await self.db.execute(
|
||||
select(func.count(Vehiculo.id))
|
||||
.where(Vehiculo.activo == True)
|
||||
.where(Vehiculo.en_servicio == True)
|
||||
.where(Vehiculo.ultima_ubicacion_tiempo < tiempo_sin_señal)
|
||||
)
|
||||
vehiculos_sin_señal = result.scalar()
|
||||
|
||||
# Conductores (simplificado)
|
||||
from app.models.conductor import Conductor
|
||||
result = await self.db.execute(
|
||||
select(func.count(Conductor.id)).where(Conductor.activo == True)
|
||||
)
|
||||
conductores_activos = result.scalar()
|
||||
|
||||
# Alertas
|
||||
result = await self.db.execute(
|
||||
select(func.count(Alerta.id)).where(Alerta.atendida == False)
|
||||
)
|
||||
alertas_pendientes = result.scalar()
|
||||
|
||||
result = await self.db.execute(
|
||||
select(func.count(Alerta.id))
|
||||
.where(Alerta.atendida == False)
|
||||
.where(Alerta.severidad == "critica")
|
||||
)
|
||||
alertas_criticas = result.scalar()
|
||||
|
||||
result = await self.db.execute(
|
||||
select(func.count(Alerta.id)).where(Alerta.creado_en >= inicio_hoy)
|
||||
)
|
||||
alertas_hoy = result.scalar()
|
||||
|
||||
# Viajes de hoy
|
||||
result = await self.db.execute(
|
||||
select(func.count(Viaje.id)).where(Viaje.inicio_tiempo >= inicio_hoy)
|
||||
)
|
||||
viajes_hoy = result.scalar()
|
||||
|
||||
result = await self.db.execute(
|
||||
select(func.coalesce(func.sum(Viaje.distancia_km), 0))
|
||||
.where(Viaje.inicio_tiempo >= inicio_hoy)
|
||||
)
|
||||
distancia_hoy = result.scalar() or 0
|
||||
|
||||
# Mantenimientos
|
||||
result = await self.db.execute(
|
||||
select(func.count(Mantenimiento.id))
|
||||
.where(Mantenimiento.estado == "vencido")
|
||||
)
|
||||
mantenimientos_vencidos = result.scalar()
|
||||
|
||||
proximos_7_dias = ahora + timedelta(days=7)
|
||||
result = await self.db.execute(
|
||||
select(func.count(Mantenimiento.id))
|
||||
.where(Mantenimiento.estado == "programado")
|
||||
.where(Mantenimiento.fecha_programada <= proximos_7_dias.date())
|
||||
)
|
||||
mantenimientos_proximos = result.scalar()
|
||||
|
||||
return DashboardResumen(
|
||||
total_vehiculos=total_vehiculos,
|
||||
vehiculos_activos=vehiculos_activos,
|
||||
vehiculos_en_movimiento=vehiculos_en_movimiento,
|
||||
vehiculos_detenidos=vehiculos_detenidos,
|
||||
vehiculos_sin_señal=vehiculos_sin_señal,
|
||||
total_conductores=conductores_activos,
|
||||
conductores_activos=conductores_activos,
|
||||
alertas_pendientes=alertas_pendientes,
|
||||
alertas_criticas=alertas_criticas,
|
||||
alertas_hoy=alertas_hoy,
|
||||
viajes_hoy=viajes_hoy,
|
||||
distancia_hoy_km=float(distancia_hoy),
|
||||
mantenimientos_vencidos=mantenimientos_vencidos,
|
||||
mantenimientos_proximos=mantenimientos_proximos,
|
||||
actualizado_en=ahora,
|
||||
)
|
||||
|
||||
async def obtener_dashboard_graficos(self) -> DashboardGrafico:
|
||||
"""
|
||||
Obtiene datos para gráficos del dashboard.
|
||||
|
||||
Returns:
|
||||
Datos para gráficos.
|
||||
"""
|
||||
ahora = datetime.now(timezone.utc)
|
||||
|
||||
# Distancia por día (últimos 7 días)
|
||||
distancia_diaria = []
|
||||
for i in range(6, -1, -1):
|
||||
fecha = ahora - timedelta(days=i)
|
||||
inicio_dia = fecha.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
fin_dia = inicio_dia + timedelta(days=1)
|
||||
|
||||
result = await self.db.execute(
|
||||
select(func.coalesce(func.sum(Viaje.distancia_km), 0))
|
||||
.where(Viaje.inicio_tiempo >= inicio_dia)
|
||||
.where(Viaje.inicio_tiempo < fin_dia)
|
||||
)
|
||||
km = result.scalar() or 0
|
||||
|
||||
distancia_diaria.append({
|
||||
"fecha": inicio_dia.strftime("%Y-%m-%d"),
|
||||
"km": float(km),
|
||||
})
|
||||
|
||||
# Viajes por día
|
||||
viajes_diarios = []
|
||||
for i in range(6, -1, -1):
|
||||
fecha = ahora - timedelta(days=i)
|
||||
inicio_dia = fecha.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
fin_dia = inicio_dia + timedelta(days=1)
|
||||
|
||||
result = await self.db.execute(
|
||||
select(func.count(Viaje.id))
|
||||
.where(Viaje.inicio_tiempo >= inicio_dia)
|
||||
.where(Viaje.inicio_tiempo < fin_dia)
|
||||
)
|
||||
cantidad = result.scalar() or 0
|
||||
|
||||
viajes_diarios.append({
|
||||
"fecha": inicio_dia.strftime("%Y-%m-%d"),
|
||||
"cantidad": cantidad,
|
||||
})
|
||||
|
||||
# Alertas por tipo (últimos 7 días)
|
||||
inicio_semana = ahora - timedelta(days=7)
|
||||
from app.models.tipo_alerta import TipoAlerta
|
||||
result = await self.db.execute(
|
||||
select(TipoAlerta.nombre, func.count(Alerta.id))
|
||||
.join(TipoAlerta, Alerta.tipo_alerta_id == TipoAlerta.id)
|
||||
.where(Alerta.creado_en >= inicio_semana)
|
||||
.group_by(TipoAlerta.nombre)
|
||||
.order_by(func.count(Alerta.id).desc())
|
||||
.limit(5)
|
||||
)
|
||||
alertas_por_tipo = [
|
||||
{"tipo": row[0], "cantidad": row[1]}
|
||||
for row in result.all()
|
||||
]
|
||||
|
||||
# Consumo de combustible (últimos 30 días)
|
||||
consumo_combustible = []
|
||||
for i in range(29, -1, -1):
|
||||
fecha = ahora - timedelta(days=i)
|
||||
inicio_dia = fecha.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
fin_dia = inicio_dia + timedelta(days=1)
|
||||
|
||||
result = await self.db.execute(
|
||||
select(func.coalesce(func.sum(CargaCombustible.litros), 0))
|
||||
.where(CargaCombustible.fecha >= inicio_dia)
|
||||
.where(CargaCombustible.fecha < fin_dia)
|
||||
)
|
||||
litros = result.scalar() or 0
|
||||
|
||||
consumo_combustible.append({
|
||||
"fecha": inicio_dia.strftime("%Y-%m-%d"),
|
||||
"litros": float(litros),
|
||||
})
|
||||
|
||||
return DashboardGrafico(
|
||||
distancia_diaria=distancia_diaria,
|
||||
viajes_diarios=viajes_diarios,
|
||||
alertas_por_tipo=alertas_por_tipo,
|
||||
consumo_combustible=consumo_combustible,
|
||||
)
|
||||
|
||||
async def generar_reporte(
|
||||
self,
|
||||
request: ReporteRequest,
|
||||
) -> ReporteResponse:
|
||||
"""
|
||||
Genera un reporte según los parámetros especificados.
|
||||
|
||||
Args:
|
||||
request: Parámetros del reporte.
|
||||
|
||||
Returns:
|
||||
Información del reporte generado.
|
||||
"""
|
||||
reporte_id = str(uuid.uuid4())
|
||||
|
||||
# Recopilar datos según el tipo de reporte
|
||||
datos = await self._recopilar_datos_reporte(request)
|
||||
|
||||
# Generar archivo según formato
|
||||
if request.formato == "pdf":
|
||||
archivo_url = await self._generar_pdf(reporte_id, request.tipo, datos)
|
||||
elif request.formato == "excel":
|
||||
archivo_url = await self._generar_excel(reporte_id, request.tipo, datos)
|
||||
else: # csv
|
||||
archivo_url = await self._generar_csv(reporte_id, request.tipo, datos)
|
||||
|
||||
return ReporteResponse(
|
||||
id=reporte_id,
|
||||
tipo=request.tipo,
|
||||
formato=request.formato,
|
||||
estado="completado",
|
||||
archivo_url=archivo_url,
|
||||
creado_en=datetime.now(timezone.utc),
|
||||
completado_en=datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
async def _recopilar_datos_reporte(
|
||||
self,
|
||||
request: ReporteRequest,
|
||||
) -> Dict[str, Any]:
|
||||
"""Recopila los datos necesarios para el reporte."""
|
||||
datos = {
|
||||
"periodo_inicio": request.fecha_inicio,
|
||||
"periodo_fin": request.fecha_fin,
|
||||
}
|
||||
|
||||
if request.tipo == "viajes":
|
||||
datos["viajes"] = await self._obtener_datos_viajes(
|
||||
request.fecha_inicio,
|
||||
request.fecha_fin,
|
||||
request.vehiculos_ids,
|
||||
)
|
||||
elif request.tipo == "alertas":
|
||||
datos["alertas"] = await self._obtener_datos_alertas(
|
||||
request.fecha_inicio,
|
||||
request.fecha_fin,
|
||||
request.vehiculos_ids,
|
||||
)
|
||||
elif request.tipo == "combustible":
|
||||
datos["combustible"] = await self._obtener_datos_combustible(
|
||||
request.fecha_inicio,
|
||||
request.fecha_fin,
|
||||
request.vehiculos_ids,
|
||||
)
|
||||
elif request.tipo == "mantenimiento":
|
||||
datos["mantenimiento"] = await self._obtener_datos_mantenimiento(
|
||||
request.fecha_inicio,
|
||||
request.fecha_fin,
|
||||
request.vehiculos_ids,
|
||||
)
|
||||
|
||||
return datos
|
||||
|
||||
async def _obtener_datos_viajes(
|
||||
self,
|
||||
desde: datetime,
|
||||
hasta: datetime,
|
||||
vehiculos_ids: List[int] = None,
|
||||
) -> List[dict]:
|
||||
"""Obtiene datos de viajes para el reporte."""
|
||||
query = (
|
||||
select(Viaje)
|
||||
.where(Viaje.inicio_tiempo >= desde)
|
||||
.where(Viaje.inicio_tiempo <= hasta)
|
||||
.order_by(Viaje.inicio_tiempo)
|
||||
)
|
||||
|
||||
if vehiculos_ids:
|
||||
query = query.where(Viaje.vehiculo_id.in_(vehiculos_ids))
|
||||
|
||||
result = await self.db.execute(query)
|
||||
viajes = result.scalars().all()
|
||||
|
||||
return [
|
||||
{
|
||||
"id": v.id,
|
||||
"vehiculo_id": v.vehiculo_id,
|
||||
"inicio": v.inicio_tiempo.isoformat(),
|
||||
"fin": v.fin_tiempo.isoformat() if v.fin_tiempo else None,
|
||||
"distancia_km": v.distancia_km,
|
||||
"duracion_segundos": v.duracion_segundos,
|
||||
"velocidad_promedio": v.velocidad_promedio,
|
||||
"velocidad_maxima": v.velocidad_maxima,
|
||||
"estado": v.estado,
|
||||
}
|
||||
for v in viajes
|
||||
]
|
||||
|
||||
async def _obtener_datos_alertas(
|
||||
self,
|
||||
desde: datetime,
|
||||
hasta: datetime,
|
||||
vehiculos_ids: List[int] = None,
|
||||
) -> List[dict]:
|
||||
"""Obtiene datos de alertas para el reporte."""
|
||||
query = (
|
||||
select(Alerta)
|
||||
.where(Alerta.creado_en >= desde)
|
||||
.where(Alerta.creado_en <= hasta)
|
||||
.order_by(Alerta.creado_en)
|
||||
)
|
||||
|
||||
if vehiculos_ids:
|
||||
query = query.where(Alerta.vehiculo_id.in_(vehiculos_ids))
|
||||
|
||||
result = await self.db.execute(query)
|
||||
alertas = result.scalars().all()
|
||||
|
||||
return [
|
||||
{
|
||||
"id": a.id,
|
||||
"vehiculo_id": a.vehiculo_id,
|
||||
"tipo_alerta_id": a.tipo_alerta_id,
|
||||
"severidad": a.severidad,
|
||||
"mensaje": a.mensaje,
|
||||
"creado_en": a.creado_en.isoformat(),
|
||||
"atendida": a.atendida,
|
||||
}
|
||||
for a in alertas
|
||||
]
|
||||
|
||||
async def _obtener_datos_combustible(
|
||||
self,
|
||||
desde: datetime,
|
||||
hasta: datetime,
|
||||
vehiculos_ids: List[int] = None,
|
||||
) -> List[dict]:
|
||||
"""Obtiene datos de combustible para el reporte."""
|
||||
query = (
|
||||
select(CargaCombustible)
|
||||
.where(CargaCombustible.fecha >= desde)
|
||||
.where(CargaCombustible.fecha <= hasta)
|
||||
.order_by(CargaCombustible.fecha)
|
||||
)
|
||||
|
||||
if vehiculos_ids:
|
||||
query = query.where(CargaCombustible.vehiculo_id.in_(vehiculos_ids))
|
||||
|
||||
result = await self.db.execute(query)
|
||||
cargas = result.scalars().all()
|
||||
|
||||
return [
|
||||
{
|
||||
"id": c.id,
|
||||
"vehiculo_id": c.vehiculo_id,
|
||||
"fecha": c.fecha.isoformat(),
|
||||
"litros": c.litros,
|
||||
"precio_litro": c.precio_litro,
|
||||
"total": c.total,
|
||||
"odometro": c.odometro,
|
||||
"estacion": c.estacion,
|
||||
}
|
||||
for c in cargas
|
||||
]
|
||||
|
||||
async def _obtener_datos_mantenimiento(
|
||||
self,
|
||||
desde: datetime,
|
||||
hasta: datetime,
|
||||
vehiculos_ids: List[int] = None,
|
||||
) -> List[dict]:
|
||||
"""Obtiene datos de mantenimiento para el reporte."""
|
||||
query = (
|
||||
select(Mantenimiento)
|
||||
.where(Mantenimiento.fecha_programada >= desde.date())
|
||||
.where(Mantenimiento.fecha_programada <= hasta.date())
|
||||
.order_by(Mantenimiento.fecha_programada)
|
||||
)
|
||||
|
||||
if vehiculos_ids:
|
||||
query = query.where(Mantenimiento.vehiculo_id.in_(vehiculos_ids))
|
||||
|
||||
result = await self.db.execute(query)
|
||||
mantenimientos = result.scalars().all()
|
||||
|
||||
return [
|
||||
{
|
||||
"id": m.id,
|
||||
"vehiculo_id": m.vehiculo_id,
|
||||
"tipo_mantenimiento_id": m.tipo_mantenimiento_id,
|
||||
"estado": m.estado,
|
||||
"fecha_programada": m.fecha_programada.isoformat(),
|
||||
"fecha_realizada": m.fecha_realizada.isoformat() if m.fecha_realizada else None,
|
||||
"costo_real": m.costo_real,
|
||||
}
|
||||
for m in mantenimientos
|
||||
]
|
||||
|
||||
async def _generar_pdf(
|
||||
self,
|
||||
reporte_id: str,
|
||||
tipo: str,
|
||||
datos: Dict[str, Any],
|
||||
) -> str:
|
||||
"""Genera un reporte en PDF."""
|
||||
# Implementación simplificada
|
||||
# En producción se usaría WeasyPrint o similar
|
||||
archivo_path = f"{settings.REPORTS_DIR}/{reporte_id}.pdf"
|
||||
# TODO: Implementar generación de PDF con WeasyPrint
|
||||
return archivo_path
|
||||
|
||||
async def _generar_excel(
|
||||
self,
|
||||
reporte_id: str,
|
||||
tipo: str,
|
||||
datos: Dict[str, Any],
|
||||
) -> str:
|
||||
"""Genera un reporte en Excel."""
|
||||
try:
|
||||
from openpyxl import Workbook
|
||||
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = tipo.capitalize()
|
||||
|
||||
# Escribir datos según el tipo
|
||||
if tipo in datos:
|
||||
items = datos[tipo]
|
||||
if items:
|
||||
# Headers
|
||||
headers = list(items[0].keys())
|
||||
for col, header in enumerate(headers, 1):
|
||||
ws.cell(row=1, column=col, value=header)
|
||||
|
||||
# Data
|
||||
for row, item in enumerate(items, 2):
|
||||
for col, key in enumerate(headers, 1):
|
||||
ws.cell(row=row, column=col, value=item.get(key))
|
||||
|
||||
archivo_path = f"{settings.REPORTS_DIR}/{reporte_id}.xlsx"
|
||||
wb.save(archivo_path)
|
||||
return archivo_path
|
||||
|
||||
except ImportError:
|
||||
return ""
|
||||
|
||||
async def _generar_csv(
|
||||
self,
|
||||
reporte_id: str,
|
||||
tipo: str,
|
||||
datos: Dict[str, Any],
|
||||
) -> str:
|
||||
"""Genera un reporte en CSV."""
|
||||
import csv
|
||||
|
||||
archivo_path = f"{settings.REPORTS_DIR}/{reporte_id}.csv"
|
||||
|
||||
if tipo in datos:
|
||||
items = datos[tipo]
|
||||
if items:
|
||||
with open(archivo_path, 'w', newline='') as f:
|
||||
writer = csv.DictWriter(f, fieldnames=items[0].keys())
|
||||
writer.writeheader()
|
||||
writer.writerows(items)
|
||||
|
||||
return archivo_path
|
||||
286
backend/app/services/traccar_service.py
Normal file
286
backend/app/services/traccar_service.py
Normal file
@@ -0,0 +1,286 @@
|
||||
"""
|
||||
Servicio para integración con Traccar.
|
||||
|
||||
Recibe datos de ubicación desde Traccar via forward
|
||||
y los procesa en el sistema.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import settings
|
||||
from app.models.dispositivo import Dispositivo
|
||||
from app.schemas.ubicacion import TraccarLocationCreate, UbicacionCreate
|
||||
from app.services.ubicacion_service import UbicacionService
|
||||
|
||||
|
||||
class TraccarService:
|
||||
"""Servicio para integración con Traccar GPS Server."""
|
||||
|
||||
def __init__(self, db: AsyncSession):
|
||||
"""
|
||||
Inicializa el servicio.
|
||||
|
||||
Args:
|
||||
db: Sesión de base de datos async.
|
||||
"""
|
||||
self.db = db
|
||||
self.ubicacion_service = UbicacionService(db)
|
||||
self.api_url = settings.TRACCAR_API_URL
|
||||
self.username = settings.TRACCAR_USERNAME
|
||||
self.password = settings.TRACCAR_PASSWORD
|
||||
|
||||
async def procesar_posicion_traccar(
|
||||
self,
|
||||
posicion: TraccarLocationCreate,
|
||||
) -> Optional[dict]:
|
||||
"""
|
||||
Procesa una posición recibida desde Traccar.
|
||||
|
||||
Args:
|
||||
posicion: Datos de posición de Traccar.
|
||||
|
||||
Returns:
|
||||
Resultado del procesamiento o None.
|
||||
"""
|
||||
# Buscar dispositivo por ID de Traccar
|
||||
result = await self.db.execute(
|
||||
select(Dispositivo)
|
||||
.where(Dispositivo.identificador == str(posicion.deviceId))
|
||||
.where(Dispositivo.protocolo == "traccar")
|
||||
)
|
||||
dispositivo = result.scalar_one_or_none()
|
||||
|
||||
if not dispositivo:
|
||||
# Intentar buscar por IMEI en attributes
|
||||
if posicion.attributes and "imei" in posicion.attributes:
|
||||
result = await self.db.execute(
|
||||
select(Dispositivo)
|
||||
.where(Dispositivo.imei == posicion.attributes["imei"])
|
||||
)
|
||||
dispositivo = result.scalar_one_or_none()
|
||||
|
||||
if not dispositivo:
|
||||
return None
|
||||
|
||||
# Convertir velocidad de nudos a km/h
|
||||
velocidad = None
|
||||
if posicion.speed is not None:
|
||||
velocidad = posicion.speed * 1.852 # nudos a km/h
|
||||
|
||||
# Extraer datos adicionales de attributes
|
||||
bateria = None
|
||||
motor_encendido = None
|
||||
odometro = None
|
||||
|
||||
if posicion.attributes:
|
||||
bateria = posicion.attributes.get("batteryLevel")
|
||||
motor_encendido = posicion.attributes.get("ignition")
|
||||
# Odómetro puede venir en metros
|
||||
odometro_metros = posicion.attributes.get("totalDistance")
|
||||
if odometro_metros:
|
||||
odometro = odometro_metros / 1000 # a km
|
||||
|
||||
# Crear schema de ubicación
|
||||
ubicacion_data = UbicacionCreate(
|
||||
vehiculo_id=dispositivo.vehiculo_id,
|
||||
dispositivo_id=dispositivo.identificador,
|
||||
lat=posicion.latitude,
|
||||
lng=posicion.longitude,
|
||||
velocidad=velocidad,
|
||||
rumbo=posicion.course,
|
||||
altitud=posicion.altitude,
|
||||
precision=posicion.accuracy,
|
||||
tiempo=posicion.fixTime,
|
||||
fuente="traccar",
|
||||
bateria_dispositivo=bateria,
|
||||
motor_encendido=motor_encendido,
|
||||
odometro=odometro,
|
||||
)
|
||||
|
||||
# Procesar ubicación
|
||||
resultado = await self.ubicacion_service.procesar_ubicacion(ubicacion_data)
|
||||
|
||||
if resultado:
|
||||
return {
|
||||
"status": "processed",
|
||||
"vehiculo_id": dispositivo.vehiculo_id,
|
||||
"dispositivo_id": dispositivo.identificador,
|
||||
}
|
||||
|
||||
return None
|
||||
|
||||
async def sincronizar_dispositivos(self) -> dict:
|
||||
"""
|
||||
Sincroniza dispositivos desde Traccar.
|
||||
|
||||
Obtiene la lista de dispositivos de Traccar y los sincroniza
|
||||
con la base de datos local.
|
||||
|
||||
Returns:
|
||||
Resultado de la sincronización.
|
||||
"""
|
||||
if not self.username or not self.password:
|
||||
return {"error": "Credenciales de Traccar no configuradas"}
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(
|
||||
f"{self.api_url}/devices",
|
||||
auth=(self.username, self.password),
|
||||
timeout=30.0,
|
||||
)
|
||||
response.raise_for_status()
|
||||
dispositivos_traccar = response.json()
|
||||
except httpx.HTTPError as e:
|
||||
return {"error": f"Error conectando a Traccar: {str(e)}"}
|
||||
|
||||
sincronizados = 0
|
||||
for d in dispositivos_traccar:
|
||||
# Verificar si ya existe
|
||||
result = await self.db.execute(
|
||||
select(Dispositivo)
|
||||
.where(Dispositivo.identificador == str(d["id"]))
|
||||
.where(Dispositivo.protocolo == "traccar")
|
||||
)
|
||||
dispositivo = result.scalar_one_or_none()
|
||||
|
||||
if not dispositivo:
|
||||
# Solo registrar, no crear vehículo automáticamente
|
||||
continue
|
||||
|
||||
# Actualizar información
|
||||
dispositivo.nombre = d.get("name", dispositivo.nombre)
|
||||
if d.get("lastUpdate"):
|
||||
dispositivo.ultimo_contacto = datetime.fromisoformat(
|
||||
d["lastUpdate"].replace("Z", "+00:00")
|
||||
)
|
||||
dispositivo.conectado = d.get("status", "") == "online"
|
||||
|
||||
sincronizados += 1
|
||||
|
||||
await self.db.commit()
|
||||
|
||||
return {
|
||||
"total_traccar": len(dispositivos_traccar),
|
||||
"sincronizados": sincronizados,
|
||||
}
|
||||
|
||||
async def obtener_posicion_actual(
|
||||
self,
|
||||
dispositivo_id: str,
|
||||
) -> Optional[dict]:
|
||||
"""
|
||||
Obtiene la posición actual de un dispositivo desde Traccar.
|
||||
|
||||
Args:
|
||||
dispositivo_id: ID del dispositivo en Traccar.
|
||||
|
||||
Returns:
|
||||
Posición actual o None.
|
||||
"""
|
||||
if not self.username or not self.password:
|
||||
return None
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(
|
||||
f"{self.api_url}/positions",
|
||||
params={"deviceId": dispositivo_id},
|
||||
auth=(self.username, self.password),
|
||||
timeout=10.0,
|
||||
)
|
||||
response.raise_for_status()
|
||||
posiciones = response.json()
|
||||
|
||||
if posiciones:
|
||||
return posiciones[0]
|
||||
|
||||
except httpx.HTTPError:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
async def enviar_comando(
|
||||
self,
|
||||
dispositivo_id: str,
|
||||
tipo_comando: str,
|
||||
data: dict = None,
|
||||
) -> Optional[dict]:
|
||||
"""
|
||||
Envía un comando a un dispositivo via Traccar.
|
||||
|
||||
Args:
|
||||
dispositivo_id: ID del dispositivo en Traccar.
|
||||
tipo_comando: Tipo de comando (ej: "engineStop", "engineResume").
|
||||
data: Datos adicionales del comando.
|
||||
|
||||
Returns:
|
||||
Respuesta de Traccar o None.
|
||||
"""
|
||||
if not self.username or not self.password:
|
||||
return None
|
||||
|
||||
comando = {
|
||||
"deviceId": int(dispositivo_id),
|
||||
"type": tipo_comando,
|
||||
"attributes": data or {},
|
||||
}
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(
|
||||
f"{self.api_url}/commands/send",
|
||||
json=comando,
|
||||
auth=(self.username, self.password),
|
||||
timeout=30.0,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
except httpx.HTTPError as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
async def obtener_reportes_traccar(
|
||||
self,
|
||||
dispositivo_id: str,
|
||||
desde: datetime,
|
||||
hasta: datetime,
|
||||
tipo: str = "route",
|
||||
) -> Optional[list]:
|
||||
"""
|
||||
Obtiene reportes desde Traccar.
|
||||
|
||||
Args:
|
||||
dispositivo_id: ID del dispositivo.
|
||||
desde: Fecha inicio.
|
||||
hasta: Fecha fin.
|
||||
tipo: Tipo de reporte (route, events, trips, stops).
|
||||
|
||||
Returns:
|
||||
Lista de datos del reporte.
|
||||
"""
|
||||
if not self.username or not self.password:
|
||||
return None
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(
|
||||
f"{self.api_url}/reports/{tipo}",
|
||||
params={
|
||||
"deviceId": dispositivo_id,
|
||||
"from": desde.isoformat(),
|
||||
"to": hasta.isoformat(),
|
||||
},
|
||||
auth=(self.username, self.password),
|
||||
timeout=60.0,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
except httpx.HTTPError:
|
||||
return None
|
||||
489
backend/app/services/ubicacion_service.py
Normal file
489
backend/app/services/ubicacion_service.py
Normal file
@@ -0,0 +1,489 @@
|
||||
"""
|
||||
Servicio para procesamiento de ubicaciones GPS.
|
||||
|
||||
Maneja la recepción, procesamiento y análisis de datos de ubicación.
|
||||
"""
|
||||
|
||||
import json
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
from sqlalchemy import and_, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import settings
|
||||
from app.models.dispositivo import Dispositivo
|
||||
from app.models.ubicacion import Ubicacion
|
||||
from app.models.vehiculo import Vehiculo
|
||||
from app.schemas.ubicacion import (
|
||||
HistorialUbicacionesResponse,
|
||||
UbicacionCreate,
|
||||
UbicacionResponse,
|
||||
)
|
||||
|
||||
|
||||
class UbicacionService:
|
||||
"""Servicio para gestión de ubicaciones GPS."""
|
||||
|
||||
def __init__(self, db: AsyncSession):
|
||||
"""
|
||||
Inicializa el servicio.
|
||||
|
||||
Args:
|
||||
db: Sesión de base de datos async.
|
||||
"""
|
||||
self.db = db
|
||||
|
||||
async def procesar_ubicacion(
|
||||
self,
|
||||
ubicacion_data: UbicacionCreate,
|
||||
) -> Optional[UbicacionResponse]:
|
||||
"""
|
||||
Procesa una nueva ubicación recibida.
|
||||
|
||||
Args:
|
||||
ubicacion_data: Datos de la ubicación a procesar.
|
||||
|
||||
Returns:
|
||||
UbicacionResponse si se procesó correctamente, None si se descartó.
|
||||
"""
|
||||
# Determinar el vehículo
|
||||
vehiculo_id = ubicacion_data.vehiculo_id
|
||||
|
||||
if not vehiculo_id and ubicacion_data.dispositivo_id:
|
||||
# Buscar vehículo por identificador de dispositivo
|
||||
result = await self.db.execute(
|
||||
select(Dispositivo)
|
||||
.where(Dispositivo.identificador == ubicacion_data.dispositivo_id)
|
||||
.where(Dispositivo.activo == True)
|
||||
)
|
||||
dispositivo = result.scalar_one_or_none()
|
||||
if dispositivo:
|
||||
vehiculo_id = dispositivo.vehiculo_id
|
||||
|
||||
# Actualizar último contacto del dispositivo
|
||||
dispositivo.ultimo_contacto = datetime.now(timezone.utc)
|
||||
dispositivo.conectado = True
|
||||
if ubicacion_data.bateria_dispositivo:
|
||||
dispositivo.bateria = ubicacion_data.bateria_dispositivo
|
||||
if ubicacion_data.satelites:
|
||||
dispositivo.satelites = ubicacion_data.satelites
|
||||
|
||||
if not vehiculo_id:
|
||||
return None
|
||||
|
||||
# Usar timestamp del servidor si no viene
|
||||
tiempo = ubicacion_data.tiempo or datetime.now(timezone.utc)
|
||||
|
||||
# Crear registro de ubicación
|
||||
ubicacion = Ubicacion(
|
||||
tiempo=tiempo,
|
||||
vehiculo_id=vehiculo_id,
|
||||
lat=ubicacion_data.lat,
|
||||
lng=ubicacion_data.lng,
|
||||
velocidad=ubicacion_data.velocidad,
|
||||
rumbo=ubicacion_data.rumbo,
|
||||
altitud=ubicacion_data.altitud,
|
||||
precision=ubicacion_data.precision,
|
||||
hdop=ubicacion_data.hdop,
|
||||
satelites=ubicacion_data.satelites,
|
||||
fuente=ubicacion_data.fuente,
|
||||
bateria_dispositivo=ubicacion_data.bateria_dispositivo,
|
||||
bateria_vehiculo=ubicacion_data.bateria_vehiculo,
|
||||
motor_encendido=ubicacion_data.motor_encendido,
|
||||
odometro=ubicacion_data.odometro,
|
||||
rpm=ubicacion_data.rpm,
|
||||
temperatura_motor=ubicacion_data.temperatura_motor,
|
||||
nivel_combustible=ubicacion_data.nivel_combustible,
|
||||
)
|
||||
|
||||
self.db.add(ubicacion)
|
||||
|
||||
# Actualizar última ubicación conocida del vehículo
|
||||
result = await self.db.execute(
|
||||
select(Vehiculo).where(Vehiculo.id == vehiculo_id)
|
||||
)
|
||||
vehiculo = result.scalar_one_or_none()
|
||||
|
||||
if vehiculo:
|
||||
vehiculo.ultima_lat = ubicacion_data.lat
|
||||
vehiculo.ultima_lng = ubicacion_data.lng
|
||||
vehiculo.ultima_velocidad = ubicacion_data.velocidad
|
||||
vehiculo.ultimo_rumbo = ubicacion_data.rumbo
|
||||
vehiculo.ultima_ubicacion_tiempo = tiempo
|
||||
vehiculo.motor_encendido = ubicacion_data.motor_encendido
|
||||
|
||||
if ubicacion_data.odometro:
|
||||
vehiculo.odometro_actual = ubicacion_data.odometro
|
||||
|
||||
await self.db.commit()
|
||||
|
||||
return UbicacionResponse(
|
||||
tiempo=ubicacion.tiempo,
|
||||
vehiculo_id=ubicacion.vehiculo_id,
|
||||
lat=ubicacion.lat,
|
||||
lng=ubicacion.lng,
|
||||
velocidad=ubicacion.velocidad,
|
||||
rumbo=ubicacion.rumbo,
|
||||
altitud=ubicacion.altitud,
|
||||
precision=ubicacion.precision,
|
||||
satelites=ubicacion.satelites,
|
||||
fuente=ubicacion.fuente,
|
||||
bateria_dispositivo=ubicacion.bateria_dispositivo,
|
||||
motor_encendido=ubicacion.motor_encendido,
|
||||
odometro=ubicacion.odometro,
|
||||
)
|
||||
|
||||
async def obtener_historial(
|
||||
self,
|
||||
vehiculo_id: int,
|
||||
desde: datetime,
|
||||
hasta: datetime,
|
||||
simplificar: bool = True,
|
||||
intervalo_segundos: Optional[int] = None,
|
||||
) -> HistorialUbicacionesResponse:
|
||||
"""
|
||||
Obtiene el historial de ubicaciones de un vehículo.
|
||||
|
||||
Args:
|
||||
vehiculo_id: ID del vehículo.
|
||||
desde: Fecha/hora de inicio.
|
||||
hasta: Fecha/hora de fin.
|
||||
simplificar: Si simplificar la ruta (Douglas-Peucker).
|
||||
intervalo_segundos: Intervalo de muestreo opcional.
|
||||
|
||||
Returns:
|
||||
Historial de ubicaciones con estadísticas.
|
||||
"""
|
||||
query = (
|
||||
select(Ubicacion)
|
||||
.where(
|
||||
and_(
|
||||
Ubicacion.vehiculo_id == vehiculo_id,
|
||||
Ubicacion.tiempo >= desde,
|
||||
Ubicacion.tiempo <= hasta,
|
||||
)
|
||||
)
|
||||
.order_by(Ubicacion.tiempo)
|
||||
)
|
||||
|
||||
result = await self.db.execute(query)
|
||||
ubicaciones = result.scalars().all()
|
||||
|
||||
# Aplicar muestreo por intervalo si se especifica
|
||||
if intervalo_segundos and ubicaciones:
|
||||
ubicaciones = self._muestrear_por_intervalo(
|
||||
ubicaciones, intervalo_segundos
|
||||
)
|
||||
|
||||
# Calcular estadísticas
|
||||
distancia_km = self._calcular_distancia_total(ubicaciones)
|
||||
tiempo_movimiento = self._calcular_tiempo_movimiento(ubicaciones)
|
||||
velocidad_promedio = None
|
||||
velocidad_maxima = None
|
||||
|
||||
if ubicaciones:
|
||||
velocidades = [u.velocidad for u in ubicaciones if u.velocidad]
|
||||
if velocidades:
|
||||
velocidad_promedio = sum(velocidades) / len(velocidades)
|
||||
velocidad_maxima = max(velocidades)
|
||||
|
||||
# Simplificar ruta si se solicita
|
||||
if simplificar and len(ubicaciones) > 100:
|
||||
ubicaciones = self._simplificar_ruta(ubicaciones, epsilon=0.0001)
|
||||
|
||||
ubicaciones_response = [
|
||||
UbicacionResponse(
|
||||
tiempo=u.tiempo,
|
||||
vehiculo_id=u.vehiculo_id,
|
||||
lat=u.lat,
|
||||
lng=u.lng,
|
||||
velocidad=u.velocidad,
|
||||
rumbo=u.rumbo,
|
||||
altitud=u.altitud,
|
||||
precision=u.precision,
|
||||
satelites=u.satelites,
|
||||
fuente=u.fuente,
|
||||
bateria_dispositivo=u.bateria_dispositivo,
|
||||
motor_encendido=u.motor_encendido,
|
||||
odometro=u.odometro,
|
||||
)
|
||||
for u in ubicaciones
|
||||
]
|
||||
|
||||
return HistorialUbicacionesResponse(
|
||||
vehiculo_id=vehiculo_id,
|
||||
desde=desde,
|
||||
hasta=hasta,
|
||||
total_puntos=len(ubicaciones_response),
|
||||
distancia_km=distancia_km,
|
||||
tiempo_movimiento_segundos=tiempo_movimiento,
|
||||
velocidad_promedio=velocidad_promedio,
|
||||
velocidad_maxima=velocidad_maxima,
|
||||
ubicaciones=ubicaciones_response,
|
||||
)
|
||||
|
||||
async def obtener_ultima_ubicacion(
|
||||
self,
|
||||
vehiculo_id: int,
|
||||
) -> Optional[UbicacionResponse]:
|
||||
"""
|
||||
Obtiene la última ubicación conocida de un vehículo.
|
||||
|
||||
Args:
|
||||
vehiculo_id: ID del vehículo.
|
||||
|
||||
Returns:
|
||||
Última ubicación o None.
|
||||
"""
|
||||
result = await self.db.execute(
|
||||
select(Ubicacion)
|
||||
.where(Ubicacion.vehiculo_id == vehiculo_id)
|
||||
.order_by(Ubicacion.tiempo.desc())
|
||||
.limit(1)
|
||||
)
|
||||
ubicacion = result.scalar_one_or_none()
|
||||
|
||||
if not ubicacion:
|
||||
return None
|
||||
|
||||
return UbicacionResponse(
|
||||
tiempo=ubicacion.tiempo,
|
||||
vehiculo_id=ubicacion.vehiculo_id,
|
||||
lat=ubicacion.lat,
|
||||
lng=ubicacion.lng,
|
||||
velocidad=ubicacion.velocidad,
|
||||
rumbo=ubicacion.rumbo,
|
||||
altitud=ubicacion.altitud,
|
||||
precision=ubicacion.precision,
|
||||
satelites=ubicacion.satelites,
|
||||
fuente=ubicacion.fuente,
|
||||
bateria_dispositivo=ubicacion.bateria_dispositivo,
|
||||
motor_encendido=ubicacion.motor_encendido,
|
||||
odometro=ubicacion.odometro,
|
||||
)
|
||||
|
||||
async def obtener_ubicaciones_flota(
|
||||
self,
|
||||
) -> List[dict]:
|
||||
"""
|
||||
Obtiene las últimas ubicaciones de todos los vehículos activos.
|
||||
|
||||
Returns:
|
||||
Lista de ubicaciones actuales de la flota.
|
||||
"""
|
||||
result = await self.db.execute(
|
||||
select(Vehiculo)
|
||||
.where(Vehiculo.activo == True)
|
||||
.where(Vehiculo.ultima_lat.isnot(None))
|
||||
)
|
||||
vehiculos = result.scalars().all()
|
||||
|
||||
ubicaciones = []
|
||||
for v in vehiculos:
|
||||
# Determinar si está en movimiento
|
||||
en_movimiento = False
|
||||
if v.ultima_velocidad and v.ultima_velocidad > 5:
|
||||
en_movimiento = True
|
||||
|
||||
ubicaciones.append({
|
||||
"id": v.id,
|
||||
"nombre": v.nombre,
|
||||
"placa": v.placa,
|
||||
"color_marcador": v.color_marcador,
|
||||
"icono": v.icono,
|
||||
"lat": v.ultima_lat,
|
||||
"lng": v.ultima_lng,
|
||||
"velocidad": v.ultima_velocidad,
|
||||
"rumbo": v.ultimo_rumbo,
|
||||
"tiempo": v.ultima_ubicacion_tiempo,
|
||||
"motor_encendido": v.motor_encendido,
|
||||
"en_movimiento": en_movimiento,
|
||||
"conductor_nombre": v.conductor.nombre_completo if v.conductor else None,
|
||||
})
|
||||
|
||||
return ubicaciones
|
||||
|
||||
def _calcular_distancia_total(
|
||||
self,
|
||||
ubicaciones: List[Ubicacion],
|
||||
) -> float:
|
||||
"""
|
||||
Calcula la distancia total recorrida entre ubicaciones.
|
||||
|
||||
Usa la fórmula de Haversine para calcular distancias.
|
||||
|
||||
Args:
|
||||
ubicaciones: Lista de ubicaciones ordenadas por tiempo.
|
||||
|
||||
Returns:
|
||||
Distancia total en kilómetros.
|
||||
"""
|
||||
if len(ubicaciones) < 2:
|
||||
return 0.0
|
||||
|
||||
import math
|
||||
|
||||
total_km = 0.0
|
||||
for i in range(1, len(ubicaciones)):
|
||||
lat1 = math.radians(ubicaciones[i - 1].lat)
|
||||
lat2 = math.radians(ubicaciones[i].lat)
|
||||
dlat = lat2 - lat1
|
||||
dlon = math.radians(ubicaciones[i].lng - ubicaciones[i - 1].lng)
|
||||
|
||||
a = (
|
||||
math.sin(dlat / 2) ** 2
|
||||
+ math.cos(lat1) * math.cos(lat2) * math.sin(dlon / 2) ** 2
|
||||
)
|
||||
c = 2 * math.asin(math.sqrt(a))
|
||||
r = 6371 # Radio de la Tierra en km
|
||||
|
||||
total_km += r * c
|
||||
|
||||
return round(total_km, 2)
|
||||
|
||||
def _calcular_tiempo_movimiento(
|
||||
self,
|
||||
ubicaciones: List[Ubicacion],
|
||||
) -> int:
|
||||
"""
|
||||
Calcula el tiempo en movimiento (velocidad > 5 km/h).
|
||||
|
||||
Args:
|
||||
ubicaciones: Lista de ubicaciones ordenadas.
|
||||
|
||||
Returns:
|
||||
Tiempo en movimiento en segundos.
|
||||
"""
|
||||
if len(ubicaciones) < 2:
|
||||
return 0
|
||||
|
||||
tiempo_total = 0
|
||||
for i in range(1, len(ubicaciones)):
|
||||
if (
|
||||
ubicaciones[i - 1].velocidad
|
||||
and ubicaciones[i - 1].velocidad > 5
|
||||
):
|
||||
delta = (
|
||||
ubicaciones[i].tiempo - ubicaciones[i - 1].tiempo
|
||||
).total_seconds()
|
||||
tiempo_total += delta
|
||||
|
||||
return int(tiempo_total)
|
||||
|
||||
def _muestrear_por_intervalo(
|
||||
self,
|
||||
ubicaciones: List[Ubicacion],
|
||||
intervalo_segundos: int,
|
||||
) -> List[Ubicacion]:
|
||||
"""
|
||||
Muestrea ubicaciones por intervalo de tiempo.
|
||||
|
||||
Args:
|
||||
ubicaciones: Lista de ubicaciones.
|
||||
intervalo_segundos: Intervalo de muestreo.
|
||||
|
||||
Returns:
|
||||
Lista filtrada de ubicaciones.
|
||||
"""
|
||||
if not ubicaciones:
|
||||
return []
|
||||
|
||||
resultado = [ubicaciones[0]]
|
||||
ultimo_tiempo = ubicaciones[0].tiempo
|
||||
|
||||
for u in ubicaciones[1:]:
|
||||
delta = (u.tiempo - ultimo_tiempo).total_seconds()
|
||||
if delta >= intervalo_segundos:
|
||||
resultado.append(u)
|
||||
ultimo_tiempo = u.tiempo
|
||||
|
||||
# Siempre incluir el último punto
|
||||
if resultado[-1] != ubicaciones[-1]:
|
||||
resultado.append(ubicaciones[-1])
|
||||
|
||||
return resultado
|
||||
|
||||
def _simplificar_ruta(
|
||||
self,
|
||||
ubicaciones: List[Ubicacion],
|
||||
epsilon: float = 0.0001,
|
||||
) -> List[Ubicacion]:
|
||||
"""
|
||||
Simplifica la ruta usando el algoritmo Douglas-Peucker.
|
||||
|
||||
Args:
|
||||
ubicaciones: Lista de ubicaciones.
|
||||
epsilon: Tolerancia de simplificación.
|
||||
|
||||
Returns:
|
||||
Lista simplificada de ubicaciones.
|
||||
"""
|
||||
if len(ubicaciones) <= 2:
|
||||
return ubicaciones
|
||||
|
||||
# Convertir a lista de puntos
|
||||
points = [(u.lat, u.lng, u) for u in ubicaciones]
|
||||
|
||||
# Douglas-Peucker
|
||||
simplified = self._douglas_peucker(points, epsilon)
|
||||
|
||||
return [p[2] for p in simplified]
|
||||
|
||||
def _douglas_peucker(
|
||||
self,
|
||||
points: List[Tuple],
|
||||
epsilon: float,
|
||||
) -> List[Tuple]:
|
||||
"""Implementación del algoritmo Douglas-Peucker."""
|
||||
if len(points) <= 2:
|
||||
return points
|
||||
|
||||
# Encontrar el punto más lejano de la línea
|
||||
dmax = 0
|
||||
index = 0
|
||||
end = len(points) - 1
|
||||
|
||||
for i in range(1, end):
|
||||
d = self._perpendicular_distance(
|
||||
points[i], points[0], points[end]
|
||||
)
|
||||
if d > dmax:
|
||||
index = i
|
||||
dmax = d
|
||||
|
||||
# Si la distancia máxima es mayor que epsilon, simplificar recursivamente
|
||||
if dmax > epsilon:
|
||||
# Dividir en dos segmentos
|
||||
rec1 = self._douglas_peucker(points[: index + 1], epsilon)
|
||||
rec2 = self._douglas_peucker(points[index:], epsilon)
|
||||
|
||||
# Combinar (evitar duplicar el punto medio)
|
||||
return rec1[:-1] + rec2
|
||||
else:
|
||||
return [points[0], points[end]]
|
||||
|
||||
def _perpendicular_distance(
|
||||
self,
|
||||
point: Tuple,
|
||||
line_start: Tuple,
|
||||
line_end: Tuple,
|
||||
) -> float:
|
||||
"""Calcula la distancia perpendicular de un punto a una línea."""
|
||||
import math
|
||||
|
||||
x, y = point[0], point[1]
|
||||
x1, y1 = line_start[0], line_start[1]
|
||||
x2, y2 = line_end[0], line_end[1]
|
||||
|
||||
# Caso especial: línea de longitud cero
|
||||
dx = x2 - x1
|
||||
dy = y2 - y1
|
||||
if dx == 0 and dy == 0:
|
||||
return math.sqrt((x - x1) ** 2 + (y - y1) ** 2)
|
||||
|
||||
# Distancia perpendicular
|
||||
numerator = abs(dy * x - dx * y + x2 * y1 - y2 * x1)
|
||||
denominator = math.sqrt(dx ** 2 + dy ** 2)
|
||||
|
||||
return numerator / denominator
|
||||
405
backend/app/services/viaje_service.py
Normal file
405
backend/app/services/viaje_service.py
Normal file
@@ -0,0 +1,405 @@
|
||||
"""
|
||||
Servicio para gestión automática de viajes.
|
||||
|
||||
Detecta automáticamente el inicio y fin de viajes basándose
|
||||
en el movimiento del vehículo.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import List, Optional
|
||||
|
||||
from sqlalchemy import and_, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import settings
|
||||
from app.models.parada import Parada
|
||||
from app.models.ubicacion import Ubicacion
|
||||
from app.models.vehiculo import Vehiculo
|
||||
from app.models.viaje import Viaje
|
||||
from app.schemas.viaje import ViajeResponse
|
||||
|
||||
|
||||
class ViajeService:
|
||||
"""Servicio para detección y gestión de viajes."""
|
||||
|
||||
# Configuración de detección
|
||||
VELOCIDAD_MINIMA_MOVIMIENTO = 5 # km/h
|
||||
MINUTOS_PARADA_FIN_VIAJE = 5 # minutos para considerar fin de viaje
|
||||
SEGUNDOS_MINIMOS_PARADA = 120 # segundos mínimos para registrar parada
|
||||
|
||||
def __init__(self, db: AsyncSession):
|
||||
"""
|
||||
Inicializa el servicio.
|
||||
|
||||
Args:
|
||||
db: Sesión de base de datos async.
|
||||
"""
|
||||
self.db = db
|
||||
|
||||
async def procesar_ubicacion_viaje(
|
||||
self,
|
||||
vehiculo_id: int,
|
||||
lat: float,
|
||||
lng: float,
|
||||
velocidad: float,
|
||||
tiempo: datetime,
|
||||
) -> Optional[dict]:
|
||||
"""
|
||||
Procesa una ubicación para detección de viajes.
|
||||
|
||||
Args:
|
||||
vehiculo_id: ID del vehículo.
|
||||
lat: Latitud.
|
||||
lng: Longitud.
|
||||
velocidad: Velocidad en km/h.
|
||||
tiempo: Timestamp de la ubicación.
|
||||
|
||||
Returns:
|
||||
Dict con información del evento de viaje si hubo cambio.
|
||||
"""
|
||||
# Obtener viaje en curso
|
||||
viaje_activo = await self._obtener_viaje_activo(vehiculo_id)
|
||||
en_movimiento = velocidad >= self.VELOCIDAD_MINIMA_MOVIMIENTO
|
||||
|
||||
if not viaje_activo:
|
||||
# No hay viaje activo
|
||||
if en_movimiento:
|
||||
# Iniciar nuevo viaje
|
||||
viaje = await self._iniciar_viaje(vehiculo_id, lat, lng, tiempo)
|
||||
return {
|
||||
"evento": "viaje_iniciado",
|
||||
"viaje_id": viaje.id,
|
||||
"vehiculo_id": vehiculo_id,
|
||||
}
|
||||
else:
|
||||
# Hay viaje activo
|
||||
if en_movimiento:
|
||||
# Actualizar viaje (incrementar puntos GPS)
|
||||
viaje_activo.puntos_gps += 1
|
||||
|
||||
# Verificar si había parada en curso y cerrarla
|
||||
await self._cerrar_parada_en_curso(viaje_activo.id, vehiculo_id, tiempo)
|
||||
|
||||
await self.db.commit()
|
||||
else:
|
||||
# Vehículo detenido
|
||||
resultado = await self._procesar_parada(
|
||||
viaje_activo, vehiculo_id, lat, lng, tiempo
|
||||
)
|
||||
if resultado:
|
||||
return resultado
|
||||
|
||||
return None
|
||||
|
||||
async def _obtener_viaje_activo(
|
||||
self,
|
||||
vehiculo_id: int,
|
||||
) -> Optional[Viaje]:
|
||||
"""Obtiene el viaje activo de un vehículo."""
|
||||
result = await self.db.execute(
|
||||
select(Viaje)
|
||||
.where(Viaje.vehiculo_id == vehiculo_id)
|
||||
.where(Viaje.estado == "en_curso")
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def _iniciar_viaje(
|
||||
self,
|
||||
vehiculo_id: int,
|
||||
lat: float,
|
||||
lng: float,
|
||||
tiempo: datetime,
|
||||
) -> Viaje:
|
||||
"""Inicia un nuevo viaje."""
|
||||
# Obtener conductor asignado
|
||||
result = await self.db.execute(
|
||||
select(Vehiculo).where(Vehiculo.id == vehiculo_id)
|
||||
)
|
||||
vehiculo = result.scalar_one_or_none()
|
||||
conductor_id = vehiculo.conductor_id if vehiculo else None
|
||||
|
||||
# Obtener odómetro actual
|
||||
odometro_inicio = vehiculo.odometro_actual if vehiculo else None
|
||||
|
||||
viaje = Viaje(
|
||||
vehiculo_id=vehiculo_id,
|
||||
conductor_id=conductor_id,
|
||||
inicio_tiempo=tiempo,
|
||||
inicio_lat=lat,
|
||||
inicio_lng=lng,
|
||||
odometro_inicio=odometro_inicio,
|
||||
estado="en_curso",
|
||||
puntos_gps=1,
|
||||
)
|
||||
|
||||
self.db.add(viaje)
|
||||
await self.db.commit()
|
||||
await self.db.refresh(viaje)
|
||||
|
||||
return viaje
|
||||
|
||||
async def _procesar_parada(
|
||||
self,
|
||||
viaje: Viaje,
|
||||
vehiculo_id: int,
|
||||
lat: float,
|
||||
lng: float,
|
||||
tiempo: datetime,
|
||||
) -> Optional[dict]:
|
||||
"""
|
||||
Procesa una parada durante un viaje.
|
||||
|
||||
Returns:
|
||||
Dict con evento si el viaje terminó.
|
||||
"""
|
||||
# Buscar parada en curso
|
||||
result = await self.db.execute(
|
||||
select(Parada)
|
||||
.where(Parada.vehiculo_id == vehiculo_id)
|
||||
.where(Parada.en_curso == True)
|
||||
)
|
||||
parada = result.scalar_one_or_none()
|
||||
|
||||
if not parada:
|
||||
# Iniciar nueva parada
|
||||
parada = Parada(
|
||||
viaje_id=viaje.id,
|
||||
vehiculo_id=vehiculo_id,
|
||||
inicio_tiempo=tiempo,
|
||||
lat=lat,
|
||||
lng=lng,
|
||||
en_curso=True,
|
||||
)
|
||||
self.db.add(parada)
|
||||
await self.db.commit()
|
||||
return None
|
||||
|
||||
# Calcular duración de la parada
|
||||
duracion_segundos = (tiempo - parada.inicio_tiempo).total_seconds()
|
||||
parada.duracion_segundos = int(duracion_segundos)
|
||||
|
||||
# Verificar si la parada es suficientemente larga para terminar el viaje
|
||||
if duracion_segundos >= self.MINUTOS_PARADA_FIN_VIAJE * 60:
|
||||
# Terminar viaje
|
||||
return await self._finalizar_viaje(viaje, parada, tiempo)
|
||||
|
||||
await self.db.commit()
|
||||
return None
|
||||
|
||||
async def _cerrar_parada_en_curso(
|
||||
self,
|
||||
viaje_id: int,
|
||||
vehiculo_id: int,
|
||||
tiempo: datetime,
|
||||
) -> None:
|
||||
"""Cierra una parada en curso si existe."""
|
||||
result = await self.db.execute(
|
||||
select(Parada)
|
||||
.where(Parada.vehiculo_id == vehiculo_id)
|
||||
.where(Parada.en_curso == True)
|
||||
)
|
||||
parada = result.scalar_one_or_none()
|
||||
|
||||
if parada:
|
||||
duracion = (tiempo - parada.inicio_tiempo).total_seconds()
|
||||
|
||||
if duracion >= self.SEGUNDOS_MINIMOS_PARADA:
|
||||
# Registrar parada
|
||||
parada.fin_tiempo = tiempo
|
||||
parada.duracion_segundos = int(duracion)
|
||||
parada.en_curso = False
|
||||
else:
|
||||
# Parada muy corta, eliminar
|
||||
await self.db.delete(parada)
|
||||
|
||||
async def _finalizar_viaje(
|
||||
self,
|
||||
viaje: Viaje,
|
||||
parada: Parada,
|
||||
tiempo: datetime,
|
||||
) -> dict:
|
||||
"""Finaliza un viaje."""
|
||||
# Cerrar parada
|
||||
parada.fin_tiempo = tiempo
|
||||
parada.en_curso = False
|
||||
|
||||
# Calcular estadísticas del viaje
|
||||
viaje.fin_tiempo = parada.inicio_tiempo # El viaje termina al inicio de la parada final
|
||||
viaje.fin_lat = parada.lat
|
||||
viaje.fin_lng = parada.lng
|
||||
viaje.estado = "completado"
|
||||
|
||||
# Calcular duración
|
||||
viaje.duracion_segundos = int(
|
||||
(viaje.fin_tiempo - viaje.inicio_tiempo).total_seconds()
|
||||
)
|
||||
|
||||
# Calcular estadísticas desde ubicaciones
|
||||
await self._calcular_estadisticas_viaje(viaje)
|
||||
|
||||
await self.db.commit()
|
||||
|
||||
return {
|
||||
"evento": "viaje_finalizado",
|
||||
"viaje_id": viaje.id,
|
||||
"vehiculo_id": viaje.vehiculo_id,
|
||||
"distancia_km": viaje.distancia_km,
|
||||
"duracion_segundos": viaje.duracion_segundos,
|
||||
}
|
||||
|
||||
async def _calcular_estadisticas_viaje(
|
||||
self,
|
||||
viaje: Viaje,
|
||||
) -> None:
|
||||
"""Calcula las estadísticas de un viaje finalizado."""
|
||||
# Obtener ubicaciones del viaje
|
||||
result = await self.db.execute(
|
||||
select(Ubicacion)
|
||||
.where(Ubicacion.vehiculo_id == viaje.vehiculo_id)
|
||||
.where(Ubicacion.tiempo >= viaje.inicio_tiempo)
|
||||
.where(Ubicacion.tiempo <= viaje.fin_tiempo)
|
||||
.order_by(Ubicacion.tiempo)
|
||||
)
|
||||
ubicaciones = result.scalars().all()
|
||||
|
||||
if not ubicaciones:
|
||||
return
|
||||
|
||||
# Distancia
|
||||
viaje.distancia_km = self._calcular_distancia(ubicaciones)
|
||||
|
||||
# Velocidades
|
||||
velocidades = [u.velocidad for u in ubicaciones if u.velocidad is not None]
|
||||
if velocidades:
|
||||
viaje.velocidad_promedio = sum(velocidades) / len(velocidades)
|
||||
viaje.velocidad_maxima = max(velocidades)
|
||||
|
||||
# Tiempo en movimiento
|
||||
tiempo_movimiento = 0
|
||||
tiempo_parado = 0
|
||||
for i in range(1, len(ubicaciones)):
|
||||
delta = (ubicaciones[i].tiempo - ubicaciones[i-1].tiempo).total_seconds()
|
||||
if ubicaciones[i-1].velocidad and ubicaciones[i-1].velocidad >= self.VELOCIDAD_MINIMA_MOVIMIENTO:
|
||||
tiempo_movimiento += delta
|
||||
else:
|
||||
tiempo_parado += delta
|
||||
|
||||
viaje.tiempo_movimiento_segundos = int(tiempo_movimiento)
|
||||
viaje.tiempo_parado_segundos = int(tiempo_parado)
|
||||
|
||||
# Odómetro final
|
||||
result = await self.db.execute(
|
||||
select(Vehiculo).where(Vehiculo.id == viaje.vehiculo_id)
|
||||
)
|
||||
vehiculo = result.scalar_one_or_none()
|
||||
if vehiculo:
|
||||
viaje.odometro_fin = vehiculo.odometro_actual
|
||||
|
||||
def _calcular_distancia(
|
||||
self,
|
||||
ubicaciones: List[Ubicacion],
|
||||
) -> float:
|
||||
"""Calcula la distancia total entre ubicaciones."""
|
||||
import math
|
||||
|
||||
if len(ubicaciones) < 2:
|
||||
return 0.0
|
||||
|
||||
total_km = 0.0
|
||||
for i in range(1, len(ubicaciones)):
|
||||
lat1 = math.radians(ubicaciones[i - 1].lat)
|
||||
lat2 = math.radians(ubicaciones[i].lat)
|
||||
dlat = lat2 - lat1
|
||||
dlon = math.radians(ubicaciones[i].lng - ubicaciones[i - 1].lng)
|
||||
|
||||
a = (
|
||||
math.sin(dlat / 2) ** 2
|
||||
+ math.cos(lat1) * math.cos(lat2) * math.sin(dlon / 2) ** 2
|
||||
)
|
||||
c = 2 * math.asin(math.sqrt(a))
|
||||
r = 6371
|
||||
|
||||
total_km += r * c
|
||||
|
||||
return round(total_km, 2)
|
||||
|
||||
async def obtener_viajes_vehiculo(
|
||||
self,
|
||||
vehiculo_id: int,
|
||||
desde: datetime = None,
|
||||
hasta: datetime = None,
|
||||
limite: int = 50,
|
||||
) -> List[Viaje]:
|
||||
"""
|
||||
Obtiene los viajes de un vehículo.
|
||||
|
||||
Args:
|
||||
vehiculo_id: ID del vehículo.
|
||||
desde: Fecha inicio (opcional).
|
||||
hasta: Fecha fin (opcional).
|
||||
limite: Límite de resultados.
|
||||
|
||||
Returns:
|
||||
Lista de viajes.
|
||||
"""
|
||||
query = (
|
||||
select(Viaje)
|
||||
.where(Viaje.vehiculo_id == vehiculo_id)
|
||||
.order_by(Viaje.inicio_tiempo.desc())
|
||||
.limit(limite)
|
||||
)
|
||||
|
||||
if desde:
|
||||
query = query.where(Viaje.inicio_tiempo >= desde)
|
||||
if hasta:
|
||||
query = query.where(Viaje.inicio_tiempo <= hasta)
|
||||
|
||||
result = await self.db.execute(query)
|
||||
return result.scalars().all()
|
||||
|
||||
async def obtener_replay_viaje(
|
||||
self,
|
||||
viaje_id: int,
|
||||
) -> Optional[dict]:
|
||||
"""
|
||||
Obtiene los datos para replay de un viaje.
|
||||
|
||||
Args:
|
||||
viaje_id: ID del viaje.
|
||||
|
||||
Returns:
|
||||
Datos del viaje con ubicaciones y paradas.
|
||||
"""
|
||||
result = await self.db.execute(
|
||||
select(Viaje).where(Viaje.id == viaje_id)
|
||||
)
|
||||
viaje = result.scalar_one_or_none()
|
||||
|
||||
if not viaje:
|
||||
return None
|
||||
|
||||
# Obtener ubicaciones
|
||||
result = await self.db.execute(
|
||||
select(Ubicacion)
|
||||
.where(Ubicacion.vehiculo_id == viaje.vehiculo_id)
|
||||
.where(Ubicacion.tiempo >= viaje.inicio_tiempo)
|
||||
.where(
|
||||
Ubicacion.tiempo <= (viaje.fin_tiempo or datetime.now(timezone.utc))
|
||||
)
|
||||
.order_by(Ubicacion.tiempo)
|
||||
)
|
||||
ubicaciones = result.scalars().all()
|
||||
|
||||
# Obtener paradas
|
||||
result = await self.db.execute(
|
||||
select(Parada)
|
||||
.where(Parada.viaje_id == viaje_id)
|
||||
.order_by(Parada.inicio_tiempo)
|
||||
)
|
||||
paradas = result.scalars().all()
|
||||
|
||||
return {
|
||||
"viaje": viaje,
|
||||
"ubicaciones": ubicaciones,
|
||||
"paradas": paradas,
|
||||
}
|
||||
411
backend/app/services/video_service.py
Normal file
411
backend/app/services/video_service.py
Normal file
@@ -0,0 +1,411 @@
|
||||
"""
|
||||
Servicio para gestión de video y cámaras.
|
||||
|
||||
Integración con MediaMTX para streaming de video.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import List, Optional
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.security import decrypt_sensitive_data, encrypt_sensitive_data
|
||||
from app.models.camara import Camara
|
||||
from app.models.grabacion import Grabacion
|
||||
from app.models.evento_video import EventoVideo
|
||||
from app.schemas.video import CamaraStreamURL
|
||||
|
||||
|
||||
class VideoService:
|
||||
"""Servicio para gestión de video y streaming."""
|
||||
|
||||
def __init__(self, db: AsyncSession):
|
||||
"""
|
||||
Inicializa el servicio.
|
||||
|
||||
Args:
|
||||
db: Sesión de base de datos async.
|
||||
"""
|
||||
self.db = db
|
||||
self.mediamtx_api = f"http://{settings.MEDIAMTX_HOST}:{settings.MEDIAMTX_API_PORT}"
|
||||
|
||||
async def obtener_urls_stream(
|
||||
self,
|
||||
camara_id: int,
|
||||
) -> Optional[CamaraStreamURL]:
|
||||
"""
|
||||
Obtiene las URLs de streaming de una cámara.
|
||||
|
||||
Args:
|
||||
camara_id: ID de la cámara.
|
||||
|
||||
Returns:
|
||||
URLs de streaming disponibles.
|
||||
"""
|
||||
result = await self.db.execute(
|
||||
select(Camara).where(Camara.id == camara_id)
|
||||
)
|
||||
camara = result.scalar_one_or_none()
|
||||
|
||||
if not camara or not camara.activa:
|
||||
return None
|
||||
|
||||
# Construir URLs según el path de MediaMTX
|
||||
path = camara.mediamtx_path or f"cam{camara.id}"
|
||||
|
||||
rtsp_url = None
|
||||
hls_url = None
|
||||
webrtc_url = None
|
||||
|
||||
if camara.url_stream:
|
||||
# Usar URL directa de la cámara
|
||||
rtsp_url = camara.url_stream_completa
|
||||
else:
|
||||
# Usar MediaMTX como proxy
|
||||
rtsp_url = f"rtsp://{settings.MEDIAMTX_HOST}:{settings.MEDIAMTX_RTSP_PORT}/{path}"
|
||||
|
||||
# URLs de MediaMTX para diferentes protocolos
|
||||
hls_url = f"http://{settings.MEDIAMTX_HOST}:8888/{path}/index.m3u8"
|
||||
webrtc_url = f"http://{settings.MEDIAMTX_HOST}:{settings.MEDIAMTX_WEBRTC_PORT}/{path}"
|
||||
|
||||
return CamaraStreamURL(
|
||||
camara_id=camara.id,
|
||||
camara_nombre=camara.nombre,
|
||||
rtsp_url=rtsp_url,
|
||||
hls_url=hls_url,
|
||||
webrtc_url=webrtc_url,
|
||||
estado=camara.estado,
|
||||
)
|
||||
|
||||
async def verificar_estado_camaras(self) -> List[dict]:
|
||||
"""
|
||||
Verifica el estado de todas las cámaras activas.
|
||||
|
||||
Returns:
|
||||
Lista con estado de cada cámara.
|
||||
"""
|
||||
result = await self.db.execute(
|
||||
select(Camara).where(Camara.activa == True)
|
||||
)
|
||||
camaras = result.scalars().all()
|
||||
|
||||
estados = []
|
||||
for camara in camaras:
|
||||
estado = await self._verificar_stream(camara)
|
||||
estados.append({
|
||||
"camara_id": camara.id,
|
||||
"nombre": camara.nombre,
|
||||
"vehiculo_id": camara.vehiculo_id,
|
||||
"estado_anterior": camara.estado,
|
||||
"estado_actual": estado,
|
||||
"cambio": camara.estado != estado,
|
||||
})
|
||||
|
||||
# Actualizar estado si cambió
|
||||
if camara.estado != estado:
|
||||
camara.estado = estado
|
||||
if estado == "conectada":
|
||||
camara.ultima_conexion = datetime.now(timezone.utc)
|
||||
|
||||
await self.db.commit()
|
||||
return estados
|
||||
|
||||
async def _verificar_stream(
|
||||
self,
|
||||
camara: Camara,
|
||||
) -> str:
|
||||
"""
|
||||
Verifica si un stream está activo.
|
||||
|
||||
Args:
|
||||
camara: Cámara a verificar.
|
||||
|
||||
Returns:
|
||||
Estado del stream.
|
||||
"""
|
||||
if not camara.url_stream and not camara.mediamtx_path:
|
||||
return "desconectada"
|
||||
|
||||
path = camara.mediamtx_path or f"cam{camara.id}"
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
# Verificar en MediaMTX API
|
||||
response = await client.get(
|
||||
f"{self.mediamtx_api}/v3/paths/get/{path}",
|
||||
timeout=5.0,
|
||||
)
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
if data.get("ready"):
|
||||
return "conectada"
|
||||
return "desconectada"
|
||||
return "desconectada"
|
||||
|
||||
except httpx.HTTPError:
|
||||
return "error"
|
||||
|
||||
async def iniciar_grabacion(
|
||||
self,
|
||||
camara_id: int,
|
||||
tipo: str = "manual",
|
||||
) -> Optional[Grabacion]:
|
||||
"""
|
||||
Inicia una grabación de una cámara.
|
||||
|
||||
Args:
|
||||
camara_id: ID de la cámara.
|
||||
tipo: Tipo de grabación.
|
||||
|
||||
Returns:
|
||||
Registro de grabación creado.
|
||||
"""
|
||||
result = await self.db.execute(
|
||||
select(Camara).where(Camara.id == camara_id)
|
||||
)
|
||||
camara = result.scalar_one_or_none()
|
||||
|
||||
if not camara or camara.estado != "conectada":
|
||||
return None
|
||||
|
||||
# Generar nombre de archivo
|
||||
timestamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
|
||||
archivo_nombre = f"cam{camara_id}_{timestamp}.mp4"
|
||||
archivo_url = f"{settings.UPLOAD_DIR}/videos/{camara.vehiculo_id}/{archivo_nombre}"
|
||||
|
||||
grabacion = Grabacion(
|
||||
camara_id=camara_id,
|
||||
vehiculo_id=camara.vehiculo_id,
|
||||
inicio_tiempo=datetime.now(timezone.utc),
|
||||
archivo_url=archivo_url,
|
||||
archivo_nombre=archivo_nombre,
|
||||
tipo=tipo,
|
||||
estado="grabando",
|
||||
)
|
||||
|
||||
self.db.add(grabacion)
|
||||
|
||||
# Actualizar estado de cámara
|
||||
camara.estado = "grabando"
|
||||
|
||||
await self.db.commit()
|
||||
await self.db.refresh(grabacion)
|
||||
|
||||
# Enviar comando a MediaMTX para iniciar grabación
|
||||
await self._iniciar_grabacion_mediamtx(camara, archivo_url)
|
||||
|
||||
return grabacion
|
||||
|
||||
async def detener_grabacion(
|
||||
self,
|
||||
grabacion_id: int,
|
||||
) -> Optional[Grabacion]:
|
||||
"""
|
||||
Detiene una grabación en curso.
|
||||
|
||||
Args:
|
||||
grabacion_id: ID de la grabación.
|
||||
|
||||
Returns:
|
||||
Grabación actualizada.
|
||||
"""
|
||||
result = await self.db.execute(
|
||||
select(Grabacion).where(Grabacion.id == grabacion_id)
|
||||
)
|
||||
grabacion = result.scalar_one_or_none()
|
||||
|
||||
if not grabacion or grabacion.estado != "grabando":
|
||||
return None
|
||||
|
||||
# Detener grabación en MediaMTX
|
||||
result_cam = await self.db.execute(
|
||||
select(Camara).where(Camara.id == grabacion.camara_id)
|
||||
)
|
||||
camara = result_cam.scalar_one_or_none()
|
||||
|
||||
if camara:
|
||||
await self._detener_grabacion_mediamtx(camara)
|
||||
camara.estado = "conectada"
|
||||
|
||||
# Actualizar registro
|
||||
grabacion.fin_tiempo = datetime.now(timezone.utc)
|
||||
grabacion.duracion_segundos = int(
|
||||
(grabacion.fin_tiempo - grabacion.inicio_tiempo).total_seconds()
|
||||
)
|
||||
grabacion.estado = "procesando" # Se procesará para generar thumbnail, etc.
|
||||
|
||||
await self.db.commit()
|
||||
await self.db.refresh(grabacion)
|
||||
|
||||
return grabacion
|
||||
|
||||
async def _iniciar_grabacion_mediamtx(
|
||||
self,
|
||||
camara: Camara,
|
||||
archivo_url: str,
|
||||
) -> bool:
|
||||
"""Envía comando a MediaMTX para iniciar grabación."""
|
||||
# MediaMTX usa configuración para grabación automática
|
||||
# o se puede usar ffmpeg para grabar el stream
|
||||
# Esta es una implementación simplificada
|
||||
try:
|
||||
# En una implementación real, se usaría la API de MediaMTX
|
||||
# o se ejecutaría ffmpeg como proceso
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
async def _detener_grabacion_mediamtx(
|
||||
self,
|
||||
camara: Camara,
|
||||
) -> bool:
|
||||
"""Envía comando a MediaMTX para detener grabación."""
|
||||
try:
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
async def registrar_evento_video(
|
||||
self,
|
||||
camara_id: int,
|
||||
tipo: str,
|
||||
severidad: str,
|
||||
lat: float = None,
|
||||
lng: float = None,
|
||||
velocidad: float = None,
|
||||
descripcion: str = None,
|
||||
confianza: float = None,
|
||||
snapshot_url: str = None,
|
||||
) -> EventoVideo:
|
||||
"""
|
||||
Registra un evento de video detectado.
|
||||
|
||||
Args:
|
||||
camara_id: ID de la cámara.
|
||||
tipo: Tipo de evento.
|
||||
severidad: Severidad del evento.
|
||||
lat, lng: Coordenadas.
|
||||
velocidad: Velocidad al momento del evento.
|
||||
descripcion: Descripción del evento.
|
||||
confianza: Confianza de la detección (0-100).
|
||||
snapshot_url: URL de la imagen del evento.
|
||||
|
||||
Returns:
|
||||
Evento creado.
|
||||
"""
|
||||
result = await self.db.execute(
|
||||
select(Camara).where(Camara.id == camara_id)
|
||||
)
|
||||
camara = result.scalar_one_or_none()
|
||||
|
||||
if not camara:
|
||||
raise ValueError(f"Cámara {camara_id} no encontrada")
|
||||
|
||||
evento = EventoVideo(
|
||||
camara_id=camara_id,
|
||||
vehiculo_id=camara.vehiculo_id,
|
||||
tipo=tipo,
|
||||
severidad=severidad,
|
||||
tiempo=datetime.now(timezone.utc),
|
||||
lat=lat,
|
||||
lng=lng,
|
||||
velocidad=velocidad,
|
||||
descripcion=descripcion,
|
||||
confianza=confianza,
|
||||
snapshot_url=snapshot_url,
|
||||
)
|
||||
|
||||
self.db.add(evento)
|
||||
await self.db.commit()
|
||||
await self.db.refresh(evento)
|
||||
|
||||
# Iniciar grabación de evento si está configurado
|
||||
if camara.grabacion_evento:
|
||||
await self.iniciar_grabacion(camara_id, tipo="evento")
|
||||
|
||||
return evento
|
||||
|
||||
async def obtener_grabaciones(
|
||||
self,
|
||||
vehiculo_id: int = None,
|
||||
camara_id: int = None,
|
||||
desde: datetime = None,
|
||||
hasta: datetime = None,
|
||||
tipo: str = None,
|
||||
limite: int = 50,
|
||||
) -> List[Grabacion]:
|
||||
"""
|
||||
Obtiene grabaciones filtradas.
|
||||
|
||||
Args:
|
||||
vehiculo_id: Filtrar por vehículo.
|
||||
camara_id: Filtrar por cámara.
|
||||
desde: Fecha inicio.
|
||||
hasta: Fecha fin.
|
||||
tipo: Tipo de grabación.
|
||||
limite: Límite de resultados.
|
||||
|
||||
Returns:
|
||||
Lista de grabaciones.
|
||||
"""
|
||||
query = (
|
||||
select(Grabacion)
|
||||
.where(Grabacion.estado != "eliminado")
|
||||
.order_by(Grabacion.inicio_tiempo.desc())
|
||||
.limit(limite)
|
||||
)
|
||||
|
||||
if vehiculo_id:
|
||||
query = query.where(Grabacion.vehiculo_id == vehiculo_id)
|
||||
if camara_id:
|
||||
query = query.where(Grabacion.camara_id == camara_id)
|
||||
if desde:
|
||||
query = query.where(Grabacion.inicio_tiempo >= desde)
|
||||
if hasta:
|
||||
query = query.where(Grabacion.inicio_tiempo <= hasta)
|
||||
if tipo:
|
||||
query = query.where(Grabacion.tipo == tipo)
|
||||
|
||||
result = await self.db.execute(query)
|
||||
return result.scalars().all()
|
||||
|
||||
async def configurar_camara_mediamtx(
|
||||
self,
|
||||
camara: Camara,
|
||||
) -> bool:
|
||||
"""
|
||||
Configura una cámara en MediaMTX.
|
||||
|
||||
Args:
|
||||
camara: Cámara a configurar.
|
||||
|
||||
Returns:
|
||||
True si se configuró correctamente.
|
||||
"""
|
||||
if not camara.url_stream:
|
||||
return False
|
||||
|
||||
path = camara.mediamtx_path or f"cam{camara.id}"
|
||||
|
||||
# Construir configuración para MediaMTX
|
||||
config = {
|
||||
"name": path,
|
||||
"source": camara.url_stream_completa,
|
||||
"sourceOnDemand": True,
|
||||
"record": camara.grabacion_continua,
|
||||
}
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(
|
||||
f"{self.mediamtx_api}/v3/config/paths/add/{path}",
|
||||
json=config,
|
||||
timeout=10.0,
|
||||
)
|
||||
return response.status_code in [200, 201]
|
||||
|
||||
except httpx.HTTPError:
|
||||
return False
|
||||
116
backend/requirements.txt
Normal file
116
backend/requirements.txt
Normal file
@@ -0,0 +1,116 @@
|
||||
# Adan Fleet Monitor - Backend Dependencies
|
||||
# Python 3.11+
|
||||
|
||||
# =============================================================================
|
||||
# Web Framework
|
||||
# =============================================================================
|
||||
fastapi>=0.109.0,<1.0.0
|
||||
uvicorn[standard]>=0.27.0,<1.0.0
|
||||
python-multipart>=0.0.6,<1.0.0
|
||||
|
||||
# =============================================================================
|
||||
# Database
|
||||
# =============================================================================
|
||||
sqlalchemy[asyncio]>=2.0.25,<3.0.0
|
||||
asyncpg>=0.29.0,<1.0.0
|
||||
alembic>=1.13.0,<2.0.0
|
||||
|
||||
# =============================================================================
|
||||
# Data Validation
|
||||
# =============================================================================
|
||||
pydantic>=2.5.0,<3.0.0
|
||||
pydantic[email]>=2.5.0,<3.0.0
|
||||
pydantic-settings>=2.1.0,<3.0.0
|
||||
|
||||
# =============================================================================
|
||||
# Authentication & Security
|
||||
# =============================================================================
|
||||
python-jose[cryptography]>=3.3.0,<4.0.0
|
||||
passlib[bcrypt]>=1.7.4,<2.0.0
|
||||
bcrypt>=4.1.0,<5.0.0
|
||||
|
||||
# =============================================================================
|
||||
# Caching & Message Queue
|
||||
# =============================================================================
|
||||
redis>=5.0.0,<6.0.0
|
||||
aioredis>=2.0.1,<3.0.0
|
||||
|
||||
# =============================================================================
|
||||
# HTTP Client
|
||||
# =============================================================================
|
||||
httpx>=0.26.0,<1.0.0
|
||||
aiohttp>=3.9.0,<4.0.0
|
||||
|
||||
# =============================================================================
|
||||
# WebSockets
|
||||
# =============================================================================
|
||||
websockets>=12.0,<13.0
|
||||
|
||||
# =============================================================================
|
||||
# Geospatial
|
||||
# =============================================================================
|
||||
shapely>=2.0.2,<3.0.0
|
||||
geopy>=2.4.1,<3.0.0
|
||||
|
||||
# =============================================================================
|
||||
# Reports & Documents
|
||||
# =============================================================================
|
||||
jinja2>=3.1.2,<4.0.0
|
||||
weasyprint>=60.0,<70.0
|
||||
openpyxl>=3.1.2,<4.0.0
|
||||
|
||||
# =============================================================================
|
||||
# MQTT (IoT Communication)
|
||||
# =============================================================================
|
||||
aiomqtt>=2.0.0,<3.0.0
|
||||
|
||||
# =============================================================================
|
||||
# Email
|
||||
# =============================================================================
|
||||
aiosmtplib>=3.0.0,<4.0.0
|
||||
|
||||
# =============================================================================
|
||||
# Utilities
|
||||
# =============================================================================
|
||||
python-dateutil>=2.8.2,<3.0.0
|
||||
pytz>=2023.3
|
||||
orjson>=3.9.10,<4.0.0
|
||||
|
||||
# =============================================================================
|
||||
# Development & Testing
|
||||
# =============================================================================
|
||||
pytest>=7.4.0,<8.0.0
|
||||
pytest-asyncio>=0.23.0,<1.0.0
|
||||
pytest-cov>=4.1.0,<5.0.0
|
||||
httpx>=0.26.0 # For TestClient
|
||||
factory-boy>=3.3.0,<4.0.0
|
||||
faker>=22.0.0,<30.0.0
|
||||
|
||||
# =============================================================================
|
||||
# Code Quality
|
||||
# =============================================================================
|
||||
black>=23.12.0,<25.0.0
|
||||
isort>=5.13.0,<6.0.0
|
||||
flake8>=7.0.0,<8.0.0
|
||||
mypy>=1.8.0,<2.0.0
|
||||
|
||||
# =============================================================================
|
||||
# Logging & Monitoring
|
||||
# =============================================================================
|
||||
structlog>=24.1.0,<25.0.0
|
||||
sentry-sdk[fastapi]>=1.39.0,<2.0.0
|
||||
|
||||
# =============================================================================
|
||||
# Optional: Push Notifications
|
||||
# =============================================================================
|
||||
firebase-admin>=6.3.0,<7.0.0
|
||||
|
||||
# =============================================================================
|
||||
# Optional: Celery for background tasks
|
||||
# =============================================================================
|
||||
celery[redis]>=5.3.0,<6.0.0
|
||||
|
||||
# =============================================================================
|
||||
# Optional: APScheduler for scheduled tasks
|
||||
# =============================================================================
|
||||
apscheduler>=3.10.0,<4.0.0
|
||||
264
deploy/README.md
Normal file
264
deploy/README.md
Normal 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.
|
||||
135
deploy/cloudflare/config.yml
Normal file
135
deploy/cloudflare/config.yml
Normal 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
|
||||
# ============================================
|
||||
237
deploy/mediamtx/mediamtx.yml
Normal file
237
deploy/mediamtx/mediamtx.yml
Normal 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
581
deploy/proxmox/vm-setup.sh
Normal 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
486
deploy/scripts/backup.sh
Normal 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 "$@"
|
||||
247
deploy/scripts/health-check.sh
Normal file
247
deploy/scripts/health-check.sh
Normal 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
1080
deploy/scripts/install.sh
Normal file
File diff suppressed because it is too large
Load Diff
133
deploy/scripts/logs.sh
Normal file
133
deploy/scripts/logs.sh
Normal 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
549
deploy/scripts/restore.sh
Normal 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
214
deploy/scripts/status.sh
Normal 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
485
deploy/scripts/update.sh
Normal 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 "$@"
|
||||
43
deploy/services/cloudflared.service
Normal file
43
deploy/services/cloudflared.service
Normal 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
|
||||
58
deploy/services/flotillas-api.service
Normal file
58
deploy/services/flotillas-api.service
Normal 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
|
||||
58
deploy/services/flotillas-web.service
Normal file
58
deploy/services/flotillas-web.service
Normal 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
|
||||
41
deploy/services/mediamtx.service
Normal file
41
deploy/services/mediamtx.service
Normal 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
200
deploy/traccar/traccar.xml
Normal 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
742
docs/guias/api-reference.md
Normal 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
449
docs/guias/configuracion.md
Normal 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
264
docs/guias/instalacion.md
Normal 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
Reference in New Issue
Block a user