Skip to content

Commit ccfacd8

Browse files
feat: add studio write access validation (#55)
* feat: add studio write access validation https://jira.2u.com/browse/ACADEMIC-16359 * feat: add edx-drf-extensions lib * Add SessionAuthentication * Add JwtAuthentication * feat: Update CHANGELOG.rst
1 parent 2dc69b0 commit ccfacd8

22 files changed

+704
-223
lines changed

CHANGELOG.rst

+9
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,15 @@ Change Log
1414
Unreleased
1515
**********
1616

17+
3.5.0 – 2023-09-04
18+
**********************************************
19+
20+
* Add edx-drf-extensions lib.
21+
* Add JwtAuthentication checks before each request.
22+
* Add SessionAuthentication checks before each request.
23+
* Add HasStudioWriteAccess permissions checks before each request.
24+
25+
1726
3.4.0 – 2023-08-30
1827
**********************************************
1928

ai_aside/__init__.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,6 @@
22
A plugin containing xblocks and apps supporting GPT and other LLM use on edX.
33
"""
44

5-
__version__ = '3.4.0'
5+
__version__ = '3.5.0'
66

77
default_app_config = "ai_aside.apps.AiAsideConfig"

ai_aside/config_api/api.py

+9-8
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
"""
22
Implements an API for updating unit and course settings.
33
"""
4-
from ai_aside.config_api.internal import NotFoundError, _get_course, _get_course_units, _get_unit
4+
from ai_aside.config_api.exceptions import AiAsideNotFoundException
5+
from ai_aside.config_api.internal import _get_course, _get_course_units, _get_unit
56
from ai_aside.models import AIAsideCourseEnabled, AIAsideUnitEnabled
67
from ai_aside.waffle import summaries_configuration_enabled
78

@@ -28,7 +29,7 @@ def set_course_settings(course_key, settings):
2829
Expects: settings to be a dictionary of the form:
2930
`{'enabled': bool}`
3031
31-
Raises NotFoundError if the settings are not found.
32+
Raises AiAsideNotFoundException if the settings are not found.
3233
"""
3334
enabled = settings['enabled']
3435

@@ -47,7 +48,7 @@ def delete_course_settings(course_key):
4748
"""
4849
Deletes the settings of a course.
4950
50-
Raises NotFoundError if the settings are not found.
51+
Raises AiAsideNotFoundException if the settings are not found.
5152
"""
5253
reset_course_unit_settings(course_key)
5354
record = _get_course(course_key)
@@ -84,7 +85,7 @@ def set_unit_settings(course_key, unit_key, settings):
8485
Expects: settings as a dictionary of the form:
8586
`{'enabled': bool}`
8687
87-
Raises NotFoundError if the settings are not found.
88+
Raises AiAsideNotFoundException if the settings are not found.
8889
"""
8990
enabled = settings['enabled']
9091

@@ -104,7 +105,7 @@ def delete_unit_settings(course_key, unit_key):
104105
"""
105106
Deletes the settings of a unit.
106107
107-
Raises NotFoundError if the settings are not found.
108+
Raises AiAsideNotFoundException if the settings are not found.
108109
"""
109110
record = _get_unit(course_key, unit_key)
110111
record.delete()
@@ -127,7 +128,7 @@ def is_course_settings_present(course_key):
127128
try:
128129
course = _get_course(course_key)
129130
return course is not None
130-
except NotFoundError:
131+
except AiAsideNotFoundException:
131132
return False
132133

133134

@@ -147,12 +148,12 @@ def is_summary_enabled(course_key, unit_key=None):
147148

148149
if unit is not None:
149150
return unit.enabled
150-
except NotFoundError:
151+
except AiAsideNotFoundException:
151152
pass
152153

153154
try:
154155
course = _get_course(course_key)
155-
except NotFoundError:
156+
except AiAsideNotFoundException:
156157
return False
157158

158159
if course is not None:

ai_aside/config_api/exceptions.py

+18
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
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
12+
13+
14+
class AiAsideNotFoundException(AiAsideException):
15+
"""
16+
A 404 exception class
17+
"""
18+
http_status = status.HTTP_404_NOT_FOUND

ai_aside/config_api/internal.py

+5-8
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,18 @@
11
"""
22
Internal methods for the API.
33
"""
4+
from ai_aside.config_api.exceptions import AiAsideNotFoundException
45
from ai_aside.models import AIAsideCourseEnabled, AIAsideUnitEnabled
56

67

7-
class NotFoundError(Exception):
8-
"Raised when the course/unit is not found in the database"
9-
10-
118
def _get_course(course_key):
129
"Private method that gets a course based on an id"
1310
try:
1411
record = AIAsideCourseEnabled.objects.get(
1512
course_key=course_key,
1613
)
17-
except AIAsideCourseEnabled.DoesNotExist as exc:
18-
raise NotFoundError from exc
14+
except AIAsideCourseEnabled.DoesNotExist as error:
15+
raise AiAsideNotFoundException from error
1916

2017
return record
2118

@@ -27,8 +24,8 @@ def _get_unit(course_key, unit_key):
2724
course_key=course_key,
2825
unit_key=unit_key,
2926
)
30-
except AIAsideUnitEnabled.DoesNotExist as exc:
31-
raise NotFoundError from exc
27+
except AIAsideUnitEnabled.DoesNotExist as error:
28+
raise AiAsideNotFoundException from error
3229

3330
return record
3431

ai_aside/config_api/permissions.py

+27
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
""" Permissions for ai-aside API"""
2+
3+
from rest_framework.permissions import BasePermission
4+
5+
from ai_aside.config_api.validators 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+
18+
if not request.user.is_authenticated:
19+
return False
20+
21+
if not request.user.is_active:
22+
return False
23+
24+
course_key_string = view.kwargs.get('course_id')
25+
course_key = validate_course_key(course_key_string)
26+
27+
return can_change_summaries_settings(request.user, course_key)

ai_aside/config_api/validators.py

+32
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
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+
8+
from ai_aside.config_api.exceptions import AiAsideException
9+
10+
11+
def validate_course_key(course_key_string: str) -> CourseKey:
12+
"""
13+
Validate and parse a course_key string, if supported.
14+
"""
15+
try:
16+
course_key = CourseKey.from_string(course_key_string)
17+
except InvalidKeyError as error:
18+
raise AiAsideException(f"{course_key_string} is not a valid CourseKey") from error
19+
if course_key.deprecated:
20+
raise AiAsideException("Deprecated CourseKeys (Org/Course/Run) are not supported.")
21+
return course_key
22+
23+
24+
def validate_unit_key(unit_key_string: str) -> UsageKey:
25+
"""
26+
Validate and parse a unit_key string, if supported.
27+
"""
28+
try:
29+
usage_key = UsageKey.from_string(unit_key_string)
30+
except InvalidKeyError as error:
31+
raise AiAsideException(f"{unit_key_string} is not a valid UsageKey") from error
32+
return usage_key

ai_aside/config_api/view_utils.py

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

0 commit comments

Comments
 (0)