Source code for bhf_smarthealth.validate.qualtrics_schema
import logging
from pydantic import (
BaseModel,
model_validator,
Field,
RootModel,
ConfigDict,
BeforeValidator,
)
from datetime import date, datetime
from typing import Optional, Self, Annotated, Any
logger = logging.getLogger(__name__)
# parse datetimes with slashes and irregular spacing
[docs]
def parse_custom_datetime(value: str) -> datetime:
if isinstance(value, str):
return datetime.strptime(value.strip(), "%Y-%m-%d %H:%M:%S")
return value
[docs]
def parse_custom_date(value: str) -> date:
if isinstance(value, str):
parsed_dt = datetime.strptime(value.strip(), "%d/%m/%Y")
return parsed_dt.date()
return value
# create custom types
CustomDate = Annotated[date, BeforeValidator(parse_custom_date)]
CustomDatetime = Annotated[datetime, BeforeValidator(parse_custom_datetime)]
# Validates one row of the base questions csv
[docs]
class BaseQuestionsRow(BaseModel):
model_config = ConfigDict(extra="forbid")
duration_secs: int = Field(validation_alias="Duration (in seconds)")
record_date: CustomDatetime = Field(validation_alias="RecordedDate")
sheffield_id: str = Field(validation_alias="ResponseId")
sig_date: CustomDate = Field(validation_alias="Sig_date")
sex: str = Field(validation_alias="Sex")
gender: str = Field(validation_alias="Gender")
gender_free_text: Optional[str] = Field(validation_alias="Gender_4_TEXT")
recruited_from_NHS: Optional[str] = Field(validation_alias="site1")
recruited_from_NHS_site: Optional[str] = Field(validation_alias="site2")
recruited_from_NHS_site_free_text: Optional[str] = Field(
validation_alias="site2_9_TEXT"
)
first_heard_choice: Optional[str] = Field(validation_alias="site3")
first_heard_other_study_free_text: Optional[str] = Field(
validation_alias="site3_9_TEXT"
)
first_heard_free_text: Optional[str] = Field(validation_alias="site3_10_TEXT")
comm_groups_choice: Optional[str] = Field(validation_alias="community")
com_groups_free_text: Optional[str] = Field(validation_alias="community_7_TEXT")
has_wearable: str = Field(validation_alias="Wearable1")
wearable_list: Optional[str] | Optional[list[str]] = Field(
validation_alias="Fitbit"
)
dom_hand: Optional[str] = Field(validation_alias="Dom_hand")
wrist: Optional[str] = Field(validation_alias="Wrist")
gps_consent: bool = Field(validation_alias="GPS")
home_lsoa: str = Field(validation_alias="lsoa")
qr_source: Optional[str] = Field(validation_alias="Source")
age: int = Field(validation_alias="Age")
# Validates one row of the bridge file csv
[docs]
class BridgeFileRow(BaseModel):
model_config = ConfigDict(extra="forbid")
sheffield_id: str = Field(validation_alias="ResponseId")
patient_id_smarthealth: str
[docs]
class BridgeFileTable(RootModel):
root: list[BridgeFileRow]
[docs]
class BaseQuestionsTable(RootModel):
root: list[BaseQuestionsRow]
# Need to validate that the files always come together and hold the same IDs
[docs]
class QualtricsStreamSchema(BaseModel):
model_config = ConfigDict(extra="forbid")
base_questions: BaseQuestionsTable
bridge_file: BridgeFileTable
[docs]
@model_validator(mode="after")
def check_participant_ids_match(self) -> Self:
# Collect errors so all ID checks run, rather than fail-fast
error_messages = []
# !---- Check for IDs not in both files ----!
bridge_ids = set(root.sheffield_id for root in self.bridge_file.root)
base_ids = set(root.sheffield_id for root in self.base_questions.root)
missing_ids = base_ids.symmetric_difference(bridge_ids)
if missing_ids:
error_messages.append(
f"Some participant IDs are not in both the bridge AND base questions file: {', '.join(missing_ids)}"
)
# !---- Check for non-unique IDs ----!
validation_checks = [
("Bridge file", self.bridge_file.root, "sheffield_id"),
("Bridge file", self.bridge_file.root, "patient_id_smarthealth"),
("Base questions file", self.base_questions.root, "sheffield_id"),
]
for file_name, table, field_name in validation_checks:
try:
self._report_duplicates(
file_name=file_name, table=table, field_name=field_name
)
except ValueError as e:
error_messages.append(str(e))
if error_messages:
raise ValueError(
f"Qualtric validation checks failed for the following reasons:\n{('\n'.join(error_messages))}"
)
return self
def _report_duplicates(
self, file_name: str, table: list[Any], field_name: str
) -> None:
"""Helper function to check named field_name is unique, else return list of duplicated values"""
seen = set()
duplicates = set()
for row in table:
val = getattr(row, field_name)
if val in seen:
duplicates.add(val)
else:
seen.add(val)
if duplicates:
formatted_dups = ", ".join(duplicates)
raise ValueError(
f"Error in file {file_name}. Field {field_name} must be unique. Found duplicates: {formatted_dups}"
)