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( diff --git a/accounts/queries.py b/accounts/queries.py index 3adb10819..9bcc912e7 100644 --- a/accounts/queries.py +++ b/accounts/queries.py @@ -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, @@ -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): 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 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" 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=( diff --git a/studies/helpers.py b/studies/helpers.py index b4b1b67ce..cc6c8a009 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.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. diff --git a/studies/migrations/0094_add_response_eligibility_default_criteria_expression.py b/studies/migrations/0094_add_response_eligibility_default_criteria_expression.py new file mode 100644 index 000000000..58746a4d1 --- /dev/null +++ b/studies/migrations/0094_add_response_eligibility_default_criteria_expression.py @@ -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=""), + ), + ] diff --git a/studies/models.py b/studies/models.py index 292b568b1..f43d987f4 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, @@ -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" ) @@ -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", @@ -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", @@ -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 @@ -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): 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", ) diff --git a/studies/tests.py b/studies/tests.py index 1a4ee099f..3377324da 100644 --- a/studies/tests.py +++ b/studies/tests.py @@ -1,15 +1,16 @@ +import json from datetime import date, datetime, timedelta, timezone from django.conf import settings 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 ( @@ -324,7 +325,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 +688,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, @@ -884,3 +885,519 @@ 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.", + ) + + 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.", + ) + + 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.", + ) 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()