Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Call Digital Growth Charts API asynchronously #418

Merged
merged 8 commits into from
Dec 5, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
132 changes: 132 additions & 0 deletions project/npda/forms/external_visit_validators.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
from dataclasses import dataclass
from decimal import Decimal
from datetime import date
import logging

import asyncio
from asgiref.sync import async_to_sync

from django.core.exceptions import ValidationError
from httpx import HTTPError, AsyncClient

from ..general_functions.dgc_centile_calculations import (
calculate_centiles_z_scores,
calculate_bmi,
)


logger = logging.getLogger(__name__)


@dataclass
class CentileAndSDS:
centile: Decimal
sds: Decimal


@dataclass
class VisitExternalValidationResult:
height_result: CentileAndSDS | ValidationError | None
weight_result: CentileAndSDS | ValidationError | None
bmi: Decimal | None
bmi_result: CentileAndSDS | ValidationError | None


async def _calculate_centiles_z_scores(
birth_date: date, observation_date: date, sex: int, measurement_method: str, observation_value: Decimal | None, async_client: AsyncClient
) -> CentileAndSDS | None:
if observation_value is None:
logger.warning(
f"Cannot calculate centiles and z-scores for {measurement_method} as it is missing"
)
return None

try:
centile, sds = await calculate_centiles_z_scores(
birth_date=birth_date,
observation_date=observation_date,
measurement_method=measurement_method,
observation_value=observation_value,
sex=sex,
async_client=async_client,
)

return CentileAndSDS(centile, sds)
except HTTPError as err:
logger.warning(f"Error calculating centiles and z-scores for {measurement_method} {err}")

# TODO: test questionnaire missing height, weight and observation_date. Do we get blank values for them?

async def validate_visit_async(
birth_date: date,
observation_date: date | None,
sex: int | None,
height: Decimal | None,
weight: Decimal | None,
async_client: AsyncClient
) -> VisitExternalValidationResult:
ret = VisitExternalValidationResult(None, None, None, None)

if not observation_date:
logger.warning("Observation date is not specified. Cannot calculate centiles and z-scores.")
return ret

if sex == 1:
sex = "male"
elif sex == 2:
sex = "female"
else:
logger.warning(
"Sex is not known or not specified. Cannot calculate centiles and z-scores."
)
return ret

if height is not None and weight is not None:
bmi = round(calculate_bmi(height, weight), 1)
ret.bmi = bmi
else:
logger.warning(
"Missing height or weight. Cannot calculate centiles and z-scores."
)

validate_height_task = _calculate_centiles_z_scores(birth_date, observation_date, sex, "height", height, async_client)
validate_weight_task = _calculate_centiles_z_scores(birth_date, observation_date, sex, "weight", weight, async_client)
validate_bmi_task = _calculate_centiles_z_scores(birth_date, observation_date, sex, "bmi", bmi, async_client)

# This is the Python equivalent of Promise.allSettled
# Run all the lookups in parallel but retain exceptions per job rather than returning the first one
[height_result, weight_result, bmi_result] = (
await asyncio.gather(
validate_height_task,
validate_weight_task,
validate_bmi_task,
return_exceptions=True,
)
)

for [result, result_field] in [
[height_result, "height_result"],
[weight_result, "weight_result"],
[bmi_result, "bmi_result"]
]:
if isinstance(result, Exception) and not type(result) is ValidationError:
raise result

setattr(ret, result_field, result)

return ret


def validate_visit_sync(
birth_date: date,
observation_date: date | None,
sex: int | None,
height: Decimal | None,
weight: Decimal | None
) -> VisitExternalValidationResult:
async def wrapper():
async with AsyncClient() as client:
ret = await validate_visit_async(birth_date, observation_date, sex, height, weight, client)
return ret

return async_to_sync(wrapper)()
190 changes: 59 additions & 131 deletions project/npda/forms/visit_form.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,7 @@
from ...constants.styles import *
from ...constants import *
from ..general_functions.validate_dates import validate_date
from ..general_functions.dgc_centile_calculations import (
calculate_centiles_z_scores,
calculate_bmi,
)
from ..forms.external_visit_validators import validate_visit_sync
from ..models import Visit


