From f9869c3378bc538db6aa44e4956286c54a4f09ec Mon Sep 17 00:00:00 2001 From: Ahtisham Shahid Date: Thu, 10 Mar 2022 13:03:58 +0500 Subject: [PATCH] feat: Added Api to create/retrieve course live configurations (#30012) --- .github/workflows/unit-test-shards.json | 1 + openedx/core/djangoapps/course_live/apps.py | 18 +- .../djangoapps/course_live/config/waffle.py | 1 - openedx/core/djangoapps/course_live/models.py | 9 +- .../djangoapps/course_live/permissions.py | 28 +++ .../core/djangoapps/course_live/plugins.py | 1 + .../djangoapps/course_live/serializers.py | 159 +++++++++++++++ .../djangoapps/course_live/tests/__init__.py | 0 .../course_live/tests/test_views.py | 189 +++++++++++++++++ openedx/core/djangoapps/course_live/urls.py | 16 ++ openedx/core/djangoapps/course_live/views.py | 191 ++++++++++++++++++ 11 files changed, 610 insertions(+), 3 deletions(-) create mode 100644 openedx/core/djangoapps/course_live/permissions.py create mode 100644 openedx/core/djangoapps/course_live/serializers.py create mode 100644 openedx/core/djangoapps/course_live/tests/__init__.py create mode 100644 openedx/core/djangoapps/course_live/tests/test_views.py create mode 100644 openedx/core/djangoapps/course_live/urls.py create mode 100644 openedx/core/djangoapps/course_live/views.py diff --git a/.github/workflows/unit-test-shards.json b/.github/workflows/unit-test-shards.json index a30423815e..f7cd11bf2c 100644 --- a/.github/workflows/unit-test-shards.json +++ b/.github/workflows/unit-test-shards.json @@ -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/", diff --git a/openedx/core/djangoapps/course_live/apps.py b/openedx/core/djangoapps/course_live/apps.py index 2a83c86aa2..81932e0a9b 100644 --- a/openedx/core/djangoapps/course_live/apps.py +++ b/openedx/core/djangoapps/course_live/apps.py @@ -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', + }, + } + } diff --git a/openedx/core/djangoapps/course_live/config/waffle.py b/openedx/core/djangoapps/course_live/config/waffle.py index 02065787f7..d2c05196f3 100644 --- a/openedx/core/djangoapps/course_live/config/waffle.py +++ b/openedx/core/djangoapps/course_live/config/waffle.py @@ -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 diff --git a/openedx/core/djangoapps/course_live/models.py b/openedx/core/djangoapps/course_live/models.py index dd8153634a..238f181187 100644 --- a/openedx/core/djangoapps/course_live/models.py +++ b/openedx/core/djangoapps/course_live/models.py @@ -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): diff --git a/openedx/core/djangoapps/course_live/permissions.py b/openedx/core/djangoapps/course_live/permissions.py new file mode 100644 index 0000000000..0415eae5d3 --- /dev/null +++ b/openedx/core/djangoapps/course_live/permissions.py @@ -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) + ) diff --git a/openedx/core/djangoapps/course_live/plugins.py b/openedx/core/djangoapps/course_live/plugins.py index 5a7d01ccfb..c569ee7335 100644 --- a/openedx/core/djangoapps/course_live/plugins.py +++ b/openedx/core/djangoapps/course_live/plugins.py @@ -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() diff --git a/openedx/core/djangoapps/course_live/serializers.py b/openedx/core/djangoapps/course_live/serializers.py new file mode 100644 index 0000000000..413be828d0 --- /dev/null +++ b/openedx/core/djangoapps/course_live/serializers.py @@ -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 diff --git a/openedx/core/djangoapps/course_live/tests/__init__.py b/openedx/core/djangoapps/course_live/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openedx/core/djangoapps/course_live/tests/test_views.py b/openedx/core/djangoapps/course_live/tests/test_views.py new file mode 100644 index 0000000000..74cf626d73 --- /dev/null +++ b/openedx/core/djangoapps/course_live/tests/test_views.py @@ -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) diff --git a/openedx/core/djangoapps/course_live/urls.py b/openedx/core/djangoapps/course_live/urls.py new file mode 100644 index 0000000000..5d46c17701 --- /dev/null +++ b/openedx/core/djangoapps/course_live/urls.py @@ -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'), +] diff --git a/openedx/core/djangoapps/course_live/views.py b/openedx/core/djangoapps/course_live/views.py new file mode 100644 index 0000000000..27e3c9242d --- /dev/null +++ b/openedx/core/djangoapps/course_live/views.py @@ -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 + } + }