Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 25 additions & 1 deletion web/templates/web/studies-history.html
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@
</div>
</div>
<h4 class="card-title">{% trans "Study Responses" %}</h4>
{% for response in study.responses.all %}
{% for response in study.response_page %}
<div class="row mb-3">
{% if form.past_studies_tabs|studies_tab_selected == "0" %}
<div class="col-3 d-flex flex-column">
Expand Down Expand Up @@ -140,6 +140,26 @@
</div>
</div>
{% endfor %}
{% if study.response_page.paginator.num_pages > 1 %}
<div class="text-end px-5">{% trans "Study responses" %}</div>
<div class="text-end px-5">
{% if study.response_page.has_previous %}
<a class="text-decoration-none"
aria-label="{% trans "Go to previous page of responses" %}"
href="?{% response_page_transform request study.pk study.response_page.previous_page_number %}">

Check failure on line 149 in web/templates/web/studies-history.html

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

The accessible name should be part of the visible label.

See more on https://sonarcloud.io/project/issues?id=lookit_lookit-api&issues=AZ0m6xWDTj4M-vqtaWnB&open=AZ0m6xWDTj4M-vqtaWnB&pullRequest=1859
{% bs_icon "chevron-left" %}
</a>
{% endif %}
{% trans "Page" %} {{ study.response_page.number }} {% trans "of" %} {{ study.response_page.paginator.num_pages }}
{% if study.response_page.has_next %}
<a class="text-decoration-none"
aria-label="{% trans "Go to next page of responses" %}"
href="?{% response_page_transform request study.pk study.response_page.next_page_number %}">

Check failure on line 157 in web/templates/web/studies-history.html

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

The accessible name should be part of the visible label.

See more on https://sonarcloud.io/project/issues?id=lookit_lookit-api&issues=AZ0m6xWDTj4M-vqtaWnC&open=AZ0m6xWDTj4M-vqtaWnC&pullRequest=1859
{% bs_icon "chevron-right" %}
</a>
{% endif %}
</div>
{% endif %}
</div>
</div>
{% empty %}
Expand All @@ -151,4 +171,8 @@
{% endif %}
</div>
{% endfor %}
{% if object_list.paginator.num_pages > 1 %}
<div class="text-end px-5">{% trans "Previous studies" %}</div>
{% include "studies/_paginator.html" with page=object_list %}
{% endif %}
{% endblock content %}
16 changes: 16 additions & 0 deletions web/templatetags/web_extras.py
Original file line number Diff line number Diff line change
Expand Up @@ -331,3 +331,19 @@ 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_<study_pk> while preserving all other GET params
(e.g. the study-level page number and tab selection).
"""
updated = request.GET.copy()
key = f"response_page_{study_pk}"
if page_number == 1:
updated.pop(key, None)
else:
updated[key] = str(page_number)
return updated.urlencode()
114 changes: 112 additions & 2 deletions web/tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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?
69 changes: 46 additions & 23 deletions web/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -654,14 +655,17 @@ def sort_fn(self):
)


class StudiesHistoryView(LoginRequiredMixin, generic.ListView, FormView):
class StudiesHistoryView(
LoginRequiredMixin, PaginatorMixin, generic.ListView, FormView
):
"""
List all active, public studies.
"""

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()
Expand All @@ -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")
Expand Down