Files
edx-platform/openedx/core/djangoapps/discussions/serializers.py
Kshitij Sobti 346257dadf fix: discussion topic links not created in some cases (#31032)
This commit attempts to fix cases where dicussion topic links aren't created
during a provider change.

It does so by eliminating areas where there could be desynchronisation between
the configuration the course configuration in Mogo and the discussion config
in MySQL.

The topic creation code now uses the database version of the config which is
more recent.
2022-09-29 17:16:03 +05:00

477 lines
18 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 xmodule.modulestore.django import modulestore
from lms.djangoapps.discussion.toggles import ENABLE_REPORTED_CONTENT_EMAIL_NOTIFICATIONS
from openedx.core.djangoapps.discussions.tasks import update_discussions_settings_from_course_task
from openedx.core.djangoapps.django_comment_common.models import CourseDiscussionSettings
from openedx.core.lib.courses import get_course_by_id
from .models import DiscussionsConfiguration, Provider
from .utils import available_division_schemes, get_divided_discussions
class LtiSerializer(serializers.ModelSerializer):
"""
Serialize LtiConfiguration responses
"""
pii_share_email = serializers.BooleanField(required=False)
pii_share_username = serializers.BooleanField(required=False)
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 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',
'reported_content_email_notifications'
]
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
active_provider = instance.provider_type
provider_type = self.context.get('provider_type') or active_provider
payload = super().to_representation(instance)
course_pii_sharing_allowed = get_lti_pii_sharing_state_for_course(course_key)
# LTI configuration is only stored for the active provider.
if provider_type == active_provider:
lti_configuration = LtiSerializer(instance=instance.lti_configuration)
lti_configuration_data = lti_configuration.data
plugin_configuration = instance.plugin_configuration
else:
lti_configuration_data = {}
plugin_configuration = {}
course = get_course_by_id(course_key)
if provider_type in [Provider.LEGACY, Provider.OPEN_EDX]:
legacy_settings = LegacySettingsSerializer(course, data=plugin_configuration)
if legacy_settings.is_valid(raise_exception=True):
plugin_configuration = legacy_settings.data
if provider_type == Provider.OPEN_EDX:
plugin_configuration.update({
"group_at_subsection": instance.plugin_configuration.get("group_at_subsection", False)
})
lti_configuration_data.update({'pii_sharing_allowed': course_pii_sharing_allowed})
payload.update({
'provider_type': provider_type,
'lti_configuration': lti_configuration_data,
'plugin_configuration': plugin_configuration,
})
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()
update_discussions_settings_from_course_task.delay(str(instance.context_key))
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
if updated_provider_type in [Provider.LEGACY, Provider.OPEN_EDX]:
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
course = self._get_course()
for key in self.Meta.course_fields:
value = validated_data.get(key)
course_value = course.discussions_settings.get(key, None)
# Delay loading course till we know something has actually been updated
if value is not None and value != course_value:
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.
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(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()
reported_content_email_notifications = 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.
"""
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),
'reported_content_email_notifications': instance.reported_content_email_notifications,
'reported_content_email_notifications_flag':
ENABLE_REPORTED_CONTENT_EMAIL_NOTIFICATIONS.is_enabled(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
class DiscussionsProviderSerializer(serializers.Serializer):
"""
Serializer for a discussion provider
"""
features = serializers.ListField(child=serializers.CharField(), help_text="Features supported by the provider")
supports_lti = serializers.BooleanField(default=False, help_text="Whether the provider supports LTI")
external_links = serializers.DictField(help_text="External documentation and links for provider")
messages = serializers.ListField(child=serializers.CharField(), help_text="Custom messaging for provider")
has_full_support = serializers.BooleanField(help_text="Whether the provider is fully supported")
admin_only_config = serializers.BooleanField(help_text="Whether the provider can only be configured by admins")
class DiscussionsFeatureSerializer(serializers.Serializer):
"""
Serializer for discussions features
"""
id = serializers.CharField(help_text="Feature ID")
feature_support_type = serializers.CharField(help_text="Feature support level classification")
class DiscussionsProvidersSerializer(serializers.Serializer):
"""
Serializer for discussion providers.
"""
active = serializers.CharField(
read_only=True,
help_text="The current active provider",
)
features = serializers.ListField(
child=DiscussionsFeatureSerializer(read_only=True),
help_text="Features support classification levels",
)
available = serializers.DictField(
child=DiscussionsProviderSerializer(read_only=True),
help_text="List of available providers",
)