Skip to content

Commit 2268e6f

Browse files
authored
Merge pull request #38213 from WGU-Open-edX/dwong2708/authz-new-perms-schedule-details
feat: add AuthZ permissions for course schedule & details
2 parents 06cc127 + 3d99e0b commit 2268e6f

File tree

5 files changed

+655
-6
lines changed

5 files changed

+655
-6
lines changed

cms/djangoapps/contentstore/rest_api/v1/views/course_details.py

Lines changed: 103 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,18 @@
33
import edx_api_doc_tools as apidocs
44
from django.core.exceptions import ValidationError
55
from opaque_keys.edx.keys import CourseKey
6+
from openedx_authz.constants.permissions import (
7+
COURSES_EDIT_DETAILS,
8+
COURSES_EDIT_SCHEDULE,
9+
COURSES_VIEW_SCHEDULE_AND_DETAILS,
10+
)
611
from rest_framework.request import Request
712
from rest_framework.response import Response
813
from rest_framework.views import APIView
914

10-
from common.djangoapps.student.auth import has_studio_read_access
1115
from common.djangoapps.util.json_request import JsonResponseBadRequest
16+
from openedx.core.djangoapps.authz.constants import LegacyAuthoringPermission
17+
from openedx.core.djangoapps.authz.decorators import user_has_course_permission
1218
from openedx.core.djangoapps.models.course_details import CourseDetails
1319
from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin, verify_course_exists, view_auth_classes
1420
from xmodule.modulestore.django import modulestore
@@ -17,6 +23,76 @@
1723
from ..serializers import CourseDetailsSerializer
1824

1925

26+
def _classify_update(payload: dict, course_key: CourseKey) -> tuple[bool, bool]:
27+
"""
28+
Determine whether the payload is updating schedule fields, detail fields, or both
29+
for the course identified by course_key.
30+
31+
Returns:
32+
(is_schedule_update, is_details_update)
33+
"""
34+
35+
# Define which fields are considered schedule fields.
36+
# Any field not in this set that is being updated will be considered a details update.
37+
schedule_fields = frozenset(
38+
{"start_date", "end_date", "enrollment_start", "enrollment_end", "certificate_available_date"}
39+
)
40+
41+
# Define which fields are date fields to ensure proper comparison after parsing.
42+
# At this time, all schedule fields are also date fields, but this is defined separately for clarity
43+
# and in case this changes in the future.
44+
date_fields = frozenset(
45+
{"start_date", "end_date", "enrollment_start", "enrollment_end", "certificate_available_date"}
46+
)
47+
48+
course_details = CourseDetails.fetch(course_key)
49+
50+
is_schedule_update = False
51+
is_details_update = False
52+
53+
serializer = CourseDetailsSerializer()
54+
55+
for field, payload_value in payload.items():
56+
# Early exit for efficiency
57+
if is_schedule_update and is_details_update:
58+
break
59+
60+
# Ignore unknown fields if needed
61+
if field not in serializer.fields:
62+
continue
63+
64+
current_value = getattr(course_details, field, None)
65+
66+
if field in date_fields:
67+
# For date fields, we need to parse the payload value to compare it with the current value
68+
try:
69+
# Convert payload value to internal value for accurate comparison
70+
# on date fields
71+
if payload_value is not None:
72+
payload_value = serializer.fields[field].to_internal_value(payload_value)
73+
except ValidationError as exc:
74+
raise ValidationError(
75+
f"Invalid date format for field {field}: {payload_value}"
76+
) from exc
77+
78+
# Check schedule fields
79+
if field in schedule_fields:
80+
if is_schedule_update:
81+
# Already classified as schedule update, no need to check again
82+
continue
83+
if payload_value != current_value:
84+
is_schedule_update = True
85+
else:
86+
# Any non-schedule field counts as details update
87+
if is_details_update:
88+
# Already classified as details update, no need to check again
89+
continue
90+
if payload_value != current_value:
91+
is_details_update = True
92+
93+
return is_schedule_update, is_details_update
94+
95+
2096
@view_auth_classes(is_authenticated=True)
2197
class CourseDetailsView(DeveloperErrorViewMixin, APIView):
2298
"""
@@ -99,7 +175,12 @@ def get(self, request: Request, course_id: str):
99175
```
100176
"""
101177
course_key = CourseKey.from_string(course_id)
102-
if not has_studio_read_access(request.user, course_key):
178+
if not user_has_course_permission(
179+
request.user,
180+
COURSES_VIEW_SCHEDULE_AND_DETAILS.identifier,
181+
course_key,
182+
LegacyAuthoringPermission.READ
183+
):
103184
self.permission_denied(request)
104185

105186
course_details = CourseDetails.fetch(course_key)
@@ -142,7 +223,26 @@ def put(self, request: Request, course_id: str):
142223
along with all the course's details similar to a ``GET`` request.
143224
"""
144225
course_key = CourseKey.from_string(course_id)
145-
if not has_studio_read_access(request.user, course_key):
226+
is_schedule_update, is_details_update = _classify_update(request.data, course_key)
227+
228+
if not is_schedule_update and not is_details_update:
229+
# No updatable fields provided in the request
230+
is_details_update = True # To trigger permission check and return 403 if user cannot edit details
231+
232+
if is_schedule_update and not user_has_course_permission(
233+
request.user,
234+
COURSES_EDIT_SCHEDULE.identifier,
235+
course_key,
236+
LegacyAuthoringPermission.READ
237+
):
238+
self.permission_denied(request)
239+
240+
if is_details_update and not user_has_course_permission(
241+
request.user,
242+
COURSES_EDIT_DETAILS.identifier,
243+
course_key,
244+
LegacyAuthoringPermission.READ
245+
):
146246
self.permission_denied(request)
147247

148248
course_block = modulestore().get_course(course_key)

cms/djangoapps/contentstore/rest_api/v1/views/settings.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,14 @@
33
import edx_api_doc_tools as apidocs
44
from django.conf import settings
55
from opaque_keys.edx.keys import CourseKey
6+
from openedx_authz.constants.permissions import COURSES_VIEW_COURSE
67
from rest_framework.request import Request
78
from rest_framework.response import Response
89
from rest_framework.views import APIView
910

10-
from common.djangoapps.student.auth import has_studio_read_access
1111
from lms.djangoapps.certificates.api import can_show_certificate_available_date_field
12+
from openedx.core.djangoapps.authz.constants import LegacyAuthoringPermission
13+
from openedx.core.djangoapps.authz.decorators import user_has_course_permission
1214
from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin, verify_course_exists, view_auth_classes
1315
from xmodule.modulestore.django import modulestore
1416

@@ -99,7 +101,12 @@ def get(self, request: Request, course_id: str):
99101
```
100102
"""
101103
course_key = CourseKey.from_string(course_id)
102-
if not has_studio_read_access(request.user, course_key):
104+
if not user_has_course_permission(
105+
request.user,
106+
COURSES_VIEW_COURSE.identifier,
107+
course_key,
108+
LegacyAuthoringPermission.READ
109+
):
103110
self.permission_denied(request)
104111

105112
with modulestore().bulk_operations(course_key):

0 commit comments

Comments
 (0)