Skip to content

fix: load user preference for notes visibility in courseware API and toggle_notes.html template #33096

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

Merged
merged 3 commits into from
Sep 7, 2023
Merged

fix: load user preference for notes visibility in courseware API and toggle_notes.html template #33096

merged 3 commits into from
Sep 7, 2023

Conversation

ArturGaspar
Copy link
Contributor

@ArturGaspar ArturGaspar commented Aug 25, 2023

Description

  • Add method to bind course overview model to a student, so that user preferences correctly override course defaults, and use this in courseware API to return correct notes visibility, so that the MFE is initialised with the correct visibility state.
  • Add block argument (for user-bound block) to toggle_notes.html template (used by course block iframe) so notes visibility can be loaded from it instead of course default.

Impacts Learner users.

Fixes openedx/wg-build-test-release#227

Supporting information

openedx/wg-build-test-release#227
openedx/frontend-app-learning#1005

Testing instructions

(Parts from openedx/wg-build-test-release#227)

  1. Enable Notes in a Course Advanced settings
  2. Open Course as a Student
  3. Check Notes tab
  4. Open any Unit in a subsection
  5. Select text to note
  6. Save the note
  7. Check the Note tab
  8. Return to the Course Subsection
  9. Check that notes are visible and that the previously noted text is highlighted
  10. Check that the button state corresponds to the visibility of notes (i.e. "Hide Notes" when notes are visible and "Show Notes" when notes are not visible)
  11. Toggle note visibility until they are hidden (or visible, if fix: toggle notes visibility button state correctly frontend-app-learning#1170 is not applied)*
  12. Reload page and check that notes are hidden
  13. Toggle note visibility until they are visible (or hidden, if fix: toggle notes visibility button state correctly frontend-app-learning#1170 is not applied)*
  14. Reload page and check that notes are visible

*Due to the issue documented at openedx/frontend-app-learning#1170, the "Show/Hide Notes" button persists the state that is opposite to the one applied. I.e., if the button is used and notes are hidden, they will be visible when the page is reloaded and vice-versa. Running the MFE with the branch from that pull request should allow observing the correct non-reversed behaviour. This is independent of this issue.

@openedx-webhooks openedx-webhooks added the open-source-contribution PR author is not from Axim or 2U label Aug 25, 2023
@openedx-webhooks
Copy link

openedx-webhooks commented Aug 25, 2023

Thanks for the pull request, @ArturGaspar! Please note that it may take us up to several weeks or months to complete a review and merge your PR.

Feel free to add as much of the following information to the ticket as you can:

  • supporting documentation
  • Open edX discussion forum threads
  • timeline information ("this must be merged by XX date", and why that is)
  • partner information ("this is a course on edx.org")
  • any other information that can help Product understand the context for the PR

All technical communication about the code itself will be done via the GitHub pull request interface. As a reminder, our process documentation is here.

Please let us know once your PR is ready for our review and all tests are green.

@ArturGaspar ArturGaspar marked this pull request as draft August 25, 2023 08:38
@ArturGaspar ArturGaspar changed the title fix: return user preference for notes visibility in courseware API fix: use user preference for notes visibility in courseware API and toggle_notes template Aug 25, 2023
@ArturGaspar ArturGaspar marked this pull request as ready for review August 25, 2023 21:08
@ArturGaspar ArturGaspar changed the title fix: use user preference for notes visibility in courseware API and toggle_notes template fix: use user preference for notes visibility in courseware API and toggle_notes template. Aug 25, 2023
@ArturGaspar ArturGaspar changed the title fix: use user preference for notes visibility in courseware API and toggle_notes template. fix: use user preference for notes visibility in courseware API and toggle_notes.html template Aug 25, 2023
@ArturGaspar ArturGaspar changed the title fix: use user preference for notes visibility in courseware API and toggle_notes.html template fix: use user preference for notes visibility in courseware API and toggle_notes.html template. Aug 28, 2023
@ArturGaspar ArturGaspar changed the title fix: use user preference for notes visibility in courseware API and toggle_notes.html template. fix: use user preference for notes visibility in courseware API and toggle_notes.html template Aug 28, 2023
Copy link
Contributor

@tecoholic tecoholic left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ArturGaspar 👍 Great job on tracking this down on both apps. It was confusing to even test due to the button and the visibility agreeing and disagreeing randomly.

  • I tested this: Followed the testing instructions in the description and verified that the visibility works in the expected way
  • I read through the code
  • I checked for accessibility issues - NA
  • Includes documentation

One small suggestion – it would be nice to have some unit tests for the is_visible_for_user. Not a blocker, as I don't see any control statements in the function.

Copy link
Member

@Agrendalath Agrendalath left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ArturGaspar, I'm missing the explanation of why this is happening - both in the PR description and in the commit message.

This mechanism is currently broken because the course instance provided by the CourseOverview's private attribute is not bound to any user. Therefore, it always returns True for the edxnotes_visibility field (which is the default value).
The code added in your solution binds the student data to the Course XBlock, so it is correct. However, the edxnotes property of the CourseOverview model remains broken, so I'd like to suggest an alternative approach to you. Instead of doing this in the edxnotes/helpers, we could add something like this to openedx/core/djangoapps/content/course_overviews/models.py (preferably below the _original_course property):

    def bind_course_for_student(self, user):
        """
        Bind user-specific field data to the Course XBlock.

        By default, the retrieved course XBlock is "unbound" - it means that any field from the `user_info` scope
        (like `edxnotes_visibility`) returns its default value.
        """
        from lms.djangoapps.courseware.block_render import get_block_for_descriptor
        get_block_for_descriptor(
            user,
            None,
            self._original_course,
            FieldDataCache([self._original_course], self._original_course.id, user),
            self._original_course.id,
        )

Then, we could add the following line here:

self.overview.bind_course_for_student(self.effective_user)

The other changes would not be needed, then. We will also eliminate the additional get_course_with_access call to MongoDB. (Technically, we could even make the bind_course_for_student method return the course instance and assign it here to remove one more call, but this is outside of the scope of this ticket.)
Adding this method to the CourseOverview improves the code reusability. I know it's not an ideal solution, as it requires an additional method call, but the CourseOverview model is used in many places within the edx-platform, so I'd like to avoid making this change implicit.

Also, a note regarding your change to the lms/templates/edxnotes/toggle_notes.html template - this change is redundant. The course XBlock used in this template is instantiated within the render_xblock function. It's not apparent from reading this function's code, but this call initializes the runtime and implicitly binds the field-data to an XBlock it's initializing, thus binding it to the parent XBlocks.

The final note - I was confused while testing this, as the Ctrl key triggers the "Show/Hide Notes" button, so using "Ctrl+R" to reload the page initiated an additional request.

@ArturGaspar
Copy link
Contributor Author

ArturGaspar commented Aug 31, 2023

@Agrendalath I have made the change as you suggested and tested that it works.

However, I believe that the change to the toggle_notes.html template is still necessary:
The get_block_by_usage_id() function gets a block object from _get_block_by_usage_key(), then passes it to get_block_for_descriptor() as the block argument which in turn binds it to a user. It does not bind the course object that it receives as an argument.
The course variable that render_xblock() places in the template context is the original course object which is not bound to a user.

toggle_notes.html is used by both courseware-chromeless.html and courseware.html. I have now changed toggle_notes.html to receive a block argument as well, so the block with the correct edxnotes_visibility attribute can be passed by render_xblock() through courseware-chromeless.html. For courseware.html, the course in template context is already bound to a user, so I have made the same object to be passed as the block argument as well.

@ArturGaspar ArturGaspar changed the title fix: use user preference for notes visibility in courseware API and toggle_notes.html template fix: load user preference for notes visibility in courseware API and toggle_notes.html template Aug 31, 2023
@Agrendalath
Copy link
Member

Agrendalath commented Aug 31, 2023

@ArturGaspar,

However, I believe that the change to the toggle_notes.html template is still necessary:
The get_block_by_usage_id() function gets a block object from _get_block_by_usage_key(), then passes it to get_block_for_descriptor() as the block argument which in turn binds it to a user. It does not bind the course object that it receives as an argument.
The course variable that render_xblock() places in the template context is the original course object which is not bound to a user.

Well, the runtime initialization and data binding is a very tangled process. I explained some of its aspects in this comment. The following debug log should partially explain how the existing course instance is bound in the render_xblock function:

[164] > /edx/app/edxapp/edx-platform/xmodule/x_module.py(592)bind_for_student()
-> if self.scope_ids.user_id is not None and user_id == self.scope_ids.user_id:

(Pdb++) l
587
588             from xmodule.course_block import CourseBlock
589             if isinstance(self, CourseBlock):
590                 import pdb; pdb.set_trace()
591             # Skip rebinding if we're already bound a user, and it's this user.
592  ->         if self.scope_ids.user_id is not None and user_id == self.scope_ids.user_id:
593                 if getattr(self.runtime, 'position', None):
594                     self.position = self.runtime.position   # update the position of the tab
595                 return
596
597             # If we are switching users mid-request, save the data from the old user.

(Pdb++) w
# I removed the previous calls from this stack to improve readability.
[124]   /edx/app/edxapp/edx-platform/lms/djangoapps/courseware/views/views.py(1573)render_xblock()
-> block, _ = get_block_by_usage_id(
[125]   /edx/app/edxapp/edx-platform/lms/djangoapps/courseware/block_render.py(862)get_block_by_usage_id()
-> instance = get_block_for_descriptor(
[126]   /edx/app/edxapp/edx-platform/lms/djangoapps/courseware/block_render.py(470)get_block_for_descriptor()
-> access = has_access(user, 'load', block, course_key)
[127]   /usr/lib/python3.8/contextlib.py(75)inner()
-> return func(*args, **kwds)
[128]   /edx/app/edxapp/edx-platform/lms/djangoapps/courseware/access.py(153)has_access()
-> return _has_access_to_block(user, action, obj, course_key)
[129]   /edx/app/edxapp/edx-platform/lms/djangoapps/courseware/access.py(597)_has_access_to_block()
-> return _dispatch(checkers, action, user, block)
[130]   /edx/app/edxapp/edx-platform/lms/djangoapps/courseware/access.py(683)_dispatch()
-> result = table[action]()
[131]   /edx/app/edxapp/edx-platform/lms/djangoapps/courseware/access.py(568)can_load()
-> group_access_response = _has_group_access(block, user, course_key)
[132]   /edx/app/edxapp/edx-platform/lms/djangoapps/courseware/access.py(458)_has_group_access()
-> merged_access = block.merged_group_access
[133]   /edx/app/edxapp/venvs/edxapp/lib/python3.8/site-packages/lazy/lazy.py(36)__get__()
-> inst.__dict__[name] = value = self.__func(inst)
[134]   /edx/app/edxapp/edx-platform/lms/djangoapps/lms_xblock/mixin.py(121)merged_group_access()
-> parent = self.get_parent()
[135]   /edx/app/edxapp/venvs/edxapp/lib/python3.8/site-packages/xblock/mixins.py(373)get_parent()
-> self._parent_block = self.runtime.get_block(self.parent)
[136]   /edx/app/edxapp/edx-platform/xmodule/x_module.py(1410)get_block()
-> return self.get_block_for_descriptor(block)
[137]   /edx/app/edxapp/edx-platform/lms/djangoapps/courseware/block_render.py(524)inner_get_block()
-> return get_block_for_descriptor(
[138]   /edx/app/edxapp/edx-platform/lms/djangoapps/courseware/block_render.py(470)get_block_for_descriptor()
-> access = has_access(user, 'load', block, course_key)
[139]   /usr/lib/python3.8/contextlib.py(75)inner()
-> return func(*args, **kwds)
[140]   /edx/app/edxapp/edx-platform/lms/djangoapps/courseware/access.py(153)has_access()
-> return _has_access_to_block(user, action, obj, course_key)
[141]   /edx/app/edxapp/edx-platform/lms/djangoapps/courseware/access.py(597)_has_access_to_block()
-> return _dispatch(checkers, action, user, block)
[142]   /edx/app/edxapp/edx-platform/lms/djangoapps/courseware/access.py(683)_dispatch()
-> result = table[action]()
[143]   /edx/app/edxapp/edx-platform/lms/djangoapps/courseware/access.py(568)can_load()
-> group_access_response = _has_group_access(block, user, course_key)
[144]   /edx/app/edxapp/edx-platform/lms/djangoapps/courseware/access.py(458)_has_group_access()
-> merged_access = block.merged_group_access
[145]   /edx/app/edxapp/venvs/edxapp/lib/python3.8/site-packages/lazy/lazy.py(36)__get__()
-> inst.__dict__[name] = value = self.__func(inst)
[146]   /edx/app/edxapp/edx-platform/lms/djangoapps/lms_xblock/mixin.py(121)merged_group_access()
-> parent = self.get_parent()
[147]   /edx/app/edxapp/venvs/edxapp/lib/python3.8/site-packages/xblock/mixins.py(373)get_parent()
-> self._parent_block = self.runtime.get_block(self.parent)
[148]   /edx/app/edxapp/edx-platform/xmodule/x_module.py(1410)get_block()
-> return self.get_block_for_descriptor(block)
[149]   /edx/app/edxapp/edx-platform/lms/djangoapps/courseware/block_render.py(524)inner_get_block()
-> return get_block_for_descriptor(
[150]   /edx/app/edxapp/edx-platform/lms/djangoapps/courseware/block_render.py(470)get_block_for_descriptor()
-> access = has_access(user, 'load', block, course_key)
[151]   /usr/lib/python3.8/contextlib.py(75)inner()
-> return func(*args, **kwds)
[152]   /edx/app/edxapp/edx-platform/lms/djangoapps/courseware/access.py(153)has_access()
-> return _has_access_to_block(user, action, obj, course_key)
[153]   /edx/app/edxapp/edx-platform/lms/djangoapps/courseware/access.py(597)_has_access_to_block()
-> return _dispatch(checkers, action, user, block)
[154]   /edx/app/edxapp/edx-platform/lms/djangoapps/courseware/access.py(683)_dispatch()
-> result = table[action]()
[155]   /edx/app/edxapp/edx-platform/lms/djangoapps/courseware/access.py(568)can_load()
-> group_access_response = _has_group_access(block, user, course_key)
[156]   /edx/app/edxapp/edx-platform/lms/djangoapps/courseware/access.py(458)_has_group_access()
-> merged_access = block.merged_group_access
[157]   /edx/app/edxapp/venvs/edxapp/lib/python3.8/site-packages/lazy/lazy.py(36)__get__()
-> inst.__dict__[name] = value = self.__func(inst)
[158]   /edx/app/edxapp/edx-platform/lms/djangoapps/lms_xblock/mixin.py(121)merged_group_access()
-> parent = self.get_parent()
[159]   /edx/app/edxapp/venvs/edxapp/lib/python3.8/site-packages/xblock/mixins.py(373)get_parent()
-> self._parent_block = self.runtime.get_block(self.parent)
[160]   /edx/app/edxapp/edx-platform/xmodule/x_module.py(1410)get_block()
-> return self.get_block_for_descriptor(block)
[161]   /edx/app/edxapp/edx-platform/lms/djangoapps/courseware/block_render.py(524)inner_get_block()
-> return get_block_for_descriptor(
[162]   /edx/app/edxapp/edx-platform/lms/djangoapps/courseware/block_render.py(455)get_block_for_descriptor()
-> block.bind_for_student(
[163]   /edx/app/edxapp/edx-platform/xmodule/seq_block.py(280)bind_for_student()
-> super().bind_for_student(user_id, wrappers)
[164] > /edx/app/edxapp/edx-platform/xmodule/x_module.py(592)bind_for_student()
-> if self.scope_ids.user_id is not None and user_id == self.scope_ids.user_id:

(Pdb++) self.id
CourseLocator('demo', 'demo', 'demo', None, None)

(Pdb++) id(self)
140456642287936

(Pdb++) c
[124] > /edx/app/edxapp/edx-platform/lms/djangoapps/courseware/views/views.py(1640)render_xblock()
-> 'fragment': fragment,

(Pdb++) l
1652                'missed_deadlines': missed_deadlines,
1653                'missed_gated_content': missed_gated_content,
1654                'has_ended': course.has_ended(),
1655                'web_app_course_url': get_learning_mfe_home_url(course_key=course.id, url_fragment='home'),
1656                'on_courseware_page': True,
1657                'verified_upgrade_link': verified_upgrade_deadline_link(request.user, course=course),
1658                'is_learning_mfe': is_learning_mfe,
1659                'is_mobile_app': is_mobile_app,
1660                'render_course_wide_assets': True,
1661
1662                **optimization_flags,

(Pdb++) id(course)
140456642287936

(Pdb++) c
[135] > /tmp/mako_lms/9db8c4f0cff738fe62d3b9ba5eff080f/edxnotes/toggle_notes.html.py(44)render_body()
-> edxnotes_visibility = course.edxnotes_visibility

(Pdb++) id(course)
140456642287936

Hopefully, we will be able to simplify it further in the future.

@ArturGaspar
Copy link
Contributor Author

@Agrendalath In my testing the call to bind_for_student(), where your first breakpoint is, does not happen in the relevant request.

The request is specifically GET /xblock/block-v1:edX+DemoX+Demo_Course+type@vertical+block@3c4b575924bf4b75a2f3542df5c354fc?exam_access=&recheck_access=1&show_bookmark=0&show_title=0&view=student_view, after selecting "View this course as: Specific Student" / "Username or email: edx", with the edx user that devstack creates by default.

The edxnotes_visbility attribute of course then differs from the one of block:

> /edx/app/edxapp/edx-platform/lms/djangoapps/courseware/views/views.py(1639)render_xblock()
-> 'fragment': fragment,
(Pdb) l
1634            optimization_flags = get_optimization_flags_for_content(block, fragment)
1635 
1636            import pdb; pdb.set_trace()
1637 
1638            context = {
1639 ->             'fragment': fragment,
1640                'course': course,
1641                'block': block,
1642                'disable_accordion': True,
1643                'allow_iframing': True,
1644                'disable_header': True,
(Pdb) course.edxnotes_visibility
True
(Pdb) block.edxnotes_visibility
False

@Agrendalath
Copy link
Member

@ArturGaspar, that's interesting - I cannot reproduce this behavior on my devstack. But as it happens in yours, it's indeed better to keep this approach.

I found one bug while checking this - when I use the "View this course as: Specific Student" option, I get the following traceback:

2023-08-31 12:21:14,128 ERROR 1574 [django.request] [user None] [ip None] log.py:224 - Internal Server Error: /api/courseware/course/course-v1:demo+demo+demo
Traceback (most recent call last):
  File "/edx/app/edxapp/venvs/edxapp/lib/python3.8/site-packages/django/core/handlers/exception.py", line 47, in inner
    response = get_response(request)
  File "/edx/app/edxapp/venvs/edxapp/lib/python3.8/site-packages/django/core/handlers/base.py", line 181, in _get_response
    response = wrapped_callback(request, *callback_args, **callback_kwargs)
  File "/usr/lib/python3.8/contextlib.py", line 75, in inner
    return func(*args, **kwds)
  File "/edx/app/edxapp/venvs/edxapp/lib/python3.8/site-packages/django/views/decorators/csrf.py", line 54, in wrapped_view
    return view_func(*args, **kwargs)
  File "/edx/app/edxapp/venvs/edxapp/lib/python3.8/site-packages/django/views/generic/base.py", line 70, in view
    return self.dispatch(request, *args, **kwargs)
  File "/edx/app/edxapp/venvs/edxapp/lib/python3.8/site-packages/rest_framework/views.py", line 509, in dispatch
    response = self.handle_exception(exc)
  File "/edx/app/edxapp/venvs/edxapp/lib/python3.8/site-packages/rest_framework/views.py", line 469, in handle_exception
    self.raise_uncaught_exception(exc)
  File "/edx/app/edxapp/venvs/edxapp/lib/python3.8/site-packages/rest_framework/views.py", line 480, in raise_uncaught_exception
    raise exc
  File "/edx/app/edxapp/venvs/edxapp/lib/python3.8/site-packages/rest_framework/views.py", line 506, in dispatch
    response = handler(request, *args, **kwargs)
  File "/edx/app/edxapp/venvs/edxapp/lib/python3.8/site-packages/rest_framework/generics.py", line 208, in get
    return self.retrieve(request, *args, **kwargs)
  File "/edx/app/edxapp/venvs/edxapp/lib/python3.8/site-packages/rest_framework/mixins.py", line 54, in retrieve
    instance = self.get_object()
  File "/edx/app/edxapp/edx-platform/openedx/core/djangoapps/courseware_api/views.py", line 524, in get_object
    overview = CoursewareMeta(
  File "/edx/app/edxapp/edx-platform/openedx/core/djangoapps/courseware_api/views.py", line 91, in __init__
    self.overview.bind_course_for_student(self.effective_user)
  File "/edx/app/edxapp/edx-platform/openedx/core/djangoapps/content/course_overviews/models.py", line 856, in bind_course_for_student
    get_block_for_descriptor(
  File "/edx/app/edxapp/edx-platform/lms/djangoapps/courseware/block_render.py", line 409, in get_block_for_descriptor
    student_kvs = MasqueradingKeyValueStore(student_kvs, request.session)
AttributeError: 'NoneType' object has no attribute 'session'

Let's bind the course below this line and make it accept self.request instead of the user. Something like this should work:

    def bind_course_for_student(self, request):
        """
        Bind user-specific field data to the Course XBlock.

        By default, the retrieved course XBlock is "unbound" - it means that any field from the `user_info` scope
        (like `edxnotes_visibility`) returns its default value.
        """
        from lms.djangoapps.courseware.block_render import get_block_for_descriptor
        get_block_for_descriptor(
            request.user,
            request,
            self._original_course,
            FieldDataCache([self._original_course], self._original_course.id, request.user),
            self._original_course.id,
        )

@ArturGaspar
Copy link
Contributor Author

ArturGaspar commented Aug 31, 2023

@Agrendalath That bug should be fixed in the latest push I made.

self.effective_user is not yet set when self.overview.bind_course_for_student(self.effective_user) was called in line 81. However it still worked because __getattr__() was returning self.overview.effective_user. I had moved it to line 91, after self.effective_user is assigned from setup_masquerade(), but that caused the bug you are seeing. Explicitly using self.overview.bind_course_for_student(self.overview.effective_user) works (which is what was effectively happening first, but the linter was complaining, and it might be confusing since it is a different object than the later self.effective_user).

overview.effective_user is itself set by course_detail() after creating the overview object.

@Agrendalath
Copy link
Member

@ArturGaspar, with this approach, the masquerade does not seem to be working correctly in the CoursewareMeta class. Steps to reproduce:

  1. Log in as edx. Disable the notes visibility.
  2. Log in as audit in a separate session. Enable the notes visibility.
  3. Return to the edx session and select "View course as: Specific Student:" audit.
  4. Reload the page. The notes will be highlighted, but you will see a "Show Notes" button at the bottom of the page.

The approach mentioned in my previous comment seems to be resolving it.

Side note: When you click the "Show/Hide Notes" button while masquerading as another user, this PUT request will affect your "original" user. This is confusing, but I don't think there is an easy way to retrieve the edxnotes_visibility value of the original user within the render_xblock view (without adding a course binding request before setting up the masquerade). Therefore, we should at least make these two values match - i.e., use the value of the user we are masquerading as, both in the render_xblock view and the CoursewareMetadata.

Add a method bind_course_for_student to course overview model,
in order to load user preferences before course defaults.
@ArturGaspar
Copy link
Contributor Author

@Agrendalath Done.

Bind the course overview object to a user in courseware API,
in order to use user preferences over course defaults.
Copy link
Member

@Agrendalath Agrendalath left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

  • I tested this: checked that we use the correct value of edxnotes_visibility
  • I read through the code
  • I checked for accessibility issues: n/a
  • Includes documentation: n/a
  • I made sure any change in configuration variables is reflected in the corresponding client's configuration-secure repository: n/a

@Agrendalath
Copy link
Member

@ormsbee, would you like to do a quick sanity check of this approach?

@Agrendalath Agrendalath merged commit 7da9984 into openedx:master Sep 7, 2023
@openedx-webhooks
Copy link

@ArturGaspar 🎉 Your pull request was merged! Please take a moment to answer a two question survey so we can improve your experience in the future.

@edx-pipeline-bot
Copy link
Contributor

2U Release Notice: This PR has been deployed to the edX staging environment in preparation for a release to production.

@edx-pipeline-bot
Copy link
Contributor

2U Release Notice: This PR has been deployed to the edX production environment.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
open-source-contribution PR author is not from Axim or 2U
Projects
Archived in project
Development

Successfully merging this pull request may close these issues.

[TC_LEARNER_39] Notes failed to load by default
5 participants