diff --git a/openedx/core/djangoapps/course_live/permissions.py b/openedx/core/djangoapps/course_live/permissions.py index 0415eae5d3..6ee49c02d5 100644 --- a/openedx/core/djangoapps/course_live/permissions.py +++ b/openedx/core/djangoapps/course_live/permissions.py @@ -3,6 +3,7 @@ API library for Django REST Framework permissions-oriented workflows """ from rest_framework.permissions import BasePermission +from common.djangoapps.student.models import CourseEnrollment from common.djangoapps.student.roles import CourseInstructorRole, CourseStaffRole, GlobalStaff from openedx.core.lib.api.view_utils import validate_course_key @@ -26,3 +27,22 @@ class IsStaffOrInstructor(BasePermission): CourseInstructorRole(course_key).has_user(request.user) or CourseStaffRole(course_key).has_user(request.user) ) + + +class IsEnrolledOrStaff(BasePermission): + """ + Check if user is enrolled in the course or staff + """ + + 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) or + CourseEnrollment.is_enrolled(request.user, course_key) + ) diff --git a/openedx/core/djangoapps/course_live/tab.py b/openedx/core/djangoapps/course_live/tab.py index 29f97eb8c9..c896828e9b 100644 --- a/openedx/core/djangoapps/course_live/tab.py +++ b/openedx/core/djangoapps/course_live/tab.py @@ -22,6 +22,11 @@ class CourseLiveTab(LtiCourseLaunchMixin, TabFragmentViewMixin, EnrolledTab): allow_multiple = False is_dynamic = True title = gettext_lazy("Live") + ROLE_MAP = { + 'student': 'Student', + 'staff': 'Administrator', + 'instructor': 'Administrator', + } @request_cached() def _get_lti_config(self, course: CourseBlock) -> LtiConfiguration: diff --git a/openedx/core/djangoapps/course_live/tests/test_views.py b/openedx/core/djangoapps/course_live/tests/test_views.py index dbc4bdd304..6b2f84b4bd 100644 --- a/openedx/core/djangoapps/course_live/tests/test_views.py +++ b/openedx/core/djangoapps/course_live/tests/test_views.py @@ -3,9 +3,11 @@ Test for course live app views """ import json +from django.test import RequestFactory from django.urls import reverse from edx_toggles.toggles.testutils import override_waffle_flag -from lti_consumer.models import CourseAllowPIISharingInLTIFlag +from lti_consumer.models import CourseAllowPIISharingInLTIFlag, LtiConfiguration +from markupsafe import Markup from rest_framework.test import APITestCase from xmodule.modulestore import ModuleStoreEnum from xmodule.modulestore.tests.django_utils import CourseUserType, ModuleStoreTestCase @@ -266,3 +268,80 @@ class TestCourseLiveProvidersView(ModuleStoreTestCase, APITestCase): response = self.client.get(self.url) content = json.loads(response.content.decode('utf-8')) self.assertEqual(content, expected_data) + + +class TestCourseLiveIFrameView(ModuleStoreTestCase, APITestCase): + """ + Unit tests for course live iframe 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 course live iframe API url. + """ + return reverse( + 'live_iframe', kwargs={'course_id': str(self.course.id)} + ) + + def test_api_returns_live_iframe(self): + request = RequestFactory().get(self.url) + request.user = self.user + live_config = CourseLiveConfiguration.objects.create( + course_key=self.course.id, + enabled=True, + provider_type="zoom", + ) + live_config.lti_configuration = LtiConfiguration.objects.create( + config_store=LtiConfiguration.CONFIG_ON_DB, + lti_config={ + "pii_share_username": 'true', + "pii_share_email": 'true', + "additional_parameters": { + "custom_instructor_email": "test@gmail.com" + } + }, + lti_1p1_launch_url='http://test.url', + lti_1p1_client_key='test_client_key', + lti_1p1_client_secret='test_client_secret', + ) + live_config.save() + with override_waffle_flag(ENABLE_COURSE_LIVE, True): + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200) + self.assertIsInstance(response.data['iframe'], Markup) + self.assertIn('iframe', str(response.data['iframe'])) + + def test_non_authenticated_user(self): + """ + Verify that 401 is returned if user is not authenticated. + """ + self.client.logout() + response = self.client.get(self.url) + self.assertEqual(response.status_code, 401) + + def test_not_enrolled_user(self): + """ + Verify that 403 is returned if user is not enrolled. + """ + self.user = self.create_user_for_course(self.course, user_type=CourseUserType.UNENROLLED) + response = self.client.get(self.url) + self.assertEqual(response.status_code, 403) + + def test_live_configuration_disabled(self): + """ + Verify that proper error message is returned if live configuration is disabled. + """ + CourseLiveConfiguration.objects.create( + course_key=self.course.id, + enabled=False, + provider_type="zoom", + ) + response = self.client.get(self.url) + self.assertEqual(response.data['developer_message'], 'Course live is not enabled for this course.') diff --git a/openedx/core/djangoapps/course_live/urls.py b/openedx/core/djangoapps/course_live/urls.py index 62dc06a2b6..e8fa2fc1d5 100644 --- a/openedx/core/djangoapps/course_live/urls.py +++ b/openedx/core/djangoapps/course_live/urls.py @@ -6,11 +6,17 @@ 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 +from openedx.core.djangoapps.course_live.views import ( + CourseLiveConfigurationView, + CourseLiveIframeView, + 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'), + re_path(fr'^iframe/{settings.COURSE_ID_PATTERN}/?$', + CourseLiveIframeView.as_view(), name='live_iframe'), ] diff --git a/openedx/core/djangoapps/course_live/views.py b/openedx/core/djangoapps/course_live/views.py index 5ecc8ad3e1..b5041c533f 100644 --- a/openedx/core/djangoapps/course_live/views.py +++ b/openedx/core/djangoapps/course_live/views.py @@ -7,13 +7,17 @@ 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 opaque_keys.edx.keys import CourseKey +from rest_framework import permissions, status 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 lms.djangoapps.courseware.courses import get_course_with_access +from openedx.core.djangoapps.course_live.permissions import IsEnrolledOrStaff, IsStaffOrInstructor +from openedx.core.djangoapps.course_live.tab import CourseLiveTab from openedx.core.lib.api.authentication import BearerAuthenticationAllowInactiveUser from ...lib.api.view_utils import verify_course_exists @@ -207,3 +211,70 @@ class CourseLiveProvidersView(APIView): "available": AVAILABLE_PROVIDERS } } + + +class CourseLiveIframeView(APIView): + """ + A view for retrieving course live iFrame. + + Path: ``api/course_live/iframe/{course_id}/`` + + Accepts: [GET] + + ------------------------------------------------------------------------------------ + GET + ------------------------------------------------------------------------------------ + + **Returns** + + * 200: OK - Contains a course live zoom iframe. + * 401: The requesting user is not authenticated. + * 403: The requesting user lacks access to the course. + * 404: The requested course does not exist. + + **Response** + + In the case of a 200 response code, the response will be iframe HTML. + + **Example** + + { + "iframe": " + + ", + } + + """ + authentication_classes = ( + JwtAuthentication, + BearerAuthenticationAllowInactiveUser, + SessionAuthenticationAllowInactiveUser + ) + permission_classes = (permissions.IsAuthenticated, IsEnrolledOrStaff) + + @ensure_valid_course_key + @verify_course_exists() + def get(self, request, course_id: str, **_kwargs) -> Response: + """ + Handle HTTP/GET requests + """ + course_key = CourseKey.from_string(course_id) + course_live_tab = CourseLiveTab({}) + course = get_course_with_access(request.user, 'load', course_key) + + if not course_live_tab.is_enabled(course, request.user): + error_data = { + "developer_message": "Course live is not enabled for this course." + } + return Response(error_data, status=status.HTTP_200_OK) + + iframe = course_live_tab.render_to_fragment(request, course) + data = { + "iframe": iframe.content + } + return Response(data, status=status.HTTP_200_OK)