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:
@@ -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)
|
||||
Reference in New Issue
Block a user