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:
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
|
||||
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
|
||||
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
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