feat: Added Api to create/retrieve course live configurations (#30012)

This commit is contained in:
Ahtisham Shahid
2022-03-10 13:03:58 +05:00
committed by GitHub
parent 60cf4d7ded
commit f9869c3378
11 changed files with 610 additions and 3 deletions

View File

@@ -104,6 +104,7 @@
"openedx/core/djangoapps/crawlers/",
"openedx/core/djangoapps/credentials/",
"openedx/core/djangoapps/credit/",
"openedx/core/djangoapps/course_live/",
"openedx/core/djangoapps/dark_lang/",
"openedx/core/djangoapps/debug/",
"openedx/core/djangoapps/demographics/",

View File

@@ -2,6 +2,9 @@
Configure the django app
"""
from django.apps import AppConfig
from edx_django_utils.plugins import PluginURLs
from openedx.core.djangoapps.plugins.constants import ProjectType
class CourseLiveConfig(AppConfig):
@@ -11,4 +14,17 @@ class CourseLiveConfig(AppConfig):
name = "openedx.core.djangoapps.course_live"
plugin_app = {}
plugin_app = {
PluginURLs.CONFIG: {
ProjectType.LMS: {
PluginURLs.NAMESPACE: '',
PluginURLs.REGEX: r'^api/course_live/',
PluginURLs.RELATIVE_PATH: 'urls',
},
ProjectType.CMS: {
PluginURLs.NAMESPACE: '',
PluginURLs.REGEX: r'^api/course_live/',
PluginURLs.RELATIVE_PATH: 'urls',
},
}
}

View File

@@ -7,7 +7,6 @@ from edx_toggles.toggles import LegacyWaffleFlagNamespace
from openedx.core.djangoapps.waffle_utils import CourseWaffleFlag
WAFFLE_NAMESPACE = LegacyWaffleFlagNamespace(name='course_live')
# .. toggle_name: course_live.enable_course_live

View File

@@ -3,10 +3,17 @@ Models course live integrations.
"""
from django.db import models
from django.utils.translation import gettext_lazy as _
from simple_history.models import HistoricalRecords
from lti_consumer.models import LtiConfiguration
from model_utils.models import TimeStampedModel
from opaque_keys.edx.django.models import CourseKeyField
from simple_history.models import HistoricalRecords
AVAILABLE_PROVIDERS = {
'zoom': {
'name': 'Zoom LTI PRO',
'features': []
}
}
class CourseLiveConfiguration(TimeStampedModel):

View File

@@ -0,0 +1,28 @@
"""
API library for Django REST Framework permissions-oriented workflows
"""
from rest_framework.permissions import BasePermission
from common.djangoapps.student.roles import CourseInstructorRole, CourseStaffRole, GlobalStaff
from openedx.core.lib.api.view_utils import validate_course_key
class IsStaffOrInstructor(BasePermission):
"""
Check if user is global or course staff
Permission that checks to see if the user is global staff, course
staff, course admin,If none of those conditions are met, HTTP403 is returned.
"""
def has_permission(self, request, view):
course_key_string = view.kwargs.get('course_id')
course_key = validate_course_key(course_key_string)
if GlobalStaff().has_user(request.user):
return True
return (
CourseInstructorRole(course_key).has_user(request.user) or
CourseStaffRole(course_key).has_user(request.user)
)

View File

@@ -9,6 +9,7 @@ from opaque_keys.edx.keys import CourseKey
from openedx.core.djangoapps.course_apps.plugins import CourseApp
from openedx.core.djangoapps.course_live.config.waffle import ENABLE_COURSE_LIVE
from .models import CourseLiveConfiguration
User = get_user_model()

View File

@@ -0,0 +1,159 @@
"""
Serializers for course live views.
"""
from django.core.exceptions import ValidationError
from django.core.validators import validate_email
from lti_consumer.models import LtiConfiguration
from rest_framework import serializers
from .models import AVAILABLE_PROVIDERS, CourseLiveConfiguration
class LtiSerializer(serializers.ModelSerializer):
"""
Serialize LtiConfiguration responses
"""
lti_config = serializers.JSONField()
class Meta:
model = LtiConfiguration
fields = [
'lti_1p1_client_key',
'lti_1p1_client_secret',
'lti_1p1_launch_url',
'version',
'lti_config'
]
read_only = [
'version'
]
def validate_lti_config(self, value):
"""
Validates if lti_config contains all required data i.e. custom_instructor_email
"""
additional_parameters = value.get('additional_parameters', None)
custom_instructor_email = additional_parameters.get('custom_instructor_email', None)
if additional_parameters and custom_instructor_email:
try:
validate_email(custom_instructor_email)
except ValidationError as error:
raise serializers.ValidationError(f'{custom_instructor_email} is not valid email address') from error
return value
raise serializers.ValidationError('custom_instructor_email is required value in additional_parameters')
def create(self, validated_data):
lti_config = validated_data.pop('lti_config', None)
instance = LtiConfiguration()
instance.version = 'lti_1p1'
for key, value in validated_data.items():
if key in set(self.Meta.fields).difference(self.Meta.read_only):
setattr(instance, key, value)
pii_sharing_allowed = self.context.get('pii_sharing_allowed', False)
instance.lti_config = {
"pii_share_username": pii_sharing_allowed,
"pii_share_email": pii_sharing_allowed,
"additional_parameters": lti_config['additional_parameters']
}
instance.save()
return instance
def update(self, instance: LtiConfiguration, validated_data: dict) -> LtiConfiguration:
"""
Create/update a model-backed instance
"""
instance.config_store = LtiConfiguration.CONFIG_ON_DB
lti_config = validated_data.pop('lti_config', None)
if lti_config.get('additional_parameters', None):
instance.lti_config['additional_parameters'] = lti_config.get('additional_parameters')
if validated_data:
for key, value in validated_data.items():
if key in self.Meta.fields:
setattr(instance, key, value)
pii_sharing_allowed = self.context.get('pii_sharing_allowed', False)
instance.pii_share_username = pii_sharing_allowed
instance.pii_share_email = pii_sharing_allowed
instance.save()
return instance
class CourseLiveConfigurationSerializer(serializers.ModelSerializer):
"""
Serialize configuration responses
"""
lti_configuration = LtiSerializer(many=False, read_only=False)
class Meta:
model = CourseLiveConfiguration
fields = ['course_key', 'provider_type', 'enabled', 'lti_configuration']
read_only_fields = ['course_key']
def create(self, validated_data):
"""
Create a new CourseLiveConfiguration entry in model
"""
lti_config = validated_data.pop('lti_configuration')
instance = CourseLiveConfiguration()
instance = self._update_course_live_instance(instance, validated_data)
instance = self._update_lti(instance, lti_config)
instance.save()
return instance
def update(self, instance: CourseLiveConfiguration, validated_data: dict) -> CourseLiveConfiguration:
"""
Update and save an existing instance
"""
lti_config = validated_data.pop('lti_configuration')
instance = self._update_course_live_instance(instance, validated_data)
instance = self._update_lti(instance, lti_config)
instance.save()
return instance
def _update_course_live_instance(self, instance: CourseLiveConfiguration, data: dict) -> CourseLiveConfiguration:
"""
Adds data to courseLiveConfiguration model instance
"""
instance.course_key = self.context.get('course_id')
instance.enabled = self.validated_data.get('enabled', False)
if data.get('provider_type') in AVAILABLE_PROVIDERS:
instance.provider_type = data.get('provider_type')
else:
raise serializers.ValidationError(
f'Provider type {data.get("provider_type")} does not exist')
return instance
def to_representation(self, instance: CourseLiveConfiguration) -> dict:
"""
Serialize data into a dictionary, to be used as a response
"""
payload = super().to_representation(instance)
payload.update({'pii_sharing_allowed': self.context['pii_sharing_allowed']})
return payload
def _update_lti(
self,
instance: CourseLiveConfiguration,
lti_config: dict,
) -> CourseLiveConfiguration:
"""
Update LtiConfiguration
"""
lti_serializer = LtiSerializer(
instance.lti_configuration or None,
data=lti_config,
partial=True,
context={
'pii_sharing_allowed': self.context.get('pii_sharing_allowed', False),
}
)
if lti_serializer.is_valid(raise_exception=True):
lti_serializer.save()
instance.lti_configuration = lti_serializer.instance
return instance

View File

@@ -0,0 +1,189 @@
"""
Test for course live app views
"""
import json
from django.urls import reverse
from lti_consumer.models import CourseAllowPIISharingInLTIFlag
from rest_framework.test import APITestCase
from xmodule.modulestore import ModuleStoreEnum
from xmodule.modulestore.tests.django_utils import CourseUserType, ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory
from ..models import AVAILABLE_PROVIDERS, CourseLiveConfiguration
class TestCourseLiveConfigurationView(ModuleStoreTestCase, APITestCase):
"""
Unit tests for the CourseLiveConfigurationView.
"""
password = 'test'
def setUp(self):
super().setUp()
store = ModuleStoreEnum.Type.split
self.course = CourseFactory.create(default_store=store)
self.user = self.create_user_for_course(self.course, user_type=CourseUserType.GLOBAL_STAFF)
@property
def url(self):
"""Returns the course live API url. """
return reverse(
'course_live', kwargs={'course_id': str(self.course.id)}
)
def _get(self):
return self.client.get(self.url)
def _post(self, data):
return self.client.post(self.url, data, format='json')
def create_course_live_config(self):
"""
creates a courseLiveConfiguration
"""
CourseAllowPIISharingInLTIFlag.objects.create(course_id=self.course.id, enabled=True)
lti_config = {
'lti_1p1_client_key': 'this_is_key',
'lti_1p1_client_secret': 'this_is_secret',
'lti_1p1_launch_url': 'example.com',
'lti_config': {
'additional_parameters': {
'custom_instructor_email': "email@example.com"
}
},
}
course_live_config_data = {
'enabled': True,
'provider_type': 'zoom',
'lti_configuration': lti_config
}
response = self._post(course_live_config_data)
return lti_config, course_live_config_data, response
def test_pii_sharing_not_allowed(self):
"""
Test response if PII sharing is not allowed
"""
response = self._get()
self.assertEqual(response.status_code, 200)
expected_data = {'pii_sharing_allowed': False, 'message': 'PII sharing is not allowed on this course'}
self.assertEqual(response.data, expected_data)
def test_pii_sharing_is_allowed(self):
"""
Test response if PII sharing is allowed
"""
CourseAllowPIISharingInLTIFlag.objects.create(course_id=self.course.id, enabled=True)
response = self._get()
self.assertEqual(response.status_code, 200)
expected_data = {
'enabled': False,
'lti_configuration': {
'lti_1p1_client_key': '',
'lti_1p1_client_secret': '',
'lti_1p1_launch_url': '',
'lti_config': None,
'version': None
},
'provider_type': ''
}
content = json.loads(response.content.decode('utf-8'))
self.assertEqual(content, expected_data)
def test_create_configurations_data(self):
"""
Create and test courseLiveConfiguration data in database
"""
lti_config, data, response = self.create_course_live_config()
course_live_configurations = CourseLiveConfiguration.get(self.course.id)
lti_configuration = CourseLiveConfiguration.get(self.course.id).lti_configuration
self.assertEqual(self.course.id, course_live_configurations.course_key)
self.assertEqual(data['enabled'], course_live_configurations.enabled)
self.assertEqual(data['provider_type'], course_live_configurations.provider_type)
self.assertEqual(lti_config['lti_1p1_client_key'], lti_configuration.lti_1p1_client_key)
self.assertEqual(lti_config['lti_1p1_client_secret'], lti_configuration.lti_1p1_client_secret)
self.assertEqual(lti_config['lti_1p1_launch_url'], lti_configuration.lti_1p1_launch_url)
self.assertEqual({
'pii_share_username': True,
'pii_share_email': True,
'additional_parameters': {'custom_instructor_email': 'email@example.com'}
}, lti_configuration.lti_config)
self.assertEqual(response.status_code, 200)
def test_create_configurations_response(self):
"""
Create and test POST request response data
"""
lti_config, course_live_config_data, response = self.create_course_live_config()
expected_data = {
'course_key': str(self.course.id),
'enabled': True,
'pii_sharing_allowed': True,
'provider_type': 'zoom',
'lti_configuration': {
'lti_1p1_client_key': 'this_is_key',
'lti_1p1_client_secret': 'this_is_secret',
'lti_1p1_launch_url': 'example.com',
'version': 'lti_1p1',
'lti_config': {
'pii_share_email': True,
'pii_share_username': True,
'additional_parameters': {
'custom_instructor_email': 'email@example.com'
},
},
},
}
content = json.loads(response.content.decode('utf-8'))
self.assertEqual(response.status_code, 200)
self.assertEqual(content, expected_data)
def test_post_error_messages(self):
"""
Test all related validation messages are recived
"""
CourseAllowPIISharingInLTIFlag.objects.create(course_id=self.course.id, enabled=True)
response = self._post({})
content = json.loads(response.content.decode('utf-8'))
expected_data = {
'provider_type': ['This field is required.'],
'lti_configuration': ['This field is required.']
}
self.assertEqual(content, expected_data)
self.assertEqual(response.status_code, 400)
class TestCourseLiveProvidersView(ModuleStoreTestCase, APITestCase):
"""
Tests for course live provider view
"""
def setUp(self):
super().setUp()
store = ModuleStoreEnum.Type.split
self.course = CourseFactory.create(default_store=store)
self.user = self.create_user_for_course(self.course, user_type=CourseUserType.GLOBAL_STAFF)
@property
def url(self):
"""
Returns the live providers API url.
"""
return reverse(
'live_providers', kwargs={'course_id': str(self.course.id)}
)
def test_response_has_correct_data(self):
expected_data = {
'providers': {
'active': '',
'available': AVAILABLE_PROVIDERS
}
}
response = self.client.get(self.url)
content = json.loads(response.content.decode('utf-8'))
self.assertEqual(content, expected_data)

View File

@@ -0,0 +1,16 @@
"""
course live API URLs.
"""
from django.conf import settings
from django.urls import re_path
from openedx.core.djangoapps.course_live.views import CourseLiveConfigurationView, CourseLiveProvidersView
urlpatterns = [
re_path(fr'^course/{settings.COURSE_ID_PATTERN}/$',
CourseLiveConfigurationView.as_view(), name='course_live'),
re_path(fr'^providers/{settings.COURSE_ID_PATTERN}/$',
CourseLiveProvidersView.as_view(), name='live_providers'),
]

View File

@@ -0,0 +1,191 @@
"""
View for course live app
"""
from typing import Dict
import edx_api_doc_tools as apidocs
from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication
from edx_rest_framework_extensions.auth.session.authentication import SessionAuthenticationAllowInactiveUser
from lti_consumer.api import get_lti_pii_sharing_state_for_course
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.serializers import ValidationError
from rest_framework.views import APIView
from common.djangoapps.util.views import ensure_valid_course_key
from openedx.core.djangoapps.course_live.permissions import IsStaffOrInstructor
from openedx.core.lib.api.authentication import BearerAuthenticationAllowInactiveUser
from ...lib.api.view_utils import verify_course_exists
from .models import AVAILABLE_PROVIDERS, CourseLiveConfiguration
from .serializers import CourseLiveConfigurationSerializer
class CourseLiveConfigurationView(APIView):
"""
View for configuring CourseLive settings.
"""
authentication_classes = (
JwtAuthentication,
BearerAuthenticationAllowInactiveUser,
SessionAuthenticationAllowInactiveUser
)
permission_classes = (IsStaffOrInstructor,)
@apidocs.schema(
parameters=[
apidocs.path_parameter(
'course_id',
str,
description="The course for which to get provider list",
)
],
responses={
200: CourseLiveConfigurationSerializer,
401: "The requester is not authenticated.",
403: "The requester cannot access the specified course.",
404: "The requested course does not exist.",
},
)
@ensure_valid_course_key
@verify_course_exists()
def get(self, request: Request, course_id: str) -> Response:
"""
Handle HTTP/GET requests
"""
pii_sharing_allowed = get_lti_pii_sharing_state_for_course(course_id)
if not pii_sharing_allowed:
return Response({
"pii_sharing_allowed": pii_sharing_allowed,
"message": "PII sharing is not allowed on this course"
})
configuration = CourseLiveConfiguration.get(course_id)
serializer = CourseLiveConfigurationSerializer(configuration, context={
"pii_sharing_allowed": pii_sharing_allowed,
})
return Response(serializer.data)
@apidocs.schema(
parameters=[
apidocs.path_parameter(
'course_id',
str,
description="The course for which to get provider list",
),
apidocs.path_parameter(
'lti_1p1_client_key',
str,
description="The LTI provider's client key",
),
apidocs.path_parameter(
'lti_1p1_client_secret',
str,
description="The LTI provider's client secretL",
),
apidocs.path_parameter(
'lti_1p1_launch_url',
str,
description="The LTI provider's launch URL",
),
apidocs.path_parameter(
'provider_type',
str,
description="The LTI provider's launch URL",
),
apidocs.parameter(
'lti_config',
apidocs.ParameterLocation.QUERY,
object,
description="The lti_config object with required additional parameters ",
),
],
responses={
200: CourseLiveConfigurationSerializer,
400: "Required parameters are missing.",
401: "The requester is not authenticated.",
403: "The requester cannot access the specified course.",
404: "The requested course does not exist.",
},
)
@ensure_valid_course_key
@verify_course_exists()
def post(self, request, course_id: str) -> Response:
"""
Handle HTTP/POST requests
"""
pii_sharing_allowed = get_lti_pii_sharing_state_for_course(course_id)
if not pii_sharing_allowed:
return Response({
"pii_sharing_allowed": pii_sharing_allowed,
"message": "PII sharing is not allowed on this course"
})
configuration = CourseLiveConfiguration.get(course_id)
serializer = CourseLiveConfigurationSerializer(
configuration,
data=request.data,
context={
"pii_sharing_allowed": pii_sharing_allowed,
"course_id": course_id
}
)
if not serializer.is_valid():
raise ValidationError(serializer.errors)
serializer.save()
return Response(serializer.data)
class CourseLiveProvidersView(APIView):
"""
Read only view that lists details of LIVE providers available for a course.
"""
authentication_classes = (
JwtAuthentication,
BearerAuthenticationAllowInactiveUser,
SessionAuthenticationAllowInactiveUser
)
permission_classes = (IsStaffOrInstructor,)
@apidocs.schema(
parameters=[
apidocs.string_parameter(
'course_id',
apidocs.ParameterLocation.PATH,
description="The course for which to get provider list",
)
],
responses={
200: CourseLiveConfigurationSerializer,
401: "The requester is not authenticated.",
403: "The requester cannot access the specified course.",
404: "The requested course does not exist.",
},
)
@ensure_valid_course_key
@verify_course_exists()
def get(self, request, course_id: str, **_kwargs) -> Response:
"""
Handle HTTP/GET requests
"""
data = self.get_provider_data(course_id)
return Response(data)
@staticmethod
def get_provider_data(course_id: str) -> Dict:
"""
Get provider data for specified course
Args:
course_id (str): course key string
Returns:
Dict: course Live providers
"""
configuration = CourseLiveConfiguration.get(course_id)
return {
"providers": {
"active": configuration.provider_type if configuration else "",
"available": AVAILABLE_PROVIDERS
}
}