feat: Adds discussions settings for new discusions experience (#28749)

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.
This commit is contained in:
Kshitij Sobti
2021-10-27 10:41:27 +00:00
committed by GitHub
parent 9ac8e59c26
commit bb0c03123c
23 changed files with 592 additions and 344 deletions

View File

@@ -15,9 +15,15 @@ class DiscussionsConfig(AppConfig):
name = 'openedx.core.djangoapps.discussions'
plugin_app = {
PluginURLs.CONFIG: {
# TODO: Remove the LMS path once its usage has been removed from frontend-app-course-authoring.
ProjectType.LMS: {
PluginURLs.NAMESPACE: '',
PluginURLs.REGEX: r'^discussions/',
PluginURLs.REGEX: r'^discussions/api/',
PluginURLs.RELATIVE_PATH: 'urls',
},
ProjectType.CMS: {
PluginURLs.NAMESPACE: '',
PluginURLs.REGEX: r'^api/discussions/',
PluginURLs.RELATIVE_PATH: 'urls',
},
},

View File

@@ -50,7 +50,7 @@ Consideration
-------------
Most of these configuration entries would be right at home in the
`DiscussionsConfiguration` model in `plugin_settings`, however since they need
``DiscussionsConfiguration`` model in ``plugin_settings``, however since they need
to be available during course import-export, they should be stored in the
course object itself.
@@ -59,13 +59,13 @@ using its usage key. This same mechanism can be used to associate a Unit usage
key with a corresponding discussion id.
However the current mechanism has a few issues. It is stored as a JSON
structure in the `DiscussionsIdMapping` model which has course id and a mapping
structure in the ``DiscussionsIdMapping`` model which has course id and a mapping
of the discussion id to the xblock usage key in a single dict.
This is OK for the existing setup because this is just a caching mechanism and
the source of truth for this mapping is the XBlock itself, which stores the
discussion id. On course publish this information is cached to
`DiscussionsIdMapping`.
``DiscussionsIdMapping``.
For the new discussions system though, this mapping would be the source of
truth for the link between discussions and units, so we should use a model
@@ -76,12 +76,12 @@ Decision
Since the discussions settings need to be stored in the course structure we
should create a new JSON structure in the course that matches the structure
of `plugin_settings`. This can then be used to store not just the settings
of ``plugin_settings``. This can then be used to store not just the settings
for the inbuilt discussions provider, but for any discussions provider in the
future.
When a course is published, we can copy over all the `plugin_settings` to the
course in a JSON field called `dicussions_settings` with the following
When a course is published, we can copy over all the ``plugin_settings`` to the
course in a JSON field called ``discussions_settings`` with the following
structure:
.. code-block:: JSON
@@ -95,7 +95,7 @@ structure:
}
}
The `edx-next` key here represents the provider id, allowing for potentially
The ``edx-next`` key here represents the provider id, allowing for potentially
multiple provider configs to coexist in case of switching providers etc.
Settings outside this key are those that are applicable to all providers. Note
that they may not be supported by all providers though, in which case they will
@@ -103,16 +103,16 @@ simply be ignored.
To store Unit-level discussions settings, we can simply add a boolean field
to the Unit block that specifies whether it is discussable or not. To be
consistent with the above names we can call this field `discussions_enabled`.
consistent with the above names we can call this field ``discussions_enabled``.
A signal can be created using the new Hooks extension system proposed in OEP-50
that is triggered when discussions settings change. This signal can encapsulate
all the data needed for setting up discussions from the modulestore. It can
traverse through all teh Units in the course that match the criterion from the
dicussions settings and provide the needed details as part of the signal data.
discussions settings and provide the needed details as part of the signal data.
A handler for the above signal, we create the discussion topics in
`cs_comments_service` and add a mapping. If an existing unit with discussions
``cs_comments_service`` and add a mapping. If an existing unit with discussions
is removed, we will disable the link but not delete the data.
The discussion grouping at subsections will simply combine the topics from all

View File

