Skip to content
Open
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
60 changes: 58 additions & 2 deletions cms/djangoapps/contentstore/views/block.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,11 @@
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_MANAGE_TAGS,
COURSES_VIEW_COURSE,
)
from web_fragments.fragment import Fragment

from cms.djangoapps.contentstore.utils import load_services_for_studio
Expand All @@ -27,6 +31,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 +134,49 @@ 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 RBAC-authoring flags used to gate portions of the XBlock
component card template (the header-actions div and the "Manage Tags"
action).

When ``authz.enable_course_authoring`` is off for the course all flags
default to values that preserve existing (pre-RBAC) behaviour:
- ``is_authz_authoring_enabled = False`` → template always shows the div
and the "Manage Tags" action.
- ``authz_can_edit_course_content = False`` → unused while flag is off.
- ``authz_can_manage_tags = 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.
- ``authz_can_manage_tags`` reflects whether the requesting user holds the
``courses.manage_tags`` permission.

Returns:
tuple[bool, bool, bool]: (is_authz_authoring_enabled,
authz_can_edit_course_content, authz_can_manage_tags)
"""
if not enable_authz_course_authoring(course_key):
return False, False, False
can_edit = user_has_course_permission(
user,
COURSES_EDIT_COURSE_CONTENT.identifier,
course_key,
legacy_permission=LegacyAuthoringPermission.WRITE,
)
can_manage_tags = user_has_course_permission(
user,
COURSES_MANAGE_TAGS.identifier,
course_key,
)
return True, can_edit, can_manage_tags


@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 @@ -205,6 +249,15 @@ 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 and the
# "Manage Tags" action on courses.manage_tags when the authz flag is
# on. See _get_authz_permissions_flags for details.
(
is_authz_authoring_enabled,
authz_can_edit_course_content,
authz_can_manage_tags,
) = _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 @@ -247,6 +300,9 @@ 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,
"authz_can_manage_tags": authz_can_manage_tags,
"root_xblock": xblock
if (view_name == "container_preview")
else None,
Expand Down
7 changes: 7 additions & 0 deletions cms/djangoapps/contentstore/views/preview.py
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,10 @@ 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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

can we include the tag manage validation in this PR?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

@dcoa I could do that, is for validating this item?
Screenshot 2026-09-02 at 2 44 00 PM

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

yes

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

ok you can count on it

authz_can_manage_tags = context.get('authz_can_manage_tags', 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 +367,9 @@ 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,
'authz_can_manage_tags': authz_can_manage_tags,
}

add_webpack_js_to_fragment(frag, "js/factories/xblock_validation")
Expand Down
261 changes: 261 additions & 0 deletions cms/djangoapps/contentstore/views/tests/test_block.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@
from opaque_keys.edx.asides import AsideUsageKeyV2
from opaque_keys.edx.keys import CourseKey, UsageKey
from opaque_keys.edx.locator import BlockUsageLocator, CourseLocator
from openedx_authz.constants.permissions import (
COURSES_EDIT_COURSE_CONTENT,
COURSES_MANAGE_TAGS,
COURSES_VIEW_COURSE,
)
from openedx_authz.constants.roles import COURSE_ADMIN, COURSE_AUDITOR, COURSE_EDITOR, COURSE_STAFF
from openedx_events.content_authoring.data import DuplicatedXBlockData
from openedx_events.content_authoring.signals import XBLOCK_DUPLICATED
Expand Down Expand Up @@ -545,6 +550,262 @@ 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.
#
# NOTE: xblock_view_handler gates the *whole* request on
# ``courses.view_course`` via this same binding before the template is ever
# rendered. A blanket return_value=False would make that view-access gate
# fail and the handler would raise PermissionDenied (403) before reaching
# the header-actions logic. We therefore drive the mock with a
# permission-aware side effect that always grants view access and only
# toggles the ``courses.edit_course_content`` permission.
AUTHZ_PERMISSION_PATH = (
"cms.djangoapps.contentstore.views.block.user_has_course_permission"
)
HEADER_ACTIONS_DIV = 'class="header-actions"'

@staticmethod
def _permission_side_effect(*, can_edit_course_content, can_manage_tags=True):
"""
Build a ``user_has_course_permission`` side effect that always grants
``courses.view_course`` (so the handler returns 200), returns
``can_edit_course_content`` for ``courses.edit_course_content`` and
``can_manage_tags`` for ``courses.manage_tags``.

