FlotillasGPS - Sistema completo de monitoreo de flotillas GPS

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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