Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 38 additions & 2 deletions cms/djangoapps/contentstore/views/block.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
5 changes: 5 additions & 0 deletions cms/djangoapps/contentstore/views/preview.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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")
Expand Down
93 changes: 93 additions & 0 deletions cms/djangoapps/contentstore/views/tests/test_block.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
2 changes: 2 additions & 0 deletions cms/templates/studio_xblock_wrapper.html
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,7 @@
% endif
</div>
</div>
% if not is_authz_authoring_enabled or authz_can_edit_course_content:
<div class="header-actions">
<ul class="actions-list nav-dd ui-right">
% if can_edit_title and not can_edit:
Expand Down Expand Up @@ -257,6 +258,7 @@
% endif
</ul>
</div>
% endif
</div>
% if not is_root:
<div class="wrapper-xblock-message xblock-validation-messages" data-locator="${xblock.location}"/>
Expand Down
Loading