ol-openedx-feedback 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
File without changes
@@ -0,0 +1,27 @@
1
+ """ol_openedx_feedback Django application initialization."""
2
+
3
+ from django.apps import AppConfig
4
+ from edx_django_utils.plugins import PluginSettings
5
+ from openedx.core.djangoapps.plugins.constants import ProjectType, SettingsType
6
+
7
+
8
+ class OLOpenedxFeedbackConfig(AppConfig):
9
+ """Configuration for the ol_openedx_feedback Django application.
10
+
11
+ Trigger-only plugin: the feedback trigger is registered via the
12
+ ``xblock_asides.v1`` entry point (no URLs or models — feedback is persisted
13
+ in mit-learn and the MFE owns the submit URL). LMS-only, since the trigger
14
+ renders in the learner (student) view; the only Django settings are the
15
+ plugin defaults populated by ``settings.common``.
16
+ """
17
+
18
+ name = "ol_openedx_feedback"
19
+ verbose_name = "Open edX Block Feedback"
20
+
21
+ plugin_app = {
22
+ PluginSettings.CONFIG: {
23
+ ProjectType.LMS: {
24
+ SettingsType.COMMON: {PluginSettings.RELATIVE_PATH: "settings.common"},
25
+ },
26
+ },
27
+ }
@@ -0,0 +1,93 @@
1
+ """XBlockAside that renders the per-block feedback trigger."""
2
+
3
+ import logging
4
+
5
+ import pkg_resources
6
+ from django.conf import settings
7
+ from django.template import Context, Template
8
+ from django.utils.translation import gettext
9
+ from web_fragments.fragment import Fragment
10
+ from xblock.core import XBlockAside
11
+
12
+ try:
13
+ from xmodule.modulestore.xml import (
14
+ XMLImportingModuleStoreRuntime as XMLImportingModuleStoreRuntime, # noqa: PLC0414
15
+ )
16
+ except ImportError:
17
+ from xmodule.modulestore.xml import ImportSystem as XMLImportingModuleStoreRuntime
18
+
19
+ from xmodule.x_module import STUDENT_VIEW
20
+
21
+ from ol_openedx_feedback.compat import get_feedback_enabled_flag
22
+ from ol_openedx_feedback.utils import is_aside_applicable_to_block
23
+
24
+ log = logging.getLogger(__name__)
25
+
26
+
27
+ def _resource(path):
28
+ """Return the decoded contents of a packaged resource."""
29
+ return pkg_resources.resource_string(__name__, path).decode("utf-8")
30
+
31
+
32
+ def _render(path, context):
33
+ """Render a packaged Django template with the given context."""
34
+ return Template(_resource(path)).render(Context(context))
35
+
36
+
37
+ class FeedbackAside(XBlockAside):
38
+ """Adds a small 'Send feedback' trigger to applicable blocks in the LMS."""
39
+
40
+ @XBlockAside.aside_for(STUDENT_VIEW)
41
+ def student_view_aside(self, block, context=None): # noqa: ARG002
42
+ """Render the feedback trigger for authenticated learners only."""
43
+ fragment = Fragment("")
44
+
45
+ # Never render in Studio author/preview or for anonymous/preview users.
46
+ if getattr(self.runtime, "is_author_mode", False) or not getattr(
47
+ self.runtime, "user_id", None
48
+ ):
49
+ return fragment
50
+
51
+ block_usage_key = block.usage_key
52
+ block_id = block_usage_key.block_id
53
+ block_type = getattr(block, "category", None)
54
+
55
+ fragment.add_content(
56
+ _render(
57
+ "static/html/student_view.html",
58
+ {
59
+ "block_id": block_id,
60
+ "label": gettext("Send feedback"),
61
+ },
62
+ )
63
+ )
64
+ fragment.add_css(_resource("static/css/feedback.css"))
65
+ fragment.add_javascript(_resource("static/js/feedback.js"))
66
+ fragment.initialize_js(
67
+ "FeedbackAsideInit",
68
+ json_args={
69
+ "block_id": block_id,
70
+ "learning_mfe_base_url": getattr(
71
+ settings, "LEARNING_MICROFRONTEND_URL", ""
72
+ ),
73
+ "drawer_payload": {
74
+ "courseId": str(block_usage_key.course_key),
75
+ "blockUsageKey": str(block_usage_key),
76
+ "blockType": block_type,
77
+ "blockDisplayName": block.display_name or "",
78
+ },
79
+ },
80
+ )
81
+ return fragment
82
+
83
+ @classmethod
84
+ def should_apply_to_block(cls, block):
85
+ """Apply to leaf blocks when the course waffle flag is enabled."""
86
+ # During course import the block lacks a resolvable course context, so
87
+ # skip the waffle-flag check (which reads the course context key) and
88
+ # gate on block type only.
89
+ if isinstance(block.runtime, XMLImportingModuleStoreRuntime):
90
+ return is_aside_applicable_to_block(block)
91
+ return get_feedback_enabled_flag().is_enabled(
92
+ block.scope_ids.usage_id.context_key
93
+ ) and is_aside_applicable_to_block(block)
@@ -0,0 +1,20 @@
1
+ """Compatibility layer isolating core-platform imports."""
2
+
3
+ WAFFLE_FLAG_NAMESPACE = "ol_openedx_feedback"
4
+
5
+ # .. toggle_name: ol_openedx_feedback.feedback_enabled
6
+ # .. toggle_implementation: CourseWaffleFlag
7
+ # .. toggle_default: False
8
+ # .. toggle_description: Enables the per-block feedback trigger for a course.
9
+ # .. toggle_use_cases: open_edx
10
+ # .. toggle_creation_date: 2026-06-09
11
+ OL_OPENEDX_FEEDBACK_ENABLED = "feedback_enabled"
12
+
13
+
14
+ def get_feedback_enabled_flag():
15
+ """Return the CourseWaffleFlag controlling feedback rollout."""
16
+ from openedx.core.djangoapps.waffle_utils import CourseWaffleFlag # noqa: PLC0415
17
+
18
+ return CourseWaffleFlag(
19
+ f"{WAFFLE_FLAG_NAMESPACE}.{OL_OPENEDX_FEEDBACK_ENABLED}", __name__
20
+ )
@@ -0,0 +1,7 @@
1
+ """Constants for ol_openedx_feedback."""
2
+
3
+ # Default structural/container blocks that never get a feedback trigger.
4
+ # Deployments can override this via the
5
+ # ``OL_OPENEDX_FEEDBACK_EXCLUDED_BLOCK_TYPES`` Django setting (e.g. to also
6
+ # exclude a content type like ``html``).
7
+ DEFAULT_EXCLUDED_BLOCK_TYPES = {"course", "chapter", "sequential", "vertical"}
@@ -0,0 +1,18 @@
1
+ # noqa: INP001
2
+
3
+ """Settings to provide to edX"""
4
+
5
+ from ol_openedx_feedback.constants import DEFAULT_EXCLUDED_BLOCK_TYPES
6
+
7
+
8
+ def plugin_settings(settings):
9
+ """
10
+ Populate common settings
11
+ """
12
+ env_tokens = getattr(settings, "ENV_TOKENS", {})
13
+ settings.OL_OPENEDX_FEEDBACK_EXCLUDED_BLOCK_TYPES = set(
14
+ env_tokens.get(
15
+ "OL_OPENEDX_FEEDBACK_EXCLUDED_BLOCK_TYPES",
16
+ DEFAULT_EXCLUDED_BLOCK_TYPES,
17
+ )
18
+ )
@@ -0,0 +1,88 @@
1
+ .ol-feedback-container {
2
+ display: flex;
3
+ justify-content: flex-end;
4
+ margin: 8px 0 4px;
5
+ }
6
+
7
+ .ol-feedback-container--relocated {
8
+ margin: 0;
9
+ }
10
+
11
+ .ol-feedback-anchor {
12
+ --ol-fb-brand: #8a1e2d;
13
+ position: relative;
14
+ display: inline-flex;
15
+ align-self: center;
16
+ }
17
+
18
+ .ol-feedback-anchor--docked {
19
+ /* Docked next to AskTIM: only positioning here (a gap before the AskTIM
20
+ button). The button appearance lives on .ol-feedback-trigger so the
21
+ megaphone always looks like a button, docked or standalone. */
22
+ margin-right: 10px;
23
+ z-index: 1;
24
+ }
25
+
26
+ /* Problem blocks draw a horizontal "saved/submit" notification line behind the
27
+ button row. Project an opaque white block across the gap to the AskTIM button
28
+ so the line is masked there too (invisible on the white problem-block
29
+ background). Not applied to video/html blocks, which have no such line.
30
+ Offset must match the docked gap (margin-right above) so the mask fills the
31
+ gap but stops at the AskTIM button's edge instead of overlapping its border. */
32
+ .ol-feedback-anchor--line-masked {
33
+ box-shadow: 10px 0 0 0 #fff;
34
+ }
35
+
36
+ .ol-feedback-trigger {
37
+ /* Always styled as a button (border + background), whether standalone or
38
+ docked next to the AskTIM button. */
39
+ display: inline-flex;
40
+ align-items: center;
41
+ justify-content: center;
42
+ width: 32px;
43
+ height: 32px;
44
+ padding: 0;
45
+ border: 1px solid #b8c2cc;
46
+ border-radius: 6px;
47
+ background: #fff;
48
+ color: #e0a106;
49
+ cursor: pointer;
50
+ transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease;
51
+ }
52
+
53
+ .ol-feedback-trigger:hover {
54
+ background: #fbf3dd;
55
+ color: #c98a00;
56
+ }
57
+
58
+ /* On click/focus the background fills the whole button and the border turns
59
+ red. */
60
+ .ol-feedback-trigger:active,
61
+ .ol-feedback-trigger:focus {
62
+ background: #e6e6e6;
63
+ border-color: var(--ol-fb-brand);
64
+ color: #c98a00;
65
+ outline: none;
66
+ }
67
+
68
+ .ol-feedback-trigger .ol-fb-ic {
69
+ width: 18px;
70
+ height: 18px;
71
+ display: block;
72
+ }
73
+
74
+ /* On video blocks the AskTIM chat container (.video-block-chat-button-container)
75
+ paints an opaque whitesmoke background plus an upward box-shadow inside its own
76
+ stacking context, which rides up over the platform's preceding "Staff Debug
77
+ Info" link (.wrap-instructor-info) and hides it for staff. Fix it here in the
78
+ feedback plugin so it holds regardless of the installed chat-plugin version.
79
+ The staff-info row is the adjacent sibling of the block's wrapper
80
+ (data-block-type="video"), so scope to video only: give the row its own
81
+ stacking context above the paint, and match the chat container's whitesmoke
82
+ background so it blends into the section instead of showing a white patch.
83
+ Problem/HTML rows are untouched. */
84
+ [data-block-type="video"] + .wrap-instructor-info {
85
+ position: relative;
86
+ z-index: 2;
87
+ background: whitesmoke;
88
+ }
@@ -0,0 +1,8 @@
1
+ <div class="ol-feedback-container">
2
+ <span class="ol-feedback-anchor">
3
+ <button class="ol-feedback-trigger" id="ol-feedback-trigger-{{ block_id }}" type="button"
4
+ aria-haspopup="dialog" aria-label="{{ label }}" title="{{ label }}">
5
+ <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>
6
+ </button>
7
+ </span>
8
+ </div>
@@ -0,0 +1,77 @@
1
+ (function ($) {
2
+ function initFeedback(initArgs) {
3
+ var blockId = initArgs.block_id;
4
+ var mfeBaseUrl = initArgs.learning_mfe_base_url;
5
+ var payload = initArgs.drawer_payload || {};
6
+
7
+ var $trigger = $("#ol-feedback-trigger-" + blockId);
8
+ if (!$trigger.length) {
9
+ return;
10
+ }
11
+ var $anchor = $trigger.closest(".ol-feedback-anchor");
12
+
13
+ // Namespace by block id and clear any prior binding so a re-init rebinds
14
+ // idempotently instead of stacking duplicate click handlers.
15
+ $trigger
16
+ .off("click.olFeedback-" + blockId)
17
+ .on("click.olFeedback-" + blockId, function (event) {
18
+ event.stopPropagation();
19
+ // Post only to the known MFE origin; never "*" (would leak block context).
20
+ if (!mfeBaseUrl) {
21
+ return;
22
+ }
23
+ window.parent.postMessage(
24
+ { type: "ol-feedback::drawer-open", payload: payload },
25
+ mfeBaseUrl
26
+ );
27
+ });
28
+
29
+ // Placement: left of the AskTIM trigger when present, else right-aligned.
30
+ var $chatBtn = $("#chat-button-" + blockId);
31
+ if ($chatBtn.length) {
32
+ $anchor.closest(".ol-feedback-container").addClass("ol-feedback-container--relocated");
33
+ $anchor.addClass("ol-feedback-anchor--docked");
34
+ // Problem blocks render a horizontal "saved/submit" notification line that
35
+ // runs behind the button row; flag them so CSS can mask it across the gap.
36
+ if (payload.blockType === "problem") {
37
+ $anchor.addClass("ol-feedback-anchor--line-masked");
38
+ }
39
+ $chatBtn.before($anchor);
40
+
41
+ // AskTIM lifts its button with an out-of-flow offset that varies by block
42
+ // type, so align the megaphone's center to the button's rendered position.
43
+ var alignFrame = null;
44
+ var alignToChatButton = function () {
45
+ $anchor.css("transform", "");
46
+ var btnRect = $chatBtn[0].getBoundingClientRect();
47
+ var anchorRect = $anchor[0].getBoundingClientRect();
48
+ var delta =
49
+ (btnRect.top + btnRect.height / 2) -
50
+ (anchorRect.top + anchorRect.height / 2);
51
+ if (Math.abs(delta) > 0.5) {
52
+ $anchor.css("transform", "translateY(" + delta + "px)");
53
+ }
54
+ };
55
+ var scheduleAlign = function () {
56
+ if (alignFrame) {
57
+ window.cancelAnimationFrame(alignFrame);
58
+ }
59
+ alignFrame = window.requestAnimationFrame(alignToChatButton);
60
+ };
61
+ scheduleAlign();
62
+ // Namespace by block id so re-init rebinds idempotently instead of
63
+ // stacking duplicate handlers; rAF collapses resize bursts to one pass.
64
+ $(window)
65
+ .off("resize.olFeedback-" + blockId)
66
+ .on("resize.olFeedback-" + blockId, scheduleAlign);
67
+ }
68
+ }
69
+
70
+ function FeedbackAsideView(runtime, element, blockElement, initArgs) {
71
+ initFeedback(initArgs);
72
+ }
73
+
74
+ window.FeedbackAsideInit = function (runtime, element, blockElement, initArgs) {
75
+ return new FeedbackAsideView(runtime, element, blockElement, initArgs);
76
+ };
77
+ })($);
@@ -0,0 +1,26 @@
1
+ """Utility helpers for ol_openedx_feedback."""
2
+
3
+ from django.conf import settings
4
+
5
+ from ol_openedx_feedback.constants import DEFAULT_EXCLUDED_BLOCK_TYPES
6
+
7
+
8
+ def get_excluded_block_types():
9
+ """Return the block types that never get a feedback trigger.
10
+
11
+ Defaults to structural containers; overridable per deployment via the
12
+ ``OL_OPENEDX_FEEDBACK_EXCLUDED_BLOCK_TYPES`` setting.
13
+ """
14
+ return set(
15
+ getattr(
16
+ settings,
17
+ "OL_OPENEDX_FEEDBACK_EXCLUDED_BLOCK_TYPES",
18
+ DEFAULT_EXCLUDED_BLOCK_TYPES,
19
+ )
20
+ )
21
+
22
+
23
+ def is_aside_applicable_to_block(block):
24
+ """Feedback applies to every block type except excluded containers."""
25
+ block_type = getattr(block, "category", None)
26
+ return bool(block_type) and block_type not in get_excluded_block_types()
@@ -0,0 +1,85 @@
1
+ Metadata-Version: 2.4
2
+ Name: ol-openedx-feedback
3
+ Version: 0.1.0
4
+ Summary: An Open edX plugin to collect per-block learner feedback
5
+ Author: MIT Office of Digital Learning
6
+ License-Expression: BSD-3-Clause
7
+ Keywords: Python,edx
8
+ Requires-Python: >=3.11
9
+ Requires-Dist: django>=4.0
10
+ Requires-Dist: xblock
11
+ Description-Content-Type: text/x-rst
12
+
13
+ ol-openedx-feedback
14
+ ###################
15
+
16
+ An Open edX plugin that adds a per-block "Send feedback" trigger to applicable
17
+ leaf blocks in the LMS via an ``XBlockAside``. The trigger is shown only to
18
+ authenticated learners (never in Studio author/preview mode and never to
19
+ anonymous users).
20
+
21
+ When a learner clicks the trigger the aside posts an ``ol-feedback::drawer-open``
22
+ message to its parent window (the Learning MFE) using ``window.parent.postMessage``.
23
+ The message payload carries the block context needed to identify the content
24
+ being rated:
25
+
26
+ - ``courseId`` — the course key
27
+ - ``blockUsageKey`` — the block's usage key
28
+ - ``blockType`` — the XBlock category (e.g. ``problem``, ``video``)
29
+ - ``blockDisplayName`` — the block's display name
30
+
31
+ The Learning MFE receives the message and opens the feedback drawer, which
32
+ submits the learner's feedback directly to the **mit-learn** service. This
33
+ plugin does **not** persist anything in edx-platform and exposes no REST API.
34
+
35
+ Installation
36
+ ============
37
+
38
+ Install the package into the LMS Python environment:
39
+
40
+ .. code-block:: bash
41
+
42
+ pip install ol-openedx-feedback
43
+
44
+ The plugin registers itself automatically through its entry points — the
45
+ ``xblock_asides.v1`` aside plus the ``lms.djangoapp`` app config — so no changes
46
+ to ``INSTALLED_APPS`` are required. Restart the LMS after installing. (The
47
+ trigger is learner-facing only, so the plugin is LMS-only and is not installed
48
+ in Studio/CMS.)
49
+
50
+ Enable XBlock asides in the LMS admin
51
+ -------------------------------------
52
+
53
+ XBlock asides must be turned on for any aside (including this one) to render.
54
+ In the LMS Django admin, open **XBlock Asides Config**
55
+ (``/admin/lms_xblock/xblockasidesconfig/``), add a new entry, and check
56
+ **Enabled**. Make sure the block types you want the feedback trigger on are
57
+ **not** listed in **Disabled blocks** (the space-separated field defaults to
58
+ ``about course_info static_tab``).
59
+
60
+ Enablement
61
+ ==========
62
+
63
+ Feedback is gated by the ``ol_openedx_feedback.feedback_enabled`` course waffle
64
+ flag (default off). Enable it for the desired courses (or globally) to roll out.
65
+
66
+ Configuration
67
+ =============
68
+
69
+ By default the trigger renders on every leaf block and is suppressed only on
70
+ structural containers (``course`` / ``chapter`` / ``sequential`` / ``vertical``).
71
+ To additionally exclude one or more block types (for example ``html``), override
72
+ the excluded set through ``ENV_TOKENS`` (e.g. in ``lms.yml``):
73
+
74
+ .. code-block:: yaml
75
+
76
+ OL_OPENEDX_FEEDBACK_EXCLUDED_BLOCK_TYPES:
77
+ - course
78
+ - chapter
79
+ - sequential
80
+ - vertical
81
+ - html
82
+
83
+ The plugin reads this value via its ``settings.common`` ``plugin_settings`` hook
84
+ and exposes it as the ``OL_OPENEDX_FEEDBACK_EXCLUDED_BLOCK_TYPES`` Django
85
+ setting, defaulting to the structural set above when unset.
@@ -0,0 +1,14 @@
1
+ ol_openedx_feedback/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ ol_openedx_feedback/apps.py,sha256=hdc6w--6HoG8Z63bZS5stpw560xqerM7fH76If9d8k4,992
3
+ ol_openedx_feedback/block.py,sha256=spyBzawijV_JON5SN2jkIuWansJVwXSVF4Ytipa9S1A,3394
4
+ ol_openedx_feedback/compat.py,sha256=pbu4K5pxxuYXx2yHcxWFsMbVi21ZSX7EyladXG3979Y,732
5
+ ol_openedx_feedback/constants.py,sha256=b6KN8iITa4GEYlLsw9Tm5wC_llRDxtg8hId1mRN7GpM,351
6
+ ol_openedx_feedback/utils.py,sha256=91SjqmdVexTjiON9qYaRg4j7yt2xhfAokXY3ZmV-GxU,807
7
+ ol_openedx_feedback/settings/common.py,sha256=wwP8RX6MBtuYOvuy82KQjoC4sN4DYRHfXukkyn50FT8,451
8
+ ol_openedx_feedback/static/css/feedback.css,sha256=HrCrKzoQn1L3cpBxHC0DLYsqlpRtdzdF4qdOuDo5hHs,2804
9
+ ol_openedx_feedback/static/html/student_view.html,sha256=LYzEeVqhnkpdYHREM49_5Ci78rycB3RxJ3y2QAm04ho,524
10
+ ol_openedx_feedback/static/js/feedback.js,sha256=uJpXXEbHujhn8O8kTZtWG7pjD0cVKbAtMoQxxwaKl78,2973
11
+ ol_openedx_feedback-0.1.0.dist-info/METADATA,sha256=sZkFn3yL2JozaSXItg6z-X_3gKfaOmjU-wxTkrxUinM,3135
12
+ ol_openedx_feedback-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
13
+ ol_openedx_feedback-0.1.0.dist-info/entry_points.txt,sha256=xNggMG22qeCYhbvW2JOpCFHswdEXhZ2T-2cHXkSAnbY,169
14
+ ol_openedx_feedback-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,5 @@
1
+ [lms.djangoapp]
2
+ ol_openedx_feedback = ol_openedx_feedback.apps:OLOpenedxFeedbackConfig
3
+
4
+ [xblock_asides.v1]
5
+ ol_openedx_feedback = ol_openedx_feedback.block:FeedbackAside