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:
@@ -117,18 +117,19 @@ Plans:
|
|||||||
2. System detects and surfaces signal degradation trends (e.g., "signal dropped 8dB over 2 weeks")
|
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")
|
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")
|
4. Operator can create sector-scoped alert rules (e.g., "alert when sector average signal drops below -75dBm")
|
||||||
**Plans**: TBD
|
**Plans:** 3 plans
|
||||||
|
|
||||||
Plans:
|
Plans:
|
||||||
- [ ] 15-01: TBD
|
- [ ] 15-01-PLAN.md — Backend data model, services, and REST API for site alert rules, alert events, and signal history
|
||||||
- [ ] 15-02: TBD
|
- [ ] 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
|
## Coverage
|
||||||
|
|
||||||
| Category | Requirements | Phase | Count |
|
| Category | Requirements | Phase | Count |
|
||||||
|----------|-------------|-------|-------|
|
|----------|-------------|-------|-------|
|
||||||
| Sites | SITE-01, SITE-02, SITE-03, SITE-04, SITE-05, SITE-06 | 11 | 3/3 | Complete | 2026-03-19 | DASH-01 | 11 | 1 |
|
| Sites | SITE-01, SITE-02, SITE-03, SITE-04, SITE-05, SITE-06 | 11 | 3/3 | Complete | 2026-03-19 | DASH-01 | 11 | 1 |
|
||||||
| Site Dashboard | DASH-02, DASH-03, DASH-04 | 14 | 3/3 | Complete | 2026-03-19 | SECT-01, SECT-02, SECT-03 | 14 | 3 |
|
| Site Dashboard | DASH-02, DASH-03, DASH-04 | 14 | 3/3 | Complete | 2026-03-19 | SECT-01, SECT-02, SECT-03 | 14 | 3 |
|
||||||
| Wireless Collection | WRCL-01, WRCL-02, WRCL-03, WRCL-04, WRCL-05, WRCL-06 | 12 | 2/2 | Complete | 2026-03-19 | LINK-01, LINK-02, LINK-03, LINK-04 | 13 | 3/3 | Complete | 2026-03-19 | WRUI-01, WRUI-02, WRUI-03 | 14 | 3 |
|
| Wireless Collection | WRCL-01, WRCL-02, WRCL-03, WRCL-04, WRCL-05, WRCL-06 | 12 | 2/2 | Complete | 2026-03-19 | LINK-01, LINK-02, LINK-03, LINK-04 | 13 | 3/3 | Complete | 2026-03-19 | WRUI-01, WRUI-02, WRUI-03 | 14 | 3 |
|
||||||
| Signal Trending | TRND-01, TRND-02 | 15 | 2 |
|
| Signal Trending | TRND-01, TRND-02 | 15 | 2 |
|
||||||
| Site Alerting | ALRT-01, ALRT-02 | 15 | 2 |
|
| Site Alerting | ALRT-01, ALRT-02 | 15 | 2 |
|
||||||
@@ -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 | - |
|
| 12. Per-Client Wireless Collection | 0/2 | Planning complete | - |
|
||||||
| 13. Link Discovery + Registration Ingestion | 0/3 | Planning complete | - |
|
| 13. Link Discovery + Registration Ingestion | 0/3 | Planning complete | - |
|
||||||
| 14. Site Dashboard + Sector Views + Wireless UI | 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*
|
*Roadmap created: 2026-03-18*
|
||||||
|
|||||||
228
backend/alembic/versions/035_site_alert_rules_and_events.py
Normal file
228
backend/alembic/versions/035_site_alert_rules_and_events.py
Normal 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")
|
||||||
@@ -126,6 +126,11 @@ class Settings(BaseSettings):
|
|||||||
PASSWORD_RESET_TOKEN_EXPIRE_MINUTES: int = 30
|
PASSWORD_RESET_TOKEN_EXPIRE_MINUTES: int = 30
|
||||||
APP_BASE_URL: str = "http://localhost:3000"
|
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
|
# Retention cleanup — delete config snapshots older than N days
|
||||||
CONFIG_RETENTION_DAYS: int = 90
|
CONFIG_RETENTION_DAYS: int = 90
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ 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.device_interface import DeviceInterface
|
||||||
from app.models.wireless_link import WirelessLink, LinkState
|
from app.models.wireless_link import WirelessLink, LinkState
|
||||||
|
from app.models.site_alert import SiteAlertRule, SiteAlertEvent
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"Tenant",
|
"Tenant",
|
||||||
@@ -52,4 +53,6 @@ __all__ = [
|
|||||||
"DeviceInterface",
|
"DeviceInterface",
|
||||||
"WirelessLink",
|
"WirelessLink",
|
||||||
"LinkState",
|
"LinkState",
|
||||||
|
"SiteAlertRule",
|
||||||
|
"SiteAlertEvent",
|
||||||
]
|
]
|
||||||
|
|||||||
168
backend/app/models/site_alert.py
Normal file
168
backend/app/models/site_alert.py
Normal 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}>"
|
||||||
168
backend/app/schemas/site_alert.py
Normal file
168
backend/app/schemas/site_alert.py
Normal 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
|
||||||
Reference in New Issue
Block a user