feat(13-02): add wireless_links table migration, ORM model, register both models

- Migration 033 creates wireless_links with state machine, missed_polls, RLS
- WirelessLink model with LinkState enum (discovered/active/degraded/down/stale)
- Register DeviceInterface, WirelessLink, LinkState in models __init__

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jason Staack
2026-03-19 06:02:14 -05:00
parent 7147b15e13
commit a71df2af29
3 changed files with 199 additions and 0 deletions

View File

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

View File

@@ -18,6 +18,8 @@ from app.models.audit_log import AuditLog
from app.models.maintenance_window import MaintenanceWindow from app.models.maintenance_window import MaintenanceWindow
from app.models.api_key import ApiKey from app.models.api_key import ApiKey
from app.models.config_backup import RouterConfigSnapshot, RouterConfigDiff, RouterConfigChange 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__ = [ __all__ = [
"Tenant", "Tenant",
@@ -45,4 +47,7 @@ __all__ = [
"RouterConfigSnapshot", "RouterConfigSnapshot",
"RouterConfigDiff", "RouterConfigDiff",
"RouterConfigChange", "RouterConfigChange",
"DeviceInterface",
"WirelessLink",
"LinkState",
] ]

View File

@@ -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"<WirelessLink id={self.id} ap={self.ap_device_id} "
f"cpe={self.cpe_device_id} state={self.state!r}>"
)