diff --git a/src/qualtricssurvey/README.rst b/src/qualtricssurvey/README.rst index 62955e8..6483a7c 100644 --- a/src/qualtricssurvey/README.rst +++ b/src/qualtricssurvey/README.rst @@ -58,12 +58,88 @@ 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`` - course-specific anonymous user ID + - ``email`` - primary email address + - ``username`` - platform username + + These values come from the XBlock user service. edx-platform only + provides them for authenticated users, so any attribute that cannot be + resolved (for example, for an anonymous visitor) is left out of the URL. + + 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. + +Privacy +~~~~~~~ + +Values sent through ``USER_QUERY_PARAMS`` become part of the survey URL. +Query strings are recorded in web server and proxy logs, kept in browser +history, and may be forwarded in ``Referer`` headers. Before mapping +``email``, ``username``, or ``user_id``, confirm that sending that data to +Qualtrics is acceptable under your institution's privacy policy. +``anonymous_id`` is the least identifying option. + + +Upgrading +~~~~~~~~~ + +Version 2.0 changed two defaults. Blocks that never set these fields in +Studio relied on the old defaults will behave differently after upgrading. + +``param_name`` default changed from ``"a"`` to ``""`` + Blocks that never set this field used to send ``?a=``. + They now send no user parameters. The field is no longer editable in + Studio; blocks that explicitly saved a value keep using it. To restore + the old behaviour platform-wide, configure: + + .. code-block:: python + + XBLOCK_SETTINGS["QualtricsSurvey"] = { + "USER_QUERY_PARAMS": {"a": "anonymous_id"}, + } + +``your_university`` default changed from ``"stanforduniversity"`` to ``""`` + Blocks that never set this field now render a link without a + subdomain unless ``DEFAULT_UNIVERSITY`` is configured. To restore the + old behaviour platform-wide, configure: + + .. code-block:: python + + XBLOCK_SETTINGS["QualtricsSurvey"] = { + "DEFAULT_UNIVERSITY": "stanforduniversity", + } 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..38d71f0 100644 --- a/src/qualtricssurvey/tests/test_display.py +++ b/src/qualtricssurvey/tests/test_display.py @@ -8,21 +8,59 @@ from opaque_keys.edx.locations import SlashSeparatedCourseKey from xblock.field_data import DictFieldData +from xblock.reference.user_service import XBlockUser from qualtricssurvey.xblocks import QualtricsSurvey -def mock_an_xblock(**kwargs): +def make_user(user_id="12345", anonymous_id="anon-user-id", username="jdoe", emails=None): + """ + Build an XBlockUser the way edx-platform does for an authenticated user. + + Pass ``None`` for any attribute to leave it unset, as happens for + anonymous visitors. + """ + user = XBlockUser(is_current_user=True, emails=emails) + opt_attrs = { + "edx-platform.user_id": user_id, + "edx-platform.anonymous_user_id": anonymous_id, + "edx-platform.username": username, + } + user.opt_attrs = {key: value for key, value in opt_attrs.items() if value is not None} + return user + + +def mock_an_xblock(field_overrides=None, user=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) + + 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 {} + + user_service = mock.Mock() + user_service.get_current_user.return_value = user if user is not None else make_user() + + def service(_block, service_name): + if service_name == "user": + 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) - xblock = QualtricsSurvey(runtime, field_data, scope_ids) - xblock.xmodule_runtime = runtime - return xblock + scope_ids.usage_id = "usage-id" + field_data = DictFieldData(field_overrides or {}) + return QualtricsSurvey(runtime, field_data, scope_ids) class TestRender(unittest.TestCase): @@ -40,30 +78,218 @@ 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( + field_overrides={"extra_params": "foo=bar&baz="}, + user=make_user(emails=["user@example.com"]), + 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) self.assertNotIn("a=", content) + def test_user_id_falls_back_to_anonymous_id(self): + """ + When edx-platform.user_id is absent, user_id resolves to the + anonymous ID instead. + """ + xblock = mock_an_xblock( + user=make_user(user_id=None), + xblock_settings={"USER_QUERY_PARAMS": {"edxuid": "user_id"}}, + ) + content = xblock.student_view().content + self.assertIn("?edxuid=anon-user-id", content) + + def test_anonymous_visitor_sends_nothing(self): + """ + edx-platform sets no identifying opt_attrs and no emails for + unauthenticated users, so every user param is skipped. + """ + xblock = mock_an_xblock( + user=XBlockUser(is_current_user=True), + xblock_settings={ + "USER_QUERY_PARAMS": { + "edxuid": "user_id", + "anon": "anonymous_id", + "email": "email", + "uname": "username", + }, + }, + ) + content = xblock.student_view().content + self.assertNotIn("?", content) + + def test_no_user_service_sends_nothing(self): + """ + A runtime without a user service yields no user params. + """ + xblock = mock_an_xblock(xblock_settings={"USER_QUERY_PARAMS": {"edxuid": "user_id"}}) + xblock.runtime.service = mock.Mock(return_value=None) + content = xblock.student_view().content + self.assertNotIn("?", content) + + def test_custom_user_query_params(self): + """ + USER_QUERY_PARAMS controls which user attributes are sent. + """ + xblock = mock_an_xblock( + user=make_user(user_id="99", emails=["j@example.com"]), + 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=12345", 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=12345", 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=12345", 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..8e425af 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 @@ -12,6 +14,45 @@ from .mixins.fragment import XBlockFragmentBuilderMixin +# Keys that edx-platform sets on ``XBlockUser.opt_attrs``. They are only +# present for authenticated users; anonymous visitors get none of them. +ATTR_KEY_USER_ID = "edx-platform.user_id" +ATTR_KEY_ANONYMOUS_USER_ID = "edx-platform.anonymous_user_id" +ATTR_KEY_USERNAME = "edx-platform.username" + + +def _resolve_user_id(user): + """Resolve the platform user ID, falling back to the anonymous ID.""" + return _opt_attr(user, ATTR_KEY_USER_ID) or _resolve_anonymous_id(user) + + +def _resolve_anonymous_id(user): + """Resolve the course-specific anonymous user ID.""" + return _opt_attr(user, ATTR_KEY_ANONYMOUS_USER_ID) + + +def _resolve_email(user): + """Resolve the primary email address.""" + return user.emails[0] if user and user.emails else None + + +def _resolve_username(user): + """Resolve the platform username.""" + return _opt_attr(user, ATTR_KEY_USERNAME) + + +def _opt_attr(user, key): + """Read an optional attribute from the user, or None if unavailable.""" + return user.opt_attrs.get(key) 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, @@ -30,19 +71,61 @@ 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 = [] + user_service = self.runtime.service(self, "user") + 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(user) + 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"