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

@@ -75,6 +75,7 @@ class CourseMetadata:
'default_tab',
'highlights_enabled_for_messaging',
'is_onboarding_exam',
'discussions_settings',
]
@classmethod

View File

@@ -1516,7 +1516,6 @@ INSTALLED_APPS = [
# Discussion
'openedx.core.djangoapps.django_comment_common',
'openedx.core.djangoapps.discussions',
# for course creator table
'django.contrib.admin',

View File

@@ -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."),

View File

@@ -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

View File

@@ -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.

View File

@@ -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

View File

@@ -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,
)

View File

@@ -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.

View File

@@ -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__)

View File

@@ -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

View File

@@ -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,

View File

@@ -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

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()

View File

@@ -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",