From 32a4f10c68bafa35487dbdfd7d85b8783cb013ea Mon Sep 17 00:00:00 2001 From: Becky Gilbert Date: Fri, 3 Nov 2023 12:47:42 -0700 Subject: [PATCH 01/12] add eligibility field to Response model/serializer, set value on object creation, add/refactor queries, add helper --- accounts/queries.py | 31 +++++++++- studies/helpers.py | 56 +++++++++++++++++++ .../migrations/0094_response_eligibility.py | 36 ++++++++++++ studies/models.py | 19 ++++++- studies/serializers.py | 2 + 5 files changed, 142 insertions(+), 2 deletions(-) create mode 100644 studies/migrations/0094_response_eligibility.py diff --git a/accounts/queries.py b/accounts/queries.py index 1a58664a1..4c975dd60 100644 --- a/accounts/queries.py +++ b/accounts/queries.py @@ -135,6 +135,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, @@ -144,7 +168,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): diff --git a/studies/helpers.py b/studies/helpers.py index b4b1b67ce..af7cbdc1f 100644 --- a/studies/helpers.py +++ b/studies/helpers.py @@ -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 @@ -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} + + 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) + elif age_range_diff < 0: + eligibility_set.add(resp_elig.INELIGIBLE_YOUNG) + + if ineligible_participation: + eligibility_set.add(resp_elig.INELIGIBLE_PARTICIPATION) + + if ineligible_criteria: + eligibility_set.add(resp_elig.INELIGIBLE_CRITERIA) + + return 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. diff --git a/studies/migrations/0094_response_eligibility.py b/studies/migrations/0094_response_eligibility.py new file mode 100644 index 000000000..4a042b894 --- /dev/null +++ b/studies/migrations/0094_response_eligibility.py @@ -0,0 +1,36 @@ +# Generated by Django 3.2.11 on 2023-11-02 21:10 + +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_TooYoung"), + ("Ineligible_TooOld", "Ineligible_TooOld"), + ( + "Ineligible_CriteriaExpression", + "Ineligible_CriteriaExpression", + ), + ("Ineligible_Participation", "Ineligible_Participation"), + ], + max_length=100, + ), + blank=True, + default=list, + size=None, + ), + ), + ] diff --git a/studies/models.py b/studies/models.py index 292b568b1..f2b252d94 100644 --- a/studies/models.py +++ b/studies/models.py @@ -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, @@ -1000,6 +1005,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 @@ -1188,6 +1198,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): diff --git a/studies/serializers.py b/studies/serializers.py index 0960694ba..1df5fb1bf 100644 --- a/studies/serializers.py +++ b/studies/serializers.py @@ -130,6 +130,7 @@ class Meta: "withdrawn", "hash_child_id", "recording_method", + "eligibility", ) def get_hash_child_id(self, obj): @@ -180,6 +181,7 @@ class Meta: "pk", "withdrawn", "recording_method", + "eligibility", ) From 7ea95c9acb0406782d25c943b300a294843ec679 Mon Sep 17 00:00:00 2001 From: Becky Gilbert Date: Fri, 3 Nov 2023 12:49:55 -0700 Subject: [PATCH 02/12] add eligibility to the Response fields displayed in admin pages --- exp/admin.py | 1 + 1 file changed, 1 insertion(+) diff --git a/exp/admin.py b/exp/admin.py index 39178542b..09ca3d706 100644 --- a/exp/admin.py +++ b/exp/admin.py @@ -48,6 +48,7 @@ class ResponseAdmin(GuardedModelAdmin): "completed_consent_frame", "withdrawn", "is_preview", + "eligibility", ) raw_id_fields = ("child", "demographic_snapshot") empty_value_display = "None" From fe5b0370a5a15ce6f1cf765b070086b2defef40b Mon Sep 17 00:00:00 2001 From: Becky Gilbert Date: Fri, 3 Nov 2023 12:59:43 -0700 Subject: [PATCH 03/12] add eligibility to response columns available for researcher download/display --- exp/views/responses_data.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/exp/views/responses_data.py b/exp/views/responses_data.py index 2cca3259a..f75b53d0b 100644 --- a/exp/views/responses_data.py +++ b/exp/views/responses_data.py @@ -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=( From 400bfeb1a020b732b29169d9ab47d06d7d49697c Mon Sep 17 00:00:00 2001 From: Becky Gilbert Date: Fri, 3 Nov 2023 13:00:46 -0700 Subject: [PATCH 04/12] display eligibility value in details table on individual responses page --- studies/models.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/studies/models.py b/studies/models.py index f2b252d94..b0ce1b379 100644 --- a/studies/models.py +++ b/studies/models.py @@ -827,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", @@ -851,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", From bb4fb0ebd6103e7661e3125147c2d97730cf92ac Mon Sep 17 00:00:00 2001 From: Becky Gilbert Date: Mon, 6 Nov 2023 22:38:23 -0800 Subject: [PATCH 05/12] handle missing fields needed to set eligibility value --- studies/helpers.py | 28 +++++++++++++++++++++------- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/studies/helpers.py b/studies/helpers.py index af7cbdc1f..f32f3695b 100644 --- a/studies/helpers.py +++ b/studies/helpers.py @@ -7,6 +7,7 @@ from django.core.mail.message import EmailMultiAlternatives from django.db import models from django.template.loader import get_template +from lark import UnexpectedCharacters, UnexpectedInput from accounts.queries import ( child_in_age_range_for_study_days_difference, @@ -117,23 +118,36 @@ def get_eligibility_for_response(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: + # handle missing/invalid criteria_expression + try: + ineligible_criteria = not get_child_eligibility( + child_obj, study_obj.criteria_expression + ) + except UnexpectedCharacters: + ineligible_criteria = False + except UnexpectedInput: + ineligible_criteria = False + + if ( + age_range_diff is None + or age_range_diff != 0 + or ineligible_participation + or ineligible_criteria + ): eligibility_set = set() - if age_range_diff > 0: + if age_range_diff is not None and age_range_diff > 0: eligibility_set.add(resp_elig.INELIGIBLE_OLD) - elif age_range_diff < 0: + elif age_range_diff is not None and age_range_diff < 0: eligibility_set.add(resp_elig.INELIGIBLE_YOUNG) if ineligible_participation: eligibility_set.add(resp_elig.INELIGIBLE_PARTICIPATION) - if ineligible_criteria: + # if birthday is missing then age_range_diff is None + if ineligible_criteria or age_range_diff is None: eligibility_set.add(resp_elig.INELIGIBLE_CRITERIA) return list(eligibility_set) From fcf9541caff4bc06f691e26b95e8758b3f3b35a2 Mon Sep 17 00:00:00 2001 From: Becky Gilbert Date: Tue, 7 Nov 2023 21:59:23 -0800 Subject: [PATCH 06/12] alter child birthday field to not allow blank or null --- ...5_alter_child_birthday_not_null_or_blank.py | 18 ++++++++++++++++++ accounts/models.py | 2 +- 2 files changed, 19 insertions(+), 1 deletion(-) create mode 100644 accounts/migrations/0055_alter_child_birthday_not_null_or_blank.py diff --git a/accounts/migrations/0055_alter_child_birthday_not_null_or_blank.py b/accounts/migrations/0055_alter_child_birthday_not_null_or_blank.py new file mode 100644 index 000000000..29dead0e9 --- /dev/null +++ b/accounts/migrations/0055_alter_child_birthday_not_null_or_blank.py @@ -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(), + ), + ] diff --git a/accounts/models.py b/accounts/models.py index 1bbf47cf0..563b6c935 100644 --- a/accounts/models.py +++ b/accounts/models.py @@ -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( From 0011558ada1ba5f3a8193e296153b8b4ffb17414 Mon Sep 17 00:00:00 2001 From: Becky Gilbert Date: Tue, 7 Nov 2023 22:17:21 -0800 Subject: [PATCH 07/12] add a default blank value for Study criteria expression, modify response eligibility migration to include change to Study model --- ...eligibility_default_criteria_expression.py} | 18 ++++++++++-------- studies/models.py | 2 +- 2 files changed, 11 insertions(+), 9 deletions(-) rename studies/migrations/{0094_response_eligibility.py => 0094_add_response_eligibility_default_criteria_expression.py} (62%) diff --git a/studies/migrations/0094_response_eligibility.py b/studies/migrations/0094_add_response_eligibility_default_criteria_expression.py similarity index 62% rename from studies/migrations/0094_response_eligibility.py rename to studies/migrations/0094_add_response_eligibility_default_criteria_expression.py index 4a042b894..58746a4d1 100644 --- a/studies/migrations/0094_response_eligibility.py +++ b/studies/migrations/0094_add_response_eligibility_default_criteria_expression.py @@ -1,4 +1,4 @@ -# Generated by Django 3.2.11 on 2023-11-02 21:10 +# Generated by Django 3.2.11 on 2023-11-08 05:47 import django.contrib.postgres.fields from django.db import migrations, models @@ -18,13 +18,10 @@ class Migration(migrations.Migration): base_field=models.CharField( choices=[ ("Eligible", "Eligible"), - ("Ineligible_TooYoung", "Ineligible_TooYoung"), - ("Ineligible_TooOld", "Ineligible_TooOld"), - ( - "Ineligible_CriteriaExpression", - "Ineligible_CriteriaExpression", - ), - ("Ineligible_Participation", "Ineligible_Participation"), + ("Ineligible_TooYoung", "Ineligible Young"), + ("Ineligible_TooOld", "Ineligible Old"), + ("Ineligible_CriteriaExpression", "Ineligible Criteria"), + ("Ineligible_Participation", "Ineligible Participation"), ], max_length=100, ), @@ -33,4 +30,9 @@ class Migration(migrations.Migration): size=None, ), ), + migrations.AlterField( + model_name="study", + name="criteria_expression", + field=models.TextField(blank=True, default=""), + ), ] diff --git a/studies/models.py b/studies/models.py index b0ce1b379..f43d987f4 100644 --- a/studies/models.py +++ b/studies/models.py @@ -351,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" ) From 6f67673e2ff82db0974ae54413a2cf5761385af0 Mon Sep 17 00:00:00 2001 From: Becky Gilbert Date: Tue, 7 Nov 2023 22:46:58 -0800 Subject: [PATCH 08/12] fix failing tests: add required birthday to Child instances --- studies/tests.py | 4 ++-- web/tests/test_external_views.py | 9 +++++++-- web/tests/test_views.py | 4 +++- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/studies/tests.py b/studies/tests.py index 1a4ee099f..5a5766aef 100644 --- a/studies/tests.py +++ b/studies/tests.py @@ -324,7 +324,7 @@ def test_potential_message_targets_deleted_children(self): user = User(is_active=True) user.save() - child = Child(user=user) + child = Child(user=user, birthday=date.today() - timedelta(days=365)) child.save() self.assertTrue( @@ -687,7 +687,7 @@ def test_responses_for_researcher_external_studies(self): study_type=StudyType.get_external(), ) user = User.objects.create(is_active=True, is_researcher=True) - child = Child.objects.create(user=user) + child = Child.objects.create(user=user, birthday=date.today()) response = Response.objects.create( study=study, child=child, diff --git a/web/tests/test_external_views.py b/web/tests/test_external_views.py index 44ee8605f..c3583e51d 100644 --- a/web/tests/test_external_views.py +++ b/web/tests/test_external_views.py @@ -1,3 +1,4 @@ +import datetime from unittest.mock import MagicMock, PropertyMock, patch from urllib.parse import parse_qs, urlparse @@ -179,7 +180,9 @@ def test_studies_without_completed_consent_frame_functional(self): user = User.objects.create() # Create child - child = Child.objects.create(user=user) + child = Child.objects.create( + user=user, birthday=datetime.date.today() - datetime.timedelta(days=365) + ) study_type = StudyType.get_external() @@ -270,7 +273,9 @@ def test_lookit_studies_history_view(self): type(mock_request).user = PropertyMock(return_value=user) # Create child - child = Child.objects.create(user=user) + child = Child.objects.create( + user=user, birthday=datetime.date.today() - datetime.timedelta(days=365) + ) # Create response for this child/study Response.objects.create( diff --git a/web/tests/test_views.py b/web/tests/test_views.py index 825a8abe7..bc041ece2 100644 --- a/web/tests/test_views.py +++ b/web/tests/test_views.py @@ -592,7 +592,9 @@ def test_studies_without_completed_consent_frame_functional(self): user = User.objects.create() # Create child - child = Child.objects.create(user=user) + child = Child.objects.create( + user=user, birthday=datetime.date.today() - datetime.timedelta(days=365) + ) study_type = StudyType.get_ember_frame_player() From 1d25cc5c0cfb306108e36babbe4400a299cd29e2 Mon Sep 17 00:00:00 2001 From: Becky Gilbert Date: Tue, 7 Nov 2023 22:55:03 -0800 Subject: [PATCH 09/12] Revert "handle missing fields needed to set eligibility value" since the necessary validation is handled by the modeland forms. This reverts commit bb4fb0ebd6103e7661e3125147c2d97730cf92ac. --- studies/helpers.py | 28 +++++++--------------------- 1 file changed, 7 insertions(+), 21 deletions(-) diff --git a/studies/helpers.py b/studies/helpers.py index f32f3695b..af7cbdc1f 100644 --- a/studies/helpers.py +++ b/studies/helpers.py @@ -7,7 +7,6 @@ from django.core.mail.message import EmailMultiAlternatives from django.db import models from django.template.loader import get_template -from lark import UnexpectedCharacters, UnexpectedInput from accounts.queries import ( child_in_age_range_for_study_days_difference, @@ -118,36 +117,23 @@ def get_eligibility_for_response(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 + ) - # handle missing/invalid criteria_expression - try: - ineligible_criteria = not get_child_eligibility( - child_obj, study_obj.criteria_expression - ) - except UnexpectedCharacters: - ineligible_criteria = False - except UnexpectedInput: - ineligible_criteria = False - - if ( - age_range_diff is None - or age_range_diff != 0 - or ineligible_participation - or ineligible_criteria - ): + if age_range_diff != 0 or ineligible_participation or ineligible_criteria: eligibility_set = set() - if age_range_diff is not None and age_range_diff > 0: + if age_range_diff > 0: eligibility_set.add(resp_elig.INELIGIBLE_OLD) - elif age_range_diff is not None and age_range_diff < 0: + elif age_range_diff < 0: eligibility_set.add(resp_elig.INELIGIBLE_YOUNG) if ineligible_participation: eligibility_set.add(resp_elig.INELIGIBLE_PARTICIPATION) - # if birthday is missing then age_range_diff is None - if ineligible_criteria or age_range_diff is None: + if ineligible_criteria: eligibility_set.add(resp_elig.INELIGIBLE_CRITERIA) return list(eligibility_set) From 47dafb738ed927b6c8b7ec715e7535f8cf0a1566 Mon Sep 17 00:00:00 2001 From: Becky Gilbert Date: Tue, 14 Nov 2023 22:04:23 -0800 Subject: [PATCH 10/12] add tests for child_in_age_range_for_study_days_difference --- accounts/tests/test_accounts.py | 61 +++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/accounts/tests/test_accounts.py b/accounts/tests/test_accounts.py index 4a5101ed3..ceec64473 100644 --- a/accounts/tests/test_accounts.py +++ b/accounts/tests/test_accounts.py @@ -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, @@ -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 From bb1f0cd25ab0c86cbce25a2c463c90ce60f8cd6c Mon Sep 17 00:00:00 2001 From: Becky Gilbert Date: Tue, 14 Nov 2023 22:12:15 -0800 Subject: [PATCH 11/12] add tests for eligibility field, get_eligibility_for_response returns sorted list of eligibility strings --- studies/helpers.py | 12 +- studies/tests.py | 438 ++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 442 insertions(+), 8 deletions(-) diff --git a/studies/helpers.py b/studies/helpers.py index af7cbdc1f..cc6c8a009 100644 --- a/studies/helpers.py +++ b/studies/helpers.py @@ -111,7 +111,7 @@ def get_eligibility_for_response(child_obj, study_obj): 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} + 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( @@ -126,17 +126,17 @@ def get_eligibility_for_response(child_obj, study_obj): eligibility_set = set() if age_range_diff > 0: - eligibility_set.add(resp_elig.INELIGIBLE_OLD) + eligibility_set.add(resp_elig.INELIGIBLE_OLD.value) elif age_range_diff < 0: - eligibility_set.add(resp_elig.INELIGIBLE_YOUNG) + eligibility_set.add(resp_elig.INELIGIBLE_YOUNG.value) if ineligible_participation: - eligibility_set.add(resp_elig.INELIGIBLE_PARTICIPATION) + eligibility_set.add(resp_elig.INELIGIBLE_PARTICIPATION.value) if ineligible_criteria: - eligibility_set.add(resp_elig.INELIGIBLE_CRITERIA) + eligibility_set.add(resp_elig.INELIGIBLE_CRITERIA.value) - return list(eligibility_set) + return sorted(list(eligibility_set)) class ResponseEligibility(models.TextChoices): diff --git a/studies/tests.py b/studies/tests.py index 5a5766aef..06ce46020 100644 --- a/studies/tests.py +++ b/studies/tests.py @@ -4,12 +4,12 @@ from django.core.files.uploadedfile import SimpleUploadedFile from django.test import TestCase from django.utils.safestring import mark_safe -from django_dynamic_fixture import G +from django_dynamic_fixture import G, N from guardian.shortcuts import assign_perm from more_itertools import quantify from accounts.models import Child, Message, User -from studies.helpers import send_mail +from studies.helpers import ResponseEligibility, send_mail from studies.models import Lab, Response, Study, StudyType, StudyTypeEnum, Video from studies.permissions import StudyPermission from studies.tasks import ( @@ -884,3 +884,437 @@ def test_recording_method_is_recordrtc(self): self.assertFalse(self.videos[1].recording_method_is_recordrtc) self.assertFalse(self.videos[2].recording_method_is_recordrtc) self.assertFalse(self.videos[6].recording_method_is_recordrtc) + + +class ResponseEligibilityTestCase(TestCase): + def setUp(self): + self.fake_lab = G( + Lab, name="ECCL", institution="MIT", contact_email="faker@fakelab.com" + ) + # Age range 2 years (730 days, 2y / 0m / 0d) to 3 years (1460 days, 4y / 0m / 0d) + self.study = G( + Study, + name="Study with 2-3 year age range", + image=SimpleUploadedFile( + "fake_image.png", b"fake-stuff", content_type="image/png" + ), + study_type=StudyType.get_ember_frame_player(), + lab=self.fake_lab, + min_age_years=2, + min_age_months=0, + min_age_days=0, + max_age_years=4, + max_age_months=0, + max_age_days=0, + ) + + self.study_criteria = G( + Study, + name="Study with a criteria expression", + image=SimpleUploadedFile( + "fake_image.png", b"fake-stuff", content_type="image/png" + ), + study_type=StudyType.get_ember_frame_player(), + lab=self.fake_lab, + min_age_years=2, + min_age_months=0, + min_age_days=0, + max_age_years=4, + max_age_months=0, + max_age_days=0, + criteria_expression="hearing_impairment", + ) + + self.other_study_1 = G(Study) + self.study_participated = G( + Study, + name="Study with must have participated criteria", + image=SimpleUploadedFile( + "fake_image.png", b"fake-stuff", content_type="image/png" + ), + study_type=StudyType.get_ember_frame_player(), + lab=self.fake_lab, + min_age_years=2, + min_age_months=0, + min_age_days=0, + max_age_years=4, + max_age_months=0, + max_age_days=0, + must_have_participated=[self.other_study_1], + ) + + self.study_participated_criteria = G( + Study, + name="Study with must have participated criteria", + image=SimpleUploadedFile( + "fake_image.png", b"fake-stuff", content_type="image/png" + ), + study_type=StudyType.get_ember_frame_player(), + lab=self.fake_lab, + min_age_years=2, + min_age_months=0, + min_age_days=0, + max_age_years=4, + max_age_months=0, + max_age_days=0, + criteria_expression="hearing_impairment", + must_have_participated=[self.other_study_1], + ) + + self.study_not_participated = G( + Study, + name="Study with must not have particpated criteria", + image=SimpleUploadedFile( + "fake_image.png", b"fake-stuff", content_type="image/png" + ), + study_type=StudyType.get_ember_frame_player(), + lab=self.fake_lab, + min_age_years=2, + min_age_months=0, + min_age_days=0, + max_age_years=4, + max_age_months=0, + max_age_days=0, + must_not_have_participated=[self.other_study_1], + ) + + self.study_not_participated_criteria = G( + Study, + name="Test study", + image=SimpleUploadedFile( + "fake_image.png", b"fake-stuff", content_type="image/png" + ), + study_type=StudyType.get_ember_frame_player(), + lab=self.fake_lab, + min_age_years=2, + min_age_months=0, + min_age_days=0, + max_age_years=4, + max_age_months=0, + max_age_days=0, + criteria_expression="hearing_impairment", + must_not_have_participated=[self.other_study_1], + ) + + self.other_study_2 = G(Study) + self.other_study_3 = G(Study) + self.study_participation_multiple = G( + Study, + name="Test study", + image=SimpleUploadedFile( + "fake_image.png", b"fake-stuff", content_type="image/png" + ), + study_type=StudyType.get_ember_frame_player(), + lab=self.fake_lab, + min_age_years=2, + min_age_months=0, + min_age_days=0, + max_age_years=4, + max_age_months=0, + max_age_days=0, + must_have_participated=[self.other_study_2], + must_not_have_participated=[self.other_study_3], + ) + + self.participant = G(User, is_active=True) + one_year_ago = date.today() - timedelta(days=365) + two_years_ago = date.today() - timedelta(days=2 * 365) + five_years_ago = date.today() - timedelta(days=5 * 365) + self.child_in_age_range = G( + Child, given_name="Child1", user=self.participant, birthday=two_years_ago + ) + self.child_young = G( + Child, given_name="Child2", user=self.participant, birthday=one_year_ago + ) + self.child_old = G( + Child, given_name="Child3", user=self.participant, birthday=five_years_ago + ) + self.child_meets_criteria_exp = G( + Child, + given_name="Child4", + user=self.participant, + birthday=two_years_ago, + existing_conditions=Child.existing_conditions.hearing_impairment, + ) + self.child_young_meets_criteria_exp = G( + Child, + given_name="Child5", + user=self.participant, + birthday=one_year_ago, + existing_conditions=Child.existing_conditions.hearing_impairment, + ) + self.child_old_meets_criteria_exp = G( + Child, + given_name="Child6", + user=self.participant, + birthday=five_years_ago, + existing_conditions=Child.existing_conditions.hearing_impairment, + ) + + def test_response_eligibility_eligible(self): + # note the use of N instead of G here - this creates the instance but doesn't save it + response_unsaved = N( + Response, + child=self.child_in_age_range, + study=self.study, + sequence=["0-video-config"], + ) + self.assertEqual( + response_unsaved.eligibility, + [], + "Response Eligibility array value is empty before the response object is saved.", + ) + + # G creates and saves the instance, and eligibility values are calculated when the Response object is saved + response_eligible = G( + Response, + child=self.child_in_age_range, + study=self.study, + sequence=["0-video-config"], + ) + self.assertEqual( + response_eligible.eligibility, + [ResponseEligibility.ELIGIBLE.value], + "Response Eligibility array only contains the ELIGIBLE category for eligible response sessions.", + ) + + response_eligible_criteria = G( + Response, + child=self.child_meets_criteria_exp, + study=self.study_criteria, + sequence=["0-video-config"], + ) + self.assertEqual( + response_eligible_criteria.eligibility, + [ResponseEligibility.ELIGIBLE.value], + "Response Eligibility array is ELIGIBLE when the child meets the criteria expression requirements.", + ) + + response_eligible_participation = G( + Response, + child=self.child_in_age_range, + study=self.study_not_participated, + sequence=["0-video-config"], + ) + self.assertEqual( + response_eligible_participation.eligibility, + [ResponseEligibility.ELIGIBLE.value], + "Response Eligibility array is ELIGIBLE when the child meets the participation requirements.", + ) + + # response_eligible.completed_consent_frame = True + # response_eligible.save() + # self.assertTrue( , "Response Eligibility array value is not set again when an existing response object is updated.") + + def test_response_eligibility_old(self): + response_old = G( + Response, + child=self.child_old, + study=self.study, + sequence=["0-video-config"], + ) + self.assertEqual( + response_old.eligibility, + [ResponseEligibility.INELIGIBLE_OLD], + "Response Eligibility array only contains the INELIGIBLE_OLD category for children who are too old.", + ) + + def test_response_eligibility_young(self): + response_young = G( + Response, + child=self.child_young, + study=self.study, + sequence=["0-video-config"], + ) + self.assertEqual( + response_young.eligibility, + [ResponseEligibility.INELIGIBLE_YOUNG], + "Response Eligibility array only contains the INELIGIBLE_YOUNG category for children who are too young.", + ) + + def test_response_eligibility_criteria(self): + response_criteria = G( + Response, + child=self.child_in_age_range, + study=self.study_criteria, + sequence=["0-video-config"], + ) + self.assertEqual( + response_criteria.eligibility, + [ResponseEligibility.INELIGIBLE_CRITERIA], + "Response Eligibility array only contains the INELIGIBLE_CRITERIA category for children who do not meet the criteria expression.", + ) + + def test_response_eligibility_age_criteria_combinations(self): + response_young_criteria = G( + Response, + child=self.child_young, + study=self.study_criteria, + sequence=["0-video-config"], + ) + self.assertEqual( + response_young_criteria.eligibility, + sorted( + [ + ResponseEligibility.INELIGIBLE_YOUNG.value, + ResponseEligibility.INELIGIBLE_CRITERIA.value, + ] + ), + "Response Eligibility array contains the INELIGIBLE_YOUNG and INELIGIBLE_CRITERIA categories for children who are too young and do not meet criteria expression.", + ) + + response_old_criteria = G( + Response, + child=self.child_old, + study=self.study_criteria, + sequence=["0-video-config"], + ) + self.assertEqual( + response_old_criteria.eligibility, + sorted( + [ + ResponseEligibility.INELIGIBLE_CRITERIA.value, + ResponseEligibility.INELIGIBLE_OLD.value, + ] + ), + "Response Eligibility array contains the INELIGIBLE_OLD and INELIGIBLE_CRITERIA categories for children who are too old and do not meet criteria expression.", + ) + + def test_response_eligibility_participation(self): + response_participation_1 = G( + Response, + child=self.child_in_age_range, + study=self.study_participated, + sequence=["0-video-config"], + ) + self.assertEqual( + response_participation_1.eligibility, + [ResponseEligibility.INELIGIBLE_PARTICIPATION.value], + "Response Eligibility array only contains the INELIGIBLE_PARTICIPATION category for children have not done the required studies.", + ) + + G( + Response, + child=self.child_in_age_range, + study=self.other_study_1, + sequence=["0-video-config"], + ) + response_participation_2 = G( + Response, + child=self.child_in_age_range, + study=self.study_not_participated, + sequence=["0-video-config"], + ) + self.assertEqual( + response_participation_2.eligibility, + [ResponseEligibility.INELIGIBLE_PARTICIPATION.value], + "Response Eligibility array only contains the INELIGIBLE_PARTICIPATION category for children who have done the disallowed studies.", + ) + + def test_response_eligibility_participation_combinations(self): + response_young_participation = G( + Response, + child=self.child_young, + study=self.study_participated, + sequence=["0-video-config"], + ) + self.assertEqual( + response_young_participation.eligibility, + sorted( + [ + ResponseEligibility.INELIGIBLE_YOUNG, + ResponseEligibility.INELIGIBLE_PARTICIPATION, + ] + ), + "Response Eligibility array contains the INELIGIBLE_YOUNG and INELIGIBLE_PARTICIPATION categories for children who are too young and do not meet participation requirements.", + ) + + response_old_participation = G( + Response, + child=self.child_old, + study=self.study_participated, + sequence=["0-video-config"], + ) + self.assertEqual( + response_old_participation.eligibility, + sorted( + [ + ResponseEligibility.INELIGIBLE_OLD, + ResponseEligibility.INELIGIBLE_PARTICIPATION, + ] + ), + "Response Eligibility array contains the INELIGIBLE_OLD and INELIGIBLE_PARTICIPATION categories for children who are too old and do not meet participation requirements.", + ) + + response_criteria_participation = G( + Response, + child=self.child_in_age_range, + study=self.study_participated_criteria, + sequence=["0-video-config"], + ) + self.assertEqual( + response_criteria_participation.eligibility, + sorted( + [ + ResponseEligibility.INELIGIBLE_CRITERIA, + ResponseEligibility.INELIGIBLE_PARTICIPATION, + ] + ), + "Response Eligibility array contains the INELIGIBLE_CRITERIA and INELIGIBLE_PARTICIPATION categories for children who do not meet criteria expression or participation requirements.", + ) + + response_young_criteria_participation = G( + Response, + child=self.child_young, + study=self.study_participated_criteria, + sequence=["0-video-config"], + ) + self.assertEqual( + response_young_criteria_participation.eligibility, + sorted( + [ + ResponseEligibility.INELIGIBLE_YOUNG, + ResponseEligibility.INELIGIBLE_CRITERIA, + ResponseEligibility.INELIGIBLE_PARTICIPATION, + ] + ), + "Response Eligibility array contains the INELIGIBLE_YOUNG, INELIGIBLE_CRITERIA and INELIGIBLE_PARTICIPATION categories for children who are too young and do not meet criteria expression or participation requirements.", + ) + + response_old_criteria_participation = G( + Response, + child=self.child_old, + study=self.study_participated_criteria, + sequence=["0-video-config"], + ) + self.assertEqual( + response_old_criteria_participation.eligibility, + sorted( + [ + ResponseEligibility.INELIGIBLE_OLD, + ResponseEligibility.INELIGIBLE_CRITERIA, + ResponseEligibility.INELIGIBLE_PARTICIPATION, + ] + ), + "Response Eligibility array contains the INELIGIBLE_OLD, INELIGIBLE_CRITERIA and INELIGIBLE_PARTICIPATION categories for children who are too old and do not meet criteria expression or participation requirements.", + ) + + def test_response_eligibility_participation_flag_only_added_once(self): + + G( + Response, + child=self.child_in_age_range, + study=self.other_study_3, + sequence=["0-video-config"], + ) + response_multiple_participation_ineligibility = G( + Response, + child=self.child_in_age_range, + study=self.study_participation_multiple, + sequence=["0-video-config"], + ) + self.assertEqual( + response_multiple_participation_ineligibility.eligibility, + [ResponseEligibility.INELIGIBLE_PARTICIPATION], + "Response Eligibility array contains INELIGIBLE_PARTICIPATION category only once, even if the child is ineligible based on both the 'must have' and 'must not have' participation requirements.", + ) From 06ed8cabb44f9375000383181be954b191d86df7 Mon Sep 17 00:00:00 2001 From: Becky Gilbert Date: Wed, 15 Nov 2023 12:52:00 -0800 Subject: [PATCH 12/12] add more tests, clean up comments --- studies/tests.py | 91 +++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 87 insertions(+), 4 deletions(-) diff --git a/studies/tests.py b/studies/tests.py index 06ce46020..3377324da 100644 --- a/studies/tests.py +++ b/studies/tests.py @@ -1,3 +1,4 @@ +import json from datetime import date, datetime, timedelta, timezone from django.conf import settings @@ -1102,10 +1103,6 @@ def test_response_eligibility_eligible(self): "Response Eligibility array is ELIGIBLE when the child meets the participation requirements.", ) - # response_eligible.completed_consent_frame = True - # response_eligible.save() - # self.assertTrue( , "Response Eligibility array value is not set again when an existing response object is updated.") - def test_response_eligibility_old(self): response_old = G( Response, @@ -1318,3 +1315,89 @@ def test_response_eligibility_participation_flag_only_added_once(self): [ResponseEligibility.INELIGIBLE_PARTICIPATION], "Response Eligibility array contains INELIGIBLE_PARTICIPATION category only once, even if the child is ineligible based on both the 'must have' and 'must not have' participation requirements.", ) + + def test_response_eligibility_set_value_only_on_creation(self): + # create a response where the participant is eligible when they begin the study + response_eligible = G( + Response, + child=self.child_in_age_range, + study=self.study_not_participated, + sequence=["0-video-config"], + ) + self.assertEqual( + response_eligible.eligibility, + [ResponseEligibility.ELIGIBLE.value], + "Response Eligibility is eligible when the response object is first created.", + ) + # create a new response that changes the participant eligibility to ineligible (due to starting a blacklisted study) + G( + Response, + child=self.child_in_age_range, + study=self.other_study_1, + sequence=["0-video-config"], + ) + # update the original response object - the eligibility field should not change + response_eligible.sequence = ["0-video-config", "1-instructions"] + # we need an exp_data field that corresponds to the frame sequence to prevent errors in the Response post-save receiver + response_eligible.exp_data = json.loads( + '{"0-video-config": {"frameType": "DEFAULT"}, "1-instructions": {"frameType": "DEFAULT"}}' + ) + response_eligible.save() + self.assertEqual( + response_eligible.eligibility, + [ResponseEligibility.ELIGIBLE.value], + "Eligibility for an existing response does not change when it is modified.", + ) + # new response is ineligible due to participation in blacklist study + response_ineligible = G( + Response, + child=self.child_in_age_range, + study=self.study_not_participated, + sequence=["0-video-config"], + ) + self.assertEqual( + response_ineligible.eligibility, + [ResponseEligibility.INELIGIBLE_PARTICIPATION.value], + "When a new response is created, participation is ineligible due to participation in blacklist study.", + ) + + def test_response_eligibility_study_blacklists_itself(self): + study_blacklists_itself = G( + Study, + name="Prior participation in this study is not allowed", + image=SimpleUploadedFile( + "fake_image.png", b"fake-stuff", content_type="image/png" + ), + study_type=StudyType.get_ember_frame_player(), + lab=self.fake_lab, + min_age_years=2, + min_age_months=0, + min_age_days=0, + max_age_years=4, + max_age_months=0, + max_age_days=0, + ) + study_blacklists_itself.must_not_have_participated.add(study_blacklists_itself) + study_blacklists_itself.save() + response_eligible = G( + Response, + child=self.child_in_age_range, + study=study_blacklists_itself, + sequence=["0-video-config"], + ) + self.assertEqual( + response_eligible.eligibility, + [ResponseEligibility.ELIGIBLE.value], + "The child's first response is eligible for a study that blacklists itself.", + ) + response_ineligible = G( + Response, + child=self.child_in_age_range, + study=study_blacklists_itself, + sequence=["0-video-config"], + ) + self.assertEqual( + response_ineligible.eligibility, + [ResponseEligibility.INELIGIBLE_PARTICIPATION.value], + "If the child makes additional responses to a study that blacklists itself, those responses are ineligible due to participation criteria.", + )