@@ -0,0 +1,72 @@
# Generated by Django 2.2.24 on 2021-10-06 04:41
from django.db import migrations, models
import django_mysql.models
class Migration(migrations.Migration):
dependencies = [
('discussions', '0005_auto_20210910_0940'),
]
operations = [
migrations.AddField(
model_name='discussionsconfiguration',
name='enable_graded_units',
field=models.BooleanField(default=False, help_text='If enabled, discussion topics will be created for graded units as well.'),
),
migrations.AddField(
model_name='discussionsconfiguration',
name='enable_in_context',
field=models.BooleanField(default=True, help_text='If enabled, discussion topics will be created for each non-graded unit in the course. A UI for discussions will show up with each unit.'),
),
migrations.AddField(
model_name='discussionsconfiguration',
name='unit_level_visibility',
field=models.BooleanField(default=False, help_text='If enabled, discussions will need to be manually enabled for each unit.'),
),
migrations.AddField(
model_name='historicaldiscussionsconfiguration',
name='enable_graded_units',
field=models.BooleanField(default=False, help_text='If enabled, discussion topics will be created for graded units as well.'),
),
migrations.AddField(
model_name='historicaldiscussionsconfiguration',
name='enable_in_context',
field=models.BooleanField(default=True, help_text='If enabled, discussion topics will be created for each non-graded unit in the course. A UI for discussions will show up with each unit.'),
),
migrations.AddField(
model_name='historicaldiscussionsconfiguration',
name='unit_level_visibility',
field=models.BooleanField(default=False, help_text='If enabled, discussions will need to be manually enabled for each unit.'),
),
migrations.AlterField(
model_name='discussionsconfiguration',
name='provider_type',
field=models.CharField(default='legacy', help_text="The discussion tool/provider's id", max_length=100, verbose_name='Discussion provider'),
),
migrations.AlterField(
model_name='historicaldiscussionsconfiguration',
name='provider_type',
field=models.CharField(default='legacy', help_text="The discussion tool/provider's id", max_length=100, verbose_name='Discussion provider'),
),
migrations.AlterField(
model_name='providerfilter',
name='allow',
field=django_mysql.models.ListCharField(models.CharField(
choices=[('legacy', 'legacy'), ('ed-discuss', 'ed-discuss'), ('inscribe', 'inscribe'),
('piazza', 'piazza'), ('yellowdig', 'yellowdig')], max_length=20), blank=True,
help_text='Comma-separated list of providers to allow, eg: legacy,ed-discuss,inscribe,piazza,yellowdig',
max_length=63, size=3, verbose_name='Allow List'),
),
migrations.AlterField(
model_name='providerfilter',
name='deny',
field=django_mysql.models.ListCharField(models.CharField(
choices=[('legacy', 'legacy'), ('ed-discuss', 'ed-discuss'), ('inscribe', 'inscribe'),
('piazza', 'piazza'), ('yellowdig', 'yellowdig')], max_length=20), blank=True,
help_text='Comma-separated list of providers to deny, eg: legacy,ed-discuss,inscribe,piazza,yellowdig',
max_length=63, size=3, verbose_name='Deny List'),
),
]

View File

