diff --git a/lms/djangoapps/learner_dashboard/config/waffle.py b/lms/djangoapps/learner_dashboard/config/waffle.py index 0809383a48..32922974b3 100644 --- a/lms/djangoapps/learner_dashboard/config/waffle.py +++ b/lms/djangoapps/learner_dashboard/config/waffle.py @@ -5,16 +5,35 @@ waffle switches for the learner_dashboard app. from edx_toggles.toggles import WaffleFlag -# .. toggle_name: learner_dashboard.enable_program_discussions +# .. toggle_name: learner_dashboard.enable_program_tab_view # .. toggle_implementation: WaffleFlag # .. toggle_default: False -# .. toggle_description: Waffle flag to enable new Program discussion experience for course. +# .. toggle_description: Waffle flag to enable new Program discussion experience in tab view for course. +# This flag is used to decide weather we need to render program data in "tab" view or simple view. +# In the new tab view, we have tabs like "journey", "live", "discussions" # .. toggle_use_cases: temporary, open_edx # .. toggle_creation_date: 2021-08-25 # .. toggle_target_removal_date: 2021-12-31 # .. toggle_warnings: When the flag is ON, the new experience for Program discussions will be enabled. # .. toggle_tickets: TNL-8434 -ENABLE_PROGRAM_DISCUSSIONS = WaffleFlag( - 'learner_dashboard.enable_program_discussions', +ENABLE_PROGRAM_TAB_VIEW = WaffleFlag( + 'learner_dashboard.enable_program_tab_view', + __name__, +) + + +# .. toggle_name: learner_dashboard.enable_masters_program_tab_view +# .. toggle_implementation: WaffleFlag +# .. toggle_default: False +# .. toggle_description: Waffle flag to enable new Masters Program discussion experience for masters program. +# This flag is used to decide weather we need to render master program data in "tab" view or simple view. +# In the new tab view, we have tabs like "journey", "live", "discussions" +# .. toggle_use_cases: temporary, open_edx +# .. toggle_creation_date: 2021-10-19 +# .. toggle_target_removal_date: 2021-12-31 +# .. toggle_warnings: When the flag is ON, the new tabbed experience for Master Program Page will be enabled. +# .. toggle_tickets: TNL-8434 +ENABLE_MASTERS_PROGRAM_TAB_VIEW = WaffleFlag( + 'learner_dashboard.enable_masters_program_tab_view', __name__, ) diff --git a/lms/djangoapps/learner_dashboard/permissions.py b/lms/djangoapps/learner_dashboard/permissions.py new file mode 100644 index 0000000000..44943b24dd --- /dev/null +++ b/lms/djangoapps/learner_dashboard/permissions.py @@ -0,0 +1,32 @@ +""" +Permissions for program discussion api +""" +from django.core.exceptions import ObjectDoesNotExist +from rest_framework import permissions, status +from rest_framework.exceptions import APIException + +from lms.djangoapps.program_enrollments.api import get_program_enrollment + + +class IsEnrolledInProgram(permissions.BasePermission): + """Permission that checks to see if the user is enrolled in the course or is staff.""" + def has_permission(self, request, view): + + """Returns true if the user is enrolled in program""" + if not view.program: + raise ProgramNotFound + + try: + get_program_enrollment(program_uuid=view.kwargs.get('program_uuid'), user=request.user) + except ObjectDoesNotExist: + return False + return True + + +class ProgramNotFound(APIException): + """ + custom exception class for Program not found error + """ + status_code = status.HTTP_404_NOT_FOUND + default_detail = 'Program not found for provided uuid' + default_code = 'program_not_found' diff --git a/lms/djangoapps/learner_dashboard/programs.py b/lms/djangoapps/learner_dashboard/programs.py index 88a68b23d4..b3dc1cb5bd 100644 --- a/lms/djangoapps/learner_dashboard/programs.py +++ b/lms/djangoapps/learner_dashboard/programs.py @@ -2,7 +2,6 @@ Fragments for rendering programs. """ - import json from django.contrib.sites.shortcuts import get_current_site @@ -10,12 +9,13 @@ from django.http import Http404 from django.template.loader import render_to_string from django.urls import reverse from django.utils.translation import gettext_lazy as _ # lint-amnesty, pylint: disable=unused-import +from lti_consumer.lti_1p1.contrib.django import lti_embed from web_fragments.fragment import Fragment from common.djangoapps.student.roles import GlobalStaff from lms.djangoapps.commerce.utils import EcommerceService -from lms.djangoapps.learner_dashboard.utils import FAKE_COURSE_KEY, strip_course_id, program_discussions_is_enabled -from lti_consumer.lti_1p1.contrib.django import lti_embed +from lms.djangoapps.learner_dashboard.utils import FAKE_COURSE_KEY, program_tab_view_is_enabled, strip_course_id + from openedx.core.djangoapps.catalog.constants import PathwayType from openedx.core.djangoapps.catalog.utils import get_pathways from openedx.core.djangoapps.credentials.utils import get_credentials_records_url @@ -75,93 +75,12 @@ class ProgramDetailsFragmentView(EdxFragmentView): """ Render the program details fragment. """ - DEFAULT_ROLE = 'Student' - ADMIN_ROLE = 'Administrator' - - @staticmethod - def get_program_discussion_configuration(program_uuid): - return ProgramDiscussionsConfiguration.objects.filter( - program_uuid=program_uuid - ).first() @staticmethod def _get_resource_link_id(program_uuid, request) -> str: site = get_current_site(request) return f'{site.domain}-{program_uuid}' - @staticmethod - def _get_result_sourcedid(context_id, resource_link_id, user_id) -> str: - return f'{context_id}:{resource_link_id}:{user_id}' - - def get_user_roles(self, user): - """ - Returns the given user's roles - """ - if GlobalStaff().has_user(user): - return self.ADMIN_ROLE - return self.DEFAULT_ROLE - - def _get_lti_embed_code(self, program_discussions_configuration, request) -> str: - """ - Returns the LTI embed code for embedding in the program discussions tab - Args: - program_discussions_configuration (ProgramDiscussionsConfiguration): ProgramDiscussionsConfiguration object. - request (HttpRequest): Request object for view in which LTI will be embedded. - Returns: - HTML code to embed LTI in program page. - """ - program_uuid = program_discussions_configuration.program_uuid - lti_consumer = program_discussions_configuration.lti_configuration.get_lti_consumer() - user_id = str(request.user.id) - context_id = program_uuid - resource_link_id = self._get_resource_link_id(program_uuid, request) - roles = self.get_user_roles(request.user) - context_title = program_uuid - result_sourcedid = self._get_result_sourcedid(context_id, resource_link_id, user_id) - - return lti_embed( - html_element_id='lti-tab-launcher', - lti_consumer=lti_consumer, - resource_link_id=resource_link_id, - user_id=user_id, - roles=roles, - context_id=context_id, - context_title=context_title, - context_label=context_id, - result_sourcedid=result_sourcedid - ) - - def render_discussions_fragment(self, program_uuid, request) -> dict: - """ - Returns the program discussion fragment if program discussions configuration exists for a program uuid - """ - if program_discussions_is_enabled(): - program_discussions_configuration = self.get_program_discussion_configuration(program_uuid) - if program_discussions_configuration: - lti_embed_html = self._get_lti_embed_code(program_discussions_configuration, request) - fragment = Fragment( - HTML( - """ - - """ - ).format( - srcdoc=lti_embed_html - ) - ) - return { - 'iframe': fragment.content, - 'enabled': True - } - return { - 'iframe': '', - 'enabled': False - } - def render_to_fragment(self, request, program_uuid, **kwargs): # lint-amnesty, pylint: disable=arguments-differ """View details about a specific program.""" programs_config = kwargs.get('programs_config') or ProgramsApiConfig.current() @@ -219,7 +138,7 @@ class ProgramDetailsFragmentView(EdxFragmentView): 'buy_button_url': ecommerce_service.get_checkout_page_url(*skus), 'program_record_url': program_record_url, } - + program_discussion_lti = ProgramDiscussionLTI(program_uuid, request) context = { 'urls': urls, 'user_preferences': get_user_preferences(user), @@ -228,8 +147,11 @@ class ProgramDetailsFragmentView(EdxFragmentView): 'certificate_data': certificate_data, 'industry_pathways': industry_pathways, 'credit_pathways': credit_pathways, - 'program_discussions_enabled': program_discussions_is_enabled(), - 'discussion_fragment': self.render_discussions_fragment(program_uuid, request) + 'program_discussions_enabled': program_tab_view_is_enabled(), + 'discussion_fragment': { + 'enabled': bool(program_discussion_lti.configuration), + 'iframe': program_discussion_lti.render_iframe() + } } html = render_to_string('learner_dashboard/program_details_fragment.html', context) @@ -242,3 +164,88 @@ class ProgramDetailsFragmentView(EdxFragmentView): Return page title for the standalone page. """ return _('Program Details') + + +class ProgramDiscussionLTI: + """ + Encapsulates methods for program discussion iframe rendering. + """ + DEFAULT_ROLE = 'Student' + ADMIN_ROLE = 'Administrator' + + def __init__(self, program_uuid, request): + self.program_uuid = program_uuid + self.request = request + self.configuration = self.get_configuration() + + def get_configuration(self) -> ProgramDiscussionsConfiguration: + """ + Returns ProgramDiscussionsConfiguration object with respect to program_uuid + """ + return ProgramDiscussionsConfiguration.objects.filter( + program_uuid=self.program_uuid + ).first() + + def _get_resource_link_id(self) -> str: + site = get_current_site(self.request) + return f'{site.domain}-{self.program_uuid}' + + def _get_result_sourcedid(self, resource_link_id) -> str: + return f'{self.program_uuid}:{resource_link_id}:{self.request.user.id}' + + def get_user_roles(self) -> str: + """ + Returns comma-separated roles for the given user + """ + basic_role = self.DEFAULT_ROLE + + if GlobalStaff().has_user(self.request.user): + basic_role = self.ADMIN_ROLE + + all_roles = [basic_role] + return ','.join(all_roles) + + def _get_lti_embed_code(self) -> str: + """ + Returns the LTI embed code for embedding in the program discussions tab + Returns: + HTML code to embed LTI in program page. + """ + resource_link_id = self._get_resource_link_id() + result_sourcedid = self._get_result_sourcedid(resource_link_id) + + return lti_embed( + html_element_id='lti-tab-launcher', + lti_consumer=self.configuration.lti_configuration.get_lti_consumer(), + resource_link_id=resource_link_id, + user_id=str(self.request.user.id), + roles=self.get_user_roles(), + context_id=self.program_uuid, + context_title=self.program_uuid, + context_label=self.program_uuid, + result_sourcedid=result_sourcedid + ) + + def render_iframe(self) -> str: + """ + Returns the program discussion fragment if program discussions configuration exists for a program uuid + """ + if not self.configuration: + return '' + + lti_embed_html = self._get_lti_embed_code() + fragment = Fragment( + HTML( + """ + + """ + ).format( + srcdoc=lti_embed_html + ) + ) + return fragment.content diff --git a/lms/djangoapps/learner_dashboard/tests/test_programs.py b/lms/djangoapps/learner_dashboard/tests/test_programs.py index f02e1a21bb..af75fb48c5 100644 --- a/lms/djangoapps/learner_dashboard/tests/test_programs.py +++ b/lms/djangoapps/learner_dashboard/tests/test_programs.py @@ -13,8 +13,13 @@ from bs4 import BeautifulSoup from django.conf import settings from django.test import override_settings from django.urls import reverse, reverse_lazy +from edx_toggles.toggles.testutils import override_waffle_flag +from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase +from xmodule.modulestore.tests.factories import CourseFactory as ModuleStoreCourseFactory from common.djangoapps.student.tests.factories import CourseEnrollmentFactory, UserFactory +from lms.djangoapps.learner_dashboard.config.waffle import ENABLE_PROGRAM_TAB_VIEW +from lms.djangoapps.program_enrollments.rest_api.v1.tests.test_views import ProgramCacheMixin from lms.envs.test import CREDENTIALS_PUBLIC_SERVICE_URL from openedx.core.djangoapps.catalog.constants import PathwayType from openedx.core.djangoapps.catalog.tests.factories import ( @@ -26,8 +31,6 @@ from openedx.core.djangoapps.catalog.tests.factories import ( from openedx.core.djangoapps.catalog.tests.mixins import CatalogIntegrationMixin from openedx.core.djangoapps.programs.tests.mixins import ProgramsApiConfigMixin from openedx.core.djangolib.testing.utils import skip_unless_lms -from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase -from xmodule.modulestore.tests.factories import CourseFactory as ModuleStoreCourseFactory PROGRAMS_UTILS_MODULE = 'openedx.core.djangoapps.programs.utils' PROGRAMS_MODULE = 'lms.djangoapps.learner_dashboard.programs' @@ -298,3 +301,35 @@ class TestProgramDetails(ProgramsApiConfigMixin, CatalogIntegrationMixin, Shared response = self.client.get(self.url) assert response.status_code == 404 + + +@override_waffle_flag(ENABLE_PROGRAM_TAB_VIEW, active=True) +class TestProgramDetailsFragmentView(SharedModuleStoreTestCase, ProgramCacheMixin, ProgramsApiConfigMixin): + """Unit tests for the program details page.""" + program_uuid = str(uuid4()) + password = 'test' + url = reverse_lazy('program_details_fragment_view', kwargs={'program_uuid': program_uuid}) + + @classmethod + def setUpClass(cls): + super().setUpClass() + + modulestore_course = ModuleStoreCourseFactory() + course_run = CourseRunFactory(key=str(modulestore_course.id)) + course = CourseFactory(course_runs=[course_run]) + cls.program = ProgramFactory(uuid=cls.program_uuid, courses=[course]) + + def setUp(self): + super().setUp() + self.user = UserFactory() + self.client.login(username=self.user.username, password=self.password) + self.set_program_in_catalog_cache(self.program_uuid, self.program) + self.create_programs_config() + + def test_discussion_flags_exist(self): + """ + Test if programDiscussionEnabled and discussionFragment exist in html. + """ + response = self.client.get(self.url) + self.assertContains(response, 'programDiscussionEnabled: true',) + self.assertContains(response, 'discussionFragment: {"enabled": false, "iframe": ""') diff --git a/lms/djangoapps/learner_dashboard/tests/test_views.py b/lms/djangoapps/learner_dashboard/tests/test_views.py new file mode 100644 index 0000000000..b52febea21 --- /dev/null +++ b/lms/djangoapps/learner_dashboard/tests/test_views.py @@ -0,0 +1,108 @@ +""" +Unit tests covering the program discussion iframe API. +""" + +from uuid import uuid4 + +from django.urls import reverse, reverse_lazy +from edx_toggles.toggles.testutils import override_waffle_flag +from lti_consumer.models import LtiConfiguration +from markupsafe import Markup +from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase +from xmodule.modulestore.tests.factories import CourseFactory as ModuleStoreCourseFactory + +from common.djangoapps.student.tests.factories import UserFactory +from lms.djangoapps.learner_dashboard.config.waffle import ENABLE_PROGRAM_TAB_VIEW, ENABLE_MASTERS_PROGRAM_TAB_VIEW +from lms.djangoapps.program_enrollments.rest_api.v1.tests.test_views import ProgramCacheMixin +from lms.djangoapps.program_enrollments.tests.factories import ProgramEnrollmentFactory +from openedx.core.djangoapps.catalog.tests.factories import CourseFactory, CourseRunFactory, ProgramFactory +from openedx.core.djangoapps.discussions.models import ProgramDiscussionsConfiguration + + +@override_waffle_flag(ENABLE_PROGRAM_TAB_VIEW, active=True) +@override_waffle_flag(ENABLE_MASTERS_PROGRAM_TAB_VIEW, active=True) +class TestProgramDiscussionIframeView(SharedModuleStoreTestCase, ProgramCacheMixin): + """Unit tests for the program details page.""" + program_uuid = str(uuid4()) + password = 'test' + url = reverse_lazy('program_discussion', kwargs={'program_uuid': program_uuid}) + + @classmethod + def setUpClass(cls): + super().setUpClass() + + modulestore_course = ModuleStoreCourseFactory() + course_run = CourseRunFactory(key=str(modulestore_course.id)) + course = CourseFactory(course_runs=[course_run]) + cls.program = ProgramFactory(uuid=cls.program_uuid, courses=[course]) + + def setUp(self): + super().setUp() + self.user = UserFactory() + self.client.login(username=self.user.username, password=self.password) + self.set_program_in_catalog_cache(self.program_uuid, self.program) + ProgramEnrollmentFactory.create( + user=self.user, + program_uuid=self.program_uuid, + external_user_key='0001', + ) + + def test_program_discussion_not_configured(self): + """ + Verify API returns proper response in case ProgramDiscussions is not Configured. + """ + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200) + expected_data = { + 'enabled': True, + 'discussion': { + 'iframe': "", + 'configured': False + } + } + self.assertEqual(response.data, expected_data) + + def test_if_user_is_not_authenticated(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_api_returns_discussions_iframe(self): + """ + Test if API returns iframe in case ProgramDiscussionsConfiguration model contains proper data + """ + discussion_config = ProgramDiscussionsConfiguration.objects.create( + program_uuid=self.program_uuid, + enabled=True, + provider_type="piazza", + ) + discussion_config.lti_configuration = LtiConfiguration.objects.create( + config_store=LtiConfiguration.CONFIG_ON_DB, + lti_1p1_launch_url='http://test.url', + lti_1p1_client_key='test_client_key', + lti_1p1_client_secret='test_client_secret', + ) + discussion_config.save() + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200) + self.assertIsInstance(response.data['discussion']['iframe'], Markup) + self.assertIn('iframe', str(response.data['discussion']['iframe']), ) + + def test_program_does_not_exist(self): + """ + Test if API returns 404 in case program does not exist + """ + response = self.client.get(reverse('program_discussion', kwargs={'program_uuid': str(uuid4())})) + self.assertEqual(response.status_code, 404) + + def test_program_access_denied(self): + """ + Test if API returns 403 in case user has no access to program + """ + self.user = UserFactory() + self.client.login(username=self.user.username, password=self.password) + response = self.client.get(self.url) + self.assertEqual(response.status_code, 403) diff --git a/lms/djangoapps/learner_dashboard/urls.py b/lms/djangoapps/learner_dashboard/urls.py index 3b1f297e2c..a7274a9c90 100644 --- a/lms/djangoapps/learner_dashboard/urls.py +++ b/lms/djangoapps/learner_dashboard/urls.py @@ -1,6 +1,5 @@ """Learner dashboard URL routing configuration""" - from django.urls import path, re_path from lms.djangoapps.learner_dashboard import programs, views @@ -8,6 +7,8 @@ from lms.djangoapps.learner_dashboard import programs, views urlpatterns = [ path('programs/', views.program_listing, name='program_listing_view'), re_path(r'^programs/(?P[0-9a-f-]+)/$', views.program_details, name='program_details_view'), + re_path(r'^programs/(?P[0-9a-f-]+)/discussion/$', views.ProgramDiscussionIframeView.as_view(), + name='program_discussion'), path('programs_fragment/', programs.ProgramsFragmentView.as_view(), name='program_listing_fragment_view'), re_path(r'^programs/(?P[0-9a-f-]+)/details_fragment/$', programs.ProgramDetailsFragmentView.as_view(), name='program_details_fragment_view'), diff --git a/lms/djangoapps/learner_dashboard/utils.py b/lms/djangoapps/learner_dashboard/utils.py index 2b0385a9c6..89c35f7e2d 100644 --- a/lms/djangoapps/learner_dashboard/utils.py +++ b/lms/djangoapps/learner_dashboard/utils.py @@ -2,10 +2,9 @@ The utility methods and functions to help the djangoapp logic """ - from opaque_keys.edx.keys import CourseKey -from lms.djangoapps.learner_dashboard.config.waffle import ENABLE_PROGRAM_DISCUSSIONS +from lms.djangoapps.learner_dashboard.config.waffle import ENABLE_PROGRAM_TAB_VIEW, ENABLE_MASTERS_PROGRAM_TAB_VIEW FAKE_COURSE_KEY = CourseKey.from_string('course-v1:fake+course+run') @@ -19,8 +18,15 @@ def strip_course_id(path): return path.split(course_id)[0] -def program_discussions_is_enabled(): +def program_tab_view_is_enabled() -> bool: """ check if program discussion is enabled. """ - return ENABLE_PROGRAM_DISCUSSIONS.is_enabled() + return ENABLE_PROGRAM_TAB_VIEW.is_enabled() + + +def masters_program_tab_view_is_enabled() -> bool: + """ + check if masters program discussion is enabled. + """ + return ENABLE_MASTERS_PROGRAM_TAB_VIEW.is_enabled() diff --git a/lms/djangoapps/learner_dashboard/views.py b/lms/djangoapps/learner_dashboard/views.py index db6c6724e8..80e6710385 100644 --- a/lms/djangoapps/learner_dashboard/views.py +++ b/lms/djangoapps/learner_dashboard/views.py @@ -1,12 +1,22 @@ """Learner dashboard views""" - from django.contrib.auth.decorators import login_required from django.views.decorators.http import require_GET - +from rest_framework import permissions, status +from rest_framework.authentication import SessionAuthentication +from rest_framework.response import Response +from rest_framework.views import APIView +from lms.djangoapps.learner_dashboard.utils import masters_program_tab_view_is_enabled from common.djangoapps.edxmako.shortcuts import render_to_response -from lms.djangoapps.learner_dashboard.programs import ProgramDetailsFragmentView, ProgramsFragmentView +from lms.djangoapps.learner_dashboard.permissions import IsEnrolledInProgram +from lms.djangoapps.learner_dashboard.programs import ( + ProgramDetailsFragmentView, + ProgramDiscussionLTI, + ProgramsFragmentView +) +from lms.djangoapps.program_enrollments.rest_api.v1.utils import ProgramSpecificViewMixin from openedx.core.djangoapps.programs.models import ProgramsApiConfig +from openedx.core.lib.api.authentication import BearerAuthentication @login_required @@ -47,3 +57,65 @@ def program_details(request, program_uuid): } return render_to_response('learner_dashboard/program_details.html', context) + + +class ProgramDiscussionIframeView(APIView, ProgramSpecificViewMixin): + """ + A view for retrieving Program Discussion IFrame . + + Path: ``/dashboard/programs/{program_uuid}/discussion/`` + + Accepts: [GET] + + ------------------------------------------------------------------------------------ + GET + ------------------------------------------------------------------------------------ + + **Returns** + + * 200: OK - Contains a program discussion iframe. + * 401: The requesting user is not authenticated. + * 403: The requesting user lacks access to the program. + * 404: The requested program does not exist. + + **Response** + + In the case of a 200 response code, the response will be iframe HTML and status if discussion is configured + for the program. + + **Example** + + { + 'enabled_for_masters': True, + 'discussion': { + "iframe": " + + ", + "enabled": false + } + } + + + """ + authentication_classes = (BearerAuthentication, SessionAuthentication) + permission_classes = (permissions.IsAuthenticated, IsEnrolledInProgram) + + def get(self, request, program_uuid): + """ GET handler """ + program_discussion_lti = ProgramDiscussionLTI(program_uuid, request) + return Response( + + { + 'enabled': masters_program_tab_view_is_enabled(), + 'discussion': { + 'iframe': program_discussion_lti.render_iframe(), + 'configured': bool(program_discussion_lti.configuration), + } + }, + status=status.HTTP_200_OK + )