Skip to content

Commit

Permalink
Add eligibility to response model - fixes #1292 (#1300)
Browse files Browse the repository at this point in the history
* add eligibility field to Response model/serializer, set value on object creation, add/refactor queries, add helper
* add eligibility to the Response fields displayed in admin pages
* add eligibility to response columns available for researcher download/display
* display eligibility value in details table on individual responses page
* alter child birthday field to not allow blank or null
* add a default blank value for Study criteria expression, modify response eligibility migration to include change to Study model
* fix failing tests: add required birthday to Child instances
* add tests for child_in_age_range_for_study_days_difference
* add tests for eligibility field, get_eligibility_for_response returns sorted list of eligibility strings
  • Loading branch information
becky-gilbert committed Nov 20, 2023
1 parent 251aacc commit 5d170da
Show file tree
Hide file tree
Showing 13 changed files with 768 additions and 11 deletions.
18 changes: 18 additions & 0 deletions accounts/migrations/0055_alter_child_birthday_not_null_or_blank.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 3.2.11 on 2023-11-08 05:47

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
("accounts", "0054_update_demo_fields"),
]

operations = [
migrations.AlterField(
model_name="child",
name="birthday",
field=models.DateField(),
),
]
2 changes: 1 addition & 1 deletion accounts/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -342,7 +342,7 @@ class Child(models.Model):
verbose_name="identifier", default=uuid.uuid4, unique=True, db_index=True
)
given_name = models.CharField(max_length=255)
birthday = models.DateField(blank=True, null=True)
birthday = models.DateField(blank=False, null=False)
gender = models.CharField(max_length=2, choices=GENDER_CHOICES)
gender_self_describe = models.TextField(blank=True)
gestational_age_at_birth = models.PositiveSmallIntegerField(
Expand Down
31 changes: 30 additions & 1 deletion accounts/queries.py
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,30 @@ def _child_in_age_range_for_study(child, study):
if not child.birthday:
return False

age_in_days_outside_of_range = child_in_age_range_for_study_days_difference(
child, study
)
return age_in_days_outside_of_range == 0


def child_in_age_range_for_study_days_difference(child, study):
"""Check if child in age range for study, using same age calculations as in study detail and response data.
Args:
child (Child): Child model object
study (Study): Study model object
Returns:
int: the difference (in days) between the child's age and and the study's min or max age (in days).
0 if the child's age is within the study's age range.
Negative int if the child is too young (days below the minimum)
Positive int if the child is too old (days above the maximum)
"""
if not child.birthday:
return None

# Similar to _child_in_age_range_for_study, but we want to know whether the child is too young/old, rather than just a boolean.

# Age ranges are defined in DAYS, using shorthand of year = 365 days, month = 30 days,
# to provide a reliable actual unit of time rather than calendar "months" and "years" which vary in duration.
# See logic used in web/studies/study-detail.html to display eligibility to participant,
Expand All @@ -164,7 +188,12 @@ def _child_in_age_range_for_study(child, study):

min_age_in_days_estimate, max_age_in_days_estimate = study_age_range(study)
age_in_days = (date.today() - child.birthday).days
return min_age_in_days_estimate <= age_in_days <= max_age_in_days_estimate
if age_in_days <= min_age_in_days_estimate:
return age_in_days - min_age_in_days_estimate
elif age_in_days >= max_age_in_days_estimate:
return age_in_days - max_age_in_days_estimate
else:
return 0


def study_age_range(study):
Expand Down
61 changes: 61 additions & 0 deletions accounts/tests/test_accounts.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from accounts.models import Child, DemographicData, GoogleAuthenticatorTOTP, User
from accounts.queries import (
age_range_eligibility_for_study,
child_in_age_range_for_study_days_difference,
get_child_eligibility,
get_child_eligibility_for_study,
get_child_participation_eligibility,
Expand Down Expand Up @@ -892,6 +893,66 @@ def test_age_range_bounds(self):
"Child just above upper age bound is eligible",
)

def test_age_range_days_difference(self):
lower_bound = float(
self.almost_one_study.min_age_years * 365
+ self.almost_one_study.min_age_months * 30
+ self.almost_one_study.min_age_days
)
upper_bound = float(
self.almost_one_study.max_age_years * 365
+ self.almost_one_study.max_age_months * 30
+ self.almost_one_study.max_age_days
)
self.assertEqual(
child_in_age_range_for_study_days_difference(
G(
Child,
birthday=datetime.date.today()
- datetime.timedelta(days=lower_bound + 1),
),
self.almost_one_study,
),
0,
"Child just inside the Study's lower bound has a day difference of 0.",
)
self.assertEqual(
child_in_age_range_for_study_days_difference(
G(
Child,
birthday=datetime.date.today()
- datetime.timedelta(days=upper_bound - 1),
),
self.almost_one_study,
),
0,
"Child just inside the Study's upper bound has a day difference of 0.",
)
self.assertEqual(
child_in_age_range_for_study_days_difference(
G(
Child,
birthday=datetime.date.today()
- datetime.timedelta(days=lower_bound - 1),
),
self.almost_one_study,
),
-1,
"Child one day younger than Study's lower bound has a day difference of -1.",
)
self.assertEqual(
child_in_age_range_for_study_days_difference(
G(
Child,
birthday=datetime.date.today()
- datetime.timedelta(days=upper_bound + 1),
),
self.almost_one_study,
),
1,
"Child one day older than Study's upper bound has a day difference of 1.",
)

@parameterized.expand(
[
# Study 0-5 yrs, Child is 0-1 yrs
Expand Down
1 change: 1 addition & 0 deletions exp/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ class ResponseAdmin(GuardedModelAdmin):
"completed_consent_frame",
"withdrawn",
"is_preview",
"eligibility",
)
raw_id_fields = ("child", "demographic_snapshot")
empty_value_display = "None"
Expand Down
9 changes: 9 additions & 0 deletions exp/views/responses_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,15 @@ class ResponseDataColumn(NamedTuple):
extractor=lambda resp: resp.withdrawn,
name="Withdrawn",
),
ResponseDataColumn(
id="response__eligibility",
description=(
"List of eligibility codes (defined in Lookit docs), separated by spaces. Can be either 'Eligible' or "
"one or more of: 'Ineligible_TooYoung'/'Ineligible_TooOld', 'Ineligible_CriteriaExpression', 'Ineligible_Participation'."
),
extractor=lambda resp: resp.eligibility,
name="Eligibility",
),
ResponseDataColumn(
id="response__parent_feedback",
description=(
Expand Down
56 changes: 56 additions & 0 deletions studies/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,14 @@
from email.mime.image import MIMEImage

from django.core.mail.message import EmailMultiAlternatives
from django.db import models
from django.template.loader import get_template

from accounts.queries import (
child_in_age_range_for_study_days_difference,
get_child_eligibility,
get_child_participation_eligibility,
)
from project.celery import app
from project.settings import BASE_URL, EMAIL_FROM_ADDRESS

Expand Down Expand Up @@ -94,6 +100,56 @@ def repl(match):
return email


def get_eligibility_for_response(child_obj, study_obj):
"""Get current eligibility for a child and study at the time that the Response object is created.
Args:
child (Child): Child model object
study (Study): Study model object
Returns:
Set: one or more possible string eligibility values (defined in the ResponseEligibility class).
The set can contain one or more 'ineligible' values, but if the child is eligible then that should be the only value in the set.
"""
resp_elig = ResponseEligibility
eligibility_set = {resp_elig.ELIGIBLE.value}

age_range_diff = child_in_age_range_for_study_days_difference(child_obj, study_obj)
ineligible_participation = not get_child_participation_eligibility(
child_obj, study_obj
)
ineligible_criteria = not get_child_eligibility(
child_obj, study_obj.criteria_expression
)

if age_range_diff != 0 or ineligible_participation or ineligible_criteria:

eligibility_set = set()

if age_range_diff > 0:
eligibility_set.add(resp_elig.INELIGIBLE_OLD.value)
elif age_range_diff < 0:
eligibility_set.add(resp_elig.INELIGIBLE_YOUNG.value)

if ineligible_participation:
eligibility_set.add(resp_elig.INELIGIBLE_PARTICIPATION.value)

if ineligible_criteria:
eligibility_set.add(resp_elig.INELIGIBLE_CRITERIA.value)

return sorted(list(eligibility_set))


class ResponseEligibility(models.TextChoices):
"""Participant eligibility categories for Response model"""

# This must be determined at the point of the study participation/response, because child eligibility can change over time for a given study
ELIGIBLE = "Eligible"
INELIGIBLE_YOUNG = "Ineligible_TooYoung"
INELIGIBLE_OLD = "Ineligible_TooOld"
INELIGIBLE_CRITERIA = "Ineligible_CriteriaExpression"
INELIGIBLE_PARTICIPATION = "Ineligible_Participation"


class FrameActionDispatcher(object):
"""Dispatches an action based on the latest frame type of a given response.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# Generated by Django 3.2.11 on 2023-11-08 05:47

import django.contrib.postgres.fields
from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
("studies", "0093_remove_studytype_configuration"),
]

operations = [
migrations.AddField(
model_name="response",
name="eligibility",
field=django.contrib.postgres.fields.ArrayField(
base_field=models.CharField(
choices=[
("Eligible", "Eligible"),
("Ineligible_TooYoung", "Ineligible Young"),
("Ineligible_TooOld", "Ineligible Old"),
("Ineligible_CriteriaExpression", "Ineligible Criteria"),
("Ineligible_Participation", "Ineligible Participation"),
],
max_length=100,
),
blank=True,
default=list,
size=None,
),
),
migrations.AlterField(
model_name="study",
name="criteria_expression",
field=models.TextField(blank=True, default=""),
),
]
23 changes: 21 additions & 2 deletions studies/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,12 @@
from attachment_helpers import get_download_url
from project import settings
from studies import workflow
from studies.helpers import FrameActionDispatcher, send_mail
from studies.helpers import (
FrameActionDispatcher,
ResponseEligibility,
get_eligibility_for_response,
send_mail,
)
from studies.permissions import (
UMBRELLA_LAB_PERMISSION_MAP,
LabGroup,
Expand Down Expand Up @@ -346,7 +351,7 @@ class Study(models.Model):
built = models.BooleanField(default=False)
is_building = models.BooleanField(default=False)
compensation_description = models.TextField(blank=True)
criteria_expression = models.TextField(blank=True)
criteria_expression = models.TextField(blank=True, default="")
must_have_participated = models.ManyToManyField(
"self", blank=True, symmetrical=False, related_name="expected_participation"
)
Expand Down Expand Up @@ -822,6 +827,7 @@ def columns_included_in_summary(self):
"response__date_created",
"response__completed",
"response__withdrawn",
"response__eligibility",
"response__parent_feedback",
"response__birthdate_difference",
"response__video_privacy",
Expand All @@ -846,6 +852,7 @@ def columns_included_in_summary(self):
"response__id",
"response__uuid",
"response__date_created",
"response__eligibility",
"response__parent_feedback",
"response__birthdate_difference",
"response__databrary",
Expand Down Expand Up @@ -1000,6 +1007,11 @@ class Response(models.Model):
StudyType, on_delete=models.PROTECT, default=StudyType.default_pk
)
recording_method = models.CharField(max_length=50, default="pipe")
eligibility = ArrayField(
models.CharField(max_length=100, choices=ResponseEligibility.choices),
blank=True,
default=list,
)

def __str__(self):
return self.display_name
Expand Down Expand Up @@ -1188,6 +1200,13 @@ def generate_videos_from_events(self):

return Video.objects.bulk_create(video_objects)

def save(self, *args, **kwargs):
"""Override save to set eligibility value"""
if self._state.adding is True:
# only set the eligibility value when the response is first being created, not when it is being updated later
self.eligibility = get_eligibility_for_response(self.child, self.study)
super(Response, self).save(*args, **kwargs)


@receiver(post_save, sender=Response)
def take_action_on_exp_data(sender, instance, created, **kwargs):
Expand Down
2 changes: 2 additions & 0 deletions studies/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ class Meta:
"withdrawn",
"hash_child_id",
"recording_method",
"eligibility",
)

def get_hash_child_id(self, obj):
Expand Down Expand Up @@ -180,6 +181,7 @@ class Meta:
"pk",
"withdrawn",
"recording_method",
"eligibility",
)


Expand Down

0 comments on commit 5d170da

Please sign in to comment.