Files
hold-slayer/api/devices.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

87 lines
2.5 KiB
Python

"""
Device Management API — Register and manage phones/softphones.
Row mapping lives in call_persistence; this layer works with the
Device domain model only.
"""
import uuid
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from api.deps import get_gateway
from core.gateway import AIPSTNGateway
from db.database import get_db
from models.device import Device, DeviceCreate, DeviceUpdate
from services import call_persistence as store
router = APIRouter()
@router.post("/", response_model=Device)
async def register_device(
device: DeviceCreate,
gateway: AIPSTNGateway = Depends(get_gateway),
db: AsyncSession = Depends(get_db),
):
"""Register a new device with the gateway."""
dev = Device(id=f"dev_{uuid.uuid4().hex[:8]}", **device.model_dump())
await store.create_device_row(db, dev)
gateway.register_device(dev)
return dev
@router.get("/", response_model=list[Device])
async def list_devices(
gateway: AIPSTNGateway = Depends(get_gateway),
):
"""List all registered devices and their status."""
return list(gateway.devices.values())
@router.get("/{device_id}", response_model=Device)
async def get_device(
device_id: str,
gateway: AIPSTNGateway = Depends(get_gateway),
):
"""Get a specific device."""
device = gateway.devices.get(device_id)
if not device:
raise HTTPException(status_code=404, detail=f"Device {device_id} not found")
return device
@router.put("/{device_id}", response_model=Device)
async def update_device(
device_id: str,
update: DeviceUpdate,
gateway: AIPSTNGateway = Depends(get_gateway),
db: AsyncSession = Depends(get_db),
):
"""Update a device."""
device = gateway.devices.get(device_id)
if not device:
raise HTTPException(status_code=404, detail=f"Device {device_id} not found")
update_data = update.model_dump(exclude_unset=True)
for key, value in update_data.items():
setattr(device, key, value)
await store.update_device_row(db, device_id, update_data)
return device
@router.delete("/{device_id}")
async def unregister_device(
device_id: str,
gateway: AIPSTNGateway = Depends(get_gateway),
db: AsyncSession = Depends(get_db),
):
"""Unregister a device."""
if device_id not in gateway.devices:
raise HTTPException(status_code=404, detail=f"Device {device_id} not found")
gateway.unregister_device(device_id)
await store.delete_device_row(db, device_id)
return {"status": "unregistered", "device_id": device_id}