Server-side half of node-26#9. NodeRecord gains secondary_sdr_mode
(none|adsb|ais|op25_2) and sdr_count; checkin ingestion stores both,
and PATCH /nodes/{id} accepts and re-pushes secondary_sdr_mode the same
way hardware_preset/ppm_override already work, so it isn't wiped by a
system reassignment (see server-26#111 for the pre-existing bug that
pattern avoids repeating).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
291 lines
10 KiB
Python
291 lines
10 KiB
Python
from pydantic import BaseModel, Field
|
|
from typing import Optional, List, Dict, Any
|
|
from datetime import datetime
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Organizations — the tenant boundary. See SAAS_PLAN.md B2 and
|
|
# app/internal/auth.py's require_org(). plan_id/subscription_status and the
|
|
# stripe_* fields are deliberately inert (None) here: no billing model has
|
|
# been decided yet (participation-based / reciprocal access, not per-seat
|
|
# SaaS — see the note on FOUNDING_ORG_ID in app/internal/tenancy.py), so this
|
|
# is just the seam a future billing pass would write into, not a promise
|
|
# about what that pass looks like.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class OrganizationRecord(BaseModel):
|
|
org_id: str
|
|
name: str
|
|
created_at: datetime
|
|
created_by_uid: str
|
|
plan_id: Optional[str] = None
|
|
subscription_status: Optional[str] = None
|
|
stripe_customer_id: Optional[str] = None
|
|
stripe_subscription_id: Optional[str] = None
|
|
current_period_end: Optional[datetime] = None
|
|
seat_limit: Optional[int] = None
|
|
node_limit: Optional[int] = None
|
|
retention_days: Optional[int] = None
|
|
|
|
|
|
class OrgMember(BaseModel):
|
|
uid: str
|
|
org_id: str
|
|
org_role: str # "owner" | "member"
|
|
email: Optional[str] = None
|
|
added_at: datetime
|
|
|
|
|
|
class EnrollmentTokenRecord(BaseModel):
|
|
"""Firestore doc id is the SHA-256 hash of the raw token — see
|
|
routers/enrollment.py's _hash_secret pattern (pickup_secret_hash)."""
|
|
org_id: str
|
|
label: str
|
|
created_at: datetime
|
|
created_by_uid: str
|
|
revoked: bool = False
|
|
uses: int = 0
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Nodes
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class NodeRecord(BaseModel):
|
|
node_id: str
|
|
name: str
|
|
org_id: Optional[str] = None # stamped at enrollment; None only on pre-tenancy docs awaiting backfill
|
|
lat: float = 0.0
|
|
lon: float = 0.0
|
|
status: str = "offline" # online / offline / recording / unconfigured
|
|
configured: bool = False
|
|
last_seen: Optional[datetime] = None
|
|
assigned_system_id: Optional[str] = None
|
|
node_type: str = "fixed" # fixed or portable
|
|
secondary_sdr_mode: str = "none" # none | adsb | ais | op25_2 — requires a second physical SDR
|
|
sdr_count: int = 1 # self-reported by the node's checkin, best-effort
|
|
enforce_override_timeout: bool = True
|
|
is_overridden: bool = False
|
|
override_system_id: Optional[str] = None
|
|
override_timeout_at: Optional[datetime] = None
|
|
|
|
|
|
class CommandPayload(BaseModel):
|
|
action: str # discord_join / discord_leave / op25_restart
|
|
guild_id: Optional[str] = None
|
|
channel_id: Optional[str] = None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Systems
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class LocalKnowledgeEntry(BaseModel):
|
|
"""A local name and what it is. Both scopes, one shape (server-26#36)."""
|
|
term: str
|
|
meaning: Optional[str] = None
|
|
|
|
|
|
class AreaContextBody(BaseModel):
|
|
"""
|
|
Ground truth about the area a system or talkgroup covers.
|
|
|
|
Every field is nullable on purpose: which SCOPE an operator fills is their
|
|
declaration of how homogeneous the system is. A single-municipality system
|
|
is described once at system level and inherited by every talkgroup; a
|
|
statewide one is left null there and described per talkgroup. See
|
|
`internal/area_context.py` for the merge rules and the anchor.
|
|
|
|
`center`/`radius_km`/`resolved_from`/`resolved_at` are absent here by
|
|
design — the backend geocodes and writes those. A client that sends them is
|
|
ignored.
|
|
"""
|
|
municipality: Optional[str] = None
|
|
county: Optional[str] = None
|
|
state: Optional[str] = None
|
|
local_knowledge: List[LocalKnowledgeEntry] = []
|
|
|
|
|
|
class TalkgroupEntry(BaseModel):
|
|
"""
|
|
One entry in `config.talkgroups[]`.
|
|
|
|
Declared so the talkgroup copy of `area_context` stops being unvalidated
|
|
JSON riding inside the config blob — it is the same shape as the system's
|
|
and gets the same validator (server-26#36).
|
|
"""
|
|
model_config = {"extra": "allow"}
|
|
|
|
id: int
|
|
name: str = ""
|
|
tag: str = "other"
|
|
vocabulary: List[str] = []
|
|
area_context: Optional[AreaContextBody] = None
|
|
|
|
|
|
class SystemRecord(BaseModel):
|
|
system_id: str
|
|
org_id: Optional[str] = None
|
|
name: str
|
|
type: str # P25 / DMR / NBFM
|
|
config: Dict[str, Any] = {} # OP25-compatible config blob
|
|
ten_codes: Dict[str, str] = {} # {"10-10": "Commercial Alarm", ...}
|
|
# Ground truth about the area this system covers, fed to the transcript
|
|
# corrector and the place verifier (server-26#36 / #37). Shape is
|
|
# AreaContextBody plus the backend-owned anchor. Per-talkgroup overrides
|
|
# live inside config.talkgroups[] and rank ABOVE this, so a multi-county
|
|
# system narrows per channel rather than replacing this wholesale.
|
|
area_context: Dict[str, Any] = {}
|
|
|
|
|
|
class SystemCreate(BaseModel):
|
|
name: str
|
|
type: str
|
|
config: Dict[str, Any] = {}
|
|
ten_codes: Dict[str, str] = {}
|
|
area_context: Dict[str, Any] = {}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Calls
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class CallRecord(BaseModel):
|
|
call_id: str
|
|
node_id: str
|
|
org_id: Optional[str] = None # inherited from the node at call_start/upload — see internal/mqtt_handler.py
|
|
system_id: Optional[str] = None
|
|
talkgroup_id: Optional[int] = None
|
|
talkgroup_name: Optional[str] = None
|
|
freq: Optional[float] = None
|
|
srcaddr: Optional[str] = None
|
|
started_at: datetime
|
|
ended_at: Optional[datetime] = None
|
|
audio_gcs_uri: Optional[str] = None # canonical gs:// object location
|
|
audio_url: Optional[str] = None # NOT stored — minted per read, see internal/storage.py
|
|
duplicate_of: Optional[str] = None # another node recorded this same transmission first
|
|
transcript: Optional[str] = None # populated later by STT
|
|
incident_ids: List[str] = [] # one per scene detected in the recording
|
|
location: Optional[Dict[str, float]] = None # {lat, lng}
|
|
tags: List[str] = []
|
|
status: str = "active" # active / ended
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Incidents
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class IncidentRecord(BaseModel):
|
|
incident_id: str
|
|
org_id: Optional[str] = None # inherited from the calls that created it — see internal/incident_correlator.py
|
|
title: Optional[str] = None
|
|
type: Optional[str] = None # fire / police / ems / etc.
|
|
status: str = "active" # active / resolved
|
|
location: Optional[Dict[str, float]] = None
|
|
call_ids: List[str] = []
|
|
started_at: datetime
|
|
updated_at: datetime
|
|
summary: Optional[str] = None
|
|
tags: List[str] = []
|
|
|
|
|
|
class IncidentCreate(BaseModel):
|
|
title: str
|
|
type: str = "other"
|
|
status: str = "active"
|
|
location: Optional[Dict[str, float]] = None
|
|
call_ids: List[str] = []
|
|
summary: Optional[str] = None
|
|
tags: List[str] = []
|
|
|
|
|
|
class IncidentUpdate(BaseModel):
|
|
title: Optional[str] = None
|
|
type: Optional[str] = None
|
|
status: Optional[str] = None
|
|
location: Optional[Dict[str, float]] = None
|
|
summary: Optional[str] = None
|
|
tags: Optional[List[str]] = None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Alerts
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class AlertRule(BaseModel):
|
|
rule_id: Optional[str] = None
|
|
org_id: Optional[str] = None
|
|
name: str
|
|
keywords: List[str] = []
|
|
talkgroup_ids: List[int] = []
|
|
enabled: bool = True
|
|
discord_webhook: Optional[str] = None # POST here when rule fires
|
|
|
|
|
|
class AlertRuleUpdate(BaseModel):
|
|
name: Optional[str] = None
|
|
keywords: Optional[List[str]] = None
|
|
talkgroup_ids: Optional[List[int]] = None
|
|
enabled: Optional[bool] = None
|
|
discord_webhook: Optional[str] = None
|
|
|
|
|
|
class AlertEvent(BaseModel):
|
|
alert_id: Optional[str] = None
|
|
org_id: Optional[str] = None
|
|
rule_id: str
|
|
rule_name: str
|
|
call_id: str
|
|
node_id: str
|
|
talkgroup_id: Optional[int] = None
|
|
talkgroup_name: Optional[str] = None
|
|
matched_keywords: List[str] = []
|
|
transcript_snippet: Optional[str] = None
|
|
triggered_at: Optional[datetime] = None
|
|
acknowledged: bool = False
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Trips
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TripCreate(BaseModel):
|
|
name: str
|
|
location: str
|
|
maps_link: Optional[str] = None
|
|
start_date: str # YYYY-MM-DD
|
|
end_date: str # YYYY-MM-DD
|
|
available_tags: List[str] = [] # tag labels configured for this trip
|
|
overlap_tags: List[str] = [] # subset of available_tags that allow time overlap
|
|
visibility: str = "public" # "public" | "private"
|
|
invited_discord_ids: List[str] = [] # discord user IDs allowed on private trips
|
|
|
|
|
|
class TripEventCreate(BaseModel):
|
|
title: str
|
|
date: str # YYYY-MM-DD, must fall within parent trip range
|
|
start_time: Optional[str] = None # HH:MM (24h)
|
|
end_time: Optional[str] = None # HH:MM (24h)
|
|
location: Optional[str] = None # inherits trip location if None
|
|
maps_link: Optional[str] = None
|
|
place_id: Optional[str] = None # Google Place ID
|
|
notes: Optional[str] = None
|
|
tags: List[str] = [] # tag labels applied to this event
|
|
|
|
|
|
class TripEventUpdate(BaseModel):
|
|
title: Optional[str] = None
|
|
date: Optional[str] = None
|
|
start_time: Optional[str] = None
|
|
end_time: Optional[str] = None
|
|
location: Optional[str] = None
|
|
maps_link: Optional[str] = None
|
|
place_id: Optional[str] = None
|
|
notes: Optional[str] = None
|
|
tags: Optional[List[str]] = None
|
|
|
|
|
|
class AttendeeAction(BaseModel):
|
|
discord_user_id: str
|
|
discord_username: Optional[str] = None
|