feat(15-01): add site alert rules/events migration, models, schemas, and config

- Create Alembic migration 035 with site_alert_rules and site_alert_events tables, RLS policies, and GRANT
- Add SiteAlertRule/SiteAlertEvent ORM models with enums for rule_type, severity, state
- Add Pydantic schemas for rule/event CRUD and signal history points
- Add SIGNAL_DEGRADATION_THRESHOLD_DB, ALERT_EVALUATION_INTERVAL_SECONDS, TREND_DETECTION_INTERVAL_SECONDS to Settings

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jason Staack
2026-03-19 07:16:05 -05:00
parent 0079db6534
commit d4cf36b200
6 changed files with 578 additions and 5 deletions

View File

@@ -117,11 +117,12 @@ Plans:
2. System detects and surfaces signal degradation trends (e.g., "signal dropped 8dB over 2 weeks")
3. Operator can create site-scoped alert rules (e.g., "alert when >20% of devices at this site go offline")
4. Operator can create sector-scoped alert rules (e.g., "alert when sector average signal drops below -75dBm")
**Plans**: TBD
**Plans:** 3 plans
Plans:
- [ ] 15-01: TBD
- [ ] 15-02: TBD
- [ ] 15-01-PLAN.md — Backend data model, services, and REST API for site alert rules, alert events, and signal history
- [ ] 15-02-PLAN.md — Backend scheduled tasks (trend detection + alert evaluation) and frontend API clients
- [ ] 15-03-PLAN.md — Frontend signal history charts, alert rules UI, alert events table, and notification bell
## Coverage
@@ -145,7 +146,7 @@ Phases execute in numeric order: 11 -> 11.x -> 12 -> 12.x -> 13 -> 13.x -> 14 ->
| 12. Per-Client Wireless Collection | 0/2 | Planning complete | - |
| 13. Link Discovery + Registration Ingestion | 0/3 | Planning complete | - |
| 14. Site Dashboard + Sector Views + Wireless UI | 0/3 | Planning complete | - |
| 15. Signal Trending + Site Alerting | 0/? | Not started | - |
| 15. Signal Trending + Site Alerting | 0/3 | Planning complete | - |
---
*Roadmap created: 2026-03-18*

View File

