diff --git a/backend/alembic/versions/033_wireless_links_table.py b/backend/alembic/versions/033_wireless_links_table.py new file mode 100644 index 0000000..16de483 --- /dev/null +++ b/backend/alembic/versions/033_wireless_links_table.py @@ -0,0 +1,109 @@ +"""Create wireless_links table for AP-CPE link state tracking. + +Revision ID: 033 +Revises: 032 +Create Date: 2026-03-19 + +Stores discovered wireless links between AP and CPE devices with +state machine columns for link health tracking (discovered -> active -> +degraded -> down -> stale). +""" + +import sqlalchemy as sa +from alembic import op + +revision = "033" +down_revision = "032" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "wireless_links", + sa.Column( + "id", + sa.dialects.postgresql.UUID(as_uuid=True), + primary_key=True, + server_default=sa.text("gen_random_uuid()"), + ), + sa.Column( + "ap_device_id", + sa.dialects.postgresql.UUID(as_uuid=True), + sa.ForeignKey("devices.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column( + "cpe_device_id", + sa.dialects.postgresql.UUID(as_uuid=True), + sa.ForeignKey("devices.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column( + "tenant_id", + sa.dialects.postgresql.UUID(as_uuid=True), + sa.ForeignKey("tenants.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column("interface", sa.String(255), nullable=True), + sa.Column("client_mac", sa.String(17), nullable=False), + sa.Column("signal_strength", sa.Integer, nullable=True), + sa.Column("tx_ccq", sa.Integer, nullable=True), + sa.Column("tx_rate", sa.String(50), nullable=True), + sa.Column("rx_rate", sa.String(50), nullable=True), + sa.Column( + "state", + sa.String(20), + nullable=False, + server_default=sa.text("'discovered'"), + ), + sa.Column("missed_polls", sa.Integer, nullable=False, server_default=sa.text("0")), + sa.Column( + "discovered_at", + sa.DateTime(timezone=True), + server_default=sa.func.now(), + nullable=False, + ), + sa.Column( + "last_seen", + sa.DateTime(timezone=True), + server_default=sa.func.now(), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.func.now(), + nullable=False, + ), + sa.UniqueConstraint("ap_device_id", "cpe_device_id", name="uq_wireless_links_ap_cpe"), + ) + + op.create_index("idx_wireless_links_ap", "wireless_links", ["ap_device_id"]) + op.create_index("idx_wireless_links_cpe", "wireless_links", ["cpe_device_id"]) + op.create_index("idx_wireless_links_tenant_state", "wireless_links", ["tenant_id", "state"]) + op.create_index("idx_wireless_links_client_mac", "wireless_links", ["client_mac"]) + + # Enable RLS with tenant isolation policy + conn = op.get_bind() + conn.execute(sa.text("ALTER TABLE wireless_links ENABLE ROW LEVEL SECURITY")) + conn.execute(sa.text("ALTER TABLE wireless_links FORCE ROW LEVEL SECURITY")) + conn.execute( + sa.text(""" + CREATE POLICY tenant_isolation ON wireless_links + USING ( + tenant_id::text = current_setting('app.current_tenant', true) + OR current_setting('app.current_tenant', true) = 'super_admin' + ) + WITH CHECK ( + tenant_id::text = current_setting('app.current_tenant', true) + OR current_setting('app.current_tenant', true) = 'super_admin' + ) + """) + ) + + +def downgrade() -> None: + conn = op.get_bind() + conn.execute(sa.text("DROP POLICY IF EXISTS tenant_isolation ON wireless_links")) + op.drop_table("wireless_links") diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 7e6cdd2..cf7c037 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -18,6 +18,8 @@ from app.models.audit_log import AuditLog from app.models.maintenance_window import MaintenanceWindow from app.models.api_key import ApiKey from app.models.config_backup import RouterConfigSnapshot, RouterConfigDiff, RouterConfigChange +from app.models.device_interface import DeviceInterface +from app.models.wireless_link import WirelessLink, LinkState __all__ = [ "Tenant", @@ -45,4 +47,7 @@ __all__ = [ "RouterConfigSnapshot", "RouterConfigDiff", "RouterConfigChange", + "DeviceInterface", + "WirelessLink", + "LinkState", ] diff --git a/backend/app/models/wireless_link.py b/backend/app/models/wireless_link.py new file mode 100644 index 0000000..11796a3 --- /dev/null +++ b/backend/app/models/wireless_link.py @@ -0,0 +1,85 @@ +"""WirelessLink model -- AP-to-CPE link state tracking for link discovery.""" + +import uuid +from datetime import datetime +from enum import Enum + +from sqlalchemy import DateTime, ForeignKey, Integer, String, UniqueConstraint, func +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.models.base import Base + + +class LinkState(str, Enum): + """Link health state machine values.""" + + DISCOVERED = "discovered" + ACTIVE = "active" + DEGRADED = "degraded" + DOWN = "down" + STALE = "stale" + + +class WirelessLink(Base): + __tablename__ = "wireless_links" + __table_args__ = ( + UniqueConstraint("ap_device_id", "cpe_device_id", name="uq_wireless_links_ap_cpe"), + ) + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + primary_key=True, + default=uuid.uuid4, + server_default=func.gen_random_uuid(), + ) + ap_device_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("devices.id", ondelete="CASCADE"), + nullable=False, + ) + cpe_device_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("devices.id", ondelete="CASCADE"), + nullable=False, + ) + tenant_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("tenants.id", ondelete="CASCADE"), + nullable=False, + ) + interface: Mapped[str | None] = mapped_column(String(255), nullable=True) + client_mac: Mapped[str] = mapped_column(String(17), nullable=False) + signal_strength: Mapped[int | None] = mapped_column(Integer, nullable=True) + tx_ccq: Mapped[int | None] = mapped_column(Integer, nullable=True) + tx_rate: Mapped[str | None] = mapped_column(String(50), nullable=True) + rx_rate: Mapped[str | None] = mapped_column(String(50), nullable=True) + state: Mapped[str] = mapped_column( + String(20), + nullable=False, + default=LinkState.DISCOVERED.value, + ) + missed_polls: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + discovered_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) + last_seen: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) + + # Relationships + ap_device: Mapped["Device"] = relationship( # type: ignore[name-defined] + "Device", foreign_keys=[ap_device_id] + ) + cpe_device: Mapped["Device"] = relationship( # type: ignore[name-defined] + "Device", foreign_keys=[cpe_device_id] + ) + + def __repr__(self) -> str: + return ( + f"" + )