Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: [AXM-644] Add authorization via cms worker for content generation view #2576

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions cms/djangoapps/contentstore/signals/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@


import logging
import requests
from datetime import datetime, timezone
from functools import wraps
from typing import Optional
Expand All @@ -23,6 +22,7 @@
CoursewareSearchIndexer,
LibrarySearchIndexer,
)
from cms.djangoapps.contentstore.utils import get_cms_api_client
from common.djangoapps.track.event_transaction_utils import get_event_transaction_id, get_event_transaction_type
from common.djangoapps.util.block_utils import yield_dynamic_block_descendants
from lms.djangoapps.grades.api import task_compute_all_grades_for_course
Expand Down Expand Up @@ -160,7 +160,8 @@ def listen_for_course_publish(sender, course_key, **kwargs): # pylint: disable=
transaction.on_commit(lambda: emit_catalog_info_changed_signal(course_key))

if is_offline_mode_enabled(course_key):
requests.post(
client = get_cms_api_client()
client.post(
url=urljoin(settings.LMS_ROOT_URL, LMS_OFFLINE_HANDLER_URL),
data={'course_id': str(course_key)},
)
Expand Down
17 changes: 17 additions & 0 deletions cms/djangoapps/contentstore/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,20 @@
import configparser
import logging
import re
import requests
from collections import defaultdict
from contextlib import contextmanager
from datetime import datetime, timezone
from urllib.parse import quote_plus
from uuid import uuid4

from django.conf import settings
from django.contrib.auth import get_user_model
from django.core.exceptions import ValidationError
from django.urls import reverse
from django.utils import translation
from django.utils.translation import gettext as _
from edx_rest_api_client.auth import SuppliedJwtAuth
from eventtracking import tracker
from help_tokens.core import HelpUrlExpert
from lti_consumer.models import CourseAllowPIISharingInLTIFlag
Expand Down Expand Up @@ -67,6 +70,7 @@
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
from openedx.core.djangoapps.site_configuration.models import SiteConfiguration
from openedx.core.djangoapps.models.course_details import CourseDetails
from openedx.core.djangoapps.oauth_dispatch.jwt import create_jwt_for_user
from openedx.core.lib.courses import course_image_url
from openedx.core.lib.html_to_text import html_to_text
from openedx.features.content_type_gating.models import ContentTypeGatingConfig
Expand Down Expand Up @@ -107,6 +111,7 @@

IMPORTABLE_FILE_TYPES = ('.tar.gz', '.zip')
log = logging.getLogger(__name__)
User = get_user_model()


def add_instructor(course_key, requesting_user, new_instructor):
Expand Down Expand Up @@ -2317,3 +2322,15 @@ def get_xblock_render_context(request, block):
return str(exc)

return ""


def get_cms_api_client():
"""
Returns an API client which can be used to make requests from the CMS service.
"""
user = User.objects.get(username=settings.EDXAPP_CMS_SERVICE_USER_NAME)
jwt = create_jwt_for_user(user)
client = requests.Session()
client.auth = SuppliedJwtAuth(jwt)

return client
2 changes: 2 additions & 0 deletions cms/envs/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -2525,6 +2525,8 @@
EXAMS_SERVICE_URL = 'http://localhost:18740/api/v1'
EXAMS_SERVICE_USERNAME = 'edx_exams_worker'

CMS_SERVICE_USER_NAME = 'edxapp_cms_worker'

FINANCIAL_REPORTS = {
'STORAGE_TYPE': 'localfs',
'BUCKET': None,
Expand Down
3 changes: 3 additions & 0 deletions cms/envs/production.py
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,9 @@ def get_env_setting(setting):
AUTHORING_API_URL = ENV_TOKENS.get('AUTHORING_API_URL', '')
# Note that FEATURES['PREVIEW_LMS_BASE'] gets read in from the environment file.

CMS_SERVICE_USER_NAME = ENV_TOKENS.get('CMS_SERVICE_USER_NAME', CMS_SERVICE_USER_NAME)


OPENAI_API_KEY = ENV_TOKENS.get('OPENAI_API_KEY', '')
LEARNER_ENGAGEMENT_PROMPT_FOR_ACTIVE_CONTRACT = ENV_TOKENS.get('LEARNER_ENGAGEMENT_PROMPT_FOR_ACTIVE_CONTRACT', '')
LEARNER_ENGAGEMENT_PROMPT_FOR_NON_ACTIVE_CONTRACT = ENV_TOKENS.get(
Expand Down
2 changes: 1 addition & 1 deletion lms/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -1055,5 +1055,5 @@
]

urlpatterns += [
path('offline_mode/', include('openedx.features.offline_mode.urls')),
path('offline_mode/', include('openedx.features.offline_mode.urls', namespace='offline_mode')),
]
2 changes: 1 addition & 1 deletion openedx/features/offline_mode/assets_management.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ def save_asset_file(temp_dir, xblock, path, filename):

def create_subdirectories_for_asset(file_path):
"""
Creates subdirectories for asset.
Creates the subdirectories for the asset file path if they do not exist.
"""
out_dir_name = '/'
for dir_name in file_path.split('/')[:-1]:
Expand Down
2 changes: 1 addition & 1 deletion openedx/features/offline_mode/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
MATHJAX_CDN_URL = f'https://cdn.jsdelivr.net/npm/mathjax@{MATHJAX_VERSION}/MathJax.js'
MATHJAX_STATIC_PATH = os.path.join('assets', 'js', f'MathJax-{MATHJAX_VERSION}.js')

DEFAULT_OFFLINE_SUPPORTED_XBLOCKS = ['html']
DEFAULT_OFFLINE_SUPPORTED_XBLOCKS = ['html', 'problem']
OFFLINE_SUPPORTED_XBLOCKS = getattr(settings, 'OFFLINE_SUPPORTED_XBLOCKS', DEFAULT_OFFLINE_SUPPORTED_XBLOCKS)

RETRY_BACKOFF_INITIAL_TIMEOUT = 60 # one minute
Expand Down
Empty file.
83 changes: 83 additions & 0 deletions openedx/features/offline_mode/tests/test_views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
"""
Tests for view that handles course published event.
"""
from unittest.mock import patch

from django.test import TestCase, RequestFactory
from django.test.client import Client
from django.urls import reverse
from edx_toggles.toggles.testutils import override_waffle_flag

from common.djangoapps.student.tests.factories import UserFactory
from openedx.features.offline_mode.views import SudioCoursePublishedEventHandler

from openedx.features.offline_mode.toggles import ENABLE_OFFLINE_MODE


class TestSudioCoursePublishedEventHandler(TestCase):
"""
Tests for the SudioCoursePublishedEventHandler view.
"""

def setUp(self):
self.client = Client()
self.factory = RequestFactory()
self.view = SudioCoursePublishedEventHandler.as_view()
self.url = reverse('offline_mode:handle_course_published')

self.user_password = 'Password1234'
self.user = UserFactory.create(password=self.user_password)
self.staff_user = UserFactory.create(is_staff=True, password=self.user_password)

def staff_login(self):
self.client.login(username=self.staff_user.username, password=self.user_password)

def test_unauthorized(self):
response = self.client.post(self.url, {})
self.assertEqual(response.status_code, 401)
self.assertEqual(response.data, {'detail': 'Authentication credentials were not provided.'})

def test_not_admin(self):
self.client.login(username=self.user.username, password=self.user_password)
response = self.client.post(self.url, {})
self.assertEqual(response.status_code, 403)
self.assertEqual(response.data, {'detail': 'You do not have permission to perform this action.'})

@override_waffle_flag(ENABLE_OFFLINE_MODE, active=True)
@patch('openedx.features.offline_mode.views.generate_offline_content_for_course.apply_async')
def test_admin_enabled_waffle_flag(self, mock_generate_offline_content_for_course_task):
self.staff_login()
course_id = 'course-v1:edX+DemoX+Demo_Course'
response = self.client.post(self.url, {'course_id': course_id})

self.assertEqual(response.status_code, 200)
self.assertEqual(response.data, None)
mock_generate_offline_content_for_course_task.assert_called_once_with(args=[course_id])

@override_waffle_flag(ENABLE_OFFLINE_MODE, active=False)
def test_admin_disabled_waffle_flag(self):
self.staff_login()
response = self.client.post(self.url, {'course_id': 'course-v1:edX+DemoX+Demo_Course'})

self.assertEqual(response.status_code, 400)
self.assertEqual(response.data, {'error': 'Offline mode is not enabled for this course'})

@override_waffle_flag(ENABLE_OFFLINE_MODE, active=True)
def test_admin_enabled_waffle_flag_no_course_id(self):
self.staff_login()
response = self.client.post(self.url, {})
self.assertEqual(response.status_code, 400)
self.assertEqual(response.data, {'error': 'course_id is required'})

@override_waffle_flag(ENABLE_OFFLINE_MODE, active=False)
def test_admin_disabled_waffle_flag_no_course_id(self):
self.staff_login()
response = self.client.post(self.url, {})
self.assertEqual(response.status_code, 400)
self.assertEqual(response.data, {'error': 'course_id is required'})

def test_invalid_course_id(self):
self.staff_login()
response = self.client.post(self.url, {'course_id': 'invalid_course_id'})
self.assertEqual(response.status_code, 400)
self.assertEqual(response.data, {'error': 'Invalid course_id'})
1 change: 1 addition & 0 deletions openedx/features/offline_mode/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

from .views import SudioCoursePublishedEventHandler

app_name = 'offline_mode'
urlpatterns = [
path('handle_course_published', SudioCoursePublishedEventHandler.as_view(), name='handle_course_published'),
]
18 changes: 16 additions & 2 deletions openedx/features/offline_mode/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,15 @@
Views for the offline_mode app.
"""
from opaque_keys.edx.keys import CourseKey
from opaque_keys import InvalidKeyError
from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication
from rest_framework import status
from rest_framework.authentication import SessionAuthentication
from rest_framework.permissions import IsAdminUser
from rest_framework.response import Response
from rest_framework.views import APIView

from openedx.core.lib.api.authentication import BearerAuthentication
from .tasks import generate_offline_content_for_course
from .toggles import is_offline_mode_enabled

Expand All @@ -18,6 +23,9 @@ class SudioCoursePublishedEventHandler(APIView):
and it triggers the generation of offline content.
"""

authentication_classes = (JwtAuthentication, BearerAuthentication, SessionAuthentication)
permission_classes = (IsAdminUser,)

def post(self, request, *args, **kwargs):
"""
Trigger the generation of offline content task.
Expand All @@ -30,14 +38,20 @@ def post(self, request, *args, **kwargs):
Returns:
Response: The response object.
"""

course_id = request.data.get('course_id')
if not course_id:
return Response(
data={'error': 'course_id is required'},
status=status.HTTP_400_BAD_REQUEST
)
course_key = CourseKey.from_string(course_id)
try:
course_key = CourseKey.from_string(course_id)
except InvalidKeyError:
return Response(
data={'error': 'Invalid course_id'},
status=status.HTTP_400_BAD_REQUEST
)

if is_offline_mode_enabled(course_key):
generate_offline_content_for_course.apply_async(args=[course_id])
return Response(status=status.HTTP_200_OK)
Expand Down
Loading