@@ -0,0 +1,228 @@
"""Create site_alert_rules and site_alert_events tables with RLS.
Revision ID: 035
Revises: 034
Create Date: 2026-03-19
"""
import sqlalchemy as sa
from alembic import op
revision = "035"
down_revision = "034"
branch_labels = None
depends_on = None
def upgrade() -> None:
# 1. Create site_alert_rules table
op.create_table(
"site_alert_rules",
sa.Column(
"id",
sa.dialects.postgresql.UUID(as_uuid=True),
primary_key=True,
server_default=sa.text("gen_random_uuid()"),
),
sa.Column(
"tenant_id",
sa.dialects.postgresql.UUID(as_uuid=True),
sa.ForeignKey("tenants.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"site_id",
sa.dialects.postgresql.UUID(as_uuid=True),
sa.ForeignKey("sites.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"sector_id",
sa.dialects.postgresql.UUID(as_uuid=True),
sa.ForeignKey("sectors.id", ondelete="SET NULL"),
nullable=True,
),
sa.Column("rule_type", sa.String(50), nullable=False),
sa.Column("name", sa.String(255), nullable=False),
sa.Column("description", sa.Text, nullable=True),
sa.Column("threshold_value", sa.Numeric, nullable=False),
sa.Column("threshold_unit", sa.String(20), nullable=False),
sa.Column(
"enabled",
sa.Boolean,
nullable=False,
server_default=sa.text("true"),
),
sa.Column(
"created_at",
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,
),
)
op.create_index(
"ix_site_alert_rules_tenant_site",
"site_alert_rules",
["tenant_id", "site_id"],
)
op.create_index(
"ix_site_alert_rules_tenant_site_sector",
"site_alert_rules",
["tenant_id", "site_id", "sector_id"],
postgresql_where=sa.text("sector_id IS NOT NULL"),
)
# 2. Create site_alert_events table
op.create_table(
"site_alert_events",
sa.Column(
"id",
sa.dialects.postgresql.UUID(as_uuid=True),
primary_key=True,
server_default=sa.text("gen_random_uuid()"),
),
sa.Column(
"tenant_id",
sa.dialects.postgresql.UUID(as_uuid=True),
sa.ForeignKey("tenants.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"site_id",
sa.dialects.postgresql.UUID(as_uuid=True),
sa.ForeignKey("sites.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"sector_id",
sa.dialects.postgresql.UUID(as_uuid=True),
sa.ForeignKey("sectors.id", ondelete="SET NULL"),
nullable=True,
),
sa.Column(
"rule_id",
sa.dialects.postgresql.UUID(as_uuid=True),
sa.ForeignKey("site_alert_rules.id", ondelete="SET NULL"),
nullable=True,
),
sa.Column(
"device_id",
sa.dialects.postgresql.UUID(as_uuid=True),
sa.ForeignKey("devices.id", ondelete="SET NULL"),
nullable=True,
),
sa.Column(
"link_id",
sa.dialects.postgresql.UUID(as_uuid=True),
sa.ForeignKey("wireless_links.id", ondelete="SET NULL"),
nullable=True,
),
sa.Column(
"severity",
sa.String(20),
nullable=False,
server_default=sa.text("'warning'"),
),
sa.Column("message", sa.Text, nullable=False),
sa.Column(
"state",
sa.String(20),
nullable=False,
server_default=sa.text("'active'"),
),
sa.Column(
"consecutive_hits",
sa.Integer,
nullable=False,
server_default=sa.text("1"),
),
sa.Column(
"triggered_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
sa.Column("resolved_at", sa.DateTime(timezone=True), nullable=True),
sa.Column(
"resolved_by",
sa.dialects.postgresql.UUID(as_uuid=True),
sa.ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
),
)
op.create_index(
"ix_site_alert_events_tenant_site_state",
"site_alert_events",
["tenant_id", "site_id", "state"],
)
# 3. Enable RLS on both tables
conn = op.get_bind()
# site_alert_rules RLS
conn.execute(sa.text("ALTER TABLE site_alert_rules ENABLE ROW LEVEL SECURITY"))
conn.execute(sa.text("ALTER TABLE site_alert_rules FORCE ROW LEVEL SECURITY"))
conn.execute(
sa.text("""
CREATE POLICY tenant_isolation ON site_alert_rules
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'
)
""")
)
conn.execute(
sa.text(
"GRANT SELECT, INSERT, UPDATE, DELETE ON site_alert_rules TO app_user"
)
)
# site_alert_events RLS
conn.execute(sa.text("ALTER TABLE site_alert_events ENABLE ROW LEVEL SECURITY"))
conn.execute(sa.text("ALTER TABLE site_alert_events FORCE ROW LEVEL SECURITY"))
conn.execute(
sa.text("""
CREATE POLICY tenant_isolation ON site_alert_events
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'
)
""")
)
conn.execute(
sa.text(
"GRANT SELECT, INSERT, UPDATE, DELETE ON site_alert_events TO app_user"
)
)
def downgrade() -> None:
conn = op.get_bind()
# Drop RLS policies
conn.execute(
sa.text("DROP POLICY IF EXISTS tenant_isolation ON site_alert_events")
)
conn.execute(
sa.text("DROP POLICY IF EXISTS tenant_isolation ON site_alert_rules")
)
# Drop tables (indexes drop automatically with tables)
op.drop_table("site_alert_events")
op.drop_table("site_alert_rules")

View File

@@ -126,6 +126,11 @@ class Settings(BaseSettings):
PASSWORD_RESET_TOKEN_EXPIRE_MINUTES: int = 30
APP_BASE_URL: str = "http://localhost:3000"
# Signal trending and site alerting (Phase 15)
SIGNAL_DEGRADATION_THRESHOLD_DB: int = 5
ALERT_EVALUATION_INTERVAL_SECONDS: int = 300
TREND_DETECTION_INTERVAL_SECONDS: int = 3600
# Retention cleanup — delete config snapshots older than N days
CONFIG_RETENTION_DAYS: int = 90

View File

@@ -21,6 +21,7 @@ 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
from app.models.site_alert import SiteAlertRule, SiteAlertEvent
__all__ = [
"Tenant",
@@ -52,4 +53,6 @@ __all__ = [
"DeviceInterface",
"WirelessLink",
"LinkState",
"SiteAlertRule",
"SiteAlertEvent",
]

View File

@@ -0,0 +1,168 @@
"""Site alert system ORM models: site/sector-scoped alert rules and events.
Separate from the device-level alert system in alert.py. These models support
site-wide and sector-scoped alerting for Phase 15 (signal trending, site alerting).
"""
import uuid
from datetime import datetime
from enum import Enum
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, Numeric, String, Text, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base
class SiteAlertRuleType(str, Enum):
"""Types of site/sector alert rules."""
DEVICE_OFFLINE_PERCENT = "device_offline_percent"
DEVICE_OFFLINE_COUNT = "device_offline_count"
SECTOR_SIGNAL_AVG = "sector_signal_avg"
SECTOR_CLIENT_DROP = "sector_client_drop"
SIGNAL_DEGRADATION = "signal_degradation"
class AlertSeverity(str, Enum):
"""Alert severity levels."""
WARNING = "warning"
CRITICAL = "critical"
class AlertState(str, Enum):
"""Alert event states."""
ACTIVE = "active"
RESOLVED = "resolved"
class SiteAlertRule(Base):
"""Configurable site/sector-scoped alert threshold rule.
Rules are always scoped to a site, and optionally to a specific sector.
When conditions are met, site_alert_events are created by the evaluation task.
"""
__tablename__ = "site_alert_rules"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
primary_key=True,
default=uuid.uuid4,
server_default=func.gen_random_uuid(),
)
tenant_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("tenants.id", ondelete="CASCADE"),
nullable=False,
)
site_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("sites.id", ondelete="CASCADE"),
nullable=False,
)
sector_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True),
ForeignKey("sectors.id", ondelete="SET NULL"),
nullable=True,
)
rule_type: Mapped[str] = mapped_column(String(50), nullable=False)
name: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
threshold_value: Mapped[float] = mapped_column(Numeric, nullable=False)
threshold_unit: Mapped[str] = mapped_column(String(20), nullable=False)
enabled: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=True, server_default="true"
)
created_at: 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(), onupdate=func.now(), nullable=False
)
# Relationships
site: Mapped["Site"] = relationship("Site") # type: ignore[name-defined]
sector: Mapped["Sector | None"] = relationship("Sector") # type: ignore[name-defined]
def __repr__(self) -> str:
return f"<SiteAlertRule id={self.id} name={self.name!r} type={self.rule_type}>"
class SiteAlertEvent(Base):
"""Record of a site/sector alert firing or being resolved.
Created by the scheduled alert evaluation task (Plan 02).
Resolved manually by operators via the API.
"""
__tablename__ = "site_alert_events"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
primary_key=True,
default=uuid.uuid4,
server_default=func.gen_random_uuid(),
)
tenant_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("tenants.id", ondelete="CASCADE"),
nullable=False,
)
site_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("sites.id", ondelete="CASCADE"),
nullable=False,
)
sector_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True),
ForeignKey("sectors.id", ondelete="SET NULL"),
nullable=True,
)
rule_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True),
ForeignKey("site_alert_rules.id", ondelete="SET NULL"),
nullable=True,
)
device_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True),
ForeignKey("devices.id", ondelete="SET NULL"),
nullable=True,
)
link_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True),
ForeignKey("wireless_links.id", ondelete="SET NULL"),
nullable=True,
)
severity: Mapped[str] = mapped_column(
String(20), nullable=False, default="warning", server_default="warning"
)
message: Mapped[str] = mapped_column(Text, nullable=False)
state: Mapped[str] = mapped_column(
String(20), nullable=False, default="active", server_default="active"
)
consecutive_hits: Mapped[int] = mapped_column(
Integer, nullable=False, default=1, server_default="1"
)
triggered_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
resolved_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
resolved_by: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True),
ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
)
# Relationships
site: Mapped["Site"] = relationship("Site") # type: ignore[name-defined]
sector: Mapped["Sector | None"] = relationship("Sector") # type: ignore[name-defined]
rule: Mapped["SiteAlertRule | None"] = relationship("SiteAlertRule")
def __repr__(self) -> str:
return f"<SiteAlertEvent id={self.id} state={self.state} severity={self.severity}>"

