Files
hold-slayer/models/device.py
Robert Helewka f7a11f2f20 Stage 5: Alembic migrations, durable call rows, one transcript truth
Alembic replaces create_all as the schema authority: async env.py
against Base.metadata (CLI and in-app entry paths share it via
config.attributes["connection"]), an autogenerated baseline of the
create_all-era schema, and init_db now runs upgrade head — stamping
the baseline first on a pre-Alembic database so existing deployments
adopt cleanly. create_all remains for tests only.

Calls are durable from the start: CallManager gains an
on_call_created hook (wired to persist_call_on_create) that inserts
an in_progress CallRecord the moment a call is created;
persist_call_on_end finalizes that same row. A SIGKILL mid-call now
leaves an in_progress row instead of erasing the call from history
(verified live against the dev database).

One transcript representation: ActiveCall.transcript_chunks holds
TranscriptEntry (t_offset_ms, speaker, text) — add_transcript stamps
real offsets from connect time, receptionist passes speaker instead
of encoding it into "caller: ..." strings, persisted chunks carry
real seek offsets, and the dead CallRecord.transcript Text column is
dropped by migration. Device.is_online migrates String → Boolean
(with a USING cast for existing rows).

Model de-triplication: CallResponse/CallStatusResponse build via
from_call classmethods (one ActiveCall→response mapping);
DeviceStatus deleted — can_receive_call is a computed field on
Device and the list endpoint returns the domain model; all row↔dict
and row↔domain mapping now lives in call_persistence.py
(record_summary/record_detail/chunk_to_dict + device row functions).

New tests/test_data_layer.py: upgrade-head-matches-models,
pre-Alembic adoption, durable in_progress rows, end-without-create
fallback, transcript offsets, consolidated response models.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 07:42:52 -04:00

75 lines
2.2 KiB
Python

"""
Device models — SIP phones, softphones, cell phones.
Devices register with the gateway and can receive transferred calls.
"""
from datetime import datetime
from enum import Enum
from typing import Optional
from pydantic import BaseModel, Field, computed_field
class DeviceType(str, Enum):
"""Types of devices that can connect to the gateway."""
SIP_PHONE = "sip_phone" # Hardware SIP phone
SOFTPHONE = "softphone" # Software SIP client
CELL = "cell" # Cell phone (reached via PSTN trunk)
TABLET = "tablet" # Tablet with SIP client
WEBRTC = "webrtc" # Browser-based WebRTC client
class DeviceBase(BaseModel):
"""Shared device fields."""
name: str # "Office SIP Phone"
type: DeviceType
extension: Optional[int] = None # 221-299, auto-assigned if omitted
sip_uri: Optional[str] = None # sip:robert@gateway.helu.ca
phone_number: Optional[str] = None # For PSTN devices (E.164)
priority: int = 10 # Routing priority (lower = higher priority)
capabilities: list[str] = Field(default_factory=lambda: ["voice"])
class Device(DeviceBase):
"""Full device model."""
id: str
is_online: bool = False
dnd: bool = False
last_seen: Optional[datetime] = None
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
@computed_field # serialized so API consumers see routability directly
@property
def can_receive_call(self) -> bool:
"""Can this device receive a call right now?"""
if self.dnd:
return False
if self.type in (DeviceType.SIP_PHONE, DeviceType.SOFTPHONE, DeviceType.WEBRTC):
return self.is_online and self.sip_uri is not None
if self.type == DeviceType.CELL:
return self.phone_number is not None
return False
class DeviceCreate(DeviceBase):
"""Request model for registering a new device."""
pass
class DeviceUpdate(BaseModel):
"""Request model for updating a device."""
name: Optional[str] = None
type: Optional[DeviceType] = None
extension: Optional[int] = None
sip_uri: Optional[str] = None
phone_number: Optional[str] = None
priority: Optional[int] = None
capabilities: Optional[list[str]] = None