Files
edx-platform/openedx/core/djangoapps/discussions/serializers.py
Kshitij Sobti 79cd0b1ef8 feat: Adds discussions settings for new discusions experience [BD-38] [TNL-8621] [BB-4854] (#29131)
* feat: Adds discussions settings for new discusions experience
This commit adds new discussions settings for the new discussions experience. These are stored in the course so they can be a part of course import/export flow.
These are also added to the discussions configuraiton API to allow MFEs to update the settings.
The discussions API is currently available via LMS, however that means it cannot save changes to the modulestore. This also adds the API to the studio config so it can now also be accessed from studio and be used to save course settings.

* fix: tests
2021-10-28 11:56:17 +05:00

436 lines
16 KiB
Python

"""
Serializers for Discussion views.
"""
from django.core.exceptions import ValidationError
from lti_consumer.api import get_lti_pii_sharing_state_for_course
from lti_consumer.models import LtiConfiguration
from rest_framework import serializers
from openedx.core.djangoapps.django_comment_common.models import CourseDiscussionSettings
from openedx.core.lib.courses import get_course_by_id
from xmodule.modulestore.django import modulestore
from .models import AVAILABLE_PROVIDER_MAP, DEFAULT_PROVIDER_TYPE, DiscussionsConfiguration, Features
from .utils import available_division_schemes, get_divided_discussions
class LtiSerializer(serializers.ModelSerializer):
"""
Serialize LtiConfiguration responses
"""
class Meta:
model = LtiConfiguration
fields = [
'pii_share_username',
'pii_share_email',
'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 to_representation(self, instance):
representation = super().to_representation(instance)
if not self.context.get('pii_sharing_allowed'):
representation.pop('pii_share_username')
representation.pop('pii_share_email')
return representation
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
pii_sharing_allowed = self.context.get('pii_sharing_allowed', False)
if validated_data:
for key, value in validated_data.items():
if key.startswith('pii_') and not pii_sharing_allowed:
raise serializers.ValidationError(
"Cannot enable sending PII data until PII sharing for LTI is enabled for the course."
)
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
course_fields = [
'provider_type',
'enable_in_context',
'enable_graded_units',
'unit_level_visibility',
]
fields = [
'enabled',
] + course_fields
def _get_course(self):
"""
Get course and save it in the context, so it doesn't need to be reloaded.
"""
if self.context.get('course') is None:
self.context['course'] = get_course_by_id(self.instance.context_key)
return self.context['course']
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 = super().to_internal_value(data)
payload.update({
'lti_configuration': data.get('lti_configuration', {}),
'plugin_configuration': data.get('plugin_configuration', {}),
})
return payload
def to_representation(self, instance: DiscussionsConfiguration) -> dict:
"""
Serialize data into a dictionary, to be used as a response
"""
course_key = instance.context_key
payload = super().to_representation(instance)
lti_configuration_data = {}
if instance.supports_lti():
lti_configuration = LtiSerializer(instance.lti_configuration, context={
'pii_sharing_allowed': get_lti_pii_sharing_state_for_course(course_key),
})
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 = 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 = [
{'id': feature.value, 'feature_support_type': feature.feature_support_type}
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': AVAILABLE_PROVIDER_MAP,
},
})
return payload
def update(self, instance: DiscussionsConfiguration, validated_data: dict) -> DiscussionsConfiguration:
"""
Update and save an existing instance
"""
# This needs to check which fields have changed, so do it before
# fields are copied over.
instance = self._update_course_configuration(instance, validated_data)
instance = self._update_plugin_configuration(instance, validated_data)
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.save()
return instance
def _update_lti(
self,
instance: DiscussionsConfiguration,
validated_data: dict,
) -> DiscussionsConfiguration:
"""
Update LtiConfiguration
"""
lti_configuration_data = validated_data.get('lti_configuration')
if not instance.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,
context={
'pii_sharing_allowed': get_lti_pii_sharing_state_for_course(instance.context_key),
}
)
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
"""
plugin_configuration = validated_data.pop('plugin_configuration', {})
updated_provider_type = validated_data.get('provider_type') or instance.provider_type
will_support_legacy = bool(
updated_provider_type == 'legacy'
)
if will_support_legacy:
legacy_settings = LegacySettingsSerializer(
self._get_course(),
context={
'user_id': self.context['user_id'],
},
data=plugin_configuration,
)
if legacy_settings.is_valid(raise_exception=True):
legacy_settings.save()
instance.plugin_configuration = {
"group_at_subsection": plugin_configuration.get("group_at_subsection", False)
}
else:
instance.plugin_configuration = plugin_configuration
return instance
def _update_course_configuration(
self,
instance: DiscussionsConfiguration,
validated_data: dict,
) -> DiscussionsConfiguration:
"""
Update configuration settings that are stored in the course.
"""
save = False
updated_provider_type = validated_data.get('provider_type') or instance.provider_type
for key in self.Meta.course_fields:
value = validated_data.get(key)
# Delay loading course till we know something has actually been updated
if value is not None and value != getattr(instance, key):
self._get_course().discussions_settings[key] = value
save = True
new_plugin_config = validated_data.get('plugin_configuration', None)
if new_plugin_config and new_plugin_config != instance.plugin_configuration:
save = True
# Any fields here that aren't already stored in the course structure
# or in other models should be stored here.
self._get_course().discussions_settings[updated_provider_type] = {
key: value
for key, value in new_plugin_config.items()
if (
key not in LegacySettingsSerializer.Meta.fields and
key not in LegacySettingsSerializer.Meta.fields_cohorts
)
}
if save:
modulestore().update_item(self._get_course(), self.context['user_id'])
return instance
class DiscussionSettingsSerializer(serializers.Serializer):
"""
Serializer for course discussion settings.
"""
divided_discussions = serializers.ListField(
child=serializers.CharField(),
write_only=True,
)
divided_course_wide_discussions = serializers.ListField(
child=serializers.CharField(),
read_only=True,
)
divided_inline_discussions = serializers.ListField(
child=serializers.CharField(),
read_only=True,
)
always_divide_inline_discussions = serializers.BooleanField()
division_scheme = serializers.CharField()
def to_internal_value(self, data: dict) -> dict:
"""
Transform the *incoming* primitive data into a native value.
"""
payload = super().to_internal_value(data) or {}
course = self.context['course']
instance = self.context['settings']
if any(item in data for item in ('divided_course_wide_discussions', 'divided_inline_discussions')):
divided_course_wide_discussions, divided_inline_discussions = get_divided_discussions(
course, instance
)
divided_course_wide_discussions = data.get(
'divided_course_wide_discussions',
divided_course_wide_discussions
)
divided_inline_discussions = data.get('divided_inline_discussions', divided_inline_discussions)
try:
payload['divided_discussions'] = divided_course_wide_discussions + divided_inline_discussions
except TypeError as error:
raise ValidationError(str(error)) from error
for item in ('always_divide_inline_discussions', 'division_scheme'):
if item in data:
payload[item] = data[item]
return payload
def to_representation(self, instance: CourseDiscussionSettings) -> dict:
"""
Return a serialized representation of the course discussion settings.
"""
payload = super().to_representation(instance)
course = self.context['course']
instance = self.context['settings']
course_key = course.id
divided_course_wide_discussions, divided_inline_discussions = get_divided_discussions(
course, instance
)
payload = {
'id': instance.id,
'divided_inline_discussions': divided_inline_discussions,
'divided_course_wide_discussions': divided_course_wide_discussions,
'always_divide_inline_discussions': instance.always_divide_inline_discussions,
'division_scheme': instance.division_scheme,
'available_division_schemes': available_division_schemes(course_key)
}
return payload
def create(self, validated_data):
"""
This method intentionally left empty
"""
def update(self, instance: CourseDiscussionSettings, validated_data: dict) -> CourseDiscussionSettings:
"""
Update and save an existing instance
"""
if not any(field in validated_data for field in self.fields):
raise ValidationError('Bad request')
try:
instance.update(validated_data)
except ValueError as e:
raise ValidationError(str(e)) from e
return instance