Skip to content

Commit 9835cff

Browse files
feat: add studio write access validation
1 parent eb82c4c commit 9835cff

File tree

7 files changed

+186
-61
lines changed

7 files changed

+186
-61
lines changed

ai_aside/api/__init__.py

+45
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
"""
2+
API Utilities
3+
"""
4+
import logging
5+
6+
from rest_framework import status
7+
from rest_framework.response import Response
8+
from rest_framework.views import APIView
9+
10+
from ai_aside.api.exceptions import AiAsideException
11+
12+
log = logging.getLogger(__name__)
13+
14+
15+
def handle_ai_aside_exception(exc, name=None): # pylint: disable=inconsistent-return-statements
16+
"""
17+
Converts ai_aside exceptions into restframework responses
18+
"""
19+
if isinstance(exc, AiAsideException):
20+
log.exception(name)
21+
return APIResponse(http_status=exc.http_status, data={'message': str(exc)})
22+
23+
24+
class APIResponse(Response):
25+
"""API Response"""
26+
def __init__(self, data=None, http_status=None, content_type=None, success=False):
27+
_status = http_status or status.HTTP_200_OK
28+
data = data or {}
29+
reply = {'response': {'success': success}}
30+
reply['response'].update(data)
31+
super().__init__(data=reply, status=_status, content_type=content_type)
32+
33+
34+
class AiAsideAPIView(APIView):
35+
"""
36+
Overrides APIView to handle ai_aside exceptions
37+
"""
38+
def handle_exception(self, exc):
39+
"""
40+
Converts proctoring exceptions into standard restframework responses
41+
"""
42+
resp = handle_ai_aside_exception(exc, name=self.__class__.__name__)
43+
if not resp:
44+
resp = super().handle_exception(exc)
45+
return resp

ai_aside/api/exceptions.py

+11
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
"""
2+
Custom exceptions for ai-aside
3+
"""
4+
from rest_framework import status
5+
6+
7+
class AiAsideException(Exception):
8+
"""
9+
A common base class for all exceptions
10+
"""
11+
http_status = status.HTTP_400_BAD_REQUEST

ai_aside/api/permissions.py

+20
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
""" Permissions for ai-aside API"""
2+
3+
from rest_framework.permissions import BasePermission
4+
5+
from ai_aside.api.view_utils import validate_course_key
6+
from ai_aside.platform_imports import can_change_summaries_settings
7+
8+
9+
class HasStudioWriteAccess(BasePermission):
10+
"""
11+
Check if the user has studio write access to a course.
12+
"""
13+
def has_permission(self, request, view):
14+
"""
15+
Check permissions for this class.
16+
"""
17+
course_key_string = view.kwargs.get('course_id')
18+
course_key = validate_course_key(course_key_string)
19+
20+
return can_change_summaries_settings(request.user, course_key)

ai_aside/api/view_utils.py

+49
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
"""
2+
Utilities related to API views
3+
"""
4+
5+
from opaque_keys import InvalidKeyError
6+
from opaque_keys.edx.keys import CourseKey, UsageKey
7+
from ai_aside.api.exceptions import AiAsideException
8+
9+
10+
def validate_course_key(course_key_string: str) -> CourseKey:
11+
"""
12+
Validate and parse a course_key string, if supported.
13+
14+
Args:
15+
course_key_string (str): string course key to validate
16+
17+
Returns:
18+
CourseKey: validated course key
19+
20+
Raises:
21+
ValidationError: DRF Validation error in case the course key is invalid
22+
"""
23+
try:
24+
course_key = CourseKey.from_string(course_key_string)
25+
except InvalidKeyError:
26+
raise AiAsideException(f"{course_key_string} is not a valid CourseKey")
27+
if course_key.deprecated:
28+
raise AiAsideException("Deprecated CourseKeys (Org/Course/Run) are not supported.")
29+
return course_key
30+
31+
32+
def validate_unit_key(unit_key_string: str) -> UsageKey:
33+
"""
34+
Validate and parse a unit_key string, if supported.
35+
36+
Args:
37+
unit_key_string (str): string unit key to validate
38+
39+
Returns:
40+
UsageKey: validated unit key
41+
42+
Raises:
43+
ValidationError: DRF Validation error in case the unit key is invalid
44+
"""
45+
try:
46+
usage_key = UsageKey.from_string(unit_key_string)
47+
except InvalidKeyError:
48+
raise AiAsideException(f"{unit_key_string} is not a valid UsageKey")
49+
return usage_key

ai_aside/config_api/views.py

