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