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:
@@ -75,6 +75,7 @@ class CourseMetadata:
|
||||
'default_tab',
|
||||
'highlights_enabled_for_messaging',
|
||||
'is_onboarding_exam',
|
||||
'discussions_settings',
|
||||
]
|
||||
|
||||
@classmethod
|
||||
|
||||
@@ -1516,7 +1516,6 @@ INSTALLED_APPS = [
|
||||
|
||||
# Discussion
|
||||
'openedx.core.djangoapps.django_comment_common',
|
||||
'openedx.core.djangoapps.discussions',
|
||||
|
||||
# for course creator table
|
||||
'django.contrib.admin',
|
||||
|
||||
@@ -406,6 +406,16 @@ class CourseFields: # lint-amnesty, pylint: disable=missing-class-docstring
|
||||
"If false, they are sorted chronologically by creation date and time."
|
||||
)
|
||||
)
|
||||
discussions_settings = Dict(
|
||||
display_name=_("Discussions Plugin Settings"),
|
||||
scope=Scope.settings,
|
||||
help=_("Settings for discussions plugins."),
|
||||
default={
|
||||
"enable_in_context": True,
|
||||
"enable_graded_units": False,
|
||||
"unit_level_visibility": False,
|
||||
}
|
||||
)
|
||||
announcement = Date(
|
||||
display_name=_("Course Announcement Date"),
|
||||
help=_("Enter the date to announce your course."),
|
||||
|
||||
@@ -10,7 +10,7 @@ from common.djangoapps.student.tests.factories import BetaTesterFactory
|
||||
from lms.djangoapps.courseware.access import has_access
|
||||
from lms.djangoapps.ccx.tests.test_overrides import inject_field_overrides
|
||||
from lms.djangoapps.courseware.field_overrides import OverrideFieldData, OverrideModulestoreFieldData
|
||||
from lms.djangoapps.discussion.django_comment_client.utils import get_accessible_discussion_xblocks
|
||||
from openedx.core.djangoapps.discussions.utils import get_accessible_discussion_xblocks
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
|
||||
|
||||
|
||||
@@ -30,6 +30,13 @@ from lms.djangoapps.teams.tests.factories import CourseTeamFactory
|
||||
from openedx.core.djangoapps.course_groups import cohorts
|
||||
from openedx.core.djangoapps.course_groups.cohorts import set_course_cohorted
|
||||
from openedx.core.djangoapps.course_groups.tests.helpers import CohortFactory, config_course_cohorts
|
||||
from openedx.core.djangoapps.discussions.utils import (
|
||||
available_division_schemes,
|
||||
get_accessible_discussion_xblocks,
|
||||
get_discussion_categories_ids,
|
||||
get_group_names_by_id,
|
||||
has_required_keys,
|
||||
)
|
||||
from openedx.core.djangoapps.django_comment_common.comment_client.utils import (
|
||||
CommentClientMaintenanceError,
|
||||
perform_request,
|
||||
@@ -200,7 +207,7 @@ class CoursewareContextTestCase(ModuleStoreTestCase):
|
||||
assert test_discussion.location not in self.store.get_orphans(course.id)
|
||||
|
||||
# Assert that there is only one discussion xblock in the course at the moment.
|
||||
assert len(utils.get_accessible_discussion_xblocks(course, self.user)) == 1
|
||||
assert len(get_accessible_discussion_xblocks(course, self.user)) == 1
|
||||
|
||||
# The above call is request cached, so we need to clear it for this test.
|
||||
RequestCache.clear_all_namespaces()
|
||||
@@ -211,7 +218,7 @@ class CoursewareContextTestCase(ModuleStoreTestCase):
|
||||
# Assert that the discussion xblock is an orphan.
|
||||
assert orphan in self.store.get_orphans(course.id)
|
||||
|
||||
assert len(utils.get_accessible_discussion_xblocks(course, self.user)) == expected_discussion_xblocks
|
||||
assert len(get_accessible_discussion_xblocks(course, self.user)) == expected_discussion_xblocks
|
||||
|
||||
|
||||
class CachedDiscussionIdMapTestCase(ModuleStoreTestCase):
|
||||
@@ -277,8 +284,8 @@ class CachedDiscussionIdMapTestCase(ModuleStoreTestCase):
|
||||
utils.get_cached_discussion_key(self.course.id, 'test_discussion_id')
|
||||
|
||||
def test_xblock_does_not_have_required_keys(self):
|
||||
assert utils.has_required_keys(self.discussion)
|
||||
assert not utils.has_required_keys(self.bad_discussion)
|
||||
assert has_required_keys(self.discussion)
|
||||
assert not has_required_keys(self.bad_discussion)
|
||||
|
||||
def verify_discussion_metadata(self):
|
||||
"""Retrieves the metadata for self.discussion and self.discussion2 and verifies that it is correct"""
|
||||
@@ -988,7 +995,7 @@ class CategoryMapTestCase(CategoryMapTestMixin, ModuleStoreTestCase):
|
||||
)
|
||||
|
||||
def test_ids_empty(self):
|
||||
assert utils.get_discussion_categories_ids(self.course, self.user) == []
|
||||
assert get_discussion_categories_ids(self.course, self.user) == []
|
||||
|
||||
def test_ids_configured_topics(self):
|
||||
self.course.discussion_topics = {
|
||||
@@ -996,8 +1003,7 @@ class CategoryMapTestCase(CategoryMapTestMixin, ModuleStoreTestCase):
|
||||
"Topic B": {"id": "Topic_B"},
|
||||
"Topic C": {"id": "Topic_C"}
|
||||
}
|
||||
assert len(utils.get_discussion_categories_ids(self.course, self.user)) ==\
|
||||
len(["Topic_A", "Topic_B", "Topic_C"])
|
||||
assert len(get_discussion_categories_ids(self.course, self.user)) == len(["Topic_A", "Topic_B", "Topic_C"])
|
||||
|
||||
def test_ids_inline(self):
|
||||
self.create_discussion("Chapter 1", "Discussion 1")
|
||||
@@ -1006,7 +1012,7 @@ class CategoryMapTestCase(CategoryMapTestMixin, ModuleStoreTestCase):
|
||||
self.create_discussion("Chapter 2 / Section 1 / Subsection 1", "Discussion")
|
||||
self.create_discussion("Chapter 2 / Section 1 / Subsection 2", "Discussion")
|
||||
self.create_discussion("Chapter 3 / Section 1", "Discussion")
|
||||
assert len(utils.get_discussion_categories_ids(self.course, self.user)) ==\
|
||||
assert len(get_discussion_categories_ids(self.course, self.user)) == \
|
||||
len(["discussion1", "discussion2", "discussion3", "discussion4", "discussion5", "discussion6"])
|
||||
|
||||
def test_ids_mixed(self):
|
||||
@@ -1018,7 +1024,7 @@ class CategoryMapTestCase(CategoryMapTestMixin, ModuleStoreTestCase):
|
||||
self.create_discussion("Chapter 1", "Discussion 1")
|
||||
self.create_discussion("Chapter 2", "Discussion")
|
||||
self.create_discussion("Chapter 2 / Section 1 / Subsection 1", "Discussion")
|
||||
assert len(utils.get_discussion_categories_ids(self.course, self.user)) ==\
|
||||
assert len(get_discussion_categories_ids(self.course, self.user)) == \
|
||||
len(["Topic_A", "Topic_B", "Topic_C", "discussion1", "discussion2", "discussion3"])
|
||||
|
||||
|
||||
@@ -1421,7 +1427,7 @@ class CourseDiscussionDivisionEnabledTestCase(ModuleStoreTestCase):
|
||||
def test_discussion_division_disabled(self):
|
||||
course_discussion_settings = CourseDiscussionSettings.get(self.course.id)
|
||||
assert not utils.course_discussion_division_enabled(course_discussion_settings)
|
||||
assert [] == utils.available_division_schemes(self.course.id)
|
||||
assert [] == available_division_schemes(self.course.id)
|
||||
|
||||
def test_discussion_division_by_cohort(self):
|
||||
set_discussion_division_settings(
|
||||
@@ -1429,13 +1435,13 @@ class CourseDiscussionDivisionEnabledTestCase(ModuleStoreTestCase):
|
||||
)
|
||||
# Because cohorts are disabled, discussion division is not enabled.
|
||||
assert not utils.course_discussion_division_enabled(CourseDiscussionSettings.get(self.course.id))
|
||||
assert [] == utils.available_division_schemes(self.course.id)
|
||||
assert [] == available_division_schemes(self.course.id)
|
||||
# Now enable cohorts, which will cause discussions to be divided.
|
||||
set_discussion_division_settings(
|
||||
self.course.id, enable_cohorts=True, division_scheme=CourseDiscussionSettings.COHORT
|
||||
)
|
||||
assert utils.course_discussion_division_enabled(CourseDiscussionSettings.get(self.course.id))
|
||||
assert [CourseDiscussionSettings.COHORT] == utils.available_division_schemes(self.course.id)
|
||||
assert [CourseDiscussionSettings.COHORT] == available_division_schemes(self.course.id)
|
||||
|
||||
def test_discussion_division_by_enrollment_track(self):
|
||||
set_discussion_division_settings(
|
||||
@@ -1443,12 +1449,12 @@ class CourseDiscussionDivisionEnabledTestCase(ModuleStoreTestCase):
|
||||
)
|
||||
# Only a single enrollment track exists, so discussion division is not enabled.
|
||||
assert not utils.course_discussion_division_enabled(CourseDiscussionSettings.get(self.course.id))
|
||||
assert [] == utils.available_division_schemes(self.course.id)
|
||||
assert [] == available_division_schemes(self.course.id)
|
||||
|
||||
# Now create a second CourseMode, which will cause discussions to be divided.
|
||||
CourseModeFactory.create(course_id=self.course.id, mode_slug=CourseMode.VERIFIED)
|
||||
assert utils.course_discussion_division_enabled(CourseDiscussionSettings.get(self.course.id))
|
||||
assert [CourseDiscussionSettings.ENROLLMENT_TRACK] == utils.available_division_schemes(self.course.id)
|
||||
assert [CourseDiscussionSettings.ENROLLMENT_TRACK] == available_division_schemes(self.course.id)
|
||||
|
||||
|
||||
class GroupNameTestCase(ModuleStoreTestCase):
|
||||
@@ -1472,7 +1478,7 @@ class GroupNameTestCase(ModuleStoreTestCase):
|
||||
|
||||
def test_discussion_division_disabled(self):
|
||||
course_discussion_settings = CourseDiscussionSettings.get(self.course.id)
|
||||
assert {} == utils.get_group_names_by_id(course_discussion_settings)
|
||||
assert {} == get_group_names_by_id(course_discussion_settings)
|
||||
assert utils.get_group_name((- 1000), course_discussion_settings) is None
|
||||
|
||||
def test_discussion_division_by_cohort(self):
|
||||
@@ -1480,7 +1486,7 @@ class GroupNameTestCase(ModuleStoreTestCase):
|
||||
self.course.id, enable_cohorts=True, division_scheme=CourseDiscussionSettings.COHORT
|
||||
)
|
||||
course_discussion_settings = CourseDiscussionSettings.get(self.course.id)
|
||||
assert {self.test_cohort_1.id: self.test_cohort_1.name, self.test_cohort_2.id: self.test_cohort_2.name} == utils.get_group_names_by_id(course_discussion_settings)
|
||||
assert {self.test_cohort_1.id: self.test_cohort_1.name, self.test_cohort_2.id: self.test_cohort_2.name} == get_group_names_by_id(course_discussion_settings)
|
||||
assert self.test_cohort_2.name == utils.get_group_name(self.test_cohort_2.id, course_discussion_settings)
|
||||
# Test also with a group_id that doesn't exist.
|
||||
assert utils.get_group_name((- 1000), course_discussion_settings) is None
|
||||
@@ -1490,7 +1496,7 @@ class GroupNameTestCase(ModuleStoreTestCase):
|
||||
self.course.id, division_scheme=CourseDiscussionSettings.ENROLLMENT_TRACK
|
||||
)
|
||||
course_discussion_settings = CourseDiscussionSettings.get(self.course.id)
|
||||
assert {(- 1): 'audit course', (- 2): 'verified course'} == utils.get_group_names_by_id(course_discussion_settings)
|
||||
assert {(- 1): 'audit course', (- 2): 'verified course'} == get_group_names_by_id(course_discussion_settings)
|
||||
|
||||
assert 'verified course' == utils.get_group_name((- 2), course_discussion_settings)
|
||||
# Test also with a group_id that doesn't exist.
|
||||
|
||||
@@ -24,7 +24,15 @@ from lms.djangoapps.discussion.django_comment_client.permissions import (
|
||||
has_permission
|
||||
)
|
||||
from lms.djangoapps.discussion.django_comment_client.settings import MAX_COMMENT_DEPTH
|
||||
from openedx.core.djangoapps.course_groups.cohorts import get_cohort_id, get_cohort_names, is_course_cohorted
|
||||
from openedx.core.djangoapps.course_groups.cohorts import get_cohort_id
|
||||
from openedx.core.djangoapps.discussions.utils import (
|
||||
get_accessible_discussion_xblocks,
|
||||
get_accessible_discussion_xblocks_by_course_id,
|
||||
get_course_division_scheme,
|
||||
get_discussion_categories_ids,
|
||||
get_group_names_by_id,
|
||||
has_required_keys,
|
||||
)
|
||||
from openedx.core.djangoapps.django_comment_common.models import (
|
||||
FORUM_ROLE_COMMUNITY_TA,
|
||||
FORUM_ROLE_STUDENT,
|
||||
@@ -116,45 +124,6 @@ def is_user_community_ta(user, course_id):
|
||||
return has_forum_access(user, course_id, FORUM_ROLE_COMMUNITY_TA)
|
||||
|
||||
|
||||
def has_required_keys(xblock):
|
||||
"""
|
||||
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 get_accessible_discussion_xblocks(course, user, include_all=False):
|
||||
"""
|
||||
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, user=None, include_all=False): # pylint: disable=invalid-name
|
||||
"""
|
||||
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 get_discussion_id_map_entry(xblock):
|
||||
"""
|
||||
Returns a tuple of (discussion_id, metadata) suitable for inclusion in the results of get_discussion_id_map().
|
||||
@@ -465,23 +434,6 @@ def discussion_category_id_access(course, user, discussion_id, xblock=None):
|
||||
return discussion_id in get_discussion_categories_ids(course, user)
|
||||
|
||||
|
||||
def get_discussion_categories_ids(course, user, include_all=False):
|
||||
"""
|
||||
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.
|
||||
include_all (bool): If True, return all ids. Used by configuration views.
|
||||
|
||||
"""
|
||||
accessible_discussion_ids = [
|
||||
xblock.discussion_id for xblock in get_accessible_discussion_xblocks(course, user, include_all=include_all)
|
||||
]
|
||||
return course.top_level_discussion_topic_ids + accessible_discussion_ids
|
||||
|
||||
|
||||
class JsonResponse(HttpResponse):
|
||||
"""
|
||||
Django response object delivering JSON representations
|
||||
@@ -881,7 +833,7 @@ def get_group_id_for_user(user, course_discussion_settings):
|
||||
If discussions are not divided, this method will return None.
|
||||
It will also return None if the user is in no group within the specified division_scheme.
|
||||
"""
|
||||
division_scheme = _get_course_division_scheme(course_discussion_settings)
|
||||
division_scheme = get_course_division_scheme(course_discussion_settings)
|
||||
if division_scheme == CourseDiscussionSettings.COHORT:
|
||||
return get_cohort_id(user, course_discussion_settings.course_id)
|
||||
elif division_scheme == CourseDiscussionSettings.ENROLLMENT_TRACK:
|
||||
@@ -960,51 +912,7 @@ def course_discussion_division_enabled(course_discussion_settings):
|
||||
|
||||
Returns: True if discussion division is enabled for the course, else False
|
||||
"""
|
||||
return _get_course_division_scheme(course_discussion_settings) != CourseDiscussionSettings.NONE
|
||||
|
||||
|
||||
def available_division_schemes(course_key):
|
||||
"""
|
||||
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 enrollment_track_group_count(course_key):
|
||||
"""
|
||||
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_course_division_scheme(course_discussion_settings):
|
||||
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
|
||||
return get_course_division_scheme(course_discussion_settings) != CourseDiscussionSettings.NONE
|
||||
|
||||
|
||||
def get_group_name(group_id, course_discussion_settings):
|
||||
@@ -1023,38 +931,6 @@ def get_group_name(group_id, course_discussion_settings):
|
||||
return group_names_by_id[group_id] if group_id in group_names_by_id else None
|
||||
|
||||
|
||||
def get_group_names_by_id(course_discussion_settings):
|
||||
"""
|
||||
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_enrollment_track_groups(course_key):
|
||||
"""
|
||||
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 _verify_group_exists(group_id, course_discussion_settings):
|
||||
"""
|
||||
Helper method that verifies the given group_id corresponds to a Group in the
|
||||
|
||||
@@ -19,6 +19,7 @@ from rest_framework.request import Request
|
||||
|
||||
from lms.djangoapps.courseware.courses import get_course_with_access
|
||||
from lms.djangoapps.courseware.exceptions import CourseAccessRedirect
|
||||
from openedx.core.djangoapps.discussions.utils import get_accessible_discussion_xblocks
|
||||
from openedx.core.djangoapps.django_comment_common.comment_client.comment import Comment
|
||||
from openedx.core.djangoapps.django_comment_common.comment_client.thread import Thread
|
||||
from openedx.core.djangoapps.django_comment_common.comment_client.utils import CommentClientRequestError
|
||||
@@ -62,7 +63,6 @@ from ..django_comment_client.base.views import (
|
||||
track_voted_event,
|
||||
)
|
||||
from ..django_comment_client.utils import (
|
||||
get_accessible_discussion_xblocks,
|
||||
get_group_id_for_user,
|
||||
is_commentable_divided,
|
||||
)
|
||||
|
||||
@@ -13,13 +13,12 @@ from rest_framework import serializers
|
||||
|
||||
from common.djangoapps.student.models import get_user_by_username_or_email
|
||||
from lms.djangoapps.discussion.django_comment_client.utils import (
|
||||
available_division_schemes,
|
||||
course_discussion_division_enabled,
|
||||
get_group_id_for_user,
|
||||
get_group_name,
|
||||
get_group_names_by_id,
|
||||
is_comment_too_deep,
|
||||
)
|
||||
from openedx.core.djangoapps.discussions.utils import get_group_names_by_id
|
||||
from lms.djangoapps.discussion.rest_api.permissions import (
|
||||
NON_UPDATABLE_COMMENT_FIELDS,
|
||||
NON_UPDATABLE_THREAD_FIELDS,
|
||||
@@ -27,7 +26,6 @@ from lms.djangoapps.discussion.rest_api.permissions import (
|
||||
get_editable_fields,
|
||||
)
|
||||
from lms.djangoapps.discussion.rest_api.render import render_body
|
||||
from lms.djangoapps.discussion.views import get_divided_discussions
|
||||
from openedx.core.djangoapps.django_comment_common.comment_client.comment import Comment
|
||||
from openedx.core.djangoapps.django_comment_common.comment_client.thread import Thread
|
||||
from openedx.core.djangoapps.django_comment_common.comment_client.user import User as CommentClientUser
|
||||
@@ -521,89 +519,6 @@ class DiscussionTopicSerializer(serializers.Serializer):
|
||||
pass # lint-amnesty, pylint: disable=unnecessary-pass
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
class DiscussionRolesSerializer(serializers.Serializer):
|
||||
"""
|
||||
Serializer for course discussion roles.
|
||||
|
||||
@@ -16,10 +16,10 @@ from rest_framework.parsers import JSONParser
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.viewsets import ViewSet
|
||||
from xmodule.modulestore.django import modulestore
|
||||
|
||||
from lms.djangoapps.course_goals.models import UserActivity
|
||||
from lms.djangoapps.instructor.access import update_forum_role
|
||||
from openedx.core.djangoapps.discussions.serializers import DiscussionSettingsSerializer
|
||||
from openedx.core.djangoapps.django_comment_common import comment_client
|
||||
from openedx.core.djangoapps.django_comment_common.models import CourseDiscussionSettings, Role
|
||||
from openedx.core.djangoapps.user_api.accounts.permissions import CanReplaceUsername, CanRetireUser
|
||||
@@ -27,6 +27,7 @@ from openedx.core.djangoapps.user_api.models import UserRetirementStatus
|
||||
from openedx.core.lib.api.authentication import BearerAuthenticationAllowInactiveUser
|
||||
from openedx.core.lib.api.parsers import MergePatchParser
|
||||
from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin, view_auth_classes
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from ..rest_api.api import (
|
||||
create_comment,
|
||||
create_thread,
|
||||
@@ -51,7 +52,6 @@ from ..rest_api.forms import (
|
||||
from ..rest_api.serializers import (
|
||||
DiscussionRolesListSerializer,
|
||||
DiscussionRolesSerializer,
|
||||
DiscussionSettingsSerializer,
|
||||
)
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -22,9 +22,9 @@ from six.moves.urllib.parse import urljoin
|
||||
import openedx.core.djangoapps.django_comment_common.comment_client as cc
|
||||
from common.djangoapps.track import segment
|
||||
from lms.djangoapps.discussion.django_comment_client.utils import (
|
||||
get_accessible_discussion_xblocks_by_course_id,
|
||||
permalink
|
||||
)
|
||||
from openedx.core.djangoapps.discussions.utils import get_accessible_discussion_xblocks_by_course_id
|
||||
from openedx.core.djangoapps.ace_common.message import BaseMessageType
|
||||
from openedx.core.djangoapps.ace_common.template_context import get_base_template_context
|
||||
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
|
||||
|
||||
@@ -4,7 +4,6 @@ Views handling read (GET) requests for the Discussion tab and inline discussions
|
||||
|
||||
|
||||
import logging
|
||||
from functools import wraps
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.decorators import login_required
|
||||
@@ -15,12 +14,12 @@ from django.shortcuts import render
|
||||
from django.template.context_processors import csrf
|
||||
from django.template.loader import render_to_string
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import get_language_bidi
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django.utils.translation import get_language_bidi, ugettext_lazy as _
|
||||
from django.views.decorators.cache import cache_control
|
||||
from django.views.decorators.csrf import ensure_csrf_cookie
|
||||
from django.views.decorators.http import require_GET, require_http_methods
|
||||
from edx_django_utils.monitoring import function_trace
|
||||
from functools import wraps
|
||||
from opaque_keys.edx.keys import CourseKey
|
||||
from rest_framework import status
|
||||
from web_fragments.fragment import Fragment
|
||||
@@ -38,18 +37,22 @@ from lms.djangoapps.discussion.django_comment_client.constants import TYPE_ENTRY
|
||||
from lms.djangoapps.discussion.django_comment_client.permissions import has_permission
|
||||
from lms.djangoapps.discussion.django_comment_client.utils import (
|
||||
add_courseware_context,
|
||||
available_division_schemes,
|
||||
course_discussion_division_enabled,
|
||||
extract,
|
||||
get_group_id_for_comments_service,
|
||||
get_group_id_for_user,
|
||||
get_group_names_by_id,
|
||||
is_commentable_divided,
|
||||
strip_none,
|
||||
)
|
||||
from lms.djangoapps.discussion.exceptions import TeamDiscussionHiddenFromUserException
|
||||
from lms.djangoapps.experiments.utils import get_experiment_user_metadata_context
|
||||
from lms.djangoapps.teams import api as team_api
|
||||
from openedx.core.djangoapps.discussions.utils import (
|
||||
available_division_schemes,
|
||||
get_discussion_categories_ids,
|
||||
get_divided_discussions,
|
||||
get_group_names_by_id,
|
||||
)
|
||||
from openedx.core.djangoapps.django_comment_common.models import CourseDiscussionSettings
|
||||
from openedx.core.djangoapps.django_comment_common.utils import ThreadContext
|
||||
from openedx.core.djangoapps.plugin_api.views import EdxFragmentView
|
||||
@@ -163,7 +166,7 @@ def get_threads(request, course, user_info, discussion_id=None, per_page=THREADS
|
||||
# If not provided with a discussion id, filter threads by commentable ids
|
||||
# which are accessible to the current user.
|
||||
if discussion_id is None:
|
||||
discussion_category_ids = set(utils.get_discussion_categories_ids(course, request.user))
|
||||
discussion_category_ids = set(get_discussion_categories_ids(course, request.user))
|
||||
threads = [
|
||||
thread for thread in threads
|
||||
if thread.get('commentable_id') in discussion_category_ids
|
||||
@@ -957,25 +960,6 @@ def course_discussions_settings_handler(request, course_key_string):
|
||||
})
|
||||
|
||||
|
||||
def get_divided_discussions(course, discussion_settings):
|
||||
"""
|
||||
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 = utils.get_discussion_categories_ids(course, None, include_all=True)
|
||||
|
||||
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 _check_team_discussion_access(request, course, discussion_id):
|
||||
"""
|
||||
Helper function to check if the discussion is visible to the user,
|
||||
|
||||
@@ -49,10 +49,11 @@ from lms.djangoapps.certificates.models import (
|
||||
from lms.djangoapps.courseware.access import has_access
|
||||
from lms.djangoapps.courseware.courses import get_studio_url
|
||||
from lms.djangoapps.courseware.module_render import get_module_by_usage_id
|
||||
from lms.djangoapps.discussion.django_comment_client.utils import available_division_schemes, has_forum_access
|
||||
from lms.djangoapps.discussion.django_comment_client.utils import has_forum_access
|
||||
from lms.djangoapps.grades.api import is_writable_gradebook_enabled
|
||||
from openedx.core.djangoapps.course_groups.cohorts import DEFAULT_COHORT_NAME, get_course_cohorts, is_course_cohorted
|
||||
from openedx.core.djangoapps.discussions.config.waffle_utils import legacy_discussion_experience_enabled
|
||||
from openedx.core.djangoapps.discussions.utils import available_division_schemes
|
||||
from openedx.core.djangoapps.django_comment_common.models import FORUM_ROLE_ADMINISTRATOR, CourseDiscussionSettings
|
||||
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
|
||||
from openedx.core.djangoapps.verified_track_content.models import VerifiedTrackCohortedCourse
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
),
|
||||
|
||||
191
openedx/core/djangoapps/discussions/utils.py
Normal file
191
openedx/core/djangoapps/discussions/utils.py
Normal 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
|
||||
@@ -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()
|
||||
|
||||
1
setup.py
1
setup.py
@@ -115,6 +115,7 @@ setup(
|
||||
# consolidate the multiple discussions-related Django apps and
|
||||
# either put them in the openedx/ dir, or in another repo entirely.
|
||||
"discussion = lms.djangoapps.discussion.apps:DiscussionConfig",
|
||||
"discussions = openedx.core.djangoapps.discussions.apps:DiscussionsConfig",
|
||||
"olx_rest_api = openedx.core.djangoapps.olx_rest_api.apps:OlxRestApiAppConfig",
|
||||
"plugins = openedx.core.djangoapps.plugins.apps:PluginsConfig",
|
||||
"theming = openedx.core.djangoapps.theming.apps:ThemingConfig",
|
||||
|
||||
Reference in New Issue
Block a user