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
from config_models.models import ConfigurationModel
......@@ -162,3 +163,30 @@ class ForumsConfig(ConfigurationModel):
def __unicode__(self):
"""Simple representation so the admin screen looks less ugly."""
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 opaque_keys.edx.locations import SlashSeparatedCourseKey
from nose.plugins.attrib import attr
from django.test import TestCase
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 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):
"""
Basic checks to make sure our Roles get assigned and unassigned as students
......@@ -55,3 +64,73 @@ class RoleAssignmentTest(TestCase):
# )
# self.assertNotIn(student_role, self.student_user.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 (
FORUM_ROLE_STUDENT,
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):
......@@ -91,3 +95,47 @@ def are_permissions_roles_seeded(course_id):
return False
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):
if self._cache and (cache_key in self._cache):
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:
raise ValueError(
"Configuration problem! No user_partition with id {0} "
......@@ -148,7 +148,7 @@ class PartitionService(object):
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.
Note that this method can return an inactive user partition.
......
......@@ -32,8 +32,8 @@
this.retrieveFollowed = function() {
return DiscussionThreadListView.prototype.retrieveFollowed.apply(self, arguments);
};
this.chooseCohort = function() {
return DiscussionThreadListView.prototype.chooseCohort.apply(self, arguments);
this.chooseGroup = function() {
return DiscussionThreadListView.prototype.chooseGroup.apply(self, arguments);
};
this.chooseFilter = function() {
return DiscussionThreadListView.prototype.chooseFilter.apply(self, arguments);
......@@ -85,7 +85,7 @@
'click .forum-nav-thread-link': 'threadSelected',
'click .forum-nav-load-more-link': 'loadMorePages',
'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) {
......@@ -194,7 +194,7 @@
edx.HtmlUtils.append(
this.$el,
this.template({
isCohorted: this.courseSettings.get('is_cohorted'),
isDiscussionDivisionEnabled: this.courseSettings.get('is_discussion_division_enabled'),
isPrivilegedUser: DiscussionUtil.isPrivilegedUser()
})
);
......@@ -404,7 +404,7 @@
return $(elem).data('discussion-id');
}).get();
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 @@
return this.retrieveFirstPage();
};
DiscussionThreadListView.prototype.chooseCohort = function() {
DiscussionThreadListView.prototype.chooseGroup = function() {
this.group_id = this.$('.forum-nav-filter-cohort-control :selected').val();
return this.retrieveFirstPage();
};
......
......@@ -35,7 +35,7 @@
'[data-discussion-id="' + this.getCurrentTopicId() + '"]'
));
} else if ($general.length > 0) {
this.setTopic($general);
this.setTopic($general.first());
} else {
this.setTopic(this.$('.post-topic option').first());
}
......
......@@ -51,7 +51,7 @@
threadTypeTemplate;
context = _.clone(this.course_settings.attributes);
_.extend(context, {
cohort_options: this.getCohortOptions(),
group_options: this.getGroupOptions(),
is_commentable_divided: this.is_commentable_divided,
mode: this.mode,
startHeader: this.startHeader,
......@@ -84,15 +84,15 @@
return this.mode === 'tab';
};
NewPostView.prototype.getCohortOptions = function() {
NewPostView.prototype.getGroupOptions = function() {
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');
return _.map(this.course_settings.get('cohorts'), function(cohort) {
return _.map(this.course_settings.get('groups'), function(group) {
return {
value: cohort.id,
text: cohort.name,
selected: cohort.id === userGroupId
value: group.id,
text: group.name,
selected: group.id === userGroupId
};
});
} else {
......@@ -112,7 +112,7 @@
};
NewPostView.prototype.toggleGroupDropdown = function($target) {
if ($target.data('cohorted')) {
if ($target.data('divided')) {
$('.js-group-select').prop('disabled', false);
return $('.group-selector-wrapper').removeClass('disabled');
} else {
......
......@@ -62,7 +62,7 @@
' <li' +
' class="forum-nav-browse-menu-item"' +
' data-discussion-id="child"' +
' data-cohorted="false"' +
' data-divided="false"' +
' >' +
' <a href="#" class="forum-nav-browse-title">Child</a>' +
' </li>' +
......@@ -70,7 +70,7 @@
' <li' +
' class="forum-nav-browse-menu-item"' +
' data-discussion-id="sibling"' +
' data-cohorted="false"' +
' data-divided="false"' +
' >' +
' <a href="#" class="forum-nav-browse-title">Sibling</a>' +
' </li>' +
......@@ -79,7 +79,7 @@
' <li' +
' class="forum-nav-browse-menu-item"' +
' data-discussion-id="other"' +
' data-cohorted="true"' +
' data-divided="true"' +
' >' +
' <a href="#" class="forum-nav-browse-title">Other Category</a>' +
' </li>' +
......@@ -95,11 +95,11 @@
' <option value="flagged">Flagged</option>' +
' </select>' +
' </label>' +
' <% if (isCohorted && isPrivilegedUser) { %>' +
' <% if (isDiscussionDivisionEnabled && isPrivilegedUser) { %>' +
' <label class="forum-nav-filter-cohort">' +
' <span class="sr">Cohort:</span>' +
' <span class="sr">Group:</span>' +
' <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="2">Cohort2</option>' +
' </select>' +
......@@ -164,7 +164,7 @@
collection: this.discussion,
el: $('#fixture-element'),
courseSettings: new DiscussionCourseSettings({
is_cohorted: true
is_discussion_division_enabled: true
})
});
return this.view.render();
......@@ -199,7 +199,7 @@
collection: discussion,
showThreadPreview: true,
courseSettings: new DiscussionCourseSettings({
is_cohorted: true
is_discussion_division_enabled: true
})
},
props
......@@ -233,7 +233,7 @@
});
});
describe('cohort selector', function() {
describe('group selector', function() {
it('should not be visible to students', function() {
return expect(this.view.$('.forum-nav-filter-cohort-control:visible')).not.toExist();
});
......
......@@ -31,7 +31,7 @@
return expect(group_disabled).toEqual(true);
}
};
describe('cohort selector', function() {
describe('group selector', function() {
beforeEach(function() {
this.course_settings = new DiscussionCourseSettings({
category_map: {
......@@ -53,8 +53,8 @@
},
allow_anonymous: false,
allow_anonymous_to_peers: false,
is_cohorted: true,
cohorts: [
is_discussion_division_enabled: true,
groups: [
{
id: 1,
name: 'Cohort1'
......@@ -75,15 +75,15 @@
it('is not visible to students', function() {
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();
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();
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();
checkVisibility(this.view, true, false, true);
......@@ -95,7 +95,7 @@
$('.post-topic').trigger('change');
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,
self = this;
DiscussionSpecHelper.makeModerator();
......@@ -116,23 +116,23 @@
});
});
});
describe('always cohort inline discussions ', function() {
describe('always divide inline discussions ', function() {
beforeEach(function() {
this.course_settings = new DiscussionCourseSettings({
'category_map': {
'children': [],
'entries': {}
category_map: {
children: [],
entries: {}
},
'allow_anonymous': false,
'allow_anonymous_to_peers': false,
'is_cohorted': true,
'cohorts': [
allow_anonymous: false,
allow_anonymous_to_peers: false,
is_discussion_division_enabled: true,
groups: [
{
'id': 1,
'name': 'Cohort1'
id: 1,
name: 'Cohort1'
}, {
'id': 2,
'name': 'Cohort2'
id: 2,
name: 'Cohort2'
}
]
});
......@@ -143,12 +143,12 @@
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();
this.view.is_commentable_divided = false;
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();
this.view.is_commentable_divided = true;
return checkVisibility(this.view, true, false, true);
......
......@@ -67,7 +67,7 @@
}
}
},
is_cohorted: true,
is_discussion_division_enabled: true,
allow_anonymous: false,
allow_anonymous_to_peers: false
},
......@@ -170,7 +170,7 @@
' <li' +
' class="forum-nav-browse-menu-item"' +
' data-discussion-id="child"' +
' data-cohorted="false"' +
' data-divided="false"' +
' >' +
' <a href="#" class="forum-nav-browse-title">Child</a>' +
' </li>' +
......@@ -178,7 +178,7 @@
' <li' +
' class="forum-nav-browse-menu-item"' +
' data-discussion-id="sibling"' +
' data-cohorted="false"' +
' data-divided="false"' +
' >' +
' <a href="#" class="forum-nav-browse-title">Sibling</a>' +
' </li>' +
......@@ -187,7 +187,7 @@
' <li' +
' class="forum-nav-browse-menu-item"' +
' data-discussion-id="other"' +
' data-cohorted="true"' +
' data-divided="true"' +
' >' +
' <a href="#" class="forum-nav-browse-title">Other Category</a>' +
' </li>' +
......@@ -203,11 +203,11 @@
' <option value="flagged">Flagged</option>' +
' </select>' +
' </label>' +
' <% if (isCohorted && isPrivilegedUser) { %>' +
' <% if (isDiscussionDivisionEnabled && isPrivilegedUser) { %>' +
' <label class="forum-nav-filter-cohort">' +
' <span class="sr">Cohort:</span>' +
' <span class="sr">Group:</span>' +
' <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="2">Cohort2</option>' +
' </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 @@
<% } %>
<ul class="post-errors" style="display: none"></ul>
<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'); } %>">
<label class="field-label">
<span class="field-label-text">
......@@ -17,12 +17,12 @@
<%- gettext("Visible to") %>
</span>
<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 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"); } %>>
<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>
<% }); %>
</select>
......
......@@ -41,10 +41,10 @@ define(
thread_pages: [],
contentInfo: null,
courseSettings: {
is_cohorted: false,
is_discussion_division_enabled: false,
allow_anonymous: false,
allow_anonymous_to_peers: false,
cohorts: [],
groups: [],
category_map: {}
}
});
......
......@@ -125,6 +125,7 @@ def make_mock_thread_data(
group_id=None,
group_name=None,
commentable_id=None,
is_commentable_divided=None,
):
data_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(
}
if group_id is not None:
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:
thread_data["children"] = [{
"id": "dummy_comment_id_{}".format(i),
......@@ -429,7 +432,10 @@ class SingleCohortedThreadTestCase(CohortedTestCase):
self.mock_text = "dummy content"
self.mock_thread_id = "test_thread_id"
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):
......@@ -453,11 +459,13 @@ class SingleCohortedThreadTestCase(CohortedTestCase):
response_data["content"],
make_mock_thread_data(
course=self.course,
commentable_id="cohorted_topic",
text=self.mock_text,
thread_id=self.mock_thread_id,
num_children=1,
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
from courseware.courses import get_course_with_access
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 courseware.access import has_access
from student.models import CourseEnrollment
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.utils import (
merge_dict,
......@@ -48,6 +45,8 @@ from django_comment_client.utils import (
strip_none,
add_courseware_context,
get_group_id_for_comments_service,
course_discussion_division_enabled,
get_group_names_by_id,
is_commentable_divided,
get_group_id_for_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
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 {
'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_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)
}
......@@ -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):
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)
if is_commentable_divided(course.id, discussion_id) and not is_moderator:
user_group_id = get_group_id_for_user(request.user, course.id)
course_discussion_settings = get_course_discussion_settings(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:
return None
......@@ -424,7 +428,8 @@ def _create_discussion_board_context(request, course_key, discussion_id=None, th
add_courseware_context(threads, course, user)
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({
'root_url': root_url,
......@@ -434,12 +439,12 @@ def _create_discussion_board_context(request, course_key, discussion_id=None, th
'thread_pages': thread_pages,
'annotated_content_info': annotated_content_info,
'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
'sort_preference': cc_user.default_sort_key,
'category_map': course_settings["category_map"],
'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
......@@ -501,7 +506,8 @@ def user_profile(request, course_key, user_id):
).order_by("name").values_list("name", flat=True).distinct()
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.update({
......
......@@ -43,6 +43,8 @@ from django_comment_common.signals import (
comment_voted,
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 lms.djangoapps.discussion_api.pagination import DiscussionAPIPagination
from lms.lib.comment_client.comment import Comment
......@@ -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 = _get_course(course_key, request.user)
context = get_context(course, request, cc_thread)
course_discussion_settings = get_course_discussion_settings(course_key)
if (
not context["is_requester_privileged"] 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:
raise ThreadNotFoundError("Thread not found.")
return cc_thread, context
......@@ -546,7 +549,7 @@ def get_thread_list(
"user_id": unicode(request.user.id),
"group_id": (
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,
"per_page": page_size,
......@@ -828,12 +831,13 @@ def create_thread(request, thread_data):
context = get_context(course, request)
_check_initializable_thread_fields(thread_data, context)
discussion_settings = get_course_discussion_settings(course_key)
if (
"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["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)
actions_form = ThreadActionsForm(thread_data)
if not (serializer.is_valid() and actions_form.is_valid()):
......
......@@ -77,7 +77,7 @@ def get_editable_fields(cc_content, context):
ret |= {"following", "read"}
if _is_author_or_privileged(cc_content, context):
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"}
# Comment fields
......
......@@ -17,6 +17,7 @@ from discussion_api.permissions import (
)
from discussion_api.render import render_body
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 (
FORUM_ROLE_ADMINISTRATOR,
FORUM_ROLE_COMMUNITY_TA,
......@@ -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.user import User as CommentClientUser
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):
......@@ -52,12 +53,13 @@ def get_context(course, request, thread=None):
requester = request.user
cc_requester = CommentClientUser.from_django_user(requester).retrieve()
cc_requester["course_id"] = course.id
course_discussion_settings = get_course_discussion_settings(course.id)
return {
"course": course,
"request": request,
"thread": thread,
# For now, the only groups are cohorts
"group_ids_to_names": get_cohort_names(course),
"discussion_division_enabled": course_discussion_division_enabled(course_discussion_settings),
"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,
"staff_user_ids": staff_user_ids,
"ta_user_ids": ta_user_ids,
......
......@@ -55,6 +55,7 @@ from openedx.core.djangoapps.course_groups.tests.helpers import CohortFactory
from openedx.core.lib.exceptions import CourseNotFoundError, PageNotFoundError
from student.tests.factories import CourseEnrollmentFactory, UserFactory
from util.testing import UrlResetMixin
from xmodule.modulestore import ModuleStoreEnum
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase, SharedModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
......@@ -591,6 +592,8 @@ class GetThreadListTest(ForumsEnableMixin, CommentsServiceMockMixin, UrlResetMix
self.request.user = self.user
CourseEnrollmentFactory.create(user=self.user, course_id=self.course.id)
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)
def get_thread_list(
......@@ -662,6 +665,8 @@ class GetThreadListTest(ForumsEnableMixin, CommentsServiceMockMixin, UrlResetMix
})
def test_thread_content(self):
self.course.cohort_config = {"cohorted": True}
modulestore().update_item(self.course, ModuleStoreEnum.UserID.test)
source_threads = [
make_minimal_cs_thread({
"id": "test_thread_id_0",
......
......@@ -25,6 +25,7 @@ def _get_context(requester_id, is_requester_privileged, is_cohorted=False, threa
"cc_requester": User(id=requester_id),
"is_requester_privileged": is_requester_privileged,
"course": CourseFactory(cohort_config={"cohorted": is_cohorted}),
"discussion_division_enabled": is_cohorted,
"thread": thread,
}
......
......@@ -28,6 +28,8 @@ from lms.lib.comment_client.comment import Comment
from lms.lib.comment_client.thread import Thread
from student.tests.factories import UserFactory
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.factories import CourseFactory
from openedx.core.djangoapps.course_groups.tests.helpers import CohortFactory
......@@ -209,6 +211,8 @@ class ThreadSerializerSerializationTest(SerializerTestMixin, SharedModuleStoreTe
self.assertEqual(serialized["pinned"], False)
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)
serialized = self.serialize(self.make_cs_content({"group_id": cohort.id}))
self.assertEqual(serialized["group_id"], cohort.id)
......
import json
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
......@@ -94,11 +99,22 @@ class CohortedTopicGroupIdTestMixin(GroupIdAssertionMixin):
def test_cohorted_topic_moderator_with_invalid_group_id(self, mock_request):
invalid_id = self.student_cohort.id + self.moderator_cohort.id
try:
response = self.call_view(mock_request, "cohorted_topic", self.moderator, invalid_id)
self.assertEqual(response.status_code, 500)
except ValueError:
pass # In mock request mode, server errors are not captured
response = self.call_view(mock_request, "cohorted_topic", self.moderator, invalid_id)
self.assertEqual(response.status_code, 500)
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):
......
......@@ -17,13 +17,16 @@ from django_comment_client.tests.unicode import UnicodeTestMixin
from django_comment_client.constants import TYPE_ENTRY, TYPE_SUBCATEGORY
import django_comment_client.utils as utils
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.tabs import get_course_tab_list
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.tests.helpers import config_course_cohorts, topic_name_to_id
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, CohortFactory
from student.tests.factories import UserFactory, AdminFactory, CourseEnrollmentFactory
from openedx.core.djangoapps.content.course_structures.models import CourseStructure
from openedx.core.djangoapps.util.testing import ContentGroupTestCase
......@@ -374,7 +377,6 @@ class CategoryMapTestCase(CategoryMapTestMixin, ModuleStoreTestCase):
)
# Courses get a default discussion topic on creation, so remove it
self.course.discussion_topics = {}
self.course.save()
self.discussion_num = 0
self.instructor = InstructorFactory(course_key=self.course.id)
self.maxDiff = None # pylint: disable=invalid-name
......@@ -426,34 +428,31 @@ class CategoryMapTestCase(CategoryMapTestMixin, ModuleStoreTestCase):
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([])
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([])
set_course_cohort_settings(
course_key=self.course.id,
is_cohorted=True,
cohorted_discussions=["Topic_B", "Topic_C"],
always_cohort_inline_discussions=False,
set_discussion_division_settings(
self.course.id,
enable_cohorts=True,
divided_discussions=["Topic_B", "Topic_C"]
)
check_cohorted_topics(["Topic_B", "Topic_C"])
set_course_cohort_settings(
course_key=self.course.id,
is_cohorted=True,
cohorted_discussions=["Topic_A", "Some_Other_Topic"],
always_cohort_inline_discussions=False,
set_discussion_division_settings(
self.course.id,
enable_cohorts=True,
divided_discussions=["Topic_A", "Some_Other_Topic"]
)
check_cohorted_topics(["Topic_A"])
# unlikely case, but make sure it works.
set_course_cohort_settings(
course_key=self.course.id,
is_cohorted=False,
cohorted_discussions=["Topic_A"],
always_cohort_inline_discussions=False,
set_discussion_division_settings(
self.course.id,
enable_cohorts=False,
divided_discussions=["Topic_A"]
)
check_cohorted_topics([])
......@@ -481,7 +480,7 @@ class CategoryMapTestCase(CategoryMapTestMixin, ModuleStoreTestCase):
def test_inline_with_always_cohort_inline_discussion_flag(self):
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(
{
......@@ -505,7 +504,7 @@ class CategoryMapTestCase(CategoryMapTestMixin, ModuleStoreTestCase):
def test_inline_without_always_cohort_inline_discussion_flag(self):
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(
{
......@@ -566,7 +565,7 @@ class CategoryMapTestCase(CategoryMapTestMixin, ModuleStoreTestCase):
self.create_discussion("Chapter 2 / Section 1 / Subsection 2", "Discussion")
self.create_discussion("Chapter 3 / Section 1", "Discussion")
def check_cohorted(is_divided):
def check_divided(is_divided):
self.assert_category_map_equals(
{
......@@ -652,15 +651,15 @@ class CategoryMapTestCase(CategoryMapTestMixin, ModuleStoreTestCase):
)
# empty / default config
check_cohorted(False)
check_divided(False)
# explicitly disabled cohorting
set_course_cohort_settings(course_key=self.course.id, is_cohorted=False)
check_cohorted(False)
set_discussion_division_settings(self.course.id, enable_cohorts=False)
check_divided(False)
# explicitly enabled cohorting with inline discusssions also cohorted.
set_course_cohort_settings(course_key=self.course.id, is_cohorted=True, always_cohort_inline_discussions=True)
check_cohorted(True)
# explicitly enable courses divided by Cohort with inline discusssions also divided.
set_discussion_division_settings(self.course.id, enable_cohorts=True, always_divide_inline_discussions=True)
check_divided(True)
def test_tree_with_duplicate_targets(self):
self.create_discussion("Chapter 1", "Discussion A")
......@@ -730,7 +729,6 @@ class CategoryMapTestCase(CategoryMapTestMixin, ModuleStoreTestCase):
def test_self_paced_start_date_filter(self):
self.course.self_paced = True
self.course.save()
now = datetime.datetime.now()
later = datetime.datetime.max
......@@ -898,7 +896,6 @@ class CategoryMapTestCase(CategoryMapTestMixin, ModuleStoreTestCase):
def test_sort_alpha(self):
self.course.discussion_sort_alpha = True
self.course.save()
self.create_discussion("Chapter", "Discussion D")
self.create_discussion("Chapter", "Discussion A")
self.create_discussion("Chapter", "Discussion E")
......@@ -1325,7 +1322,7 @@ class IsCommentableDividedTestCase(ModuleStoreTestCase):
course,
is_cohorted=True,
discussion_topics=["General", "Feedback"],
cohorted_discussions=["Feedback"]
divided_discussions=["Feedback"]
)
self.assertTrue(cohorts.is_course_cohorted(course.id))
......@@ -1349,7 +1346,7 @@ class IsCommentableDividedTestCase(ModuleStoreTestCase):
course,
is_cohorted=True,
discussion_topics=["General", "Feedback"],
cohorted_discussions=["Feedback", "random_inline"]
divided_discussions=["Feedback", "random_inline"]
)
self.assertFalse(
utils.is_commentable_divided(course.id, to_id("random")),
......@@ -1362,8 +1359,8 @@ class IsCommentableDividedTestCase(ModuleStoreTestCase):
course,
is_cohorted=True,
discussion_topics=["General", "Feedback"],
cohorted_discussions=["Feedback", "random_inline"],
always_cohort_inline_discussions=False
divided_discussions=["Feedback", "random_inline"],
always_divide_inline_discussions=False
)
self.assertFalse(
utils.is_commentable_divided(course.id, to_id("random")),
......@@ -1383,7 +1380,7 @@ class IsCommentableDividedTestCase(ModuleStoreTestCase):
course = modulestore().get_course(self.toy_course_key)
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)
# Verify that team discussions are not cohorted, but other discussions are
......@@ -1391,6 +1388,205 @@ class IsCommentableDividedTestCase(ModuleStoreTestCase):
self.assertFalse(utils.is_commentable_divided(course.id, team.discussion_topic_id))
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):
"""Test utils functionality related to forums "abilities" (permissions)"""
......@@ -1497,3 +1693,21 @@ class ClientConfigurationTestCase(TestCase):
result = perform_request('GET', 'http://www.google.com')
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
from opaque_keys.edx.locations import i4xEncoder
from opaque_keys.edx.keys import CourseKey
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.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 courseware import courses
from courseware.access import has_access
from openedx.core.djangoapps.content.course_structures.models import CourseStructure
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 student.roles import GlobalStaff
......@@ -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.
user: User to check for access.
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 = {
......@@ -310,7 +312,9 @@ def get_discussion_category_map(course, user, divided_only_if_explicit=False, ex
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:
discussion_id = xblock.discussion_id
......@@ -357,13 +361,13 @@ def get_discussion_category_map(course, user, divided_only_if_explicit=False, ex
node[level]["start_date"] = category_start_date
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
for entry in entries:
is_entry_divided = (
course_cohort_settings.is_cohorted and (
divide_all_inline_discussions or entry["id"] in course_cohort_settings.cohorted_discussions
discussion_division_enabled and (
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
"sort_key": entry.get("sort_key", topic),
"start_date": datetime.now(UTC()),
"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):
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
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
content (dict): A thread or comment.
course_key (CourseKey): The course key of the course.
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 = [
'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
else:
del endorsement["user_id"]
if course_is_cohorted is None:
course_is_cohorted = is_course_cohorted(course_key)
if discussion_division_enabled is None:
discussion_division_enabled = course_discussion_division_enabled(get_course_discussion_settings(course_key))
for child_content_key in ["children", "endorsed_responses", "non_endorsed_responses"]:
if child_content_key in content:
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]
]
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.
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:
# 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)
return content
......@@ -741,7 +751,8 @@ def get_group_id_for_comments_service(request, course_key, commentable_id=None):
Raises:
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":
requested_group_id = request.GET.get('group_id')
elif request.method == "POST":
......@@ -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 not requested_group_id:
return None
try:
group_id = int(requested_group_id)
get_cohort_by_id(course_key, group_id)
except CourseUserGroup.DoesNotExist:
raise ValueError
group_id = int(requested_group_id)
_verify_group_exists(group_id, course_discussion_settings)
else:
# 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
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
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.
Currently pass-through to get_cohort_id.
Given a user, return the group_id for that user according to the course_discussion_settings.
If discussions are not divided, this method will return None.
It will also return None if the user is in no group within the specified division_scheme.
"""
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):
......@@ -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:
course_key: CourseKey
commentable_id: string
course_discussion_settings: CourseDiscussionSettings model instance (optional). If not
supplied, it will be retrieved via the course_key.
Returns:
Bool: is this commentable divided, meaning that learners are divided into
......@@ -799,28 +820,118 @@ def is_commentable_divided(course_key, commentable_id):
Raises:
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_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 :)
ans = False
elif (
commentable_id in course.top_level_discussion_topic_ids or
course_cohort_settings.always_cohort_inline_discussions is False
commentable_id in course.top_level_discussion_topic_ids or
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).
# 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:
# inline discussions are cohorted by default
# inline discussions are divided by default
ans = True
log.debug(u"is_commentable_divided(%s, %s) = {%s}", course_key, commentable_id, 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):
"""
Return True if discussions are enabled; else False
......
......@@ -67,7 +67,7 @@ from lms.djangoapps.instructor_task.api_helper import AlreadyRunningError
from certificates.tests.factories import GeneratedCertificateFactory
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.djangoapps.site_configuration import helpers as configuration_helpers
from openedx.core.djangoapps.site_configuration.tests.mixins import SiteMixin
......@@ -2701,7 +2701,7 @@ class TestInstructorAPILevelsDataDump(SharedModuleStoreTestCase, LoginEnrollment
cohorted, and does not when the course is not cohorted.
"""
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, {})
res_json = json.loads(response.content)
......
......@@ -20,7 +20,7 @@ from openedx.core.djangolib.markup import HTML
class="forum-nav-browse-menu-item"
data-discussion-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"
>
% if entry:
......
......@@ -21,14 +21,14 @@
%endif
</select>
## safe-lint: disable=python-parse-error,python-wrap-html
</label>${"<% if (isCohorted && isPrivilegedUser) { %>" | n, decode.utf8}<label class="forum-nav-filter-cohort">
## Translators: This labels a cohort menu in forum navigation
<span class="sr">${_("Cohort:")}</span>
</label>${"<% if (isDiscussionDivisionEnabled && isPrivilegedUser) { %>" | n, decode.utf8}<label class="forum-nav-filter-cohort">
## Translators: This labels a group menu in forum navigation
<span class="sr">${_("Group:")}</span>
<select class="forum-nav-filter-cohort-control">
<option value="">${_("in all cohorts")}</option>
## cohorts is not iterable sometimes because inline discussions xmodule doesn't pass it
%for c in (cohorts or []):
<option value="${c['id']}">${c['name']}</option>
<option value="">${_("in all groups")}</option>
## groups is not iterable sometimes because inline discussions xmodule doesn't pass it
%for group in (groups or []):
<option value="${group['id']}">${group['name']}</option>
%endfor
</select>
## safe-lint: disable=python-parse-error,python-wrap-html
......
......@@ -118,7 +118,21 @@ def is_course_cohorted(course_key):
Raises:
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):
......@@ -130,22 +144,6 @@ def get_cohort_id(user, course_key, use_cached=False):
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"
......@@ -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
# in non-cohorted courses, but settings can change after course starts)
course_cohort_settings = get_course_cohort_settings(course_key)
if not course_cohort_settings.is_cohorted:
if not is_course_cohorted(course_key):
return cache.setdefault(cache_key, None)
# If course is cohorted, check if the user already has a cohort.
......@@ -277,11 +274,7 @@ def migrate_cohort_settings(course):
"""
cohort_settings, created = CourseCohortsSettings.objects.get_or_create(
course_id=course.id,
defaults={
'is_cohorted': course.is_cohorted,
'cohorted_discussions': list(course.cohorted_discussions),
'always_cohort_inline_discussions': course.always_cohort_inline_discussions
}
defaults=_get_cohort_settings_from_modulestore(course)
)
# Add the new and update the existing cohorts
......@@ -507,50 +500,49 @@ def is_last_random_cohort(user_group):
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:
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:
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:
Http404 if course_key is invalid.
"""
fields = {'is_cohorted': bool, 'always_cohort_inline_discussions': bool, 'cohorted_discussions': list}
course_cohort_settings = get_course_cohort_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_cohort_settings, field, kwargs[field])
course_cohort_settings.save()
try:
course_cohort_settings = CourseCohortsSettings.objects.get(course_id=course_key)
except CourseCohortsSettings.DoesNotExist:
course = courses.get_course_by_id(course_key)
course_cohort_settings = migrate_cohort_settings(course)
return course_cohort_settings
@request_cached
def get_course_cohort_settings(course_key):
"""
Return cohort settings for a course.
Arguments:
course_key: CourseKey
def get_legacy_discussion_settings(course_key):
Returns:
A CourseCohortSettings object.
Raises:
Http404 if course_key is invalid.
"""
try:
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:
course = courses.get_course_by_id(course_key)
course_cohort_settings = migrate_cohort_settings(course)
return course_cohort_settings
return _get_cohort_settings_from_modulestore(course)
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):
class CourseCohortsSettings(models.Model):
"""
This model represents cohort settings for courses.
The only non-deprecated fields are `is_cohorted` and `course_id`.
"""
is_cohorted = models.BooleanField(default=False)
......@@ -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
# course_module.always_cohort_inline_discussions (via `migrate_cohort_settings`).
# 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)
@property
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)
@cohorted_discussions.setter
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)
......
......@@ -10,7 +10,9 @@ from opaque_keys.edx.locations import SlashSeparatedCourseKey
from xmodule.modulestore.django import modulestore
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
......@@ -140,8 +142,8 @@ def config_course_cohorts(
auto_cohorts=[],
manual_cohorts=[],
discussion_topics=[],
cohorted_discussions=[],
always_cohort_inline_discussions=False
divided_discussions=[],
always_divide_inline_discussions=False
):
"""
Set discussions and configure cohorts for a course.
......@@ -153,10 +155,10 @@ def config_course_cohorts(
manual_cohorts (list): Names of manual cohorts to create.
discussion_topics (list): Discussion topic names. Picks ids and
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.
always_cohort_inline_discussions (bool): Whether inline discussions
should be cohorted by default.
always_divide_inline_discussions (bool): Whether inline discussions
should be divided by default.
Returns:
Nothing -- modifies course in place.
......@@ -165,11 +167,12 @@ def config_course_cohorts(
"""Convert name to id."""
return topic_name_to_id(course, name)
set_course_cohort_settings(
set_course_cohorted(course.id, is_cohorted)
set_course_discussion_settings(
course.id,
is_cohorted=is_cohorted,
cohorted_discussions=[to_id(name) for name in cohorted_discussions],
always_cohort_inline_discussions=always_cohort_inline_discussions
divided_discussions=[to_id(name) for name in divided_discussions],
always_divide_inline_discussions=always_divide_inline_discussions,
division_scheme=CourseDiscussionSettings.COHORT,
)
for cohort_name in auto_cohorts:
......
......@@ -22,8 +22,8 @@ from xmodule.modulestore.tests.factories import ToyCourseFactory
from ..models import CourseUserGroup, CourseCohort, CourseUserGroupPartitionGroup
from .. import cohorts
from ..tests.helpers import (
topic_name_to_id, config_course_cohorts, config_course_cohorts_legacy,
CohortFactory, CourseCohortFactory, CourseCohortSettingsFactory
config_course_cohorts, config_course_cohorts_legacy,
CohortFactory, CourseCohortFactory
)
......@@ -475,44 +475,6 @@ class TestCohorts(ModuleStoreTestCase):
{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):
"""
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):
# Note that the following get() will fail with MultipleObjectsReturned if race condition is not handled.
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.
Test that cohorts.set_course_cohorted raises exception if argument is not a boolean.
"""
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.
"""
course = modulestore().get_course(self.toy_course_key)
CourseCohortSettingsFactory(course_id=course.id)
with self.assertRaises(ValueError) as value_error:
cohorts.set_course_cohorted(course.id, 'not a boolean')
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:
cohorts.set_course_cohort_settings(course.id, **{field['name']: ''})
self.assertEqual(
value_error.exception.message,
exception_msg_tpl.format(field['name'], field['type'].__name__)
)
self.assertEqual("Cohorted must be a boolean", value_error.exception.message)
@attr(shard=2)
......
......@@ -125,7 +125,7 @@ class CohortViewsTestCase(ModuleStoreTestCase):
self.course,
is_cohorted=True,
discussion_topics=discussion_topics,
cohorted_discussions=cohorted_discussions
divided_discussions=cohorted_discussions
)
return cohorted_inline_discussions, cohorted_course_wide_discussions
......@@ -317,12 +317,23 @@ class CourseCohortSettingsHandlerTestCase(CohortViewsTestCase):
response = self.patch_handler(
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': ''},
expected_response_code=400,
handler=course_cohort_settings_handler
)
self.assertEqual(
"Incorrect field type for `{}`. Type must be `{}`".format('is_cohorted', bool.__name__),
"Cohorted must be a boolean",
response.get("error")
)
......
......@@ -5,6 +5,7 @@ Views related to course groups functionality.
import logging
import re
from courseware.courses import get_course_with_access
from django.contrib.auth.decorators import login_required
from django.contrib.auth.models import User
from django.core.paginator import EmptyPage, Paginator
......@@ -14,13 +15,13 @@ from django.http import Http404, HttpResponseBadRequest
from django.utils.translation import ugettext
from django.views.decorators.csrf import ensure_csrf_cookie
from django.views.decorators.http import require_http_methods, require_POST
from opaque_keys.edx.keys import CourseKey
from opaque_keys.edx.locations import SlashSeparatedCourseKey
from courseware.courses import get_course_with_access
from django_comment_common.models import CourseDiscussionSettings
from django_comment_common.utils import get_course_discussion_settings, set_course_discussion_settings
from edxmako.shortcuts import render_to_response
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 opaque_keys.edx.keys import CourseKey
from opaque_keys.edx.locations import SlashSeparatedCourseKey
from util.json_request import JsonResponse, expect_json
from . import cohorts
......@@ -63,20 +64,20 @@ def unlink_cohort_partition_group(cohort):
# 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.
"""
cohorted_course_wide_discussions, cohorted_inline_discussions = get_cohorted_discussions(
course, course_cohort_settings
divided_course_wide_discussions, divided_inline_discussions = get_divided_discussions(
course, course_discussion_settings
)
return {
'id': course_cohort_settings.id,
'is_cohorted': course_cohort_settings.is_cohorted,
'cohorted_inline_discussions': cohorted_inline_discussions,
'cohorted_course_wide_discussions': cohorted_course_wide_discussions,
'always_cohort_inline_discussions': course_cohort_settings.always_cohort_inline_discussions,
'id': course_discussion_settings.id,
'is_cohorted': is_cohorted,
'cohorted_inline_discussions': divided_inline_discussions,
'cohorted_course_wide_discussions': divided_course_wide_discussions,
'always_cohort_inline_discussions': course_discussion_settings.always_divide_inline_discussions,
}
......@@ -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 = []
cohorted_inline_discussions = []
divided_course_wide_discussions = []
divided_inline_discussions = []
course_wide_discussions = [topic['id'] for __, topic in course.discussion_topics.items()]
all_discussions = get_discussion_categories_ids(course, None, include_all=True)
for cohorted_discussion_id in course_settings.cohorted_discussions:
if cohorted_discussion_id in course_wide_discussions:
cohorted_course_wide_discussions.append(cohorted_discussion_id)
elif cohorted_discussion_id in all_discussions:
cohorted_inline_discussions.append(cohorted_discussion_id)
for divided_discussion_id in discussion_settings.divided_discussions:
if divided_discussion_id in course_wide_discussions:
divided_course_wide_discussions.append(divided_discussion_id)
elif divided_discussion_id in all_discussions:
divided_inline_discussions.append(divided_discussion_id)
return cohorted_course_wide_discussions, cohorted_inline_discussions
return divided_course_wide_discussions, divided_inline_discussions
@require_http_methods(("GET", "PATCH"))
......@@ -131,11 +132,12 @@ def course_cohort_settings_handler(request, course_key_string):
"""
course_key = CourseKey.from_string(course_key_string)
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':
cohorted_course_wide_discussions, cohorted_inline_discussions = get_cohorted_discussions(
course, cohort_settings
divided_course_wide_discussions, divided_inline_discussions = get_divided_discussions(
course, discussion_settings
)
settings_to_change = {}
......@@ -144,16 +146,16 @@ def course_cohort_settings_handler(request, course_key_string):
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:
cohorted_course_wide_discussions = request.json.get(
'cohorted_course_wide_discussions', cohorted_course_wide_discussions
divided_course_wide_discussions = request.json.get(
'cohorted_course_wide_discussions', divided_course_wide_discussions
)
cohorted_inline_discussions = request.json.get(
'cohorted_inline_discussions', cohorted_inline_discussions
divided_inline_discussions = request.json.get(
'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:
settings_to_change['always_cohort_inline_discussions'] = request.json.get(
settings_to_change['always_divide_inline_discussions'] = request.json.get(
'always_cohort_inline_discussions'
)
......@@ -161,14 +163,19 @@ def course_cohort_settings_handler(request, course_key_string):
return JsonResponse({"error": unicode("Bad Request")}, 400)
try:
cohort_settings = cohorts.set_course_cohort_settings(
course_key, **settings_to_change
)
if 'is_cohorted' in 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:
# 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(_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"))
......
......@@ -12,7 +12,7 @@ from course_modes.models import CourseMode
from student.models import CourseEnrollment
from opaque_keys.edx.keys import CourseKey
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
......
......@@ -18,7 +18,7 @@ from xmodule.modulestore.tests.factories import CourseFactory
from ..models import VerifiedTrackCohortedCourse, DEFAULT_VERIFIED_COHORT_NAME
from ..tasks import sync_cohort_with_mode
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
......@@ -88,7 +88,7 @@ class TestMoveToVerified(SharedModuleStoreTestCase):
def _enable_cohorting(self):
""" 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):
""" 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