From a6f04a6c63e74c30a0e7449620325e814bdeb968 Mon Sep 17 00:00:00 2001 From: Julian Lobbes Date: Sat, 13 May 2023 05:06:23 +0200 Subject: [PATCH] feat(backend): records models, schemas and tables --- .../7e5a8cabd3a4_create_device_tables.py | 2 +- .../a7522972878d_create_records_tables.py | 79 ++++++++++ backend/models/devices.py | 7 + backend/models/records.py | 110 ++++++++++++++ backend/schemas/records.py | 140 ++++++++++++++++++ 5 files changed, 337 insertions(+), 1 deletion(-) create mode 100644 backend/database/migrations/versions/a7522972878d_create_records_tables.py create mode 100644 backend/models/records.py create mode 100644 backend/schemas/records.py diff --git a/backend/database/migrations/versions/7e5a8cabd3a4_create_device_tables.py b/backend/database/migrations/versions/7e5a8cabd3a4_create_device_tables.py index 8978bed..249a891 100644 --- a/backend/database/migrations/versions/7e5a8cabd3a4_create_device_tables.py +++ b/backend/database/migrations/versions/7e5a8cabd3a4_create_device_tables.py @@ -14,7 +14,7 @@ from sqlalchemy.sql.functions import now revision = '7e5a8cabd3a4' down_revision = '335e07a98bc8' branch_labels = None -depends_on = '335e07a98bc8' +depends_on = down_revision def upgrade() -> None: diff --git a/backend/database/migrations/versions/a7522972878d_create_records_tables.py b/backend/database/migrations/versions/a7522972878d_create_records_tables.py new file mode 100644 index 0000000..9fac45b --- /dev/null +++ b/backend/database/migrations/versions/a7522972878d_create_records_tables.py @@ -0,0 +1,79 @@ +"""create records tables + +Revision ID: a7522972878d +Revises: 7e5a8cabd3a4 +Create Date: 2023-05-13 04:46:57.958926 + +""" +from alembic import op +from sqlalchemy import Column, ForeignKey, Integer, SmallInteger, DateTime, Enum, Numeric +from sqlalchemy.sql.functions import now + +from backend.models.records import AvpuScore, RespirationScore + + +# revision identifiers, used by Alembic. +revision = 'a7522972878d' +down_revision = '7e5a8cabd3a4' +branch_labels = None +depends_on = down_revision + + +def upgrade() -> None: + op.create_table( + 'heart_rate_records', + Column('id', Integer, primary_key=True, autoincrement=True, index=True), + Column('measured', DateTime(timezone=True), nullable=False, index=True), + Column('value', SmallInteger, nullable=False), + Column('device_id', Integer, ForeignKey('devices.id', ondelete="CASCADE"), nullable=False, index=True), + ) + + op.create_table( + 'avpu_score_records', + Column('id', Integer, primary_key=True, autoincrement=True, index=True), + Column('measured', DateTime(timezone=True), nullable=False, index=True), + Column('value', Enum(AvpuScore), nullable=False), + Column('device_id', Integer, ForeignKey('devices.id', ondelete="CASCADE"), nullable=False, index=True), + ) + + op.create_table( + 'body_temperature_records', + Column('id', Integer, primary_key=True, autoincrement=True, index=True), + Column('measured', DateTime(timezone=True), nullable=False, index=True), + Column('value', Numeric(precision=4, scale=2, decimal_return_scale=2), nullable=False), + Column('device_id', Integer, ForeignKey('devices.id', ondelete="CASCADE"), nullable=False, index=True), + ) + + op.create_table( + 'blood_pressure_records', + Column('id', Integer, primary_key=True, autoincrement=True, index=True), + Column('measured', DateTime(timezone=True), nullable=False, index=True), + Column('value_systolic', SmallInteger, nullable=False), + Column('value_diastolic', SmallInteger, nullable=False), + Column('device_id', Integer, ForeignKey('devices.id', ondelete="CASCADE"), nullable=False, index=True), + ) + + op.create_table( + 'blood_oxygen_records', + Column('id', Integer, primary_key=True, autoincrement=True, index=True), + Column('measured', DateTime(timezone=True), nullable=False, index=True), + Column('value', Numeric(precision=5, scale=2, decimal_return_scale=2), nullable=False), + Column('device_id', Integer, ForeignKey('devices.id', ondelete="CASCADE"), nullable=False, index=True), + ) + + op.create_table( + 'respiration_score_records', + Column('id', Integer, primary_key=True, autoincrement=True, index=True), + Column('measured', DateTime(timezone=True), nullable=False, index=True), + Column('value', Enum(RespirationScore), nullable=False), + Column('device_id', Integer, ForeignKey('devices.id', ondelete="CASCADE"), nullable=False, index=True), + ) + + +def downgrade() -> None: + op.drop_table('heart_rate_records') + op.drop_table('avpu_score_records') + op.drop_table('body_temperature_records') + op.drop_table('blood_pressure_records') + op.drop_table('blood_oxygen_records') + op.drop_table('respiration_score_records') diff --git a/backend/models/devices.py b/backend/models/devices.py index c48751c..37106f6 100644 --- a/backend/models/devices.py +++ b/backend/models/devices.py @@ -34,3 +34,10 @@ class Device(Base): model = relationship(DeviceModel, back_populates="instances", uselist=False) owner = relationship("Patient", back_populates="devices", uselist=False) + + heart_rate_records = relationship("HeartRateRecord", back_populates="device", uselist=True, cascade="all, delete") + avpu_score_records = relationship("AvpuScoreRecord", back_populates="device", uselist=True, cascade="all, delete") + blood_pressure_records = relationship("BloodPressureRecord", back_populates="device", uselist=True, cascade="all, delete") + blood_oxygen_records = relationship("BloodOxygenRecord", back_populates="device", uselist=True, cascade="all, delete") + body_temperature_records = relationship("BodyTemperatureRecord", back_populates="device", uselist=True, cascade="all, delete") + respiration_score_records = relationship("RespirationScoreRecord", back_populates="device", uselist=True, cascade="all, delete") diff --git a/backend/models/records.py b/backend/models/records.py new file mode 100644 index 0000000..8c5df9e --- /dev/null +++ b/backend/models/records.py @@ -0,0 +1,110 @@ +"""This module defines the SQL data model for vital parameter records.""" + +import enum + +from sqlalchemy import Column, ForeignKey, DateTime, SmallInteger, Integer, Enum, Numeric +from sqlalchemy.orm import relationship + +from backend.database.engine import Base +from backend.models.devices import Device + + +class HeartRateRecord(Base): + """Model for the heart rate records table. Measured in beats per minute (bpm).""" + + __tablename__ = "heart_rate_records" + + id = Column('id', Integer, primary_key=True, autoincrement=True, index=True) + measured = Column('measured', DateTime(timezone=True), nullable=False, index=True) + value = Column('value', SmallInteger, nullable=False) + device_id = Column('device_id', Integer, ForeignKey('devices.id', ondelete="CASCADE"), nullable=False, index=True) + + device = relationship(Device, back_populates="heart_rate_records", uselist=False) + + +class AvpuScore(enum.Enum): + alert = 'a' + voice = 'v' + pain = 'p' + unresponsive = 'u' + + +class AvpuScoreRecord(Base): + """Model for the avpu score records table. Measured as a discrete classification.""" + + __tablename__ = "avpu_score_records" + + id = Column('id', Integer, primary_key=True, autoincrement=True, index=True) + measured = Column('measured', DateTime(timezone=True), nullable=False, index=True) + value = Column('value', Enum(AvpuScore), nullable=False) + device_id = Column('device_id', Integer, ForeignKey('devices.id', ondelete="CASCADE"), nullable=False, index=True) + + device = relationship(Device, back_populates="avpu_score_records", uselist=False) + + +class BodyTemperatureRecord(Base): + """Model for the body temperature records table. Measured in degrees Celsius. + + Two digits before and two digits after the decimal point: [-99.99, 99.99]. + """ + + __tablename__ = "body_temperature_records" + + id = Column('id', Integer, primary_key=True, autoincrement=True, index=True) + measured = Column('measured', DateTime(timezone=True), nullable=False, index=True) + value = Column('value', Numeric(precision=4, scale=2, decimal_return_scale=2), nullable=False) + device_id = Column('device_id', Integer, ForeignKey('devices.id', ondelete="CASCADE"), nullable=False, index=True) + + device = relationship(Device, back_populates="body_temperature_records", uselist=False) + + +class BloodPressureRecord(Base): + """Model for the blood pressure records table. Measured in (mmHg).""" + + __tablename__ = "blood_pressure_records" + + id = Column('id', Integer, primary_key=True, autoincrement=True, index=True) + measured = Column('measured', DateTime(timezone=True), nullable=False, index=True) + value_systolic = Column('value_systolic', SmallInteger, nullable=False) + value_diastolic = Column('value_diastolic', SmallInteger, nullable=False) + device_id = Column('device_id', Integer, ForeignKey('devices.id', ondelete="CASCADE"), nullable=False, index=True) + + device = relationship(Device, back_populates="blood_pressure_records", uselist=False) + + +class BloodOxygenRecord(Base): + """Model for the blood oxygen records table. Measured as a decimal percentage. + + Three digits before and two digits after the decimal point: [-999.99, 999.99]. + """ + + __tablename__ = "blood_oxygen_records" + + id = Column('id', Integer, primary_key=True, autoincrement=True, index=True) + measured = Column('measured', DateTime(timezone=True), nullable=False, index=True) + value = Column('value', Numeric(precision=5, scale=2, decimal_return_scale=2), nullable=False) + device_id = Column('device_id', Integer, ForeignKey('devices.id', ondelete="CASCADE"), nullable=False, index=True) + + device = relationship(Device, back_populates="blood_oxygen_records", uselist=False) + + +class RespirationScore(enum.Enum): + """Measures whether patient is experiencing shortness of breath, and its severity.""" + + none = 0 + low = 1 + medium = 2 + high = 3 + + +class RespirationScoreRecord(Base): + """Model for the respiration score records table. Measured as a discrete classification.""" + + __tablename__ = "respiration_score_records" + + id = Column('id', Integer, primary_key=True, autoincrement=True, index=True) + measured = Column('measured', DateTime(timezone=True), nullable=False, index=True) + value = Column('value', Enum(RespirationScore), nullable=False) + device_id = Column('device_id', Integer, ForeignKey('devices.id', ondelete="CASCADE"), nullable=False, index=True) + + device = relationship(Device, back_populates="respiration_score_records", uselist=False) diff --git a/backend/schemas/records.py b/backend/schemas/records.py new file mode 100644 index 0000000..d54582b --- /dev/null +++ b/backend/schemas/records.py @@ -0,0 +1,140 @@ +"""This module declareds the pydantic schema representation for devices.""" + +from datetime import datetime +from abc import ABC +from decimal import Decimal + +from pydantic import BaseModel, validator + +from backend.models.records import AvpuScore, RespirationScore + + +class AbstractRecordCreate(BaseModel, ABC): + """Base class containing fields common to all vitals records during creation.""" + + measured: datetime + device_id: int + + @validator('measured') + def assert_measured_is_valid(cls, measured: datetime) -> datetime: + if measured >= datetime.now(): + raise ValueError("Time of measurement cannot be in the future.") + return measured + +class AbstractRecord(BaseModel, ABC): + """Base class containing fields common to all vitals records for display.""" + + id: int + measured: datetime + device_id: int + + @validator('measured') + def assert_measured_is_valid(cls, measured: datetime) -> datetime: + if measured >= datetime.now(): + raise ValueError("Time of measurement cannot be in the future.") + return measured + + +class AbstractHeartRateRecord(BaseModel, ABC): + value: int + + @validator('value') + def assert_value_is_valid(cls, value): + if value < 0: + raise ValueError("Value cannot be negative.") + if value >= 32767: + raise ValueError("Value is too large.") + return value + +class HeartRateRecordCreate(AbstractRecordCreate, AbstractHeartRateRecord): + pass + +class HeartRateRecord(AbstractRecord, AbstractHeartRateRecord): + pass + + +class AbstractAvpuScoreRecord(BaseModel, ABC): + value: AvpuScore + +class AvpuScoreRecordCreate(AbstractRecordCreate, AbstractAvpuScoreRecord): + pass + +class AvpuScoreRecord(AbstractRecord, AbstractAvpuScoreRecord): + pass + + +class AbstractBodyTemperatureRecord(BaseModel, ABC): + value: Decimal + + @validator('value') + def assert_value_is_valid(cls, value: Decimal) -> Decimal: + if value < 0: + raise ValueError("Value cannot be negative.") + if value >= 100: + raise ValueError("Value cannot exceed '99.99'.") + if len(value.as_tuple().digits) > 4: + raise ValueError("Value can have at most two digits after the decimal point.") + return value + +class BodyTemperatureRecordCreate(AbstractRecordCreate, AbstractBodyTemperatureRecord): + pass + +class BodyTemperatureRecord(AbstractRecord, AbstractBodyTemperatureRecord): + pass + + +class AbstractBloodPressureRecord(BaseModel, ABC): + value_systolic: int + value_diastolic: int + + @validator('value_systolic') + def assert_value_systolic_is_valid(cls, value_systolic): + if value_systolic < 0: + raise ValueError("Value (systolic) cannot be negative.") + if value_systolic >= 32767: + raise ValueError("Value (systolic) is too large.") + return value_systolic + + @validator('value_diastolic') + def assert_value_diastolic_is_valid(cls, value_diastolic): + if value_diastolic < 0: + raise ValueError("Value (diastolic) cannot be negative.") + if value_diastolic >= 32767: + raise ValueError("Value (diastolic) is too large.") + return value_diastolic + +class BloodPressureRecordCreate(AbstractRecordCreate, AbstractBloodPressureRecord): + pass + +class BloodPressureRecord(AbstractRecord, AbstractBloodPressureRecord): + pass + + +class AbstractBloodOxygenRecord(BaseModel, ABC): + value: Decimal + + @validator('value') + def assert_value_is_valid(cls, value: Decimal) -> Decimal: + if value < 0: + raise ValueError("Value cannot be negative.") + if value > 100: + raise ValueError("Value cannot exceed '100.00'.") + if len(value.as_tuple().digits) > 5: + raise ValueError("Value can have at most two digits after the decimal point.") + return value + +class BloodOxygenRecordCreate(AbstractRecordCreate, AbstractBloodOxygenRecord): + pass + +class BloodOxygenRecord(AbstractRecord, AbstractBloodOxygenRecord): + pass + + +class AbstractRespirationScoreScoreRecord(BaseModel, ABC): + value: RespirationScore + +class RespirationScoreScoreRecordCreate(AbstractRecordCreate, AbstractRespirationScoreScoreRecord): + pass + +class RespirationScoreScoreRecord(AbstractRecord, AbstractRespirationScoreScoreRecord): + pass