Expand Down Expand Up @@ -61,15 +58,7 @@ class Meta:
"hospital_discharge_date",
"hospital_admission_reason",
"dka_additional_therapies",
"hospital_admission_other",
# calculated fields
"height_centile",
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As these are calculated fields it should not be possible to submit them through the form

"height_sds",
"weight_centile",
"weight_sds",
"bmi",
"bmi_centile",
"bmi_sds",
"hospital_admission_other"
]

widgets = {
Expand Down Expand Up @@ -112,31 +101,9 @@ class Meta:
"hospital_discharge_date": DateInput(),
"hospital_admission_reason": forms.Select(),
"dka_additional_therapies": forms.Select(),
"hospital_admission_other": forms.TextInput(attrs={"class": TEXT_INPUT}),
# calculated fields as hidden inputs
"height_centile": forms.HiddenInput(),
"height_sds": forms.HiddenInput(),
"weight_centile": forms.HiddenInput(),
"weight_sds": forms.HiddenInput(),
"bmi": forms.HiddenInput(),
"bmi_centile": forms.HiddenInput(),
"bmi_sds": forms.HiddenInput(),
"hospital_admission_other": forms.TextInput(attrs={"class": TEXT_INPUT})
}

height_centile = forms.DecimalField(widget=forms.HiddenInput(), required=False)
height_sds = forms.DecimalField(
widget=forms.HiddenInput(), required=False, decimal_places=1
)
weight_centile = forms.DecimalField(widget=forms.HiddenInput(), required=False)
weight_sds = forms.DecimalField(
widget=forms.HiddenInput(), required=False, decimal_places=1
)
bmi = forms.DecimalField(widget=forms.HiddenInput(), required=False)
bmi_centile = forms.DecimalField(
widget=forms.HiddenInput(), required=False, decimal_places=1
)
bmi_sds = forms.DecimalField(widget=forms.HiddenInput(), required=False)

