Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
bc3dce4
add a new study field called max_responses to model, study edit/creat…
becky-gilbert Feb 3, 2026
f4ecc62
add client side validation to strip invalid characters/values
becky-gilbert Feb 3, 2026
28ffc43
add study methods for getting valid response count, comparing with ma…
becky-gilbert Feb 3, 2026
c146a21
update valid_response_count logic so that completion status is ignore…
becky-gilbert Feb 5, 2026
248fb90
add tests for study properties: valid_response_count, has_reached_max…
becky-gilbert Feb 5, 2026
82ffd46
try to reduce code duplication to pass sonar gate
becky-gilbert Feb 6, 2026
100e6ed
add a checkbox for set response limit, which enables/disables the max…
becky-gilbert Feb 6, 2026
c78edeb
move check for set_response_limit=False and setting max_responses to …
becky-gilbert Feb 6, 2026
4aa3c14
if study has a max responses value, show the current/max counts and a…
becky-gilbert Feb 6, 2026
7422b72
put the study max responses field into a checkbox (to make it clear t…
becky-gilbert Feb 6, 2026
440eeee
edit help text for set response limit and max responses questions
becky-gilbert Feb 11, 2026
b3c11d4
show help text under max responses question
becky-gilbert Feb 11, 2026
7b9481d
move max responses placeholder text into study form model
becky-gilbert Feb 11, 2026
7224cde
set response progress bar to red/striped if exceeded, add alerts for …
becky-gilbert Feb 11, 2026
f51943c
add check_and_pause_if_at_max_responses logic: pause if study has a m…
becky-gilbert Feb 11, 2026
fc07864
add tests for Study model check_and_pause_if_at_max_responses method
becky-gilbert Feb 11, 2026
d82a4a6
refactor tests to pass sonar code duplication checks
becky-gilbert Feb 12, 2026
47d8df9
override celery since these tests involve state changes that send emails
becky-gilbert Feb 12, 2026
8c5ab61
fix issue with tests failing because study image field was not up-to-…
becky-gilbert Feb 12, 2026
08243da
remove unnecessary study.save after study.pause
becky-gilbert Feb 12, 2026
c054ba1
add args to check_and_pause_if_at_max_responses for optionally sendin…
becky-gilbert Feb 17, 2026
3eb1d46
add new email template for notifying researchers of automatic study p…
becky-gilbert Feb 17, 2026
8cbbcbe
update post resp save check_and_pause method to also send researchers…
becky-gilbert Feb 17, 2026
ba686a0
add tests for updates to check_and_pause_if_at_max_responses: args fo…
becky-gilbert Feb 17, 2026
a24a3af
move pause/email/banner message into if study state is active block, …
becky-gilbert Feb 18, 2026
6135bdc
when a study edit form is submitted, check to see if the max_response…
becky-gilbert Feb 18, 2026
b156081
add tests for check/pause if at max responses in Study Update view
becky-gilbert Feb 18, 2026
e090835
fix typo: jspysch -> jspsych
becky-gilbert Feb 18, 2026
6c28deb
response post save: if response is for external study, check if study…
becky-gilbert Feb 18, 2026
fa7d199
add new before check for transitioning study to active state: check i…
becky-gilbert Feb 19, 2026
fde0525
add tests for max responses check prior to activating study
becky-gilbert Feb 19, 2026
ae90bd6
update max responses email for clarity
becky-gilbert Feb 19, 2026
fd0fa16
study activation error message due to max responses: add info about w…
becky-gilbert Feb 19, 2026
aeb614f
edit study log template to indicate automatic pausing
becky-gilbert Feb 19, 2026
ed963ce
valid response count: internal responses must have a consent frame, a…
becky-gilbert Feb 19, 2026
28fdbda
fix valid response cases to include completed_consent_frame=True; add…
becky-gilbert Feb 19, 2026
04b1ad9
refactor tests to pass code duplication checks
becky-gilbert Feb 19, 2026
39733c1
merge updates from add-max-responses-to-study and fix conflicts
becky-gilbert Feb 20, 2026
4692acb
fix sonarqube issue: Prefer Number.parseInt over parseInt
becky-gilbert Feb 20, 2026
a2971fe
replace div with progress element for improved accessibility (resolve…
becky-gilbert Feb 20, 2026
775db4f
update CSS for change from div to progress element; move CSS out of c…
becky-gilbert Feb 20, 2026
3c668b5
remove TODO comment (resolves sonarqube issue)
becky-gilbert Feb 20, 2026
b036ac2
use more concise regex (resolves sonarqube issue)
becky-gilbert Feb 20, 2026
49c12ac
use type=text for max responses input - fixes problems with preventin…
becky-gilbert Feb 20, 2026
8ffd76c
add sanitize_log_input helper to use for logging user input (addresse…
becky-gilbert Feb 21, 2026
54168e8
Revert "add sanitize_log_input helper to use for logging user input (…
becky-gilbert Feb 23, 2026
1a0f19a
merge changes from add-max-responses-to-study and fix conflicts
becky-gilbert Feb 24, 2026
6b5e804
Merge pull request #1832 from lookit/pause-max-responses
becky-gilbert Feb 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
361 changes: 360 additions & 1 deletion exp/tests/test_study_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@
StudyDetailView,
StudyPreviewDetailView,
)
from studies.models import Lab, Study, StudyType
from studies.helpers import ResponseEligibility
from studies.models import Lab, Response, Study, StudyType
from studies.permissions import LabPermission, StudyPermission


Expand Down Expand Up @@ -672,6 +673,171 @@ def test_update_trigger_object_no_attr(
mock_request.POST.keys.assert_not_called()


@override_settings(CELERY_TASK_ALWAYS_EAGER=True)
@override_settings(CELERY_TASK_EAGER_PROPAGATES=True)
@patch("studies.helpers.send_mail")
class ActivateStudyMaxResponsesTestCase(TestCase):
"""Integration tests for the check_if_at_max_responses workflow guard.

When a researcher tries to activate a study (from approved or paused state),
the transition should be blocked if the study has already reached its
max_responses limit.
"""

def setUp(self):
self.client = Force2FAClient()
self.user = G(User, is_active=True, is_researcher=True)
self.lab = G(Lab, name="Activation Test Lab", approved_to_test=True)
self.lab.researchers.add(self.user)

self.study = G(
Study,
image=SimpleUploadedFile(
name="small.gif",
content=(
b"\x47\x49\x46\x38\x39\x61\x01\x00\x01\x00\x00\x00\x00\x21\xf9\x04"
b"\x01\x0a\x00\x01\x00\x2c\x00\x00\x00\x00\x01\x00\x01\x00\x00\x02"
b"\x02\x4c\x01\x00\x3b"
),
content_type="image/gif",
),
study_type=StudyType.get_external(),
creator=self.user,
lab=self.lab,
name="Activation Test Study",
built=True,
)
self.study.admin_group.user_set.add(self.user)
assign_perm(
StudyPermission.CHANGE_STUDY_STATUS.prefixed_codename,
self.user,
self.study,
)
self.client.force_login(self.user)

self.change_status_url = reverse(
"exp:change-study-status", kwargs={"pk": self.study.pk}
)

def _create_eligible_responses(self, count):
"""Create eligible, non-preview responses for the study."""
participant = G(User, is_active=True)
child = G(
Child,
user=participant,
given_name="Test child",
birthday=datetime.date.today() - datetime.timedelta(days=30),
)
for _ in range(count):
r = Response.objects.create(
study=self.study,
child=child,
study_type=self.study.study_type,
demographic_snapshot=participant.latest_demographics,
completed=True,
is_preview=False,
)
Response.objects.filter(pk=r.pk).update(
eligibility=[ResponseEligibility.ELIGIBLE]
)

def _get_error_messages(self, response):
"""Extract error-level messages from a followed response."""
from django.contrib.messages import constants

return [m for m in response.context["messages"] if m.level == constants.ERROR]

def _get_success_messages(self, response):
"""Extract success-level messages from a followed response."""
from django.contrib.messages import constants

return [m for m in response.context["messages"] if m.level == constants.SUCCESS]

def test_activate_blocked_from_approved_when_at_max_responses(self, mock_send_mail):
"""Activating an approved study fails when max_responses has been reached."""
self.study.state = "approved"
self.study.max_responses = 3
self.study.save()
self._create_eligible_responses(3)

response = self.client.post(
self.change_status_url, {"trigger": "activate"}, follow=True
)

self.study.refresh_from_db()
self.assertNotEqual(self.study.state, "active")
errors = self._get_error_messages(response)
self.assertEqual(len(errors), 1)
self.assertIn("TRANSITION ERROR", str(errors[0]))
self.assertIn("maximum number of responses", str(errors[0]))

def test_activate_blocked_from_paused_when_at_max_responses(self, mock_send_mail):
"""Reactivating a paused study fails when max_responses has been reached."""
self.study.state = "paused"
self.study.max_responses = 2
self.study.save()
self._create_eligible_responses(3)

response = self.client.post(
self.change_status_url, {"trigger": "activate"}, follow=True
)

self.study.refresh_from_db()
self.assertNotEqual(self.study.state, "active")
errors = self._get_error_messages(response)
self.assertEqual(len(errors), 1)
self.assertIn("TRANSITION ERROR", str(errors[0]))
self.assertIn("maximum number of responses", str(errors[0]))

def test_activate_succeeds_when_below_max_responses(self, mock_send_mail):
"""Activating an approved study succeeds when below the max_responses limit."""
self.study.state = "approved"
self.study.max_responses = 10
self.study.save()
self._create_eligible_responses(3)

response = self.client.post(
self.change_status_url, {"trigger": "activate"}, follow=True
)

self.study.refresh_from_db()
self.assertEqual(self.study.state, "active")
errors = self._get_error_messages(response)
self.assertEqual(len(errors), 0)

def test_activate_succeeds_when_no_max_responses_set(self, mock_send_mail):
"""Activating an approved study succeeds when max_responses is not set."""
self.study.state = "approved"
self.study.max_responses = None
self.study.save()
self._create_eligible_responses(5)

response = self.client.post(
self.change_status_url, {"trigger": "activate"}, follow=True
)

self.study.refresh_from_db()
self.assertEqual(self.study.state, "active")
errors = self._get_error_messages(response)
self.assertEqual(len(errors), 0)

def test_activate_succeeds_when_exactly_at_limit_minus_one(self, mock_send_mail):
"""Activating succeeds when response count is one below max_responses."""
self.study.state = "approved"
self.study.max_responses = 4
self.study.save()
self._create_eligible_responses(3)

response = self.client.post(
self.change_status_url, {"trigger": "activate"}, follow=True
)

self.study.refresh_from_db()
self.assertEqual(self.study.state, "active")
errors = self._get_error_messages(response)
self.assertEqual(len(errors), 0)


class ManageResearcherPermissionsViewTestCase(TestCase):
def test_model(self) -> None:
manage_researcher_permissions_view = ManageResearcherPermissionsView()
Expand Down Expand Up @@ -1334,3 +1500,196 @@ def test_must_not_have_participated(self):
# TODO: StudyPreviewProxyView
# - add checks analogous to preview detail view
# - check for correct redirect


@override_settings(CELERY_TASK_ALWAYS_EAGER=True)
@override_settings(CELERY_TASK_EAGER_PROPAGATES=True)
@patch("studies.helpers.send_mail")
class StudyUpdateMaxResponsesTestCase(TestCase):
"""Tests for banner messages when max_responses is edited via StudyUpdateView."""

def setUp(self):
self.client = Force2FAClient()
self.user = G(User, is_active=True, is_researcher=True)
self.lab = G(Lab, name="Max Resp Lab", approved_to_test=True)
self.lab.researchers.add(self.user)

self.study = G(
Study,
image=SimpleUploadedFile(
name="small.gif",
content=(
b"\x47\x49\x46\x38\x39\x61\x01\x00\x01\x00\x00\x00\x00\x21\xf9\x04"
b"\x01\x0a\x00\x01\x00\x2c\x00\x00\x00\x00\x01\x00\x01\x00\x00\x02"
b"\x02\x4c\x01\x00\x3b"
),
content_type="image/gif",
),
study_type=StudyType.get_ember_frame_player(),
creator=self.user,
lab=self.lab,
name="Max Resp Study",
short_description="test",
preview_summary="test",
purpose="test",
criteria="test",
duration="test",
contact_info="test",
exit_url="https://mit.edu",
)
self.study.admin_group.user_set.add(self.user)
assign_perm(
StudyPermission.WRITE_STUDY_DETAILS.prefixed_codename,
self.user,
self.study,
)
self.client.force_login(self.user)

def _form_data(self, include_image=True, **overrides):
"""Build minimal valid form data for the StudyEditForm.

Set include_image=False to avoid triggering the pre_save signal that
rejects approved/active studies when monitored fields change.
"""
data = {
"name": self.study.name,
"lab": self.study.lab_id,
"study_type": self.study.study_type_id,
"min_age_years": 0,
"min_age_months": 0,
"min_age_days": 0,
"max_age_years": 1,
"max_age_months": 0,
"max_age_days": 0,
"priority": 1,
"preview_summary": self.study.preview_summary,
"short_description": self.study.short_description,
"purpose": self.study.purpose,
"compensation_description": self.study.compensation_description,
"exit_url": self.study.exit_url,
"criteria": self.study.criteria,
"duration": self.study.duration,
"contact_info": self.study.contact_info,
}
if include_image:
data["image"] = SimpleUploadedFile(
name="test_image.jpg",
content=open("exp/tests/static/study_image.png", "rb").read(),
content_type="image/jpeg",
)
data.update(overrides)
return data

def _create_eligible_responses(self, count):
"""Create eligible, completed, non-preview responses for the study."""
participant = G(User, is_active=True)
child = G(
Child,
user=participant,
given_name="Test child",
birthday=datetime.date.today() - datetime.timedelta(days=30),
)
for _ in range(count):
r = Response.objects.create(
study=self.study,
child=child,
study_type=self.study.study_type,
demographic_snapshot=participant.latest_demographics,
completed_consent_frame=True,
completed=True,
is_preview=False,
)
Response.objects.filter(pk=r.pk).update(
eligibility=[ResponseEligibility.ELIGIBLE]
)

def _get_warning_messages(self, response):
"""Extract warning-level messages from a followed response."""
from django.contrib.messages import constants

return [m for m in response.context["messages"] if m.level == constants.WARNING]

def test_banner_when_max_responses_reached_non_active_study(self, mock_send_mail):
"""Warning banner shown when max_responses is set at/below response count on non-active study."""
self.assertEqual(self.study.state, "created")
self._create_eligible_responses(3)
data = self._form_data(set_response_limit=True, max_responses=3)
response = self.client.post(
reverse("exp:study-edit", kwargs={"pk": self.study.id}),
data,
follow=True,
)
warnings = self._get_warning_messages(response)
self.assertEqual(len(warnings), 1)
self.assertIn("reached the response limit", str(warnings[0]))

# Study should NOT be paused (was not active)
self.study.refresh_from_db()
self.assertNotEqual(self.study.state, "paused")

def test_study_paused_when_max_responses_reached_active_study(self, mock_send_mail):
"""Active study is paused and warning shown when max_responses is set at response count."""
self.assertEqual(self.study.state, "created")
self.study.state = "active"
self.study.save()
self._create_eligible_responses(3)
# include_image=False to avoid triggering the pre_save signal that
# rejects active studies when monitored fields (like image) change.
data = self._form_data(
include_image=False, set_response_limit=True, max_responses=3
)
response = self.client.post(
reverse("exp:study-edit", kwargs={"pk": self.study.id}),
data,
follow=True,
)
warnings = self._get_warning_messages(response)
self.assertEqual(len(warnings), 1)
self.assertIn("automatically paused", str(warnings[0]))

self.study.refresh_from_db()
self.assertEqual(self.study.state, "paused")

def test_no_banner_when_max_responses_not_reached(self, mock_send_mail):
"""No warning when max_responses is set above the current response count."""
self._create_eligible_responses(2)
data = self._form_data(set_response_limit=True, max_responses=10)
response = self.client.post(
reverse("exp:study-edit", kwargs={"pk": self.study.id}),
data,
follow=True,
)
warnings = self._get_warning_messages(response)
self.assertEqual(len(warnings), 0)
self.assertNotEqual(self.study.state, "paused")

def test_no_banner_when_max_responses_unchanged(self, mock_send_mail):
"""No warning when max_responses is submitted but hasn't changed."""
self.study.max_responses = 5
self.study.save()
self._create_eligible_responses(5)
data = self._form_data(set_response_limit=True, max_responses=5)
response = self.client.post(
reverse("exp:study-edit", kwargs={"pk": self.study.id}),
data,
follow=True,
)
warnings = self._get_warning_messages(response)
self.assertEqual(len(warnings), 0)

def test_banner_when_max_responses_lowered_below_count(self, mock_send_mail):
"""Warning shown when max_responses is lowered below existing response count."""
self.assertEqual(self.study.state, "created")
self.study.max_responses = 10
self.study.save()
self._create_eligible_responses(5)
data = self._form_data(set_response_limit=True, max_responses=3)
response = self.client.post(
reverse("exp:study-edit", kwargs={"pk": self.study.id}),
data,
follow=True,
)
warnings = self._get_warning_messages(response)
self.assertEqual(len(warnings), 1)
self.assertIn("reached the response limit", str(warnings[0]))
self.assertEqual(self.study.state, "created")
11 changes: 10 additions & 1 deletion exp/views/study.py
Original file line number Diff line number Diff line change
Expand Up @@ -226,9 +226,18 @@ def form_valid(self, form: StudyEditForm):
)
study.must_have_participated.set(form.cleaned_data["must_have_participated"])

changed_fields = form.changed_data

messages.success(self.request, f"{study.name} study details saved.")

return super().form_valid(form)
# Save form first so the new max_responses value is persisted before
# check_and_pause_if_at_max_responses (which calls refresh_from_db).
response = super().form_valid(form)
# Now check to see if the study has reached max responses with the new value
if "max_responses" in changed_fields:
study.check_and_pause_if_at_max_responses(request=self.request)

return response

def form_invalid(self, form: StudyEditForm):
messages.error(self.request, form.errors)
Expand Down
Loading