diff --git a/.gitignore b/.gitignore index 9bd7661b3..a2948fbf3 100644 --- a/.gitignore +++ b/.gitignore @@ -154,3 +154,8 @@ data bootstrap-5* .python-version + +.aws/* +.claude/ +scratch/* +aws-lambda/* diff --git a/accounts/models.py b/accounts/models.py index 911433ab2..d732e35c2 100644 --- a/accounts/models.py +++ b/accounts/models.py @@ -1,9 +1,12 @@ import base64 import hashlib +import logging import uuid from io import BytesIO from typing import Union +logger = logging.getLogger(__name__) + import pydenticon import pyotp from bitfield import BitField @@ -681,6 +684,12 @@ def send_as_email(self): recipient_email_list = list(self.recipients.values_list("username", flat=True)) for to_email in recipient_email_list: + if not to_email or "@" not in to_email: + logger.warning( + "Skipping custom email recipient: username %s is not a valid email address.", + to_email, + ) + continue user = User.objects.get(username=to_email) context.update(token=user.generate_token(), username=to_email) send_mail.delay( diff --git a/exp/views/lab.py b/exp/views/lab.py index 53c91c826..0aa7bf837 100644 --- a/exp/views/lab.py +++ b/exp/views/lab.py @@ -73,6 +73,7 @@ def get_context_data(self, **kwargs): context["custom_url"] = self.request.build_absolute_uri( reverse("web:lab-studies-list", args=[lab.slug]) ) + context["lab_stats"] = lab.get_response_stats() return context diff --git a/pyproject.toml b/pyproject.toml index ff8209be4..06aa2a479 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ requires-python = "==3.13.*" dependencies = [ "boto3==1.40.52", "ciso8601==2.3.2", - "django==5.2.13", + "django==5.2.14", "django-ace-overlay", "django-bitfield==2.2.0", "django-bootstrap-icons==0.9.0", diff --git a/studies/forms.py b/studies/forms.py index 104529022..7dba801c8 100644 --- a/studies/forms.py +++ b/studies/forms.py @@ -120,6 +120,15 @@ class Meta: "badge": "This image will be shown at the top of your custom URL page when it is viewed on a mobile device/narrow browser window, and as a badge/avatar image for your lab. This image should be square. Please keep your file size less than 1 MB.", } + def clean_description(self): + """Lazy, on-demand fix for malformed lab description values in the database that contain literal \r\n strings.""" + description = self.cleaned_data.get("description", "") + return ( + description.replace("\\r\\n", "\n") + .replace("\\r", "\n") + .replace("\\n", "\n") + ) + def clean_banner(self): cleaned_banner = self.cleaned_data["banner"] ratio = 2 diff --git a/studies/management/commands/delete_lab.py b/studies/management/commands/delete_lab.py new file mode 100644 index 000000000..d320cd36f --- /dev/null +++ b/studies/management/commands/delete_lab.py @@ -0,0 +1,153 @@ +from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import ValidationError +from django.core.management.base import BaseCommand, CommandError +from django.db import transaction +from guardian.models import GroupObjectPermission, UserObjectPermission + +from studies.models import Lab + +EXPECTED_RELS = { + ("Study", "lab"), + ("LabUserObjectPermission", "content_object"), + ("LabGroupObjectPermission", "content_object"), +} + + +class Command(BaseCommand): + help = "Safely delete a Lab and its associated permissions and groups." + + def add_arguments(self, parser): + parser.add_argument( + "--uuid", type=str, required=True, help="UUID of the Lab to delete." + ) + parser.add_argument( + "--keep-groups", + action="store_true", + help="Preserve the Lab's auth Groups after deletion.", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Preview deletion without deleting.", + ) + parser.add_argument( + "--force", + action="store_true", + help="Actually perform deletion.", + ) + + def handle(self, *args, **options): + uuid = options["uuid"] + keep_groups = options["keep_groups"] + dry_run = options["dry_run"] + force = options["force"] + + if not dry_run and not force: + raise CommandError( + "Refusing to delete without --force. Use --dry-run to preview." + ) + + self.stdout.write("Inspecting relations pointing to Lab...\n") + found_rels = set() + for rel in Lab._meta.related_objects: + model_name = rel.related_model.__name__ + field_name = rel.field.name + on_delete = rel.field.remote_field.on_delete.__name__ + nullable = rel.field.null + found_rels.add((model_name, field_name)) + self.stdout.write( + f"- {model_name}.{field_name} (on_delete={on_delete}, nullable={nullable})" + ) + + unexpected = found_rels - EXPECTED_RELS + if unexpected: + raise CommandError( + f"Refusing to delete Lab. Unexpected relations detected: {unexpected}" + ) + + try: + lab = Lab.objects.get(uuid=uuid) + except (Lab.DoesNotExist, ValidationError): + raise CommandError(f"Lab with UUID {uuid} is invalid or not found.") + + self.stdout.write(self.style.WARNING(f"\nLab: {lab.name} ({lab.uuid})")) + + study_count = lab.studies.count() + if study_count > 0: + raise CommandError( + f"Refusing to delete lab: {study_count} studies are still attached." + ) + + lab_ct = ContentType.objects.get_for_model(Lab) + user_perm_count = UserObjectPermission.objects.filter( + content_type=lab_ct, object_pk=str(lab.pk) + ).count() + group_perm_count = GroupObjectPermission.objects.filter( + content_type=lab_ct, object_pk=str(lab.pk) + ).count() + researcher_count = lab.researchers.count() + requested_count = lab.requested_researchers.count() + groups = [ + g + for g in [ + lab.guest_group, + lab.readonly_group, + lab.member_group, + lab.admin_group, + ] + if g is not None + ] + + self.stdout.write(f" User object permissions to delete: {user_perm_count}") + self.stdout.write(f" Group object permissions to delete: {group_perm_count}") + self.stdout.write(f" Researchers to remove: {researcher_count}") + self.stdout.write(f" Pending join requests to remove: {requested_count}") + self.stdout.write( + f" Auth groups to {'preserve' if keep_groups else 'delete'}: {len(groups)}" + ) + self.stdout.write(f" Badge image to delete: {lab.badge.name or 'none'}") + self.stdout.write(f" Banner image to delete: {lab.banner.name or 'none'}") + + if dry_run: + self.stdout.write( + self.style.SUCCESS("\nDry run complete. No deletion performed.") + ) + return + + with transaction.atomic(): + lab = Lab.objects.select_for_update().get(uuid=uuid) + + UserObjectPermission.objects.filter( + content_type=lab_ct, object_pk=str(lab.pk) + ).delete() + GroupObjectPermission.objects.filter( + content_type=lab_ct, object_pk=str(lab.pk) + ).delete() + + lab.researchers.clear() + lab.requested_researchers.clear() + + if lab.badge: + lab.badge.delete(save=False) + if lab.banner: + lab.banner.delete(save=False) + + groups = [ + g + for g in [ + lab.guest_group, + lab.readonly_group, + lab.member_group, + lab.admin_group, + ] + if g is not None + ] + lab.delete() + self.stdout.write("Lab deleted.") + + if not keep_groups: + for group in groups: + self.stdout.write(f"Deleting group: {group.name}") + group.delete() + + self.stdout.write(self.style.SUCCESS("Done.")) diff --git a/studies/migrations/0107_add_lab_created_at.py b/studies/migrations/0107_add_lab_created_at.py new file mode 100644 index 000000000..873a55655 --- /dev/null +++ b/studies/migrations/0107_add_lab_created_at.py @@ -0,0 +1,15 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("studies", "0106_add_researcher_valid_response"), + ] + + operations = [ + migrations.AddField( + model_name="lab", + name="created_at", + field=models.DateTimeField(blank=True, null=True), + ), + ] diff --git a/studies/models.py b/studies/models.py index a168092eb..6d10bdea5 100644 --- a/studies/models.py +++ b/studies/models.py @@ -1,6 +1,6 @@ import logging import uuid -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone from enum import Enum import boto3 @@ -54,6 +54,12 @@ REJECTED = "rejected" CONSENT_RULINGS = (ACCEPTED, REJECTED, PENDING) +# It would've made more sense to put this in queries.py and import it here, but queries.py imports from this file, so doing it the other way around avoids a circular import. +EFFECTIVELY_TALLIED_Q = models.Q( + is_tallied_researcher_override__isnull=False, + is_tallied_researcher_override=True, +) | models.Q(is_tallied_researcher_override__isnull=True, is_tallied=True) + S3_RESOURCE = boto3.resource("s3") S3_BUCKET = S3_RESOURCE.Bucket(settings.BUCKET_NAME) @@ -155,6 +161,7 @@ class Lab(models.Model): ) banner = models.ImageField(null=True, blank=True, upload_to="lab_images/") badge = models.ImageField(null=True, blank=True, upload_to="lab_images/") + created_at = models.DateTimeField(null=True, blank=True) class Meta: permissions = LabPermission @@ -163,6 +170,37 @@ class Meta: def __str__(self): return f"{self.name} ({self.principal_investigator_name}, {self.institution})" + def save(self, *args, **kwargs): + if self._state.adding and self.created_at is None: + self.created_at = dutimezone.now() + super().save(*args, **kwargs) + + def get_response_stats(self) -> dict: + """Return tallied response counts for this lab, broken down by study type and time range. + + Uses effective_is_tallied logic: researcher override takes precedence over is_tallied. + """ + one_year_ago = dutimezone.now() - timedelta(days=365) + + # StudyType id=2 is external (consistent with StudyType.is_external) + base_qs = Response.objects.filter(study__lab=self).filter(EFFECTIVELY_TALLIED_Q) + internal_qs = base_qs.exclude(study__study_type__id=2) + external_qs = base_qs.filter(study__study_type__id=2) + + internal_all = internal_qs.count() + internal_year = internal_qs.filter(date_created__gte=one_year_ago).count() + external_all = external_qs.count() + external_year = external_qs.filter(date_created__gte=one_year_ago).count() + + return { + "internal_all_time": internal_all, + "internal_last_year": internal_year, + "external_all_time": external_all, + "external_last_year": external_year, + "total_all_time": internal_all + external_all, + "total_last_year": internal_year + external_year, + } + # Using Direct foreign keys for guardian, see: # https://django-guardian.readthedocs.io/en/stable/userguide/performance.html @@ -566,11 +604,7 @@ def tallied_response_count(self) -> int: Returns: int: Count of tallied responses """ - is_effectively_tallied = models.Q( - is_tallied_researcher_override__isnull=False, - is_tallied_researcher_override=True, - ) | models.Q(is_tallied_researcher_override__isnull=True, is_tallied=True) - return self.responses.filter(is_effectively_tallied).count() + return self.responses.filter(EFFECTIVELY_TALLIED_Q).count() @property def has_reached_max_responses(self) -> bool: diff --git a/studies/queries.py b/studies/queries.py index 41cbd72ac..d049470f6 100644 --- a/studies/queries.py +++ b/studies/queries.py @@ -24,6 +24,7 @@ from attachment_helpers import get_url from studies.models import ( ACCEPTED, + EFFECTIVELY_TALLIED_Q, PENDING, REJECTED, ConsentRuling, @@ -290,10 +291,6 @@ def get_response_type_counts_external(study) -> Dict: - tallied: all effectively tallied non-preview responses - total: all responses (preview/tallied/untallied) """ - is_effectively_tallied = Q( - is_tallied_researcher_override__isnull=False, - is_tallied_researcher_override=True, - ) | Q(is_tallied_researcher_override__isnull=True, is_tallied=True) is_effectively_untallied = Q( is_tallied_researcher_override__isnull=False, is_tallied_researcher_override=False, @@ -301,7 +298,7 @@ def get_response_type_counts_external(study) -> Dict: counts = study.responses.aggregate( preview=Count("id", filter=Q(is_preview=True)), - tallied=Count("id", filter=Q(is_preview=False) & is_effectively_tallied), + tallied=Count("id", filter=Q(is_preview=False) & EFFECTIVELY_TALLIED_Q), untallied=Count("id", filter=Q(is_preview=False) & is_effectively_untallied), ) counts["total"] = counts["preview"] + counts["tallied"] + counts["untallied"] @@ -330,10 +327,6 @@ def get_response_type_counts(study) -> Dict: - total_rejected: all responses with rejected consent (preview_rejected + nonpreview_rejected) - total: total_available + total_pending + total_rejected """ - is_effectively_tallied = Q( - is_tallied_researcher_override__isnull=False, - is_tallied_researcher_override=True, - ) | Q(is_tallied_researcher_override__isnull=True, is_tallied=True) is_effectively_untallied = Q( is_tallied_researcher_override__isnull=False, is_tallied_researcher_override=False, @@ -355,12 +348,12 @@ def get_response_type_counts(study) -> Dict: tallied_approved=Count( "id", filter=Q(is_preview=False) - & is_effectively_tallied + & EFFECTIVELY_TALLIED_Q & Q(current_ruling="accepted"), ), tallied_pending=Count( "id", - filter=Q(is_preview=False) & is_effectively_tallied & is_pending_ruling, + filter=Q(is_preview=False) & EFFECTIVELY_TALLIED_Q & is_pending_ruling, ), untallied_approved=Count( "id", diff --git a/studies/tasks.py b/studies/tasks.py index 99bfc8e77..bbf796283 100644 --- a/studies/tasks.py +++ b/studies/tasks.py @@ -179,6 +179,14 @@ def _deserialized(user_grouped_targets, number_of_parents: int = 100): def _validated(deserialized_groups): """Yield only groups with targets that satisfy criteria for their respective studies.""" for user, child_study_pairs in deserialized_groups: + if not user or not user.username or "@" not in user.username: + # Skip users that were deleted between the SQL query and ORM cache lookup, + # or who have no email address (which would break unsubscribe URL generation). + logger.warning( + "Skipping announcement email target: user %s has no valid username.", + getattr(user, "id", None), + ) + continue valid_message_targets = [] for pair in child_study_pairs: child, study = pair diff --git a/studies/templates/studies/lab_detail.html b/studies/templates/studies/lab_detail.html index b7f3cd103..88842f0c4 100644 --- a/studies/templates/studies/lab_detail.html +++ b/studies/templates/studies/lab_detail.html @@ -28,66 +28,92 @@ {% endif %} {% set_variable link_edit_lab|add:link_lab_members|add:lab_join_message as page_title_right_content %} {% page_title lab.name right_side_elements=page_title_right_content %} -
-
- Institution -

{{ lab.institution }}

-
-
-
-
- Principal investigator -

{{ lab.principal_investigator_name }}

-
-
-
-
- Contact email -

{{ lab.contact_email }}

-
-
-
-
- Contact phone -

{{ lab.contact_phone }}

-
-
-
-
- Lab website -

- {{ lab.lab_website }} -

-
-
-
-
- Custom URL -

- {{ custom_url }} -

-
-
-
-
- Lab description - {{ lab.description|linebreaks }} -
-
-
-
- IRB contact info -

{{ lab.irb_contact_info }}

-
-
-
-
- Approved to test: - {{ lab.approved_to_test|yesno:"Yes,No" }} +
+
+
+ Institution +

{{ lab.institution }}

+
+
+ Principal investigator +

{{ lab.principal_investigator_name }}

+
+
+ Contact email +

{{ lab.contact_email }}

+
+
+ Contact phone +

{{ lab.contact_phone }}

+
+
+ Lab website +

+ {{ lab.lab_website }} +

+
+
+ Custom URL +

+ {{ custom_url }} +

+
+
+ Lab description +

{{ lab.description|squash_spaces|linebreaks }}

+
+
+ IRB contact info +

{{ lab.irb_contact_info }}

+
+
+ Approved to test: + {{ lab.approved_to_test|yesno:"Yes,No" }} +
+ {% if in_this_lab %} +
+
+ CHS License and Usage +
The {{ lab.name }} is using Children Helping Science under a free license (through 2026-12-31).
+
Response sessions collected through the CHS platform:
+ + + + + + + + + + + + + + + + + + + + + + + + +
Response sessions collected through the CHS platform by study type and time range.
InternalExternalTotal
Last 12 months{{ lab_stats.internal_last_year }}{{ lab_stats.external_last_year }}{{ lab_stats.total_last_year }}
+ {% if lab.created_at %} + Since {{ lab.created_at|date:"N j, Y" }} + {% else %} + All time + {% endif %}{{ lab_stats.internal_all_time }}{{ lab_stats.external_all_time }}{{ lab_stats.total_all_time }}
+

Note: Session counts reflect the number of “tallied” sessions across all of your studies, including only non-preview responses from eligible children. For internal studies only, tallied sessions are also limited to those which are marked complete and have a consent status of approved or pending. See documentation for additional detail on session tallies and how to submit individual session updates.

+
+
+ {% endif %}
{% endblock content %} diff --git a/studies/tests.py b/studies/tests.py index 318891e1c..387c6f51c 100644 --- a/studies/tests.py +++ b/studies/tests.py @@ -37,6 +37,7 @@ from studies.permissions import StudyPermission from studies.tasks import ( MessageTarget, + _validated, acquire_potential_announcement_email_targets, cleanup_incomplete_video_uploads, complete_multipart_upload, @@ -662,6 +663,22 @@ def test_potential_message_targets_external(self): # Check that the message target no longer has this child for this study self.assertNotIn(message_target, potential_message_targets()) + def test_validated_skips_none_user(self): + result = list(_validated([(None, [])])) + self.assertEqual(result, []) + + def test_validated_skips_user_with_empty_username(self): + mock_user = MagicMock() + mock_user.username = "" + result = list(_validated([(mock_user, [])])) + self.assertEqual(result, []) + + def test_validated_skips_user_with_non_email_username(self): + mock_user = MagicMock() + mock_user.username = "debuguser" + result = list(_validated([(mock_user, [])])) + self.assertEqual(result, []) + class TestSendMail(TestCase): def setUp(self): @@ -818,6 +835,55 @@ def test_email_headers_returns_none_if_username_missing(self): self.assertIsNone(headers) +class TestSendAsEmail(TestCase): + def setUp(self): + self.fake_lab = G(Lab, contact_email="lab@example.com") + self.study = G( + Study, + lab=self.fake_lab, + image=SimpleUploadedFile( + "fake_image.png", b"fake-stuff", content_type="image/png" + ), + study_type=StudyType.get_ember_frame_player(), + ) + self.message = G( + Message, related_study=self.study, subject="Test Subject", body="Test body" + ) + + @patch("accounts.models.send_mail") + def test_send_as_email_skips_recipient_with_non_email_username( + self, mock_send_mail + ): + invalid_user = G(User, username="notanemail") + self.message.recipients.add(invalid_user) + + self.message.send_as_email() + + mock_send_mail.delay.assert_not_called() + + @patch("accounts.models.send_mail") + def test_send_as_email_skips_recipient_with_empty_username(self, mock_send_mail): + invalid_user = User(username="") + invalid_user.save() + self.message.recipients.add(invalid_user) + + self.message.send_as_email() + + mock_send_mail.delay.assert_not_called() + + @patch("accounts.models.send_mail") + def test_send_as_email_sends_to_valid_recipients_only(self, mock_send_mail): + valid_user = G(User, username="valid@example.com") + invalid_user = G(User, username="notanemail") + self.message.recipients.add(valid_user, invalid_user) + + self.message.send_as_email() + + mock_send_mail.delay.assert_called_once() + call_args = mock_send_mail.delay.call_args + self.assertEqual(call_args[0][2], "valid@example.com") + + class StudyTypeModelTestCase(TestCase): def test_default_pk(self): study_type = StudyType.objects.get(name=StudyTypeEnum.ember_frame_player.value) @@ -2846,3 +2912,177 @@ def test_metadata_change_rejected(self): self.study.save() self.study.refresh_from_db() self.assertEqual(self.study.state, "rejected") + + +class LabCreatedAtTestCase(TestCase): + """Tests for Lab.created_at auto-population on save.""" + + def _make_lab(self, name, email): + return Lab.objects.create(name=name, institution="Test", contact_email=email) + + def test_created_at_set_on_new_lab(self): + lab = self._make_lab("New Lab", "new@lab.com") + lab.refresh_from_db() + self.assertIsNotNone(lab.created_at) + + def test_created_at_not_overwritten_on_resave(self): + lab = self._make_lab("Resave Lab", "resave@lab.com") + original_created_at = lab.created_at + lab.institution = "Changed" + lab.save() + lab.refresh_from_db() + self.assertEqual(lab.created_at, original_created_at) + + def test_null_created_at_stays_null_on_resave(self): + """Labs with null created_at (simulating pre-migration rows) keep null after a subsequent save.""" + lab = self._make_lab("Null Lab", "null@lab.com") + Lab.objects.filter(pk=lab.pk).update(created_at=None) + lab.refresh_from_db() + self.assertIsNone(lab.created_at) + lab.institution = "Updated" + lab.save() + lab.refresh_from_db() + self.assertIsNone(lab.created_at) + + +class LabGetResponseStatsTestCase(TestCase): + """Tests for Lab.get_response_stats() using the effective-is-tallied logic.""" + + def setUp(self): + self.lab = Lab.objects.create( + name="Stats Lab", institution="Test", contact_email="stats@lab.com" + ) + self.other_lab = Lab.objects.create( + name="Other Lab", institution="Test", contact_email="other@lab.com" + ) + self.internal_study = Study.objects.create( + name="Internal Study", + lab=self.lab, + study_type=StudyType.get_ember_frame_player(), + ) + self.external_study = Study.objects.create( + name="External Study", + lab=self.lab, + study_type=StudyType.get_external(), + ) + self.other_study = Study.objects.create( + name="Other Lab Study", + lab=self.other_lab, + study_type=StudyType.get_ember_frame_player(), + ) + self.user = User.objects.create(is_active=True) + self.child = Child.objects.create( + user=self.user, birthday=date.today() - timedelta(days=365) + ) + + def _make_response(self, study, tallied=True, override=None): + """Create a response whose is_tallied matches `tallied`, optionally with a researcher override.""" + eligibility = ( + [ResponseEligibility.ELIGIBLE] + if tallied + else [ResponseEligibility.INELIGIBLE_OLD] + ) + r = Response.objects.create( + study=study, + child=self.child, + study_type=study.study_type, + completed=True, + completed_consent_frame=True, + is_preview=False, + ) + # Second save sets eligibility (first save auto-computed it from child/study age range) + r.eligibility = eligibility + r.save() + r.refresh_from_db() # ensure is_tallied reflects the signal-computed DB value + if override is not None: + r.is_tallied_researcher_override = override + r.save() + return r + + def test_empty_lab_returns_zeros(self): + stats = self.lab.get_response_stats() + for key in ( + "internal_all_time", + "external_all_time", + "total_all_time", + "internal_last_year", + "external_last_year", + "total_last_year", + ): + self.assertEqual(stats[key], 0, f"Expected 0 for {key}") + + def test_counts_tallied_internal_responses(self): + self._make_response(self.internal_study, tallied=True) + self._make_response(self.internal_study, tallied=True) + self._make_response( + self.internal_study, tallied=False + ) # untallied, should not count + stats = self.lab.get_response_stats() + self.assertEqual(stats["internal_all_time"], 2) + self.assertEqual(stats["external_all_time"], 0) + self.assertEqual(stats["total_all_time"], 2) + + def test_counts_tallied_external_responses(self): + self._make_response(self.external_study, tallied=True) + self._make_response( + self.external_study, tallied=False + ) # untallied, should not count + stats = self.lab.get_response_stats() + self.assertEqual(stats["internal_all_time"], 0) + self.assertEqual(stats["external_all_time"], 1) + self.assertEqual(stats["total_all_time"], 1) + + def test_mixed_study_types_breakdown(self): + self._make_response(self.internal_study, tallied=True) + self._make_response(self.internal_study, tallied=True) + self._make_response(self.external_study, tallied=True) + stats = self.lab.get_response_stats() + self.assertEqual(stats["internal_all_time"], 2) + self.assertEqual(stats["external_all_time"], 1) + self.assertEqual(stats["total_all_time"], 3) + + def test_researcher_override_true_counts_untallied_response(self): + """Override=True on an untallied response makes it effectively tallied.""" + r = self._make_response(self.internal_study, tallied=False) + self.assertFalse(r.is_tallied) + r.is_tallied_researcher_override = True + r.save() + stats = self.lab.get_response_stats() + self.assertEqual(stats["internal_all_time"], 1) + + def test_researcher_override_false_excludes_tallied_response(self): + """Override=False on a tallied response makes it effectively untallied.""" + r = self._make_response(self.internal_study, tallied=True) + self.assertTrue(r.is_tallied) + r.is_tallied_researcher_override = False + r.save() + stats = self.lab.get_response_stats() + self.assertEqual(stats["internal_all_time"], 0) + + def test_last_year_excludes_responses_older_than_365_days(self): + self._make_response(self.internal_study, tallied=True) + old = self._make_response(self.internal_study, tallied=True) + two_years_ago = datetime.now(timezone.utc) - timedelta(days=730) + Response.objects.filter(pk=old.pk).update(date_created=two_years_ago) + + stats = self.lab.get_response_stats() + self.assertEqual(stats["internal_all_time"], 2) + self.assertEqual(stats["internal_last_year"], 1) + + def test_last_year_totals_sum_correctly(self): + self._make_response(self.internal_study, tallied=True) + self._make_response(self.external_study, tallied=True) + old_ext = self._make_response(self.external_study, tallied=True) + two_years_ago = datetime.now(timezone.utc) - timedelta(days=730) + Response.objects.filter(pk=old_ext.pk).update(date_created=two_years_ago) + + stats = self.lab.get_response_stats() + self.assertEqual(stats["total_all_time"], 3) + self.assertEqual(stats["total_last_year"], 2) + + def test_does_not_count_other_lab_responses(self): + self._make_response(self.other_study, tallied=True) + self._make_response(self.other_study, tallied=True) + stats = self.lab.get_response_stats() + self.assertEqual(stats["total_all_time"], 0) + self.assertEqual(stats["total_last_year"], 0) diff --git a/uv.lock b/uv.lock index dc2bdc8d4..4f845e9d1 100644 --- a/uv.lock +++ b/uv.lock @@ -325,16 +325,16 @@ wheels = [ [[package]] name = "django" -version = "5.2.13" +version = "5.2.14" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "asgiref" }, { name = "sqlparse" }, { name = "tzdata", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1f/c5/c69e338eb2959f641045802e5ea87ca4bf5ac90c5fd08953ca10742fad51/django-5.2.13.tar.gz", hash = "sha256:a31589db5188d074c63f0945c3888fad104627dfcc236fb2b97f71f89da33bc4", size = 10890368, upload-time = "2026-04-07T14:02:15.072Z" } +sdist = { url = "https://files.pythonhosted.org/packages/65/95/95f7faa0950867afaa0bef2460c6263afd6a2c78cc9434046ed28160b015/django-5.2.14.tar.gz", hash = "sha256:58a63ba841662e5c686b57ba1fec52ddd68c0b93bd96ac3029d55728f00bf8a2", size = 10895118, upload-time = "2026-05-05T13:57:31.104Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/59/b1/51ab36b2eefcf8cdb9338c7188668a157e29e30306bfc98a379704c9e10d/django-5.2.13-py3-none-any.whl", hash = "sha256:5788fce61da23788a8ce6f02583765ab060d396720924789f97fa42119d37f7a", size = 8310982, upload-time = "2026-04-07T14:02:08.883Z" }, + { url = "https://files.pythonhosted.org/packages/14/44/f172870cf87aa25afef48fb72adba89ee8b77fcab6f3b23d240b923f1528/django-5.2.14-py3-none-any.whl", hash = "sha256:6f712143bd3064310d1f50fac859c3e9a274bdcfc9595339853be7779297fc76", size = 8311320, upload-time = "2026-05-05T13:57:25.795Z" }, ] [[package]] @@ -773,11 +773,11 @@ wheels = [ [[package]] name = "idna" -version = "3.10" +version = "3.15" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload-time = "2024-09-15T18:07:39.745Z" } +sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload-time = "2026-05-12T22:45:57.011Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, + { url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" }, ] [[package]] @@ -900,7 +900,7 @@ dev = [ requires-dist = [ { name = "boto3", specifier = "==1.40.52" }, { name = "ciso8601", specifier = "==2.3.2" }, - { name = "django", specifier = "==5.2.13" }, + { name = "django", specifier = "==5.2.14" }, { name = "django-ace-overlay", git = "https://github.com/lookit/django-ace-overlay.git?rev=master" }, { name = "django-bitfield", specifier = "==2.2.0" }, { name = "django-bootstrap-icons", specifier = "==0.9.0" }, @@ -1517,11 +1517,11 @@ wheels = [ [[package]] name = "urllib3" -version = "2.6.3" +version = "2.7.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, ] [[package]] diff --git a/web/templates/web/jspsych-study-detail.html b/web/templates/web/jspsych-study-detail.html index 707779ba4..59580efeb 100644 --- a/web/templates/web/jspsych-study-detail.html +++ b/web/templates/web/jspsych-study-detail.html @@ -82,6 +82,9 @@ + diff --git a/web/templatetags/web_extras.py b/web/templatetags/web_extras.py index f4a10c13e..e2417e652 100644 --- a/web/templatetags/web_extras.py +++ b/web/templatetags/web_extras.py @@ -1,3 +1,4 @@ +import re import textwrap from typing import Text @@ -356,3 +357,13 @@ def response_page_transform(request, study_pk, page_number): else: updated[key] = str(page_number) return updated.urlencode() + + +@register.filter(name="squash_spaces") +def squash_spaces(value): + """Collapses runs of spaces/tabs to a single space, preserving newlines. + Also normalizes literal backslash-r-backslash-n escape sequences to actual newlines.""" + if isinstance(value, str): + value = value.replace("\\r\\n", "\n").replace("\\r", "\n").replace("\\n", "\n") + return re.sub(r"[^\S\r\n]+", " ", value) + return value