Commit 8951ac8c by cahrens

Refactor server-size code to enable enrollment tracks.

EDUCATOR-11
parent 0d9146a2
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
import openedx.core.djangoapps.xmodule_django.models
class Migration(migrations.Migration):
dependencies = [
('django_comment_common', '0004_auto_20161117_1209'),
]
operations = [
migrations.CreateModel(
name='CourseDiscussionSettings',
fields=[
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
('course_id', openedx.core.djangoapps.xmodule_django.models.CourseKeyField(help_text=b'Which course are these settings associated with?', unique=True, max_length=255, db_index=True)),
('always_divide_inline_discussions', models.BooleanField(default=False)),
('_divided_discussions', models.TextField(null=True, db_column=b'divided_discussions', blank=True)),
('division_scheme', models.CharField(default=b'none', max_length=20, choices=[(b'none', b'None'), (b'cohort', b'Cohort'), (b'enrollment_track', b'Enrollment Track')])),
],
),
]
import json
import logging import logging
from config_models.models import ConfigurationModel from config_models.models import ConfigurationModel
...@@ -162,3 +163,30 @@ class ForumsConfig(ConfigurationModel): ...@@ -162,3 +163,30 @@ class ForumsConfig(ConfigurationModel):
def __unicode__(self): def __unicode__(self):
"""Simple representation so the admin screen looks less ugly.""" """Simple representation so the admin screen looks less ugly."""
return u"ForumsConfig: timeout={}".format(self.connection_timeout) return u"ForumsConfig: timeout={}".format(self.connection_timeout)
class CourseDiscussionSettings(models.Model):
course_id = CourseKeyField(
unique=True,
max_length=255,
db_index=True,
help_text="Which course are these settings associated with?",
)
always_divide_inline_discussions = models.BooleanField(default=False)
_divided_discussions = models.TextField(db_column='divided_discussions', null=True, blank=True) # JSON list
COHORT = 'cohort'
ENROLLMENT_TRACK = 'enrollment_track'
NONE = 'none'
ASSIGNMENT_TYPE_CHOICES = ((NONE, 'None'), (COHORT, 'Cohort'), (ENROLLMENT_TRACK, 'Enrollment Track'))
division_scheme = models.CharField(max_length=20, choices=ASSIGNMENT_TYPE_CHOICES, default=NONE)
@property
def divided_discussions(self):
"""Jsonify the divided_discussions"""
return json.loads(self._divided_discussions)
@divided_discussions.setter
def divided_discussions(self, value):
"""Un-Jsonify the divided_discussions"""
self._divided_discussions = json.dumps(value)
from django.test import TestCase from nose.plugins.attrib import attr
from opaque_keys.edx.locations import SlashSeparatedCourseKey
from django.test import TestCase
from django_comment_common.models import Role from django_comment_common.models import Role
from models import CourseDiscussionSettings
from opaque_keys.edx.locations import SlashSeparatedCourseKey
from openedx.core.djangoapps.course_groups.cohorts import CourseCohortsSettings
from student.models import CourseEnrollment, User from student.models import CourseEnrollment, User
from utils import get_course_discussion_settings, set_course_discussion_settings
from xmodule.modulestore import ModuleStoreEnum
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory
@attr(shard=1)
class RoleAssignmentTest(TestCase): class RoleAssignmentTest(TestCase):
""" """
Basic checks to make sure our Roles get assigned and unassigned as students Basic checks to make sure our Roles get assigned and unassigned as students
...@@ -55,3 +64,73 @@ class RoleAssignmentTest(TestCase): ...@@ -55,3 +64,73 @@ class RoleAssignmentTest(TestCase):
# ) # )
# self.assertNotIn(student_role, self.student_user.roles.all()) # self.assertNotIn(student_role, self.student_user.roles.all())
# self.assertIn(student_role, another_student.roles.all()) # self.assertIn(student_role, another_student.roles.all())
@attr(shard=1)
class CourseDiscussionSettingsTest(ModuleStoreTestCase):
def setUp(self):
super(CourseDiscussionSettingsTest, self).setUp()
self.course = CourseFactory.create()
def test_get_course_discussion_settings(self):
discussion_settings = get_course_discussion_settings(self.course.id)
self.assertEqual(CourseDiscussionSettings.NONE, discussion_settings.division_scheme)
self.assertEqual([], discussion_settings.divided_discussions)
self.assertFalse(discussion_settings.always_divide_inline_discussions)
def test_get_course_discussion_settings_legacy_settings(self):
self.course.cohort_config = {
'cohorted': True,
'always_cohort_inline_discussions': True,
'cohorted_discussions': ['foo']
}
modulestore().update_item(self.course, ModuleStoreEnum.UserID.system)
discussion_settings = get_course_discussion_settings(self.course.id)
self.assertEqual(CourseDiscussionSettings.COHORT, discussion_settings.division_scheme)
self.assertEqual(['foo'], discussion_settings.divided_discussions)
self.assertTrue(discussion_settings.always_divide_inline_discussions)
def test_get_course_discussion_settings_cohort_settings(self):
CourseCohortsSettings.objects.get_or_create(
course_id=self.course.id,
defaults={
'is_cohorted': True,
'always_cohort_inline_discussions': True,
'cohorted_discussions': ['foo', 'bar']
}
)
discussion_settings = get_course_discussion_settings(self.course.id)
self.assertEqual(CourseDiscussionSettings.COHORT, discussion_settings.division_scheme)
self.assertEqual(['foo', 'bar'], discussion_settings.divided_discussions)
self.assertTrue(discussion_settings.always_divide_inline_discussions)
def test_set_course_discussion_settings(self):
set_course_discussion_settings(
course_key=self.course.id,
divided_discussions=['cohorted_topic'],
division_scheme=CourseDiscussionSettings.ENROLLMENT_TRACK,
always_divide_inline_discussions=True,
)
discussion_settings = get_course_discussion_settings(self.course.id)
self.assertEqual(CourseDiscussionSettings.ENROLLMENT_TRACK, discussion_settings.division_scheme)
self.assertEqual(['cohorted_topic'], discussion_settings.divided_discussions)
self.assertTrue(discussion_settings.always_divide_inline_discussions)
def test_invalid_data_types(self):
exception_msg_template = "Incorrect field type for `{}`. Type must be `{}`"
fields = [
{'name': 'division_scheme', 'type': str},
{'name': 'always_divide_inline_discussions', 'type': bool},
{'name': 'divided_discussions', 'type': list}
]
invalid_value = 3.14
for field in fields:
with self.assertRaises(ValueError) as value_error:
set_course_discussion_settings(self.course.id, **{field['name']: invalid_value})
self.assertEqual(
value_error.exception.message,
exception_msg_template.format(field['name'], field['type'].__name__)
)
...@@ -9,6 +9,10 @@ from django_comment_common.models import ( ...@@ -9,6 +9,10 @@ from django_comment_common.models import (
FORUM_ROLE_STUDENT, FORUM_ROLE_STUDENT,
Role Role
) )
from openedx.core.djangoapps.course_groups.cohorts import get_legacy_discussion_settings
from request_cache.middleware import request_cached
from .models import CourseDiscussionSettings
class ThreadContext(object): class ThreadContext(object):
...@@ -91,3 +95,47 @@ def are_permissions_roles_seeded(course_id): ...@@ -91,3 +95,47 @@ def are_permissions_roles_seeded(course_id):
return False return False
return True return True
@request_cached
def get_course_discussion_settings(course_key):
try:
course_discussion_settings = CourseDiscussionSettings.objects.get(course_id=course_key)
except CourseDiscussionSettings.DoesNotExist:
legacy_discussion_settings = get_legacy_discussion_settings(course_key)
course_discussion_settings, _ = CourseDiscussionSettings.objects.get_or_create(
course_id=course_key,
defaults={
'always_divide_inline_discussions': legacy_discussion_settings['always_cohort_inline_discussions'],
'divided_discussions': legacy_discussion_settings['cohorted_discussions'],
'division_scheme': CourseDiscussionSettings.COHORT if legacy_discussion_settings['is_cohorted']
else CourseDiscussionSettings.NONE
}
)
return course_discussion_settings
def set_course_discussion_settings(course_key, **kwargs):
"""
Set discussion settings for a course.
Arguments:
course_key: CourseKey
always_divide_inline_discussions (bool): If inline discussions should always be divided.
divided_discussions (list): List of discussion ids.
division_scheme (str): `CourseDiscussionSettings.NONE`, `CourseDiscussionSettings.COHORT`,
or `CourseDiscussionSettings.ENROLLMENT_TRACK`
Returns:
A CourseDiscussionSettings object.
"""
fields = {'division_scheme': str, 'always_divide_inline_discussions': bool, 'divided_discussions': list}
course_discussion_settings = get_course_discussion_settings(course_key)
for field, field_type in fields.items():
if field in kwargs:
if not isinstance(kwargs[field], field_type):
raise ValueError("Incorrect field type for `{}`. Type must be `{}`".format(field, field_type.__name__))
setattr(course_discussion_settings, field, kwargs[field])
course_discussion_settings.save()
return course_discussion_settings
...@@ -133,7 +133,7 @@ class PartitionService(object): ...@@ -133,7 +133,7 @@ class PartitionService(object):
if self._cache and (cache_key in self._cache): if self._cache and (cache_key in self._cache):
return self._cache[cache_key] return self._cache[cache_key]
user_partition = self._get_user_partition(user_partition_id) user_partition = self.get_user_partition(user_partition_id)
if user_partition is None: if user_partition is None:
raise ValueError( raise ValueError(
"Configuration problem! No user_partition with id {0} " "Configuration problem! No user_partition with id {0} "
...@@ -148,7 +148,7 @@ class PartitionService(object): ...@@ -148,7 +148,7 @@ class PartitionService(object):
return group_id return group_id
def _get_user_partition(self, user_partition_id): def get_user_partition(self, user_partition_id):
""" """
Look for a user partition with a matching id in the course's partitions. Look for a user partition with a matching id in the course's partitions.
Note that this method can return an inactive user partition. Note that this method can return an inactive user partition.
......
...@@ -32,8 +32,8 @@ ...@@ -32,8 +32,8 @@
this.retrieveFollowed = function() { this.retrieveFollowed = function() {
return DiscussionThreadListView.prototype.retrieveFollowed.apply(self, arguments); return DiscussionThreadListView.prototype.retrieveFollowed.apply(self, arguments);
}; };
this.chooseCohort = function() { this.chooseGroup = function() {
return DiscussionThreadListView.prototype.chooseCohort.apply(self, arguments); return DiscussionThreadListView.prototype.chooseGroup.apply(self, arguments);
}; };
this.chooseFilter = function() { this.chooseFilter = function() {
return DiscussionThreadListView.prototype.chooseFilter.apply(self, arguments); return DiscussionThreadListView.prototype.chooseFilter.apply(self, arguments);
...@@ -85,7 +85,7 @@ ...@@ -85,7 +85,7 @@
'click .forum-nav-thread-link': 'threadSelected', 'click .forum-nav-thread-link': 'threadSelected',
'click .forum-nav-load-more-link': 'loadMorePages', 'click .forum-nav-load-more-link': 'loadMorePages',
'change .forum-nav-filter-main-control': 'chooseFilter', 'change .forum-nav-filter-main-control': 'chooseFilter',
'change .forum-nav-filter-cohort-control': 'chooseCohort' 'change .forum-nav-filter-cohort-control': 'chooseGroup'
}; };
DiscussionThreadListView.prototype.initialize = function(options) { DiscussionThreadListView.prototype.initialize = function(options) {
...@@ -194,7 +194,7 @@ ...@@ -194,7 +194,7 @@
edx.HtmlUtils.append( edx.HtmlUtils.append(
this.$el, this.$el,
this.template({ this.template({
isCohorted: this.courseSettings.get('is_cohorted'), isDiscussionDivisionEnabled: this.courseSettings.get('is_discussion_division_enabled'),
isPrivilegedUser: DiscussionUtil.isPrivilegedUser() isPrivilegedUser: DiscussionUtil.isPrivilegedUser()
}) })
); );
...@@ -404,7 +404,7 @@ ...@@ -404,7 +404,7 @@
return $(elem).data('discussion-id'); return $(elem).data('discussion-id');
}).get(); }).get();
this.retrieveDiscussions(discussionIds); this.retrieveDiscussions(discussionIds);
return this.$('.forum-nav-filter-cohort').toggle($item.data('cohorted') === true); return this.$('.forum-nav-filter-cohort').toggle($item.data('divided') === true);
} }
}; };
...@@ -413,7 +413,7 @@ ...@@ -413,7 +413,7 @@
return this.retrieveFirstPage(); return this.retrieveFirstPage();
}; };
DiscussionThreadListView.prototype.chooseCohort = function() { DiscussionThreadListView.prototype.chooseGroup = function() {
this.group_id = this.$('.forum-nav-filter-cohort-control :selected').val(); this.group_id = this.$('.forum-nav-filter-cohort-control :selected').val();
return this.retrieveFirstPage(); return this.retrieveFirstPage();
}; };
......
...@@ -35,7 +35,7 @@ ...@@ -35,7 +35,7 @@
'[data-discussion-id="' + this.getCurrentTopicId() + '"]' '[data-discussion-id="' + this.getCurrentTopicId() + '"]'
)); ));
} else if ($general.length > 0) { } else if ($general.length > 0) {
this.setTopic($general); this.setTopic($general.first());
} else { } else {
this.setTopic(this.$('.post-topic option').first()); this.setTopic(this.$('.post-topic option').first());
} }
......
...@@ -51,7 +51,7 @@ ...@@ -51,7 +51,7 @@
threadTypeTemplate; threadTypeTemplate;
context = _.clone(this.course_settings.attributes); context = _.clone(this.course_settings.attributes);
_.extend(context, { _.extend(context, {
cohort_options: this.getCohortOptions(), group_options: this.getGroupOptions(),
is_commentable_divided: this.is_commentable_divided, is_commentable_divided: this.is_commentable_divided,
mode: this.mode, mode: this.mode,
startHeader: this.startHeader, startHeader: this.startHeader,
...@@ -84,15 +84,15 @@ ...@@ -84,15 +84,15 @@
return this.mode === 'tab'; return this.mode === 'tab';
}; };
NewPostView.prototype.getCohortOptions = function() { NewPostView.prototype.getGroupOptions = function() {
var userGroupId; var userGroupId;
if (this.course_settings.get('is_cohorted') && DiscussionUtil.isPrivilegedUser()) { if (this.course_settings.get('is_discussion_division_enabled') && DiscussionUtil.isPrivilegedUser()) {
userGroupId = $('#discussion-container').data('user-group-id'); userGroupId = $('#discussion-container').data('user-group-id');
return _.map(this.course_settings.get('cohorts'), function(cohort) { return _.map(this.course_settings.get('groups'), function(group) {
return { return {
value: cohort.id, value: group.id,
text: cohort.name, text: group.name,
selected: cohort.id === userGroupId selected: group.id === userGroupId
}; };
}); });
} else { } else {
...@@ -112,7 +112,7 @@ ...@@ -112,7 +112,7 @@
}; };
NewPostView.prototype.toggleGroupDropdown = function($target) { NewPostView.prototype.toggleGroupDropdown = function($target) {
if ($target.data('cohorted')) { if ($target.data('divided')) {
$('.js-group-select').prop('disabled', false); $('.js-group-select').prop('disabled', false);
return $('.group-selector-wrapper').removeClass('disabled'); return $('.group-selector-wrapper').removeClass('disabled');
} else { } else {
......
...@@ -62,7 +62,7 @@ ...@@ -62,7 +62,7 @@
' <li' + ' <li' +
' class="forum-nav-browse-menu-item"' + ' class="forum-nav-browse-menu-item"' +
' data-discussion-id="child"' + ' data-discussion-id="child"' +
' data-cohorted="false"' + ' data-divided="false"' +
' >' + ' >' +
' <a href="#" class="forum-nav-browse-title">Child</a>' + ' <a href="#" class="forum-nav-browse-title">Child</a>' +
' </li>' + ' </li>' +
...@@ -70,7 +70,7 @@ ...@@ -70,7 +70,7 @@
' <li' + ' <li' +
' class="forum-nav-browse-menu-item"' + ' class="forum-nav-browse-menu-item"' +
' data-discussion-id="sibling"' + ' data-discussion-id="sibling"' +
' data-cohorted="false"' + ' data-divided="false"' +
' >' + ' >' +
' <a href="#" class="forum-nav-browse-title">Sibling</a>' + ' <a href="#" class="forum-nav-browse-title">Sibling</a>' +
' </li>' + ' </li>' +
...@@ -79,7 +79,7 @@ ...@@ -79,7 +79,7 @@
' <li' + ' <li' +
' class="forum-nav-browse-menu-item"' + ' class="forum-nav-browse-menu-item"' +
' data-discussion-id="other"' + ' data-discussion-id="other"' +
' data-cohorted="true"' + ' data-divided="true"' +
' >' + ' >' +
' <a href="#" class="forum-nav-browse-title">Other Category</a>' + ' <a href="#" class="forum-nav-browse-title">Other Category</a>' +
' </li>' + ' </li>' +
...@@ -95,11 +95,11 @@ ...@@ -95,11 +95,11 @@
' <option value="flagged">Flagged</option>' + ' <option value="flagged">Flagged</option>' +
' </select>' + ' </select>' +
' </label>' + ' </label>' +
' <% if (isCohorted && isPrivilegedUser) { %>' + ' <% if (isDiscussionDivisionEnabled && isPrivilegedUser) { %>' +
' <label class="forum-nav-filter-cohort">' + ' <label class="forum-nav-filter-cohort">' +
' <span class="sr">Cohort:</span>' + ' <span class="sr">Group:</span>' +
' <select class="forum-nav-filter-cohort-control">' + ' <select class="forum-nav-filter-cohort-control">' +
' <option value="">in all cohorts</option>' + ' <option value="">in all groups</option>' +
' <option value="1">Cohort1</option>' + ' <option value="1">Cohort1</option>' +
' <option value="2">Cohort2</option>' + ' <option value="2">Cohort2</option>' +
' </select>' + ' </select>' +
...@@ -164,7 +164,7 @@ ...@@ -164,7 +164,7 @@
collection: this.discussion, collection: this.discussion,
el: $('#fixture-element'), el: $('#fixture-element'),
courseSettings: new DiscussionCourseSettings({ courseSettings: new DiscussionCourseSettings({
is_cohorted: true is_discussion_division_enabled: true
}) })
}); });
return this.view.render(); return this.view.render();
...@@ -199,7 +199,7 @@ ...@@ -199,7 +199,7 @@
collection: discussion, collection: discussion,
showThreadPreview: true, showThreadPreview: true,
courseSettings: new DiscussionCourseSettings({ courseSettings: new DiscussionCourseSettings({
is_cohorted: true is_discussion_division_enabled: true
}) })
}, },
props props
...@@ -233,7 +233,7 @@ ...@@ -233,7 +233,7 @@
}); });
}); });
describe('cohort selector', function() { describe('group selector', function() {
it('should not be visible to students', function() { it('should not be visible to students', function() {
return expect(this.view.$('.forum-nav-filter-cohort-control:visible')).not.toExist(); return expect(this.view.$('.forum-nav-filter-cohort-control:visible')).not.toExist();
}); });
......
...@@ -31,7 +31,7 @@ ...@@ -31,7 +31,7 @@
return expect(group_disabled).toEqual(true); return expect(group_disabled).toEqual(true);
} }
}; };
describe('cohort selector', function() { describe('group selector', function() {
beforeEach(function() { beforeEach(function() {
this.course_settings = new DiscussionCourseSettings({ this.course_settings = new DiscussionCourseSettings({
category_map: { category_map: {
...@@ -53,8 +53,8 @@ ...@@ -53,8 +53,8 @@
}, },
allow_anonymous: false, allow_anonymous: false,
allow_anonymous_to_peers: false, allow_anonymous_to_peers: false,
is_cohorted: true, is_discussion_division_enabled: true,
cohorts: [ groups: [
{ {
id: 1, id: 1,
name: 'Cohort1' name: 'Cohort1'
...@@ -75,15 +75,15 @@ ...@@ -75,15 +75,15 @@
it('is not visible to students', function() { it('is not visible to students', function() {
return checkVisibility(this.view, false, false, true); return checkVisibility(this.view, false, false, true);
}); });
it('allows TAs to see the cohort selector when the topic is cohorted', function() { it('allows TAs to see the group selector when the topic is divided', function() {
DiscussionSpecHelper.makeTA(); DiscussionSpecHelper.makeTA();
return checkVisibility(this.view, true, false, true); return checkVisibility(this.view, true, false, true);
}); });
it('allows moderators to see the cohort selector when the topic is cohorted', function() { it('allows moderators to see the group selector when the topic is divided', function() {
DiscussionSpecHelper.makeModerator(); DiscussionSpecHelper.makeModerator();
return checkVisibility(this.view, true, false, true); return checkVisibility(this.view, true, false, true);
}); });
it('only enables the cohort selector when applicable', function() { it('only enables the group selector when applicable', function() {
DiscussionSpecHelper.makeModerator(); DiscussionSpecHelper.makeModerator();
checkVisibility(this.view, true, false, true); checkVisibility(this.view, true, false, true);
...@@ -95,7 +95,7 @@ ...@@ -95,7 +95,7 @@
$('.post-topic').trigger('change'); $('.post-topic').trigger('change');
return checkVisibility(this.view, true, false, false); return checkVisibility(this.view, true, false, false);
}); });
it('allows the user to make a cohort selection', function() { it('allows the user to make a group selection', function() {
var expectedGroupId, var expectedGroupId,
self = this; self = this;
DiscussionSpecHelper.makeModerator(); DiscussionSpecHelper.makeModerator();
...@@ -116,23 +116,23 @@ ...@@ -116,23 +116,23 @@
}); });
}); });
}); });
describe('always cohort inline discussions ', function() { describe('always divide inline discussions ', function() {
beforeEach(function() { beforeEach(function() {
this.course_settings = new DiscussionCourseSettings({ this.course_settings = new DiscussionCourseSettings({
'category_map': { category_map: {
'children': [], children: [],
'entries': {} entries: {}
}, },
'allow_anonymous': false, allow_anonymous: false,
'allow_anonymous_to_peers': false, allow_anonymous_to_peers: false,
'is_cohorted': true, is_discussion_division_enabled: true,
'cohorts': [ groups: [
{ {
'id': 1, id: 1,
'name': 'Cohort1' name: 'Cohort1'
}, { }, {
'id': 2, id: 2,
'name': 'Cohort2' name: 'Cohort2'
} }
] ]
}); });
...@@ -143,12 +143,12 @@ ...@@ -143,12 +143,12 @@
mode: 'tab' mode: 'tab'
}); });
}); });
it('disables the cohort menu if it is set false', function() { it('disables the group menu if it is set false', function() {
DiscussionSpecHelper.makeModerator(); DiscussionSpecHelper.makeModerator();
this.view.is_commentable_divided = false; this.view.is_commentable_divided = false;
return checkVisibility(this.view, true, true, true); return checkVisibility(this.view, true, true, true);
}); });
it('enables the cohort menu if it is set true', function() { it('enables the group menu if it is set true', function() {
DiscussionSpecHelper.makeModerator(); DiscussionSpecHelper.makeModerator();
this.view.is_commentable_divided = true; this.view.is_commentable_divided = true;
return checkVisibility(this.view, true, false, true); return checkVisibility(this.view, true, false, true);
......
...@@ -67,7 +67,7 @@ ...@@ -67,7 +67,7 @@
} }
} }
}, },
is_cohorted: true, is_discussion_division_enabled: true,
allow_anonymous: false, allow_anonymous: false,
allow_anonymous_to_peers: false allow_anonymous_to_peers: false
}, },
...@@ -170,7 +170,7 @@ ...@@ -170,7 +170,7 @@
' <li' + ' <li' +
' class="forum-nav-browse-menu-item"' + ' class="forum-nav-browse-menu-item"' +
' data-discussion-id="child"' + ' data-discussion-id="child"' +
' data-cohorted="false"' + ' data-divided="false"' +
' >' + ' >' +
' <a href="#" class="forum-nav-browse-title">Child</a>' + ' <a href="#" class="forum-nav-browse-title">Child</a>' +
' </li>' + ' </li>' +
...@@ -178,7 +178,7 @@ ...@@ -178,7 +178,7 @@
' <li' + ' <li' +
' class="forum-nav-browse-menu-item"' + ' class="forum-nav-browse-menu-item"' +
' data-discussion-id="sibling"' + ' data-discussion-id="sibling"' +
' data-cohorted="false"' + ' data-divided="false"' +
' >' + ' >' +
' <a href="#" class="forum-nav-browse-title">Sibling</a>' + ' <a href="#" class="forum-nav-browse-title">Sibling</a>' +
' </li>' + ' </li>' +
...@@ -187,7 +187,7 @@ ...@@ -187,7 +187,7 @@
' <li' + ' <li' +
' class="forum-nav-browse-menu-item"' + ' class="forum-nav-browse-menu-item"' +
' data-discussion-id="other"' + ' data-discussion-id="other"' +
' data-cohorted="true"' + ' data-divided="true"' +
' >' + ' >' +
' <a href="#" class="forum-nav-browse-title">Other Category</a>' + ' <a href="#" class="forum-nav-browse-title">Other Category</a>' +
' </li>' + ' </li>' +
...@@ -203,11 +203,11 @@ ...@@ -203,11 +203,11 @@
' <option value="flagged">Flagged</option>' + ' <option value="flagged">Flagged</option>' +
' </select>' + ' </select>' +
' </label>' + ' </label>' +
' <% if (isCohorted && isPrivilegedUser) { %>' + ' <% if (isDiscussionDivisionEnabled && isPrivilegedUser) { %>' +
' <label class="forum-nav-filter-cohort">' + ' <label class="forum-nav-filter-cohort">' +
' <span class="sr">Cohort:</span>' + ' <span class="sr">Group:</span>' +
' <select class="forum-nav-filter-cohort-control">' + ' <select class="forum-nav-filter-cohort-control">' +
' <option value="">in all cohorts</option>' + ' <option value="">in all groups</option>' +
' <option value="1">Cohort1</option>' + ' <option value="1">Cohort1</option>' +
' <option value="2">Cohort2</option>' + ' <option value="2">Cohort2</option>' +
' </select>' + ' </select>' +
......
<option class="topic-title" data-discussion-id="<%- id %>" data-cohorted="<%- is_divided %>"><%- text %></option> <option class="topic-title" data-discussion-id="<%- id %>" data-divided="<%- is_divided %>"><%- text %></option>
...@@ -9,7 +9,7 @@ ...@@ -9,7 +9,7 @@
<% } %> <% } %>
<ul class="post-errors" style="display: none"></ul> <ul class="post-errors" style="display: none"></ul>
<div class="forum-new-post-form-wrapper"></div> <div class="forum-new-post-form-wrapper"></div>
<% if (cohort_options) { %> <% if (group_options) { %>
<div class="post-field group-selector-wrapper <% if (!is_commentable_divided) { print('disabled'); } %>"> <div class="post-field group-selector-wrapper <% if (!is_commentable_divided) { print('disabled'); } %>">
<label class="field-label"> <label class="field-label">
<span class="field-label-text"> <span class="field-label-text">
...@@ -17,12 +17,12 @@ ...@@ -17,12 +17,12 @@
<%- gettext("Visible to") %> <%- gettext("Visible to") %>
</span> </span>
<div class="field-help" id="field_help_visible_to"> <div class="field-help" id="field_help_visible_to">
<%- gettext("Discussion admins, moderators, and TAs can make their posts visible to all students or specify a single cohort.") %> <%- gettext("Discussion admins, moderators, and TAs can make their posts visible to all students or specify a single group.") %>
</div> </div>
<div class="field-input"> <div class="field-input">
<select aria-describedby="field_help_visible_to" class="post-topic field-input js-group-select" name="group_id" <% if (!is_commentable_divided) { print("disabled"); } %>> <select aria-describedby="field_help_visible_to" class="post-topic field-input js-group-select" name="group_id" <% if (!is_commentable_divided) { print("disabled"); } %>>
<option value=""><%- gettext("All Groups") %></option> <option value=""><%- gettext("All Groups") %></option>
<% _.each(cohort_options, function(opt) { %> <% _.each(group_options, function(opt) { %>
<option value="<%- opt.value %>" <% if (opt.selected) { print("selected"); } %>><%- opt.text %></option> <option value="<%- opt.value %>" <% if (opt.selected) { print("selected"); } %>><%- opt.text %></option>
<% }); %> <% }); %>
</select> </select>
......
...@@ -41,10 +41,10 @@ define( ...@@ -41,10 +41,10 @@ define(
thread_pages: [], thread_pages: [],
contentInfo: null, contentInfo: null,
courseSettings: { courseSettings: {
is_cohorted: false, is_discussion_division_enabled: false,
allow_anonymous: false, allow_anonymous: false,
allow_anonymous_to_peers: false, allow_anonymous_to_peers: false,
cohorts: [], groups: [],
category_map: {} category_map: {}
} }
}); });
......
...@@ -125,6 +125,7 @@ def make_mock_thread_data( ...@@ -125,6 +125,7 @@ def make_mock_thread_data(
group_id=None, group_id=None,
group_name=None, group_name=None,
commentable_id=None, commentable_id=None,
is_commentable_divided=None,
): ):
data_commentable_id = ( data_commentable_id = (
commentable_id or course.discussion_topics.get('General', {}).get('id') or "dummy_commentable_id" commentable_id or course.discussion_topics.get('General', {}).get('id') or "dummy_commentable_id"
...@@ -145,6 +146,8 @@ def make_mock_thread_data( ...@@ -145,6 +146,8 @@ def make_mock_thread_data(
} }
if group_id is not None: if group_id is not None:
thread_data['group_name'] = group_name thread_data['group_name'] = group_name
if is_commentable_divided is not None:
thread_data['is_commentable_divided'] = is_commentable_divided
if num_children is not None: if num_children is not None:
thread_data["children"] = [{ thread_data["children"] = [{
"id": "dummy_comment_id_{}".format(i), "id": "dummy_comment_id_{}".format(i),
...@@ -429,7 +432,10 @@ class SingleCohortedThreadTestCase(CohortedTestCase): ...@@ -429,7 +432,10 @@ class SingleCohortedThreadTestCase(CohortedTestCase):
self.mock_text = "dummy content" self.mock_text = "dummy content"
self.mock_thread_id = "test_thread_id" self.mock_thread_id = "test_thread_id"
mock_request.side_effect = make_mock_request_impl( mock_request.side_effect = make_mock_request_impl(
course=self.course, text=self.mock_text, thread_id=self.mock_thread_id, group_id=self.student_cohort.id course=self.course, text=self.mock_text,
thread_id=self.mock_thread_id,
group_id=self.student_cohort.id,
commentable_id="cohorted_topic",
) )
def test_ajax(self, mock_request): def test_ajax(self, mock_request):
...@@ -453,11 +459,13 @@ class SingleCohortedThreadTestCase(CohortedTestCase): ...@@ -453,11 +459,13 @@ class SingleCohortedThreadTestCase(CohortedTestCase):
response_data["content"], response_data["content"],
make_mock_thread_data( make_mock_thread_data(
course=self.course, course=self.course,
commentable_id="cohorted_topic",
text=self.mock_text, text=self.mock_text,
thread_id=self.mock_thread_id, thread_id=self.mock_thread_id,
num_children=1, num_children=1,
group_id=self.student_cohort.id, group_id=self.student_cohort.id,
group_name=self.student_cohort.name group_name=self.student_cohort.name,
is_commentable_divided=True,
) )
) )
......
...@@ -30,17 +30,14 @@ from web_fragments.fragment import Fragment ...@@ -30,17 +30,14 @@ from web_fragments.fragment import Fragment
from courseware.courses import get_course_with_access from courseware.courses import get_course_with_access
from courseware.views.views import CourseTabView from courseware.views.views import CourseTabView
from openedx.core.djangoapps.course_groups.cohorts import (
is_course_cohorted,
get_course_cohorts,
)
from openedx.core.djangoapps.plugin_api.views import EdxFragmentView from openedx.core.djangoapps.plugin_api.views import EdxFragmentView
from courseware.access import has_access from courseware.access import has_access
from student.models import CourseEnrollment from student.models import CourseEnrollment
from xmodule.modulestore.django import modulestore from xmodule.modulestore.django import modulestore
from django_comment_common.utils import ThreadContext from django_comment_common.utils import ThreadContext, get_course_discussion_settings
from django_comment_client.permissions import has_permission, get_team from django_comment_client.permissions import has_permission, get_team
from django_comment_client.utils import ( from django_comment_client.utils import (
merge_dict, merge_dict,
...@@ -48,6 +45,8 @@ from django_comment_client.utils import ( ...@@ -48,6 +45,8 @@ from django_comment_client.utils import (
strip_none, strip_none,
add_courseware_context, add_courseware_context,
get_group_id_for_comments_service, get_group_id_for_comments_service,
course_discussion_division_enabled,
get_group_names_by_id,
is_commentable_divided, is_commentable_divided,
get_group_id_for_user, get_group_id_for_user,
) )
...@@ -83,11 +82,15 @@ def make_course_settings(course, user): ...@@ -83,11 +82,15 @@ def make_course_settings(course, user):
Generate a JSON-serializable model for course settings, which will be used to initialize a Generate a JSON-serializable model for course settings, which will be used to initialize a
DiscussionCourseSettings object on the client. DiscussionCourseSettings object on the client.
""" """
course_discussion_settings = get_course_discussion_settings(course.id)
group_names_by_id = get_group_names_by_id(course_discussion_settings)
return { return {
'is_cohorted': is_course_cohorted(course.id), 'is_discussion_division_enabled': course_discussion_division_enabled(course_discussion_settings),
'allow_anonymous': course.allow_anonymous, 'allow_anonymous': course.allow_anonymous,
'allow_anonymous_to_peers': course.allow_anonymous_to_peers, 'allow_anonymous_to_peers': course.allow_anonymous_to_peers,
'cohorts': [{"id": str(g.id), "name": g.name} for g in get_course_cohorts(course)], 'groups': [
{"id": str(group_id), "name": group_name} for group_id, group_name in group_names_by_id.iteritems()
],
'category_map': utils.get_discussion_category_map(course, user) 'category_map': utils.get_discussion_category_map(course, user)
} }
...@@ -346,10 +349,11 @@ def _find_thread(request, course, discussion_id, thread_id): ...@@ -346,10 +349,11 @@ def _find_thread(request, course, discussion_id, thread_id):
if thread_context == "course" and not utils.discussion_category_id_access(course, request.user, discussion_id): if thread_context == "course" and not utils.discussion_category_id_access(course, request.user, discussion_id):
return None return None
# verify that the thread belongs to the requesting student's cohort # verify that the thread belongs to the requesting student's group
is_moderator = has_permission(request.user, "see_all_cohorts", course.id) is_moderator = has_permission(request.user, "see_all_cohorts", course.id)
if is_commentable_divided(course.id, discussion_id) and not is_moderator: course_discussion_settings = get_course_discussion_settings(course.id)
user_group_id = get_group_id_for_user(request.user, course.id) if is_commentable_divided(course.id, discussion_id, course_discussion_settings) and not is_moderator:
user_group_id = get_group_id_for_user(request.user, course_discussion_settings)
if getattr(thread, "group_id", None) is not None and user_group_id != thread.group_id: if getattr(thread, "group_id", None) is not None and user_group_id != thread.group_id:
return None return None
...@@ -424,7 +428,8 @@ def _create_discussion_board_context(request, course_key, discussion_id=None, th ...@@ -424,7 +428,8 @@ def _create_discussion_board_context(request, course_key, discussion_id=None, th
add_courseware_context(threads, course, user) add_courseware_context(threads, course, user)
with newrelic_function_trace("get_cohort_info"): with newrelic_function_trace("get_cohort_info"):
user_group_id = get_group_id_for_user(user, course_key) course_discussion_settings = get_course_discussion_settings(course_key)
user_group_id = get_group_id_for_user(user, course_discussion_settings)
context.update({ context.update({
'root_url': root_url, 'root_url': root_url,
...@@ -434,12 +439,12 @@ def _create_discussion_board_context(request, course_key, discussion_id=None, th ...@@ -434,12 +439,12 @@ def _create_discussion_board_context(request, course_key, discussion_id=None, th
'thread_pages': thread_pages, 'thread_pages': thread_pages,
'annotated_content_info': annotated_content_info, 'annotated_content_info': annotated_content_info,
'is_moderator': has_permission(user, "see_all_cohorts", course_key), 'is_moderator': has_permission(user, "see_all_cohorts", course_key),
'cohorts': course_settings["cohorts"], # still needed to render _thread_list_template 'groups': course_settings["groups"], # still needed to render _thread_list_template
'user_group_id': user_group_id, # read from container in NewPostView 'user_group_id': user_group_id, # read from container in NewPostView
'sort_preference': cc_user.default_sort_key, 'sort_preference': cc_user.default_sort_key,
'category_map': course_settings["category_map"], 'category_map': course_settings["category_map"],
'course_settings': course_settings, 'course_settings': course_settings,
'is_commentable_divided': is_commentable_divided(course_key, discussion_id) 'is_commentable_divided': is_commentable_divided(course_key, discussion_id, course_discussion_settings)
}) })
return context return context
...@@ -501,7 +506,8 @@ def user_profile(request, course_key, user_id): ...@@ -501,7 +506,8 @@ def user_profile(request, course_key, user_id):
).order_by("name").values_list("name", flat=True).distinct() ).order_by("name").values_list("name", flat=True).distinct()
with newrelic_function_trace("get_cohort_info"): with newrelic_function_trace("get_cohort_info"):
user_group_id = get_group_id_for_user(request.user, course_key) course_discussion_settings = get_course_discussion_settings(course_key)
user_group_id = get_group_id_for_user(request.user, course_discussion_settings)
context = _create_base_discussion_view_context(request, course_key) context = _create_base_discussion_view_context(request, course_key)
context.update({ context.update({
......
...@@ -43,6 +43,8 @@ from django_comment_common.signals import ( ...@@ -43,6 +43,8 @@ from django_comment_common.signals import (
comment_voted, comment_voted,
comment_deleted, comment_deleted,
) )
from django_comment_common.utils import get_course_discussion_settings
from django_comment_client.utils import get_accessible_discussion_xblocks, is_commentable_divided, get_group_id_for_user from django_comment_client.utils import get_accessible_discussion_xblocks, is_commentable_divided, get_group_id_for_user
from lms.djangoapps.discussion_api.pagination import DiscussionAPIPagination from lms.djangoapps.discussion_api.pagination import DiscussionAPIPagination
from lms.lib.comment_client.comment import Comment from lms.lib.comment_client.comment import Comment
...@@ -109,12 +111,13 @@ def _get_thread_and_context(request, thread_id, retrieve_kwargs=None): ...@@ -109,12 +111,13 @@ def _get_thread_and_context(request, thread_id, retrieve_kwargs=None):
course_key = CourseKey.from_string(cc_thread["course_id"]) course_key = CourseKey.from_string(cc_thread["course_id"])
course = _get_course(course_key, request.user) course = _get_course(course_key, request.user)
context = get_context(course, request, cc_thread) context = get_context(course, request, cc_thread)
course_discussion_settings = get_course_discussion_settings(course_key)
if ( if (
not context["is_requester_privileged"] and not context["is_requester_privileged"] and
cc_thread["group_id"] and cc_thread["group_id"] and
is_commentable_divided(course.id, cc_thread["commentable_id"]) is_commentable_divided(course.id, cc_thread["commentable_id"], course_discussion_settings)
): ):
requester_group_id = get_group_id_for_user(request.user, course.id) requester_group_id = get_group_id_for_user(request.user, course_discussion_settings)
if requester_group_id is not None and cc_thread["group_id"] != requester_group_id: if requester_group_id is not None and cc_thread["group_id"] != requester_group_id:
raise ThreadNotFoundError("Thread not found.") raise ThreadNotFoundError("Thread not found.")
return cc_thread, context return cc_thread, context
...@@ -546,7 +549,7 @@ def get_thread_list( ...@@ -546,7 +549,7 @@ def get_thread_list(
"user_id": unicode(request.user.id), "user_id": unicode(request.user.id),
"group_id": ( "group_id": (
None if context["is_requester_privileged"] else None if context["is_requester_privileged"] else
get_group_id_for_user(request.user, course.id) get_group_id_for_user(request.user, get_course_discussion_settings(course.id))
), ),
"page": page, "page": page,
"per_page": page_size, "per_page": page_size,
...@@ -828,12 +831,13 @@ def create_thread(request, thread_data): ...@@ -828,12 +831,13 @@ def create_thread(request, thread_data):
context = get_context(course, request) context = get_context(course, request)
_check_initializable_thread_fields(thread_data, context) _check_initializable_thread_fields(thread_data, context)
discussion_settings = get_course_discussion_settings(course_key)
if ( if (
"group_id" not in thread_data and "group_id" not in thread_data and
is_commentable_divided(course_key, thread_data.get("topic_id")) is_commentable_divided(course_key, thread_data.get("topic_id"), discussion_settings)
): ):
thread_data = thread_data.copy() thread_data = thread_data.copy()
thread_data["group_id"] = get_group_id_for_user(user, course_key) thread_data["group_id"] = get_group_id_for_user(user, discussion_settings)
serializer = ThreadSerializer(data=thread_data, context=context) serializer = ThreadSerializer(data=thread_data, context=context)
actions_form = ThreadActionsForm(thread_data) actions_form = ThreadActionsForm(thread_data)
if not (serializer.is_valid() and actions_form.is_valid()): if not (serializer.is_valid() and actions_form.is_valid()):
......
...@@ -77,7 +77,7 @@ def get_editable_fields(cc_content, context): ...@@ -77,7 +77,7 @@ def get_editable_fields(cc_content, context):
ret |= {"following", "read"} ret |= {"following", "read"}
if _is_author_or_privileged(cc_content, context): if _is_author_or_privileged(cc_content, context):
ret |= {"topic_id", "type", "title"} ret |= {"topic_id", "type", "title"}
if context["is_requester_privileged"] and context["course"].is_cohorted: if context["is_requester_privileged"] and context["discussion_division_enabled"]:
ret |= {"group_id"} ret |= {"group_id"}
# Comment fields # Comment fields
......
...@@ -17,6 +17,7 @@ from discussion_api.permissions import ( ...@@ -17,6 +17,7 @@ from discussion_api.permissions import (
) )
from discussion_api.render import render_body from discussion_api.render import render_body
from django_comment_client.utils import is_comment_too_deep from django_comment_client.utils import is_comment_too_deep
from django_comment_common.utils import get_course_discussion_settings
from django_comment_common.models import ( from django_comment_common.models import (
FORUM_ROLE_ADMINISTRATOR, FORUM_ROLE_ADMINISTRATOR,
FORUM_ROLE_COMMUNITY_TA, FORUM_ROLE_COMMUNITY_TA,
...@@ -27,7 +28,7 @@ from lms.lib.comment_client.comment import Comment ...@@ -27,7 +28,7 @@ from lms.lib.comment_client.comment import Comment
from lms.lib.comment_client.thread import Thread from lms.lib.comment_client.thread import Thread
from lms.lib.comment_client.user import User as CommentClientUser from lms.lib.comment_client.user import User as CommentClientUser
from lms.lib.comment_client.utils import CommentClientRequestError from lms.lib.comment_client.utils import CommentClientRequestError
from openedx.core.djangoapps.course_groups.cohorts import get_cohort_names from lms.djangoapps.django_comment_client.utils import course_discussion_division_enabled, get_group_names_by_id
def get_context(course, request, thread=None): def get_context(course, request, thread=None):
...@@ -52,12 +53,13 @@ def get_context(course, request, thread=None): ...@@ -52,12 +53,13 @@ def get_context(course, request, thread=None):
requester = request.user requester = request.user
cc_requester = CommentClientUser.from_django_user(requester).retrieve() cc_requester = CommentClientUser.from_django_user(requester).retrieve()
cc_requester["course_id"] = course.id cc_requester["course_id"] = course.id
course_discussion_settings = get_course_discussion_settings(course.id)
return { return {
"course": course, "course": course,
"request": request, "request": request,
"thread": thread, "thread": thread,
# For now, the only groups are cohorts "discussion_division_enabled": course_discussion_division_enabled(course_discussion_settings),
"group_ids_to_names": get_cohort_names(course), "group_ids_to_names": get_group_names_by_id(course_discussion_settings),
"is_requester_privileged": requester.id in staff_user_ids or requester.id in ta_user_ids, "is_requester_privileged": requester.id in staff_user_ids or requester.id in ta_user_ids,
"staff_user_ids": staff_user_ids, "staff_user_ids": staff_user_ids,
"ta_user_ids": ta_user_ids, "ta_user_ids": ta_user_ids,
......
...@@ -55,6 +55,7 @@ from openedx.core.djangoapps.course_groups.tests.helpers import CohortFactory ...@@ -55,6 +55,7 @@ from openedx.core.djangoapps.course_groups.tests.helpers import CohortFactory
from openedx.core.lib.exceptions import CourseNotFoundError, PageNotFoundError from openedx.core.lib.exceptions import CourseNotFoundError, PageNotFoundError
from student.tests.factories import CourseEnrollmentFactory, UserFactory from student.tests.factories import CourseEnrollmentFactory, UserFactory
from util.testing import UrlResetMixin from util.testing import UrlResetMixin
from xmodule.modulestore import ModuleStoreEnum
from xmodule.modulestore.django import modulestore from xmodule.modulestore.django import modulestore
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase, SharedModuleStoreTestCase from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase, SharedModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
...@@ -591,6 +592,8 @@ class GetThreadListTest(ForumsEnableMixin, CommentsServiceMockMixin, UrlResetMix ...@@ -591,6 +592,8 @@ class GetThreadListTest(ForumsEnableMixin, CommentsServiceMockMixin, UrlResetMix
self.request.user = self.user self.request.user = self.user
CourseEnrollmentFactory.create(user=self.user, course_id=self.course.id) CourseEnrollmentFactory.create(user=self.user, course_id=self.course.id)
self.author = UserFactory.create() self.author = UserFactory.create()
self.course.cohort_config = {"cohorted": False}
modulestore().update_item(self.course, ModuleStoreEnum.UserID.test)
self.cohort = CohortFactory.create(course_id=self.course.id) self.cohort = CohortFactory.create(course_id=self.course.id)
def get_thread_list( def get_thread_list(
...@@ -662,6 +665,8 @@ class GetThreadListTest(ForumsEnableMixin, CommentsServiceMockMixin, UrlResetMix ...@@ -662,6 +665,8 @@ class GetThreadListTest(ForumsEnableMixin, CommentsServiceMockMixin, UrlResetMix
}) })
def test_thread_content(self): def test_thread_content(self):
self.course.cohort_config = {"cohorted": True}
modulestore().update_item(self.course, ModuleStoreEnum.UserID.test)
source_threads = [ source_threads = [
make_minimal_cs_thread({ make_minimal_cs_thread({
"id": "test_thread_id_0", "id": "test_thread_id_0",
......
...@@ -25,6 +25,7 @@ def _get_context(requester_id, is_requester_privileged, is_cohorted=False, threa ...@@ -25,6 +25,7 @@ def _get_context(requester_id, is_requester_privileged, is_cohorted=False, threa
"cc_requester": User(id=requester_id), "cc_requester": User(id=requester_id),
"is_requester_privileged": is_requester_privileged, "is_requester_privileged": is_requester_privileged,
"course": CourseFactory(cohort_config={"cohorted": is_cohorted}), "course": CourseFactory(cohort_config={"cohorted": is_cohorted}),
"discussion_division_enabled": is_cohorted,
"thread": thread, "thread": thread,
} }
......
...@@ -28,6 +28,8 @@ from lms.lib.comment_client.comment import Comment ...@@ -28,6 +28,8 @@ from lms.lib.comment_client.comment import Comment
from lms.lib.comment_client.thread import Thread from lms.lib.comment_client.thread import Thread
from student.tests.factories import UserFactory from student.tests.factories import UserFactory
from util.testing import UrlResetMixin from util.testing import UrlResetMixin
from xmodule.modulestore import ModuleStoreEnum
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory from xmodule.modulestore.tests.factories import CourseFactory
from openedx.core.djangoapps.course_groups.tests.helpers import CohortFactory from openedx.core.djangoapps.course_groups.tests.helpers import CohortFactory
...@@ -209,6 +211,8 @@ class ThreadSerializerSerializationTest(SerializerTestMixin, SharedModuleStoreTe ...@@ -209,6 +211,8 @@ class ThreadSerializerSerializationTest(SerializerTestMixin, SharedModuleStoreTe
self.assertEqual(serialized["pinned"], False) self.assertEqual(serialized["pinned"], False)
def test_group(self): def test_group(self):
self.course.cohort_config = {"cohorted": True}
modulestore().update_item(self.course, ModuleStoreEnum.UserID.test)
cohort = CohortFactory.create(course_id=self.course.id) cohort = CohortFactory.create(course_id=self.course.id)
serialized = self.serialize(self.make_cs_content({"group_id": cohort.id})) serialized = self.serialize(self.make_cs_content({"group_id": cohort.id}))
self.assertEqual(serialized["group_id"], cohort.id) self.assertEqual(serialized["group_id"], cohort.id)
......
import json import json
import re import re
from course_modes.models import CourseMode
from course_modes.tests.factories import CourseModeFactory
from django_comment_common.models import CourseDiscussionSettings
from django_comment_common.utils import set_course_discussion_settings
from lms.djangoapps.teams.tests.factories import CourseTeamFactory from lms.djangoapps.teams.tests.factories import CourseTeamFactory
...@@ -94,11 +99,22 @@ class CohortedTopicGroupIdTestMixin(GroupIdAssertionMixin): ...@@ -94,11 +99,22 @@ class CohortedTopicGroupIdTestMixin(GroupIdAssertionMixin):
def test_cohorted_topic_moderator_with_invalid_group_id(self, mock_request): def test_cohorted_topic_moderator_with_invalid_group_id(self, mock_request):
invalid_id = self.student_cohort.id + self.moderator_cohort.id invalid_id = self.student_cohort.id + self.moderator_cohort.id
try:
response = self.call_view(mock_request, "cohorted_topic", self.moderator, invalid_id) response = self.call_view(mock_request, "cohorted_topic", self.moderator, invalid_id)
self.assertEqual(response.status_code, 500) self.assertEqual(response.status_code, 500)
except ValueError:
pass # In mock request mode, server errors are not captured def test_cohorted_topic_enrollment_track_invalid_group_id(self, mock_request):
CourseModeFactory.create(course_id=self.course.id, mode_slug=CourseMode.AUDIT)
CourseModeFactory.create(course_id=self.course.id, mode_slug=CourseMode.VERIFIED)
set_course_discussion_settings(
course_key=self.course.id,
divided_discussions=['cohorted_topic'],
division_scheme=CourseDiscussionSettings.ENROLLMENT_TRACK,
always_divide_inline_discussions=True,
)
invalid_id = -1000
response = self.call_view(mock_request, "cohorted_topic", self.moderator, invalid_id)
self.assertEqual(response.status_code, 500)
class NonCohortedTopicGroupIdTestMixin(GroupIdAssertionMixin): class NonCohortedTopicGroupIdTestMixin(GroupIdAssertionMixin):
......
...@@ -17,13 +17,16 @@ from django_comment_client.tests.unicode import UnicodeTestMixin ...@@ -17,13 +17,16 @@ from django_comment_client.tests.unicode import UnicodeTestMixin
from django_comment_client.constants import TYPE_ENTRY, TYPE_SUBCATEGORY from django_comment_client.constants import TYPE_ENTRY, TYPE_SUBCATEGORY
import django_comment_client.utils as utils import django_comment_client.utils as utils
from lms.lib.comment_client.utils import perform_request, CommentClientMaintenanceError from lms.lib.comment_client.utils import perform_request, CommentClientMaintenanceError
from django_comment_common.models import ForumsConfig from django_comment_common.models import ForumsConfig, CourseDiscussionSettings
from django_comment_common.utils import get_course_discussion_settings, set_course_discussion_settings
from course_modes.models import CourseMode
from course_modes.tests.factories import CourseModeFactory
from courseware.tests.factories import InstructorFactory from courseware.tests.factories import InstructorFactory
from courseware.tabs import get_course_tab_list from courseware.tabs import get_course_tab_list
from openedx.core.djangoapps.course_groups import cohorts from openedx.core.djangoapps.course_groups import cohorts
from openedx.core.djangoapps.course_groups.cohorts import set_course_cohort_settings from openedx.core.djangoapps.course_groups.cohorts import set_course_cohorted
from openedx.core.djangoapps.course_groups.tests.helpers import config_course_cohorts, topic_name_to_id from openedx.core.djangoapps.course_groups.tests.helpers import config_course_cohorts, topic_name_to_id, CohortFactory
from student.tests.factories import UserFactory, AdminFactory, CourseEnrollmentFactory from student.tests.factories import UserFactory, AdminFactory, CourseEnrollmentFactory
from openedx.core.djangoapps.content.course_structures.models import CourseStructure from openedx.core.djangoapps.content.course_structures.models import CourseStructure
from openedx.core.djangoapps.util.testing import ContentGroupTestCase from openedx.core.djangoapps.util.testing import ContentGroupTestCase
...@@ -374,7 +377,6 @@ class CategoryMapTestCase(CategoryMapTestMixin, ModuleStoreTestCase): ...@@ -374,7 +377,6 @@ class CategoryMapTestCase(CategoryMapTestMixin, ModuleStoreTestCase):
) )
# Courses get a default discussion topic on creation, so remove it # Courses get a default discussion topic on creation, so remove it
self.course.discussion_topics = {} self.course.discussion_topics = {}
self.course.save()
self.discussion_num = 0 self.discussion_num = 0
self.instructor = InstructorFactory(course_key=self.course.id) self.instructor = InstructorFactory(course_key=self.course.id)
self.maxDiff = None # pylint: disable=invalid-name self.maxDiff = None # pylint: disable=invalid-name
...@@ -426,34 +428,31 @@ class CategoryMapTestCase(CategoryMapTestMixin, ModuleStoreTestCase): ...@@ -426,34 +428,31 @@ class CategoryMapTestCase(CategoryMapTestMixin, ModuleStoreTestCase):
check_cohorted_topics([]) # default (empty) cohort config check_cohorted_topics([]) # default (empty) cohort config
set_course_cohort_settings(course_key=self.course.id, is_cohorted=False, cohorted_discussions=[]) set_discussion_division_settings(self.course.id, enable_cohorts=False)
check_cohorted_topics([]) check_cohorted_topics([])
set_course_cohort_settings(course_key=self.course.id, is_cohorted=True, cohorted_discussions=[]) set_discussion_division_settings(self.course.id, enable_cohorts=True)
check_cohorted_topics([]) check_cohorted_topics([])
set_course_cohort_settings( set_discussion_division_settings(
course_key=self.course.id, self.course.id,
is_cohorted=True, enable_cohorts=True,
cohorted_discussions=["Topic_B", "Topic_C"], divided_discussions=["Topic_B", "Topic_C"]
always_cohort_inline_discussions=False,
) )
check_cohorted_topics(["Topic_B", "Topic_C"]) check_cohorted_topics(["Topic_B", "Topic_C"])
set_course_cohort_settings( set_discussion_division_settings(
course_key=self.course.id, self.course.id,
is_cohorted=True, enable_cohorts=True,
cohorted_discussions=["Topic_A", "Some_Other_Topic"], divided_discussions=["Topic_A", "Some_Other_Topic"]
always_cohort_inline_discussions=False,
) )
check_cohorted_topics(["Topic_A"]) check_cohorted_topics(["Topic_A"])
# unlikely case, but make sure it works. # unlikely case, but make sure it works.
set_course_cohort_settings( set_discussion_division_settings(
course_key=self.course.id, self.course.id,
is_cohorted=False, enable_cohorts=False,
cohorted_discussions=["Topic_A"], divided_discussions=["Topic_A"]
always_cohort_inline_discussions=False,
) )
check_cohorted_topics([]) check_cohorted_topics([])
...@@ -481,7 +480,7 @@ class CategoryMapTestCase(CategoryMapTestMixin, ModuleStoreTestCase): ...@@ -481,7 +480,7 @@ class CategoryMapTestCase(CategoryMapTestMixin, ModuleStoreTestCase):
def test_inline_with_always_cohort_inline_discussion_flag(self): def test_inline_with_always_cohort_inline_discussion_flag(self):
self.create_discussion("Chapter", "Discussion") self.create_discussion("Chapter", "Discussion")
set_course_cohort_settings(course_key=self.course.id, is_cohorted=True, always_cohort_inline_discussions=True) set_discussion_division_settings(self.course.id, enable_cohorts=True, always_divide_inline_discussions=True)
self.assert_category_map_equals( self.assert_category_map_equals(
{ {
...@@ -505,7 +504,7 @@ class CategoryMapTestCase(CategoryMapTestMixin, ModuleStoreTestCase): ...@@ -505,7 +504,7 @@ class CategoryMapTestCase(CategoryMapTestMixin, ModuleStoreTestCase):
def test_inline_without_always_cohort_inline_discussion_flag(self): def test_inline_without_always_cohort_inline_discussion_flag(self):
self.create_discussion("Chapter", "Discussion") self.create_discussion("Chapter", "Discussion")
set_course_cohort_settings(course_key=self.course.id, is_cohorted=True) set_discussion_division_settings(self.course.id, enable_cohorts=True)
self.assert_category_map_equals( self.assert_category_map_equals(
{ {
...@@ -566,7 +565,7 @@ class CategoryMapTestCase(CategoryMapTestMixin, ModuleStoreTestCase): ...@@ -566,7 +565,7 @@ class CategoryMapTestCase(CategoryMapTestMixin, ModuleStoreTestCase):
self.create_discussion("Chapter 2 / Section 1 / Subsection 2", "Discussion") self.create_discussion("Chapter 2 / Section 1 / Subsection 2", "Discussion")
self.create_discussion("Chapter 3 / Section 1", "Discussion") self.create_discussion("Chapter 3 / Section 1", "Discussion")
def check_cohorted(is_divided): def check_divided(is_divided):
self.assert_category_map_equals( self.assert_category_map_equals(
{ {
...@@ -652,15 +651,15 @@ class CategoryMapTestCase(CategoryMapTestMixin, ModuleStoreTestCase): ...@@ -652,15 +651,15 @@ class CategoryMapTestCase(CategoryMapTestMixin, ModuleStoreTestCase):
) )
# empty / default config # empty / default config
check_cohorted(False) check_divided(False)
# explicitly disabled cohorting # explicitly disabled cohorting
set_course_cohort_settings(course_key=self.course.id, is_cohorted=False) set_discussion_division_settings(self.course.id, enable_cohorts=False)
check_cohorted(False) check_divided(False)
# explicitly enabled cohorting with inline discusssions also cohorted. # explicitly enable courses divided by Cohort with inline discusssions also divided.
set_course_cohort_settings(course_key=self.course.id, is_cohorted=True, always_cohort_inline_discussions=True) set_discussion_division_settings(self.course.id, enable_cohorts=True, always_divide_inline_discussions=True)
check_cohorted(True) check_divided(True)
def test_tree_with_duplicate_targets(self): def test_tree_with_duplicate_targets(self):
self.create_discussion("Chapter 1", "Discussion A") self.create_discussion("Chapter 1", "Discussion A")
...@@ -730,7 +729,6 @@ class CategoryMapTestCase(CategoryMapTestMixin, ModuleStoreTestCase): ...@@ -730,7 +729,6 @@ class CategoryMapTestCase(CategoryMapTestMixin, ModuleStoreTestCase):
def test_self_paced_start_date_filter(self): def test_self_paced_start_date_filter(self):
self.course.self_paced = True self.course.self_paced = True
self.course.save()
now = datetime.datetime.now() now = datetime.datetime.now()
later = datetime.datetime.max later = datetime.datetime.max
...@@ -898,7 +896,6 @@ class CategoryMapTestCase(CategoryMapTestMixin, ModuleStoreTestCase): ...@@ -898,7 +896,6 @@ class CategoryMapTestCase(CategoryMapTestMixin, ModuleStoreTestCase):
def test_sort_alpha(self): def test_sort_alpha(self):
self.course.discussion_sort_alpha = True self.course.discussion_sort_alpha = True
self.course.save()
self.create_discussion("Chapter", "Discussion D") self.create_discussion("Chapter", "Discussion D")
self.create_discussion("Chapter", "Discussion A") self.create_discussion("Chapter", "Discussion A")
self.create_discussion("Chapter", "Discussion E") self.create_discussion("Chapter", "Discussion E")
...@@ -1325,7 +1322,7 @@ class IsCommentableDividedTestCase(ModuleStoreTestCase): ...@@ -1325,7 +1322,7 @@ class IsCommentableDividedTestCase(ModuleStoreTestCase):
course, course,
is_cohorted=True, is_cohorted=True,
discussion_topics=["General", "Feedback"], discussion_topics=["General", "Feedback"],
cohorted_discussions=["Feedback"] divided_discussions=["Feedback"]
) )
self.assertTrue(cohorts.is_course_cohorted(course.id)) self.assertTrue(cohorts.is_course_cohorted(course.id))
...@@ -1349,7 +1346,7 @@ class IsCommentableDividedTestCase(ModuleStoreTestCase): ...@@ -1349,7 +1346,7 @@ class IsCommentableDividedTestCase(ModuleStoreTestCase):
course, course,
is_cohorted=True, is_cohorted=True,
discussion_topics=["General", "Feedback"], discussion_topics=["General", "Feedback"],
cohorted_discussions=["Feedback", "random_inline"] divided_discussions=["Feedback", "random_inline"]
) )
self.assertFalse( self.assertFalse(
utils.is_commentable_divided(course.id, to_id("random")), utils.is_commentable_divided(course.id, to_id("random")),
...@@ -1362,8 +1359,8 @@ class IsCommentableDividedTestCase(ModuleStoreTestCase): ...@@ -1362,8 +1359,8 @@ class IsCommentableDividedTestCase(ModuleStoreTestCase):
course, course,
is_cohorted=True, is_cohorted=True,
discussion_topics=["General", "Feedback"], discussion_topics=["General", "Feedback"],
cohorted_discussions=["Feedback", "random_inline"], divided_discussions=["Feedback", "random_inline"],
always_cohort_inline_discussions=False always_divide_inline_discussions=False
) )
self.assertFalse( self.assertFalse(
utils.is_commentable_divided(course.id, to_id("random")), utils.is_commentable_divided(course.id, to_id("random")),
...@@ -1383,7 +1380,7 @@ class IsCommentableDividedTestCase(ModuleStoreTestCase): ...@@ -1383,7 +1380,7 @@ class IsCommentableDividedTestCase(ModuleStoreTestCase):
course = modulestore().get_course(self.toy_course_key) course = modulestore().get_course(self.toy_course_key)
self.assertFalse(cohorts.is_course_cohorted(course.id)) self.assertFalse(cohorts.is_course_cohorted(course.id))
config_course_cohorts(course, is_cohorted=True, always_cohort_inline_discussions=True) config_course_cohorts(course, is_cohorted=True, always_divide_inline_discussions=True)
team = CourseTeamFactory(course_id=course.id) team = CourseTeamFactory(course_id=course.id)
# Verify that team discussions are not cohorted, but other discussions are # Verify that team discussions are not cohorted, but other discussions are
...@@ -1391,6 +1388,205 @@ class IsCommentableDividedTestCase(ModuleStoreTestCase): ...@@ -1391,6 +1388,205 @@ class IsCommentableDividedTestCase(ModuleStoreTestCase):
self.assertFalse(utils.is_commentable_divided(course.id, team.discussion_topic_id)) self.assertFalse(utils.is_commentable_divided(course.id, team.discussion_topic_id))
self.assertTrue(utils.is_commentable_divided(course.id, "random")) self.assertTrue(utils.is_commentable_divided(course.id, "random"))
def test_is_commentable_divided_cohorts(self):
course = modulestore().get_course(self.toy_course_key)
set_discussion_division_settings(
course.id,
enable_cohorts=True,
divided_discussions=[],
always_divide_inline_discussions=True,
division_scheme=CourseDiscussionSettings.NONE,
)
# Although Cohorts are enabled, discussion division is explicitly disabled.
self.assertFalse(utils.is_commentable_divided(course.id, "random"))
# Now set the discussion division scheme.
set_discussion_division_settings(
course.id,
enable_cohorts=True,
divided_discussions=[],
always_divide_inline_discussions=True,
division_scheme=CourseDiscussionSettings.COHORT,
)
self.assertTrue(utils.is_commentable_divided(course.id, "random"))
def test_is_commentable_divided_enrollment_track(self):
course = modulestore().get_course(self.toy_course_key)
set_discussion_division_settings(
course.id,
divided_discussions=[],
always_divide_inline_discussions=True,
division_scheme=CourseDiscussionSettings.ENROLLMENT_TRACK,
)
# Although division scheme is set to ENROLLMENT_TRACK, divided returns
# False because there is only a single enrollment mode.
self.assertFalse(utils.is_commentable_divided(course.id, "random"))
# Now create 2 explicit course modes.
CourseModeFactory.create(course_id=course.id, mode_slug=CourseMode.AUDIT)
CourseModeFactory.create(course_id=course.id, mode_slug=CourseMode.VERIFIED)
self.assertTrue(utils.is_commentable_divided(course.id, "random"))
@attr(shard=1)
class GroupIdForUserTestCase(ModuleStoreTestCase):
""" Test the get_group_id_for_user method. """
def setUp(self):
super(GroupIdForUserTestCase, self).setUp()
self.course = CourseFactory.create()
CourseModeFactory.create(course_id=self.course.id, mode_slug=CourseMode.AUDIT)
CourseModeFactory.create(course_id=self.course.id, mode_slug=CourseMode.VERIFIED)
self.test_user = UserFactory.create()
CourseEnrollmentFactory.create(
mode=CourseMode.VERIFIED, user=self.test_user, course_id=self.course.id
)
self.test_cohort = CohortFactory(
course_id=self.course.id,
name='Test Cohort',
users=[self.test_user]
)
def test_discussion_division_disabled(self):
course_discussion_settings = get_course_discussion_settings(self.course.id)
self.assertEqual(CourseDiscussionSettings.NONE, course_discussion_settings.division_scheme)
self.assertIsNone(utils.get_group_id_for_user(self.test_user, course_discussion_settings))
def test_discussion_division_by_cohort(self):
set_discussion_division_settings(
self.course.id, enable_cohorts=True, division_scheme=CourseDiscussionSettings.COHORT
)
course_discussion_settings = get_course_discussion_settings(self.course.id)
self.assertEqual(CourseDiscussionSettings.COHORT, course_discussion_settings.division_scheme)
self.assertEqual(
self.test_cohort.id,
utils.get_group_id_for_user(self.test_user, course_discussion_settings)
)
def test_discussion_division_by_enrollment_track(self):
set_discussion_division_settings(
self.course.id, division_scheme=CourseDiscussionSettings.ENROLLMENT_TRACK
)
course_discussion_settings = get_course_discussion_settings(self.course.id)
self.assertEqual(CourseDiscussionSettings.ENROLLMENT_TRACK, course_discussion_settings.division_scheme)
self.assertEqual(
-2, # Verified has group ID 2, and we negate that value to ensure unique IDs
utils.get_group_id_for_user(self.test_user, course_discussion_settings)
)
@attr(shard=1)
class CourseDiscussionDivisionEnabledTestCase(ModuleStoreTestCase):
""" Test the course_discussion_division_enabled method. """
def setUp(self):
super(CourseDiscussionDivisionEnabledTestCase, self).setUp()
self.course = CourseFactory.create()
CourseModeFactory.create(course_id=self.course.id, mode_slug=CourseMode.AUDIT)
self.test_cohort = CohortFactory(
course_id=self.course.id,
name='Test Cohort',
users=[]
)
def test_discussion_division_disabled(self):
course_discussion_settings = get_course_discussion_settings(self.course.id)
self.assertFalse(utils.course_discussion_division_enabled(course_discussion_settings))
def test_discussion_division_by_cohort(self):
set_discussion_division_settings(
self.course.id, enable_cohorts=False, division_scheme=CourseDiscussionSettings.COHORT
)
# Because cohorts are disabled, discussion division is not enabled.
self.assertFalse(utils.course_discussion_division_enabled(get_course_discussion_settings(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
)
self.assertTrue(utils.course_discussion_division_enabled(get_course_discussion_settings(self.course.id)))
def test_discussion_division_by_enrollment_track(self):
set_discussion_division_settings(
self.course.id, division_scheme=CourseDiscussionSettings.ENROLLMENT_TRACK
)
# Only a single enrollment track exists, so discussion division is not enabled.
self.assertFalse(utils.course_discussion_division_enabled(get_course_discussion_settings(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)
self.assertTrue(utils.course_discussion_division_enabled(get_course_discussion_settings(self.course.id)))
@attr(shard=1)
class GroupNameTestCase(ModuleStoreTestCase):
""" Test the get_group_name and get_group_names_by_id methods. """
def setUp(self):
super(GroupNameTestCase, self).setUp()
self.course = CourseFactory.create()
CourseModeFactory.create(course_id=self.course.id, mode_slug=CourseMode.AUDIT)
CourseModeFactory.create(course_id=self.course.id, mode_slug=CourseMode.VERIFIED)
self.test_cohort_1 = CohortFactory(
course_id=self.course.id,
name='Cohort 1',
users=[]
)
self.test_cohort_2 = CohortFactory(
course_id=self.course.id,
name='Cohort 2',
users=[]
)
def test_discussion_division_disabled(self):
course_discussion_settings = get_course_discussion_settings(self.course.id)
self.assertEqual({}, utils.get_group_names_by_id(course_discussion_settings))
self.assertIsNone(utils.get_group_name(-1000, course_discussion_settings))
def test_discussion_division_by_cohort(self):
set_discussion_division_settings(
self.course.id, enable_cohorts=True, division_scheme=CourseDiscussionSettings.COHORT
)
course_discussion_settings = get_course_discussion_settings(self.course.id)
self.assertEqual(
{
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)
)
self.assertEqual(
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.
self.assertIsNone(
utils.get_group_name(-1000, course_discussion_settings)
)
def test_discussion_division_by_enrollment_track(self):
set_discussion_division_settings(
self.course.id, division_scheme=CourseDiscussionSettings.ENROLLMENT_TRACK
)
course_discussion_settings = get_course_discussion_settings(self.course.id)
self.assertEqual(
{
-1: "audit course",
-2: "verified course"
},
utils.get_group_names_by_id(course_discussion_settings)
)
self.assertEqual(
"verified course",
utils.get_group_name(-2, course_discussion_settings)
)
# Test also with a group_id that doesn't exist.
self.assertIsNone(
utils.get_group_name(-1000, course_discussion_settings)
)
class PermissionsTestCase(ModuleStoreTestCase): class PermissionsTestCase(ModuleStoreTestCase):
"""Test utils functionality related to forums "abilities" (permissions)""" """Test utils functionality related to forums "abilities" (permissions)"""
...@@ -1497,3 +1693,21 @@ class ClientConfigurationTestCase(TestCase): ...@@ -1497,3 +1693,21 @@ class ClientConfigurationTestCase(TestCase):
result = perform_request('GET', 'http://www.google.com') result = perform_request('GET', 'http://www.google.com')
self.assertEqual(result, {}) self.assertEqual(result, {})
def set_discussion_division_settings(
course_key, enable_cohorts=False, always_divide_inline_discussions=False,
divided_discussions=[], division_scheme=CourseDiscussionSettings.COHORT
):
"""
Convenience method for setting cohort enablement and discussion settings.
COHORT is the default division_scheme, as no other schemes were supported at
the time that the unit tests were originally written.
"""
set_course_discussion_settings(
course_key=course_key,
divided_discussions=divided_discussions,
division_scheme=division_scheme,
always_divide_inline_discussions=always_divide_inline_discussions,
)
set_course_cohorted(course_key, enable_cohorts)
...@@ -14,20 +14,22 @@ import pystache_custom as pystache ...@@ -14,20 +14,22 @@ import pystache_custom as pystache
from opaque_keys.edx.locations import i4xEncoder from opaque_keys.edx.locations import i4xEncoder
from opaque_keys.edx.keys import CourseKey from opaque_keys.edx.keys import CourseKey
from xmodule.modulestore.django import modulestore from xmodule.modulestore.django import modulestore
from xmodule.partitions.partitions_service import PartitionService
from xmodule.partitions.partitions import ENROLLMENT_TRACK_PARTITION_ID
from django_comment_common.models import Role, FORUM_ROLE_STUDENT from django_comment_client.constants import TYPE_ENTRY, TYPE_SUBCATEGORY
from django_comment_client.permissions import check_permissions_by_view, has_permission, get_team from django_comment_client.permissions import check_permissions_by_view, has_permission, get_team
from django_comment_client.settings import MAX_COMMENT_DEPTH from django_comment_client.settings import MAX_COMMENT_DEPTH
from django_comment_client.constants import TYPE_ENTRY, TYPE_SUBCATEGORY from django_comment_common.models import Role, FORUM_ROLE_STUDENT, CourseDiscussionSettings
from django_comment_common.utils import get_course_discussion_settings
from edxmako import lookup_template from edxmako import lookup_template
from courseware import courses from courseware import courses
from courseware.access import has_access from courseware.access import has_access
from openedx.core.djangoapps.content.course_structures.models import CourseStructure from openedx.core.djangoapps.content.course_structures.models import CourseStructure
from openedx.core.djangoapps.course_groups.cohorts import ( from openedx.core.djangoapps.course_groups.cohorts import (
get_course_cohort_settings, get_cohort_by_id, get_cohort_id, is_course_cohorted is_course_cohorted, get_cohort_id, get_cohort_names
) )
from openedx.core.djangoapps.course_groups.models import CourseUserGroup
from request_cache.middleware import request_cached from request_cache.middleware import request_cached
from student.roles import GlobalStaff from student.roles import GlobalStaff
...@@ -267,7 +269,7 @@ def get_discussion_category_map(course, user, divided_only_if_explicit=False, ex ...@@ -267,7 +269,7 @@ def get_discussion_category_map(course, user, divided_only_if_explicit=False, ex
course: Course for which to get the ids. course: Course for which to get the ids.
user: User to check for access. user: User to check for access.
divided_only_if_explicit (bool): If True, inline topics are marked is_divided only if they are divided_only_if_explicit (bool): If True, inline topics are marked is_divided only if they are
explicitly listed in course_cohort_settings.discussion_topics. explicitly listed in CourseDiscussionSettings.discussion_topics.
Example: Example:
>>> example = { >>> example = {
...@@ -310,7 +312,9 @@ def get_discussion_category_map(course, user, divided_only_if_explicit=False, ex ...@@ -310,7 +312,9 @@ def get_discussion_category_map(course, user, divided_only_if_explicit=False, ex
xblocks = get_accessible_discussion_xblocks(course, user) xblocks = get_accessible_discussion_xblocks(course, user)
course_cohort_settings = get_course_cohort_settings(course.id) discussion_settings = get_course_discussion_settings(course.id)
discussion_division_enabled = course_discussion_division_enabled(discussion_settings)
divided_discussion_ids = discussion_settings.divided_discussions
for xblock in xblocks: for xblock in xblocks:
discussion_id = xblock.discussion_id discussion_id = xblock.discussion_id
...@@ -357,13 +361,13 @@ def get_discussion_category_map(course, user, divided_only_if_explicit=False, ex ...@@ -357,13 +361,13 @@ def get_discussion_category_map(course, user, divided_only_if_explicit=False, ex
node[level]["start_date"] = category_start_date node[level]["start_date"] = category_start_date
divide_all_inline_discussions = ( # pylint: disable=invalid-name divide_all_inline_discussions = ( # pylint: disable=invalid-name
not divided_only_if_explicit and course_cohort_settings.always_cohort_inline_discussions not divided_only_if_explicit and discussion_settings.always_divide_inline_discussions
) )
dupe_counters = defaultdict(lambda: 0) # counts the number of times we see each title dupe_counters = defaultdict(lambda: 0) # counts the number of times we see each title
for entry in entries: for entry in entries:
is_entry_divided = ( is_entry_divided = (
course_cohort_settings.is_cohorted and ( discussion_division_enabled and (
divide_all_inline_discussions or entry["id"] in course_cohort_settings.cohorted_discussions divide_all_inline_discussions or entry["id"] in divided_discussion_ids
) )
) )
...@@ -387,7 +391,7 @@ def get_discussion_category_map(course, user, divided_only_if_explicit=False, ex ...@@ -387,7 +391,7 @@ def get_discussion_category_map(course, user, divided_only_if_explicit=False, ex
"sort_key": entry.get("sort_key", topic), "sort_key": entry.get("sort_key", topic),
"start_date": datetime.now(UTC()), "start_date": datetime.now(UTC()),
"is_divided": ( "is_divided": (
course_cohort_settings.is_cohorted and entry["id"] in course_cohort_settings.cohorted_discussions discussion_division_enabled and entry["id"] in divided_discussion_ids
) )
} }
...@@ -652,7 +656,7 @@ def add_courseware_context(content_list, course, user, id_map=None): ...@@ -652,7 +656,7 @@ def add_courseware_context(content_list, course, user, id_map=None):
content.update({"courseware_url": url, "courseware_title": title}) content.update({"courseware_url": url, "courseware_title": title})
def prepare_content(content, course_key, is_staff=False, course_is_cohorted=None): def prepare_content(content, course_key, is_staff=False, discussion_division_enabled=None):
""" """
This function is used to pre-process thread and comment models in various This function is used to pre-process thread and comment models in various
ways before adding them to the HTTP response. This includes fixing empty ways before adding them to the HTTP response. This includes fixing empty
...@@ -666,7 +670,9 @@ def prepare_content(content, course_key, is_staff=False, course_is_cohorted=None ...@@ -666,7 +670,9 @@ def prepare_content(content, course_key, is_staff=False, course_is_cohorted=None
content (dict): A thread or comment. content (dict): A thread or comment.
course_key (CourseKey): The course key of the course. course_key (CourseKey): The course key of the course.
is_staff (bool): Whether the user is a staff member. is_staff (bool): Whether the user is a staff member.
course_is_cohorted (bool): Whether the course is cohorted. discussion_division_enabled (bool): Whether division of course discussions is enabled.
Note that callers of this method do not need to provide this value (it defaults to None)--
it is calculated and then passed to recursive calls of this method.
""" """
fields = [ fields = [
'id', 'title', 'body', 'course_id', 'anonymous', 'anonymous_to_peers', 'id', 'title', 'body', 'course_id', 'anonymous', 'anonymous_to_peers',
...@@ -707,23 +713,27 @@ def prepare_content(content, course_key, is_staff=False, course_is_cohorted=None ...@@ -707,23 +713,27 @@ def prepare_content(content, course_key, is_staff=False, course_is_cohorted=None
else: else:
del endorsement["user_id"] del endorsement["user_id"]
if course_is_cohorted is None: if discussion_division_enabled is None:
course_is_cohorted = is_course_cohorted(course_key) discussion_division_enabled = course_discussion_division_enabled(get_course_discussion_settings(course_key))
for child_content_key in ["children", "endorsed_responses", "non_endorsed_responses"]: for child_content_key in ["children", "endorsed_responses", "non_endorsed_responses"]:
if child_content_key in content: if child_content_key in content:
children = [ children = [
prepare_content(child, course_key, is_staff, course_is_cohorted=course_is_cohorted) prepare_content(child, course_key, is_staff, discussion_division_enabled=discussion_division_enabled)
for child in content[child_content_key] for child in content[child_content_key]
] ]
content[child_content_key] = children content[child_content_key] = children
if course_is_cohorted: if discussion_division_enabled:
# Augment the specified thread info to include the group name if a group id is present. # Augment the specified thread info to include the group name if a group id is present.
if content.get('group_id') is not None: if content.get('group_id') is not None:
content['group_name'] = get_cohort_by_id(course_key, content.get('group_id')).name course_discussion_settings = get_course_discussion_settings(course_key)
content['group_name'] = get_group_name(content.get('group_id'), course_discussion_settings)
content['is_commentable_divided'] = is_commentable_divided(
course_key, content['commentable_id'], course_discussion_settings
)
else: else:
# Remove any cohort information that might remain if the course had previously been cohorted. # Remove any group information that might remain if the course had previously been divided.
content.pop('group_id', None) content.pop('group_id', None)
return content return content
...@@ -741,7 +751,8 @@ def get_group_id_for_comments_service(request, course_key, commentable_id=None): ...@@ -741,7 +751,8 @@ def get_group_id_for_comments_service(request, course_key, commentable_id=None):
Raises: Raises:
ValueError if the requested group_id is invalid ValueError if the requested group_id is invalid
""" """
if commentable_id is None or is_commentable_divided(course_key, commentable_id): course_discussion_settings = get_course_discussion_settings(course_key)
if commentable_id is None or is_commentable_divided(course_key, commentable_id, course_discussion_settings):
if request.method == "GET": if request.method == "GET":
requested_group_id = request.GET.get('group_id') requested_group_id = request.GET.get('group_id')
elif request.method == "POST": elif request.method == "POST":
...@@ -749,27 +760,35 @@ def get_group_id_for_comments_service(request, course_key, commentable_id=None): ...@@ -749,27 +760,35 @@ def get_group_id_for_comments_service(request, course_key, commentable_id=None):
if has_permission(request.user, "see_all_cohorts", course_key): if has_permission(request.user, "see_all_cohorts", course_key):
if not requested_group_id: if not requested_group_id:
return None return None
try:
group_id = int(requested_group_id) group_id = int(requested_group_id)
get_cohort_by_id(course_key, group_id) _verify_group_exists(group_id, course_discussion_settings)
except CourseUserGroup.DoesNotExist:
raise ValueError
else: else:
# regular users always query with their own id. # regular users always query with their own id.
group_id = get_group_id_for_user(request.user, course_key) group_id = get_group_id_for_user(request.user, course_discussion_settings)
return group_id return group_id
else: else:
# Never pass a group_id to the comments service for a non-cohorted # Never pass a group_id to the comments service for a non-divided
# commentable # commentable
return None return None
def get_group_id_for_user(user, course_key): def get_group_id_for_user(user, course_discussion_settings):
""" """
This method will be modified to consider Enrollment Tracks. Given a user, return the group_id for that user according to the course_discussion_settings.
Currently pass-through to get_cohort_id. 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.
""" """
return get_cohort_id(user, course_key) 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:
partition_service = PartitionService(course_discussion_settings.course_id)
group_id = partition_service.get_user_group_id_for_partition(user, ENROLLMENT_TRACK_PARTITION_ID)
# 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 if group_id is not None else None
else:
return None
def is_comment_too_deep(parent): def is_comment_too_deep(parent):
...@@ -786,11 +805,13 @@ def is_comment_too_deep(parent): ...@@ -786,11 +805,13 @@ def is_comment_too_deep(parent):
) )
def is_commentable_divided(course_key, commentable_id): def is_commentable_divided(course_key, commentable_id, course_discussion_settings=None):
""" """
Args: Args:
course_key: CourseKey course_key: CourseKey
commentable_id: string commentable_id: string
course_discussion_settings: CourseDiscussionSettings model instance (optional). If not
supplied, it will be retrieved via the course_key.
Returns: Returns:
Bool: is this commentable divided, meaning that learners are divided into Bool: is this commentable divided, meaning that learners are divided into
...@@ -799,28 +820,118 @@ def is_commentable_divided(course_key, commentable_id): ...@@ -799,28 +820,118 @@ def is_commentable_divided(course_key, commentable_id):
Raises: Raises:
Http404 if the course doesn't exist. Http404 if the course doesn't exist.
""" """
if not course_discussion_settings:
course_discussion_settings = get_course_discussion_settings(course_key)
course = courses.get_course_by_id(course_key) course = courses.get_course_by_id(course_key)
course_cohort_settings = get_course_cohort_settings(course_key)
if not course_cohort_settings.is_cohorted or get_team(commentable_id): if not course_discussion_division_enabled(course_discussion_settings) or get_team(commentable_id):
# this is the easy case :) # this is the easy case :)
ans = False ans = False
elif ( elif (
commentable_id in course.top_level_discussion_topic_ids or commentable_id in course.top_level_discussion_topic_ids or
course_cohort_settings.always_cohort_inline_discussions is False course_discussion_settings.always_divide_inline_discussions is False
): ):
# top level discussions have to be manually configured as cohorted # top level discussions have to be manually configured as divided
# (default is not). # (default is not).
# Same thing for inline discussions if the default is explicitly set to False in settings # Same thing for inline discussions if the default is explicitly set to False in settings
ans = commentable_id in course_cohort_settings.cohorted_discussions ans = commentable_id in course_discussion_settings.divided_discussions
else: else:
# inline discussions are cohorted by default # inline discussions are divided by default
ans = True ans = True
log.debug(u"is_commentable_divided(%s, %s) = {%s}", course_key, commentable_id, ans) log.debug(u"is_commentable_divided(%s, %s) = {%s}", course_key, commentable_id, ans)
return ans return ans
def course_discussion_division_enabled(course_discussion_settings):
"""
Are discussions divided for the course represented by this instance of
course_discussion_settings? This method looks both at
course_discussion_settings.division_scheme, and information about the course
state itself (For example, are cohorts enabled? And are there multiple
enrollment tracks?).
Args:
course_discussion_settings: CourseDiscussionSettings model instance
Returns: True if discussion division is enabled for the course, else False
"""
return _get_course_division_scheme(course_discussion_settings) != CourseDiscussionSettings.NONE
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
len(_get_enrollment_track_groups(course_discussion_settings.course_id)) <= 1
):
division_scheme = CourseDiscussionSettings.NONE
return division_scheme
def get_group_name(group_id, course_discussion_settings):
"""
Given a specified comments_service group_id, returns the learner-facing
name of the Group. If no such Group exists for the specified group_id
(taking into account the division_scheme and course specified by course_discussion_settings),
returns None.
Args:
group_id: the group_id as used by the comments_service code
course_discussion_settings: CourseDiscussionSettings model instance
Returns: learner-facing name of the Group, or None if no such group exists
"""
group_names_by_id = get_group_names_by_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(courses.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
division scheme being used. If it does not, a ValueError will be raised.
"""
if get_group_name(group_id, course_discussion_settings) is None:
raise ValueError
def is_discussion_enabled(course_id): def is_discussion_enabled(course_id):
""" """
Return True if discussions are enabled; else False Return True if discussions are enabled; else False
......
...@@ -67,7 +67,7 @@ from lms.djangoapps.instructor_task.api_helper import AlreadyRunningError ...@@ -67,7 +67,7 @@ from lms.djangoapps.instructor_task.api_helper import AlreadyRunningError
from certificates.tests.factories import GeneratedCertificateFactory from certificates.tests.factories import GeneratedCertificateFactory
from certificates.models import CertificateStatuses from certificates.models import CertificateStatuses
from openedx.core.djangoapps.course_groups.cohorts import set_course_cohort_settings from openedx.core.djangoapps.course_groups.cohorts import set_course_cohorted
from openedx.core.lib.xblock_utils import grade_histogram from openedx.core.lib.xblock_utils import grade_histogram
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
from openedx.core.djangoapps.site_configuration.tests.mixins import SiteMixin from openedx.core.djangoapps.site_configuration.tests.mixins import SiteMixin
...@@ -2701,7 +2701,7 @@ class TestInstructorAPILevelsDataDump(SharedModuleStoreTestCase, LoginEnrollment ...@@ -2701,7 +2701,7 @@ class TestInstructorAPILevelsDataDump(SharedModuleStoreTestCase, LoginEnrollment
cohorted, and does not when the course is not cohorted. cohorted, and does not when the course is not cohorted.
""" """
url = reverse('get_students_features', kwargs={'course_id': unicode(self.course.id)}) url = reverse('get_students_features', kwargs={'course_id': unicode(self.course.id)})
set_course_cohort_settings(self.course.id, is_cohorted=is_cohorted) set_course_cohorted(self.course.id, is_cohorted)
response = self.client.post(url, {}) response = self.client.post(url, {})
res_json = json.loads(response.content) res_json = json.loads(response.content)
......
...@@ -20,7 +20,7 @@ from openedx.core.djangolib.markup import HTML ...@@ -20,7 +20,7 @@ from openedx.core.djangolib.markup import HTML
class="forum-nav-browse-menu-item" class="forum-nav-browse-menu-item"
data-discussion-id='${entries[entry]["id"]}' data-discussion-id='${entries[entry]["id"]}'
id='${entries[entry]["id"]}' id='${entries[entry]["id"]}'
data-cohorted="${str(entries[entry]['is_divided']).lower()}" data-divided="${str(entries[entry]['is_divided']).lower()}"
role="option" role="option"
> >
% if entry: % if entry:
......
...@@ -21,14 +21,14 @@ ...@@ -21,14 +21,14 @@
%endif %endif
</select> </select>
## safe-lint: disable=python-parse-error,python-wrap-html ## safe-lint: disable=python-parse-error,python-wrap-html
</label>${"<% if (isCohorted && isPrivilegedUser) { %>" | n, decode.utf8}<label class="forum-nav-filter-cohort"> </label>${"<% if (isDiscussionDivisionEnabled && isPrivilegedUser) { %>" | n, decode.utf8}<label class="forum-nav-filter-cohort">
## Translators: This labels a cohort menu in forum navigation ## Translators: This labels a group menu in forum navigation
<span class="sr">${_("Cohort:")}</span> <span class="sr">${_("Group:")}</span>
<select class="forum-nav-filter-cohort-control"> <select class="forum-nav-filter-cohort-control">
<option value="">${_("in all cohorts")}</option> <option value="">${_("in all groups")}</option>
## cohorts is not iterable sometimes because inline discussions xmodule doesn't pass it ## groups is not iterable sometimes because inline discussions xmodule doesn't pass it
%for c in (cohorts or []): %for group in (groups or []):
<option value="${c['id']}">${c['name']}</option> <option value="${group['id']}">${group['name']}</option>
%endfor %endfor
</select> </select>
## safe-lint: disable=python-parse-error,python-wrap-html ## safe-lint: disable=python-parse-error,python-wrap-html
......
...@@ -118,7 +118,21 @@ def is_course_cohorted(course_key): ...@@ -118,7 +118,21 @@ def is_course_cohorted(course_key):
Raises: Raises:
Http404 if the course doesn't exist. Http404 if the course doesn't exist.
""" """
return get_course_cohort_settings(course_key).is_cohorted return _get_course_cohort_settings(course_key).is_cohorted
def set_course_cohorted(course_key, cohorted):
"""
Given a course course and a boolean, sets whether or not the course is cohorted.
Raises:
Value error if `cohorted` is not a boolean
"""
if not isinstance(cohorted, bool):
raise ValueError("Cohorted must be a boolean")
course_cohort_settings = _get_course_cohort_settings(course_key)
course_cohort_settings.is_cohorted = cohorted
course_cohort_settings.save()
def get_cohort_id(user, course_key, use_cached=False): def get_cohort_id(user, course_key, use_cached=False):
...@@ -130,22 +144,6 @@ def get_cohort_id(user, course_key, use_cached=False): ...@@ -130,22 +144,6 @@ def get_cohort_id(user, course_key, use_cached=False):
return None if cohort is None else cohort.id return None if cohort is None else cohort.id
def get_cohorted_commentables(course_key):
"""
Given a course_key return a set of strings representing cohorted commentables.
"""
course_cohort_settings = get_course_cohort_settings(course_key)
if not course_cohort_settings.is_cohorted:
# this is the easy case :)
ans = set()
else:
ans = set(course_cohort_settings.cohorted_discussions)
return ans
COHORT_CACHE_NAMESPACE = u"cohorts.get_cohort" COHORT_CACHE_NAMESPACE = u"cohorts.get_cohort"
...@@ -213,8 +211,7 @@ def get_cohort(user, course_key, assign=True, use_cached=False): ...@@ -213,8 +211,7 @@ def get_cohort(user, course_key, assign=True, use_cached=False):
# First check whether the course is cohorted (users shouldn't be in a cohort # First check whether the course is cohorted (users shouldn't be in a cohort
# in non-cohorted courses, but settings can change after course starts) # in non-cohorted courses, but settings can change after course starts)
course_cohort_settings = get_course_cohort_settings(course_key) if not is_course_cohorted(course_key):
if not course_cohort_settings.is_cohorted:
return cache.setdefault(cache_key, None) return cache.setdefault(cache_key, None)
# If course is cohorted, check if the user already has a cohort. # If course is cohorted, check if the user already has a cohort.
...@@ -277,11 +274,7 @@ def migrate_cohort_settings(course): ...@@ -277,11 +274,7 @@ def migrate_cohort_settings(course):
""" """
cohort_settings, created = CourseCohortsSettings.objects.get_or_create( cohort_settings, created = CourseCohortsSettings.objects.get_or_create(
course_id=course.id, course_id=course.id,
defaults={ defaults=_get_cohort_settings_from_modulestore(course)
'is_cohorted': course.is_cohorted,
'cohorted_discussions': list(course.cohorted_discussions),
'always_cohort_inline_discussions': course.always_cohort_inline_discussions
}
) )
# Add the new and update the existing cohorts # Add the new and update the existing cohorts
...@@ -507,50 +500,49 @@ def is_last_random_cohort(user_group): ...@@ -507,50 +500,49 @@ def is_last_random_cohort(user_group):
return len(random_cohorts) == 1 and random_cohorts[0].name == user_group.name return len(random_cohorts) == 1 and random_cohorts[0].name == user_group.name
def set_course_cohort_settings(course_key, **kwargs): @request_cached
def _get_course_cohort_settings(course_key):
""" """
Set cohort settings for a course. Return cohort settings for a course. NOTE that the only non-deprecated fields in
CourseCohortSettings are `course_id` and `is_cohorted`. Other fields should only be used for
migration purposes.
Arguments: Arguments:
course_key: CourseKey course_key: CourseKey
is_cohorted (bool): If the course should be cohorted.
always_cohort_inline_discussions (bool): If inline discussions should always be cohorted.
cohorted_discussions (list): List of discussion ids.
Returns: Returns:
A CourseCohortSettings object. A CourseCohortSettings object. NOTE that the only non-deprecated field in
CourseCohortSettings are `course_id` and `is_cohorted`. Other fields should only be used
for migration purposes.
Raises: Raises:
Http404 if course_key is invalid. Http404 if course_key is invalid.
""" """
fields = {'is_cohorted': bool, 'always_cohort_inline_discussions': bool, 'cohorted_discussions': list} try:
course_cohort_settings = get_course_cohort_settings(course_key) course_cohort_settings = CourseCohortsSettings.objects.get(course_id=course_key)
for field, field_type in fields.items(): except CourseCohortsSettings.DoesNotExist:
if field in kwargs: course = courses.get_course_by_id(course_key)
if not isinstance(kwargs[field], field_type): course_cohort_settings = migrate_cohort_settings(course)
raise ValueError("Incorrect field type for `{}`. Type must be `{}`".format(field, field_type.__name__))
setattr(course_cohort_settings, field, kwargs[field])
course_cohort_settings.save()
return course_cohort_settings return course_cohort_settings
@request_cached def get_legacy_discussion_settings(course_key):
def get_course_cohort_settings(course_key):
"""
Return cohort settings for a course.
Arguments:
course_key: CourseKey
Returns:
A CourseCohortSettings object.
Raises:
Http404 if course_key is invalid.
"""
try: try:
course_cohort_settings = CourseCohortsSettings.objects.get(course_id=course_key) course_cohort_settings = CourseCohortsSettings.objects.get(course_id=course_key)
return {
'is_cohorted': course_cohort_settings.is_cohorted,
'cohorted_discussions': course_cohort_settings.cohorted_discussions,
'always_cohort_inline_discussions': course_cohort_settings.always_cohort_inline_discussions
}
except CourseCohortsSettings.DoesNotExist: except CourseCohortsSettings.DoesNotExist:
course = courses.get_course_by_id(course_key) course = courses.get_course_by_id(course_key)
course_cohort_settings = migrate_cohort_settings(course) return _get_cohort_settings_from_modulestore(course)
return course_cohort_settings
def _get_cohort_settings_from_modulestore(course):
return {
'is_cohorted': course.is_cohorted,
'cohorted_discussions': list(course.cohorted_discussions),
'always_cohort_inline_discussions': course.always_cohort_inline_discussions
}
...@@ -168,6 +168,7 @@ class CourseUserGroupPartitionGroup(models.Model): ...@@ -168,6 +168,7 @@ class CourseUserGroupPartitionGroup(models.Model):
class CourseCohortsSettings(models.Model): class CourseCohortsSettings(models.Model):
""" """
This model represents cohort settings for courses. This model represents cohort settings for courses.
The only non-deprecated fields are `is_cohorted` and `course_id`.
""" """
is_cohorted = models.BooleanField(default=False) is_cohorted = models.BooleanField(default=False)
...@@ -184,16 +185,23 @@ class CourseCohortsSettings(models.Model): ...@@ -184,16 +185,23 @@ class CourseCohortsSettings(models.Model):
# in reality the default value at the time that cohorting is enabled for a course comes from # in reality the default value at the time that cohorting is enabled for a course comes from
# course_module.always_cohort_inline_discussions (via `migrate_cohort_settings`). # course_module.always_cohort_inline_discussions (via `migrate_cohort_settings`).
# pylint: disable=invalid-name # pylint: disable=invalid-name
# DEPRECATED-- DO NOT USE: Instead use `CourseDiscussionSettings.always_divide_inline_discussions`
# via `get_course_discussion_settings` or `set_course_discussion_settings`.
always_cohort_inline_discussions = models.BooleanField(default=False) always_cohort_inline_discussions = models.BooleanField(default=False)
@property @property
def cohorted_discussions(self): def cohorted_discussions(self):
"""Jsonify the cohorted_discussions""" """
DEPRECATED-- DO NOT USE. Instead use `CourseDiscussionSettings.divided_discussions`
via `get_course_discussion_settings`.
"""
return json.loads(self._cohorted_discussions) return json.loads(self._cohorted_discussions)
@cohorted_discussions.setter @cohorted_discussions.setter
def cohorted_discussions(self, value): def cohorted_discussions(self, value):
"""Un-Jsonify the cohorted_discussions""" """
DEPRECATED-- DO NOT USE. Instead use `CourseDiscussionSettings` via `set_course_discussion_settings`.
"""
self._cohorted_discussions = json.dumps(value) self._cohorted_discussions = json.dumps(value)
......
...@@ -10,7 +10,9 @@ from opaque_keys.edx.locations import SlashSeparatedCourseKey ...@@ -10,7 +10,9 @@ from opaque_keys.edx.locations import SlashSeparatedCourseKey
from xmodule.modulestore.django import modulestore from xmodule.modulestore.django import modulestore
from xmodule.modulestore import ModuleStoreEnum from xmodule.modulestore import ModuleStoreEnum
from ..cohorts import set_course_cohort_settings from ..cohorts import set_course_cohorted
from django_comment_common.models import CourseDiscussionSettings
from django_comment_common.utils import set_course_discussion_settings
from ..models import CourseUserGroup, CourseCohort, CourseCohortsSettings, CohortMembership from ..models import CourseUserGroup, CourseCohort, CourseCohortsSettings, CohortMembership
...@@ -140,8 +142,8 @@ def config_course_cohorts( ...@@ -140,8 +142,8 @@ def config_course_cohorts(
auto_cohorts=[], auto_cohorts=[],
manual_cohorts=[], manual_cohorts=[],
discussion_topics=[], discussion_topics=[],
cohorted_discussions=[], divided_discussions=[],
always_cohort_inline_discussions=False always_divide_inline_discussions=False
): ):
""" """
Set discussions and configure cohorts for a course. Set discussions and configure cohorts for a course.
...@@ -153,10 +155,10 @@ def config_course_cohorts( ...@@ -153,10 +155,10 @@ def config_course_cohorts(
manual_cohorts (list): Names of manual cohorts to create. manual_cohorts (list): Names of manual cohorts to create.
discussion_topics (list): Discussion topic names. Picks ids and discussion_topics (list): Discussion topic names. Picks ids and
sort_keys automatically. sort_keys automatically.
cohorted_discussions: Discussion topics to cohort. Converts the divided_discussions: Discussion topics to divide. Converts the
list to use the same ids as discussion topic names. list to use the same ids as discussion topic names.
always_cohort_inline_discussions (bool): Whether inline discussions always_divide_inline_discussions (bool): Whether inline discussions
should be cohorted by default. should be divided by default.
Returns: Returns:
Nothing -- modifies course in place. Nothing -- modifies course in place.
...@@ -165,11 +167,12 @@ def config_course_cohorts( ...@@ -165,11 +167,12 @@ def config_course_cohorts(
"""Convert name to id.""" """Convert name to id."""
return topic_name_to_id(course, name) return topic_name_to_id(course, name)
set_course_cohort_settings( set_course_cohorted(course.id, is_cohorted)
set_course_discussion_settings(
course.id, course.id,
is_cohorted=is_cohorted, divided_discussions=[to_id(name) for name in divided_discussions],
cohorted_discussions=[to_id(name) for name in cohorted_discussions], always_divide_inline_discussions=always_divide_inline_discussions,
always_cohort_inline_discussions=always_cohort_inline_discussions division_scheme=CourseDiscussionSettings.COHORT,
) )
for cohort_name in auto_cohorts: for cohort_name in auto_cohorts:
......
...@@ -22,8 +22,8 @@ from xmodule.modulestore.tests.factories import ToyCourseFactory ...@@ -22,8 +22,8 @@ from xmodule.modulestore.tests.factories import ToyCourseFactory
from ..models import CourseUserGroup, CourseCohort, CourseUserGroupPartitionGroup from ..models import CourseUserGroup, CourseCohort, CourseUserGroupPartitionGroup
from .. import cohorts from .. import cohorts
from ..tests.helpers import ( from ..tests.helpers import (
topic_name_to_id, config_course_cohorts, config_course_cohorts_legacy, config_course_cohorts, config_course_cohorts_legacy,
CohortFactory, CourseCohortFactory, CourseCohortSettingsFactory CohortFactory, CourseCohortFactory
) )
...@@ -475,44 +475,6 @@ class TestCohorts(ModuleStoreTestCase): ...@@ -475,44 +475,6 @@ class TestCohorts(ModuleStoreTestCase):
{cohort1.id: cohort1.name, cohort2.id: cohort2.name} {cohort1.id: cohort1.name, cohort2.id: cohort2.name}
) )
def test_get_cohorted_commentables(self):
"""
Make sure cohorts.get_cohorted_commentables() correctly returns a list of strings representing cohorted
commentables. Also verify that we can't get the cohorted commentables from a course which does not exist.
"""
course = modulestore().get_course(self.toy_course_key)
self.assertEqual(cohorts.get_cohorted_commentables(course.id), set())
config_course_cohorts(course, is_cohorted=True)
self.assertEqual(cohorts.get_cohorted_commentables(course.id), set())
config_course_cohorts(
course,
is_cohorted=True,
discussion_topics=["General", "Feedback"],
cohorted_discussions=["Feedback"]
)
self.assertItemsEqual(
cohorts.get_cohorted_commentables(course.id),
set([topic_name_to_id(course, "Feedback")])
)
config_course_cohorts(
course,
is_cohorted=True,
discussion_topics=["General", "Feedback"],
cohorted_discussions=["General", "Feedback"]
)
self.assertItemsEqual(
cohorts.get_cohorted_commentables(course.id),
set([topic_name_to_id(course, "General"), topic_name_to_id(course, "Feedback")])
)
self.assertRaises(
Http404,
lambda: cohorts.get_cohorted_commentables(SlashSeparatedCourseKey("course", "does_not", "exist"))
)
def test_get_cohort_by_name(self): def test_get_cohort_by_name(self):
""" """
Make sure cohorts.get_cohort_by_name() properly finds a cohort by name for a given course. Also verify that it Make sure cohorts.get_cohort_by_name() properly finds a cohort by name for a given course. Also verify that it
...@@ -672,59 +634,16 @@ class TestCohorts(ModuleStoreTestCase): ...@@ -672,59 +634,16 @@ class TestCohorts(ModuleStoreTestCase):
# Note that the following get() will fail with MultipleObjectsReturned if race condition is not handled. # Note that the following get() will fail with MultipleObjectsReturned if race condition is not handled.
self.assertEqual(first_cohort.users.get(), course_user) self.assertEqual(first_cohort.users.get(), course_user)
def test_get_course_cohort_settings(self): def test_set_cohorted_with_invalid_data_type(self):
"""
Test that cohorts.get_course_cohort_settings is working as expected.
"""
course = modulestore().get_course(self.toy_course_key)
course_cohort_settings = cohorts.get_course_cohort_settings(course.id)
self.assertFalse(course_cohort_settings.is_cohorted)
self.assertEqual(course_cohort_settings.cohorted_discussions, [])
self.assertFalse(course_cohort_settings.always_cohort_inline_discussions)
def test_update_course_cohort_settings(self):
""" """
Test that cohorts.set_course_cohort_settings is working as expected. Test that cohorts.set_course_cohorted raises exception if argument is not a boolean.
""" """
course = modulestore().get_course(self.toy_course_key) course = modulestore().get_course(self.toy_course_key)
CourseCohortSettingsFactory(course_id=course.id)
cohorts.set_course_cohort_settings(
course.id,
is_cohorted=False,
cohorted_discussions=['topic a id', 'topic b id'],
always_cohort_inline_discussions=True
)
course_cohort_settings = cohorts.get_course_cohort_settings(course.id)
self.assertFalse(course_cohort_settings.is_cohorted)
self.assertEqual(course_cohort_settings.cohorted_discussions, ['topic a id', 'topic b id'])
self.assertTrue(course_cohort_settings.always_cohort_inline_discussions)
def test_update_course_cohort_settings_with_invalid_data_type(self):
"""
Test that cohorts.set_course_cohort_settings raises exception if fields have incorrect data type.
"""
course = modulestore().get_course(self.toy_course_key)
CourseCohortSettingsFactory(course_id=course.id)
exception_msg_tpl = "Incorrect field type for `{}`. Type must be `{}`"
fields = [
{'name': 'is_cohorted', 'type': bool},
{'name': 'always_cohort_inline_discussions', 'type': bool},
{'name': 'cohorted_discussions', 'type': list}
]
for field in fields:
with self.assertRaises(ValueError) as value_error: with self.assertRaises(ValueError) as value_error:
cohorts.set_course_cohort_settings(course.id, **{field['name']: ''}) cohorts.set_course_cohorted(course.id, 'not a boolean')
self.assertEqual( self.assertEqual("Cohorted must be a boolean", value_error.exception.message)
value_error.exception.message,
exception_msg_tpl.format(field['name'], field['type'].__name__)
)
@attr(shard=2) @attr(shard=2)
......
...@@ -125,7 +125,7 @@ class CohortViewsTestCase(ModuleStoreTestCase): ...@@ -125,7 +125,7 @@ class CohortViewsTestCase(ModuleStoreTestCase):
self.course, self.course,
is_cohorted=True, is_cohorted=True,
discussion_topics=discussion_topics, discussion_topics=discussion_topics,
cohorted_discussions=cohorted_discussions divided_discussions=cohorted_discussions
) )
return cohorted_inline_discussions, cohorted_course_wide_discussions return cohorted_inline_discussions, cohorted_course_wide_discussions
...@@ -317,12 +317,23 @@ class CourseCohortSettingsHandlerTestCase(CohortViewsTestCase): ...@@ -317,12 +317,23 @@ class CourseCohortSettingsHandlerTestCase(CohortViewsTestCase):
response = self.patch_handler( response = self.patch_handler(
self.course, self.course,
data={'always_cohort_inline_discussions': ''},
expected_response_code=400,
handler=course_cohort_settings_handler
)
self.assertEqual(
"Incorrect field type for `{}`. Type must be `{}`".format('always_divide_inline_discussions', bool.__name__),
response.get("error")
)
response = self.patch_handler(
self.course,
data={'is_cohorted': ''}, data={'is_cohorted': ''},
expected_response_code=400, expected_response_code=400,
handler=course_cohort_settings_handler handler=course_cohort_settings_handler
) )
self.assertEqual( self.assertEqual(
"Incorrect field type for `{}`. Type must be `{}`".format('is_cohorted', bool.__name__), "Cohorted must be a boolean",
response.get("error") response.get("error")
) )
......
...@@ -5,6 +5,7 @@ Views related to course groups functionality. ...@@ -5,6 +5,7 @@ Views related to course groups functionality.
import logging import logging
import re import re
from courseware.courses import get_course_with_access
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.core.paginator import EmptyPage, Paginator from django.core.paginator import EmptyPage, Paginator
...@@ -14,13 +15,13 @@ from django.http import Http404, HttpResponseBadRequest ...@@ -14,13 +15,13 @@ from django.http import Http404, HttpResponseBadRequest
from django.utils.translation import ugettext from django.utils.translation import ugettext
from django.views.decorators.csrf import ensure_csrf_cookie from django.views.decorators.csrf import ensure_csrf_cookie
from django.views.decorators.http import require_http_methods, require_POST from django.views.decorators.http import require_http_methods, require_POST
from opaque_keys.edx.keys import CourseKey from django_comment_common.models import CourseDiscussionSettings
from opaque_keys.edx.locations import SlashSeparatedCourseKey from django_comment_common.utils import get_course_discussion_settings, set_course_discussion_settings
from courseware.courses import get_course_with_access
from edxmako.shortcuts import render_to_response from edxmako.shortcuts import render_to_response
from lms.djangoapps.django_comment_client.constants import TYPE_ENTRY from lms.djangoapps.django_comment_client.constants import TYPE_ENTRY
from lms.djangoapps.django_comment_client.utils import get_discussion_categories_ids, get_discussion_category_map from lms.djangoapps.django_comment_client.utils import get_discussion_categories_ids, get_discussion_category_map
from opaque_keys.edx.keys import CourseKey
from opaque_keys.edx.locations import SlashSeparatedCourseKey
from util.json_request import JsonResponse, expect_json from util.json_request import JsonResponse, expect_json
from . import cohorts from . import cohorts
...@@ -63,20 +64,20 @@ def unlink_cohort_partition_group(cohort): ...@@ -63,20 +64,20 @@ def unlink_cohort_partition_group(cohort):
# pylint: disable=invalid-name # pylint: disable=invalid-name
def _get_course_cohort_settings_representation(course, course_cohort_settings): def _get_course_cohort_settings_representation(course, is_cohorted, course_discussion_settings):
""" """
Returns a JSON representation of a course cohort settings. Returns a JSON representation of a course cohort settings.
""" """
cohorted_course_wide_discussions, cohorted_inline_discussions = get_cohorted_discussions( divided_course_wide_discussions, divided_inline_discussions = get_divided_discussions(
course, course_cohort_settings course, course_discussion_settings
) )
return { return {
'id': course_cohort_settings.id, 'id': course_discussion_settings.id,
'is_cohorted': course_cohort_settings.is_cohorted, 'is_cohorted': is_cohorted,
'cohorted_inline_discussions': cohorted_inline_discussions, 'cohorted_inline_discussions': divided_inline_discussions,
'cohorted_course_wide_discussions': cohorted_course_wide_discussions, 'cohorted_course_wide_discussions': divided_course_wide_discussions,
'always_cohort_inline_discussions': course_cohort_settings.always_cohort_inline_discussions, 'always_cohort_inline_discussions': course_discussion_settings.always_divide_inline_discussions,
} }
...@@ -97,23 +98,23 @@ def _get_cohort_representation(cohort, course): ...@@ -97,23 +98,23 @@ def _get_cohort_representation(cohort, course):
} }
def get_cohorted_discussions(course, course_settings): def get_divided_discussions(course, discussion_settings):
""" """
Returns the course-wide and inline cohorted discussion ids separately. Returns the course-wide and inline divided discussion ids separately.
""" """
cohorted_course_wide_discussions = [] divided_course_wide_discussions = []
cohorted_inline_discussions = [] divided_inline_discussions = []
course_wide_discussions = [topic['id'] for __, topic in course.discussion_topics.items()] course_wide_discussions = [topic['id'] for __, topic in course.discussion_topics.items()]
all_discussions = get_discussion_categories_ids(course, None, include_all=True) all_discussions = get_discussion_categories_ids(course, None, include_all=True)
for cohorted_discussion_id in course_settings.cohorted_discussions: for divided_discussion_id in discussion_settings.divided_discussions:
if cohorted_discussion_id in course_wide_discussions: if divided_discussion_id in course_wide_discussions:
cohorted_course_wide_discussions.append(cohorted_discussion_id) divided_course_wide_discussions.append(divided_discussion_id)
elif cohorted_discussion_id in all_discussions: elif divided_discussion_id in all_discussions:
cohorted_inline_discussions.append(cohorted_discussion_id) divided_inline_discussions.append(divided_discussion_id)
return cohorted_course_wide_discussions, cohorted_inline_discussions return divided_course_wide_discussions, divided_inline_discussions
@require_http_methods(("GET", "PATCH")) @require_http_methods(("GET", "PATCH"))
...@@ -131,11 +132,12 @@ def course_cohort_settings_handler(request, course_key_string): ...@@ -131,11 +132,12 @@ def course_cohort_settings_handler(request, course_key_string):
""" """
course_key = CourseKey.from_string(course_key_string) course_key = CourseKey.from_string(course_key_string)
course = get_course_with_access(request.user, 'staff', course_key) course = get_course_with_access(request.user, 'staff', course_key)
cohort_settings = cohorts.get_course_cohort_settings(course_key) is_cohorted = cohorts.is_course_cohorted(course_key)
discussion_settings = get_course_discussion_settings(course_key)
if request.method == 'PATCH': if request.method == 'PATCH':
cohorted_course_wide_discussions, cohorted_inline_discussions = get_cohorted_discussions( divided_course_wide_discussions, divided_inline_discussions = get_divided_discussions(
course, cohort_settings course, discussion_settings
) )
settings_to_change = {} settings_to_change = {}
...@@ -144,16 +146,16 @@ def course_cohort_settings_handler(request, course_key_string): ...@@ -144,16 +146,16 @@ def course_cohort_settings_handler(request, course_key_string):
settings_to_change['is_cohorted'] = request.json.get('is_cohorted') settings_to_change['is_cohorted'] = request.json.get('is_cohorted')
if 'cohorted_course_wide_discussions' in request.json or 'cohorted_inline_discussions' in request.json: if 'cohorted_course_wide_discussions' in request.json or 'cohorted_inline_discussions' in request.json:
cohorted_course_wide_discussions = request.json.get( divided_course_wide_discussions = request.json.get(
'cohorted_course_wide_discussions', cohorted_course_wide_discussions 'cohorted_course_wide_discussions', divided_course_wide_discussions
) )
cohorted_inline_discussions = request.json.get( divided_inline_discussions = request.json.get(
'cohorted_inline_discussions', cohorted_inline_discussions 'cohorted_inline_discussions', divided_inline_discussions
) )
settings_to_change['cohorted_discussions'] = cohorted_course_wide_discussions + cohorted_inline_discussions settings_to_change['divided_discussions'] = divided_course_wide_discussions + divided_inline_discussions
if 'always_cohort_inline_discussions' in request.json: if 'always_cohort_inline_discussions' in request.json:
settings_to_change['always_cohort_inline_discussions'] = request.json.get( settings_to_change['always_divide_inline_discussions'] = request.json.get(
'always_cohort_inline_discussions' 'always_cohort_inline_discussions'
) )
...@@ -161,14 +163,19 @@ def course_cohort_settings_handler(request, course_key_string): ...@@ -161,14 +163,19 @@ def course_cohort_settings_handler(request, course_key_string):
return JsonResponse({"error": unicode("Bad Request")}, 400) return JsonResponse({"error": unicode("Bad Request")}, 400)
try: try:
cohort_settings = cohorts.set_course_cohort_settings( if 'is_cohorted' in settings_to_change:
course_key, **settings_to_change is_cohorted = settings_to_change['is_cohorted']
) cohorts.set_course_cohorted(course_key, is_cohorted)
del settings_to_change['is_cohorted']
settings_to_change['division_scheme'] = CourseDiscussionSettings.COHORT if is_cohorted \
else CourseDiscussionSettings.NONE
if settings_to_change:
discussion_settings = set_course_discussion_settings(course_key, **settings_to_change)
except ValueError as err: except ValueError as err:
# Note: error message not translated because it is not exposed to the user (UI prevents this state). # Note: error message not translated because it is not exposed to the user (UI prevents this state).
return JsonResponse({"error": unicode(err)}, 400) return JsonResponse({"error": unicode(err)}, 400)
return JsonResponse(_get_course_cohort_settings_representation(course, cohort_settings)) return JsonResponse(_get_course_cohort_settings_representation(course, is_cohorted, discussion_settings))
@require_http_methods(("GET", "PUT", "POST", "PATCH")) @require_http_methods(("GET", "PUT", "POST", "PATCH"))
......
...@@ -12,7 +12,7 @@ from course_modes.models import CourseMode ...@@ -12,7 +12,7 @@ from course_modes.models import CourseMode
from student.models import CourseEnrollment from student.models import CourseEnrollment
from opaque_keys.edx.keys import CourseKey from opaque_keys.edx.keys import CourseKey
from openedx.core.djangoapps.verified_track_content.models import VerifiedTrackCohortedCourse from openedx.core.djangoapps.verified_track_content.models import VerifiedTrackCohortedCourse
from xmodule.partitions.partitions import NoSuchUserPartitionGroupError, Group, UserPartition from xmodule.partitions.partitions import Group, UserPartition
# These IDs must be less than 100 so that they do not overlap with Groups in # These IDs must be less than 100 so that they do not overlap with Groups in
......
...@@ -18,7 +18,7 @@ from xmodule.modulestore.tests.factories import CourseFactory ...@@ -18,7 +18,7 @@ from xmodule.modulestore.tests.factories import CourseFactory
from ..models import VerifiedTrackCohortedCourse, DEFAULT_VERIFIED_COHORT_NAME from ..models import VerifiedTrackCohortedCourse, DEFAULT_VERIFIED_COHORT_NAME
from ..tasks import sync_cohort_with_mode from ..tasks import sync_cohort_with_mode
from openedx.core.djangoapps.course_groups.cohorts import ( from openedx.core.djangoapps.course_groups.cohorts import (
set_course_cohort_settings, add_cohort, CourseCohort, DEFAULT_COHORT_NAME set_course_cohorted, add_cohort, CourseCohort, DEFAULT_COHORT_NAME
) )
from openedx.core.djangolib.testing.utils import skip_unless_lms from openedx.core.djangolib.testing.utils import skip_unless_lms
...@@ -88,7 +88,7 @@ class TestMoveToVerified(SharedModuleStoreTestCase): ...@@ -88,7 +88,7 @@ class TestMoveToVerified(SharedModuleStoreTestCase):
def _enable_cohorting(self): def _enable_cohorting(self):
""" Turn on cohorting in the course. """ """ Turn on cohorting in the course. """
set_course_cohort_settings(self.course.id, is_cohorted=True) set_course_cohorted(self.course.id, True)
def _create_verified_cohort(self, name=DEFAULT_VERIFIED_COHORT_NAME): def _create_verified_cohort(self, name=DEFAULT_VERIFIED_COHORT_NAME):
""" Create a verified cohort. """ """ Create a verified cohort. """
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment