From c60da59bf27bf07327011ff6b1f71c3449712ce5 Mon Sep 17 00:00:00 2001 From: Becky Gilbert Date: Wed, 25 Mar 2026 14:06:27 -0700 Subject: [PATCH 1/6] create new template tag to build query string for response pagination within studies on study history page - keeps all query params and adds response_page_{study_pk} var with page number request --- web/templatetags/web_extras.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/web/templatetags/web_extras.py b/web/templatetags/web_extras.py index 7f9e50f98..cae37a088 100644 --- a/web/templatetags/web_extras.py +++ b/web/templatetags/web_extras.py @@ -331,3 +331,15 @@ def staff_profile(name, img, blurb, type="large"): @register.simple_tag def absolute_url(name, *args, **kwargs): return get_absolute_url(reverse(name, args=args, kwargs=kwargs)) + + +@register.simple_tag +def response_page_transform(request, study_pk, page_number): + """Build a query string for per-study response pagination. + + Updates response_page_ while preserving all other GET params + (e.g. the study-level page number and tab selection). + """ + updated = request.GET.copy() + updated[f"response_page_{study_pk}"] = str(page_number) + return updated.urlencode() From a58aa0e3d4ad930e6da6ae79ad48af25e2add178 Mon Sep 17 00:00:00 2001 From: Becky Gilbert Date: Wed, 25 Mar 2026 14:13:05 -0700 Subject: [PATCH 2/6] paginate both the number of studies (10) and the number of responses per study (1); add query parameters for navigating across study pages and response pages --- web/templates/web/studies-history.html | 22 +++++++- web/views.py | 69 +++++++++++++++++--------- 2 files changed, 67 insertions(+), 24 deletions(-) diff --git a/web/templates/web/studies-history.html b/web/templates/web/studies-history.html index 68cf1df76..e18f4baea 100644 --- a/web/templates/web/studies-history.html +++ b/web/templates/web/studies-history.html @@ -76,7 +76,7 @@

{{ study.name }}

{% trans "Study Responses" %}

- {% for response in study.responses.all %} + {% for response in study.response_page %}
{% if form.past_studies_tabs|studies_tab_selected == "0" %}
@@ -140,6 +140,25 @@

{% trans "Study Responses" %}

{% endfor %} + {% if study.response_page.paginator.num_pages > 1 %} +
+ {% if study.response_page.has_previous %} + + {% bs_icon "chevron-left" %} + + {% endif %} + {% trans "Page" %} {{ study.response_page.number }} {% trans "of" %} {{ study.response_page.paginator.num_pages }} + {% if study.response_page.has_next %} + + {% bs_icon "chevron-right" %} + + {% endif %} +
+ {% endif %} {% empty %} @@ -151,4 +170,5 @@

{% trans "Study Responses" %}

