From e63fb2e01ad0e540419f07bc2fb7442d68a4659e Mon Sep 17 00:00:00 2001 From: Ahtisham Shahid Date: Fri, 21 Jan 2022 12:26:47 +0500 Subject: [PATCH] feat: Created API for programs live page zoom lti (#29763) * feat: Created API for programs live page zoom lti * refactor: Merged similar code by inheritance * refactor: removed duplicates and resolved tight coupling issues * refactor: Decoupled views --- lms/djangoapps/learner_dashboard/programs.py | 33 +++++-- lms/djangoapps/learner_dashboard/urls.py | 2 + lms/djangoapps/learner_dashboard/utils.py | 18 +++- lms/djangoapps/learner_dashboard/views.py | 88 +++++++++++++++---- openedx/core/djangoapps/discussions/admin.py | 29 +++++- openedx/core/djangoapps/discussions/forms.py | 30 ++++++- .../migrations/0008_auto_20220119_0746.py | 76 ++++++++++++++++ openedx/core/djangoapps/discussions/models.py | 23 +++-- 8 files changed, 262 insertions(+), 37 deletions(-) create mode 100644 openedx/core/djangoapps/discussions/migrations/0008_auto_20220119_0746.py diff --git a/lms/djangoapps/learner_dashboard/programs.py b/lms/djangoapps/learner_dashboard/programs.py index 8f88e8b228..fe5ea2bfde 100644 --- a/lms/djangoapps/learner_dashboard/programs.py +++ b/lms/djangoapps/learner_dashboard/programs.py @@ -3,23 +3,27 @@ Fragments for rendering programs. """ import json - +from abc import ABC, abstractmethod from urllib.parse import quote + from django.contrib.sites.shortcuts import get_current_site from django.http import Http404 from django.template.loader import render_to_string from django.urls import reverse -from django.utils.translation import get_language, to_locale, gettext_lazy as _ # lint-amnesty, pylint: disable=unused-import +from django.utils.translation import get_language +from django.utils.translation import gettext_lazy as _ # lint-amnesty, pylint: disable=unused-import +from django.utils.translation import to_locale from lti_consumer.lti_1p1.contrib.django import lti_embed from web_fragments.fragment import Fragment +from common.djangoapps.student.models import anonymous_id_for_user from common.djangoapps.student.roles import GlobalStaff from lms.djangoapps.commerce.utils import EcommerceService 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, get_programs from openedx.core.djangoapps.credentials.utils import get_credentials_records_url -from openedx.core.djangoapps.discussions.models import ProgramDiscussionsConfiguration +from openedx.core.djangoapps.discussions.models import ProgramDiscussionsConfiguration, ProgramLiveConfiguration from openedx.core.djangoapps.plugin_api.views import EdxFragmentView from openedx.core.djangoapps.programs.models import ProgramsApiConfig from openedx.core.djangoapps.programs.utils import ( @@ -30,7 +34,6 @@ from openedx.core.djangoapps.programs.utils import ( ) from openedx.core.djangoapps.user_api.preferences.api import get_user_preferences from openedx.core.djangolib.markup import HTML -from common.djangoapps.student.models import anonymous_id_for_user class ProgramsFragmentView(EdxFragmentView): @@ -167,9 +170,9 @@ class ProgramDetailsFragmentView(EdxFragmentView): return _('Program Details') -class ProgramDiscussionLTI: +class ProgramLTI(ABC): """ - Encapsulates methods for program discussion iframe rendering. + Encapsulates methods for program LTI iframe rendering. """ DEFAULT_ROLE = 'Student' ADMIN_ROLE = 'Administrator' @@ -178,7 +181,11 @@ class ProgramDiscussionLTI: self.program_uuid = program_uuid self.program = get_programs(uuid=self.program_uuid) self.request = request - self.configuration = ProgramDiscussionsConfiguration.get(self.program_uuid) + self.configuration = self.get_configuration() + + @abstractmethod + def get_configuration(self): + return @property def is_configured(self): @@ -264,7 +271,7 @@ class ProgramDiscussionLTI: def render_iframe(self) -> str: """ - Returns the program discussion fragment if program discussions configuration exists for a program uuid + Returns the program LTI iframe if program Lti configuration exists for a program uuid """ if not self.is_configured: return '' @@ -285,3 +292,13 @@ class ProgramDiscussionLTI: ) ) return fragment.content + + +class ProgramDiscussionLTI(ProgramLTI): + def get_configuration(self): + return ProgramDiscussionsConfiguration.get(self.program_uuid) + + +class ProgramLiveLTI(ProgramLTI): + def get_configuration(self): + return ProgramLiveConfiguration.get(self.program_uuid) diff --git a/lms/djangoapps/learner_dashboard/urls.py b/lms/djangoapps/learner_dashboard/urls.py index a7274a9c90..cba464b631 100644 --- a/lms/djangoapps/learner_dashboard/urls.py +++ b/lms/djangoapps/learner_dashboard/urls.py @@ -9,6 +9,8 @@ urlpatterns = [ 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'), + re_path(r'^programs/(?P[0-9a-f-]+)/live/$', views.ProgramLiveIframeView.as_view(), + name='program_live'), 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 89c35f7e2d..6f00c1f057 100644 --- a/lms/djangoapps/learner_dashboard/utils.py +++ b/lms/djangoapps/learner_dashboard/utils.py @@ -2,9 +2,12 @@ The utility methods and functions to help the djangoapp logic """ +from django.core.exceptions import ObjectDoesNotExist from opaque_keys.edx.keys import CourseKey -from lms.djangoapps.learner_dashboard.config.waffle import ENABLE_PROGRAM_TAB_VIEW, ENABLE_MASTERS_PROGRAM_TAB_VIEW +from common.djangoapps.student.roles import GlobalStaff +from lms.djangoapps.learner_dashboard.config.waffle import ENABLE_MASTERS_PROGRAM_TAB_VIEW, ENABLE_PROGRAM_TAB_VIEW +from lms.djangoapps.program_enrollments.api import get_program_enrollment FAKE_COURSE_KEY = CourseKey.from_string('course-v1:fake+course+run') @@ -30,3 +33,16 @@ def masters_program_tab_view_is_enabled() -> bool: check if masters program discussion is enabled. """ return ENABLE_MASTERS_PROGRAM_TAB_VIEW.is_enabled() + + +def is_enrolled_or_staff(request, program_uuid): + """Returns true if the user is enrolled in the program or staff""" + + if GlobalStaff().has_user(request.user): + return True + + try: + get_program_enrollment(program_uuid=program_uuid, user=request.user) + except ObjectDoesNotExist: + return False + return True diff --git a/lms/djangoapps/learner_dashboard/views.py b/lms/djangoapps/learner_dashboard/views.py index 3f38b0209a..edceace20c 100644 --- a/lms/djangoapps/learner_dashboard/views.py +++ b/lms/djangoapps/learner_dashboard/views.py @@ -1,21 +1,18 @@ """Learner dashboard views""" from django.contrib.auth.decorators import login_required -from django.core.exceptions import ObjectDoesNotExist from django.views.decorators.http import require_GET from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication 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 lms.djangoapps.program_enrollments.api import get_program_enrollment +from lms.djangoapps.learner_dashboard.utils import masters_program_tab_view_is_enabled, is_enrolled_or_staff from common.djangoapps.edxmako.shortcuts import render_to_response -from common.djangoapps.student.roles import GlobalStaff from lms.djangoapps.learner_dashboard.programs import ( ProgramDetailsFragmentView, ProgramDiscussionLTI, - ProgramsFragmentView + ProgramsFragmentView, ProgramLiveLTI ) from lms.djangoapps.program_enrollments.rest_api.v1.utils import ProgramSpecificViewMixin from openedx.core.djangoapps.programs.models import ProgramsApiConfig @@ -107,21 +104,9 @@ class ProgramDiscussionIframeView(APIView, ProgramSpecificViewMixin): authentication_classes = (JwtAuthentication, BearerAuthentication, SessionAuthentication) permission_classes = (permissions.IsAuthenticated,) - def is_enrolled_or_staff(self, request, program_uuid): - """Returns true if the user is enrolled in the program or staff""" - - if GlobalStaff().has_user(request.user): - return True - - try: - get_program_enrollment(program_uuid=program_uuid, user=request.user) - except ObjectDoesNotExist: - return False - return True - def get(self, request, program_uuid): """ GET handler """ - if not self.is_enrolled_or_staff(request, program_uuid): + if not is_enrolled_or_staff(request, program_uuid): default_response = { 'tab_view_enabled': False, 'discussion': { @@ -139,3 +124,70 @@ class ProgramDiscussionIframeView(APIView, ProgramSpecificViewMixin): } } return Response(response_data, status=status.HTTP_200_OK) + + +class ProgramLiveIframeView(APIView, ProgramSpecificViewMixin): + """ + A view for retrieving Program live IFrame . + + Path: ``/dashboard/programs/{program_uuid}/live/`` + + Accepts: [GET] + + ------------------------------------------------------------------------------------ + GET + ------------------------------------------------------------------------------------ + + **Returns** + + * 200: OK - Contains a program live zoom 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** + + { + 'tab_view_enabled': True, + 'live': { + "iframe": " + + ", + "configured": false + } + } + + """ + authentication_classes = (JwtAuthentication, BearerAuthentication, SessionAuthentication) + permission_classes = (permissions.IsAuthenticated,) + + def get(self, request, program_uuid): + """ GET handler """ + if not is_enrolled_or_staff(request, program_uuid): + default_response = { + 'tab_view_enabled': False, + 'live': { + 'configured': False, + 'iframe': '' + } + } + return Response(default_response, status=status.HTTP_200_OK) + program_live_lti = ProgramLiveLTI(program_uuid, request) + response_data = { + 'tab_view_enabled': masters_program_tab_view_is_enabled(), + 'live': { + 'iframe': program_live_lti.render_iframe(), + 'configured': program_live_lti.is_configured, + } + } + return Response(response_data, status=status.HTTP_200_OK) diff --git a/openedx/core/djangoapps/discussions/admin.py b/openedx/core/djangoapps/discussions/admin.py index ae3fd1dea9..8e35be116a 100644 --- a/openedx/core/djangoapps/discussions/admin.py +++ b/openedx/core/djangoapps/discussions/admin.py @@ -7,8 +7,8 @@ from simple_history.admin import SimpleHistoryAdmin from openedx.core.djangoapps.config_model_utils.admin import StackedConfigModelAdmin -from .forms import ProgramDiscussionsConfigurationForm -from .models import DiscussionsConfiguration, ProgramDiscussionsConfiguration +from .forms import ProgramDiscussionsConfigurationForm, ProgramLiveConfigurationForm +from .models import DiscussionsConfiguration, ProgramDiscussionsConfiguration, ProgramLiveConfiguration from .models import ProviderFilter @@ -112,6 +112,31 @@ class ProviderFilterAdmin(StackedConfigModelAdmin): ) +class ProgramLiveConfigurationAdmin(SimpleHistoryAdmin): + """ + Customize the admin interface for the program live configuration + """ + form = ProgramLiveConfigurationForm + + fieldsets = ( + (None, { + 'fields': ('program_uuid', 'enabled', 'lti_configuration', 'pii_share_username', 'pii_share_email', + 'provider_type'), + }), + ) + + search_fields = ( + 'program_uuid', + 'enabled', + 'provider_type', + ) + list_filter = ( + 'enabled', + 'provider_type', + ) + + admin.site.register(DiscussionsConfiguration, DiscussionsConfigurationAdmin) admin.site.register(ProgramDiscussionsConfiguration, ProgramDiscussionsConfigurationAdmin) +admin.site.register(ProgramLiveConfiguration, ProgramLiveConfigurationAdmin) admin.site.register(ProviderFilter, ProviderFilterAdmin) diff --git a/openedx/core/djangoapps/discussions/forms.py b/openedx/core/djangoapps/discussions/forms.py index 17d81f87d9..ac4bff5b72 100644 --- a/openedx/core/djangoapps/discussions/forms.py +++ b/openedx/core/djangoapps/discussions/forms.py @@ -3,7 +3,7 @@ Forms for discussions. """ from django import forms -from .models import ProgramDiscussionsConfiguration +from .models import ProgramDiscussionsConfiguration, ProgramLiveConfiguration class ProgramDiscussionsConfigurationForm(forms.ModelForm): @@ -32,3 +32,31 @@ class ProgramDiscussionsConfigurationForm(forms.ModelForm): class Meta: model = ProgramDiscussionsConfiguration fields = '__all__' + + +class ProgramLiveConfigurationForm(forms.ModelForm): + """ + Custom ProgramLiveConfigurationForm form for admin page + """ + pii_share_username = forms.BooleanField(required=False, initial=False) + pii_share_email = forms.BooleanField(required=False, initial=False) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if self.instance.lti_configuration: + self.fields['pii_share_username'].initial = self.instance.lti_configuration.pii_share_username + self.fields['pii_share_email'].initial = self.instance.lti_configuration.pii_share_email + + def save(self, commit=True): + pii_share_username = self.cleaned_data.get('pii_share_username', False) + pii_share_email = self.cleaned_data.get('pii_share_email', False) + lti_configuration = self.cleaned_data.get('lti_configuration', None) + if lti_configuration: + lti_configuration.pii_share_username = pii_share_username + lti_configuration.pii_share_email = pii_share_email + lti_configuration.save() + return super().save(commit=commit) + + class Meta: + model = ProgramLiveConfiguration + fields = '__all__' diff --git a/openedx/core/djangoapps/discussions/migrations/0008_auto_20220119_0746.py b/openedx/core/djangoapps/discussions/migrations/0008_auto_20220119_0746.py new file mode 100644 index 0000000000..61797a5b65 --- /dev/null +++ b/openedx/core/djangoapps/discussions/migrations/0008_auto_20220119_0746.py @@ -0,0 +1,76 @@ +# Generated by Django 3.2.11 on 2022-01-19 07:46 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone +import model_utils.fields +import simple_history.models + + +class Migration(migrations.Migration): + + dependencies = [ + ('lti_consumer', '0013_auto_20210712_1352'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('discussions', '0007_add_discussion_topic_link'), + ] + + operations = [ + migrations.AlterField( + model_name='historicalprogramdiscussionsconfiguration', + name='enabled', + field=models.BooleanField(default=True, help_text='If disabled, the LTI in the associated program will be disabled.'), + ), + migrations.AlterField( + model_name='historicalprogramdiscussionsconfiguration', + name='provider_type', + field=models.CharField(help_text="The LTI provider's id", max_length=50, verbose_name='LTI provider'), + ), + migrations.AlterField( + model_name='programdiscussionsconfiguration', + name='enabled', + field=models.BooleanField(default=True, help_text='If disabled, the LTI in the associated program will be disabled.'), + ), + migrations.AlterField( + model_name='programdiscussionsconfiguration', + name='provider_type', + field=models.CharField(help_text="The LTI provider's id", max_length=50, verbose_name='LTI provider'), + ), + migrations.CreateModel( + name='ProgramLiveConfiguration', + fields=[ + ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), + ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), + ('program_uuid', models.CharField(db_index=True, max_length=50, primary_key=True, serialize=False, verbose_name='Program UUID')), + ('enabled', models.BooleanField(default=True, help_text='If disabled, the LTI in the associated program will be disabled.')), + ('provider_type', models.CharField(help_text="The LTI provider's id", max_length=50, verbose_name='LTI provider')), + ('lti_configuration', models.ForeignKey(blank=True, help_text='The LTI configuration data for this program/provider.', null=True, on_delete=django.db.models.deletion.SET_NULL, to='lti_consumer.lticonfiguration')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='HistoricalProgramLiveConfiguration', + fields=[ + ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), + ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), + ('program_uuid', models.CharField(db_index=True, max_length=50, verbose_name='Program UUID')), + ('enabled', models.BooleanField(default=True, help_text='If disabled, the LTI in the associated program will be disabled.')), + ('provider_type', models.CharField(help_text="The LTI provider's id", max_length=50, verbose_name='LTI provider')), + ('history_id', models.AutoField(primary_key=True, serialize=False)), + ('history_date', models.DateTimeField()), + ('history_change_reason', models.CharField(max_length=100, null=True)), + ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), + ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ('lti_configuration', models.ForeignKey(blank=True, db_constraint=False, help_text='The LTI configuration data for this program/provider.', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='lti_consumer.lticonfiguration')), + ], + options={ + 'verbose_name': 'historical program live configuration', + 'ordering': ('-history_date', '-history_id'), + 'get_latest_by': 'history_date', + }, + bases=(simple_history.models.HistoricalChanges, models.Model), + ), + ] diff --git a/openedx/core/djangoapps/discussions/models.py b/openedx/core/djangoapps/discussions/models.py index b485a43f77..4d3708885c 100644 --- a/openedx/core/djangoapps/discussions/models.py +++ b/openedx/core/djangoapps/discussions/models.py @@ -518,10 +518,12 @@ class DiscussionsConfiguration(TimeStampedModel): ) -class ProgramDiscussionsConfiguration(TimeStampedModel): +class AbstractProgramLTIConfiguration(TimeStampedModel): """ - Associates a program with a discussion provider and configuration + Associates a program with a LTI provider and configuration """ + class Meta: + abstract = True program_uuid = models.CharField( primary_key=True, @@ -531,7 +533,7 @@ class ProgramDiscussionsConfiguration(TimeStampedModel): ) enabled = models.BooleanField( default=True, - help_text=_("If disabled, the discussions in the associated program will be disabled.") + help_text=_("If disabled, the LTI in the associated program will be disabled.") ) lti_configuration = models.ForeignKey( LtiConfiguration, @@ -543,10 +545,9 @@ class ProgramDiscussionsConfiguration(TimeStampedModel): provider_type = models.CharField( blank=False, max_length=50, - verbose_name=_("Discussion provider"), - help_text=_("The discussion provider's id"), + verbose_name=_("LTI provider"), + help_text=_("The LTI provider's id"), ) - history = HistoricalRecords() def __str__(self): return f"Configuration(uuid='{self.program_uuid}', provider='{self.provider_type}', enabled={self.enabled})" @@ -556,7 +557,7 @@ class ProgramDiscussionsConfiguration(TimeStampedModel): """ Lookup a program discussion configuration by program uuid. """ - return ProgramDiscussionsConfiguration.objects.filter( + return cls.objects.filter( program_uuid=program_uuid ).first() @@ -614,3 +615,11 @@ class DiscussionTopicLink(models.Model): f'enabled_in_context={self.enabled_in_context}' f')' ) + + +class ProgramLiveConfiguration(AbstractProgramLTIConfiguration): + history = HistoricalRecords() + + +class ProgramDiscussionsConfiguration(AbstractProgramLTIConfiguration): + history = HistoricalRecords()