Commit 6cdcf8e9 by Albert St. Aubin

Discussion group moderation

parent 8fb86474
...@@ -16,6 +16,7 @@ from xmodule.modulestore.exceptions import ItemNotFoundError ...@@ -16,6 +16,7 @@ from xmodule.modulestore.exceptions import ItemNotFoundError
FORUM_ROLE_ADMINISTRATOR = ugettext_noop('Administrator') FORUM_ROLE_ADMINISTRATOR = ugettext_noop('Administrator')
FORUM_ROLE_MODERATOR = ugettext_noop('Moderator') FORUM_ROLE_MODERATOR = ugettext_noop('Moderator')
FORUM_ROLE_GROUP_MODERATOR = ugettext_noop('Group Moderator')
FORUM_ROLE_COMMUNITY_TA = ugettext_noop('Community TA') FORUM_ROLE_COMMUNITY_TA = ugettext_noop('Community TA')
FORUM_ROLE_STUDENT = ugettext_noop('Student') FORUM_ROLE_STUDENT = ugettext_noop('Student')
......
from django.test import TestCase
from nose.plugins.attrib import attr 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 models import CourseDiscussionSettings
from opaque_keys.edx.locations import SlashSeparatedCourseKey
from openedx.core.djangoapps.course_groups.cohorts import CourseCohortsSettings 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 utils import get_course_discussion_settings, set_course_discussion_settings
......
...@@ -5,6 +5,7 @@ Common comment client utility functions. ...@@ -5,6 +5,7 @@ Common comment client utility functions.
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,
FORUM_ROLE_GROUP_MODERATOR,
FORUM_ROLE_MODERATOR, FORUM_ROLE_MODERATOR,
FORUM_ROLE_STUDENT, FORUM_ROLE_STUDENT,
Role Role
...@@ -28,6 +29,9 @@ STUDENT_ROLE_PERMISSIONS = ["vote", "update_thread", "follow_thread", "unfollow_ ...@@ -28,6 +29,9 @@ STUDENT_ROLE_PERMISSIONS = ["vote", "update_thread", "follow_thread", "unfollow_
MODERATOR_ROLE_PERMISSIONS = ["edit_content", "delete_thread", "openclose_thread", MODERATOR_ROLE_PERMISSIONS = ["edit_content", "delete_thread", "openclose_thread",
"endorse_comment", "delete_comment", "see_all_cohorts"] "endorse_comment", "delete_comment", "see_all_cohorts"]
GROUP_MODERATOR_ROLE_PERMISSIONS = ["group_edit_content", "group_delete_thread", "group_openclose_thread",
"group_endorse_comment", "group_delete_comment"]
ADMINISTRATOR_ROLE_PERMISSIONS = ["manage_moderator"] ADMINISTRATOR_ROLE_PERMISSIONS = ["manage_moderator"]
...@@ -50,6 +54,7 @@ def seed_permissions_roles(course_key): ...@@ -50,6 +54,7 @@ def seed_permissions_roles(course_key):
""" """
administrator_role = _save_forum_role(course_key, FORUM_ROLE_ADMINISTRATOR) administrator_role = _save_forum_role(course_key, FORUM_ROLE_ADMINISTRATOR)
moderator_role = _save_forum_role(course_key, FORUM_ROLE_MODERATOR) moderator_role = _save_forum_role(course_key, FORUM_ROLE_MODERATOR)
group_moderator_role = _save_forum_role(course_key, FORUM_ROLE_GROUP_MODERATOR)
community_ta_role = _save_forum_role(course_key, FORUM_ROLE_COMMUNITY_TA) community_ta_role = _save_forum_role(course_key, FORUM_ROLE_COMMUNITY_TA)
student_role = _save_forum_role(course_key, FORUM_ROLE_STUDENT) student_role = _save_forum_role(course_key, FORUM_ROLE_STUDENT)
...@@ -59,11 +64,14 @@ def seed_permissions_roles(course_key): ...@@ -59,11 +64,14 @@ def seed_permissions_roles(course_key):
for per in MODERATOR_ROLE_PERMISSIONS: for per in MODERATOR_ROLE_PERMISSIONS:
moderator_role.add_permission(per) moderator_role.add_permission(per)
for per in GROUP_MODERATOR_ROLE_PERMISSIONS:
group_moderator_role.add_permission(per)
for per in ADMINISTRATOR_ROLE_PERMISSIONS: for per in ADMINISTRATOR_ROLE_PERMISSIONS:
administrator_role.add_permission(per) administrator_role.add_permission(per)
moderator_role.inherit_permissions(student_role) moderator_role.inherit_permissions(student_role)
group_moderator_role.inherit_permissions(student_role)
# For now, Community TA == Moderator, except for the styling. # For now, Community TA == Moderator, except for the styling.
community_ta_role.inherit_permissions(moderator_role) community_ta_role.inherit_permissions(moderator_role)
...@@ -78,6 +86,7 @@ def are_permissions_roles_seeded(course_id): ...@@ -78,6 +86,7 @@ def are_permissions_roles_seeded(course_id):
try: try:
administrator_role = Role.objects.get(name=FORUM_ROLE_ADMINISTRATOR, course_id=course_id) administrator_role = Role.objects.get(name=FORUM_ROLE_ADMINISTRATOR, course_id=course_id)
moderator_role = Role.objects.get(name=FORUM_ROLE_MODERATOR, course_id=course_id) moderator_role = Role.objects.get(name=FORUM_ROLE_MODERATOR, course_id=course_id)
group_moderator_role = Role.objects.get(name=FORUM_ROLE_GROUP_MODERATOR, course_id=course_id)
student_role = Role.objects.get(name=FORUM_ROLE_STUDENT, course_id=course_id) student_role = Role.objects.get(name=FORUM_ROLE_STUDENT, course_id=course_id)
except: except:
return False return False
...@@ -90,6 +99,10 @@ def are_permissions_roles_seeded(course_id): ...@@ -90,6 +99,10 @@ def are_permissions_roles_seeded(course_id):
if not moderator_role.has_permission(per): if not moderator_role.has_permission(per):
return False return False
for per in GROUP_MODERATOR_ROLE_PERMISSIONS + STUDENT_ROLE_PERMISSIONS:
if not group_moderator_role.has_permission(per):
return False
for per in ADMINISTRATOR_ROLE_PERMISSIONS + MODERATOR_ROLE_PERMISSIONS + STUDENT_ROLE_PERMISSIONS: for per in ADMINISTRATOR_ROLE_PERMISSIONS + MODERATOR_ROLE_PERMISSIONS + STUDENT_ROLE_PERMISSIONS:
if not administrator_role.has_permission(per): if not administrator_role.has_permission(per):
return False return False
......
...@@ -139,7 +139,7 @@ class AutoAuthEnabledTestCase(AutoAuthTestCase): ...@@ -139,7 +139,7 @@ class AutoAuthEnabledTestCase(AutoAuthTestCase):
def test_set_roles(self, course_id, course_key): def test_set_roles(self, course_id, course_key):
seed_permissions_roles(course_key) seed_permissions_roles(course_key)
course_roles = dict((r.name, r) for r in Role.objects.filter(course_id=course_key)) course_roles = dict((r.name, r) for r in Role.objects.filter(course_id=course_key))
self.assertEqual(len(course_roles), 4) # sanity check self.assertEqual(len(course_roles), 5) # sanity check
# Student role is assigned by default on course enrollment. # Student role is assigned by default on course enrollment.
self._auto_auth({'username': 'a_student', 'course_id': course_id}) self._auto_auth({'username': 'a_student', 'course_id': course_id})
......
...@@ -28,6 +28,8 @@ from django_comment_client.utils import ( ...@@ -28,6 +28,8 @@ from django_comment_client.utils import (
get_annotated_content_info, get_annotated_content_info,
get_cached_discussion_id_map, get_cached_discussion_id_map,
get_group_id_for_comments_service, get_group_id_for_comments_service,
get_group_id_for_user,
get_user_group_ids,
is_comment_too_deep, is_comment_too_deep,
prepare_content prepare_content
) )
...@@ -169,6 +171,8 @@ def permitted(func): ...@@ -169,6 +171,8 @@ def permitted(func):
""" """
Extract the forum object from the keyword arguments to the view. Extract the forum object from the keyword arguments to the view.
""" """
user_group_id = None
content_user_group_id = None
if "thread_id" in kwargs: if "thread_id" in kwargs:
content = cc.Thread.find(kwargs["thread_id"]).to_dict() content = cc.Thread.find(kwargs["thread_id"]).to_dict()
elif "comment_id" in kwargs: elif "comment_id" in kwargs:
...@@ -177,9 +181,16 @@ def permitted(func): ...@@ -177,9 +181,16 @@ def permitted(func):
content = cc.Commentable.find(kwargs["commentable_id"]).to_dict() content = cc.Commentable.find(kwargs["commentable_id"]).to_dict()
else: else:
content = None content = None
return content
if 'username' in content:
(user_group_id, content_user_group_id) = get_user_group_ids(course_key, content, request.user)
return content, user_group_id, content_user_group_id
course_key = CourseKey.from_string(kwargs['course_id']) course_key = CourseKey.from_string(kwargs['course_id'])
if check_permissions_by_view(request.user, course_key, fetch_content(), request.view_name): content, user_group_id, content_user_group_id = fetch_content()
if check_permissions_by_view(request.user, course_key, content,
request.view_name, user_group_id, content_user_group_id):
return func(request, *args, **kwargs) return func(request, *args, **kwargs)
else: else:
return JsonError("unauthorized", status=401) return JsonError("unauthorized", status=401)
...@@ -203,7 +214,7 @@ def ajax_content_response(request, course_key, content): ...@@ -203,7 +214,7 @@ def ajax_content_response(request, course_key, content):
@permitted @permitted
def create_thread(request, course_id, commentable_id): def create_thread(request, course_id, commentable_id):
""" """
Given a course and commentble ID, create the thread Given a course and commentable ID, create the thread
""" """
log.debug("Creating new thread in %r, id %r", course_id, commentable_id) log.debug("Creating new thread in %r, id %r", course_id, commentable_id)
......
...@@ -7,7 +7,8 @@ from types import NoneType ...@@ -7,7 +7,8 @@ from types import NoneType
from opaque_keys.edx.keys import CourseKey from opaque_keys.edx.keys import CourseKey
from django_comment_common.models import all_permissions_for_user_in_course from django_comment_common.models import CourseDiscussionSettings, all_permissions_for_user_in_course
from django_comment_common.utils import get_course_discussion_settings
from lms.djangoapps.teams.models import CourseTeam from lms.djangoapps.teams.models import CourseTeam
from lms.lib.comment_client import Thread from lms.lib.comment_client import Thread
from request_cache.middleware import RequestCache, request_cached from request_cache.middleware import RequestCache, request_cached
...@@ -44,6 +45,7 @@ def get_team(commentable_id): ...@@ -44,6 +45,7 @@ def get_team(commentable_id):
def _check_condition(user, condition, content): def _check_condition(user, condition, content):
""" Check whether or not the given condition applies for the given user and content. """ """ Check whether or not the given condition applies for the given user and content. """
def check_open(_user, content): def check_open(_user, content):
""" Check whether the content is open. """ """ Check whether the content is open. """
try: try:
...@@ -106,7 +108,7 @@ def _check_condition(user, condition, content): ...@@ -106,7 +108,7 @@ def _check_condition(user, condition, content):
return handlers[condition](user, content) return handlers[condition](user, content)
def _check_conditions_permissions(user, permissions, course_id, content): def _check_conditions_permissions(user, permissions, course_id, content, user_group_id=None, content_user_group=None):
""" """
Accepts a list of permissions and proceed if any of the permission is valid. Accepts a list of permissions and proceed if any of the permission is valid.
Note that ["can_view", "can_edit"] will proceed if the user has either Note that ["can_view", "can_edit"] will proceed if the user has either
...@@ -118,6 +120,17 @@ def _check_conditions_permissions(user, permissions, course_id, content): ...@@ -118,6 +120,17 @@ def _check_conditions_permissions(user, permissions, course_id, content):
if isinstance(per, basestring): if isinstance(per, basestring):
if per in CONDITIONS: if per in CONDITIONS:
return _check_condition(user, per, content) return _check_condition(user, per, content)
if 'group_' in per:
# If a course does not have divided discussions
# or a course has divided discussions, but the current user's content group does not equal
# the content group of the commenter/poster,
# then the current user does not have group edit permissions.
division_scheme = get_course_discussion_settings(course_id).division_scheme
if (division_scheme is CourseDiscussionSettings.NONE
or user_group_id is None
or content_user_group is None
or user_group_id != content_user_group):
return False
return has_permission(user, per, course_id=course_id) return has_permission(user, per, course_id=course_id)
elif isinstance(per, list) and operator in ["and", "or"]: elif isinstance(per, list) and operator in ["and", "or"]:
results = [test(user, x, operator="and") for x in per] results = [test(user, x, operator="and") for x in per]
...@@ -125,42 +138,50 @@ def _check_conditions_permissions(user, permissions, course_id, content): ...@@ -125,42 +138,50 @@ def _check_conditions_permissions(user, permissions, course_id, content):
return True in results return True in results
elif operator == "and": elif operator == "and":
return False not in results return False not in results
return test(user, permissions, operator="or") return test(user, permissions, operator="or")
# Note: 'edit_content' is being used as a generic way of telling if someone is a privileged user # Note: 'edit_content' is being used as a generic way of telling if someone is a privileged user
# (forum Moderator/Admin/TA), because there is a desire that team membership does not impact privileged users. # (forum Moderator/Admin/TA), because there is a desire that team membership does not impact privileged users.
VIEW_PERMISSIONS = { VIEW_PERMISSIONS = {
'update_thread': ['edit_content', ['update_thread', 'is_open', 'is_author']], 'update_thread': ['group_edit_content', 'edit_content', ['update_thread', 'is_open', 'is_author']],
'create_comment': ['edit_content', ["create_comment", "is_open", "is_team_member_if_applicable"]], 'create_comment': ['group_edit_content', 'edit_content', ["create_comment", "is_open",
'delete_thread': ['delete_thread', ['update_thread', 'is_author']], "is_team_member_if_applicable"]],
'update_comment': ['edit_content', ['update_comment', 'is_open', 'is_author']], 'delete_thread': ['group_delete_thread', 'delete_thread', ['update_thread', 'is_author']],
'update_comment': ['group_edit_content', 'edit_content', ['update_comment', 'is_open', 'is_author']],
'endorse_comment': ['endorse_comment', 'is_question_author'], 'endorse_comment': ['endorse_comment', 'is_question_author'],
'openclose_thread': ['openclose_thread'], 'openclose_thread': ['group_openclose_thread', 'openclose_thread'],
'create_sub_comment': ['edit_content', ['create_sub_comment', 'is_open', 'is_team_member_if_applicable']], 'create_sub_comment': ['group_edit_content', 'edit_content', ['create_sub_comment', 'is_open',
'delete_comment': ['delete_comment', ['update_comment', 'is_open', 'is_author']], 'is_team_member_if_applicable']],
'vote_for_comment': ['edit_content', ['vote', 'is_open', 'is_team_member_if_applicable']], 'delete_comment': ['group_delete_comment', 'delete_comment', ['update_comment', 'is_open', 'is_author']],
'undo_vote_for_comment': ['edit_content', ['unvote', 'is_open', 'is_team_member_if_applicable']], 'vote_for_comment': ['group_edit_content', 'edit_content', ['vote', 'is_open', 'is_team_member_if_applicable']],
'vote_for_thread': ['edit_content', ['vote', 'is_open', 'is_team_member_if_applicable']], 'undo_vote_for_comment': ['group_edit_content', 'edit_content', ['unvote', 'is_open',
'flag_abuse_for_thread': ['edit_content', ['vote', 'is_team_member_if_applicable']], 'is_team_member_if_applicable']],
'un_flag_abuse_for_thread': ['edit_content', ['vote', 'is_team_member_if_applicable']], 'vote_for_thread': ['group_edit_content', 'edit_content', ['vote', 'is_open', 'is_team_member_if_applicable']],
'flag_abuse_for_comment': ['edit_content', ['vote', 'is_team_member_if_applicable']], 'flag_abuse_for_thread': ['group_edit_content', 'edit_content', ['vote', 'is_team_member_if_applicable']],
'un_flag_abuse_for_comment': ['edit_content', ['vote', 'is_team_member_if_applicable']], 'un_flag_abuse_for_thread': ['group_edit_content', 'edit_content', ['vote', 'is_team_member_if_applicable']],
'undo_vote_for_thread': ['edit_content', ['unvote', 'is_open', 'is_team_member_if_applicable']], 'flag_abuse_for_comment': ['group_edit_content', 'edit_content', ['vote', 'is_team_member_if_applicable']],
'pin_thread': ['openclose_thread'], 'un_flag_abuse_for_comment': ['group_edit_content', 'edit_content', ['vote', 'is_team_member_if_applicable']],
'un_pin_thread': ['openclose_thread'], 'undo_vote_for_thread': ['group_edit_content', 'edit_content', ['unvote', 'is_open',
'follow_thread': ['edit_content', ['follow_thread', 'is_team_member_if_applicable']], 'is_team_member_if_applicable']],
'follow_commentable': ['edit_content', ['follow_commentable', 'is_team_member_if_applicable']], 'pin_thread': ['group_openclose_thread', 'openclose_thread'],
'unfollow_thread': ['edit_content', ['unfollow_thread', 'is_team_member_if_applicable']], 'un_pin_thread': ['group_openclose_thread', 'openclose_thread'],
'unfollow_commentable': ['edit_content', ['unfollow_commentable', 'is_team_member_if_applicable']], 'follow_thread': ['group_edit_content', 'edit_content', ['follow_thread', 'is_team_member_if_applicable']],
'create_thread': ['edit_content', ['create_thread', 'is_team_member_if_applicable']], 'follow_commentable': ['group_edit_content', 'edit_content', ['follow_commentable',
'is_team_member_if_applicable']],
'unfollow_thread': ['group_edit_content', 'edit_content', ['unfollow_thread', 'is_team_member_if_applicable']],
'unfollow_commentable': ['group_edit_content', 'edit_content', ['unfollow_commentable',
'is_team_member_if_applicable']],
'create_thread': ['group_edit_content', 'edit_content', ['create_thread', 'is_team_member_if_applicable']],
} }
def check_permissions_by_view(user, course_id, content, name): def check_permissions_by_view(user, course_id, content, name, group_id=None, content_user_group=None):
assert isinstance(course_id, CourseKey) assert isinstance(course_id, CourseKey)
p = None
try: try:
p = VIEW_PERMISSIONS[name] p = VIEW_PERMISSIONS[name]
except KeyError: except KeyError:
logging.warning("Permission for view named %s does not exist in permissions.py", name) logging.warning("Permission for view named %s does not exist in permissions.py", name)
return _check_conditions_permissions(user, p, course_id, content) return _check_conditions_permissions(user, p, course_id, content, group_id, content_user_group)
...@@ -4,6 +4,7 @@ import json ...@@ -4,6 +4,7 @@ import json
import ddt import ddt
import mock import mock
from django.core.management import call_command
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from django.test import RequestFactory, TestCase from django.test import RequestFactory, TestCase
from django.utils.timezone import UTC as django_utc from django.utils.timezone import UTC as django_utc
...@@ -20,10 +21,20 @@ from django_comment_client.constants import TYPE_ENTRY, TYPE_SUBCATEGORY ...@@ -20,10 +21,20 @@ from django_comment_client.constants import TYPE_ENTRY, TYPE_SUBCATEGORY
from django_comment_client.tests.factories import RoleFactory from django_comment_client.tests.factories import RoleFactory
from django_comment_client.tests.unicode import UnicodeTestMixin from django_comment_client.tests.unicode import UnicodeTestMixin
from django_comment_client.tests.utils import config_course_discussions, topic_name_to_id from django_comment_client.tests.utils import config_course_discussions, topic_name_to_id
from django_comment_common.models import CourseDiscussionSettings, ForumsConfig from django_comment_common.models import (
from django_comment_common.utils import get_course_discussion_settings, set_course_discussion_settings FORUM_ROLE_GROUP_MODERATOR,
CourseDiscussionSettings,
ForumsConfig,
Role,
assign_role
)
from django_comment_common.utils import (
get_course_discussion_settings,
seed_permissions_roles,
set_course_discussion_settings
)
from edxmako import add_lookup from edxmako import add_lookup
from lms.djangoapps.teams.tests.factories import CourseTeamFactory from lms.djangoapps.teams.tests.factories import CourseTeamFactory, CourseTeamMembershipFactory
from lms.lib.comment_client.utils import CommentClientMaintenanceError, perform_request from lms.lib.comment_client.utils import CommentClientMaintenanceError, perform_request
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 import cohorts from openedx.core.djangoapps.course_groups import cohorts
...@@ -1681,6 +1692,139 @@ class PermissionsTestCase(ModuleStoreTestCase): ...@@ -1681,6 +1692,139 @@ class PermissionsTestCase(ModuleStoreTestCase):
self.assertFalse(utils.is_content_authored_by(content, user)) self.assertFalse(utils.is_content_authored_by(content, user))
class GroupModeratorPermissionsTestCase(ModuleStoreTestCase):
"""Test utils functionality related to forums "abilities" (permissions) for group moderators"""
def _check_condition(user, condition, content):
return True if condition == 'is_open' or condition == 'is_team_member_if_applicable' else False
def setUp(self):
super(GroupModeratorPermissionsTestCase, self).setUp()
# Create course, seed permissions roles, and create team
self.course = CourseFactory.create()
seed_permissions_roles(self.course.id)
# Create four users: group_moderator (who is within the verified enrollment track and in the cohort),
# verified_user (who is in the verified enrollment track but not the cohort),
# cohorted_user (who is in the cohort but not the verified enrollment track),
# and plain_user (who is neither in the cohort nor the verified enrollment track)x
self.group_moderator = UserFactory(username='group_moderator', email='group_moderator@edx.org')
self.group_moderator.id = 1
CourseEnrollmentFactory(
course_id=self.course.id,
user=self.group_moderator,
mode=CourseMode.VERIFIED
)
self.verified_user = UserFactory(username='verified', email='verified@edx.org')
self.verified_user.id = 2
CourseEnrollmentFactory(
course_id=self.course.id,
user=self.verified_user,
mode=CourseMode.VERIFIED
)
self.cohorted_user = UserFactory(username='cohort', email='cohort@edx.org')
self.cohorted_user.id = 3
CourseEnrollmentFactory(
course_id=self.course.id,
user=self.cohorted_user,
mode=CourseMode.AUDIT
)
self.plain_user = UserFactory(username='plain', email='plain@edx.org')
self.plain_user.id = 4
CourseEnrollmentFactory(
course_id=self.course.id,
user=self.plain_user,
mode=CourseMode.AUDIT
)
CohortFactory(
course_id=self.course.id,
name='Test Cohort',
users=[self.verified_user, self.cohorted_user]
)
# Give group moderator permissions to group_moderator
assign_role(self.course.id, self.group_moderator, 'Group Moderator')
@mock.patch('django_comment_client.permissions._check_condition', side_effect=_check_condition)
def test_not_divided(self, check_condition_function):
"""
Group moderator should not have moderator permissions if the discussions are not divided.
"""
content = {'user_id': self.plain_user.id, 'type': 'thread', 'username': self.plain_user.username}
self.assertEqual(utils.get_ability(self.course.id, content, self.group_moderator), {
'editable': False,
'can_reply': True,
'can_delete': False,
'can_openclose': False,
'can_vote': True,
'can_report': True
})
content = {'user_id': self.cohorted_user.id, 'type': 'thread'}
self.assertEqual(utils.get_ability(self.course.id, content, self.group_moderator), {
'editable': False,
'can_reply': True,
'can_delete': False,
'can_openclose': False,
'can_vote': True,
'can_report': True
})
content = {'user_id': self.verified_user.id, 'type': 'thread'}
self.assertEqual(utils.get_ability(self.course.id, content, self.group_moderator), {
'editable': False,
'can_reply': True,
'can_delete': False,
'can_openclose': False,
'can_vote': True,
'can_report': True
})
@mock.patch('django_comment_client.permissions._check_condition', side_effect=_check_condition)
def test_divided_within_group(self, check_condition_function):
"""
Group moderator should have moderator permissions within their group if the discussions are divided.
"""
set_discussion_division_settings(self.course.id, enable_cohorts=True,
division_scheme=CourseDiscussionSettings.COHORT)
content = {'user_id': self.cohorted_user.id, 'type': 'thread', 'username': self.cohorted_user.username}
self.assertEqual(utils.get_ability(self.course.id, content, self.group_moderator), {
'editable': True,
'can_reply': True,
'can_delete': True,
'can_openclose': True,
'can_vote': True,
'can_report': True
})
set_discussion_division_settings(self.course.id, division_scheme=CourseDiscussionSettings.ENROLLMENT_TRACK)
content = {'user_id': self.verified_user.id, 'type': 'thread', 'username': self.verified_user.username}
self.assertEqual(utils.get_ability(self.course.id, content, self.group_moderator), {
'editable': True,
'can_reply': True,
'can_delete': True,
'can_openclose': True,
'can_vote': True,
'can_report': True
})
@mock.patch('django_comment_client.permissions._check_condition', side_effect=_check_condition)
def test_divided_outside_group(self, check_condition_function):
"""
Group moderator should not have moderator permissions outside of their group.
"""
content = {'user_id': self.plain_user.id, 'type': 'thread', 'username': self.plain_user.username}
set_discussion_division_settings(self.course.id, division_scheme=CourseDiscussionSettings.NONE)
self.assertEqual(utils.get_ability(self.course.id, content, self.group_moderator), {
'editable': False,
'can_reply': True,
'can_delete': False,
'can_openclose': False,
'can_vote': True,
'can_report': True
})
class ClientConfigurationTestCase(TestCase): class ClientConfigurationTestCase(TestCase):
"""Simple test cases to ensure enabling/disabling the use of the comment service works as intended.""" """Simple test cases to ensure enabling/disabling the use of the comment service works as intended."""
......
...@@ -25,6 +25,7 @@ from edxmako import lookup_template ...@@ -25,6 +25,7 @@ from edxmako import lookup_template
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 get_cohort_id, get_cohort_names, is_course_cohorted from openedx.core.djangoapps.course_groups.cohorts import get_cohort_id, get_cohort_names, is_course_cohorted
from request_cache.middleware import request_cached from request_cache.middleware import request_cached
from student.models import get_user_by_username_or_email
from student.roles import GlobalStaff from student.roles import GlobalStaff
from xmodule.modulestore.django import modulestore from xmodule.modulestore.django import modulestore
from xmodule.partitions.partitions import ENROLLMENT_TRACK_PARTITION_ID from xmodule.partitions.partitions import ENROLLMENT_TRACK_PARTITION_ID
...@@ -516,11 +517,33 @@ def get_ability(course_id, content, user): ...@@ -516,11 +517,33 @@ def get_ability(course_id, content, user):
""" """
Return a dictionary of forums-oriented actions and the user's permission to perform them Return a dictionary of forums-oriented actions and the user's permission to perform them
""" """
(user_group_id, content_user_group_id) = get_user_group_ids(course_id, content, user)
return { return {
'editable': check_permissions_by_view(user, course_id, content, "update_thread" if content['type'] == 'thread' else "update_comment"), 'editable': check_permissions_by_view(
user,
course_id,
content,
"update_thread" if content['type'] == 'thread' else "update_comment",
user_group_id,
content_user_group_id
),
'can_reply': check_permissions_by_view(user, course_id, content, "create_comment" if content['type'] == 'thread' else "create_sub_comment"), 'can_reply': check_permissions_by_view(user, course_id, content, "create_comment" if content['type'] == 'thread' else "create_sub_comment"),
'can_delete': check_permissions_by_view(user, course_id, content, "delete_thread" if content['type'] == 'thread' else "delete_comment"), 'can_delete': check_permissions_by_view(
'can_openclose': check_permissions_by_view(user, course_id, content, "openclose_thread") if content['type'] == 'thread' else False, user,
course_id,
content,
"delete_thread" if content['type'] == 'thread' else "delete_comment",
user_group_id,
content_user_group_id
),
'can_openclose': check_permissions_by_view(
user,
course_id,
content,
"openclose_thread" if content['type'] == 'thread' else False,
user_group_id,
content_user_group_id
),
'can_vote': not is_content_authored_by(content, user) and check_permissions_by_view( 'can_vote': not is_content_authored_by(content, user) and check_permissions_by_view(
user, user,
course_id, course_id,
...@@ -538,6 +561,25 @@ def get_ability(course_id, content, user): ...@@ -538,6 +561,25 @@ def get_ability(course_id, content, user):
# TODO: RENAME # TODO: RENAME
def get_user_group_ids(course_id, content, user=None):
"""
Given a user, course ID, and the content of the thread or comment, returns the group ID for the current user
and the user that posted the thread/comment.
"""
content_user_group_id = None
user_group_id = None
if course_id is not None:
if content.get('username'):
try:
content_user = get_user_by_username_or_email(content.get('username'))
content_user_group_id = get_group_id_for_user(content_user, get_course_discussion_settings(course_id))
except User.DoesNotExist:
content_user_group_id = None
user_group_id = get_group_id_for_user(user, get_course_discussion_settings(course_id)) if user else None
return user_group_id, content_user_group_id
def get_annotated_content_info(course_id, content, user, user_info): def get_annotated_content_info(course_id, content, user, user_info):
""" """
Get metadata for an individual content (thread or comment) Get metadata for an individual content (thread or comment)
...@@ -780,7 +822,7 @@ def get_group_id_for_user(user, course_discussion_settings): ...@@ -780,7 +822,7 @@ def get_group_id_for_user(user, course_discussion_settings):
elif division_scheme == CourseDiscussionSettings.ENROLLMENT_TRACK: elif division_scheme == CourseDiscussionSettings.ENROLLMENT_TRACK:
partition_service = PartitionService(course_discussion_settings.course_id) partition_service = PartitionService(course_discussion_settings.course_id)
group_id = partition_service.get_user_group_id_for_partition(user, ENROLLMENT_TRACK_PARTITION_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 # 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). # 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 return -1 * group_id if group_id is not None else None
else: else:
......
...@@ -46,7 +46,13 @@ from courseware.access import has_access ...@@ -46,7 +46,13 @@ from courseware.access import has_access
from courseware.courses import get_course_by_id, get_course_with_access from courseware.courses import get_course_by_id, get_course_with_access
from courseware.models import StudentModule from courseware.models import StudentModule
from django_comment_client.utils import has_forum_access from django_comment_client.utils import has_forum_access
from django_comment_common.models import FORUM_ROLE_ADMINISTRATOR, FORUM_ROLE_COMMUNITY_TA, FORUM_ROLE_MODERATOR, Role from django_comment_common.models import (
Role,
FORUM_ROLE_ADMINISTRATOR,
FORUM_ROLE_MODERATOR,
FORUM_ROLE_GROUP_MODERATOR,
FORUM_ROLE_COMMUNITY_TA,
)
from edxmako.shortcuts import render_to_string from edxmako.shortcuts import render_to_string
from lms.djangoapps.instructor.access import ROLES, allow_access, list_with_level, revoke_access, update_forum_role from lms.djangoapps.instructor.access import ROLES, allow_access, list_with_level, revoke_access, update_forum_role
from lms.djangoapps.instructor.enrollment import ( from lms.djangoapps.instructor.enrollment import (
...@@ -2487,7 +2493,8 @@ def list_forum_members(request, course_id): ...@@ -2487,7 +2493,8 @@ def list_forum_members(request, course_id):
return HttpResponseBadRequest("Operation requires instructor access.") return HttpResponseBadRequest("Operation requires instructor access.")
# filter out unsupported for roles # filter out unsupported for roles
if rolename not in [FORUM_ROLE_ADMINISTRATOR, FORUM_ROLE_MODERATOR, FORUM_ROLE_COMMUNITY_TA]: if rolename not in [FORUM_ROLE_ADMINISTRATOR, FORUM_ROLE_MODERATOR, FORUM_ROLE_GROUP_MODERATOR,
FORUM_ROLE_COMMUNITY_TA]:
return HttpResponseBadRequest(strip_tags( return HttpResponseBadRequest(strip_tags(
"Unrecognized rolename '{}'.".format(rolename) "Unrecognized rolename '{}'.".format(rolename)
)) ))
...@@ -2610,7 +2617,8 @@ def update_forum_role_membership(request, course_id): ...@@ -2610,7 +2617,8 @@ def update_forum_role_membership(request, course_id):
Query parameters: Query parameters:
- `email` is the target users email - `email` is the target users email
- `rolename` is one of [FORUM_ROLE_ADMINISTRATOR, FORUM_ROLE_MODERATOR, FORUM_ROLE_COMMUNITY_TA] - `rolename` is one of [FORUM_ROLE_ADMINISTRATOR, FORUM_ROLE_GROUP_MODERATOR,
FORUM_ROLE_MODERATOR, FORUM_ROLE_COMMUNITY_TA]
- `action` is one of ['allow', 'revoke'] - `action` is one of ['allow', 'revoke']
""" """
course_id = SlashSeparatedCourseKey.from_deprecated_string(course_id) course_id = SlashSeparatedCourseKey.from_deprecated_string(course_id)
...@@ -2634,7 +2642,8 @@ def update_forum_role_membership(request, course_id): ...@@ -2634,7 +2642,8 @@ def update_forum_role_membership(request, course_id):
if rolename == FORUM_ROLE_ADMINISTRATOR and not has_instructor_access: if rolename == FORUM_ROLE_ADMINISTRATOR and not has_instructor_access:
return HttpResponseBadRequest("Operation requires instructor access.") return HttpResponseBadRequest("Operation requires instructor access.")
if rolename not in [FORUM_ROLE_ADMINISTRATOR, FORUM_ROLE_MODERATOR, FORUM_ROLE_COMMUNITY_TA]: if rolename not in [FORUM_ROLE_ADMINISTRATOR, FORUM_ROLE_MODERATOR, FORUM_ROLE_GROUP_MODERATOR,
FORUM_ROLE_COMMUNITY_TA]:
return HttpResponseBadRequest(strip_tags( return HttpResponseBadRequest(strip_tags(
"Unrecognized rolename '{}'.".format(rolename) "Unrecognized rolename '{}'.".format(rolename)
)) ))
......
...@@ -249,6 +249,20 @@ from django.utils.translation import ugettext as _ ...@@ -249,6 +249,20 @@ from django.utils.translation import ugettext as _
></div> ></div>
<div class="auth-list-container" <div class="auth-list-container"
data-rolename="Group Moderator"
data-display-name="${_("Discussion Group Moderators")}"
data-info-text="
${_("Discussion Group Moderators can edit or delete any post, clear misuse flags, close "
"and re-open threads, endorse responses, and see posts from all groups. "
"Their posts are marked as 'staff'. They cannot manage course team membership by "
"adding or removing discussion moderation roles. Only enrolled users can be "
"added as Discussion Moderators.")}"
data-list-endpoint="${ section_data['list_forum_members_url'] }"
data-modify-endpoint="${ section_data['update_forum_role_membership_url'] }"
data-add-button-label="${_("Add Group Moderator")}"
></div>
<div class="auth-list-container"
data-rolename="Community TA" data-rolename="Community TA"
data-display-name="${_("Discussion Community TAs")}" data-display-name="${_("Discussion Community TAs")}"
data-info-text=" data-info-text="
......
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