{% endif %} {% endfor %} + {% include "studies/_paginator.html" with page=object_list %} {% endblock content %} diff --git a/web/views.py b/web/views.py index 6923f5d30..9e45abeeb 100644 --- a/web/views.py +++ b/web/views.py @@ -38,6 +38,7 @@ get_child_eligibility_for_study, ) from accounts.utils import hash_id +from exp.mixins.paginator_mixin import PaginatorMixin from project import settings from studies.helpers import get_experiment_absolute_url from studies.models import Lab, Response, Study, StudyType, StudyTypeEnum, Video @@ -654,7 +655,9 @@ def sort_fn(self): ) -class StudiesHistoryView(LoginRequiredMixin, generic.ListView, FormView): +class StudiesHistoryView( + LoginRequiredMixin, PaginatorMixin, generic.ListView, FormView +): """ List all active, public studies. """ @@ -662,6 +665,7 @@ class StudiesHistoryView(LoginRequiredMixin, generic.ListView, FormView): template_name = "web/studies-history.html" model = Study form_class = PastStudiesForm + responses_per_study = 10 def post(self, request, *args, **kwargs): form = self.get_form() @@ -677,39 +681,58 @@ def post(self, request, *args, **kwargs): def get_queryset(self): tab_value = self.request.session.get("past_studies_tabs", "0") - response_query = Q() + self._response_query = Q() study_query = Q() if tab_value == PastStudiesFormTabChoices.lookit_studies.value[0]: study_query = Q( study_type__name=StudyTypeEnum.ember_frame_player.value ) | Q(study_type__name=StudyTypeEnum.jspsych.value) - response_query = Q(completed_consent_frame=True) + self._response_query = Q(completed_consent_frame=True) elif tab_value == PastStudiesFormTabChoices.external_studies.value[0]: study_query = Q(study_type__name=StudyTypeEnum.external.value) - children_ids = Child.objects.filter(user__id=self.request.user.id).values_list( - "id", flat=True - ) - responses = ( - Response.objects.filter(Q(child__id__in=children_ids) & response_query) - .select_related("child") - .prefetch_related( - Prefetch( - "videos", - queryset=Video.objects.order_by("pipe_numeric_id", "s3_timestamp"), - ), - "consent_rulings", - "feedback", - ) - .order_by("-date_created") - ) + self._children_ids = Child.objects.filter( + user__id=self.request.user.id + ).values_list("id", flat=True) - study_ids = responses.values_list("study_id", flat=True) + study_ids = Response.objects.filter( + Q(child__id__in=self._children_ids) & self._response_query + ).values_list("study_id", flat=True) - return Study.objects.filter(Q(id__in=study_ids) & study_query).prefetch_related( - Prefetch("responses", queryset=responses) - ) + return Study.objects.filter(Q(id__in=study_ids) & study_query) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + page = self.request.GET.get("page", 1) + studies_page = self.paginated_queryset(context["object_list"], page, 10) + + for study in studies_page: + response_page_num = self.request.GET.get(f"response_page_{study.pk}", 1) + study_responses = ( + Response.objects.filter( + Q(child__id__in=self._children_ids) & self._response_query, + study=study, + ) + .select_related("child") + .prefetch_related( + Prefetch( + "videos", + queryset=Video.objects.order_by( + "pipe_numeric_id", "s3_timestamp" + ), + ), + "consent_rulings", + "feedback", + ) + .order_by("-date_created") + ) + study.response_page = self.paginated_queryset( + study_responses, response_page_num, self.responses_per_study + ) + + context["object_list"] = studies_page + return context def get_success_url(self): return reverse("web:studies-history") From 581ddbb6b0a297fca4404b1d9f2fa35d83fe34dc Mon Sep 17 00:00:00 2001 From: Becky Gilbert Date: Wed, 25 Mar 2026 15:07:34 -0700 Subject: [PATCH 3/6] add labels to the different sets of page navs: Study responses; Previous studies --- web/templates/web/studies-history.html | 2 ++ 1 file changed, 2 insertions(+) diff --git a/web/templates/web/studies-history.html b/web/templates/web/studies-history.html index e18f4baea..3c0b67cb0 100644 --- a/web/templates/web/studies-history.html +++ b/web/templates/web/studies-history.html @@ -141,6 +141,7 @@

{% trans "Study Responses" %}