+29-56
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,11 @@
1515
1616
Both GET and DELETE methods respond with a 404 if the setting cannot be found.
1717
"""
18-
from opaque_keys import InvalidKeyError
19-
from opaque_keys.edx.keys import CourseKey, UsageKey
2018
from rest_framework import status
21-
from rest_framework.response import Response
22-
from rest_framework.views import APIView
2319

20+
from ai_aside.api import AiAsideAPIView, APIResponse
21+
from ai_aside.api.permissions import HasStudioWriteAccess
22+
from ai_aside.api.view_utils import validate_course_key, validate_unit_key
2423
from ai_aside.config_api.api import (
2524
NotFoundError,
2625
delete_course_settings,
@@ -34,47 +33,36 @@
3433
)
3534

3635

37-
class APIResponse(Response):
38-
"""API Response"""
39-
def __init__(self, data=None, http_status=None, content_type=None, success=False):
40-
_status = http_status or status.HTTP_200_OK
41-
data = data or {}
42-
reply = {'response': {'success': success}}
43-
reply['response'].update(data)
44-
super().__init__(data=reply, status=_status, content_type=content_type)
45-
46-
47-
class CourseSummaryConfigEnabledAPIView(APIView):
36+
class CourseSummaryConfigEnabledAPIView(AiAsideAPIView):
4837
"""
4938
Simple GET endpoint to expose whether the course may use summary config.
5039
"""
5140

41+
permission_classes = (HasStudioWriteAccess,)
42+
5243
def get(self, request, course_id=None):
5344
"""Expose whether the course may use summary config"""
5445
if course_id is None:
5546
return APIResponse(http_status=status.HTTP_404_NOT_FOUND)
5647

57-
try:
58-
enabled = is_summary_config_enabled(CourseKey.from_string(course_id))
59-
return APIResponse(success=True, data={'enabled': enabled})
60-
except InvalidKeyError:
61-
data = {'message': 'Invalid Key'}
62-
return APIResponse(http_status=status.HTTP_400_BAD_REQUEST, data=data)
48+
course_key = validate_course_key(course_id)
49+
enabled = is_summary_config_enabled(course_key)
50+
return APIResponse(success=True, data={'enabled': enabled})
6351

6452

65-
class CourseEnabledAPIView(APIView):
53+
class CourseEnabledAPIView(AiAsideAPIView):
6654
"""Handlers for course level settings"""
6755

56+
permission_classes = (HasStudioWriteAccess,)
57+
6858
def get(self, request, course_id=None):
6959
"""Gets the enabled state for a course"""
7060
if course_id is None:
7161
return APIResponse(http_status=status.HTTP_404_NOT_FOUND)
7262

7363
try:
74-
settings = get_course_settings(CourseKey.from_string(course_id))
75-
except InvalidKeyError:
76-
data = {'message': 'Invalid Key'}
77-
return APIResponse(http_status=status.HTTP_400_BAD_REQUEST, data=data)
64+
course_key = validate_course_key(course_id)
65+
settings = get_course_settings(course_key)
7866
except NotFoundError:
7967
return APIResponse(http_status=status.HTTP_404_NOT_FOUND)
8068

@@ -90,13 +78,10 @@ def post(self, request, course_id=None):
9078
reset = request.data.get('reset')
9179

9280
try:
93-
course_key = CourseKey.from_string(course_id)
81+
course_key = validate_course_key(course_id)
9482
set_course_settings(course_key, {'enabled': enabled})
9583
if reset:
9684
reset_course_unit_settings(course_key)
97-
except InvalidKeyError:
98-
data = {'message': 'Invalid Key'}
99-
return APIResponse(http_status=status.HTTP_400_BAD_REQUEST, data=data)
10085
except TypeError:
10186
data = {'message': 'Invalid parameters'}
10287
return APIResponse(http_status=status.HTTP_400_BAD_REQUEST, data=data)
@@ -110,32 +95,28 @@ def delete(self, request, course_id=None):
11095
return APIResponse(http_status=status.HTTP_404_NOT_FOUND)
11196

11297
try:
113-
delete_course_settings(CourseKey.from_string(course_id))
114-
except InvalidKeyError:
115-
data = {'message': 'Invalid Key'}
116-
return APIResponse(http_status=status.HTTP_400_BAD_REQUEST, data=data)
98+
course_key = validate_course_key(course_id)
99+
delete_course_settings(course_key)
117100
except NotFoundError:
118101
return APIResponse(http_status=status.HTTP_404_NOT_FOUND)
119102

120103
return APIResponse(success=True)
121104

122105

123-
class UnitEnabledAPIView(APIView):
106+
class UnitEnabledAPIView(AiAsideAPIView):
124107
"""Handlers for module level settings"""
125108

109+
permission_classes = (HasStudioWriteAccess,)
110+
126111
def get(self, request, course_id=None, unit_id=None):
127112
"""Gets the enabled state for a unit"""
128113
if course_id is None or unit_id is None:
129114
return APIResponse(http_status=status.HTTP_404_NOT_FOUND)
130115

131116
try:
132-
settings = get_unit_settings(
133-
CourseKey.from_string(course_id),
134-
UsageKey.from_string(unit_id),
135-
)
136-
except InvalidKeyError:
137-
data = {'message': 'Invalid Key'}
138-
return APIResponse(http_status=status.HTTP_400_BAD_REQUEST, data=data)
117+
course_key = validate_course_key(course_id)
118+
unit_key = validate_unit_key(unit_id)
119+
settings = get_unit_settings(course_key, unit_key)
139120
except NotFoundError:
140121
return APIResponse(http_status=status.HTTP_404_NOT_FOUND)
141122

@@ -147,13 +128,9 @@ def post(self, request, course_id=None, unit_id=None):
147128
enabled = request.data.get('enabled')
148129

149130
try:
150-
set_unit_settings(
151-
CourseKey.from_string(course_id),
152-
UsageKey.from_string(unit_id),
153-
{'enabled': enabled})
154-
except InvalidKeyError:
155-
data = {'message': 'Invalid Key'}
156-
return APIResponse(http_status=status.HTTP_400_BAD_REQUEST, data=data)
131+
course_key = validate_course_key(course_id)
132+
unit_key = validate_unit_key(unit_id)
133+
set_unit_settings(course_key, unit_key, {'enabled': enabled})
157134
except TypeError:
158135
data = {'message': 'Invalid parameters'}
159136
return APIResponse(http_status=status.HTTP_400_BAD_REQUEST, data=data)
@@ -167,13 +144,9 @@ def delete(self, request, course_id=None, unit_id=None):
167144
return APIResponse(http_status=status.HTTP_404_NOT_FOUND)
168145

169146
try:
170-
delete_unit_settings(
171-
CourseKey.from_string(course_id),
172-
UsageKey.from_string(unit_id),
173-
)
174-
except InvalidKeyError:
175-
data = {'message': 'Invalid Key'}
176-
return APIResponse(http_status=status.HTTP_400_BAD_REQUEST, data=data)
147+
course_key = validate_course_key(course_id)
148+
unit_key = validate_unit_key(unit_id)
149+
delete_unit_settings(course_key, unit_key,)
177150
except NotFoundError:
178151
return APIResponse(http_status=status.HTTP_404_NOT_FOUND)
179152

ai_aside/platform_imports.py

+12
Original file line numberDiff line numberDiff line change
@@ -26,3 +26,15 @@ def get_block(usage_key):
2626
# pylint: disable=import-error, import-outside-toplevel
2727
from xmodule.modulestore.django import modulestore
2828
return modulestore().get_item(usage_key)
29+
30+
31+
def can_change_summaries_settings(user, course_key) -> bool:
32+
"""
33+
Check if the user can change the summaries settings.
34+
"""
35+
try:
36+
# pylint: disable=import-error, import-outside-toplevel
37+
from common.djangoapps.student.auth import has_studio_write_access
38+
return has_studio_write_access(user, course_key)
39+
except (ModuleNotFoundError, ImportError):
40+
return False

tests/api/test_views.py

+20-5
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"""
22
Tests for the API
33
"""
4-
from unittest.mock import patch
4+
from unittest.mock import MagicMock, patch
55

66
import ddt
77
from django.urls import reverse
@@ -20,10 +20,25 @@
2020
'block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_321ac313f2de',
2121
]
2222

23+
has_studio_write_access = True
24+
25+
26+
def fake_has_studio_write_access(user, course_key): # pylint: disable=unused-argument
27+
return (has_studio_write_access, 'unused', 'unused')
28+
2329

2430
@ddt.ddt
2531
class TestApiViews(APITestCase):
2632
"""API Endpoint View tests"""
33+
def setUp(self):
34+
auth_mock = MagicMock()
35+
auth_mock.has_studio_write_access = fake_has_studio_write_access
36+
37+
modules = {
38+
'common.djangoapps.student.auth': auth_mock,
39+
}
40+
41+
patch.dict('sys.modules', modules).start()
2742

2843
@ddt.data(True, False)
2944
@patch('ai_aside.config_api.api.summaries_configuration_enabled')
@@ -211,9 +226,9 @@ def test_unit_enabled_setter_invalid_key(self):
211226
'unit_id': unit_id,
212227
})
213228
response = self.client.post(api_url, {'enabled': True}, format='json')
214-
229+
message = response.data['response']['message']
215230
self.assertEqual(response.status_code, 400)
216-
self.assertEqual(response.data['response']['message'], 'Invalid Key')
231+
self.assertEqual(message, 'this:is:not_a-valid~key#either! is not a valid UsageKey')
217232

218233
def test_unit_enabled_getter_valid(self):
219234
course_id = course_keys[0]
@@ -244,9 +259,9 @@ def test_unit_enabled_getter_invalid_key(self):
244259
'unit_id': unit_id,
245260
})
246261
response = self.client.get(api_url)
247-
262+
message = response.data['response']['message']
248263
self.assertEqual(response.status_code, 400)
249-
self.assertEqual(response.data['response']['message'], 'Invalid Key')
264+
self.assertEqual(message, 'this:is:not_a-valid~key#either! is not a valid UsageKey')
250265

251266
def test_unit_enabled_getter_404(self):
252267
course_id = course_keys[1]

0 commit comments

Comments
 (0)