- {% if otp %}
- {# If we have OTP, enable setup completion in the case where it's not yet activated, and allow reset with verified OTP if it is. #}
-
+ {% if otp.activated %}
+ {% bootstrap_button "Submit" name="form-handle" value="deactivate-otp" %}
{% else %}
- {# if we don't have OTP, link to 2FA setup directly. #}
- {% trans "Set up Two-Factor Authentication" %}
+ {% bootstrap_button "Submit" name="form-handle" value="activate-otp" %}
{% endif %}
-
{% dropdown_item request 'accounts:manage-account' 'Manage Account' %}
{% dropdown_item request 'exp:lab-list' 'Manage Labs' %}
diff --git a/exp/tests/test_runner_forms.py b/exp/tests/test_runner_forms.py
new file mode 100644
index 000000000..1cf6b5bf9
--- /dev/null
+++ b/exp/tests/test_runner_forms.py
@@ -0,0 +1,118 @@
+import json
+
+from django.test import TestCase
+
+from studies.forms import EFPForm, ExternalForm, ScheduledChoice
+from studies.models import default_study_structure
+
+
+class EFPFormTestCase(TestCase):
+ def test_successful(self):
+ form = EFPForm(
+ data={
+ "last_known_player_sha": "862604874f7eeff8c9d72adcb8914b21bfb5427e",
+ "player_repo_url": "https://github.com/lookit/ember-lookit-frameplayer",
+ "structure": json.dumps(default_study_structure()),
+ }
+ )
+ self.assertDictEqual(form.errors, {})
+ self.assertTrue(form.is_valid())
+
+ def test_failed_structure(self):
+ form = EFPForm(
+ data={
+ "last_known_player_sha": "862604874f7eeff8c9d72adcb8914b21bfb5427e",
+ "player_repo_url": "https://github.com/lookit/ember-lookit-frameplayer",
+ "structure": "{this is not valid json}",
+ }
+ )
+ self.assertDictEqual(
+ form.errors,
+ {
+ "structure": [
+ "Saving protocol configuration failed due to invalid JSON! Please use valid JSON and save again. If you reload this page, all changes will be lost."
+ ]
+ },
+ )
+ self.assertFalse(form.is_valid())
+
+ def test_failed_generator(self):
+ form = EFPForm(
+ data={
+ "last_known_player_sha": "862604874f7eeff8c9d72adcb8914b21bfb5427e",
+ "player_repo_url": "https://github.com/lookit/ember-lookit-frameplayer",
+ "structure": json.dumps(default_study_structure()),
+ "generator": "This is not valid Javascript.",
+ }
+ )
+
+ self.assertDictEqual(
+ form.errors,
+ {
+ "generator": [
+ "Generator javascript seems to be invalid. Please edit and save again. If you reload this page, all changes will be lost."
+ ]
+ },
+ )
+ self.assertFalse(form.is_valid())
+
+ def test_failed_player_repo_url(self):
+ data = {
+ "last_known_player_sha": "862604874f7eeff8c9d72adcb8914b21bfb5427e",
+ "structure": json.dumps(default_study_structure()),
+ }
+
+ # Check completely invalid url
+ data.update(player_repo_url="https://not-a-valid.url")
+ form = EFPForm(data=data)
+ self.assertDictEqual(
+ form.errors,
+ {
+ "player_repo_url": [
+ f'Frameplayer repo url {data["player_repo_url"]} does not work.'
+ ]
+ },
+ )
+ self.assertFalse(form.is_valid())
+
+ # Check slightly off url
+ data.update(player_repo_url="https://github.com/lookit/not-a-valid-project")
+ form = EFPForm(data=data)
+ self.assertDictEqual(
+ form.errors,
+ {
+ "player_repo_url": [
+ f'Frameplayer repo url {data["player_repo_url"]} does not work.'
+ ]
+ },
+ )
+ self.assertFalse(form.is_valid())
+
+ def test_failed_last_known_player_sha(self):
+ data = {
+ "last_known_player_sha": "not a valid sha",
+ "player_repo_url": "https://github.com/lookit/ember-lookit-frameplayer",
+ "structure": json.dumps(default_study_structure()),
+ }
+ form = EFPForm(data=data)
+ self.assertDictEqual(
+ form.errors,
+ {
+ "last_known_player_sha": [
+ f'Frameplayer commit {data["last_known_player_sha"]} does not exist.'
+ ]
+ },
+ )
+ self.assertFalse(form.is_valid())
+
+
+class ExternalFormTestCase(TestCase):
+ def test_successful(self):
+ form = ExternalForm(
+ data={
+ "scheduled": ScheduledChoice.scheduled.value,
+ "url": "https://google.com",
+ }
+ )
+ self.assertDictEqual(form.errors, {})
+ self.assertTrue(form.is_valid())
diff --git a/exp/tests/test_runner_views.py b/exp/tests/test_runner_views.py
new file mode 100644
index 000000000..46fa1be91
--- /dev/null
+++ b/exp/tests/test_runner_views.py
@@ -0,0 +1,153 @@
+from http import HTTPStatus
+
+from django.test import Client, TestCase
+from django.urls import reverse
+from django_dynamic_fixture import G
+from guardian.shortcuts import assign_perm
+
+from accounts.backends import TWO_FACTOR_AUTH_SESSION_KEY
+from accounts.models import User
+from project import settings
+from studies.forms import ScheduledChoice
+from studies.models import Lab, Study
+from studies.permissions import StudyPermission
+
+
+class Force2FAClient(Client):
+ @property
+ def session(self):
+ _session = super().session
+ _session[TWO_FACTOR_AUTH_SESSION_KEY] = True
+ return _session
+
+
+class RunnerDetailsViewsTestCase(TestCase):
+ def setUp(self):
+ self.client = Force2FAClient()
+
+ def test_external_details_view(self):
+ user = G(User, is_active=True, is_researcher=True)
+ lab = G(Lab)
+ study = G(Study, creator=user, lab=lab, study_type=2)
+ metadata = {
+ "url": "https://mit.edu",
+ "scheduled": ScheduledChoice.scheduled.value == "Scheduled",
+ "scheduling": "",
+ "study_platform": "",
+ "other_scheduling": "",
+ "other_study_platform": "",
+ }
+
+ assign_perm(StudyPermission.WRITE_STUDY_DETAILS.codename, user, study)
+ assign_perm(StudyPermission.READ_STUDY_DETAILS.codename, user, study)
+
+ self.client.force_login(user)
+ response = self.client.post(
+ reverse("exp:external-study-details", kwargs={"pk": study.id}),
+ {"scheduled": ScheduledChoice.scheduled.value, "url": metadata["url"]},
+ follow=True,
+ )
+
+ if "form" in response.context:
+ self.assertEqual(response.context_data["form"].errors, {})
+ self.assertEqual(response.status_code, HTTPStatus.OK)
+ self.assertEqual(Study.objects.get(id=study.id).metadata, metadata)
+
+ def test_efp_details_view(self):
+ user = G(User, is_active=True, is_researcher=True)
+ lab = G(Lab)
+ study = G(Study, creator=user, lab=lab, study_type=1)
+ metadata = {
+ "player_repo_url": settings.EMBER_EXP_PLAYER_REPO,
+ "last_known_player_sha": "862604874f7eeff8c9d72adcb8914b21bfb5427e",
+ }
+
+ assign_perm(StudyPermission.WRITE_STUDY_DETAILS.codename, user, study)
+ assign_perm(StudyPermission.READ_STUDY_DETAILS.codename, user, study)
+
+ self.client.force_login(user)
+ response = self.client.post(
+ reverse("exp:efp-study-details", kwargs={"pk": study.id}),
+ {
+ "structure": "{}",
+ "player_repo_url": metadata["player_repo_url"],
+ "last_known_player_sha": metadata["last_known_player_sha"],
+ },
+ follow=True,
+ )
+
+ if "form" in response.context:
+ self.assertEqual(response.context_data["form"].errors, {})
+ self.assertEqual(response.status_code, HTTPStatus.OK)
+ self.assertEqual(Study.objects.get(id=study.id).metadata, metadata)
+
+ def test_study_details_redirect(self):
+ user = G(User, is_active=True, is_researcher=True)
+ lab = G(Lab)
+ efp = G(Study, creator=user, lab=lab, study_type=1)
+ external = G(Study, creator=user, lab=lab, study_type=2)
+
+ assign_perm(StudyPermission.WRITE_STUDY_DETAILS.codename, user, efp)
+ assign_perm(StudyPermission.READ_STUDY_DETAILS.codename, user, efp)
+ assign_perm(StudyPermission.WRITE_STUDY_DETAILS.codename, user, external)
+ assign_perm(StudyPermission.READ_STUDY_DETAILS.codename, user, external)
+
+ self.client.force_login(user)
+ response = self.client.get(
+ reverse("exp:study-details", kwargs={"pk": efp.id}), follow=True
+ )
+ self.assertEqual(
+ response.redirect_chain,
+ [
+ (
+ reverse("exp:efp-study-details", kwargs={"pk": efp.id}),
+ HTTPStatus.FOUND,
+ )
+ ],
+ )
+
+ response = self.client.get(
+ reverse("exp:study-details", kwargs={"pk": external.id}), follow=True
+ )
+ self.assertEqual(
+ response.redirect_chain,
+ [
+ (
+ reverse("exp:external-study-details", kwargs={"pk": external.id}),
+ HTTPStatus.FOUND,
+ )
+ ],
+ )
+
+ def test_efp_study_set_not_built(self):
+ user = G(User, is_active=True, is_researcher=True)
+ lab = G(Lab)
+ study = G(
+ Study, creator=user, lab=lab, study_type=1, built=True, is_building=True
+ )
+
+ assign_perm(StudyPermission.WRITE_STUDY_DETAILS.codename, user, study)
+ assign_perm(StudyPermission.READ_STUDY_DETAILS.codename, user, study)
+
+ self.assertTrue(study.built)
+ self.assertTrue(study.is_building)
+
+ self.client.force_login(user)
+ response = self.client.post(
+ reverse("exp:efp-study-details", kwargs={"pk": study.id}),
+ {
+ "structure": "{}",
+ "last_known_player_sha": "862604874f7eeff8c9d72adcb8914b21bfb5427e",
+ "player_repo_url": settings.EMBER_EXP_PLAYER_REPO,
+ },
+ follow=True,
+ )
+
+ if "form" in response.context:
+ self.assertEqual(response.context_data["form"].errors, {})
+ self.assertEqual(response.status_code, HTTPStatus.OK)
+
+ study = Study.objects.get(id=study.id)
+
+ self.assertFalse(study.built)
+ self.assertFalse(study.is_building)
diff --git a/exp/tests/test_study_forms.py b/exp/tests/test_study_forms.py
index ae810a269..502eaaac7 100644
--- a/exp/tests/test_study_forms.py
+++ b/exp/tests/test_study_forms.py
@@ -1,16 +1,12 @@
-from typing import Dict, Text
from unittest.case import skip
-from unittest.mock import Mock, patch
from django.core.files.uploadedfile import SimpleUploadedFile
from django.forms.models import model_to_dict
from django.test import TestCase
from django_dynamic_fixture import G
from guardian.shortcuts import assign_perm
-from parameterized import parameterized
from accounts.models import User
-from exp.views.mixins import StudyTypeMixin
from studies.forms import StudyCreateForm, StudyEditForm
from studies.models import Lab, Study, StudyType
from studies.permissions import LabPermission, StudyPermission
@@ -86,44 +82,6 @@ def setUp(self):
self.study.save()
self.age_error_message = "The maximum age must be greater than the minimum age."
- def test_valid_structure_accepted(self):
- data = model_to_dict(self.study)
- structure_text = """{
- "frames": {"frame-a": {}, "frame-b": {}},
- "sequence": ["frame-a", "frame-b"]
- }"""
- data["structure"] = structure_text
- form = StudyEditForm(data=data, instance=self.study, user=self.study_designer)
- self.assertNotIn("structure", form.errors)
- form.is_valid()
- self.assertDictEqual(
- form.cleaned_data["structure"],
- {
- "frames": {"frame-a": {}, "frame-b": {}},
- "sequence": ["frame-a", "frame-b"],
- "exact_text": structure_text,
- },
- )
-
- def test_structure_with_extra_comma_invalid(self):
- data = model_to_dict(self.study)
- data[
- "structure"
- ] = """
- {
- "frames": {"frame-a": {}, "frame-b": {}},
- "sequence": ["frame-a", "frame-b"],
- }
- """
- form = StudyEditForm(data=data, instance=self.study, user=self.study_designer)
- self.assertIn("structure", form.errors)
-
- def test_empty_structure_invalid(self):
- data = model_to_dict(self.study)
- data["structure"] = ""
- form = StudyEditForm(data=data, instance=self.study, user=self.study_designer)
- self.assertIn("structure", form.errors)
-
def test_valid_criteria_expression(self):
data = model_to_dict(self.study)
data["criteria_expression"] = (
@@ -424,73 +382,3 @@ def test_lab_options_as_user_who_can_create_studies_in_two_labs(self):
self.assertIn(self.second_lab, edit_form.fields["lab"].queryset)
self.assertNotIn(self.other_lab, edit_form.fields["lab"].queryset)
self.assertNotIn("lab", edit_form.errors)
-
- def test_scheduled_checkbox_enabled(self):
- """Checking that the Study Edit Form's sheduled field is always enabled. JavaScript will disable this field when study is internal."""
- data = model_to_dict(self.study)
- structure_text = '{"frames": {"frame-a": {}, "frame-b": {}}, "sequence": ["frame-a", "frame-b"]}'
- data["structure"] = structure_text
- form = StudyEditForm(data=data, instance=self.study, user=self.study_designer)
- self.assertFalse(form.fields["scheduled"].disabled)
-
-
-class StudyMixinsTestCase(TestCase):
- @parameterized.expand(
- [
- ("", "", False, "", "", "", ""),
- ("", "on", True, "scheduling value", "", "study platform value", ""),
- ("https://lookit.mit.edu", "", False, "", "", "", ""),
- (
- "https://lookit.mit.edu",
- "on",
- True,
- "Other",
- "Other value",
- "Other",
- "Other value",
- ),
- ]
- )
- @patch.object(StudyTypeMixin, "request", create=True)
- def test_validate_and_fetch_metadata(
- self,
- url: Text,
- post_scheduled: Text,
- meta_scheduled: bool,
- scheduling: Text,
- other_scheduling: Text,
- study_platform: Text,
- other_study_platform: Text,
- mock_request: Mock,
- ):
- type(mock_request).POST = expected_metadata = {
- "url": url,
- "scheduled": post_scheduled,
- "scheduling": scheduling,
- "other_scheduling": other_scheduling,
- "study_platform": study_platform,
- "other_study_platform": other_study_platform,
- }
- external = StudyType.get_external()
- metadata, errors = StudyTypeMixin().validate_and_fetch_metadata(external)
-
- expected_metadata["scheduled"] = meta_scheduled
-
- self.assertFalse(errors)
- self.assertEqual(expected_metadata, metadata)
-
- @parameterized.expand(
- [
- ({},),
- ({"scheduled": False},),
- ({"scheduled": True},),
- ]
- )
- @patch.object(StudyTypeMixin, "request", create=True)
- def test_validate_and_fetch_metadata_invalid_metadata(
- self, post_data: Dict, mock_request: Mock
- ):
- type(mock_request).POST = post_data
- external = StudyType.get_external()
- _, errors = StudyTypeMixin().validate_and_fetch_metadata(external)
- self.assertTrue(errors)
diff --git a/exp/tests/test_study_views.py b/exp/tests/test_study_views.py
index e5da63021..f2a192e3d 100644
--- a/exp/tests/test_study_views.py
+++ b/exp/tests/test_study_views.py
@@ -16,7 +16,6 @@
from django.forms.models import model_to_dict
from django.test import Client, TestCase, override_settings
from django.urls import reverse
-from django.utils import timezone
from django.views.generic.detail import SingleObjectMixin
from django_dynamic_fixture import G
from guardian.shortcuts import assign_perm, get_objects_for_user
@@ -31,7 +30,6 @@
ManageResearcherPermissionsView,
StudyDetailView,
StudyPreviewDetailView,
- StudyUpdateView,
)
from studies.models import Lab, Study, StudyType
from studies.permissions import LabPermission, StudyPermission
@@ -151,7 +149,7 @@ def setUp(self):
self.all_study_views_urls = [
reverse("exp:study-list"),
reverse("exp:study-create"),
- reverse("exp:study-detail", kwargs={"pk": self.study.pk}),
+ reverse("exp:study", kwargs={"pk": self.study.pk}),
reverse("exp:study-participant-contact", kwargs={"pk": self.study.pk}),
reverse("exp:preview-detail", kwargs={"uuid": self.study.uuid}),
reverse(
@@ -318,35 +316,6 @@ def test_build_study_with_correct_perms_and_specific_exp_runner(self):
self.study.built, "Study built field not True following study build"
)
- def test_study_edit_displays_generator(self):
- self.client.force_login(self.lab_researcher)
- url = reverse("exp:study-edit", kwargs={"pk": self.study.id})
- assign_perm(
- StudyPermission.WRITE_STUDY_DETAILS.prefixed_codename,
- self.lab_researcher,
- self.study,
- )
- response = self.client.get(url)
- content = response.content.decode("utf-8")
- self.assertEqual(
- response.status_code, 200, "Study edit view returns invalid response"
- )
- self.assertIn(
- self.generator_function_string,
- content,
- "Generator function not rendered in editor on study edit page",
- )
- self.assertIn(
- self.structure_string,
- content,
- "Exact text representation of structure not displayed on study edit page",
- )
- self.assertNotIn(
- "frame-a",
- content,
- "internal structure displayed on study edit page instead of just exact text",
- )
-
@patch("exp.views.mixins.StudyTypeMixin.validate_and_fetch_metadata")
@skip("Not able to change study type on in study edit view.")
def test_study_edit_change_study_type(self, mock_validate):
@@ -382,112 +351,6 @@ def test_study_edit_change_study_type(self, mock_validate):
"Study build was not invalidated after editing study type",
)
- @patch("exp.views.mixins.StudyTypeMixin.validate_and_fetch_metadata")
- def test_change_study_metadata_invalidates_build(
- self, mock_validate_and_fetch_metadata
- ):
- new_metadata = {
- "player_repo_url": "https://github.com/lookit/ember-lookit-frameplayer",
- "last_known_player_sha": "2aa08ee6132cd6351eed58abc2253368c14ad184",
- }
- mock_validate_and_fetch_metadata.return_value = new_metadata, []
- self.client.force_login(self.lab_researcher)
- url = reverse("exp:study-edit", kwargs={"pk": self.study.id})
- assign_perm(
- StudyPermission.WRITE_STUDY_DETAILS.prefixed_codename,
- self.lab_researcher,
- self.study,
- )
- data = model_to_dict(self.study)
- data["metadata"] = new_metadata
- data["comments"] = "Changed experiment runner version"
- data["comments_extra"] = {}
- data["status_change_date"] = timezone.now()
- data["structure"] = json.dumps(data["structure"])
-
- self.assertTrue(self.study.built)
- response = self.client.post(url, data, follow=True)
-
- self.assertEqual(
- response.status_code,
- 200,
- "Study edit returns invalid response when editing metadata",
- )
- self.assertEqual(
- response.redirect_chain,
- [(reverse("exp:study-edit", kwargs={"pk": self.study.pk}), 302)],
- )
-
- updated_study = Study.objects.get(id=self.study.id)
- self.assertFalse(
- updated_study.built,
- "Study build was not invalidated after editing metadata",
- )
-
- @patch("django.views.generic.edit.ModelFormMixin.form_valid")
- @patch("exp.views.mixins.StudyTypeMixin.validate_and_fetch_metadata")
- @patch("exp.views.study.StudyUpdateView.get_form")
- @patch.object(StudyUpdateView, "request", create=True)
- @patch.object(SingleObjectMixin, "get_object")
- def test_study_model_save_on_post(
- self,
- mock_get_object,
- mock_request,
- mock_get_form,
- mock_validate_and_fetch_metadata,
- mock_form_valid,
- ):
- # fill mocks with data
- mock_metadata = MagicMock()
- mock_validate_and_fetch_metadata.return_value = mock_metadata, []
- type(mock_get_object()).metadata = PropertyMock(return_value=mock_metadata)
-
- # run view's post method
- view = StudyUpdateView()
- view.post(mock_request)
-
- # assert mocks
- mock_get_form().instance.save.assert_called_with()
- mock_get_form.assert_called_with()
- mock_form_valid.assert_called_with(mock_get_form())
-
- @patch("exp.views.mixins.StudyTypeMixin.validate_and_fetch_metadata")
- def test_change_study_protocol_does_not_affect_build_status(
- self, mock_validate_and_fetch_metadata
- ):
- mock_validate_and_fetch_metadata.return_value = self.study.metadata, []
- self.client.force_login(self.lab_researcher)
- url = reverse("exp:study-edit", kwargs={"pk": self.study.id})
- assign_perm(
- StudyPermission.WRITE_STUDY_DETAILS.prefixed_codename,
- self.lab_researcher,
- self.study,
- )
-
- data = model_to_dict(self.study)
- data["structure"] = json.dumps(
- {"frames": {"frame-c": {}}, "sequence": ["frame-c"]}
- )
- data["comments"] = "Changed protocol"
- data["status_change_date"] = timezone.now()
- data["comments_extra"] = {}
-
- response = self.client.post(url, data, follow=True)
- self.assertEqual(
- response.status_code,
- 200,
- "Study edit returns invalid response when editing metadata",
- )
- self.assertEqual(
- response.redirect_chain,
- [(reverse("exp:study-edit", kwargs={"pk": self.study.pk}), 302)],
- )
-
- updated_study = Study.objects.get(id=self.study.id)
- self.assertTrue(
- updated_study.built, "Study build was invalidated upon editing protocol"
- )
-
def test_new_user_can_create_studies_in_sandbox_lab_only(self):
new_researcher = G(
User, is_active=True, is_researcher=True, given_name="New researcher"
@@ -530,7 +393,7 @@ def test_create_study_buttons_shown_if_allowed(self):
"Create Study button not displayed on study list view",
)
detail_view_response = self.client.get(
- reverse("exp:study-detail", kwargs={"pk": self.study.pk})
+ reverse("exp:study", kwargs={"pk": self.study.pk})
)
self.assertIn(
"Clone Study",
@@ -556,7 +419,7 @@ def test_create_study_buttons_not_shown_if_not_allowed(self):
"Create Study button displayed on study list view",
)
detail_view_response = self.client.get(
- reverse("exp:study-detail", kwargs={"pk": self.study.pk})
+ reverse("exp:study", kwargs={"pk": self.study.pk})
)
self.assertNotIn(
"Clone Study",
@@ -727,7 +590,7 @@ def test_post(
self.assertEqual(change_study_status_view.post(), mock_http_response_redirect())
mock_http_response_redirect.assert_called_with()
mock_reverse.assert_called_with(
- "exp:study-detail", kwargs={"pk": mock_get_object().pk}
+ "exp:study", kwargs={"pk": mock_get_object().pk}
)
change_study_status_view.update_trigger.assert_called_with()
@@ -753,7 +616,7 @@ def test_post_exception(
mock_request, f"TRANSITION ERROR: {sentinel.error_message}"
)
mock_reverse.assert_called_with(
- "exp:study-detail", kwargs={"pk": mock_get_object().pk}
+ "exp:study", kwargs={"pk": mock_get_object().pk}
)
@patch.object(ChangeStudyStatusView, "request", create=True)
@@ -869,7 +732,7 @@ def test_post(
)
mock_https_response_redirect.assert_called_with()
mock_reverse.assert_called_once_with(
- "exp:study-detail", kwargs={"pk": mock_get_object().pk}
+ "exp:study", kwargs={"pk": mock_get_object().pk}
)
mock_manage_researcher_permissions.assert_called_once_with()
@@ -1314,14 +1177,14 @@ def test_user_can_see_or_edit_study_details(
def test_study_detail_review_consent(self):
# check if review consent is viewable on a frame player study
response = self.client.get(
- reverse("exp:study-detail", kwargs={"pk": self.frame_player_study.pk})
+ reverse("exp:study", kwargs={"pk": self.frame_player_study.pk})
)
self.assertEqual(200, response.status_code)
self.assertIn(b"Review Consent", response.content)
# check that review consent is not view on an external study
response = self.client.get(
- reverse("exp:study-detail", kwargs={"pk": self.external_study.pk})
+ reverse("exp:study", kwargs={"pk": self.external_study.pk})
)
self.assertEqual(200, response.status_code)
self.assertNotIn(b"Review Consent", response.content)
@@ -1336,32 +1199,6 @@ def test_get_context_data_not_deleted_children(self, mock_request):
mock_request.user.children.filter.assert_called_once_with(deleted=False)
-class StudyUpdateViewTestCase(TestCase):
- @patch("exp.views.study.messages")
- @patch("exp.views.study.StudyUpdateView.get_form")
- @patch("exp.views.mixins.StudyTypeMixin.validate_and_fetch_metadata")
- @patch.object(StudyUpdateView, "request", create=True)
- @patch.object(SingleObjectMixin, "get_object")
- def test_metadata_error_message(
- self,
- mock_get_object,
- mock_request,
- mock_validate_and_fetch_metadata,
- mock_get_form,
- mock_messages,
- ):
- type(mock_get_form().save()).id = PropertyMock(return_value=1)
- error_msg = "error message"
- mock_validate_and_fetch_metadata.return_value = {}, [error_msg]
- view = StudyUpdateView()
- view.post(mock_request)
-
- mock_messages.error.assert_called_once_with(
- mock_request,
- f"WARNING: Changes to experiment were not saved: {error_msg}",
- )
-
-
class StudyParticipatedViewTestCase(TestCase):
def setUp(self):
self.client = Force2FAClient()
diff --git a/exp/urls.py b/exp/urls.py
index 19be5effa..b311bff05 100644
--- a/exp/urls.py
+++ b/exp/urls.py
@@ -61,6 +61,9 @@
from exp.views.study import (
ChangeStudyStatusView,
CloneStudyView,
+ EFPEditView,
+ ExperimentRunnerEditRedirect,
+ ExternalEditView,
ManageResearcherPermissionsView,
StudyListViewActive,
StudyListViewApproved,
@@ -121,7 +124,7 @@
name="study-participant-analytics",
),
path("studies/create/", StudyCreateView.as_view(), name="study-create"),
- path("studies//", StudyDetailView.as_view(), name="study-detail"),
+ path("studies//", StudyDetailView.as_view(), name="study"),
path("studies//clone-study", CloneStudyView.as_view(), name="clone-study"),
path(
"studies//change-study-status",
@@ -251,4 +254,19 @@
name="preview-proxy",
),
path("support/", SupportView.as_view(), name="support"),
+ path(
+ "studies//study-details/",
+ ExperimentRunnerEditRedirect.as_view(),
+ name="study-details",
+ ),
+ path(
+ "studies//study-details/efp/",
+ EFPEditView.as_view(),
+ name="efp-study-details",
+ ),
+ path(
+ "studies//study-details/external/",
+ ExternalEditView.as_view(),
+ name="external-study-details",
+ ),
]
diff --git a/exp/views/mixins.py b/exp/views/mixins.py
index 8029eab33..9372dc8ca 100644
--- a/exp/views/mixins.py
+++ b/exp/views/mixins.py
@@ -1,8 +1,6 @@
from functools import cached_property
from typing import Dict, Iterable, Optional, Protocol, Type, TypeVar, Union
-import requests
-from django.conf import settings
from django.contrib import messages
from django.contrib.auth.mixins import UserPassesTestMixin
from django.contrib.auth.models import AnonymousUser
@@ -15,7 +13,7 @@
from accounts.backends import TWO_FACTOR_AUTH_SESSION_KEY
from accounts.models import User
-from studies.models import Lab, Response, Study, StudyType, StudyTypeEnum
+from studies.models import Lab, Response, Study
from studies.permissions import StudyPermission
LookitUser = Union[User, AnonymousUser]
@@ -127,99 +125,3 @@ def get_object(self, queryset: Optional[QuerySet] = None) -> ModelType:
# Only call get_object() when self.object isn't present.
self.object: ModelType = super().get_object(queryset)
return self.object
-
-
-class StudyTypeMixin:
-
- request: LookitRequest
-
- def validate_and_fetch_metadata(self, study_type: Optional[StudyType] = None):
- """Gets the study type and runs hardcoded validations.
-
- TODO: this is obviously a fragile pattern, and there's probably a better way to do this.
- Let's think of a way to do this more dynamically in the future.
-
- :return: A tuple of boolean and tuple, the inner tuple containing error data.
- """
- if not study_type:
- target_study_type_id = self.request.POST["study_type"]
- study_type = StudyType.objects.get(id=target_study_type_id)
- metadata = self.extract_type_metadata(study_type=study_type)
-
- errors = VALIDATIONS.get(study_type.name, is_valid_ember_frame_player)(metadata)
-
- return metadata, errors
-
- def extract_type_metadata(self, study_type: StudyType):
- """
- Pull the metadata related to the selected StudyType from the POST request
- """
- type_fields = study_type.configuration["metadata"]["fields"]
-
- metadata = {}
-
- for type_field in type_fields:
- if type_field["input_type"] == "checkbox":
- metadata[type_field["name"]] = type_field["name"] in self.request.POST
- else:
- metadata[type_field["name"]] = self.request.POST.get(
- type_field["name"], None
- )
-
- if study_type.is_external:
- metadata["scheduled"] = self.request.POST.get("scheduled", "") == "on"
- metadata["other_scheduling"] = self.request.POST.get("other_scheduling", "")
- metadata["other_study_platform"] = self.request.POST.get(
- "other_study_platform", ""
- )
-
- return metadata
-
-
-def is_valid_ember_frame_player(metadata):
- """Checks commit sha and player repo url.
-
- This must fulfill the contract of returning a list. We are exploiting the fact that empty
- lists evaluate falsey.
-
- :param metadata: the metadata object containing shas for both frameplayer and addons repo
- :type metadata: dict
- :return: a list of errors.
- :rtype: list.
- """
- player_repo_url = metadata.get("player_repo_url", settings.EMBER_EXP_PLAYER_REPO)
- frameplayer_commit_sha = metadata.get("last_known_player_sha", "")
-
- errors = []
-
- if not player_repo_url:
- errors.append("Frameplayer repo url is not set.")
- else:
- if not player_repo_url or not requests.get(player_repo_url).ok:
- errors.append(f"Frameplayer repo url {player_repo_url} does not work.")
- if (
- not player_repo_url
- or not requests.get(f"{player_repo_url}/commit/{frameplayer_commit_sha}").ok
- ):
- errors.append(
- f"Frameplayer commit {frameplayer_commit_sha} does not exist."
- )
-
- return errors
-
-
-def is_valid_external(metadata):
- errors = []
-
- if "url" not in metadata or metadata["url"] is None:
- errors.append("External Study doesn't have URL")
- if "scheduled" not in metadata:
- errors.append("External Study doesn't have Scheduled value")
-
- return errors
-
-
-VALIDATIONS = {
- StudyTypeEnum.ember_frame_player.value: is_valid_ember_frame_player,
- StudyTypeEnum.external.value: is_valid_external,
-}
diff --git a/exp/views/responses.py b/exp/views/responses.py
index 02293feb8..f87c1558e 100644
--- a/exp/views/responses.py
+++ b/exp/views/responses.py
@@ -857,7 +857,7 @@ def post(self, request, *args, **kwargs):
def get(self, request, *args, **kwargs):
if self.get_object().study_type.is_external:
messages.error(request, "There is no consent manager for external studies.")
- return HttpResponseRedirect(reverse("exp:study-detail", kwargs=kwargs))
+ return HttpResponseRedirect(reverse("exp:study", kwargs=kwargs))
else:
return super().get(request, *args, **kwargs)
diff --git a/exp/views/study.py b/exp/views/study.py
index f7dd3b10a..232f66b91 100644
--- a/exp/views/study.py
+++ b/exp/views/study.py
@@ -2,15 +2,21 @@
import operator
import re
from functools import reduce
-from typing import NamedTuple, Text
+from typing import Any, Dict, NamedTuple, Text
from django.contrib import messages
from django.contrib.auth.mixins import UserPassesTestMixin
from django.db.models import Q
from django.db.models.functions import Lower
-from django.http import HttpResponseForbidden, HttpResponseRedirect
+from django.forms.models import BaseModelForm
+from django.http import (
+ HttpRequest,
+ HttpResponse,
+ HttpResponseForbidden,
+ HttpResponseRedirect,
+)
from django.http.response import HttpResponse
-from django.shortcuts import get_object_or_404, reverse
+from django.shortcuts import get_object_or_404, redirect, reverse
from django.views import generic
from django.views.generic.detail import SingleObjectMixin
from revproxy.views import ProxyView
@@ -21,12 +27,18 @@
ResearcherAuthenticatedRedirectMixin,
ResearcherLoginRequiredMixin,
SingleObjectFetchProtocol,
- StudyTypeMixin,
)
from project import settings
-from studies.forms import StudyCreateForm, StudyEditForm
+from studies.forms import (
+ DEFAULT_GENERATOR,
+ EFPForm,
+ ExternalForm,
+ ScheduledChoice,
+ StudyCreateForm,
+ StudyEditForm,
+)
from studies.helpers import send_mail
-from studies.models import Study, StudyType
+from studies.models import Study
from studies.permissions import LabPermission, StudyPermission
from studies.queries import get_study_list_qs
from studies.tasks import ember_build_and_gcp_deploy
@@ -84,25 +96,9 @@ def get_discoverability_text(study):
return DISCOVERABILITY_HELP_TEXT.get(discoverability_key)
-KEY_DISPLAY_NAMES = {
- "player_repo_url": "Experiment runner code URL",
- "last_known_player_sha": "Experiment runner version (commit SHA)",
- "url": "Study URL",
- "scheduling": "Scheduling",
- "study_platform": "Study Platform",
-}
-
-KEY_HELP_TEXT = {
- "url": "This is the link that participants will be sent to from the Lookit details page.",
- "scheduling": "Indicate how participants schedule appointments for your section. Remember that Lookit encourages you to use its messaging system rather than collecting email addresses - this presents a privacy risk for your participants.",
- "study_platform": "What software or website will you use to present & collect data for your study?",
-}
-
-
class StudyCreateView(
ResearcherLoginRequiredMixin,
UserPassesTestMixin,
- StudyTypeMixin,
generic.CreateView,
):
"""
@@ -113,6 +109,7 @@ class StudyCreateView(
model = Study
raise_exception = True
form_class = StudyCreateForm
+ template_name = "studies/study_create.html"
def user_can_make_study(self):
# If returning False,
@@ -131,14 +128,7 @@ def form_valid(self, form):
user = self.request.user
- if form.cleaned_data["external"]:
- target_study_type = StudyType.get_external()
- else:
- target_study_type = StudyType.get_ember_frame_player()
-
- form.instance.metadata = self.extract_type_metadata(target_study_type)
form.instance.creator = user
- form.instance.study_type = target_study_type
# Add user to admin group for study.
new_study = self.object = form.save()
@@ -150,26 +140,7 @@ def form_valid(self, form):
return HttpResponseRedirect(self.get_success_url())
def get_success_url(self):
- return reverse("exp:study-detail", kwargs=dict(pk=self.object.id))
-
- def get_context_data(self, **kwargs):
- """
- Adds study types to get_context_data
- """
- context = super().get_context_data(**kwargs)
- context["study_types"] = StudyType.objects.all()
- context["key_display_names"] = KEY_DISPLAY_NAMES
- context["key_help_text"] = KEY_HELP_TEXT
- return context
-
- def get_initial(self):
- """
- Returns initial data to use for the create study form - make default
- structure field data an empty dict
- """
- initial = super().get_initial()
- initial["structure"] = json.dumps(Study._meta.get_field("structure").default())
- return initial
+ return reverse("exp:study-details", kwargs=dict(pk=self.object.id))
def get_form_kwargs(self):
kwargs = super().get_form_kwargs()
@@ -185,7 +156,6 @@ def form_invalid(self, form: StudyCreateForm) -> HttpResponse:
class StudyUpdateView(
ResearcherLoginRequiredMixin,
UserPassesTestMixin,
- StudyTypeMixin,
SingleObjectFetchProtocol[Study],
generic.UpdateView,
):
@@ -223,37 +193,8 @@ def get_form_kwargs(self):
test_func = user_can_edit_study
def get_initial(self):
- """Get initial data for the study update form.
-
- Provides the exact_text stored in the structure field as the initial
- value to edit, to preserve ordering and formatting from user's standpoint.
-
- Provides the initial value of the generator function if current value is empty.
-
- Returns:
- A dictionary containing initial data for the form
-
- """
initial = super().get_initial()
-
study = self.object
- structure = study.structure
-
- # For editing, display the exact text that was used to generate the structure,
- # if available. We rely on form validation to make sure structure["exact_text"]
- # is valid JSON.
-
- if structure:
- if "exact_text" in structure:
- initial["structure"] = structure["exact_text"]
- else:
- initial["structure"] = json.dumps(structure)
- if not study.generator.strip():
- initial["generator"] = StudyEditForm.base_fields["generator"].initial
-
- if study.study_type.is_external:
- initial["external"] = True
- initial["scheduled"] = study.metadata.get("scheduled", False)
# Must have participated was a huge query, custom form code allowed us to make this
# large query more efficiently. Because are using custom form fields, we have add the values to initial.
@@ -276,26 +217,7 @@ def form_valid(self, form: StudyEditForm):
)
study.must_have_participated.set(form.cleaned_data["must_have_participated"])
- metadata, meta_errors = self.validate_and_fetch_metadata(
- study_type=study.study_type
- )
- if meta_errors:
- messages.error(
- self.request,
- f'WARNING: Changes to experiment were not saved: {", ".join(meta_errors)}',
- )
- else:
- # Check that study type hasn't changed.
- if metadata != study.metadata:
- # Invalidate the previous build
- study.built = False
- # May still be building, but we're now good to allow another build
- study.is_building = False
- # Update metadata
- study.metadata = metadata
-
- study.save()
- messages.success(self.request, f"{study.name} study details saved.")
+ messages.success(self.request, f"{study.name} study details saved.")
return super().form_valid(form)
@@ -308,21 +230,16 @@ def get_context_data(self, **kwargs):
In addition to the study, adds several items to the context dictionary.
"""
context = super().get_context_data(**kwargs)
-
- context["study_types"] = StudyType.objects.all()
- context["key_display_names"] = KEY_DISPLAY_NAMES
- context["key_help_text"] = KEY_HELP_TEXT
context["save_confirmation"] = self.object.state in [
"approved",
"active",
"paused",
"deactivated",
]
-
return context
def get_success_url(self):
- return reverse("exp:study-edit", kwargs={"pk": self.object.id})
+ return reverse("exp:study-details", kwargs={"pk": self.object.id})
class StudyListView(
@@ -614,7 +531,7 @@ def post(self, *args, **kwargs):
return HttpResponseForbidden()
return HttpResponseRedirect(
- reverse("exp:study-detail", kwargs=dict(pk=self.get_object().pk))
+ reverse("exp:study", kwargs=dict(pk=self.get_object().pk))
)
def send_study_email(self, user, permission):
@@ -753,7 +670,7 @@ def post(self, *args, **kwargs):
messages.error(self.request, f"TRANSITION ERROR: {e}")
return HttpResponseRedirect(
- reverse("exp:study-detail", kwargs=dict(pk=self.get_object().pk))
+ reverse("exp:study", kwargs=dict(pk=self.get_object().pk))
)
def update_declarations(self, trigger: Text, study: Study):
@@ -878,7 +795,7 @@ class StudyBuildView(
slug_field = "uuid"
def get_redirect_url(self, *args, **kwargs):
- return reverse("exp:study-detail", kwargs={"pk": str(self.get_object().pk)})
+ return reverse("exp:study", kwargs={"pk": str(self.get_object().pk)})
def user_can_build_study(self):
user = self.request.user
@@ -1077,3 +994,190 @@ def get_permitted_triggers(view_instance, triggers):
permitted_triggers.append(trigger)
return permitted_triggers
+
+
+class ExperimentRunnerEditRedirect(
+ ResearcherLoginRequiredMixin,
+ UserPassesTestMixin,
+ SingleObjectFetchProtocol[Study],
+ generic.UpdateView,
+):
+ model = Study
+
+ def user_can_edit_study(self):
+ """Test predicate for the experiment runner edit view. Borrowed permissions from study edit view.
+
+ Returns:
+ True if this user can edit this Study, False otherwise
+
+ """
+ user: User = self.request.user
+ study = self.get_object()
+
+ return (
+ user
+ and user.is_researcher
+ and user.has_study_perms(StudyPermission.WRITE_STUDY_DETAILS, study)
+ )
+
+ test_func = user_can_edit_study
+
+ def get(self, request: HttpRequest, *args: str, **kwargs: Any) -> HttpResponse:
+ study_type = self.object.study_type
+
+ if study_type.is_ember_frame_player:
+ return redirect(
+ reverse("exp:efp-study-details", kwargs={"pk": self.object.id})
+ )
+ if study_type.is_external:
+ return redirect(
+ reverse("exp:external-study-details", kwargs={"pk": self.object.id})
+ )
+
+
+class ExperimentRunnerEditView(
+ ResearcherLoginRequiredMixin,
+ UserPassesTestMixin,
+ SingleObjectFetchProtocol[Study],
+ generic.UpdateView,
+):
+
+ model = Study
+
+ def user_can_edit_study(self):
+ """Test predicate for the experiment runner edit view. Borrowed permissions from study edit view.
+
+ Returns:
+ True if this user can edit this Study, False otherwise
+
+ """
+ user: User = self.request.user
+ study = self.get_object()
+
+ return (
+ user
+ and user.is_researcher
+ and user.has_study_perms(StudyPermission.WRITE_STUDY_DETAILS, study)
+ )
+
+ test_func = user_can_edit_study
+
+ def get_context_data(self, **kwargs: Any) -> Dict[str, Any]:
+ context = super().get_context_data(**kwargs)
+ context["save_confirmation"] = self.object.state in [
+ "approved",
+ "active",
+ "paused",
+ "deactivated",
+ ]
+ return context
+
+ def get_success_url(self, **kwargs):
+ """Upon successful form submission, change the view to study detail."""
+ return reverse("exp:study", kwargs={"pk": self.object.pk})
+
+
+class EFPEditView(ExperimentRunnerEditView):
+ template_name = "studies/experiment_runner/efp_edit.html"
+ form_class = EFPForm
+
+ def get_initial(self):
+ """Populate Exp Runner with data from the study metadata field. Also, convert structure code to json."""
+ initial = super().get_initial()
+ study = self.object
+ metadata = study.metadata
+ structure = study.structure
+
+ if "exact_text" in structure:
+ structure = structure.get("exact_text")
+ else:
+ structure = json.dumps(structure)
+
+ initial.update(
+ player_repo_url=metadata.get(
+ "player_repo_url", settings.EMBER_EXP_PLAYER_REPO
+ ),
+ last_known_player_sha=metadata.get("last_known_player_sha"),
+ structure=structure,
+ )
+
+ if not study.generator.strip():
+ initial.update(generator=DEFAULT_GENERATOR)
+
+ return initial
+
+ def form_valid(self, form: BaseModelForm) -> HttpResponse:
+ """After form has been determined to be valid, place metadata into the appropriate field in the study table. If
+ There are changes to metadata, set to study to NOT BUILT.
+
+ Args:
+ form (BaseModelForm): _description_
+
+ Returns:
+ HttpResponse: _description_
+ """
+ study = self.object
+ metadata = {
+ "player_repo_url": form.cleaned_data["player_repo_url"],
+ "last_known_player_sha": form.cleaned_data["last_known_player_sha"],
+ }
+
+ if metadata != study.metadata:
+ study.built = False
+ study.is_building = False
+ study.metadata = metadata
+
+ return super().form_valid(form)
+
+
+class ExternalEditView(ExperimentRunnerEditView):
+ template_name = "studies/experiment_runner/external_edit.html"
+ form_class = ExternalForm
+
+ def get_success_url(self, **kwargs):
+ """Upon successful form submission, change the view to study detail."""
+ return reverse("exp:study", kwargs={"pk": self.object.pk})
+
+ def get_initial(self):
+ initial = super().get_initial()
+ metadata = self.object.metadata
+
+ # Scheduled is stored as a boolean value, but repesented in the form as a choice field. We want to
+ # retain the three states this value is stored (true, false, none).
+ scheduled = metadata.get("scheduled")
+ if scheduled is not None:
+ if scheduled:
+ scheduled = ScheduledChoice.scheduled.value
+ else:
+ scheduled = ScheduledChoice.unmoderated.value
+
+ initial.update(
+ scheduled=scheduled,
+ url=metadata.get("url"),
+ scheduling=metadata.get("scheduling"),
+ other_scheduling=metadata.get("other_scheduling"),
+ study_platform=metadata.get("study_platform"),
+ other_study_platform=metadata.get("other_study_platform"),
+ )
+
+ return initial
+
+ def form_valid(self, form: BaseModelForm) -> HttpResponse:
+ study = self.object
+
+ metadata = {
+ "scheduled": form.cleaned_data["scheduled"]
+ == ScheduledChoice.scheduled.value,
+ "url": form.cleaned_data["url"],
+ "scheduling": form.cleaned_data["scheduling"],
+ "other_scheduling": form.cleaned_data["other_scheduling"],
+ "study_platform": form.cleaned_data["study_platform"],
+ "other_study_platform": form.cleaned_data["other_study_platform"],
+ }
+
+ if metadata != study.metadata:
+ study.built = False
+ study.is_building = False
+ study.metadata = metadata
+
+ return super().form_valid(form)
diff --git a/poetry.lock b/poetry.lock
index 6967b451a..d707a8faa 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -1279,6 +1279,22 @@ files = [
{file = "jmespath-0.10.0.tar.gz", hash = "sha256:b85d0567b8666149a93172712e68920734333c0ce7e89b78b3e987f71e5ed4f9"},
]
+[[package]]
+name = "js2py"
+version = "0.74"
+description = "JavaScript to Python Translator & JavaScript interpreter written in 100% pure Python."
+optional = false
+python-versions = "*"
+files = [
+ {file = "Js2Py-0.74-py3-none-any.whl", hash = "sha256:40a508a79e2f8d624e3f2e604f90a1e6f46ac75b416d7f4745939ff4a2e95e09"},
+ {file = "Js2Py-0.74.tar.gz", hash = "sha256:39f3a6aa8469180efba3c8677271df27c31332fd1b471df1af2af58b87b8972f"},
+]
+
+[package.dependencies]
+pyjsparser = ">=2.5.1"
+six = ">=1.10"
+tzlocal = ">=1.2"
+
[[package]]
name = "jsbeautifier"
version = "1.14.9"
@@ -1829,6 +1845,17 @@ files = [
{file = "pyinstrument_cext-0.2.4.tar.gz", hash = "sha256:79b29797209eebd441a8596accfa8b617445d9252fbf7ce75d3a4a0eb46cb877"},
]
+[[package]]
+name = "pyjsparser"
+version = "2.7.1"
+description = "Fast javascript parser (based on esprima.js)"
+optional = false
+python-versions = "*"
+files = [
+ {file = "pyjsparser-2.7.1-py2-none-any.whl", hash = "sha256:2b12842df98d83f65934e0772fa4a5d8b123b3bc79f1af1789172ac70265dd21"},
+ {file = "pyjsparser-2.7.1.tar.gz", hash = "sha256:be60da6b778cc5a5296a69d8e7d614f1f870faf94e1b1b6ac591f2ad5d729579"},
+]
+
[[package]]
name = "pyotp"
version = "2.6.0"
@@ -2381,6 +2408,34 @@ files = [
{file = "typing_extensions-4.1.1.tar.gz", hash = "sha256:1a9462dcc3347a79b1f1c0271fbe79e844580bb598bafa1ed208b94da3cdcd42"},
]
+[[package]]
+name = "tzdata"
+version = "2023.3"
+description = "Provider of IANA time zone data"
+optional = false
+python-versions = ">=2"
+files = [
+ {file = "tzdata-2023.3-py2.py3-none-any.whl", hash = "sha256:7e65763eef3120314099b6939b5546db7adce1e7d6f2e179e3df563c70511eda"},
+ {file = "tzdata-2023.3.tar.gz", hash = "sha256:11ef1e08e54acb0d4f95bdb1be05da659673de4acbd21bf9c69e94cc5e907a3a"},
+]
+
+[[package]]
+name = "tzlocal"
+version = "5.0.1"
+description = "tzinfo object for the local timezone"
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "tzlocal-5.0.1-py3-none-any.whl", hash = "sha256:f3596e180296aaf2dbd97d124fe76ae3a0e3d32b258447de7b939b3fd4be992f"},
+ {file = "tzlocal-5.0.1.tar.gz", hash = "sha256:46eb99ad4bdb71f3f72b7d24f4267753e240944ecfc16f25d2719ba89827a803"},
+]
+
+[package.dependencies]
+tzdata = {version = "*", markers = "platform_system == \"Windows\""}
+
+[package.extras]
+devenv = ["black", "check-manifest", "flake8", "pyroma", "pytest (>=4.3)", "pytest-cov", "pytest-mock (>=3.3)", "zest.releaser"]
+
[[package]]
name = "urllib3"
version = "1.26.15"
@@ -2544,4 +2599,4 @@ testing = ["coverage (>=5.0.3)", "zope.event", "zope.testing"]
[metadata]
lock-version = "2.0"
python-versions = ">=3.9 <3.10"
-content-hash = "34bb0d988941235be5529d96ff77ff553a9f66a6663436fbd513d1c857843782"
+content-hash = "6deb10a4f6ec430e7d8bbf23131d4b468dcb42e145a5c92b265ffcd827ed4e6a"
diff --git a/pyproject.toml b/pyproject.toml
index 44e49c749..ec647caf2 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -51,6 +51,7 @@ transitions = "0.9.0"
uWSGI = "2.0.19.1"
pillow = "9.4.0"
django-bootstrap-icons = "0.8.2"
+js2py = "^0.74"
[tool.poetry.group.dev.dependencies]
coverage = "^7.2"
diff --git a/scss/study-fields.scss b/scss/study-fields.scss
index 6de1b3f45..60f6047d4 100644
--- a/scss/study-fields.scss
+++ b/scss/study-fields.scss
@@ -1,18 +1,14 @@
-#study-details-form,
-#study-create-form {
- .ace-overlay {
- width: 100%;
- margin-bottom: 1.5rem;
- }
+.ace-overlay {
+ width: 100%;
+ margin-bottom: 1.5rem;
}
-#structure-container,
-#generator-container {
- .code-container {
- max-height: 10em;
- }
+
+.code-container {
+ max-height: 10em;
}
+
span.priority-highest {
float: right;
}
diff --git a/studies/forms.py b/studies/forms.py
index 1b1aa12e9..5f322f9b9 100644
--- a/studies/forms.py
+++ b/studies/forms.py
@@ -1,5 +1,8 @@
import json
+from enum import Enum
+import js2py
+import requests
from ace_overlay.widgets import AceOverlayWidget
from django import forms
from django.db.models import Q
@@ -8,6 +11,7 @@
from PIL import Image
from accounts.queries import compile_expression
+from project import settings
from studies.models import Lab, Response, Study
from studies.permissions import LabPermission, StudyPermission
@@ -19,8 +23,6 @@
PROTOCOL_GENERATOR_HELP_LINK = (
"https://lookit.readthedocs.io/en/develop/researchers-protocol-generators.html"
)
-PROTOCOL_HELP_TEXT_EDIT = f"Configure frames to use in your study and specify their order. For information on how to set up your protocol, please see the documentation."
-PROTOCOL_HELP_TEXT_INITIAL = f"{PROTOCOL_HELP_TEXT_EDIT} You can leave the default for now and come back to this later."
DEFAULT_GENERATOR = """function generateProtocol(child, pastSessions) {
/*
* Generate the protocol for this study.
@@ -154,58 +156,6 @@ class Meta:
class StudyForm(ModelForm):
"""Base form for creating or editing a study"""
- # Eventually when we support other experiment runner types (labjs, jspsych, etc.)
- # we may do one of the following:
- # - separate the 'study protocol specification' fields into their own
- # form which collects various information and cleans it and sets a single 'structure' object,
- # with the selected
- # - creating a model to represent each study type, likely such that each study has a nullable
- # relation for lookit_runner_protocol, jspsych_runner_protocol, etc.
-
- structure = forms.CharField(
- label="Protocol configuration",
- widget=AceOverlayWidget(
- mode="json",
- wordwrap=True,
- theme="textmate",
- width="100%",
- height="100%",
- showprintmargin=False,
- ),
- required=False,
- )
-
- external = forms.BooleanField(
- required=False,
- help_text="Post an external link to a study, rather than Lookit's experiment builder.",
- )
- scheduled = forms.BooleanField(
- required=False,
- help_text="Schedule participants for one-on-one appointments with a researcher.",
- )
-
- # Define initial value here rather than providing actual default so that any updates don't
- # require migrations: this isn't a true "default" value that would ever be used, but rather
- # a helpful skeleton to guide the user
- generator = forms.CharField(
- label="Protocol generator",
- widget=AceOverlayWidget(
- mode="javascript",
- wordwrap=True,
- theme="textmate",
- width="100%",
- height="100%",
- showprintmargin=False,
- ),
- required=False,
- help_text=(
- "Write a Javascript function that returns a study protocol object with 'frames' and "
- "'sequence' keys. This allows more flexible randomization and dependence on past sessions in "
- f"complex cases. See documentation for details."
- ),
- initial=DEFAULT_GENERATOR,
- )
-
def participated_choices():
return [
(s[0], f"{s[1]} ({s[2]})")
@@ -235,22 +185,6 @@ def clean(self):
)
return cleaned_data
- def clean_structure(self):
- structure_text = self.cleaned_data["structure"]
-
- # Parse edited text representation of structure object, and additionally store the
- # exact text (so user can organize frames, parameters, etc. for readability)
- try:
- json_data = json.loads(structure_text) # loads string as json
- json_data["exact_text"] = structure_text
- except Exception:
- raise forms.ValidationError(
- "Saving protocol configuration failed due to invalid JSON! Please use valid JSON and save again. If you reload this page, all changes will be lost."
- )
-
- # Store the object which includes the exact text (not just the text)
- return json_data
-
def clean_criteria_expression(self):
criteria_expression = self.cleaned_data["criteria_expression"]
try:
@@ -294,12 +228,10 @@ class Meta:
"contact_info",
"public",
"shared_preview",
- "structure",
- "generator",
- "use_generator",
"criteria_expression",
"must_have_participated",
"must_not_have_participated",
+ "study_type",
]
labels = {
"name": "Study Name",
@@ -312,9 +244,8 @@ class Meta:
"contact_info": "Researcher Contact Information",
"public": "Discoverable",
"shared_preview": "Share Preview",
- "study_type": "Experiment Runner Type",
+ "study_type": "Experiment Type",
"compensation_description": "Compensation",
- "use_generator": "Use protocol generator (advanced)",
"priority": "Lab Page Priority",
}
widgets = {
@@ -352,7 +283,9 @@ class Meta:
"lab": "Which lab this study will be affiliated with",
"image": "This is the image participants will see when browsing studies. Please make sure that your image file dimensions are square and the size is less than 1 MB.",
"exit_url": "Specify the page where you want to send your participants after they've completed the study. (The 'Past studies' page on Lookit is a good default option.)",
- "preview_summary": "This is the text participants will see when browsing studies. The limit is 300 characters.",
+ "preview_summary": """
This is the text (limit 300 characters) that participants will see when browsing studies. Most CHS studies involve a single testing session that a family can complete right now on their own. If your study involves something different, please note this in the description! Use a format something like the following:
+
"In this (study)/(scheduled video call with a researcher)/(four-session study), your child/baby will..."
+
"Help us learn about [topic] (...in a live video call with a researcher)/(...in a four-session study)"
""",
"short_description": "Describe what happens during your study here. This should give families a concrete idea of what they will be doing - e.g., reading a story together and answering questions, watching a short video, playing a game about numbers. If you are running a scheduled study, make sure to include a description of how they will sign up and access the study session.",
"purpose": "Explain the purpose of your study here. This should address what question this study answers AND why that is an interesting or important question, in layperson-friendly terms.",
"contact_info": "This should give the name of the PI for your study, and an email address where the PI or study staff can be reached with questions. Format: PIs Name (contact: youremail@lab.edu)",
@@ -365,13 +298,8 @@ class Meta:
),
"public": "List this study on the 'Studies' page once you start it.",
"shared_preview": "Allow other Lookit researchers to preview your study and give feedback.",
- "study_type": f"""
After selecting an experiment runner type above, you'll be asked
- to provide some additional configuration information.
-
If you're not sure what to enter here, just leave the defaults (you can change this later).
- For more information on experiment runner types, please
- see the documentation.
""",
- "structure": PROTOCOL_HELP_TEXT_INITIAL,
- "priority": f"This affects how studies are ordered at your lab's custom URL, not the main study page. If you leave all studies at the highest priority (99), then all of your lab's active/discoverable studies will be shown in a randomized order on your lab page. If you lower the priority of this study to 1, then it will appear last in the list on your lab page. You can find your lab's custom URL from the labs page. For more info, see the documentation on study prioritization.",
+ "study_type": "Choose the type of experiment you are creating - this will change the fields that appear on the Study Details page.",
+ "priority": "This affects how studies are ordered at your lab's custom URL, not the main study page. If you leave all studies at the highest priority (99), then all of your lab's active/discoverable studies will be shown in a randomized order on your lab page. If you lower the priority of this study to 1, then it will appear last in the list on your lab page. You can find your lab's custom URL from the labs page. For more info, see the documentation on study prioritization.",
}
@@ -380,8 +308,18 @@ class StudyEditForm(StudyForm):
def __init__(self, user=None, *args, **kwargs):
super().__init__(*args, **kwargs)
- self.fields["external"].disabled = True
- self.fields["structure"].help_text = PROTOCOL_HELP_TEXT_EDIT
+
+ # Disable ablility to change study type after study creation.
+ self.fields["study_type"].disabled = True
+ self.fields["study_type"].help_text = (
+ "
NOTE: The study type cannot be changed after creation.
"
+ "Before saving for the first time, please "
+ 'review the distinction between experiment builder (internal) studies and external studies'
+ ", and ask a question on "
+ 'Slack'
+ " if you need help selecting the right option!"
+ )
+
# Restrict ability to edit study lab based on user permissions
can_change_lab = user.has_study_perms(
StudyPermission.CHANGE_STUDY_LAB, self.instance
@@ -411,24 +349,12 @@ def __init__(self, user=None, *args, **kwargs):
)
self.fields["lab"].disabled = True
- def clean_external(self):
- study = self.instance
- external = self.cleaned_data["external"]
-
- if (not external and study.study_type.is_external) or (
- external and study.study_type.is_ember_frame_player
- ):
- raise forms.ValidationError("Attempt to change study type not allowed.")
-
- return external
-
class StudyCreateForm(StudyForm):
"""Form for creating a new study"""
def __init__(self, user=None, *args, **kwargs):
super().__init__(*args, **kwargs)
- self.fields["structure"].help_text = PROTOCOL_HELP_TEXT_EDIT
# Limit initial lab options to labs this user is a member of & can create studies in
self.fields["lab"].queryset = Lab.objects.filter(
id__in=get_objects_for_user(
@@ -445,3 +371,161 @@ class EmailParticipantsForm(forms.Form):
recipients = forms.ChoiceField(widget=EmailRecipientSelectMultiple())
subject = forms.CharField()
body = forms.CharField(widget=forms.Textarea())
+
+
+class EFPForm(ModelForm):
+ player_repo_url = forms.URLField(
+ label="Experiment runner code URL",
+ help_text="Leave this value alone unless you are using a custom version of the experiment builder.",
+ )
+ last_known_player_sha = forms.CharField(
+ label="Experiment runner version (commit SHA)",
+ help_text=(
+ "If you're using the default Ember Frame Player, you can see '
+ "the commits page for other commit SHA options."
+ ),
+ )
+ structure = forms.CharField(
+ label="Protocol configuration",
+ widget=AceOverlayWidget(
+ mode="json",
+ wordwrap=True,
+ theme="textmate",
+ width="100%",
+ height="100%",
+ showprintmargin=False,
+ ),
+ required=False,
+ help_text=(
+ "Configure frames to use in your study and specify their order. For information on how to "
+ "set up your protocol, please see the documentation."
+ ),
+ )
+ generator = forms.CharField(
+ label="Protocol generator",
+ widget=AceOverlayWidget(
+ mode="javascript",
+ wordwrap=True,
+ theme="textmate",
+ width="100%",
+ height="100%",
+ showprintmargin=False,
+ ),
+ required=False,
+ )
+
+ class Meta:
+ model = Study
+ fields = ("use_generator", "generator", "structure")
+ labels = {"use_generator": "Use protocol generator (advanced)"}
+ help_texts = {
+ "use_generator": (
+ "Write a Javascript function that returns a study protocol object with 'frames' and "
+ "'sequence' keys. This allows more flexible randomization and dependence on past sessions in "
+ f"complex cases. See documentation for details."
+ )
+ }
+
+ def clean_structure(self):
+ try:
+ structure = json.loads(self.cleaned_data["structure"])
+ structure["exact_text"] = self.cleaned_data["structure"]
+ return structure
+ except json.JSONDecodeError:
+ raise forms.ValidationError(
+ "Saving protocol configuration failed due to invalid JSON! Please use valid JSON and save again. If you reload this page, all changes will be lost."
+ )
+
+ def clean_generator(self):
+ try:
+ generator = self.cleaned_data["generator"]
+
+ if not generator.strip():
+ generator = DEFAULT_GENERATOR
+
+ js2py.eval_js(generator)
+ return generator
+ except js2py.internals.simplex.JsException:
+ raise forms.ValidationError(
+ "Generator javascript seems to be invalid. Please edit and save again. If you reload this page, all changes will be lost."
+ )
+
+ def clean_player_repo_url(self):
+ player_repo_url = self.cleaned_data["player_repo_url"]
+ validation_error = forms.ValidationError(
+ f"Frameplayer repo url {player_repo_url} does not work."
+ )
+
+ try:
+ if not requests.get(player_repo_url).ok:
+ raise validation_error
+ except requests.exceptions.ConnectionError:
+ raise validation_error
+
+ return player_repo_url
+
+ def clean_last_known_player_sha(self):
+ last_known_player_sha = self.cleaned_data.get("last_known_player_sha")
+ player_repo_url = self.cleaned_data.get("player_repo_url")
+
+ if last_known_player_sha and player_repo_url:
+ if not requests.get(f"{player_repo_url}/commit/{last_known_player_sha}").ok:
+ raise forms.ValidationError(
+ f"Frameplayer commit {last_known_player_sha} does not exist."
+ )
+
+ return last_known_player_sha
+
+
+class ScheduledChoice(Enum):
+ scheduled = "Scheduled"
+ unmoderated = "Unmoderated"
+
+
+class ExternalForm(ModelForm):
+ scheduled = forms.ChoiceField(
+ choices=[
+ (
+ ScheduledChoice.scheduled.value,
+ f"{ScheduledChoice.scheduled.value} (Schedule participants for one-on-one appointments with a researcher.)",
+ ),
+ (
+ ScheduledChoice.unmoderated.value,
+ f"{ScheduledChoice.unmoderated.value} (Give participants a link to take the study on their own.)",
+ ),
+ ]
+ )
+ url = forms.URLField(
+ label="Study URL",
+ help_text="This is the link that participants will be sent to from the Lookit details page.",
+ )
+ scheduling = forms.ChoiceField(
+ required=False,
+ choices=[
+ ("Calendly", "Calendly"),
+ ("Google Calendar", "Google Calendar"),
+ ("Google Form", "Google Form"),
+ ("Other", "Other"),
+ ],
+ widget=forms.RadioSelect,
+ help_text="Indicate how participants schedule appointments for your section. Remember that Lookit encourages you to use its messaging system rather than collecting email addresses - this presents a privacy risk for your participants.",
+ )
+ other_scheduling = forms.CharField(label="", required=False)
+ study_platform = forms.ChoiceField(
+ label="Study Platform",
+ required=False,
+ choices=[
+ ("Qualtrics", "Qualtrics"),
+ ("Prolific", "Prolific"),
+ ("Mechanical Turk", "Mechanical Turk"),
+ ("Other", "Other"),
+ ],
+ widget=forms.RadioSelect,
+ help_text="What software or website will you use to present & collect data for your study?",
+ )
+ other_study_platform = forms.CharField(label="", required=False)
+
+ class Meta:
+ model = Study
+ fields = ()
diff --git a/studies/migrations/0093_remove_studytype_configuration.py b/studies/migrations/0093_remove_studytype_configuration.py
new file mode 100644
index 000000000..2c1384cb8
--- /dev/null
+++ b/studies/migrations/0093_remove_studytype_configuration.py
@@ -0,0 +1,54 @@
+# Generated by Django 3.2.11 on 2023-08-05 15:27
+
+from django.db import migrations
+
+NAMES = {
+ "external": {
+ "old": "External",
+ "new": "External Study (Choose this if you are posting a study link rather using an experiment builder)",
+ },
+ "efp": {
+ "old": "Ember Frame Player (default)",
+ "new": "Lookit/Ember Frame Player (Default experiment builder)",
+ },
+}
+
+
+def study_type_names(apps, from_key, to_key):
+ study_type = apps.get_model("studies", "StudyType")
+
+ # EFP name field
+ efp = study_type.objects.get(name=NAMES["efp"][from_key])
+ efp.name = NAMES["efp"][to_key]
+ efp.save()
+
+ # External name field
+ external = study_type.objects.get(name=NAMES["external"][from_key])
+ external.name = NAMES["external"][to_key]
+ external.save()
+
+
+def update_study_type_names(apps, schema_editor):
+ """Update the display names for study types."""
+ study_type_names(apps, "old", "new")
+
+
+def revert_study_type_names(apps, schema_editor):
+ """Update the display names for study types."""
+ study_type_names(apps, "new", "old")
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("studies", "0092_allow_null_video_pipe_name"),
+ ]
+
+ operations = [
+ migrations.RemoveField(
+ model_name="studytype",
+ name="configuration",
+ ),
+ migrations.RunPython(
+ update_study_type_names, reverse_code=revert_study_type_names
+ ),
+ ]
diff --git a/studies/models.py b/studies/models.py
index 4a6da0089..292b568b1 100644
--- a/studies/models.py
+++ b/studies/models.py
@@ -55,6 +55,9 @@
def default_configuration():
+ """This function was used in the StudyType model. The field requiring this has
+ been removed. However, migrations still reference this function
+ """
return {
# task module should have a build_experiment method decorated as a
# celery task that takes a study uuid
@@ -222,20 +225,19 @@ def notify_lab_of_approval(sender, instance, **kwargs):
class StudyTypeEnum(Enum):
- external = "External"
- ember_frame_player = "Ember Frame Player (default)"
+ external = "External Study (Choose this if you are posting a study link rather using an experiment builder)"
+ ember_frame_player = "Lookit/Ember Frame Player (Default experiment builder)"
class StudyType(models.Model):
name = models.CharField(max_length=255, blank=False, null=False)
- configuration = models.JSONField(default=default_configuration)
def __str__(self):
return self.name
@classmethod
def default_pk(cls):
- return cls.objects.get(name=StudyTypeEnum.ember_frame_player.value).pk
+ return 1
@property
def is_ember_frame_player(self):
@@ -254,11 +256,11 @@ def display_name(self):
@classmethod
def get_ember_frame_player(cls):
- return cls.objects.get(name=StudyTypeEnum.ember_frame_player.value)
+ return cls.objects.get(id=1)
@classmethod
def get_external(cls):
- return cls.objects.get(name=StudyTypeEnum.external.value)
+ return cls.objects.get(id=2)
def default_study_structure():
diff --git a/studies/tasks.py b/studies/tasks.py
index f645f84be..0908a2af2 100644
--- a/studies/tasks.py
+++ b/studies/tasks.py
@@ -73,8 +73,8 @@
FROM studies_response sr
INNER JOIN accounts_child ac on sr.child_id = ac.id
INNER JOIN studies_studytype sst on sr.study_type_id = sst.id
- WHERE (sr.completed_consent_frame = true AND sst."name" = 'Ember Frame Player (default)')
- OR (sst."name" = 'External')
+ WHERE (sr.completed_consent_frame = true AND sst.id = 1)
+ OR (sst.id = 2)
)
),
latest_study_notifications_for_children AS (
diff --git a/studies/templates/emails/notify_admins_of_study_action.html b/studies/templates/emails/notify_admins_of_study_action.html
index f314449bf..c16b4db7b 100644
--- a/studies/templates/emails/notify_admins_of_study_action.html
+++ b/studies/templates/emails/notify_admins_of_study_action.html
@@ -7,19 +7,19 @@
{{ comments|linebreaks }}
- You can approve or disapprove the study here.
+ You can approve or disapprove the study here.
{% elif action == 'retracted' %}
{{ researcher_name }} has retracted the submission of a study: {{ study_name }}
{% elif action == 'active' %}
{{ researcher_name }} has started the study
- {{ study_name }}.
+ {{ study_name }}.
{% elif action == 'paused' %}
{{ researcher_name }} has paused the study
- {{ study_name }}.
+ {{ study_name }}.
{% elif action == 'deactivated' %}
{{ researcher_name }} has deactivated the study
- {{ study_name }}.
+ {{ study_name }}.
{% elif action == 'deployed' %}
- An experiment runner has been built for {{ study_name }}. This study can be previewed here. When this study is approved and activated, participants will be able to access it here.
+ An experiment runner has been built for {{ study_name }}. This study can be previewed here. When this study is approved and activated, participants will be able to access it here.
{% endif %}
diff --git a/studies/templates/emails/notify_admins_of_study_action.txt b/studies/templates/emails/notify_admins_of_study_action.txt
index 37f08e709..9b48751fe 100644
--- a/studies/templates/emails/notify_admins_of_study_action.txt
+++ b/studies/templates/emails/notify_admins_of_study_action.txt
@@ -7,17 +7,17 @@ Dear Lookit Admin,
{{ comments|linebreaks }}
- You can approve or disapprove the study here: {{ base_url }}{% url 'exp:study-detail' pk=study_id %}
+ You can approve or disapprove the study here: {{ base_url }}{% url 'exp:study' pk=study_id %}
{% elif action == 'retracted' %}
{{ researcher_name }} has retracted the submission of a study: {{ study_name }}
{% elif action == 'active' %}
- {{ researcher_name }} has started the study {{study_name}}. {{ base_url }}{% url 'exp:study-detail' pk=study_id %}
+ {{ researcher_name }} has started the study {{study_name}}. {{ base_url }}{% url 'exp:study' pk=study_id %}
{% elif action == 'paused' %}
- {{ researcher_name }} has paused the study {{study_name}}. {{ base_url }}{% url 'exp:study-detail' pk=study_id %}
+ {{ researcher_name }} has paused the study {{study_name}}. {{ base_url }}{% url 'exp:study' pk=study_id %}
{% elif action == 'deactivated' %}
- {{ researcher_name }} has deactivated the study {{study_name}}. {{ base_url }}{% url 'exp:study-detail' pk=study_id %}
+ {{ researcher_name }} has deactivated the study {{study_name}}. {{ base_url }}{% url 'exp:study' pk=study_id %}
{% elif action == 'deployed' %}
- An experiment runner has been built for {{ study_name }} ({{ base_url }}{% url 'exp:study-detail' pk=study_id %})
+ An experiment runner has been built for {{ study_name }} ({{ base_url }}{% url 'exp:study' pk=study_id %})
This study can be previewed here: {{base_url}}{% url 'exp:preview-detail' uuid=study_uuid %}
diff --git a/studies/templates/emails/notify_researchers_of_approval_decision.html b/studies/templates/emails/notify_researchers_of_approval_decision.html
index 309c17d54..00a97bca1 100644
--- a/studies/templates/emails/notify_researchers_of_approval_decision.html
+++ b/studies/templates/emails/notify_researchers_of_approval_decision.html
@@ -13,7 +13,7 @@
You can modify your study and resubmit for approval.
{% endif %}
- Your study can be found here.
+ Your study can be found here.
{% if comments %}
diff --git a/studies/templates/emails/notify_researchers_of_approval_decision.txt b/studies/templates/emails/notify_researchers_of_approval_decision.txt
index 8b896391f..0d0b281d6 100644
--- a/studies/templates/emails/notify_researchers_of_approval_decision.txt
+++ b/studies/templates/emails/notify_researchers_of_approval_decision.txt
@@ -12,7 +12,7 @@ To start your study, log in to Lookit, navigate to the study, and select "Start"
You can modify your study and resubmit for approval.
{% endif %}
-Your study can be found here: {{base_url}}{% url 'exp:study-detail' study_id %}
+Your study can be found here: {{base_url}}{% url 'exp:study' study_id %}
{% if comments %}
Comments from the Lookit Admin:
diff --git a/studies/templates/emails/notify_researchers_of_build_failure.html b/studies/templates/emails/notify_researchers_of_build_failure.html
index aca8371be..52d12e84b 100644
--- a/studies/templates/emails/notify_researchers_of_build_failure.html
+++ b/studies/templates/emails/notify_researchers_of_build_failure.html
@@ -1,6 +1,6 @@
Dear Study Researchers,
- The experiment runner for your study, {{ study_name }},
+ The experiment runner for your study, {{ study_name }},
has failed to build.
It failed during the {{ failure_stage }} stage. Logs are provided below to help with troubleshooting.
diff --git a/studies/templates/emails/notify_researchers_of_build_failure.txt b/studies/templates/emails/notify_researchers_of_build_failure.txt
index e2dd59879..7744d82a6 100644
--- a/studies/templates/emails/notify_researchers_of_build_failure.txt
+++ b/studies/templates/emails/notify_researchers_of_build_failure.txt
@@ -1,6 +1,6 @@
Dear Study Researchers,
- The experiment runner for your study, {{ study_name }} ({{base_url}}{% url 'exp:study-detail' pk=study_id %}),
+ The experiment runner for your study, {{ study_name }} ({{base_url}}{% url 'exp:study' pk=study_id %}),
has failed to build.
It failed during the {{ failure_stage }} stage. Logs are provided below to help with troubleshooting.
diff --git a/studies/templates/emails/notify_researchers_of_deployment.html b/studies/templates/emails/notify_researchers_of_deployment.html
index f881c4107..551b2b542 100644
--- a/studies/templates/emails/notify_researchers_of_deployment.html
+++ b/studies/templates/emails/notify_researchers_of_deployment.html
@@ -1,4 +1,4 @@
Dear Study Researchers,
- An experiment runner has been built for {{ study_name }}. This study can now be previewed here. When this study is approved and activated, participants will be able to access it here.
+ An experiment runner has been built for {{ study_name }}. This study can now be previewed here. When this study is approved and activated, participants will be able to access it here.
diff --git a/studies/templates/emails/notify_researchers_of_deployment.txt b/studies/templates/emails/notify_researchers_of_deployment.txt
index 32fc000a8..3abf2e442 100644
--- a/studies/templates/emails/notify_researchers_of_deployment.txt
+++ b/studies/templates/emails/notify_researchers_of_deployment.txt
@@ -1,6 +1,6 @@
Dear Study Researchers,
- An experiment runner has been built for {{ study_name}} ({{ base_url }}{% url 'exp:study-detail' pk=study_id %}).
+ An experiment runner has been built for {{ study_name}} ({{ base_url }}{% url 'exp:study' pk=study_id %}).
This study can now be previewed here: {{ base_url }}{% url 'exp:preview-detail' uuid=study_uuid %}
diff --git a/studies/templates/studies/_all_json_and_csv_data.html b/studies/templates/studies/_all_json_and_csv_data.html
index b5bd98b0f..ff1762233 100644
--- a/studies/templates/studies/_all_json_and_csv_data.html
+++ b/studies/templates/studies/_all_json_and_csv_data.html
@@ -16,7 +16,7 @@
{% block breadcrumb %}
{% breadcrumb %}
{% url 'exp:study-list' %} Manage Studies
- {% url 'exp:study-detail' pk=study.id %} {{ study.name }}
+ {% url 'exp:study' pk=study.id %} {{ study.name }}
{{ active_tab }}
{% endbreadcrumb %}
{% endblock breadcrumb %}
@@ -229,7 +229,7 @@
+ value="participant__global_id" />
@@ -59,9 +48,7 @@
title=""
id="id_min_age_years">
{% for x, y in form.fields.min_age_years.choices %}
-
+
{% endfor %}
@@ -76,9 +63,7 @@
name="min_age_months"
title="">
{% for x, y in form.fields.min_age_months.choices %}
-
+
{% endfor %}
@@ -93,9 +78,7 @@
name="min_age_days"
title="">
{% for x, y in form.fields.min_age_days.choices %}
-
+
{% endfor %}
@@ -123,9 +106,7 @@
title=""
id="id_max_age_years">
{% for x, y in form.fields.max_age_years.choices %}
-
+
{% endfor %}
@@ -140,10 +121,7 @@
name="max_age_months"
title="">
{% for x, y in form.fields.max_age_months.choices %}
-
+
{% endfor %}
@@ -158,9 +136,7 @@
name="max_age_days"
title="">
{% for x, y in form.fields.max_age_days.choices %}
-
+
{% endfor %}
@@ -181,17 +157,4 @@
{% bootstrap_field form.must_not_have_participated label_class="form-label fw-bold" wrapper_class="mb-4" %}
{% bootstrap_field form.criteria_expression label_class="form-label fw-bold" wrapper_class="mb-4" %}
-
-
-
-
- Now it's time for the actual study! For internal studies, you will add a protocol configuration or generator. For external studies, you will paste in your study or scheduling link. If you don't see what you expect, check the "External" and "Scheduled" checkboxes at the top of this form!
-
- {% for field in study_type.configuration.metadata.fields %}
- {% with field.name as key %}
- {% with field.value as value %}
- {% with key_help_text|get_key:key|default:"" as help_text %}
- {% if field.input_type == "checkbox" %}
-
-
-
-
- {% elif field.input_type == "radio" %}
-
-
- {% for option in field.options %}
-
-
-
- {% if option == "Other" %}
- {% if study %}
- {% with "other_"|add:field.name as other_field %}
- {% with study.metadata|get_key:other_field|default:'' as other_value %}
-
- {% endwith %}
- {% endwith %}
- {% else %}
-
- {% endif %}
- {% endif %}
- {% endfor %}
-
+ We have split the study forms into two views for easier navigation.
+ You can update eligibility and other recruiting information on
+ the Study Ad
+ and Recruitment page.
+