|
3 | 3 | import edx_api_doc_tools as apidocs |
4 | 4 | from django.core.exceptions import ValidationError |
5 | 5 | 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 | +) |
6 | 11 | from rest_framework.request import Request |
7 | 12 | from rest_framework.response import Response |
8 | 13 | from rest_framework.views import APIView |
9 | 14 |
|
10 | | -from common.djangoapps.student.auth import has_studio_read_access |
11 | 15 | 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 |
12 | 18 | from openedx.core.djangoapps.models.course_details import CourseDetails |
13 | 19 | from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin, verify_course_exists, view_auth_classes |
14 | 20 | from xmodule.modulestore.django import modulestore |
|
17 | 23 | from ..serializers import CourseDetailsSerializer |
18 | 24 |
|
19 | 25 |
|
| 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 | + |
20 | 96 | @view_auth_classes(is_authenticated=True) |
21 | 97 | class CourseDetailsView(DeveloperErrorViewMixin, APIView): |
22 | 98 | """ |
@@ -99,7 +175,12 @@ def get(self, request: Request, course_id: str): |
99 | 175 | ``` |
100 | 176 | """ |
101 | 177 | 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 | + ): |
103 | 184 | self.permission_denied(request) |
104 | 185 |
|
105 | 186 | course_details = CourseDetails.fetch(course_key) |
@@ -142,7 +223,26 @@ def put(self, request: Request, course_id: str): |
142 | 223 | along with all the course's details similar to a ``GET`` request. |
143 | 224 | """ |
144 | 225 | 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 | + ): |
146 | 246 | self.permission_denied(request) |
147 | 247 |
|
148 | 248 | course_block = modulestore().get_course(course_key) |
|
0 commit comments