""" 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""