feat: Added new discussion providers and features

refactor: Fixed linter issues

refactor: Fixed styling issues

refactor: Code refactor and removed conflicts

refactor: Replaced feature dict with enum
This commit is contained in:
Ahtisham Shahid
2021-05-07 17:30:47 +05:00
parent 4f4be6538a
commit 1a43a4ca29
3 changed files with 395 additions and 289 deletions

View File

@@ -3,6 +3,7 @@ Provide django models to back the discussions app
"""
from __future__ import annotations
import logging
from enum import Enum
from django.core.exceptions import ValidationError
from django.db import models
@@ -21,18 +22,118 @@ from openedx.core.djangoapps.content.course_overviews.models import CourseOvervi
log = logging.getLogger(__name__)
DEFAULT_PROVIDER_TYPE = 'legacy'
class Features(Enum):
"""
Features to be used/mapped in discussion providers
"""
ADVANCED_IN_CONTEXT_DISCUSSION = 'advanced_in_context_discussion'
ANONYMOUS_POSTING = 'anonymous_posting'
AUTOMATIC_LEARNER_ENROLLMENT = 'automatic_learner_enrollment'
BLACKOUT_DISCUSSION_DATES = 'blackout_discussion_dates'
COMMUNITY_TA_SUPPORT = 'community_ta_support'
COURSE_COHORT_SUPPORT = 'course_cohort_support'
DIRECT_MESSAGES_FROM_INSTRUCTORS = 'direct_messages_from_instructors'
DISCUSSION_PAGE = 'discussion-page'
DISCUSSION_CONTENT_PROMPTS = 'discussion_content_prompts'
EMAIL_NOTIFICATIONS = 'email_notifications'
EMBEDDED_COURSE_SECTIONS = 'embedded-course-sections'
GRADED_DISCUSSIONS = 'graded_discussions'
IN_PLATFORM_NOTIFICATIONS = 'in_platform_notifications'
INTERNATIONALIZATION_SUPPORT = 'internationalization_support'
LTI = 'lti'
LTI_ADVANCED_SHARING_MODE = 'lti_advanced_sharing_mode'
LTI_BASIC_CONFIGURATION = 'lti_basic_configuration'
PRIMARY_DISCUSSION_APP_EXPERIENCE = 'primary_discussion_app_experience'
QUESTION_DISCUSSION_SUPPORT = 'question_discussion_support'
REPORT_FLAG_CONTENT_TO_MODERATORS = 'report/flag_content_to_moderators'
RESEARCH_DATA_EVENTS = 'research_data_events'
SIMPLIFIED_IN_CONTEXT_DISCUSSION = 'simplified_in_context_discussion'
USER_MENTIONS = 'user_mentions'
WCAG_2_1 = 'wcag-2.1'
WCAG_2_0_SUPPORT = 'wcag_2_0_support'
PROVIDER_FEATURE_MAP = {
'legacy': [
'discussion-page',
'embedded-course-sections',
'wcag-2.1',
Features.DISCUSSION_PAGE.value,
Features.WCAG_2_1.value,
Features.AUTOMATIC_LEARNER_ENROLLMENT.value,
Features.WCAG_2_0_SUPPORT.value,
Features.INTERNATIONALIZATION_SUPPORT.value,
Features.ANONYMOUS_POSTING.value,
Features.REPORT_FLAG_CONTENT_TO_MODERATORS.value,
Features.QUESTION_DISCUSSION_SUPPORT.value,
Features.COMMUNITY_TA_SUPPORT.value,
Features.BLACKOUT_DISCUSSION_DATES.value,
Features.COURSE_COHORT_SUPPORT.value,
Features.RESEARCH_DATA_EVENTS.value,
],
'piazza': [
'discussion-page',
'lti',
Features.DISCUSSION_PAGE.value,
Features.LTI.value,
Features.WCAG_2_0_SUPPORT.value,
Features.ANONYMOUS_POSTING.value,
Features.REPORT_FLAG_CONTENT_TO_MODERATORS.value,
Features.QUESTION_DISCUSSION_SUPPORT.value,
Features.COMMUNITY_TA_SUPPORT.value,
Features.EMAIL_NOTIFICATIONS.value,
Features.BLACKOUT_DISCUSSION_DATES.value,
Features.DISCUSSION_CONTENT_PROMPTS.value,
Features.DIRECT_MESSAGES_FROM_INSTRUCTORS.value,
Features.USER_MENTIONS.value,
],
'edx-next': [
Features.AUTOMATIC_LEARNER_ENROLLMENT.value,
Features.WCAG_2_0_SUPPORT.value,
Features.INTERNATIONALIZATION_SUPPORT.value,
Features.ANONYMOUS_POSTING.value,
Features.REPORT_FLAG_CONTENT_TO_MODERATORS.value,
Features.QUESTION_DISCUSSION_SUPPORT.value,
Features.COMMUNITY_TA_SUPPORT.value,
Features.EMAIL_NOTIFICATIONS.value,
Features.BLACKOUT_DISCUSSION_DATES.value,
Features.SIMPLIFIED_IN_CONTEXT_DISCUSSION.value,
Features.ADVANCED_IN_CONTEXT_DISCUSSION.value,
Features.COURSE_COHORT_SUPPORT.value,
Features.RESEARCH_DATA_EVENTS.value,
Features.DISCUSSION_CONTENT_PROMPTS.value,
Features.GRADED_DISCUSSIONS.value,
],
'yellowdig': [
Features.WCAG_2_0_SUPPORT.value,
Features.ANONYMOUS_POSTING.value,
Features.REPORT_FLAG_CONTENT_TO_MODERATORS.value,
Features.QUESTION_DISCUSSION_SUPPORT.value,
Features.COMMUNITY_TA_SUPPORT.value,
Features.EMAIL_NOTIFICATIONS.value,
Features.RESEARCH_DATA_EVENTS.value,
Features.IN_PLATFORM_NOTIFICATIONS.value,
Features.GRADED_DISCUSSIONS.value,
Features.DIRECT_MESSAGES_FROM_INSTRUCTORS.value,
Features.USER_MENTIONS.value,
],
'inscribe': [
Features.PRIMARY_DISCUSSION_APP_EXPERIENCE.value,
Features.LTI_BASIC_CONFIGURATION.value,
],
'discourse': [
Features.PRIMARY_DISCUSSION_APP_EXPERIENCE.value,
Features.LTI_BASIC_CONFIGURATION.value,
Features.LTI_ADVANCED_SHARING_MODE.value,
],
'ed-discuss': [
Features.PRIMARY_DISCUSSION_APP_EXPERIENCE.value,
Features.LTI_BASIC_CONFIGURATION.value,
Features.WCAG_2_0_SUPPORT.value,
Features.INTERNATIONALIZATION_SUPPORT.value,
Features.ANONYMOUS_POSTING.value,
Features.REPORT_FLAG_CONTENT_TO_MODERATORS.value,
Features.QUESTION_DISCUSSION_SUPPORT.value,
Features.COMMUNITY_TA_SUPPORT.value,
Features.EMAIL_NOTIFICATIONS.value,
]
}
@@ -223,6 +324,7 @@ class DiscussionsConfiguration(TimeStampedModel):
provider_type=DEFAULT_PROVIDER_TYPE,
)
return configuration
# pylint: enable=undefined-variable
@property

View File

@@ -0,0 +1,279 @@
"""
Serializers for Discussion views.
"""
from rest_framework import serializers
from lti_consumer.models import LtiConfiguration
from openedx.core.lib.courses import get_course_by_id
from openedx.core.djangoapps.django_comment_common.models import CourseDiscussionSettings
from lms.djangoapps.discussion.rest_api.serializers import DiscussionSettingsSerializer
from xmodule.modulestore.django import modulestore
from .models import DiscussionsConfiguration, DEFAULT_PROVIDER_TYPE, Features, PROVIDER_FEATURE_MAP
class LtiSerializer(serializers.ModelSerializer):
"""
Serialize LtiConfiguration responses
"""
class Meta:
model = LtiConfiguration
fields = [
'lti_1p1_client_key',
'lti_1p1_client_secret',
'lti_1p1_launch_url',
'version',
]
def to_internal_value(self, data: dict) -> dict:
"""
Transform the incoming primitive data into a native value
"""
data = data or {}
payload = {
key: value
for key, value in data.items()
if key in self.Meta.fields
}
return payload
def update(self, instance: LtiConfiguration, validated_data: dict) -> LtiConfiguration:
"""
Create/update a model-backed instance
"""
instance = instance or LtiConfiguration()
instance.config_store = LtiConfiguration.CONFIG_ON_DB
if validated_data:
for key, value in validated_data.items():
if key in self.Meta.fields:
setattr(instance, key, value)
instance.save()
return instance
class LegacySettingsSerializer(serializers.BaseSerializer):
"""
Serialize legacy discussions settings
"""
class Meta:
fields = [
'allow_anonymous',
'allow_anonymous_to_peers',
'discussion_blackouts',
'discussion_topics',
# The following fields are deprecated;
# they technically still exist in Studio (so we mention them here),
# but they are not supported in the new experience:
# 'discussion_link',
# 'discussion_sort_alpha',
]
fields_cohorts = [
'always_divide_inline_discussions',
'divided_course_wide_discussions',
'divided_inline_discussions',
'division_scheme',
]
def create(self, validated_data):
"""
We do not need this.
"""
raise NotImplementedError
def to_internal_value(self, data: dict) -> dict:
"""
Transform the incoming primitive data into a native value
"""
if not isinstance(data.get('allow_anonymous', False), bool):
raise serializers.ValidationError('Wrong type for allow_anonymous')
if not isinstance(data.get('allow_anonymous_to_peers', False), bool):
raise serializers.ValidationError('Wrong type for allow_anonymous_to_peers')
if not isinstance(data.get('discussion_blackouts', []), list):
raise serializers.ValidationError('Wrong type for discussion_blackouts')
if not isinstance(data.get('discussion_topics', {}), dict):
raise serializers.ValidationError('Wrong type for discussion_topics')
return data
def to_representation(self, instance) -> dict:
"""
Serialize data into a dictionary, to be used as a response
"""
settings = {
field.name: field.read_json(instance)
for field in instance.fields.values()
if field.name in self.Meta.fields
}
discussion_settings = CourseDiscussionSettings.get(instance.id)
serializer = DiscussionSettingsSerializer(
discussion_settings,
context={
'course': instance,
'settings': discussion_settings,
},
partial=True,
)
settings.update({
key: value
for key, value in serializer.data.items()
if key != 'id'
})
return settings
def update(self, instance, validated_data: dict):
"""
Update and save an existing instance
"""
save = False
cohort_settings = {}
for field, value in validated_data.items():
if field in self.Meta.fields:
setattr(instance, field, value)
save = True
elif field in self.Meta.fields_cohorts:
cohort_settings[field] = value
if cohort_settings:
discussion_settings = CourseDiscussionSettings.get(instance.id)
serializer = DiscussionSettingsSerializer(
discussion_settings,
context={
'course': instance,
'settings': discussion_settings,
},
data=cohort_settings,
partial=True,
)
if serializer.is_valid(raise_exception=True):
serializer.save()
if save:
modulestore().update_item(instance, self.context['user_id'])
return instance
class DiscussionsConfigurationSerializer(serializers.ModelSerializer):
"""
Serialize configuration responses
"""
class Meta:
model = DiscussionsConfiguration
fields = [
'enabled',
'provider_type',
]
def create(self, validated_data):
"""
We do not need this.
"""
raise NotImplementedError
def to_internal_value(self, data: dict) -> dict:
"""
Transform the *incoming* primitive data into a native value.
"""
payload = {
'context_key': data.get('course_key', ''),
'enabled': data.get('enabled', False),
'lti_configuration': data.get('lti_configuration', {}),
'plugin_configuration': data.get('plugin_configuration', {}),
'provider_type': data.get('provider_type', DEFAULT_PROVIDER_TYPE),
}
return payload
def to_representation(self, instance: DiscussionsConfiguration) -> dict:
"""
Serialize data into a dictionary, to be used as a response
"""
payload = super().to_representation(instance)
lti_configuration_data = {}
supports_lti = instance.supports('lti')
if supports_lti:
lti_configuration = LtiSerializer(instance.lti_configuration)
lti_configuration_data = lti_configuration.data
provider_type = instance.provider_type or DEFAULT_PROVIDER_TYPE
plugin_configuration = instance.plugin_configuration
if provider_type == 'legacy':
course_key = instance.context_key
course = get_course_by_id(course_key)
legacy_settings = LegacySettingsSerializer(
course,
data=plugin_configuration,
)
if legacy_settings.is_valid(raise_exception=True):
plugin_configuration = legacy_settings.data
features_list = [feature.value for feature in Features]
payload.update({
'features': features_list,
'lti_configuration': lti_configuration_data,
'plugin_configuration': plugin_configuration,
'providers': {
'active': provider_type or DEFAULT_PROVIDER_TYPE,
'available': PROVIDER_FEATURE_MAP,
},
})
return payload
def update(self, instance: DiscussionsConfiguration, validated_data: dict) -> DiscussionsConfiguration:
"""
Update and save an existing instance
"""
for key in self.Meta.fields:
value = validated_data.get(key)
if value is not None:
setattr(instance, key, value)
# _update_* helpers assume `enabled` and `provider_type`
# have already been set
instance = self._update_lti(instance, validated_data)
instance = self._update_plugin_configuration(instance, validated_data)
instance.save()
return instance
def _update_lti(self, instance: DiscussionsConfiguration, validated_data: dict) -> DiscussionsConfiguration:
"""
Update LtiConfiguration
"""
lti_configuration_data = validated_data.get('lti_configuration')
supports_lti = instance.supports('lti')
if not supports_lti:
instance.lti_configuration = None
elif lti_configuration_data:
lti_configuration = instance.lti_configuration or LtiConfiguration()
lti_serializer = LtiSerializer(
lti_configuration,
data=lti_configuration_data,
partial=True,
)
if lti_serializer.is_valid(raise_exception=True):
lti_serializer.save()
instance.lti_configuration = lti_configuration
return instance
def _update_plugin_configuration(
self,
instance: DiscussionsConfiguration,
validated_data: dict,
) -> DiscussionsConfiguration:
"""
Create/update legacy provider settings
"""
updated_provider_type = validated_data.get('provider_type') or instance.provider_type
will_support_legacy = bool(
updated_provider_type == 'legacy'
)
if will_support_legacy:
course_key = instance.context_key
course = get_course_by_id(course_key)
legacy_settings = LegacySettingsSerializer(
course,
context={
'user_id': self.context['user_id'],
},
data=validated_data.get('plugin_configuration', {}),
)
if legacy_settings.is_valid(raise_exception=True):
legacy_settings.save()
instance.plugin_configuration = {}
else:
instance.plugin_configuration = validated_data.get('plugin_configuration') or {}
return instance

View File

@@ -3,24 +3,19 @@ Handle view-logic for the djangoapp
"""
from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication
from edx_rest_framework_extensions.auth.session.authentication import SessionAuthenticationAllowInactiveUser
from lti_consumer.models import LtiConfiguration
from opaque_keys.edx.keys import CourseKey
from opaque_keys import InvalidKeyError
from rest_framework import serializers
from rest_framework.permissions import BasePermission
from rest_framework.response import Response
from rest_framework.views import APIView
from rest_framework import serializers
from common.djangoapps.student.roles import CourseStaffRole
from lms.djangoapps.discussion.rest_api.serializers import DiscussionSettingsSerializer
from openedx.core.djangoapps.django_comment_common.models import CourseDiscussionSettings
from openedx.core.lib.api.authentication import BearerAuthenticationAllowInactiveUser
from openedx.core.lib.courses import get_course_by_id
from xmodule.modulestore.django import modulestore
from .models import DEFAULT_PROVIDER_TYPE
from openedx.core.lib.api.authentication import BearerAuthenticationAllowInactiveUser
from .models import DiscussionsConfiguration
from .models import PROVIDER_FEATURE_MAP
from .serializers import DiscussionsConfigurationSerializer
class IsStaff(BasePermission):
@@ -30,6 +25,7 @@ class IsStaff(BasePermission):
We create our own copy of this because other versions of this check
allow access to additional user roles.
"""
def has_permission(self, request, view):
"""
Check if user has global or course staff permission
@@ -44,147 +40,6 @@ class IsStaff(BasePermission):
).has_user(request.user)
class LtiSerializer(serializers.ModelSerializer):
"""
Serialize LtiConfiguration responses
"""
class Meta:
model = LtiConfiguration
fields = [
'lti_1p1_client_key',
'lti_1p1_client_secret',
'lti_1p1_launch_url',
'version',
]
def to_internal_value(self, data: dict) -> dict:
"""
Transform the incoming primitive data into a native value
"""
data = data or {}
payload = {
key: value
for key, value in data.items()
if key in self.Meta.fields
}
return payload
def update(self, instance: LtiConfiguration, validated_data: dict) -> LtiConfiguration:
"""
Create/update a model-backed instance
"""
instance = instance or LtiConfiguration()
instance.config_store = LtiConfiguration.CONFIG_ON_DB
if validated_data:
for key, value in validated_data.items():
if key in self.Meta.fields:
setattr(instance, key, value)
instance.save()
return instance
class LegacySettingsSerializer(serializers.BaseSerializer):
"""
Serialize legacy discussions settings
"""
class Meta:
fields = [
'allow_anonymous',
'allow_anonymous_to_peers',
'discussion_blackouts',
'discussion_topics',
# The following fields are deprecated;
# they technically still exist in Studio (so we mention them here),
# but they are not supported in the new experience:
# 'discussion_link',
# 'discussion_sort_alpha',
]
fields_cohorts = [
'always_divide_inline_discussions',
'divided_course_wide_discussions',
'divided_inline_discussions',
'division_scheme',
]
def create(self, validated_data):
"""
We do not need this.
"""
raise NotImplementedError
def to_internal_value(self, data: dict) -> dict:
"""
Transform the incoming primitive data into a native value
"""
if not isinstance(data.get('allow_anonymous', False), bool):
raise serializers.ValidationError('Wrong type for allow_anonymous')
if not isinstance(data.get('allow_anonymous_to_peers', False), bool):
raise serializers.ValidationError('Wrong type for allow_anonymous_to_peers')
if not isinstance(data.get('discussion_blackouts', []), list):
raise serializers.ValidationError('Wrong type for discussion_blackouts')
if not isinstance(data.get('discussion_topics', {}), dict):
raise serializers.ValidationError('Wrong type for discussion_topics')
payload = {
key: value
for key, value in data.items() # lint-amnesty, pylint: disable=unnecessary-comprehension
}
return payload
def to_representation(self, instance) -> dict:
"""
Serialize data into a dictionary, to be used as a response
"""
settings = {
field.name: field.read_json(instance)
for field in instance.fields.values()
if field.name in self.Meta.fields
}
discussion_settings = CourseDiscussionSettings.get(instance.id)
serializer = DiscussionSettingsSerializer(
discussion_settings,
context={
'course': instance,
'settings': discussion_settings,
},
partial=True,
)
settings.update({
key: value
for key, value in serializer.data.items()
if key != 'id'
})
return settings
def update(self, instance, validated_data: dict):
"""
Update and save an existing instance
"""
save = False
cohort_settings = {}
for field, value in validated_data.items():
if field in self.Meta.fields:
setattr(instance, field, value)
save = True
elif field in self.Meta.fields_cohorts:
cohort_settings[field] = value
if cohort_settings:
discussion_settings = CourseDiscussionSettings.get(instance.id)
serializer = DiscussionSettingsSerializer(
discussion_settings,
context={
'course': instance,
'settings': discussion_settings,
},
data=cohort_settings,
partial=True,
)
if serializer.is_valid(raise_exception=True):
serializer.save()
if save:
modulestore().update_item(instance, self.context['user_id'])
return instance
class DiscussionsConfigurationView(APIView):
"""
Handle configuration-related view-logic
@@ -196,137 +51,6 @@ class DiscussionsConfigurationView(APIView):
)
permission_classes = (IsStaff,)
class Serializer(serializers.ModelSerializer):
"""
Serialize configuration responses
"""
class Meta:
model = DiscussionsConfiguration
fields = [
'enabled',
'provider_type',
]
def create(self, validated_data):
"""
We do not need this.
"""
raise NotImplementedError
def to_internal_value(self, data: dict) -> dict:
"""
Transform the *incoming* primitive data into a native value.
"""
payload = {
'context_key': data.get('course_key', ''),
'enabled': data.get('enabled', False),
'lti_configuration': data.get('lti_configuration', {}),
'plugin_configuration': data.get('plugin_configuration', {}),
'provider_type': data.get('provider_type', DEFAULT_PROVIDER_TYPE),
}
return payload
def to_representation(self, instance: DiscussionsConfiguration) -> dict:
"""
Serialize data into a dictionary, to be used as a response
"""
payload = super().to_representation(instance)
lti_configuration_data = {}
supports_lti = instance.supports('lti')
if supports_lti:
lti_configuration = LtiSerializer(instance.lti_configuration)
lti_configuration_data = lti_configuration.data
provider_type = instance.provider_type or DEFAULT_PROVIDER_TYPE
plugin_configuration = instance.plugin_configuration
if provider_type == 'legacy':
course_key = instance.context_key
course = get_course_by_id(course_key)
legacy_settings = LegacySettingsSerializer(
course,
data=plugin_configuration,
)
if legacy_settings.is_valid(raise_exception=True):
plugin_configuration = legacy_settings.data
payload.update({
'features': {
'discussion-page',
'embedded-course-sections',
'lti',
'wcag-2.1',
},
'lti_configuration': lti_configuration_data,
'plugin_configuration': plugin_configuration,
'providers': {
'active': provider_type or DEFAULT_PROVIDER_TYPE,
'available': PROVIDER_FEATURE_MAP,
},
})
return payload
def update(self, instance: DiscussionsConfiguration, validated_data: dict) -> DiscussionsConfiguration:
"""
Update and save an existing instance
"""
for key in self.Meta.fields:
value = validated_data.get(key)
if value is not None:
setattr(instance, key, value)
# _update_* helpers assume `enabled` and `provider_type`
# have already been set
instance = self._update_lti(instance, validated_data)
instance = self._update_plugin_configuration(instance, validated_data)
instance.save()
return instance
def _update_lti(self, instance: DiscussionsConfiguration, validated_data: dict) -> DiscussionsConfiguration:
"""
Update LtiConfiguration
"""
lti_configuration_data = validated_data.get('lti_configuration')
supports_lti = instance.supports('lti')
if not supports_lti:
instance.lti_configuration = None
elif lti_configuration_data:
lti_configuration = instance.lti_configuration or LtiConfiguration()
lti_serializer = LtiSerializer(
lti_configuration,
data=lti_configuration_data,
partial=True,
)
if lti_serializer.is_valid(raise_exception=True):
lti_serializer.save()
instance.lti_configuration = lti_configuration
return instance
def _update_plugin_configuration(
self,
instance: DiscussionsConfiguration,
validated_data: dict,
) -> DiscussionsConfiguration:
"""
Create/update legacy provider settings
"""
updated_provider_type = validated_data.get('provider_type') or instance.provider_type
will_support_legacy = bool(
updated_provider_type == 'legacy'
)
if will_support_legacy:
course_key = instance.context_key
course = get_course_by_id(course_key)
legacy_settings = LegacySettingsSerializer(
course,
context={
'user_id': self.context['user_id'],
},
data=validated_data.get('plugin_configuration', {}),
)
if legacy_settings.is_valid(raise_exception=True):
legacy_settings.save()
instance.plugin_configuration = {}
else:
instance.plugin_configuration = validated_data.get('plugin_configuration') or {}
return instance
# pylint: disable=redefined-builtin
def get(self, request, course_key_string: str, **_kwargs) -> Response:
"""
@@ -334,7 +58,8 @@ class DiscussionsConfigurationView(APIView):
"""
course_key = _validate_course_key(course_key_string)
configuration = DiscussionsConfiguration.get(course_key)
serializer = self.Serializer(configuration)
serializer = DiscussionsConfigurationSerializer(configuration)
# breakpoint()
return Response(serializer.data)
def post(self, request, course_key_string: str, **_kwargs) -> Response:
@@ -343,7 +68,7 @@ class DiscussionsConfigurationView(APIView):
"""
course_key = _validate_course_key(course_key_string)
configuration = DiscussionsConfiguration.get(course_key)
serializer = self.Serializer(
serializer = DiscussionsConfigurationSerializer(
configuration,
context={
'user_id': request.user.id,