View File

@@ -0,0 +1,168 @@
"""Pydantic schemas for site alert rules, events, and signal history."""
import uuid
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, ConfigDict, field_validator
class SiteAlertRuleCreate(BaseModel):
"""Schema for creating a new site alert rule."""
name: str
rule_type: str
threshold_value: float
threshold_unit: str
sector_id: Optional[uuid.UUID] = None
description: Optional[str] = None
enabled: bool = True
@field_validator("name")
@classmethod
def validate_name(cls, v: str) -> str:
v = v.strip()
if len(v) < 1 or len(v) > 255:
raise ValueError("Rule name must be 1-255 characters")
return v
@field_validator("rule_type")
@classmethod
def validate_rule_type(cls, v: str) -> str:
allowed = {
"device_offline_percent",
"device_offline_count",
"sector_signal_avg",
"sector_client_drop",
"signal_degradation",
}
if v not in allowed:
raise ValueError(f"rule_type must be one of: {', '.join(sorted(allowed))}")
return v
@field_validator("threshold_unit")
@classmethod
def validate_threshold_unit(cls, v: str) -> str:
allowed = {"percent", "count", "dBm"}
if v not in allowed:
raise ValueError(f"threshold_unit must be one of: {', '.join(sorted(allowed))}")
return v
class SiteAlertRuleUpdate(BaseModel):
"""Schema for updating an existing site alert rule. All fields optional."""
name: Optional[str] = None
rule_type: Optional[str] = None
threshold_value: Optional[float] = None
threshold_unit: Optional[str] = None
sector_id: Optional[uuid.UUID] = None
description: Optional[str] = None
enabled: Optional[bool] = None
@field_validator("name")
@classmethod
def validate_name(cls, v: Optional[str]) -> Optional[str]:
if v is None:
return v
v = v.strip()
if len(v) < 1 or len(v) > 255:
raise ValueError("Rule name must be 1-255 characters")
return v
@field_validator("rule_type")
@classmethod
def validate_rule_type(cls, v: Optional[str]) -> Optional[str]:
if v is None:
return v
allowed = {
"device_offline_percent",
"device_offline_count",
"sector_signal_avg",
"sector_client_drop",
"signal_degradation",
}
if v not in allowed:
raise ValueError(f"rule_type must be one of: {', '.join(sorted(allowed))}")
return v
@field_validator("threshold_unit")
@classmethod
def validate_threshold_unit(cls, v: Optional[str]) -> Optional[str]:
if v is None:
return v
allowed = {"percent", "count", "dBm"}
if v not in allowed:
raise ValueError(f"threshold_unit must be one of: {', '.join(sorted(allowed))}")
return v
class SiteAlertRuleResponse(BaseModel):
"""Site alert rule response schema."""
id: uuid.UUID
tenant_id: uuid.UUID
site_id: uuid.UUID
sector_id: Optional[uuid.UUID] = None
rule_type: str
name: str
description: Optional[str] = None
threshold_value: float
threshold_unit: str
enabled: bool
created_at: datetime
updated_at: datetime
model_config = ConfigDict(from_attributes=True)
class SiteAlertRuleListResponse(BaseModel):
"""List of site alert rules with total count."""
items: list[SiteAlertRuleResponse]
total: int
class SiteAlertEventResponse(BaseModel):
"""Site alert event response schema."""
id: uuid.UUID
tenant_id: uuid.UUID
site_id: uuid.UUID
sector_id: Optional[uuid.UUID] = None
rule_id: Optional[uuid.UUID] = None
device_id: Optional[uuid.UUID] = None
link_id: Optional[uuid.UUID] = None
severity: str
message: str
state: str
consecutive_hits: int
triggered_at: datetime
resolved_at: Optional[datetime] = None
resolved_by: Optional[uuid.UUID] = None
model_config = ConfigDict(from_attributes=True)
class SiteAlertEventListResponse(BaseModel):
"""List of site alert events with total count."""
items: list[SiteAlertEventResponse]
total: int
class SignalHistoryPoint(BaseModel):
"""A single time-bucketed signal history data point."""
timestamp: datetime
signal_avg: int
signal_min: int
signal_max: int
class SignalHistoryResponse(BaseModel):
"""Signal history response with time-bucketed data points."""
items: list[SignalHistoryPoint]
mac_address: str
range: str