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

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