Files
PTSRaiAgent/app/db/identity.py
ElebobaandClaude Opus 5.5 121b607414
CI / ruff · pytest (pull_request) Successful in 45s
feat(backend): data model — identity / clinical / audit, repositories, create_all
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>
2026-10-03 14:58:26 +03:00

64 lines
2.8 KiB
Python

"""Identity DB schema: direct identifiers (152-FZ personal data). SPEC §5, ADR-003.
Why a separate database: a leak or over-broad query on the clinical side must not reveal who the patient
is. Names, SNILS, phones and Telegram chat ids live only here; the clinical DB stores health data keyed by
`token` = HMAC(PSEUDO_KEY, SNILS) (`app.core.privacy.pseudo_id`). Without the key and this DB the clinical
data is pseudonymised. In compose each DB has its own Postgres role with no access to the other one.
Only registration and doctor-notification code may import this module (guardrail #5).
"""
from datetime import datetime
from sqlalchemy import BigInteger, Boolean, ForeignKey, SmallInteger, String, Text
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from app.db.types import TZDateTime
TOKEN_LEN = 32
class IdentityBase(DeclarativeBase):
"""Metadata of the identity DB only; never mixed with clinical metadata."""
class Patient(IdentityBase):
__tablename__ = "patients"
token: Mapped[str] = mapped_column(String(TOKEN_LEN), primary_key=True)
display_name: Mapped[str] = mapped_column(Text)
snils: Mapped[str] = mapped_column(String(14), unique=True)
phone: Mapped[str | None] = mapped_column(String(32))
tg_chat_id: Mapped[int | None] = mapped_column(BigInteger, unique=True)
birth_year: Mapped[int | None] = mapped_column(SmallInteger)
sex: Mapped[str | None] = mapped_column(String(1)) # "m" | "f"
clinic_id: Mapped[str] = mapped_column(String(64))
doctor_id: Mapped[str] = mapped_column(String(64), index=True)
# NULL consent_at = no consent: processing of health data is forbidden (152-FZ art. 10).
consent_at: Mapped[datetime | None] = mapped_column(TZDateTime)
consent_ver: Mapped[str | None] = mapped_column(String(32))
revoked_at: Mapped[datetime | None] = mapped_column(TZDateTime)
created_at: Mapped[datetime] = mapped_column(TZDateTime)
class Doctor(IdentityBase):
__tablename__ = "doctors"
doctor_id: Mapped[str] = mapped_column(String(64), primary_key=True)
full_name: Mapped[str] = mapped_column(Text)
clinic_id: Mapped[str] = mapped_column(String(64))
notify_chat: Mapped[int | None] = mapped_column(BigInteger)
on_call: Mapped[bool] = mapped_column(Boolean, default=False)
class ConnectCode(IdentityBase):
"""One-time code the doctor gives the patient (QR) to bind the bot chat to the patient record."""
__tablename__ = "connect_codes"
code: Mapped[str] = mapped_column(String(32), primary_key=True)
token: Mapped[str] = mapped_column(ForeignKey("patients.token"), index=True)
doctor_id: Mapped[str] = mapped_column(String(64))
created_at: Mapped[datetime] = mapped_column(TZDateTime)
used_at: Mapped[datetime | None] = mapped_column(TZDateTime)