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>
64 lines
2.8 KiB
Python
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)
|