The permission identifier is passed as the second positional argument
by every call site in ``block.py``.
"""
def _side_effect(_user, permission_identifier, *_args, **_kwargs):
if permission_identifier == COURSES_VIEW_COURSE.identifier:
return True
if permission_identifier == COURSES_EDIT_COURSE_CONTENT.identifier:
return can_edit_course_content
if permission_identifier == COURSES_MANAGE_TAGS.identifier:
return can_manage_tags
return False

return _side_effect

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,
side_effect=self._permission_side_effect(can_edit_course_content=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,
side_effect=self._permission_side_effect(can_edit_course_content=False),
):
html = self._get_container_preview_html()

self.assertNotIn(self.HEADER_ACTIONS_DIV, html) # noqa: PT009


class TestXBlockViewHandlerManageTagsAuthz(ItemTest):
"""
Regression tests for the "Manage Tags" action-menu item gating based on the
RBAC ``courses.manage_tags`` 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_manage_tags``: True when the user holds courses.manage_tags
(only evaluated when the flag is on).

The template condition is:
``use_tagging and (not is_authz_authoring_enabled or authz_can_manage_tags)``

So the "Manage Tags" link is shown when tagging is enabled and either the
authz flag is off (preserving existing behaviour) or the flag is on and the
user holds the permission.

Because the outer ``header-actions`` div is itself gated on
``courses.edit_course_content``, these tests always grant that permission so
the menu renders and only the "Manage Tags" item is toggled.
"""

AUTHZ_FLAG_PATH = (
"cms.djangoapps.contentstore.views.block.enable_authz_course_authoring"
)
AUTHZ_PERMISSION_PATH = (
"cms.djangoapps.contentstore.views.block.user_has_course_permission"
)
MANAGE_TAGS_LINK = 'class="manage-tags-button"'

@staticmethod
def _permission_side_effect(*, can_manage_tags):
"""
Build a ``user_has_course_permission`` side effect that always grants
``courses.view_course`` (200 response) and ``courses.edit_course_content``
(so the header-actions menu renders), and returns ``can_manage_tags`` for
``courses.manage_tags``.
"""
def _side_effect(_user, permission_identifier, *_args, **_kwargs):
if permission_identifier == COURSES_VIEW_COURSE.identifier:
return True
if permission_identifier == COURSES_EDIT_COURSE_CONTENT.identifier:
return True
if permission_identifier == COURSES_MANAGE_TAGS.identifier:
return can_manage_tags
return False

return _side_effect

def _get_container_preview_html(self):
"""
Return the rendered HTML for a leaf ``html`` component card inside a
parent vertical.

The "Manage Tags" action lives inside the ``% if not show_inline:``
block of the card template, where ``show_inline = xblock.has_children
and not xblock_url``. A vertical has children and no studio URL, so it
is rendered inline and its "Manage Tags" item is never emitted. A leaf
``html`` component has no children and does have a studio URL, so
``show_inline`` is False and the action menu (including "Manage Tags")
is rendered. Requesting ``reorderable_container_child_preview`` for the
component yields a non-root card that includes the full action menu.
"""
parent_usage_key = self._create_vertical()
resp = self.create_xblock(
parent_usage_key=parent_usage_key, category="html"
)
self.assertEqual(resp.status_code, 200) # noqa: PT009
child_usage_key = self.response_usage_key(resp)

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_manage_tags_visible_when_flag_off(self):
"""
When enable_authz_course_authoring is off, is_authz_authoring_enabled is
False and the ``not False or *`` clause is always True, so the "Manage
Tags" link must be present (tagging is enabled in the test environment).
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.MANAGE_TAGS_LINK, html) # noqa: PT009

def test_manage_tags_visible_when_flag_on_and_user_allowed(self):
"""
When the flag is on and the user holds courses.manage_tags,
is_authz_authoring_enabled=True and authz_can_manage_tags=True, so the
template condition is True and the "Manage Tags" link must be rendered.
"""
with patch(self.AUTHZ_FLAG_PATH, return_value=True), \
patch(
self.AUTHZ_PERMISSION_PATH,
side_effect=self._permission_side_effect(can_manage_tags=True),
):
html = self._get_container_preview_html()

self.assertIn(self.MANAGE_TAGS_LINK, html) # noqa: PT009

def test_manage_tags_hidden_when_flag_on_and_user_denied(self):
"""
When the flag is on and the user does NOT hold courses.manage_tags,
is_authz_authoring_enabled=True and authz_can_manage_tags=False, so the
template condition is False and the "Manage Tags" link must be absent
from the rendered HTML, even though the surrounding header-actions menu
still renders (the user retains courses.edit_course_content).
This is the core regression test: without the fix the link would always
render for any user who can see the actions menu when the flag is on.
"""
with patch(self.AUTHZ_FLAG_PATH, return_value=True), \
patch(
self.AUTHZ_PERMISSION_PATH,
side_effect=self._permission_side_effect(can_manage_tags=False),
):
html = self._get_container_preview_html()

self.assertNotIn(self.MANAGE_TAGS_LINK, html) # noqa: PT009


@ddt.ddt
class DeleteItem(ItemTest):
"""Tests for '/xblock' DELETE url."""
Expand Down
Loading
Loading