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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 36 additions & 4 deletions src/qualtricssurvey/README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
40 changes: 21 additions & 19 deletions src/qualtricssurvey/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ class QualtricsSurveyModelMixin:
"survey_id",
"your_university",
"link_text",
"param_name",
"extra_params",
"message",
]
display_name = String(
Expand All @@ -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:"),
Expand All @@ -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
2 changes: 1 addition & 1 deletion src/qualtricssurvey/templates/view.html
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
<p>{{ message }}</p>
<p>
<a class="button primary-button qualtrics-button"
href="https://{{ your_university }}.qualtrics.com/jfe/form/{{ survey_id }}{{ user_id_string }}"
href="https://{{ your_university }}.qualtrics.com/jfe/form/{{ survey_id }}{{ query_string }}"
target="_blank">{{ link_text }}</a>
</p>
</div>
222 changes: 209 additions & 13 deletions src/qualtricssurvey/tests/test_display.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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("&amp;email=user%40example.com", content)
self.assertIn("&amp;foo=bar", content)
self.assertIn("&amp;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("&amp;course=CS+101", content)
self.assertIn("&amp;blank=", content)

def test_custom_message(self):
"""
Checks the student view with a custom message.
Expand Down
Loading
Loading