From 1c114c27d64a6fedb394da327859cb37ec0c0c1c Mon Sep 17 00:00:00 2001 From: carlos cantillo Date: Mon, 29 Dec 2025 15:18:21 -0500 Subject: [PATCH 1/9] feat: remove the posibility for staff users to publish courses --- .../rest_api/v1/serializers/__init__.py | 1 + .../contentstore/rest_api/v1/urls.py | 6 +++ .../rest_api/v1/views/__init__.py | 1 + .../xblock_storage_handlers/view_handlers.py | 41 +++++++++++++++++++ common/djangoapps/student/roles.py | 34 +++++++++++++++ 5 files changed, 83 insertions(+) diff --git a/cms/djangoapps/contentstore/rest_api/v1/serializers/__init__.py b/cms/djangoapps/contentstore/rest_api/v1/serializers/__init__.py index 616204ef59c7..1cc8b9cc9ad6 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/serializers/__init__.py +++ b/cms/djangoapps/contentstore/rest_api/v1/serializers/__init__.py @@ -6,6 +6,7 @@ from .course_index import CourseIndexSerializer from .course_rerun import CourseRerunSerializer from .course_team import CourseTeamSerializer +from .course_user_role import CourseUserRoleSerializer from .course_waffle_flags import CourseWaffleFlagsSerializer from .grading import CourseGradingModelSerializer, CourseGradingSerializer from .group_configurations import CourseGroupConfigurationsSerializer diff --git a/cms/djangoapps/contentstore/rest_api/v1/urls.py b/cms/djangoapps/contentstore/rest_api/v1/urls.py index 349218679709..a3dca3f76361 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/urls.py +++ b/cms/djangoapps/contentstore/rest_api/v1/urls.py @@ -10,6 +10,7 @@ CourseCertificatesView, CourseDetailsView, CourseTeamView, + CourseUserRoleView, CourseTextbooksView, CourseIndexView, CourseGradingView, @@ -92,6 +93,11 @@ CourseTeamView.as_view(), name="course_team" ), + re_path( + fr'^course_user_role/{COURSE_ID_PATTERN}$', + CourseUserRoleView.as_view(), + name="course_user_role" + ), re_path( fr'^course_grading/{COURSE_ID_PATTERN}$', CourseGradingView.as_view(), diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/__init__.py b/cms/djangoapps/contentstore/rest_api/v1/views/__init__.py index 89d8d56eaa11..74f4bd83913b 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/views/__init__.py +++ b/cms/djangoapps/contentstore/rest_api/v1/views/__init__.py @@ -7,6 +7,7 @@ from .course_rerun import CourseRerunView from .course_waffle_flags import CourseWaffleFlagsView from .course_team import CourseTeamView +from .course_user_role import CourseUserRoleView from .grading import CourseGradingView from .group_configurations import CourseGroupConfigurationsView from .help_urls import HelpUrlsView diff --git a/cms/djangoapps/contentstore/xblock_storage_handlers/view_handlers.py b/cms/djangoapps/contentstore/xblock_storage_handlers/view_handlers.py index 31a17466769d..abedd462382a 100644 --- a/cms/djangoapps/contentstore/xblock_storage_handlers/view_handlers.py +++ b/cms/djangoapps/contentstore/xblock_storage_handlers/view_handlers.py @@ -46,6 +46,8 @@ has_studio_read_access, has_studio_write_access, ) +from common.djangoapps.student.models import CourseAccessRole +from common.djangoapps.student.roles import GlobalStaff from common.djangoapps.util.date_utils import get_default_time_display from common.djangoapps.util.json_request import JsonResponse, expect_json from openedx.core.djangoapps.bookmarks import api as bookmarks_api @@ -172,6 +174,45 @@ def handle_xblock(request, usage_key_string=None): ) if not access_check(request.user, usage_key.course_key): raise PermissionDenied() + + # Debug logging to see what's in the request + log.info(f"=== XBLOCK REQUEST DEBUG === method={request.method}, user={request.user.username}, usage_key={usage_key}") + log.info(f"request.json exists: {hasattr(request, 'json')}, request.json value: {getattr(request, 'json', None)}") + + # Check if user is trying to publish and if they have permission + if request.method in ("POST", "PUT", "PATCH"): + try: + publish_action = request.json.get("publish") if hasattr(request, 'json') and request.json else None + log.info(f"Publish action check: method={request.method}, publish={publish_action}, user={request.user.username}, is_superuser={request.user.is_superuser}") + + if publish_action == "make_public": + # Global staff always have permission to publish + is_global_staff = GlobalStaff().has_user(request.user) + log.info(f"User {request.user.username} is_global_staff: {is_global_staff}") + + if not is_global_staff: + # Check the user's course access role from database + user_course_roles = list(CourseAccessRole.objects.filter( + user=request.user, + course_id=usage_key.course_key, + role__in=['instructor', 'staff'] + ).values_list('role', flat=True)) + + log.info(f"Publish permission check: user={request.user.username}, roles={user_course_roles}, course={usage_key.course_key}") + + # If user is only staff (not instructor), deny publish permission + if 'staff' in user_course_roles and 'instructor' not in user_course_roles: + log.warning(f"Publish DENIED for staff-only user: {request.user.username}") + return JsonResponse( + { + "error": _("Only instructors can publish content. Staff members do not have publish permissions.") + }, + status=403, + ) + else: + log.info(f"Publish ALLOWED for user: {request.user.username}, roles={user_course_roles}") + except Exception as e: + log.error(f"Error checking publish permissions: {e}", exc_info=True) if request.method == "GET": accept_header = request.META.get("HTTP_ACCEPT", "application/json") diff --git a/common/djangoapps/student/roles.py b/common/djangoapps/student/roles.py index 33daa377c792..8cda94f79a0c 100644 --- a/common/djangoapps/student/roles.py +++ b/common/djangoapps/student/roles.py @@ -546,3 +546,37 @@ def courses_with_role(self): * role (will be self.role--thus uninteresting) """ return CourseAccessRole.objects.filter(role__in=RoleCache.get_roles(self.role), user=self.user) + + +def get_user_course_role(user, course_key): + """ + Get the role for a user in a course from CourseAccessRole model. + + First checks for course-specific roles (where course_id matches), + then falls back to org-level roles (where course_id is NULL/Empty). + If the user has multiple roles, returns the first one found. + + Args: + user: Django User object + course_key: CourseKey for the course + + Returns: + str: The exact role name from CourseAccessRole, or None if the user has no role + """ + # First, check for course-specific role + role_entry = CourseAccessRole.objects.filter( + user=user, + course_id=course_key + ).first() + + if role_entry: + return role_entry.role + + # If no course-specific role, check for org-level role (course_id is Empty) + role_entry = CourseAccessRole.objects.filter( + user=user, + org=course_key.org, + course_id=CourseKeyField.Empty + ).first() + + return role_entry.role if role_entry else None From b219dd12225fad0d25fe8b51682b1b3eb31c2ae0 Mon Sep 17 00:00:00 2001 From: carlos cantillo Date: Thu, 1 Jan 2026 14:27:29 -0500 Subject: [PATCH 2/9] feat: fix logic for staff members publishing --- .../xblock_storage_handlers/view_handlers.py | 44 +++++++++---------- 1 file changed, 21 insertions(+), 23 deletions(-) diff --git a/cms/djangoapps/contentstore/xblock_storage_handlers/view_handlers.py b/cms/djangoapps/contentstore/xblock_storage_handlers/view_handlers.py index abedd462382a..9391e343c6dd 100644 --- a/cms/djangoapps/contentstore/xblock_storage_handlers/view_handlers.py +++ b/cms/djangoapps/contentstore/xblock_storage_handlers/view_handlers.py @@ -186,31 +186,29 @@ def handle_xblock(request, usage_key_string=None): log.info(f"Publish action check: method={request.method}, publish={publish_action}, user={request.user.username}, is_superuser={request.user.is_superuser}") if publish_action == "make_public": - # Global staff always have permission to publish + # Check the user's course access role from database first + # This check applies to all users, including GlobalStaff + user_course_roles = list(CourseAccessRole.objects.filter( + user=request.user, + course_id=usage_key.course_key, + role__in=['instructor', 'staff'] + ).values_list('role', flat=True)) + is_global_staff = GlobalStaff().has_user(request.user) - log.info(f"User {request.user.username} is_global_staff: {is_global_staff}") + log.info(f"Publish permission check: user={request.user.username}, roles={user_course_roles}, is_global_staff={is_global_staff}, course={usage_key.course_key}") - if not is_global_staff: - # Check the user's course access role from database - user_course_roles = list(CourseAccessRole.objects.filter( - user=request.user, - course_id=usage_key.course_key, - role__in=['instructor', 'staff'] - ).values_list('role', flat=True)) - - log.info(f"Publish permission check: user={request.user.username}, roles={user_course_roles}, course={usage_key.course_key}") - - # If user is only staff (not instructor), deny publish permission - if 'staff' in user_course_roles and 'instructor' not in user_course_roles: - log.warning(f"Publish DENIED for staff-only user: {request.user.username}") - return JsonResponse( - { - "error": _("Only instructors can publish content. Staff members do not have publish permissions.") - }, - status=403, - ) - else: - log.info(f"Publish ALLOWED for user: {request.user.username}, roles={user_course_roles}") + # If user is only staff (not instructor), deny publish permission + # This applies even to GlobalStaff users + if 'staff' in user_course_roles and 'instructor' not in user_course_roles: + log.warning(f"Publish DENIED for staff-only user: {request.user.username} (global_staff={is_global_staff})") + return JsonResponse( + { + "error": _("Only instructors can publish content. Staff members do not have publish permissions.") + }, + status=403, + ) + else: + log.info(f"Publish ALLOWED for user: {request.user.username}, roles={user_course_roles}, global_staff={is_global_staff}") except Exception as e: log.error(f"Error checking publish permissions: {e}", exc_info=True) From 379238bedc3d3b2dd15ba319e42a4c2236df9216 Mon Sep 17 00:00:00 2001 From: carlos cantillo Date: Fri, 2 Jan 2026 15:57:19 -0500 Subject: [PATCH 3/9] fix: missing files and white space errors --- .../v1/serializers/course_user_role.py | 13 ++++ .../rest_api/v1/views/course_user_role.py | 59 +++++++++++++++++++ .../xblock_storage_handlers/view_handlers.py | 10 ++-- common/djangoapps/student/roles.py | 12 ++-- 4 files changed, 83 insertions(+), 11 deletions(-) create mode 100644 cms/djangoapps/contentstore/rest_api/v1/serializers/course_user_role.py create mode 100644 cms/djangoapps/contentstore/rest_api/v1/views/course_user_role.py diff --git a/cms/djangoapps/contentstore/rest_api/v1/serializers/course_user_role.py b/cms/djangoapps/contentstore/rest_api/v1/serializers/course_user_role.py new file mode 100644 index 000000000000..7216a1378b57 --- /dev/null +++ b/cms/djangoapps/contentstore/rest_api/v1/serializers/course_user_role.py @@ -0,0 +1,13 @@ +""" +API Serializer for the current user's course role. +""" + +from rest_framework import serializers + + +class CourseUserRoleSerializer(serializers.Serializer): + """ + Serializer for the current user's role in a given course. + """ + role = serializers.CharField(allow_null=True) + diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/course_user_role.py b/cms/djangoapps/contentstore/rest_api/v1/views/course_user_role.py new file mode 100644 index 000000000000..7e54c0a5d23a --- /dev/null +++ b/cms/djangoapps/contentstore/rest_api/v1/views/course_user_role.py @@ -0,0 +1,59 @@ +""" API View for current user's course role """ + +import edx_api_doc_tools as apidocs +from opaque_keys.edx.keys import CourseKey +from rest_framework.request import Request +from rest_framework.response import Response +from rest_framework.views import APIView + +from common.djangoapps.student.auth import has_studio_read_access +from common.djangoapps.student.roles import get_user_course_role +from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin, verify_course_exists, view_auth_classes + +from ..serializers import CourseUserRoleSerializer + + +@view_auth_classes(is_authenticated=True) +class CourseUserRoleView(DeveloperErrorViewMixin, APIView): + """ + View for getting the authenticated user's role for a course. + """ + + @apidocs.schema( + parameters=[ + apidocs.string_parameter("course_id", apidocs.ParameterLocation.PATH, description="Course ID"), + ], + responses={ + 200: CourseUserRoleSerializer, + 401: "The requester is not authenticated.", + 403: "The requester cannot access the specified course.", + 404: "The requested course does not exist.", + }, + ) + @verify_course_exists() + def get(self, request: Request, course_id: str): + """ + Get the authenticated user's role for the specified course. + + **Example Request** + + GET /api/contentstore/v1/course_user_role/{course_id} + + **Example Response** + + ```json + { "role": "instructor" } + ``` + """ + user = request.user + course_key = CourseKey.from_string(course_id) + + if not has_studio_read_access(user, course_key): + self.permission_denied(request) + + role = get_user_course_role(user, course_key) + print(f"role: {role}") + + serializer = CourseUserRoleSerializer({"role": role}) + return Response(serializer.data) + diff --git a/cms/djangoapps/contentstore/xblock_storage_handlers/view_handlers.py b/cms/djangoapps/contentstore/xblock_storage_handlers/view_handlers.py index 9391e343c6dd..78dcb8fc97d2 100644 --- a/cms/djangoapps/contentstore/xblock_storage_handlers/view_handlers.py +++ b/cms/djangoapps/contentstore/xblock_storage_handlers/view_handlers.py @@ -174,17 +174,17 @@ def handle_xblock(request, usage_key_string=None): ) if not access_check(request.user, usage_key.course_key): raise PermissionDenied() - + # Debug logging to see what's in the request log.info(f"=== XBLOCK REQUEST DEBUG === method={request.method}, user={request.user.username}, usage_key={usage_key}") log.info(f"request.json exists: {hasattr(request, 'json')}, request.json value: {getattr(request, 'json', None)}") - + # Check if user is trying to publish and if they have permission if request.method in ("POST", "PUT", "PATCH"): try: publish_action = request.json.get("publish") if hasattr(request, 'json') and request.json else None log.info(f"Publish action check: method={request.method}, publish={publish_action}, user={request.user.username}, is_superuser={request.user.is_superuser}") - + if publish_action == "make_public": # Check the user's course access role from database first # This check applies to all users, including GlobalStaff @@ -193,10 +193,10 @@ def handle_xblock(request, usage_key_string=None): course_id=usage_key.course_key, role__in=['instructor', 'staff'] ).values_list('role', flat=True)) - + is_global_staff = GlobalStaff().has_user(request.user) log.info(f"Publish permission check: user={request.user.username}, roles={user_course_roles}, is_global_staff={is_global_staff}, course={usage_key.course_key}") - + # If user is only staff (not instructor), deny publish permission # This applies even to GlobalStaff users if 'staff' in user_course_roles and 'instructor' not in user_course_roles: diff --git a/common/djangoapps/student/roles.py b/common/djangoapps/student/roles.py index 8cda94f79a0c..a238e00b815d 100644 --- a/common/djangoapps/student/roles.py +++ b/common/djangoapps/student/roles.py @@ -551,15 +551,15 @@ def courses_with_role(self): def get_user_course_role(user, course_key): """ Get the role for a user in a course from CourseAccessRole model. - + First checks for course-specific roles (where course_id matches), then falls back to org-level roles (where course_id is NULL/Empty). If the user has multiple roles, returns the first one found. - + Args: user: Django User object course_key: CourseKey for the course - + Returns: str: The exact role name from CourseAccessRole, or None if the user has no role """ @@ -568,15 +568,15 @@ def get_user_course_role(user, course_key): user=user, course_id=course_key ).first() - + if role_entry: return role_entry.role - + # If no course-specific role, check for org-level role (course_id is Empty) role_entry = CourseAccessRole.objects.filter( user=user, org=course_key.org, course_id=CourseKeyField.Empty ).first() - + return role_entry.role if role_entry else None From 9a5352e076873ff42b42be131ae138f8dd016722 Mon Sep 17 00:00:00 2001 From: carlos cantillo Date: Fri, 2 Jan 2026 16:07:34 -0500 Subject: [PATCH 4/9] fix: codestyle errors --- .../v1/serializers/course_user_role.py | 1 - .../rest_api/v1/views/course_user_role.py | 1 - .../xblock_storage_handlers/view_handlers.py | 46 +++++++++++++++---- 3 files changed, 37 insertions(+), 11 deletions(-) diff --git a/cms/djangoapps/contentstore/rest_api/v1/serializers/course_user_role.py b/cms/djangoapps/contentstore/rest_api/v1/serializers/course_user_role.py index 7216a1378b57..2fbda559a55a 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/serializers/course_user_role.py +++ b/cms/djangoapps/contentstore/rest_api/v1/serializers/course_user_role.py @@ -10,4 +10,3 @@ class CourseUserRoleSerializer(serializers.Serializer): Serializer for the current user's role in a given course. """ role = serializers.CharField(allow_null=True) - diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/course_user_role.py b/cms/djangoapps/contentstore/rest_api/v1/views/course_user_role.py index 7e54c0a5d23a..85ac75111428 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/views/course_user_role.py +++ b/cms/djangoapps/contentstore/rest_api/v1/views/course_user_role.py @@ -56,4 +56,3 @@ def get(self, request: Request, course_id: str): serializer = CourseUserRoleSerializer({"role": role}) return Response(serializer.data) - diff --git a/cms/djangoapps/contentstore/xblock_storage_handlers/view_handlers.py b/cms/djangoapps/contentstore/xblock_storage_handlers/view_handlers.py index 78dcb8fc97d2..9c23c2d954a8 100644 --- a/cms/djangoapps/contentstore/xblock_storage_handlers/view_handlers.py +++ b/cms/djangoapps/contentstore/xblock_storage_handlers/view_handlers.py @@ -176,14 +176,28 @@ def handle_xblock(request, usage_key_string=None): raise PermissionDenied() # Debug logging to see what's in the request - log.info(f"=== XBLOCK REQUEST DEBUG === method={request.method}, user={request.user.username}, usage_key={usage_key}") - log.info(f"request.json exists: {hasattr(request, 'json')}, request.json value: {getattr(request, 'json', None)}") + log.info( + f"=== XBLOCK REQUEST DEBUG === method={request.method}, " + f"user={request.user.username}, usage_key={usage_key}" + ) + log.info( + f"request.json exists: {hasattr(request, 'json')}, " + f"request.json value: {getattr(request, 'json', None)}" + ) # Check if user is trying to publish and if they have permission if request.method in ("POST", "PUT", "PATCH"): try: - publish_action = request.json.get("publish") if hasattr(request, 'json') and request.json else None - log.info(f"Publish action check: method={request.method}, publish={publish_action}, user={request.user.username}, is_superuser={request.user.is_superuser}") + publish_action = ( + request.json.get("publish") + if hasattr(request, 'json') and request.json + else None + ) + log.info( + f"Publish action check: method={request.method}, " + f"publish={publish_action}, user={request.user.username}, " + f"is_superuser={request.user.is_superuser}" + ) if publish_action == "make_public": # Check the user's course access role from database first @@ -195,21 +209,35 @@ def handle_xblock(request, usage_key_string=None): ).values_list('role', flat=True)) is_global_staff = GlobalStaff().has_user(request.user) - log.info(f"Publish permission check: user={request.user.username}, roles={user_course_roles}, is_global_staff={is_global_staff}, course={usage_key.course_key}") + log.info( + f"Publish permission check: user={request.user.username}, " + f"roles={user_course_roles}, " + f"is_global_staff={is_global_staff}, " + f"course={usage_key.course_key}" + ) # If user is only staff (not instructor), deny publish permission # This applies even to GlobalStaff users if 'staff' in user_course_roles and 'instructor' not in user_course_roles: - log.warning(f"Publish DENIED for staff-only user: {request.user.username} (global_staff={is_global_staff})") + log.warning( + f"Publish DENIED for staff-only user: " + f"{request.user.username} (global_staff={is_global_staff})" + ) return JsonResponse( { - "error": _("Only instructors can publish content. Staff members do not have publish permissions.") + "error": _( + "Only instructors can publish content. " + "Staff members do not have publish permissions." + ) }, status=403, ) else: - log.info(f"Publish ALLOWED for user: {request.user.username}, roles={user_course_roles}, global_staff={is_global_staff}") - except Exception as e: + log.info( + f"Publish ALLOWED for user: {request.user.username}, " + f"roles={user_course_roles}, global_staff={is_global_staff}" + ) + except Exception as e: # lint-amnesty, pylint: disable=broad-exception-caught log.error(f"Error checking publish permissions: {e}", exc_info=True) if request.method == "GET": From ef8b9cf17c089752429af108eb1f5744d8e6f4b0 Mon Sep 17 00:00:00 2001 From: carlos cantillo Date: Sun, 4 Jan 2026 10:43:21 -0500 Subject: [PATCH 5/9] fix: too many statements --- .../xblock_storage_handlers/view_handlers.py | 112 ++++++++++-------- 1 file changed, 60 insertions(+), 52 deletions(-) diff --git a/cms/djangoapps/contentstore/xblock_storage_handlers/view_handlers.py b/cms/djangoapps/contentstore/xblock_storage_handlers/view_handlers.py index 9c23c2d954a8..d86b1f9adb2e 100644 --- a/cms/djangoapps/contentstore/xblock_storage_handlers/view_handlers.py +++ b/cms/djangoapps/contentstore/xblock_storage_handlers/view_handlers.py @@ -157,6 +157,63 @@ def _get_block_parent_children(xblock): return response +def _check_publish_permissions(request, usage_key): + try: + publish_action = ( + request.json.get("publish") + if hasattr(request, "json") and request.json + else None + ) + log.info( + f"Publish action check: method={request.method}, " + f"publish={publish_action}, user={request.user.username}, " + f"is_superuser={request.user.is_superuser}" + ) + + if publish_action == "make_public": + # Check the user's course access role from database first + # This check applies to all users, including GlobalStaff + user_course_roles = list(CourseAccessRole.objects.filter( + user=request.user, + course_id=usage_key.course_key, + role__in=["instructor", "staff"] + ).values_list("role", flat=True)) + + is_global_staff = GlobalStaff().has_user(request.user) + log.info( + f"Publish permission check: user={request.user.username}, " + f"roles={user_course_roles}, " + f"is_global_staff={is_global_staff}, " + f"course={usage_key.course_key}" + ) + + # If user is only staff (not instructor), deny publish permission + # This applies even to GlobalStaff users + if "staff" in user_course_roles and "instructor" not in user_course_roles: + log.warning( + f"Publish DENIED for staff-only user: " + f"{request.user.username} (global_staff={is_global_staff})" + ) + return JsonResponse( + { + "error": _( + "Only instructors can publish content. " + "Staff members do not have publish permissions." + ) + }, + status=403, + ) + else: + log.info( + f"Publish ALLOWED for user: {request.user.username}, " + f"roles={user_course_roles}, global_staff={is_global_staff}" + ) + except Exception as e: # lint-amnesty, pylint: disable=broad-exception-caught + log.error(f"Error checking publish permissions: {e}", exc_info=True) + + return None + + def handle_xblock(request, usage_key_string=None): """ Service method with all business logic for handling xblock requests. @@ -187,58 +244,9 @@ def handle_xblock(request, usage_key_string=None): # Check if user is trying to publish and if they have permission if request.method in ("POST", "PUT", "PATCH"): - try: - publish_action = ( - request.json.get("publish") - if hasattr(request, 'json') and request.json - else None - ) - log.info( - f"Publish action check: method={request.method}, " - f"publish={publish_action}, user={request.user.username}, " - f"is_superuser={request.user.is_superuser}" - ) - - if publish_action == "make_public": - # Check the user's course access role from database first - # This check applies to all users, including GlobalStaff - user_course_roles = list(CourseAccessRole.objects.filter( - user=request.user, - course_id=usage_key.course_key, - role__in=['instructor', 'staff'] - ).values_list('role', flat=True)) - - is_global_staff = GlobalStaff().has_user(request.user) - log.info( - f"Publish permission check: user={request.user.username}, " - f"roles={user_course_roles}, " - f"is_global_staff={is_global_staff}, " - f"course={usage_key.course_key}" - ) - - # If user is only staff (not instructor), deny publish permission - # This applies even to GlobalStaff users - if 'staff' in user_course_roles and 'instructor' not in user_course_roles: - log.warning( - f"Publish DENIED for staff-only user: " - f"{request.user.username} (global_staff={is_global_staff})" - ) - return JsonResponse( - { - "error": _( - "Only instructors can publish content. " - "Staff members do not have publish permissions." - ) - }, - status=403, - ) - else: - log.info( - f"Publish ALLOWED for user: {request.user.username}, " - f"roles={user_course_roles}, global_staff={is_global_staff}" - ) - except Exception as e: # lint-amnesty, pylint: disable=broad-exception-caught - log.error(f"Error checking publish permissions: {e}", exc_info=True) + permission_response = _check_publish_permissions(request, usage_key) + if permission_response: + return permission_response if request.method == "GET": accept_header = request.META.get("HTTP_ACCEPT", "application/json") From 5281d214db15d9cf0db971965324c206f18cc0e7 Mon Sep 17 00:00:00 2001 From: carlos cantillo Date: Sun, 4 Jan 2026 10:54:14 -0500 Subject: [PATCH 6/9] fix: missing docstring --- .../xblock_storage_handlers/view_handlers.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/cms/djangoapps/contentstore/xblock_storage_handlers/view_handlers.py b/cms/djangoapps/contentstore/xblock_storage_handlers/view_handlers.py index d86b1f9adb2e..d291d089f74d 100644 --- a/cms/djangoapps/contentstore/xblock_storage_handlers/view_handlers.py +++ b/cms/djangoapps/contentstore/xblock_storage_handlers/view_handlers.py @@ -158,6 +158,20 @@ def _get_block_parent_children(xblock): def _check_publish_permissions(request, usage_key): + """ + Check if the user has permission to publish content. + + This function validates that only instructors (not staff-only users) can publish + content. Staff members are denied publish permissions even if they are GlobalStaff. + + Args: + request: The HTTP request object containing the publish action and user information. + usage_key: The usage key identifying the xblock/content being published. + + Returns: + None if permission check passes, or JsonResponse with error message and 403 status + if the user lacks publish permissions. + """ try: publish_action = ( request.json.get("publish") From e47907683c08e1e3ee28832f0c424cbbf84c676f Mon Sep 17 00:00:00 2001 From: ccantillo <43839760+ccantillo@users.noreply.github.com> Date: Sun, 4 Jan 2026 11:52:16 -0500 Subject: [PATCH 7/9] fix: Update common/djangoapps/student/roles.py Co-authored-by: Johan Seto Kaiba <51926076+johanseto@users.noreply.github.com> --- common/djangoapps/student/roles.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/djangoapps/student/roles.py b/common/djangoapps/student/roles.py index a238e00b815d..8dcd4293c7d7 100644 --- a/common/djangoapps/student/roles.py +++ b/common/djangoapps/student/roles.py @@ -566,7 +566,7 @@ def get_user_course_role(user, course_key): # First, check for course-specific role role_entry = CourseAccessRole.objects.filter( user=user, - course_id=course_key + course_id=course_key, ).first() if role_entry: From a43e9a3dfa233dd907ce9e09af822d38b0e12749 Mon Sep 17 00:00:00 2001 From: ccantillo <43839760+ccantillo@users.noreply.github.com> Date: Sun, 4 Jan 2026 11:52:26 -0500 Subject: [PATCH 8/9] fix: Update common/djangoapps/student/roles.py Co-authored-by: Johan Seto Kaiba <51926076+johanseto@users.noreply.github.com> --- common/djangoapps/student/roles.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/djangoapps/student/roles.py b/common/djangoapps/student/roles.py index 8dcd4293c7d7..93e49ac48bfa 100644 --- a/common/djangoapps/student/roles.py +++ b/common/djangoapps/student/roles.py @@ -576,7 +576,7 @@ def get_user_course_role(user, course_key): role_entry = CourseAccessRole.objects.filter( user=user, org=course_key.org, - course_id=CourseKeyField.Empty + course_id=CourseKeyField.Empty, ).first() return role_entry.role if role_entry else None From 96a324e798f53dc0ddb0c372812da38440232bc7 Mon Sep 17 00:00:00 2001 From: ccantillo <43839760+ccantillo@users.noreply.github.com> Date: Sun, 4 Jan 2026 11:52:44 -0500 Subject: [PATCH 9/9] fix: Update cms/djangoapps/contentstore/rest_api/v1/urls.py Co-authored-by: Johan Seto Kaiba <51926076+johanseto@users.noreply.github.com> --- cms/djangoapps/contentstore/rest_api/v1/urls.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cms/djangoapps/contentstore/rest_api/v1/urls.py b/cms/djangoapps/contentstore/rest_api/v1/urls.py index a3dca3f76361..a26b96358292 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/urls.py +++ b/cms/djangoapps/contentstore/rest_api/v1/urls.py @@ -96,7 +96,7 @@ re_path( fr'^course_user_role/{COURSE_ID_PATTERN}$', CourseUserRoleView.as_view(), - name="course_user_role" + name="course_user_role", ), re_path( fr'^course_grading/{COURSE_ID_PATTERN}$',