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