diff --git a/cms/static/js/views/metadata.js b/cms/static/js/views/metadata.js index 6bf30b3781..0322f34788 100644 --- a/cms/static/js/views/metadata.js +++ b/cms/static/js/views/metadata.js @@ -299,6 +299,27 @@ function(Backbone, BaseView, _, MetadataModel, AbstractEditor, FileUpload, Uploa } }); + Metadata.PublicAccess = Metadata.Option.extend({ + + templateName: 'metadata-option-public-access', + + initialize: function() { + Metadata.Option.prototype.initialize.apply(this, arguments); + this.listenTo(this.model, 'change', this.updateUrlFieldVisibility); + this.updateUrlFieldVisibility(); + }, + + updateUrlFieldVisibility: function() { + const urlContainer = this.$el.find('.public-access-block-url-container'); + + if(this.getValueFromEditor()) { + urlContainer.removeClass('is-hidden'); + } else { + urlContainer.addClass('is-hidden'); + } + } + }); + Metadata.List = AbstractEditor.extend({ events: { diff --git a/cms/templates/js/metadata-option-public-access.underscore b/cms/templates/js/metadata-option-public-access.underscore new file mode 100644 index 0000000000..966dff317d --- /dev/null +++ b/cms/templates/js/metadata-option-public-access.underscore @@ -0,0 +1,21 @@ +
+ + + +
+<%- model.get('help') %> +
+ diff --git a/cms/templates/widgets/metadata-edit.html b/cms/templates/widgets/metadata-edit.html index 150f537249..f636ec4c2f 100644 --- a/cms/templates/widgets/metadata-edit.html +++ b/cms/templates/widgets/metadata-edit.html @@ -16,7 +16,7 @@ <%static:include path="js/${template_name}.underscore" /> % endfor -% for template_name in ["metadata-number-entry", "metadata-string-entry", "metadata-option-entry", "metadata-list-entry", "metadata-dict-entry", "metadata-file-uploader-entry", "metadata-file-uploader-item"]: +% for template_name in ["metadata-number-entry", "metadata-string-entry", "metadata-option-entry", "metadata-option-public-access", "metadata-list-entry", "metadata-dict-entry", "metadata-file-uploader-entry", "metadata-file-uploader-item"]: diff --git a/cms/templates/widgets/tabs/metadata-edit-tab.html b/cms/templates/widgets/tabs/metadata-edit-tab.html index 1817397764..96a28dad0a 100644 --- a/cms/templates/widgets/tabs/metadata-edit-tab.html +++ b/cms/templates/widgets/tabs/metadata-edit-tab.html @@ -8,7 +8,7 @@ <%static:include path="js/metadata-editor.underscore" /> -% for template_name in ["metadata-number-entry", "metadata-string-entry", "metadata-option-entry", "metadata-list-entry", "metadata-dict-entry", "metadata-file-uploader-entry", "metadata-file-uploader-item"]: +% for template_name in ["metadata-number-entry", "metadata-string-entry", "metadata-option-entry", "metadata-option-public-access", "metadata-list-entry", "metadata-dict-entry", "metadata-file-uploader-entry", "metadata-file-uploader-item"]: diff --git a/common/lib/xmodule/xmodule/video_module/video_module.py b/common/lib/xmodule/xmodule/video_module/video_module.py index ae4fae9960..be5a8b88f1 100644 --- a/common/lib/xmodule/xmodule/video_module/video_module.py +++ b/common/lib/xmodule/xmodule/video_module/video_module.py @@ -589,6 +589,13 @@ class VideoBlock( # Backbonjs view can handle it. editable_fields['edx_video_id']['type'] = 'VideoID' + # `public_access` is a boolean field and by default backbonejs code render it as a dropdown with 2 options + # but in our case we also need to show an input field with dropdown, the input field will show the url to + # be shared with leaners. This is not possible with default rendering logic in backbonjs code, that is why + # we are setting a new type and then do a custom rendering in backbonejs code to render the desired UI. + editable_fields['public_access']['type'] = 'PublicAccess' + editable_fields['public_access']['url'] = fr'{settings.LMS_ROOT_URL}/videos/{str(self.location)}' + # construct transcripts info and also find if `en` subs exist transcripts_info = self.get_transcripts_info() possible_sub_ids = [self.sub, self.youtube_id_1_0] + get_html5_ids(self.html5_sources) diff --git a/common/lib/xmodule/xmodule/video_module/video_xfields.py b/common/lib/xmodule/xmodule/video_module/video_xfields.py index d97fe0b3bf..06eb200062 100644 --- a/common/lib/xmodule/xmodule/video_module/video_xfields.py +++ b/common/lib/xmodule/xmodule/video_module/video_xfields.py @@ -206,3 +206,9 @@ class VideoFields: scope=Scope.preferences, default=False, ) + public_access = Boolean( + help=_("Specify whether the video can be accessed publicly by learners."), + display_name=_("Public Access"), + scope=Scope.settings, + default=False + ) diff --git a/lms/djangoapps/courseware/tests/test_views.py b/lms/djangoapps/courseware/tests/test_views.py index 1265ed19f1..65c2539277 100644 --- a/lms/djangoapps/courseware/tests/test_views.py +++ b/lms/djangoapps/courseware/tests/test_views.py @@ -3282,6 +3282,79 @@ class TestRenderXBlock(RenderXBlockTestMixin, ModuleStoreTestCase, CompletionWaf self.assertNotContains(response, banner_text, html=True) +class TestRenderPublicVideoXBlock(ModuleStoreTestCase): + """ + Tests for the courseware.render_public_video_xblock endpoint. + """ + def setup_course(self): + """ + Helper method to create the course. + """ + with self.store.default_store(self.store.default_modulestore.get_modulestore_type()): + course = CourseFactory.create(**{'start': datetime.now() - timedelta(days=1)}) + chapter = ItemFactory.create(parent=course, category='chapter') + vertical_block = ItemFactory.create( + parent_location=chapter.location, + category='vertical', + display_name="Vertical" + ) + self.html_block = ItemFactory.create( # pylint: disable=attribute-defined-outside-init + parent=vertical_block, + category='html', + data="

Test HTML Content

" + ) + self.video_block_public = ItemFactory.create( # pylint: disable=attribute-defined-outside-init + parent=vertical_block, + category='video', + display_name='Video with public access', + metadata={'public_access': True} + ) + self.video_block_not_public = ItemFactory.create( # pylint: disable=attribute-defined-outside-init + parent=vertical_block, + category='video', + display_name='Video with private access' + ) + CourseOverview.load_from_module_store(course.id) + + def get_response(self, usage_key): + """ + Overridable method to get the response from the endpoint that is being tested. + """ + url = reverse('render_public_video_xblock', kwargs={'usage_key_string': str(usage_key)}) + return self.client.get(url) + + def test_render_xblock_with_invalid_usage_key(self): + """ + Verify that endpoint returns expected response with invalid usage key + """ + response = self.get_response(usage_key='some_invalid_usage_key') + self.assertContains(response, 'Page not found', status_code=404) + + def test_render_xblock_with_non_video_usage_key(self): + """ + Verify that endpoint returns expected response if usage key block type is not `video` + """ + self.setup_course() + response = self.get_response(usage_key=self.html_block.location) + self.assertContains(response, 'Page not found', status_code=404) + + def test_render_xblock_with_video_usage_key_with_public_access(self): + """ + Verify that endpoint returns expected response if usage key block type is `video` and video has public access + """ + self.setup_course() + response = self.get_response(usage_key=self.video_block_public.location) + self.assertContains(response, 'Play video', status_code=200) + + def test_render_xblock_with_video_usage_key_with_non_public_access(self): + """ + Verify that endpoint returns expected response if usage key block type is `video` and video has private access + """ + self.setup_course() + response = self.get_response(usage_key=self.video_block_not_public.location) + self.assertContains(response, 'Page not found', status_code=404) + + class TestRenderXBlockSelfPaced(TestRenderXBlock): # lint-amnesty, pylint: disable=test-inherits-tests """ Test rendering XBlocks for a self-paced course. Relies on the query diff --git a/lms/djangoapps/courseware/testutils.py b/lms/djangoapps/courseware/testutils.py index 864e18200c..4570de9199 100644 --- a/lms/djangoapps/courseware/testutils.py +++ b/lms/djangoapps/courseware/testutils.py @@ -116,6 +116,11 @@ class RenderXBlockTestMixin(MasqueradeMixin, metaclass=ABCMeta): category='problem', display_name='Problem' ) + self.video_block = ItemFactory.create( + parent=self.vertical_block, + category='video', + display_name='Video' + ) CourseOverview.load_from_module_store(self.course.id) # block_name_to_be_tested can be `html_block` or `vertical_block`. diff --git a/lms/djangoapps/courseware/views/views.py b/lms/djangoapps/courseware/views/views.py index 0db5e27957..2091a485c3 100644 --- a/lms/djangoapps/courseware/views/views.py +++ b/lms/djangoapps/courseware/views/views.py @@ -113,6 +113,7 @@ from openedx.core.djangoapps.credit.api import ( is_credit_course, is_user_eligible_for_credit ) +from openedx.core.lib.courses import get_course_by_id from openedx.core.djangoapps.enrollments.api import add_enrollment from openedx.core.djangoapps.enrollments.permissions import ENROLL_IN_COURSE from openedx.core.djangoapps.models.course_details import CourseDetails @@ -1824,6 +1825,58 @@ def render_xblock(request, usage_key_string, check_if_enrolled=True): return render_to_response('courseware/courseware-chromeless.html', context) +@require_http_methods(["GET"]) +@ensure_valid_usage_key +@xframe_options_exempt +@transaction.non_atomic_requests +def render_public_video_xblock(request, usage_key_string): + """ + Returns an HttpResponse with HTML content for the Video xBlock with the given usage_key. + The returned HTML is a chromeless rendering of the Video xBlock (excluding content of the containing courseware). + """ + view = 'public_view' + + usage_key = UsageKey.from_string(usage_key_string) + usage_key = usage_key.replace(course_key=modulestore().fill_in_run(usage_key.course_key)) + course_key = usage_key.course_key + + # usage key block type must be `video` else raise 404 + if usage_key.block_type != 'video': + raise Http404("Video not found.") + + with modulestore().bulk_operations(course_key): + course = get_course_by_id(course_key, 0) + + block, _ = get_module_by_usage_id( + request, + str(course_key), + str(usage_key), + disable_staff_debug_info=True, + course=course, + will_recheck_access=False + ) + + # video must be public (`Public Access` field set to True) by course author in studio in video advanced settings + if not block.public_access: + raise Http404("Video not found.") + + fragment = block.render(view, context={}) + + context = { + 'fragment': fragment, + 'course': course, + 'disable_accordion': False, + 'allow_iframing': True, + 'disable_header': False, + 'disable_footer': False, + 'disable_window_wrap': True, + 'edx_notes_enabled': False, + 'is_learning_mfe': True, + 'is_mobile_app': False, + } + return render_to_response('courseware/courseware-chromeless.html', context) + + def get_optimization_flags_for_content(block, fragment): """ Return a dict with a set of display options appropriate for the block. diff --git a/lms/urls.py b/lms/urls.py index c1362779df..11e6913975 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -56,6 +56,7 @@ from common.djangoapps.util import views as util_views RESET_COURSE_DEADLINES_NAME = 'reset_course_deadlines' RENDER_XBLOCK_NAME = 'render_xblock' +RENDER_VIDEO_XBLOCK_NAME = 'render_public_video_xblock' COURSE_DATES_NAME = 'dates' COURSE_PROGRESS_NAME = 'progress' @@ -318,6 +319,11 @@ urlpatterns += [ courseware_views.render_xblock, name=RENDER_XBLOCK_NAME, ), + re_path( + fr'^videos/{settings.USAGE_KEY_PATTERN}$', + courseware_views.render_public_video_xblock, + name=RENDER_VIDEO_XBLOCK_NAME, + ), # xblock Resource URL re_path(