categories = [
"Measurements",
"HBA1c",
Expand Down Expand Up @@ -472,6 +439,7 @@ def clean_total_cholesterol(self):

def clean_visit_date(self):
data = self.cleaned_data["visit_date"]

valid, error = validate_date(
date_under_examination_field_name="visit_date",
date_under_examination_label_name="Visit/Appointment Date",
Expand Down Expand Up @@ -740,114 +708,53 @@ def clean_hospital_discharge_date(self):

return self.cleaned_data["hospital_discharge_date"]

def handle_async_validation_errors(self):
# These are calculated fields but we handle them in the form because we want to add validation errors.
# Conceptually we both "clean" weight and height and derive new fields from them. The actual data is
# saved in .save() below - this is just for the validation errors.

for [result_field, fields_to_attach_errors] in [
["height_result", ["height"]],
["weight_result", ["weight"]],
["bmi_result", ["height", "weight"]]
]:
result = getattr(self.async_validation_results, result_field)

if result and type(result) is ValidationError:
for field in fields_to_attach_errors:
self.add_error(field, result)

def clean(self):
cleaned_data = super().clean()

# prevent calculated fields from being saved as empty strings
if cleaned_data["height_centile"] == "":
cleaned_data["height_centile"] = None
if cleaned_data["height_sds"] == "":
cleaned_data["height_sds"] = None
if cleaned_data["weight_centile"] == "":
cleaned_data["weight_centile"] = None
if cleaned_data["weight_sds"] == "":
cleaned_data["weight_sds"] = None
if cleaned_data["bmi"] == "":
cleaned_data["bmi"] = None
if cleaned_data["bmi_centile"] == "":
cleaned_data["bmi_centile"] = None
if cleaned_data["bmi_sds"] == "":
cleaned_data["bmi_sds"] = None

def round_to_one_decimal_place(value):
return Decimal(value).quantize(
Decimal("0.1"), rounding=ROUND_HALF_UP
) # round to 1 decimal place: although the rounding is done in the clean methods for height and weight, this is a final check

# calculate centile and SDS for measurements if present
basic_params = all(
param is not None
for param in [
self.patient.date_of_birth,
cleaned_data["height_weight_observation_date"],
self.patient.sex,
]
) # check if all required parameters are present. Observation date has already been cleaned and validated at this point
if "height" in cleaned_data:
height = cleaned_data["height"]
else:
height = None
if "weight" in cleaned_data:
weight = cleaned_data["weight"]
else:
weight = None
birth_date = self.patient.date_of_birth
observation_date = cleaned_data["height_weight_observation_date"]
sex = self.patient.sex

observation_date = cleaned_data.get("height_weight_observation_date")

height = cleaned_data.get("height")
if height is not None:
cleaned_data["height"] = round_to_one_decimal_place(height)
cleaned_data["height"] = height = round_to_one_decimal_place(height)

weight = cleaned_data.get("weight")
if weight is not None:
cleaned_data["weight"] = round_to_one_decimal_place(weight)

if basic_params and height is not None:
# cleaned_data["height"] = round_to_one_decimal_place(height)
try:
centile, sds = calculate_centiles_z_scores(
birth_date=birth_date,
observation_date=observation_date,
measurement_method="height",
observation_value=round_to_one_decimal_place(height),
sex=sex,
)
cleaned_data["height_centile"] = round_to_one_decimal_place(centile)
cleaned_data["height_sds"] = round_to_one_decimal_place(sds)
except Exception as e:
cleaned_data["height_centile"] = None
cleaned_data["height_sds"] = None
# we are not raising a validation error here as sds and centile are not required fields
pass
if basic_params and weight is not None:
# cleaned_data["weight"] = round_to_one_decimal_place(weight)
try:
centile, sds = calculate_centiles_z_scores(
birth_date=birth_date,
observation_date=observation_date,
measurement_method="weight",
observation_value=round_to_one_decimal_place(weight),
sex=sex,
)
cleaned_data["weight_centile"] = round_to_one_decimal_place(centile)
cleaned_data["weight_sds"] = round_to_one_decimal_place(sds)
except Exception as e:
cleaned_data["weight_centile"] = None
cleaned_data["weight_sds"] = None
# we are not raising a validation error here as sds and centile are not required fields
pass
# calculate BMI, BMI centile and BMI SDS, if height and weight are present
if basic_params and height is not None and weight is not None:
cleaned_data["bmi"] = calculate_bmi(height, weight)
if (
cleaned_data["bmi"] is None
): # the BMI calculation returns None if BMI is > 99
cleaned_data["bmi_centile"] = None
cleaned_data["bmi_sds"] = None
else:
try:
centile, sds = calculate_centiles_z_scores(
birth_date=birth_date,
observation_date=observation_date,
measurement_method="bmi",
observation_value=round(cleaned_data["bmi"], 1),
sex=sex,
)
cleaned_data["bmi_centile"] = round_to_one_decimal_place(centile)
cleaned_data["bmi_sds"] = round_to_one_decimal_place(sds)
except Exception as e:
cleaned_data["bmi_centile"] = None
cleaned_data["bmi_sds"] = None
# we are not raising a validation error here as sds and centile are not required fields
pass
cleaned_data["weight"] = weight = round_to_one_decimal_place(weight)

if not getattr(self, "async_validation_results", None):
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the same trick we already use for the patient form, allows us to run things asynchronously for the CSV upload but synchronously for the questionnaire form submission

self.async_validation_results = validate_visit_sync(
birth_date=birth_date,
observation_date=observation_date,
height=height,
weight=weight,
sex=sex
)

self.handle_async_validation_errors()

# Check that the hba1c value is within the correct range
hba1c_value = cleaned_data["hba1c"]
Expand Down Expand Up @@ -876,3 +783,24 @@ def round_to_one_decimal_place(value):
)

return cleaned_data

# Called when submitting the questionnaire. For CSV upload, instances are created directly in csv_upload to preserve
# invalid data. Without overriding save here, the data from the dGC call would not be saved as the fields are not
# in the list at the top (that we expect to receive from a POST).
def save(self, commit=True):
instance = super().save(commit=False)

if getattr(self, "async_validation_results"):
instance.bmi = self.async_validation_results.bmi

for field_prefix in ["height", "weight", "bmi"]:
result = getattr(self.async_validation_results, f"{field_prefix}_result")

if result and not type(result) is ValidationError:
setattr(instance, f"{field_prefix}_centile", result.centile)
setattr(instance, f"{field_prefix}_sds", result.sds)

if commit:
instance.save()

return instance
Loading
Loading