@@ -4,14 +4,15 @@ Provide django models to back the discussions app
from __future__ import annotations
import logging
from enum import Enum
from collections import namedtuple
from typing import List, Type, TypeVar
from django.conf import settings
from django.core.exceptions import ValidationError
from django.db import models
from django.utils.translation import gettext_lazy as _
from django_mysql.models import ListCharField
from enum import Enum
from jsonfield import JSONField
from lti_consumer.models import LtiConfiguration
from model_utils.models import TimeStampedModel
@@ -232,17 +233,14 @@ AVAILABLE_PROVIDER_MAP = {
}
def get_supported_providers() -> list[str]:
def get_supported_providers() -> List[str]:
"""
Return the list of supported discussion providers
TODO: Load this from entry points?
"""
providers = [
'legacy',
'piazza',
]
return providers
return list(AVAILABLE_PROVIDER_MAP.keys())
class ProviderFilter(StackedConfigurationModel):
@@ -304,7 +302,7 @@ class ProviderFilter(StackedConfigurationModel):
)
@property
def available_providers(self) -> list[str]:
def available_providers(self) -> List[str]:
"""
Return a filtered list of available providers
"""
@@ -324,12 +322,15 @@ class ProviderFilter(StackedConfigurationModel):
return _providers
@classmethod
def get_available_providers(cls, course_key: CourseKey) -> list[str]:
def get_available_providers(cls, course_key: CourseKey) -> List[str]:
_filter = cls.current(course_key=course_key)
providers = _filter.available_providers
return providers
T = TypeVar('T', bound='DiscussionsConfiguration')
class DiscussionsConfiguration(TimeStampedModel):
"""
Associates a learning context with discussion provider and configuration
@@ -356,6 +357,21 @@ class DiscussionsConfiguration(TimeStampedModel):
null=True,
help_text=_("The LTI configuration data for this context/provider."),
)
enable_in_context = models.BooleanField(
default=True,
help_text=_(
"If enabled, discussion topics will be created for each non-graded unit in the course. "
"A UI for discussions will show up with each unit."
)
)
enable_graded_units = models.BooleanField(
default=False,
help_text=_("If enabled, discussion topics will be created for graded units as well.")
)
unit_level_visibility = models.BooleanField(
default=False,
help_text=_("If enabled, discussions will need to be manually enabled for each unit.")
)
plugin_configuration = JSONField(
blank=True,
default={},
@@ -366,6 +382,7 @@ class DiscussionsConfiguration(TimeStampedModel):
max_length=100,
verbose_name=_("Discussion provider"),
help_text=_("The discussion tool/provider's id"),
default=DEFAULT_PROVIDER_TYPE,
)
history = HistoricalRecords()
@@ -407,9 +424,8 @@ class DiscussionsConfiguration(TimeStampedModel):
configuration = cls.get(context_key)
return configuration.enabled
# pylint: disable=undefined-variable
@classmethod
def get(cls, context_key: CourseKey) -> cls:
def get(cls: Type[T], context_key: CourseKey) -> T:
"""
Lookup a model by context_key
"""
@@ -423,14 +439,12 @@ class DiscussionsConfiguration(TimeStampedModel):
)
return configuration
# pylint: enable=undefined-variable
@property
def available_providers(self) -> list[str]:
def available_providers(self) -> List[str]:
return ProviderFilter.current(course_key=self.context_key).available_providers
@classmethod
def get_available_providers(cls, context_key: CourseKey) -> list[str]:
def get_available_providers(cls, context_key: CourseKey) -> List[str]:
return ProviderFilter.current(course_key=context_key).available_providers
@classmethod

View File

@@ -1,17 +1,16 @@
"""
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 opaque_keys.edx.keys import CourseKey
from rest_framework import serializers
from xmodule.modulestore.django import modulestore
from lms.djangoapps.discussion.rest_api.serializers import DiscussionSettingsSerializer
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):
@@ -172,10 +171,23 @@ class DiscussionsConfigurationSerializer(serializers.ModelSerializer):
class Meta:
model = DiscussionsConfiguration
course_fields = [
'provider_type',
'enable_in_context',
'enable_graded_units',
'unit_level_visibility',
]
fields = [
'enabled',
'provider_type',
]
] + 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):
"""
@@ -187,13 +199,11 @@ class DiscussionsConfigurationSerializer(serializers.ModelSerializer):
"""
Transform the *incoming* primitive data into a native value.
"""
payload = {
'context_key': data.get('course_key', ''),
'enabled': data.get('enabled', False),
payload = super().to_internal_value(data)
payload.update({
'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:
@@ -237,14 +247,17 @@ class DiscussionsConfigurationSerializer(serializers.ModelSerializer):
"""
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.context_key)
instance = self._update_plugin_configuration(instance, validated_data)
instance = self._update_lti(instance, validated_data)
instance.save()
return instance
@@ -252,7 +265,6 @@ class DiscussionsConfigurationSerializer(serializers.ModelSerializer):
self,
instance: DiscussionsConfiguration,
validated_data: dict,
course_key: CourseKey
) -> DiscussionsConfiguration:
"""
Update LtiConfiguration
@@ -268,7 +280,7 @@ class DiscussionsConfigurationSerializer(serializers.ModelSerializer):
data=lti_configuration_data,
partial=True,
context={
'pii_sharing_allowed': get_lti_pii_sharing_state_for_course(course_key),
'pii_sharing_allowed': get_lti_pii_sharing_state_for_course(instance.context_key),
}
)
if lti_serializer.is_valid(raise_exception=True):
@@ -284,23 +296,140 @@ class DiscussionsConfigurationSerializer(serializers.ModelSerializer):
"""
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:
course_key = instance.context_key
course = get_course_by_id(course_key)
legacy_settings = LegacySettingsSerializer(
course,
self._get_course(),
context={
'user_id': self.context['user_id'],
},
data=validated_data.get('plugin_configuration', {}),
data=plugin_configuration,
)
if legacy_settings.is_valid(raise_exception=True):
legacy_settings.save()
instance.plugin_configuration = {}
instance.plugin_configuration = {
"group_at_subsection": plugin_configuration.get("group_at_subsection", False)
}
else:
instance.plugin_configuration = validated_data.get('plugin_configuration') or {}
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

