diff --git a/src/qualtricssurvey/README.rst b/src/qualtricssurvey/README.rst index 62955e8..257113b 100644 --- a/src/qualtricssurvey/README.rst +++ b/src/qualtricssurvey/README.rst @@ -58,12 +58,44 @@ Using the Studio editor, you can edit the following fields: - survey id - university - link text +- extra parameters - message -- parameter name for userid -Note: If you plan to make use of the "Param Name" field to store User ID -data, you will need to configure your Qualtrics surveys to in turn -collect that data on Qualtrics' end. +Configuration +~~~~~~~~~~~~~ + +Operators can configure system-wide defaults via ``XBLOCK_SETTINGS`` in +the Django settings: + +.. code-block:: python + + XBLOCK_SETTINGS["QualtricsSurvey"] = { + "DEFAULT_UNIVERSITY": "stanforduniversity", + "USER_QUERY_PARAMS": { + "edxuid": "user_id", + "email": "email", + }, + } + +``DEFAULT_UNIVERSITY`` + The default Qualtrics subdomain for your institution. Used when the + per-instance university field is left blank. + +``USER_QUERY_PARAMS`` + A mapping of URL parameter names to user attributes. The key is the + query parameter name that appears in the survey URL, and the value is + the user attribute to resolve. Supported attributes: + + - ``user_id`` - platform user ID (with fallback to anonymous ID) + - ``anonymous_id`` - anonymous student identifier + - ``email`` - primary email address + - ``username`` - platform username + + If ``USER_QUERY_PARAMS`` is not configured, no user parameters are + sent by default. To start sending user data to Qualtrics, operators + must explicitly configure this setting. + Existing blocks that already store a legacy ``param_name`` value + continue to use that value as a fallback. Participants diff --git a/src/qualtricssurvey/models.py b/src/qualtricssurvey/models.py index 4cf4b8a..6f70130 100644 --- a/src/qualtricssurvey/models.py +++ b/src/qualtricssurvey/models.py @@ -16,7 +16,7 @@ class QualtricsSurveyModelMixin: "survey_id", "your_university", "link_text", - "param_name", + "extra_params", "message", ] display_name = String( @@ -37,11 +37,15 @@ class QualtricsSurveyModelMixin: scope=Scope.settings, help=_("This is the text that will be displayed above the link to your survey."), ) - param_name = String( - display_name=_("Param Name:"), - default="a", + extra_params = String( + display_name=_("Extra Parameters:"), + default="", scope=Scope.settings, - help=_("This is the name for the User ID parameter in the url. If blank, User ID is ommitted from the url."), + help=_( + "Additional query parameters to include in the survey URL. " + "Format: key1=value1&key2=value2. " + "If blank, no extra parameters are added." + ), ) survey_id = String( display_name=_("Survey ID:"), @@ -55,19 +59,17 @@ class QualtricsSurveyModelMixin: ) your_university = String( display_name=_("Your University:"), - default="stanforduniversity", + default="", + scope=Scope.settings, + help=_( + "The subdomain for your university's Qualtrics account " + "(e.g.'stanforduniversity'). " + "If left blank, the system-wide default is used." + ), + ) + # Deprecated: kept for backward compatibility with existing course data. + # Not included in editable_fields so it no longer appears in Studio. + param_name = String( + default="", scope=Scope.settings, - help=_("This is the name of your university."), ) - - def get_anon_id(self): - """ - Return an anonymous user id - """ - try: - user_id = self.xmodule_runtime.anonymous_student_id - except AttributeError: - user_id = -1 - return user_id - - # pylint: enable=no-member diff --git a/src/qualtricssurvey/templates/view.html b/src/qualtricssurvey/templates/view.html index 8e5d233..708c07e 100644 --- a/src/qualtricssurvey/templates/view.html +++ b/src/qualtricssurvey/templates/view.html @@ -2,7 +2,7 @@

{{ message }}

{{ link_text }}

diff --git a/src/qualtricssurvey/tests/test_display.py b/src/qualtricssurvey/tests/test_display.py index 251952d..45911c8 100644 --- a/src/qualtricssurvey/tests/test_display.py +++ b/src/qualtricssurvey/tests/test_display.py @@ -12,14 +12,34 @@ from qualtricssurvey.xblocks import QualtricsSurvey -def mock_an_xblock(**kwargs): +def mock_an_xblock(field_overrides=None, user_service=None, xblock_settings=None): """ Create and return an instance of the XBlock """ course_id = SlashSeparatedCourseKey("foo", "bar", "baz") runtime = mock.Mock(course_id=course_id) + runtime.anonymous_student_id = "anon-user-id" + + i18n_service = mock.Mock() + i18n_service.ugettext.side_effect = lambda text: text + i18n_service.gettext.side_effect = lambda text: text + + settings_service = mock.Mock() + settings_service.get_settings_bucket.return_value = xblock_settings or {} + + def service(_block, service_name): + if service_name == "user" and user_service is not None: + return user_service + if service_name == "i18n": + return i18n_service + if service_name == "settings": + return settings_service + raise Exception("Service not available") + + runtime.service = mock.Mock(side_effect=service) scope_ids = mock.Mock() - field_data = DictFieldData(kwargs) + scope_ids.usage_id = "usage-id" + field_data = DictFieldData(field_overrides or {}) xblock = QualtricsSurvey(runtime, field_data, scope_ids) xblock.xmodule_runtime = runtime return xblock @@ -40,30 +60,206 @@ def test_render(self): self.assertNotEqual("", html) self.assertIn("qualtricssurvey_block", html) - def test_student_view(self): + def test_student_view_defaults(self): """ - Checks the student view with param_name but without - anonymous_user_id. + Checks the default student view with no XBLOCK_SETTINGS configured. + Since param_name defaults to "" and no USER_QUERY_PARAMS is set, + no user params are sent. """ xblock = self.xblock fragment = xblock.student_view() content = fragment.content self.assertIn("Begin Survey", content) self.assertIn('target="_blank"', content) - self.assertIn("a=", content) - self.assertIn('href="https://stanforduniversity.qualtrics.com/jfe/form/Enter', content) + self.assertNotIn("?", content) self.assertIn(xblock.message, content) - def test_student_view_no_param_name(self): + def test_blank_param_name_sends_nothing(self): """ - Checks the student view without param_name; - user id part should be missing. + When param_name is deliberately blank and no USER_QUERY_PARAMS + is configured, no user params are sent. """ - xblock = mock_an_xblock(param_name=None) - fragment = xblock.student_view() - content = fragment.content + xblock = mock_an_xblock(field_overrides={"param_name": ""}) + content = xblock.student_view().content + self.assertNotIn("edxuid=", content) + self.assertNotIn("anon-user-id", content) + self.assertNotIn("?", content) + + def test_student_view_with_settings(self): + """ + When USER_QUERY_PARAMS is configured in XBLOCK_SETTINGS, + uses the configured mapping instead of legacy param_name. + """ + xblock = mock_an_xblock( + xblock_settings={ + "USER_QUERY_PARAMS": { + "edxuid": "user_id", + "email": "email", + }, + } + ) + content = xblock.student_view().content + self.assertIn("?edxuid=anon-user-id", content) self.assertNotIn("a=", content) + def test_student_view_with_user_service(self): + """ + Checks the student view when the runtime provides user information + and USER_QUERY_PARAMS is configured. + """ + user = mock.Mock() + user.user_id = None + user.opt_attrs = {"edx-platform.user_id": "12345"} + user.emails = ["user@example.com"] + user_service = mock.Mock() + user_service.get_current_user.return_value = user + xblock = mock_an_xblock( + field_overrides={"extra_params": "foo=bar&baz="}, + user_service=user_service, + xblock_settings={ + "USER_QUERY_PARAMS": { + "edxuid": "user_id", + "email": "email", + }, + }, + ) + content = xblock.student_view().content + self.assertIn("?edxuid=12345", content) + self.assertIn("&email=user%40example.com", content) + self.assertIn("&foo=bar", content) + self.assertIn("&baz=", content) + + def test_custom_user_query_params(self): + """ + USER_QUERY_PARAMS controls which user attributes are sent. + """ + user = mock.Mock() + user.user_id = "99" + user.opt_attrs = {"edx-platform.username": "jdoe"} + user.emails = ["j@example.com"] + user_service = mock.Mock() + user_service.get_current_user.return_value = user + xblock = mock_an_xblock( + user_service=user_service, + xblock_settings={ + "USER_QUERY_PARAMS": { + "uid": "user_id", + "uname": "username", + }, + }, + ) + content = xblock.student_view().content + self.assertIn("uid=99", content) + self.assertIn("uname=jdoe", content) + self.assertNotIn("email=", content) + self.assertNotIn("edxuid=", content) + + def test_anonymous_id_resolver(self): + """ + USER_QUERY_PARAMS can explicitly request anonymous_id. + """ + xblock = mock_an_xblock( + xblock_settings={ + "USER_QUERY_PARAMS": { + "anon": "anonymous_id", + }, + } + ) + content = xblock.student_view().content + self.assertIn("?anon=anon-user-id", content) + + def test_empty_user_query_params(self): + """ + Setting USER_QUERY_PARAMS to an empty dict disables user params. + """ + xblock = mock_an_xblock(xblock_settings={"USER_QUERY_PARAMS": {}}) + content = xblock.student_view().content + self.assertNotIn("edxuid=", content) + self.assertNotIn("email=", content) + self.assertNotIn("?", content) + + def test_unknown_attribute_key_skipped(self): + """ + An unrecognized USER_QUERY_PARAMS attribute is skipped. + """ + xblock = mock_an_xblock( + xblock_settings={ + "USER_QUERY_PARAMS": { + "x": "nonexistent_attribute", + "edxuid": "user_id", + }, + } + ) + content = xblock.student_view().content + self.assertNotIn("x=", content) + self.assertIn("edxuid=anon-user-id", content) + + def test_param_name_backward_compat(self): + """ + Without USER_QUERY_PARAMS, param_name falls back to anonymous_id. + """ + xblock = mock_an_xblock(field_overrides={"param_name": "a"}) + content = xblock.student_view().content + self.assertIn("?a=anon-user-id", content) + self.assertNotIn("edxuid=", content) + + def test_param_name_overridden_by_settings(self): + """ + USER_QUERY_PARAMS takes precedence over param_name. + """ + xblock = mock_an_xblock( + field_overrides={"param_name": "a"}, + xblock_settings={"USER_QUERY_PARAMS": {"edxuid": "user_id"}}, + ) + content = xblock.student_view().content + self.assertIn("edxuid=anon-user-id", content) + self.assertNotIn("a=", content) + + def test_university_from_settings_fallback(self): + """ + Blank your_university falls back to DEFAULT_UNIVERSITY. + """ + xblock = mock_an_xblock(xblock_settings={"DEFAULT_UNIVERSITY": "mit"}) + content = xblock.student_view().content + self.assertIn('href="https://mit.qualtrics.com/jfe/form/Enter', content) + + def test_university_field_takes_precedence(self): + """ + A per-block your_university overrides DEFAULT_UNIVERSITY. + """ + xblock = mock_an_xblock( + field_overrides={"your_university": "stanford"}, + xblock_settings={"DEFAULT_UNIVERSITY": "mit"}, + ) + content = xblock.student_view().content + self.assertIn("https://stanford.qualtrics.com", content) + self.assertNotIn("mit", content) + + def test_extra_params(self): + """ + extra_params are appended to the survey URL. + """ + xblock = mock_an_xblock( + field_overrides={"extra_params": "course=CS101&term=fall"}, + xblock_settings={"USER_QUERY_PARAMS": {}}, + ) + content = xblock.student_view().content + self.assertIn("course=CS101", content) + self.assertIn("term=fall", content) + + def test_extra_params_are_encoded_together_with_user_params(self): + """ + User params and extra_params share one encoded query string. + """ + xblock = mock_an_xblock( + field_overrides={"extra_params": "&course=CS 101&blank="}, + xblock_settings={"USER_QUERY_PARAMS": {"edxuid": "user_id"}}, + ) + content = xblock.student_view().content + self.assertIn("?edxuid=anon-user-id", content) + self.assertIn("&course=CS+101", content) + self.assertIn("&blank=", content) + def test_custom_message(self): """ Checks the student view with a custom message. diff --git a/src/qualtricssurvey/views.py b/src/qualtricssurvey/views.py index 31a7255..92b6fba 100644 --- a/src/qualtricssurvey/views.py +++ b/src/qualtricssurvey/views.py @@ -2,6 +2,8 @@ Handle view logic for the XBlock """ +from urllib.parse import parse_qsl, urlencode + try: from xblock.utils.resources import ResourceLoader from xblock.utils.studio_editable import StudioEditableXBlockMixin @@ -13,6 +15,42 @@ from .mixins.fragment import XBlockFragmentBuilderMixin +def _resolve_user_id(xblock, user, runtime): + """Resolve the platform user ID with fallbacks.""" + if user: + user_id = user.user_id or user.opt_attrs.get("edx-platform.user_id") + if user_id: + return user_id + return _resolve_anonymous_id(xblock, user, runtime) + + +def _resolve_anonymous_id(xblock, _user, runtime): + """Resolve the anonymous student ID.""" + return getattr(runtime, "anonymous_student_id", None) or getattr( + getattr(xblock, "xmodule_runtime", None), + "anonymous_student_id", + None, + ) + + +def _resolve_email(_xblock, user, _runtime): + """Resolve the primary email address.""" + return user.emails[0] if user and user.emails else None + + +def _resolve_username(_xblock, user, _runtime): + """Resolve the platform username.""" + return user.opt_attrs.get("edx-platform.username") if user else None + + +USER_ATTRIBUTE_RESOLVERS = { + "user_id": _resolve_user_id, + "anonymous_id": _resolve_anonymous_id, + "email": _resolve_email, + "username": _resolve_username, +} + + class QualtricsSurveyViewMixin( XBlockFragmentBuilderMixin, StudioEditableXBlockMixin, @@ -30,19 +68,69 @@ def provide_context(self, context=None): """ context = context or {} context = dict(context) - param_name = self.param_name - anon_user_id = self.get_anon_id() - user_id_string = "" - if param_name: - user_id_string = f"?{param_name}={anon_user_id}" + settings = self.get_xblock_settings(default={}) + query_params = self._user_query_params(settings) + query_params.extend(self._extra_query_params()) + query_string = "" + if query_params: + query_string = f"?{urlencode(query_params, doseq=True)}" + university = self.your_university or settings.get("DEFAULT_UNIVERSITY", "") context.update( { "xblock_id": str(self.scope_ids.usage_id), "survey_id": self.survey_id, - "your_university": self.your_university, + "your_university": university, "link_text": self.link_text, - "user_id_string": user_id_string, + "query_string": query_string, "message": self.message, } ) return context + + def _user_query_params(self, settings): + """ + Return query parameters derived from the current user. + """ + params = [] + runtime = getattr(self, "runtime", None) + if not runtime: + return params + + try: + user_service = runtime.service(self, "user") + except Exception: # pragma: no cover - service may be unavailable + user_service = None + + user = user_service.get_current_user() if user_service else None + + if "USER_QUERY_PARAMS" in settings: + param_map = settings["USER_QUERY_PARAMS"] + elif self.param_name: + param_map = {self.param_name: "anonymous_id"} + else: + param_map = {} + + for url_param_name, attribute_key in param_map.items(): + resolver = USER_ATTRIBUTE_RESOLVERS.get(attribute_key) + if not resolver: + continue + value = resolver(self, user, runtime) + if value: + params.append((url_param_name, value)) + + return params + + def _extra_query_params(self): + """ + Return query parameters defined by the author. + """ + extra_params = getattr(self, "extra_params", "") or "" + extra_params = extra_params.strip() + if not extra_params: + return [] + + cleaned = extra_params.lstrip("&?") + if not cleaned: + return [] + + return parse_qsl(cleaned, keep_blank_values=True) diff --git a/src/qualtricssurvey/xblocks.py b/src/qualtricssurvey/xblocks.py index 74b1017..c8406cb 100644 --- a/src/qualtricssurvey/xblocks.py +++ b/src/qualtricssurvey/xblocks.py @@ -3,6 +3,7 @@ """ from xblock.core import XBlock +from xblock.utils.settings import XBlockWithSettingsMixin from .mixins.scenario import XBlockWorkbenchMixin from .models import QualtricsSurveyModelMixin @@ -10,12 +11,17 @@ @XBlock.needs("i18n") +@XBlock.wants("user") +@XBlock.wants("settings") class QualtricsSurvey( QualtricsSurveyModelMixin, QualtricsSurveyViewMixin, + XBlockWithSettingsMixin, XBlockWorkbenchMixin, XBlock, ): """ A Qualtrics survey XBlock. """ + + block_settings_key = "QualtricsSurvey"