feat(backend): data model — identity / clinical / audit, repositories, create_all
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:
2026-10-03 14:58:26 +03:00
co-authored by Claude Opus 5.5
parent 10faec73e0
commit 121b607414
10 changed files with 1174 additions and 1 deletions
+63
View File
@@ -0,0 +1,63 @@
"""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)