feat(backend): data model — identity / clinical / audit, repositories, create_all
CI / ruff · pytest (pull_request) Successful in 45s
CI / ruff · pytest (pull_request) Successful in 45s
Two DeclarativeBases on two engines (ADR-003): identity holds direct identifiers, clinical holds token-keyed pseudonymised data plus audit_log. Portable types (JSONB variant, tz-aware UTC timestamps rejecting naive values) so tests run on SQLite. init_db() runs create_all at startup. Guardrail #5 checked by AST and import-graph tests. Refs #6 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+298
@@ -0,0 +1,298 @@
|
||||
"""Thin clinical-DB repository helpers. Must not import `app.db.identity` (guardrail #5).
|
||||
|
||||
Functions work inside the caller's session (`get_clinical_session()`), flush but never commit, and take
|
||||
time as an explicit argument (ADR-005).
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
from datetime import date, datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.types import AlertLevel, ScrubbedText
|
||||
from app.db.audit import audit
|
||||
from app.db.clinical import (
|
||||
Alert,
|
||||
ClockState,
|
||||
DailyCheckin,
|
||||
DialogState,
|
||||
ItemResponse,
|
||||
Message,
|
||||
PatientProfile,
|
||||
ScaleResult,
|
||||
)
|
||||
|
||||
_CHECKIN_FIELDS = frozenset(
|
||||
{
|
||||
"mood",
|
||||
"med_taken",
|
||||
"sleep_bucket",
|
||||
"alcohol_bucket",
|
||||
"side_effects",
|
||||
"quit_intent",
|
||||
"nightmares",
|
||||
"free_text_scrubbed",
|
||||
}
|
||||
)
|
||||
_PROFILE_FIELDS = frozenset(
|
||||
{
|
||||
"doctor_id",
|
||||
"modules",
|
||||
"therapy_start",
|
||||
"next_visit",
|
||||
"birth_year",
|
||||
"sex",
|
||||
"enhanced_monitoring",
|
||||
"status_color",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# --- patient profiles -------------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def get_profile(session: AsyncSession, token: str) -> PatientProfile | None:
|
||||
return await session.get(PatientProfile, token)
|
||||
|
||||
|
||||
async def upsert_profile(session: AsyncSession, *, token: str, **fields: Any) -> PatientProfile:
|
||||
"""Create or update a profile; only the passed fields are changed."""
|
||||
unknown = set(fields) - _PROFILE_FIELDS
|
||||
if unknown:
|
||||
raise ValueError(f"unknown profile fields: {sorted(unknown)}")
|
||||
profile = await session.get(PatientProfile, token)
|
||||
if profile is None:
|
||||
profile = PatientProfile(token=token, modules=[], enhanced_monitoring=False)
|
||||
session.add(profile)
|
||||
for key, value in fields.items():
|
||||
setattr(profile, key, value)
|
||||
await session.flush()
|
||||
return profile
|
||||
|
||||
|
||||
async def patients_of_doctor(session: AsyncSession, doctor_id: str) -> Sequence[PatientProfile]:
|
||||
rows = await session.scalars(
|
||||
select(PatientProfile).where(PatientProfile.doctor_id == doctor_id).order_by(PatientProfile.token)
|
||||
)
|
||||
return rows.all()
|
||||
|
||||
|
||||
# --- daily check-ins and scales ---------------------------------------------------------------------
|
||||
|
||||
|
||||
async def upsert_checkin(
|
||||
session: AsyncSession, *, token: str, day: date, now: datetime, **fields: Any
|
||||
) -> DailyCheckin:
|
||||
"""Merge answers into the (token, day) check-in.
|
||||
|
||||
The daily dialog fills the row step by step (mood, then medication, then the module question), so
|
||||
only fields passed and not None overwrite stored values.
|
||||
"""
|
||||
unknown = set(fields) - _CHECKIN_FIELDS
|
||||
if unknown:
|
||||
raise ValueError(f"unknown check-in fields: {sorted(unknown)}")
|
||||
row = await session.scalar(
|
||||
select(DailyCheckin).where(DailyCheckin.token == token, DailyCheckin.day == day)
|
||||
)
|
||||
if row is None:
|
||||
row = DailyCheckin(token=token, day=day, side_effects=[], updated_at=now)
|
||||
session.add(row)
|
||||
for key, value in fields.items():
|
||||
if value is not None:
|
||||
setattr(row, key, value)
|
||||
row.updated_at = now
|
||||
await session.flush()
|
||||
return row
|
||||
|
||||
|
||||
async def list_checkins(
|
||||
session: AsyncSession, token: str, since: date | None = None
|
||||
) -> Sequence[DailyCheckin]:
|
||||
stmt = select(DailyCheckin).where(DailyCheckin.token == token)
|
||||
if since is not None:
|
||||
stmt = stmt.where(DailyCheckin.day >= since)
|
||||
return (await session.scalars(stmt.order_by(DailyCheckin.day))).all()
|
||||
|
||||
|
||||
async def add_item_response(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
token: str,
|
||||
scale: str,
|
||||
item_id: str,
|
||||
score: int | None,
|
||||
source: str,
|
||||
ts: datetime,
|
||||
confidence: float | None = None,
|
||||
evidence: ScrubbedText | None = None,
|
||||
) -> ItemResponse:
|
||||
row = ItemResponse(
|
||||
token=token,
|
||||
scale=scale,
|
||||
item_id=item_id,
|
||||
score=score,
|
||||
source=source,
|
||||
ts=ts,
|
||||
confidence=confidence,
|
||||
evidence=evidence,
|
||||
)
|
||||
session.add(row)
|
||||
await session.flush()
|
||||
return row
|
||||
|
||||
|
||||
async def add_scale_result(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
token: str,
|
||||
scale: str,
|
||||
total: int,
|
||||
severity: str,
|
||||
complete: bool,
|
||||
ts: datetime,
|
||||
item9: int | None = None,
|
||||
scheduled: bool = True,
|
||||
flags: dict[str, Any] | None = None,
|
||||
period_start: date | None = None,
|
||||
period_end: date | None = None,
|
||||
) -> ScaleResult:
|
||||
row = ScaleResult(
|
||||
token=token,
|
||||
scale=scale,
|
||||
total=total,
|
||||
severity=severity,
|
||||
complete=complete,
|
||||
ts=ts,
|
||||
item9=item9,
|
||||
scheduled=scheduled,
|
||||
flags=flags or {},
|
||||
period_start=period_start,
|
||||
period_end=period_end,
|
||||
)
|
||||
session.add(row)
|
||||
await session.flush()
|
||||
return row
|
||||
|
||||
|
||||
async def list_scale_results(
|
||||
session: AsyncSession, token: str, scale: str | None = None
|
||||
) -> Sequence[ScaleResult]:
|
||||
stmt = select(ScaleResult).where(ScaleResult.token == token)
|
||||
if scale is not None:
|
||||
stmt = stmt.where(ScaleResult.scale == scale)
|
||||
return (await session.scalars(stmt.order_by(ScaleResult.ts))).all()
|
||||
|
||||
|
||||
# --- alerts -----------------------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def add_alert(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
token: str,
|
||||
level: AlertLevel,
|
||||
code: str,
|
||||
reason: str,
|
||||
ts: datetime,
|
||||
params: dict[str, Any] | None = None,
|
||||
) -> Alert:
|
||||
row = Alert(token=token, level=level.name.lower(), code=code, reason=reason, ts=ts, params=params or {})
|
||||
session.add(row)
|
||||
await session.flush()
|
||||
return row
|
||||
|
||||
|
||||
async def open_alert_codes(session: AsyncSession, token: str) -> set[str]:
|
||||
"""Codes of unacknowledged alerts — lets rules avoid raising the same alert twice."""
|
||||
rows = await session.scalars(select(Alert.code).where(Alert.token == token, Alert.acked_at.is_(None)))
|
||||
return set(rows.all())
|
||||
|
||||
|
||||
async def list_alerts(
|
||||
session: AsyncSession, *, token: str | None = None, open_only: bool = False
|
||||
) -> Sequence[Alert]:
|
||||
stmt = select(Alert)
|
||||
if token is not None:
|
||||
stmt = stmt.where(Alert.token == token)
|
||||
if open_only:
|
||||
stmt = stmt.where(Alert.acked_at.is_(None))
|
||||
return (await session.scalars(stmt.order_by(Alert.ts.desc(), Alert.id.desc()))).all()
|
||||
|
||||
|
||||
async def ack_alert(session: AsyncSession, *, alert_id: int, doctor_id: str, ts: datetime) -> Alert | None:
|
||||
"""Doctor acknowledges an alert; the access is written to audit_log in the same transaction.
|
||||
|
||||
Re-acking is a no-op (first acknowledgement wins, no duplicate audit record). Unknown id -> None.
|
||||
"""
|
||||
alert = await session.get(Alert, alert_id)
|
||||
if alert is None or alert.acked_at is not None:
|
||||
return alert
|
||||
alert.acked_by = doctor_id
|
||||
alert.acked_at = ts
|
||||
await audit(
|
||||
session,
|
||||
actor=doctor_id,
|
||||
action="ack_alert",
|
||||
token=alert.token,
|
||||
details={"alert_id": alert.id, "code": alert.code, "level": alert.level},
|
||||
ts=ts,
|
||||
)
|
||||
return alert
|
||||
|
||||
|
||||
# --- dialog -----------------------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def get_dialog_state(session: AsyncSession, token: str) -> DialogState | None:
|
||||
return await session.get(DialogState, token)
|
||||
|
||||
|
||||
async def set_dialog_state(
|
||||
session: AsyncSession, *, token: str, state: str, data: dict[str, Any], now: datetime
|
||||
) -> DialogState:
|
||||
row = await session.get(DialogState, token)
|
||||
if row is None:
|
||||
row = DialogState(token=token, state=state, data=data, updated_at=now)
|
||||
session.add(row)
|
||||
else:
|
||||
row.state = state
|
||||
row.data = dict(data) # new object so SQLAlchemy sees the JSON change
|
||||
row.updated_at = now
|
||||
await session.flush()
|
||||
return row
|
||||
|
||||
|
||||
async def add_message(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
token: str,
|
||||
direction: str,
|
||||
text: ScrubbedText,
|
||||
ts: datetime,
|
||||
fsm_state: str | None = None,
|
||||
) -> Message:
|
||||
if direction not in ("in", "out"):
|
||||
raise ValueError("direction must be 'in' or 'out'")
|
||||
row = Message(token=token, direction=direction, text_scrubbed=text, ts=ts, fsm_state=fsm_state)
|
||||
session.add(row)
|
||||
await session.flush()
|
||||
return row
|
||||
|
||||
|
||||
# --- demo clock persistence (ADR-005) ----------------------------------------------------------------
|
||||
|
||||
|
||||
async def load_clock_offset(session: AsyncSession) -> float:
|
||||
row = await session.get(ClockState, 1)
|
||||
return row.offset_seconds if row else 0.0
|
||||
|
||||
|
||||
async def save_clock_offset(session: AsyncSession, offset_seconds: float) -> None:
|
||||
row = await session.get(ClockState, 1)
|
||||
if row is None:
|
||||
session.add(ClockState(id=1, offset_seconds=offset_seconds))
|
||||
else:
|
||||
row.offset_seconds = offset_seconds
|
||||
await session.flush()
|
||||
Reference in New Issue
Block a user