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

Add eligibility to response model - fixes #1292 #1300

Merged
merged 13 commits into from
Nov 20, 2023
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.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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"

Comment on lines +141 to +151
Copy link
Contributor

Choose a reason for hiding this comment

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

TextChoices! I should've known about this, very nice.


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