-
Notifications
You must be signed in to change notification settings - Fork 6
ol_openedx_feedback: Per-block learner feedback in learning MFE #813
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
1a9172c
0259e35
2d8e713
3b57e9c
995441f
4584903
1a086bf
ecb3ad8
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We need to add setup/installation steps.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can you please add a step in installation guide to enable the asides from LMS admin? |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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"}, | ||
| }, | ||
| }, | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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): | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We can't disable feedback for a block from the authoring MFE, and also, we cannot disable it for a block type like text block, etc. I think we should add the ability to disable feedback for a block from authoring and convert the constant to a setting to be able to disable it for a block type.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We intentionally left this out as per the direction on the parent issue. Feedback should be "enabled for all blocks" |
||
| """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) | ||
|
sentry[bot] marked this conversation as resolved.
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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__ | ||
| ) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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"} |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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, | ||
| ) | ||
| ) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| <div class="ol-feedback-container"> | ||
| <span class="ol-feedback-anchor"> | ||
| <button class="ol-feedback-trigger" id="ol-feedback-trigger-{{ block_id }}" type="button" | ||
| aria-haspopup="dialog" aria-label="{{ label }}" title="{{ label }}"> | ||
| <svg class="ol-fb-ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m3 11 18-5v12L3 14v-3z"/><path d="M11.6 16.8a3 3 0 1 1-5.8-1.6"/></svg> | ||
| </button> | ||
| </span> | ||
| </div> |
Uh oh!
There was an error while loading. Please reload this page.