View File

@@ -162,7 +162,7 @@ class DiscussionsConfigurationModelTest(TestCase):
assert configuration.enabled # by default
assert configuration.lti_configuration is None
assert len(configuration.plugin_configuration.keys()) == 0
assert not configuration.provider_type
assert configuration.provider_type == 'legacy'
def test_get_with_values(self):
"""
@@ -236,7 +236,7 @@ class DiscussionsConfigurationModelTest(TestCase):
assert configuration.enabled
assert not configuration.lti_configuration
assert not configuration.plugin_configuration
assert not configuration.provider_type
assert configuration.provider_type == 'legacy'
def test_get_explicit(self):
"""

View File

@@ -2,23 +2,20 @@
Test app view logic
"""
# pylint: disable=test-inherits-tests
from datetime import datetime, timedelta, timezone
import unittest
import itertools
from contextlib import contextmanager
from datetime import datetime, timedelta, timezone
import ddt
from django.conf import settings
from django.core.exceptions import ValidationError
from django.urls import reverse
from lti_consumer.models import CourseAllowPIISharingInLTIFlag
from rest_framework import status
from rest_framework.test import APITestCase
from xmodule.modulestore.tests.django_utils import CourseUserType
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore import ModuleStoreEnum
from xmodule.modulestore.tests.django_utils import CourseUserType, ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory
from ..models import AVAILABLE_PROVIDER_MAP, DEFAULT_CONFIG_ENABLED, DEFAULT_PROVIDER_TYPE
DATA_LEGACY_COHORTS = {
@@ -45,7 +42,6 @@ DATA_LTI_CONFIGURATION = {
}
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'URLs are only configured in LMS')
class ApiTest(ModuleStoreTestCase, APITestCase):
"""
Test basic API operations
@@ -294,30 +290,34 @@ class DataTest(AuthorizedApiTest):
"""
Check validation of basic configuration
"""
with self.assertRaises(ValidationError):
response = self._post(payload)
response = self._post(payload)
assert status.is_client_error(response.status_code)
assert 'enabled' in response.json()
response = self._get()
self._assert_defaults(response)
def test_post_lti_valid(self):
@ddt.data(
*DATA_LTI_CONFIGURATION.items()
)
@ddt.unpack
def test_post_lti_valid(self, key, value):
"""
Check we can set LTI configuration
"""
provider_type = 'piazza'
for key, value in DATA_LTI_CONFIGURATION.items():
payload = {
'enabled': True,
'provider_type': provider_type,
'lti_configuration': {
key: value,
}
payload = {
'enabled': True,
'provider_type': provider_type,
'lti_configuration': {
key: value,
}
response = self._post(payload)
response = self._get()
data = response.json()
assert data['enabled']
assert data['provider_type'] == provider_type
assert data['lti_configuration'][key] == value
}
self._post(payload)
response = self._get()
data = response.json()
assert data['enabled']
assert data['provider_type'] == provider_type
assert data['lti_configuration'][key] == value
def test_post_lti_invalid(self):
"""
@@ -481,6 +481,48 @@ class DataTest(AuthorizedApiTest):
assert data['provider_type'] == 'legacy'
assert not data['plugin_configuration']['allow_anonymous']
@ddt.data(
*itertools.product(
["enable_in_context", "enable_graded_units", "unit_level_visibility"],
[True, False],
),
("provider_type", "piazza"),
)
@ddt.unpack
def test_change_course_fields(self, field, value):
"""
Test changing fields that are saved to the course
"""
payload = {
field: value
}
response = self._post(payload)
data = response.json()
assert data[field] == value
course = self.store.get_course(self.course.id)
assert course.discussions_settings[field] == value
def test_change_plugin_configuration(self):
"""
Test changing plugin config that is saved to the course
"""
payload = {
"provider_type": "piazza",
"plugin_configuration": {
"allow_anonymous": False,
"custom_field": "custom_value",
},
}
response = self._post(payload)
data = response.json()
assert data["plugin_configuration"] == payload["plugin_configuration"]
course = self.store.get_course(self.course.id)
# Only configuration fields not stored in the course, or
# directly in the model should be stored here.
assert course.discussions_settings["piazza"] == {
"custom_field": "custom_value",
}
@ddt.data(*[
user_type.name for user_type in CourseUserType
if user_type not in { # pylint: disable=undefined-variable

View File

@@ -8,7 +8,7 @@ from .views import DiscussionsConfigurationView
urlpatterns = [
url(
r'^api/v0/(?P<course_key_string>.+)$',
r'^v0/(?P<course_key_string>.+)$',
DiscussionsConfigurationView.as_view(),
name='discussions',
),

View File

@@ -0,0 +1,191 @@
"""
Shared utility code related to discussions.
"""
import logging
from typing import Dict, List, Optional, Tuple
from opaque_keys.edx.keys import CourseKey
from lms.djangoapps.courseware.access import has_access
from openedx.core.djangoapps.course_groups.cohorts import get_cohort_names, is_course_cohorted
from openedx.core.djangoapps.django_comment_common.models import CourseDiscussionSettings
from openedx.core.lib.cache_utils import request_cached
from openedx.core.lib.courses import get_course_by_id
from openedx.core.lib.xblock_builtin.xblock_discussion.xblock_discussion import DiscussionXBlock
from openedx.core.types import User
from xmodule.course_module import CourseBlock
from xmodule.modulestore.django import modulestore
from xmodule.partitions.partitions import ENROLLMENT_TRACK_PARTITION_ID, Group
from xmodule.partitions.partitions_service import PartitionService
log = logging.getLogger(__name__)
def get_divided_discussions(
course: CourseBlock,
discussion_settings: CourseDiscussionSettings,
) -> Tuple[List[str], List[str]]:
"""
Returns the course-wide and inline divided discussion ids separately.
"""
divided_course_wide_discussions = []
divided_inline_discussions = []
course_wide_discussions = [topic['id'] for __, topic in course.discussion_topics.items()]
all_discussions = get_discussion_categories_ids(course, None)
for divided_discussion_id in discussion_settings.divided_discussions:
if divided_discussion_id in course_wide_discussions:
divided_course_wide_discussions.append(divided_discussion_id)
elif divided_discussion_id in all_discussions:
divided_inline_discussions.append(divided_discussion_id)
return divided_course_wide_discussions, divided_inline_discussions
def get_discussion_categories_ids(course: CourseBlock, user: Optional[User]) -> List[str]:
"""
Returns a list of available ids of categories for the course that
are accessible to the given user.
Args:
course: Course for which to get the ids.
user: User to check for access.
"""
accessible_discussion_ids = [
xblock.discussion_id for xblock in get_accessible_discussion_xblocks(course, user)
]
return course.top_level_discussion_topic_ids + accessible_discussion_ids
def get_accessible_discussion_xblocks(
course: CourseBlock,
user: Optional[User],
) -> List[DiscussionXBlock]:
"""
Return a list of all valid discussion xblocks in this course that
are accessible to the given user.
"""
include_all = getattr(user, 'is_community_ta', False)
return get_accessible_discussion_xblocks_by_course_id(course.id, user, include_all=include_all)
@request_cached()
def get_accessible_discussion_xblocks_by_course_id(
course_id: CourseKey,
user: Optional[User] = None,
include_all: bool = False
) -> List[DiscussionXBlock]:
"""
Return a list of all valid discussion xblocks in this course.
Checks for the given user's access if include_all is False.
"""
all_xblocks = modulestore().get_items(course_id, qualifiers={'category': 'discussion'}, include_orphans=False)
return [
xblock for xblock in all_xblocks
if has_required_keys(xblock) and (include_all or has_access(user, 'load', xblock, course_id))
]
def available_division_schemes(course_key: CourseKey) -> List[str]:
"""
Returns a list of possible discussion division schemes for this course.
This takes into account if cohorts are enabled and if there are multiple
enrollment tracks. If no schemes are available, returns an empty list.
Args:
course_key: CourseKey
Returns: list of possible division schemes (for example, CourseDiscussionSettings.COHORT)
"""
available_schemes = []
if is_course_cohorted(course_key):
available_schemes.append(CourseDiscussionSettings.COHORT)
if enrollment_track_group_count(course_key) > 1:
available_schemes.append(CourseDiscussionSettings.ENROLLMENT_TRACK)
return available_schemes
def has_required_keys(xblock: DiscussionXBlock):
"""
Returns True iff xblock has the proper attributes for generating metadata
with get_discussion_id_map_entry()
"""
for key in ('discussion_id', 'discussion_category', 'discussion_target'):
if getattr(xblock, key, None) is None:
log.debug(
"Required key '%s' not in discussion %s, leaving out of category map",
key,
xblock.location
)
return False
return True
def enrollment_track_group_count(course_key: CourseKey) -> int:
"""
Returns the count of possible enrollment track division schemes for this course.
Args:
course_key: CourseKey
Returns:
Count of enrollment track division scheme
"""
return len(_get_enrollment_track_groups(course_key))
def _get_enrollment_track_groups(course_key: CourseKey) -> List[Group]:
"""
Helper method that returns an array of the Groups in the EnrollmentTrackUserPartition for the given course.
If no such partition exists on the course, an empty array is returned.
"""
partition_service = PartitionService(course_key)
partition = partition_service.get_user_partition(ENROLLMENT_TRACK_PARTITION_ID)
return partition.groups if partition else []
def get_group_names_by_id(course_discussion_settings: CourseDiscussionSettings) -> Dict[str, str]:
"""
Creates of a dict of group_id to learner-facing group names, for the division_scheme
in use as specified by course_discussion_settings.
Args:
course_discussion_settings: CourseDiscussionSettings model instance
Returns: dict of group_id to learner-facing group names. If no division_scheme
is in use, returns an empty dict.
"""
division_scheme = get_course_division_scheme(course_discussion_settings)
course_key = course_discussion_settings.course_id
if division_scheme == CourseDiscussionSettings.COHORT:
return get_cohort_names(get_course_by_id(course_key))
elif division_scheme == CourseDiscussionSettings.ENROLLMENT_TRACK:
# We negate the group_ids from dynamic partitions so that they will not conflict
# with cohort IDs (which are an auto-incrementing integer field, starting at 1).
return {-1 * group.id: group.name for group in _get_enrollment_track_groups(course_key)}
else:
return {}
def get_course_division_scheme(course_discussion_settings: CourseDiscussionSettings) -> str:
"""
Returns the division scheme used by the course, from the course discussion settings.
Args:
course_discussion_settings (CourseDiscussionSettings): An instance of the CourseDiscussionSettings model
Returns:
(string) Returns 'cohort', 'enrollment_track' or 'none'
depending on the division scheme used by the course.
"""
division_scheme = course_discussion_settings.division_scheme
if (
division_scheme == CourseDiscussionSettings.COHORT and
not is_course_cohorted(course_discussion_settings.course_id)
):
division_scheme = CourseDiscussionSettings.NONE
elif (
division_scheme == CourseDiscussionSettings.ENROLLMENT_TRACK and
enrollment_track_group_count(course_discussion_settings.course_id) <= 1
):
division_scheme = CourseDiscussionSettings.NONE
return division_scheme

View File

@@ -107,7 +107,8 @@ class DiscussionsConfigurationView(APIView):
partial=True,
)
if serializer.is_valid(raise_exception=True):
if serializer.validated_data['provider_type'] != configuration.provider_type:
new_provider_type = serializer.validated_data.get('provider_type', None)
if new_provider_type is not None and new_provider_type != configuration.provider_type:
check_course_permissions(course, request.user, 'change_provider')
serializer.save()