diff --git a/src/ol_openedx_feedback/README.rst b/src/ol_openedx_feedback/README.rst new file mode 100644 index 00000000..d52e20a4 --- /dev/null +++ b/src/ol_openedx_feedback/README.rst @@ -0,0 +1,73 @@ +ol-openedx-feedback +################### + +An Open edX plugin that adds a per-block "Send feedback" trigger to applicable +leaf blocks in the LMS via an ``XBlockAside``. The trigger is shown only to +authenticated learners (never in Studio author/preview mode and never to +anonymous users). + +When a learner clicks the trigger the aside posts an ``ol-feedback::drawer-open`` +message to its parent window (the Learning MFE) using ``window.parent.postMessage``. +The message payload carries the block context needed to identify the content +being rated: + +- ``courseId`` — the course key +- ``blockUsageKey`` — the block's usage key +- ``blockType`` — the XBlock category (e.g. ``problem``, ``video``) +- ``blockDisplayName`` — the block's display name + +The Learning MFE receives the message and opens the feedback drawer, which +submits the learner's feedback directly to the **mit-learn** service. This +plugin does **not** persist anything in edx-platform and exposes no REST API. + +Installation +============ + +Install the package into the LMS Python environment: + +.. code-block:: bash + + pip install ol-openedx-feedback + +The plugin registers itself automatically through its entry points — the +``xblock_asides.v1`` aside plus the ``lms.djangoapp`` app config — so no changes +to ``INSTALLED_APPS`` are required. Restart the LMS after installing. (The +trigger is learner-facing only, so the plugin is LMS-only and is not installed +in Studio/CMS.) + +Enable XBlock asides in the LMS admin +------------------------------------- + +XBlock asides must be turned on for any aside (including this one) to render. +In the LMS Django admin, open **XBlock Asides Config** +(``/admin/lms_xblock/xblockasidesconfig/``), add a new entry, and check +**Enabled**. Make sure the block types you want the feedback trigger on are +**not** listed in **Disabled blocks** (the space-separated field defaults to +``about course_info static_tab``). + +Enablement +========== + +Feedback is gated by the ``ol_openedx_feedback.feedback_enabled`` course waffle +flag (default off). Enable it for the desired courses (or globally) to roll out. + +Configuration +============= + +By default the trigger renders on every leaf block and is suppressed only on +structural containers (``course`` / ``chapter`` / ``sequential`` / ``vertical``). +To additionally exclude one or more block types (for example ``html``), override +the excluded set through ``ENV_TOKENS`` (e.g. in ``lms.yml``): + +.. code-block:: yaml + + OL_OPENEDX_FEEDBACK_EXCLUDED_BLOCK_TYPES: + - course + - chapter + - sequential + - vertical + - html + +The plugin reads this value via its ``settings.common`` ``plugin_settings`` hook +and exposes it as the ``OL_OPENEDX_FEEDBACK_EXCLUDED_BLOCK_TYPES`` Django +setting, defaulting to the structural set above when unset. diff --git a/src/ol_openedx_feedback/ol_openedx_feedback/__init__.py b/src/ol_openedx_feedback/ol_openedx_feedback/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/ol_openedx_feedback/ol_openedx_feedback/apps.py b/src/ol_openedx_feedback/ol_openedx_feedback/apps.py new file mode 100644 index 00000000..a3ee96fb --- /dev/null +++ b/src/ol_openedx_feedback/ol_openedx_feedback/apps.py @@ -0,0 +1,27 @@ +"""ol_openedx_feedback Django application initialization.""" + +from django.apps import AppConfig +from edx_django_utils.plugins import PluginSettings +from openedx.core.djangoapps.plugins.constants import ProjectType, SettingsType + + +class OLOpenedxFeedbackConfig(AppConfig): + """Configuration for the ol_openedx_feedback Django application. + + Trigger-only plugin: the feedback trigger is registered via the + ``xblock_asides.v1`` entry point (no URLs or models — feedback is persisted + in mit-learn and the MFE owns the submit URL). LMS-only, since the trigger + renders in the learner (student) view; the only Django settings are the + plugin defaults populated by ``settings.common``. + """ + + name = "ol_openedx_feedback" + verbose_name = "Open edX Block Feedback" + + plugin_app = { + PluginSettings.CONFIG: { + ProjectType.LMS: { + SettingsType.COMMON: {PluginSettings.RELATIVE_PATH: "settings.common"}, + }, + }, + } diff --git a/src/ol_openedx_feedback/ol_openedx_feedback/block.py b/src/ol_openedx_feedback/ol_openedx_feedback/block.py new file mode 100644 index 00000000..91a17d38 --- /dev/null +++ b/src/ol_openedx_feedback/ol_openedx_feedback/block.py @@ -0,0 +1,93 @@ +"""XBlockAside that renders the per-block feedback trigger.""" + +import logging + +import pkg_resources +from django.conf import settings +from django.template import Context, Template +from django.utils.translation import gettext +from web_fragments.fragment import Fragment +from xblock.core import XBlockAside + +try: + from xmodule.modulestore.xml import ( + XMLImportingModuleStoreRuntime as XMLImportingModuleStoreRuntime, # noqa: PLC0414 + ) +except ImportError: + from xmodule.modulestore.xml import ImportSystem as XMLImportingModuleStoreRuntime + +from xmodule.x_module import STUDENT_VIEW + +from ol_openedx_feedback.compat import get_feedback_enabled_flag +from ol_openedx_feedback.utils import is_aside_applicable_to_block + +log = logging.getLogger(__name__) + + +def _resource(path): + """Return the decoded contents of a packaged resource.""" + return pkg_resources.resource_string(__name__, path).decode("utf-8") + + +def _render(path, context): + """Render a packaged Django template with the given context.""" + return Template(_resource(path)).render(Context(context)) + + +class FeedbackAside(XBlockAside): + """Adds a small 'Send feedback' trigger to applicable blocks in the LMS.""" + + @XBlockAside.aside_for(STUDENT_VIEW) + def student_view_aside(self, block, context=None): # noqa: ARG002 + """Render the feedback trigger for authenticated learners only.""" + fragment = Fragment("") + + # Never render in Studio author/preview or for anonymous/preview users. + if getattr(self.runtime, "is_author_mode", False) or not getattr( + self.runtime, "user_id", None + ): + return fragment + + block_usage_key = block.usage_key + block_id = block_usage_key.block_id + block_type = getattr(block, "category", None) + + fragment.add_content( + _render( + "static/html/student_view.html", + { + "block_id": block_id, + "label": gettext("Send feedback"), + }, + ) + ) + fragment.add_css(_resource("static/css/feedback.css")) + fragment.add_javascript(_resource("static/js/feedback.js")) + fragment.initialize_js( + "FeedbackAsideInit", + json_args={ + "block_id": block_id, + "learning_mfe_base_url": getattr( + settings, "LEARNING_MICROFRONTEND_URL", "" + ), + "drawer_payload": { + "courseId": str(block_usage_key.course_key), + "blockUsageKey": str(block_usage_key), + "blockType": block_type, + "blockDisplayName": block.display_name or "", + }, + }, + ) + return fragment + + @classmethod + def should_apply_to_block(cls, block): + """Apply to leaf blocks when the course waffle flag is enabled.""" + # During course import the block lacks a resolvable course context, so + # skip the waffle-flag check (which reads the course context key) and + # gate on block type only. + if isinstance(block.runtime, XMLImportingModuleStoreRuntime): + return is_aside_applicable_to_block(block) + return get_feedback_enabled_flag().is_enabled( + block.scope_ids.usage_id.context_key + ) and is_aside_applicable_to_block(block) diff --git a/src/ol_openedx_feedback/ol_openedx_feedback/compat.py b/src/ol_openedx_feedback/ol_openedx_feedback/compat.py new file mode 100644 index 00000000..214b42fe --- /dev/null +++ b/src/ol_openedx_feedback/ol_openedx_feedback/compat.py @@ -0,0 +1,20 @@ +"""Compatibility layer isolating core-platform imports.""" + +WAFFLE_FLAG_NAMESPACE = "ol_openedx_feedback" + +# .. toggle_name: ol_openedx_feedback.feedback_enabled +# .. toggle_implementation: CourseWaffleFlag +# .. toggle_default: False +# .. toggle_description: Enables the per-block feedback trigger for a course. +# .. toggle_use_cases: open_edx +# .. toggle_creation_date: 2026-06-09 +OL_OPENEDX_FEEDBACK_ENABLED = "feedback_enabled" + + +def get_feedback_enabled_flag(): + """Return the CourseWaffleFlag controlling feedback rollout.""" + from openedx.core.djangoapps.waffle_utils import CourseWaffleFlag # noqa: PLC0415 + + return CourseWaffleFlag( + f"{WAFFLE_FLAG_NAMESPACE}.{OL_OPENEDX_FEEDBACK_ENABLED}", __name__ + ) diff --git a/src/ol_openedx_feedback/ol_openedx_feedback/constants.py b/src/ol_openedx_feedback/ol_openedx_feedback/constants.py new file mode 100644 index 00000000..a4258ce6 --- /dev/null +++ b/src/ol_openedx_feedback/ol_openedx_feedback/constants.py @@ -0,0 +1,7 @@ +"""Constants for ol_openedx_feedback.""" + +# Default structural/container blocks that never get a feedback trigger. +# Deployments can override this via the +# ``OL_OPENEDX_FEEDBACK_EXCLUDED_BLOCK_TYPES`` Django setting (e.g. to also +# exclude a content type like ``html``). +DEFAULT_EXCLUDED_BLOCK_TYPES = {"course", "chapter", "sequential", "vertical"} diff --git a/src/ol_openedx_feedback/ol_openedx_feedback/settings/common.py b/src/ol_openedx_feedback/ol_openedx_feedback/settings/common.py new file mode 100644 index 00000000..93ca697a --- /dev/null +++ b/src/ol_openedx_feedback/ol_openedx_feedback/settings/common.py @@ -0,0 +1,18 @@ +# noqa: INP001 + +"""Settings to provide to edX""" + +from ol_openedx_feedback.constants import DEFAULT_EXCLUDED_BLOCK_TYPES + + +def plugin_settings(settings): + """ + Populate common settings + """ + env_tokens = getattr(settings, "ENV_TOKENS", {}) + settings.OL_OPENEDX_FEEDBACK_EXCLUDED_BLOCK_TYPES = set( + env_tokens.get( + "OL_OPENEDX_FEEDBACK_EXCLUDED_BLOCK_TYPES", + DEFAULT_EXCLUDED_BLOCK_TYPES, + ) + ) diff --git a/src/ol_openedx_feedback/ol_openedx_feedback/static/css/feedback.css b/src/ol_openedx_feedback/ol_openedx_feedback/static/css/feedback.css new file mode 100644 index 00000000..488857b3 --- /dev/null +++ b/src/ol_openedx_feedback/ol_openedx_feedback/static/css/feedback.css @@ -0,0 +1,88 @@ +.ol-feedback-container { + display: flex; + justify-content: flex-end; + margin: 8px 0 4px; +} + +.ol-feedback-container--relocated { + margin: 0; +} + +.ol-feedback-anchor { + --ol-fb-brand: #8a1e2d; + position: relative; + display: inline-flex; + align-self: center; +} + +.ol-feedback-anchor--docked { + /* Docked next to AskTIM: only positioning here (a gap before the AskTIM + button). The button appearance lives on .ol-feedback-trigger so the + megaphone always looks like a button, docked or standalone. */ + margin-right: 10px; + z-index: 1; +} + +/* Problem blocks draw a horizontal "saved/submit" notification line behind the + button row. Project an opaque white block across the gap to the AskTIM button + so the line is masked there too (invisible on the white problem-block + background). Not applied to video/html blocks, which have no such line. + Offset must match the docked gap (margin-right above) so the mask fills the + gap but stops at the AskTIM button's edge instead of overlapping its border. */ +.ol-feedback-anchor--line-masked { + box-shadow: 10px 0 0 0 #fff; +} + +.ol-feedback-trigger { + /* Always styled as a button (border + background), whether standalone or + docked next to the AskTIM button. */ + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + padding: 0; + border: 1px solid #b8c2cc; + border-radius: 6px; + background: #fff; + color: #e0a106; + cursor: pointer; + transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease; +} + +.ol-feedback-trigger:hover { + background: #fbf3dd; + color: #c98a00; +} + +/* On click/focus the background fills the whole button and the border turns + red. */ +.ol-feedback-trigger:active, +.ol-feedback-trigger:focus { + background: #e6e6e6; + border-color: var(--ol-fb-brand); + color: #c98a00; + outline: none; +} + +.ol-feedback-trigger .ol-fb-ic { + width: 18px; + height: 18px; + display: block; +} + +/* On video blocks the AskTIM chat container (.video-block-chat-button-container) + paints an opaque whitesmoke background plus an upward box-shadow inside its own + stacking context, which rides up over the platform's preceding "Staff Debug + Info" link (.wrap-instructor-info) and hides it for staff. Fix it here in the + feedback plugin so it holds regardless of the installed chat-plugin version. + The staff-info row is the adjacent sibling of the block's wrapper + (data-block-type="video"), so scope to video only: give the row its own + stacking context above the paint, and match the chat container's whitesmoke + background so it blends into the section instead of showing a white patch. + Problem/HTML rows are untouched. */ +[data-block-type="video"] + .wrap-instructor-info { + position: relative; + z-index: 2; + background: whitesmoke; +} diff --git a/src/ol_openedx_feedback/ol_openedx_feedback/static/html/student_view.html b/src/ol_openedx_feedback/ol_openedx_feedback/static/html/student_view.html new file mode 100644 index 00000000..6bba61c9 --- /dev/null +++ b/src/ol_openedx_feedback/ol_openedx_feedback/static/html/student_view.html @@ -0,0 +1,8 @@ +
+ + + +
diff --git a/src/ol_openedx_feedback/ol_openedx_feedback/static/js/feedback.js b/src/ol_openedx_feedback/ol_openedx_feedback/static/js/feedback.js new file mode 100644 index 00000000..d8c08983 --- /dev/null +++ b/src/ol_openedx_feedback/ol_openedx_feedback/static/js/feedback.js @@ -0,0 +1,77 @@ +(function ($) { + function initFeedback(initArgs) { + var blockId = initArgs.block_id; + var mfeBaseUrl = initArgs.learning_mfe_base_url; + var payload = initArgs.drawer_payload || {}; + + var $trigger = $("#ol-feedback-trigger-" + blockId); + if (!$trigger.length) { + return; + } + var $anchor = $trigger.closest(".ol-feedback-anchor"); + + // Namespace by block id and clear any prior binding so a re-init rebinds + // idempotently instead of stacking duplicate click handlers. + $trigger + .off("click.olFeedback-" + blockId) + .on("click.olFeedback-" + blockId, function (event) { + event.stopPropagation(); + // Post only to the known MFE origin; never "*" (would leak block context). + if (!mfeBaseUrl) { + return; + } + window.parent.postMessage( + { type: "ol-feedback::drawer-open", payload: payload }, + mfeBaseUrl + ); + }); + + // Placement: left of the AskTIM trigger when present, else right-aligned. + var $chatBtn = $("#chat-button-" + blockId); + if ($chatBtn.length) { + $anchor.closest(".ol-feedback-container").addClass("ol-feedback-container--relocated"); + $anchor.addClass("ol-feedback-anchor--docked"); + // Problem blocks render a horizontal "saved/submit" notification line that + // runs behind the button row; flag them so CSS can mask it across the gap. + if (payload.blockType === "problem") { + $anchor.addClass("ol-feedback-anchor--line-masked"); + } + $chatBtn.before($anchor); + + // AskTIM lifts its button with an out-of-flow offset that varies by block + // type, so align the megaphone's center to the button's rendered position. + var alignFrame = null; + var alignToChatButton = function () { + $anchor.css("transform", ""); + var btnRect = $chatBtn[0].getBoundingClientRect(); + var anchorRect = $anchor[0].getBoundingClientRect(); + var delta = + (btnRect.top + btnRect.height / 2) - + (anchorRect.top + anchorRect.height / 2); + if (Math.abs(delta) > 0.5) { + $anchor.css("transform", "translateY(" + delta + "px)"); + } + }; + var scheduleAlign = function () { + if (alignFrame) { + window.cancelAnimationFrame(alignFrame); + } + alignFrame = window.requestAnimationFrame(alignToChatButton); + }; + scheduleAlign(); + // Namespace by block id so re-init rebinds idempotently instead of + // stacking duplicate handlers; rAF collapses resize bursts to one pass. + $(window) + .off("resize.olFeedback-" + blockId) + .on("resize.olFeedback-" + blockId, scheduleAlign); + } + } + + function FeedbackAsideView(runtime, element, blockElement, initArgs) { + initFeedback(initArgs); + } + + window.FeedbackAsideInit = function (runtime, element, blockElement, initArgs) { + return new FeedbackAsideView(runtime, element, blockElement, initArgs); + }; +})($); diff --git a/src/ol_openedx_feedback/ol_openedx_feedback/utils.py b/src/ol_openedx_feedback/ol_openedx_feedback/utils.py new file mode 100644 index 00000000..c27aa319 --- /dev/null +++ b/src/ol_openedx_feedback/ol_openedx_feedback/utils.py @@ -0,0 +1,26 @@ +"""Utility helpers for ol_openedx_feedback.""" + +from django.conf import settings + +from ol_openedx_feedback.constants import DEFAULT_EXCLUDED_BLOCK_TYPES + + +def get_excluded_block_types(): + """Return the block types that never get a feedback trigger. + + Defaults to structural containers; overridable per deployment via the + ``OL_OPENEDX_FEEDBACK_EXCLUDED_BLOCK_TYPES`` setting. + """ + return set( + getattr( + settings, + "OL_OPENEDX_FEEDBACK_EXCLUDED_BLOCK_TYPES", + DEFAULT_EXCLUDED_BLOCK_TYPES, + ) + ) + + +def is_aside_applicable_to_block(block): + """Feedback applies to every block type except excluded containers.""" + block_type = getattr(block, "category", None) + return bool(block_type) and block_type not in get_excluded_block_types() diff --git a/src/ol_openedx_feedback/pyproject.toml b/src/ol_openedx_feedback/pyproject.toml new file mode 100644 index 00000000..3ffd7304 --- /dev/null +++ b/src/ol_openedx_feedback/pyproject.toml @@ -0,0 +1,37 @@ +[project] +name = "ol-openedx-feedback" +version = "0.1.0" +description = "An Open edX plugin to collect per-block learner feedback" +authors = [{name = "MIT Office of Digital Learning"}] +license = "BSD-3-Clause" +readme = "README.rst" +requires-python = ">=3.11" +keywords = ["Python", "edx"] +dependencies = [ + "Django>=4.0", + "XBlock", +] + +[project.entry-points."xblock_asides.v1"] +ol_openedx_feedback = "ol_openedx_feedback.block:FeedbackAside" + +[project.entry-points."lms.djangoapp"] +ol_openedx_feedback = "ol_openedx_feedback.apps:OLOpenedxFeedbackConfig" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["ol_openedx_feedback"] +include = [ + "ol_openedx_feedback/**/*.py", + "ol_openedx_feedback/static/**/*", +] + +[tool.hatch.build.targets.sdist] +include = [ + "ol_openedx_feedback/**/*", + "README.rst", + "pyproject.toml", +] diff --git a/src/ol_openedx_feedback/setup.cfg b/src/ol_openedx_feedback/setup.cfg new file mode 100644 index 00000000..16432cf3 --- /dev/null +++ b/src/ol_openedx_feedback/setup.cfg @@ -0,0 +1,39 @@ +[isort] +include_trailing_comma = True +indent = ' ' +line_length = 120 +multi_line_output = 3 + +[wheel] +universal = 1 + +[tool:pytest] +pep8maxlinelength = 119 +DJANGO_SETTINGS_MODULE = lms.envs.test +addopts = --nomigrations --reuse-db --durations=20 +# Enable default handling for all warnings, including those that are ignored by default; +# but hide rate-limit warnings (because we deliberately don't throttle test user logins) +# and field_data deprecation warnings (because fixing them requires a major low-priority refactoring) +filterwarnings = + default + ignore::xblock.exceptions.FieldDataDeprecationWarning + ignore::pytest.PytestConfigWarning + ignore:No request passed to the backend, unable to rate-limit:UserWarning + ignore:Flags not at the start of the expression:DeprecationWarning + ignore:Using or importing the ABCs from 'collections' instead of from 'collections.abc':DeprecationWarning + ignore:invalid escape sequence:DeprecationWarning + ignore:`formatargspec` is deprecated since Python 3.5:DeprecationWarning + ignore:the imp module is deprecated in favour of importlib:DeprecationWarning + ignore:"is" with a literal:SyntaxWarning + ignore:defusedxml.lxml is no longer supported:DeprecationWarning + ignore: `np.int` is a deprecated alias for the builtin `int`.:DeprecationWarning + ignore: `np.float` is a deprecated alias for the builtin `float`.:DeprecationWarning + ignore: `np.complex` is a deprecated alias for the builtin `complex`.:DeprecationWarning + ignore: 'etree' is deprecated. Use 'xml.etree.ElementTree' instead.:DeprecationWarning + ignore: defusedxml.cElementTree is deprecated, import from defusedxml.ElementTree instead.:DeprecationWarning + + +junit_family = xunit2 +norecursedirs = .* *.egg build conf dist node_modules test_root cms/envs lms/envs +python_classes = +python_files = tests.py test_*.py tests_*.py *_tests.py __init__.py diff --git a/src/ol_openedx_feedback/tests/__init__.py b/src/ol_openedx_feedback/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/ol_openedx_feedback/tests/conftest.py b/src/ol_openedx_feedback/tests/conftest.py new file mode 100644 index 00000000..d1602548 --- /dev/null +++ b/src/ol_openedx_feedback/tests/conftest.py @@ -0,0 +1,27 @@ +"""Pytest config for ol_openedx_feedback.""" + +import logging + + +def pytest_addoption(parser): + """Pytest hook that adds command line options.""" + parser.addoption( + "--disable-logging", + action="store_true", + default=False, + help="Disable all logging during test run", + ) + parser.addoption( + "--error-log-only", + action="store_true", + default=False, + help="Disable all logging output below 'error' level during test run", + ) + + +def pytest_configure(config): + """Pytest hook that runs after command line options have been parsed.""" + if config.getoption("--disable-logging"): + logging.disable(logging.CRITICAL) + elif config.getoption("--error-log-only"): + logging.disable(logging.WARNING) diff --git a/src/ol_openedx_feedback/tests/test_aside.py b/src/ol_openedx_feedback/tests/test_aside.py new file mode 100644 index 00000000..5e9aaf1c --- /dev/null +++ b/src/ol_openedx_feedback/tests/test_aside.py @@ -0,0 +1,90 @@ +"""Tests for the FeedbackAside trigger rendering and gating.""" + +from unittest.mock import Mock, patch + +from ddt import data, ddt, unpack +from ol_openedx_feedback.block import FeedbackAside +from openedx.core.djangolib.testing.utils import skip_unless_lms + +try: + from xmodule.modulestore.xml import ( + XMLImportingModuleStoreRuntime as XMLImportingModuleStoreRuntime, # noqa: PLC0414 + ) +except ImportError: + from xmodule.modulestore.xml import ImportSystem as XMLImportingModuleStoreRuntime + +from tests.utils import OLFeedbackTestCase + + +@ddt +class FeedbackAsideTests(OLFeedbackTestCase): + """Tests for FeedbackAside rendering and gating.""" + + @data( + *[ + [5, False, True], + [None, False, False], + [5, True, False], + ] + ) + @unpack + @skip_unless_lms + def test_student_view_aside(self, user_id, is_author_mode, should_render): + """ + The trigger renders only for authenticated learners and never in + Studio author/preview mode. + """ + self.runtime.user_id = user_id + self.runtime.is_author_mode = is_author_mode + self.video_aside_instance.runtime = self.runtime + + fragment = self.video_aside_instance.student_view_aside(self.video_block) + + assert bool(fragment.content) is should_render + if should_render: + assert ( + f"ol-feedback-trigger-{self.video_block.usage_key.block_id}" + in fragment.content + ) + assert fragment.js_init_fn == "FeedbackAsideInit" + else: + assert fragment.content == "" + assert fragment.js_init_fn is None + + @data( + *[ + ["video", True, False, True], + ["video", False, False, False], + ["video", True, True, True], + ["video", False, True, True], + ["problem", True, False, True], + ["vertical", True, False, False], + ["vertical", False, False, False], + ["vertical", True, True, False], + ] + ) + @unpack + def test_should_apply_to_block( + self, block_category, waffle_flag_enabled, is_import_runtime, should_apply + ): + """ + `should_apply_to_block` is True only for leaf blocks when the course + waffle flag is enabled. During course import the block lacks course + context, so the flag is skipped and only the block type is checked. + """ + block = { + "video": self.video_block, + "problem": self.problem_block, + "vertical": self.vertical, + }[block_category] + + with patch( + "ol_openedx_feedback.block.get_feedback_enabled_flag" + ) as mock_get_feedback_enabled_flag: + mock_get_feedback_enabled_flag.return_value = Mock( + is_enabled=Mock(return_value=waffle_flag_enabled) + ) + if is_import_runtime: + block.runtime = Mock(spec=XMLImportingModuleStoreRuntime) + + assert FeedbackAside.should_apply_to_block(block) is should_apply diff --git a/src/ol_openedx_feedback/tests/test_utils.py b/src/ol_openedx_feedback/tests/test_utils.py new file mode 100644 index 00000000..e57ef356 --- /dev/null +++ b/src/ol_openedx_feedback/tests/test_utils.py @@ -0,0 +1,44 @@ +"""Tests for block applicability helper.""" + +from types import SimpleNamespace + +import pytest +from django.test import override_settings +from ol_openedx_feedback.utils import is_aside_applicable_to_block + + +def _block(category): + return SimpleNamespace(category=category) + + +@pytest.mark.parametrize( + ("category", "expected"), + [ + ("video", True), + ("problem", True), + ("html", True), + ("vertical", False), + ("sequential", False), + ("chapter", False), + ("course", False), + (None, False), + ], +) +def test_is_aside_applicable_to_block(category, expected): + """Applies to leaf blocks; excludes structural containers and missing types.""" + assert is_aside_applicable_to_block(_block(category)) is expected + + +@override_settings( + OL_OPENEDX_FEEDBACK_EXCLUDED_BLOCK_TYPES={ + "course", + "chapter", + "sequential", + "vertical", + "html", + } +) +def test_excluded_block_types_setting_override(): + """A deployment can exclude an extra block type via the setting.""" + assert is_aside_applicable_to_block(_block("html")) is False + assert is_aside_applicable_to_block(_block("video")) is True diff --git a/src/ol_openedx_feedback/tests/utils.py b/src/ol_openedx_feedback/tests/utils.py new file mode 100644 index 00000000..533e0744 --- /dev/null +++ b/src/ol_openedx_feedback/tests/utils.py @@ -0,0 +1,82 @@ +from datetime import datetime + +from pytz import UTC +from xblock.runtime import DictKeyValueStore, KvsFieldData +from xblock.test.tools import TestRuntime +from xmodule.modulestore import ModuleStoreEnum +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from xmodule.modulestore.tests.factories import BlockFactory, CourseFactory + + +class OLFeedbackTestCase(ModuleStoreTestCase): + """Base test case that builds a course tree with leaf and container blocks.""" + + def setUp(self): + super().setUp() + + key_store = DictKeyValueStore() + field_data = KvsFieldData(key_store) + self.runtime = TestRuntime(services={"field-data": field_data}) + + course = CourseFactory.create(default_store=ModuleStoreEnum.Type.split) + self.course = BlockFactory.create( + parent_location=course.location, + category="course", + display_name="Test course", + ) + self.chapter = BlockFactory.create( + parent_location=self.course.location, + category="chapter", + display_name="Week 1", + publish_item=True, + start=datetime(2015, 3, 1, tzinfo=UTC), + ) + self.sequential = BlockFactory.create( + parent_location=self.chapter.location, + category="sequential", + display_name="Lesson 1", + publish_item=True, + start=datetime(2015, 3, 1, tzinfo=UTC), + ) + self.vertical = BlockFactory.create( + parent_location=self.sequential.location, + category="vertical", + display_name="Subsection 1", + publish_item=True, + start=datetime(2015, 4, 1, tzinfo=UTC), + ) + self.problem_block = BlockFactory.create( + category="problem", + parent_location=self.vertical.location, + display_name="A Problem Block", + weight=1, + user_id=self.user.id, + publish_item=False, + ) + self.video_block = BlockFactory.create( + parent_location=self.vertical.location, + category="video", + display_name="My Video", + user_id=self.user.id, + ) + self.html_block = BlockFactory.create( + category="html", + parent_location=self.vertical.location, + display_name="An HTML Block", + user_id=self.user.id, + ) + + self.aside_name = "ol_openedx_feedback" + self.video_aside_instance = self.create_aside("video") + self.problem_aside_instance = self.create_aside("problem") + + def create_aside(self, block_type): + """ + Create an aside instance. + """ + def_id = self.runtime.id_generator.create_definition(block_type) + usage_id = self.runtime.id_generator.create_usage(def_id) + _, aside_id = self.runtime.id_generator.create_aside( + def_id, usage_id, self.aside_name + ) + return self.runtime.get_aside(aside_id)