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:
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}')>"
|
||||
Reference in New Issue
Block a user