{% endfor %} {% if study.response_page.paginator.num_pages > 1 %} +
Study responses
{% if study.response_page.has_previous %} {% trans "Study Responses" %} {% endif %}
{% endfor %} +
Previous studies
{% include "studies/_paginator.html" with page=object_list %} {% endblock content %} From 9919d3005cf93721cbbad5f481d2adc1dd831951 Mon Sep 17 00:00:00 2001 From: Becky Gilbert Date: Wed, 25 Mar 2026 15:08:53 -0700 Subject: [PATCH 4/6] modify response_page_transform template tag to remove query param when the response_page request is 1 (the default) - helps keep the url shorter --- web/templatetags/web_extras.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/web/templatetags/web_extras.py b/web/templatetags/web_extras.py index cae37a088..db97f2f7e 100644 --- a/web/templatetags/web_extras.py +++ b/web/templatetags/web_extras.py @@ -341,5 +341,9 @@ def response_page_transform(request, study_pk, page_number): (e.g. the study-level page number and tab selection). """ updated = request.GET.copy() - updated[f"response_page_{study_pk}"] = str(page_number) + key = f"response_page_{study_pk}" + if page_number == 1: + updated.pop(key, None) + else: + updated[key] = str(page_number) return updated.urlencode() From 657de31a6a701ee7005745f187115de4aa09f3f2 Mon Sep 17 00:00:00 2001 From: Becky Gilbert Date: Wed, 25 Mar 2026 15:40:42 -0700 Subject: [PATCH 5/6] add tests for StudiesHistoryView --- web/tests/test_views.py | 114 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 112 insertions(+), 2 deletions(-) diff --git a/web/tests/test_views.py b/web/tests/test_views.py index 306fd8a1c..861d5788a 100644 --- a/web/tests/test_views.py +++ b/web/tests/test_views.py @@ -839,11 +839,121 @@ def test_unsuscribe_view_removes_email_pref(self): ) +class StudiesHistoryViewTestCase(TestCase): + def setUp(self): + self.participant = G(User, is_active=True, is_researcher=False) + self.child = G(Child, user=self.participant) + self.second_child = G(Child, user=self.participant) + self.other_participant = G(User, is_active=True, is_researcher=False) + self.other_child = G(Child, user=self.other_participant) + self.study_type = StudyType.get_ember_frame_player() + self.study = self._make_study("Test Study") + self.url = reverse("web:studies-history") + + def _make_study(self, name="Study"): + small_gif = ( + 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" + ) + thumbnail = SimpleUploadedFile( + name="small.gif", content=small_gif, content_type="image/gif" + ) + return G(Study, study_type=self.study_type, name=name, image=thumbnail) + + def _make_response(self, child=None, study=None, completed_consent_frame=True): + return G( + Response, + child=child or self.child, + study=study or self.study, + study_type=self.study_type, + completed_consent_frame=completed_consent_frame, + ) + + def test_shows_study_with_completed_consent_frame(self): + """A response with completed_consent_frame=True appears even without a consent ruling (pending).""" + self.client.force_login(self.participant) + self._make_response() + + response = self.client.get(self.url) + + self.assertEqual(response.status_code, 200) + studies = list(response.context["object_list"]) + self.assertEqual(len(studies), 1) + self.assertEqual(studies[0].pk, self.study.pk) + self.assertEqual(len(list(studies[0].response_page)), 1) + + def test_shows_shows_responses_for_multiple_children(self): + """If a user has multiple child accounts, they can see the responses for all of their children.""" + self.client.force_login(self.participant) + self._make_response(child=self.child) + self._make_response(child=self.second_child) + + response = self.client.get(self.url) + + self.assertEqual(response.status_code, 200) + studies = list(response.context["object_list"]) + self.assertEqual(len(studies), 1) + self.assertEqual(studies[0].pk, self.study.pk) + self.assertEqual(len(list(studies[0].response_page)), 2) + + def test_excludes_response_without_completed_consent_frame(self): + """A response where the consent frame was not completed should not appear.""" + self.client.force_login(self.participant) + self._make_response(completed_consent_frame=False) + + response = self.client.get(self.url) + + self.assertEqual(response.status_code, 200) + self.assertEqual(len(list(response.context["object_list"])), 0) + + def test_excludes_other_users_responses(self): + """Responses for another user's child should not be visible.""" + self.client.force_login(self.participant) + self._make_response(child=self.other_child) + + response = self.client.get(self.url) + + self.assertEqual(response.status_code, 200) + self.assertEqual(len(list(response.context["object_list"])), 0) + + def test_study_pagination(self): + """Studies are paginated at 10 per page.""" + self.client.force_login(self.participant) + for i in range(11): + self._make_response(study=self._make_study(f"Study {i}")) + + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200) + page = response.context["object_list"] + self.assertEqual(len(list(page)), 10) + self.assertEqual(page.paginator.num_pages, 2) + + response_p2 = self.client.get(self.url, {"page": 2}) + self.assertEqual(len(list(response_p2.context["object_list"])), 1) + + def test_response_pagination(self): + """Responses within a study are paginated at 10 per response page.""" + self.client.force_login(self.participant) + for _ in range(11): + self._make_response() + + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200) + studies = list(response.context["object_list"]) + self.assertEqual(len(studies), 1) + self.assertEqual(studies[0].response_page.paginator.num_pages, 2) + self.assertEqual(len(list(studies[0].response_page)), 10) + + response_p2 = self.client.get(self.url, {f"response_page_{self.study.pk}": 2}) + studies_p2 = list(response_p2.context["object_list"]) + self.assertEqual(len(list(studies_p2[0].response_page)), 1) + + # TODO: StudyDetailView # - check can see for public or private active study, unauthenticated or authenticated # - check context[children] has own children # TODO: StudiesHistoryView -# - check can see several sessions where consent frame was completed (but consent not marked), not for someone else's -# child, not for consent frame incomplete. +# - external studies # TODO: ExperimentAssetsProxyView # - check have to be authenticated, maybe that's it for now? From 28a529cb9b8d6cc4b549763013a2ab90886024c4 Mon Sep 17 00:00:00 2001 From: Becky Gilbert Date: Wed, 25 Mar 2026 15:54:02 -0700 Subject: [PATCH 6/6] translate pagination labels; only include study pagination UI if there is more than 1 page --- web/templates/web/studies-history.html | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/web/templates/web/studies-history.html b/web/templates/web/studies-history.html index 3c0b67cb0..28191f282 100644 --- a/web/templates/web/studies-history.html +++ b/web/templates/web/studies-history.html @@ -141,7 +141,7 @@

{% trans "Study Responses" %}

{% endfor %} {% if study.response_page.paginator.num_pages > 1 %} -
Study responses
+
{% trans "Study responses" %}
{% endfor %} -
Previous studies
- {% include "studies/_paginator.html" with page=object_list %} + {% if object_list.paginator.num_pages > 1 %} +
{% trans "Previous studies" %}
+ {% include "studies/_paginator.html" with page=object_list %} + {% endif %} {% endblock content %}