diff --git a/cms/djangoapps/contentstore/views/block.py b/cms/djangoapps/contentstore/views/block.py index b2c0e39df77c..00d12288dda2 100644 --- a/cms/djangoapps/contentstore/views/block.py +++ b/cms/djangoapps/contentstore/views/block.py @@ -12,7 +12,7 @@ from django.views.decorators.clickjacking import xframe_options_exempt from django.views.decorators.http import require_http_methods from opaque_keys.edx.keys import CourseKey -from openedx_authz.constants.permissions import COURSES_VIEW_COURSE +from openedx_authz.constants.permissions import COURSES_EDIT_COURSE_CONTENT, COURSES_VIEW_COURSE from web_fragments.fragment import Fragment from cms.djangoapps.contentstore.utils import load_services_for_studio @@ -27,6 +27,7 @@ from cms.lib.xblock.authoring_mixin import VISIBILITY_VIEW from common.djangoapps.edxmako.shortcuts import render_to_response, render_to_string from common.djangoapps.student.auth import has_studio_read_access, has_studio_write_access +from common.djangoapps.student.roles import enable_authz_course_authoring from common.djangoapps.util.json_request import JsonResponse, expect_json from openedx.core.djangoapps.authz.constants import LegacyAuthoringPermission from openedx.core.djangoapps.authz.decorators import user_has_course_permission @@ -129,10 +130,37 @@ def xblock_handler(request, usage_key_string=None): return handle_xblock(request, usage_key_string) +def _get_authz_permissions_flags(user, course_key): + """ + Return the two RBAC-authoring flags used to gate the header-actions div + in the XBlock component card template. + + When ``authz.enable_course_authoring`` is off for the course both flags + default to values that preserve existing (pre-RBAC) behaviour: + - ``is_authz_authoring_enabled = False`` → template always shows the div. + - ``authz_can_edit_course_content = False`` → unused while flag is off. + + When the flag is on, ``authz_can_edit_course_content`` reflects whether + the requesting user holds the ``courses.edit_course_content`` permission. + + Returns: + tuple[bool, bool]: (is_authz_authoring_enabled, authz_can_edit_course_content) + """ + if not enable_authz_course_authoring(course_key): + return False, False + can_edit = user_has_course_permission( + user, + COURSES_EDIT_COURSE_CONTENT.identifier, + course_key, + legacy_permission=LegacyAuthoringPermission.WRITE, + ) + return True, can_edit + + @require_http_methods("GET") @login_required @expect_json -def xblock_view_handler(request, usage_key_string, view_name): +def xblock_view_handler(request, usage_key_string, view_name): # pylint: disable=too-many-statements """ The restful handler for requests for rendered xblock views. @@ -200,6 +228,12 @@ def xblock_view_handler(request, usage_key_string, view_name): ) # Only the "Pages" view uses student view in Studio can_edit = has_studio_write_access(request.user, usage_key.course_key) + # Gate the header-actions div on courses.edit_course_content when + # the authz flag is on. See _get_authz_preview_flags for details. + is_authz_authoring_enabled, authz_can_edit_course_content = ( + _get_authz_permissions_flags(request.user, usage_key.course_key) + ) + # Determine the items to be shown as reorderable. Note that the view # 'reorderable_container_child_preview' is only rendered for xblocks that # are being shown in a reorderable container, so the xblock is automatically @@ -242,6 +276,8 @@ def xblock_view_handler(request, usage_key_string, view_name): "is_pages_view": is_pages_view or view_name == AUTHOR_VIEW, "is_unit_page": is_unit(xblock), "can_edit": can_edit, + "is_authz_authoring_enabled": is_authz_authoring_enabled, + "authz_can_edit_course_content": authz_can_edit_course_content, "root_xblock": xblock if (view_name == "container_preview") else None, diff --git a/cms/djangoapps/contentstore/views/preview.py b/cms/djangoapps/contentstore/views/preview.py index 1fa81b8694dd..82ac21d4295b 100644 --- a/cms/djangoapps/contentstore/views/preview.py +++ b/cms/djangoapps/contentstore/views/preview.py @@ -318,6 +318,9 @@ def _studio_wrap_xblock(xblock, view, frag, context, display_name_only=False): can_edit = context.get('can_edit', True) can_add = context.get('can_add', True) can_move = context.get('can_move', True) + # Set by block.py; default False so callers that don't set it are unaffected. + is_authz_authoring_enabled = context.get('is_authz_authoring_enabled', False) + authz_can_edit_course_content = context.get('authz_can_edit_course_content', True) root_upstream_link = UpstreamLink.try_get_for_block(root_xblock, log_error=False) upstream_link = UpstreamLink.try_get_for_block(xblock, log_error=False) if ( @@ -363,6 +366,8 @@ def _studio_wrap_xblock(xblock, view, frag, context, display_name_only=False): 'is_course': is_course, 'tags_count': tags_count, 'can_edit_title': True, # This is always true even for imported components + 'is_authz_authoring_enabled': is_authz_authoring_enabled, + 'authz_can_edit_course_content': authz_can_edit_course_content, } add_webpack_js_to_fragment(frag, "js/factories/xblock_validation") diff --git a/cms/djangoapps/contentstore/views/tests/test_block.py b/cms/djangoapps/contentstore/views/tests/test_block.py index 62798c7e8701..a7f6ba4f1063 100644 --- a/cms/djangoapps/contentstore/views/tests/test_block.py +++ b/cms/djangoapps/contentstore/views/tests/test_block.py @@ -545,6 +545,99 @@ def assert_xblock_info(xblock, xblock_info): self.assertEqual(xblock_info, response) # noqa: PT009 +class TestXBlockViewHandlerHeaderActionsAuthz(ItemTest): + """ + Regression tests for the ``header-actions`` div gating introduced to + conditionally render the component card action menu based on the RBAC + ``courses.edit_course_content`` permission. + + The gate uses two independent context flags: + - ``is_authz_authoring_enabled``: True when enable_authz_course_authoring + is on for the course. + - ``authz_can_edit_course_content``: True when the user holds + courses.edit_course_content (only evaluated when the flag is on). + + The template condition is: + ``not is_authz_authoring_enabled or authz_can_edit_course_content`` + + So the div is shown when the flag is off (preserving existing behaviour) + or when the flag is on and the user has the permission. + """ + + AUTHZ_FLAG_PATH = ( + "cms.djangoapps.contentstore.views.block.enable_authz_course_authoring" + ) + # Patch user_has_course_permission at the block.py binding so the + # authz_can_edit_course_content value is fully controlled by the test. + AUTHZ_PERMISSION_PATH = ( + "cms.djangoapps.contentstore.views.block.user_has_course_permission" + ) + HEADER_ACTIONS_DIV = 'class="header-actions"' + + def _get_container_preview_html(self): + """ + Return the rendered HTML for a child vertical card inside a parent vertical. + + ``header-actions`` only appears on non-root blocks (``is_root=False``). + We replicate the setup used by ``test_draft_container_preview_html`` in + ``test_container_page.py``: create a parent vertical, add a child + vertical inside it, then request ``reorderable_container_child_preview`` + for that child. A vertical renders cleanly in the test environment + without needing any external services, and its card includes the full + ``header-actions`` section. + """ + parent_usage_key = self._create_vertical() + child_usage_key = self._create_vertical(parent_usage_key=parent_usage_key) + + preview_url = reverse_usage_url( + "xblock_view_handler", + child_usage_key, + {"view_name": "reorderable_container_child_preview"}, + ) + resp = self.client.get(preview_url, HTTP_ACCEPT="application/json") + self.assertEqual(resp.status_code, 200) # noqa: PT009 + return json.loads(resp.content.decode("utf-8"))["html"] + + def test_header_actions_visible_when_flag_off(self): + """ + When enable_authz_course_authoring is off, is_authz_authoring_enabled + is False and the template condition ``not False or *`` is always True, + so the div must be present regardless of any permission value. + Preserves existing behaviour for courses not yet on the authz rollout. + """ + with patch(self.AUTHZ_FLAG_PATH, return_value=False): + html = self._get_container_preview_html() + + self.assertIn(self.HEADER_ACTIONS_DIV, html) # noqa: PT009 + + def test_header_actions_visible_when_flag_on_and_user_allowed(self): + """ + When the flag is on and the user holds courses.edit_course_content, + is_authz_authoring_enabled=True and authz_can_edit_course_content=True, + so the template condition is True and the div must be rendered. + """ + with patch(self.AUTHZ_FLAG_PATH, return_value=True), \ + patch(self.AUTHZ_PERMISSION_PATH, return_value=True): + html = self._get_container_preview_html() + + self.assertIn(self.HEADER_ACTIONS_DIV, html) # noqa: PT009 + + def test_header_actions_hidden_when_flag_on_and_user_denied(self): + """ + When the flag is on and the user does NOT hold courses.edit_course_content, + is_authz_authoring_enabled=True and authz_can_edit_course_content=False, + so the template condition is False and the entire header-actions div + must be absent from the rendered HTML. + This is the core regression test: without the fix the div would always + render even for read-only users when the authz flag is on. + """ + with patch(self.AUTHZ_FLAG_PATH, return_value=True), \ + patch(self.AUTHZ_PERMISSION_PATH, return_value=False): + html = self._get_container_preview_html() + + self.assertNotIn(self.HEADER_ACTIONS_DIV, html) # noqa: PT009 + + @ddt.ddt class DeleteItem(ItemTest): """Tests for '/xblock' DELETE url.""" diff --git a/cms/templates/studio_xblock_wrapper.html b/cms/templates/studio_xblock_wrapper.html index e05bee6017bc..a9b96f92ef6a 100644 --- a/cms/templates/studio_xblock_wrapper.html +++ b/cms/templates/studio_xblock_wrapper.html @@ -163,6 +163,7 @@ % endif + % if not is_authz_authoring_enabled or authz_can_edit_course_content: