Move django_comment_common to openedx/core/djangoapps/discussion_common

This commit is contained in:
Nimisha Asthagiri
2019-04-29 18:36:51 -04:00
parent c2e232a3d4
commit df962a31b7
100 changed files with 339 additions and 296 deletions

View File

@@ -10,8 +10,8 @@ from factory import Sequence, post_generation
from factory.django import DjangoModelFactory
from opaque_keys.edx.locator import CourseLocator
from django_comment_common.models import CourseDiscussionSettings
from django_comment_common.utils import set_course_discussion_settings
from openedx.core.djangoapps.discussion_common.models import CourseDiscussionSettings
from openedx.core.djangoapps.discussion_common.utils import set_course_discussion_settings
from xmodule.modulestore import ModuleStoreEnum
from xmodule.modulestore.django import modulestore

View File

@@ -15,8 +15,8 @@ from django.http import Http404
from django.test.client import RequestFactory
from opaque_keys.edx.locator import CourseLocator
from django_comment_common.models import CourseDiscussionSettings
from django_comment_common.utils import get_course_discussion_settings
from openedx.core.djangoapps.discussion_common.models import CourseDiscussionSettings
from openedx.core.djangoapps.discussion_common.utils import get_course_discussion_settings
from lms.djangoapps.courseware.tests.factories import InstructorFactory, StaffFactory
from student.models import CourseEnrollment
from student.tests.factories import UserFactory

View File

@@ -0,0 +1 @@
See ``lms/djangoapps/discussion/README.rst``

View File

@@ -0,0 +1,8 @@
"""
Admin for managing the connection to the Forums backend service.
"""
from django.contrib import admin
from .models import ForumsConfig
admin.site.register(ForumsConfig)

View File

@@ -0,0 +1 @@
See ``lms/djangoapps/discussion/README.rst``

View File

@@ -0,0 +1,6 @@
# pylint: disable=missing-docstring,wildcard-import
from .comment_client import *
from .utils import (
CommentClientError, CommentClientRequestError,
CommentClient500Error, CommentClientMaintenanceError
)

View File

@@ -0,0 +1,113 @@
# pylint: disable=missing-docstring,protected-access
from openedx.core.djangoapps.discussion_common.comment_client import models, settings
from .thread import Thread, _url_for_flag_abuse_thread, _url_for_unflag_abuse_thread
from .utils import CommentClientRequestError, perform_request
class Comment(models.Model):
accessible_fields = [
'id', 'body', 'anonymous', 'anonymous_to_peers', 'course_id',
'endorsed', 'parent_id', 'thread_id', 'username', 'votes', 'user_id',
'closed', 'created_at', 'updated_at', 'depth', 'at_position_list',
'type', 'commentable_id', 'abuse_flaggers', 'endorsement',
'child_count',
]
updatable_fields = [
'body', 'anonymous', 'anonymous_to_peers', 'course_id', 'closed',
'user_id', 'endorsed', 'endorsement_user_id',
]
initializable_fields = updatable_fields
metrics_tag_fields = ['course_id', 'endorsed', 'closed']
base_url = "{prefix}/comments".format(prefix=settings.PREFIX)
type = 'comment'
def __init__(self, *args, **kwargs):
super(Comment, self).__init__(*args, **kwargs)
self._cached_thread = None
@property
def thread(self):
if not self._cached_thread:
self._cached_thread = Thread(id=self.thread_id, type='thread')
return self._cached_thread
@property
def context(self):
"""Return the context of the thread which this comment belongs to."""
return self.thread.context
@classmethod
def url_for_comments(cls, params=None):
if params and params.get('parent_id'):
return _url_for_comment(params['parent_id'])
else:
return _url_for_thread_comments(params['thread_id'])
@classmethod
def url(cls, action, params=None):
if params is None:
params = {}
if action in ['post']:
return cls.url_for_comments(params)
else:
return super(Comment, cls).url(action, params)
def flagAbuse(self, user, voteable):
if voteable.type == 'thread':
url = _url_for_flag_abuse_thread(voteable.id)
elif voteable.type == 'comment':
url = _url_for_flag_abuse_comment(voteable.id)
else:
raise CommentClientRequestError("Can only flag/unflag threads or comments")
params = {'user_id': user.id}
response = perform_request(
'put',
url,
params,
metric_tags=self._metric_tags,
metric_action='comment.abuse.flagged'
)
voteable._update_from_response(response)
def unFlagAbuse(self, user, voteable, removeAll):
if voteable.type == 'thread':
url = _url_for_unflag_abuse_thread(voteable.id)
elif voteable.type == 'comment':
url = _url_for_unflag_abuse_comment(voteable.id)
else:
raise CommentClientRequestError("Can flag/unflag for threads or comments")
params = {'user_id': user.id}
if removeAll:
params['all'] = True
response = perform_request(
'put',
url,
params,
metric_tags=self._metric_tags,
metric_action='comment.abuse.unflagged'
)
voteable._update_from_response(response)
def _url_for_thread_comments(thread_id):
return "{prefix}/threads/{thread_id}/comments".format(prefix=settings.PREFIX, thread_id=thread_id)
def _url_for_comment(comment_id):
return "{prefix}/comments/{comment_id}".format(prefix=settings.PREFIX, comment_id=comment_id)
def _url_for_flag_abuse_comment(comment_id):
return "{prefix}/comments/{comment_id}/abuse_flag".format(prefix=settings.PREFIX, comment_id=comment_id)
def _url_for_unflag_abuse_comment(comment_id):
return "{prefix}/comments/{comment_id}/abuse_unflag".format(prefix=settings.PREFIX, comment_id=comment_id)

View File

@@ -0,0 +1,6 @@
"""Import other classes here so they can be imported from here."""
# pylint: disable=unused-import
from .comment import Comment
from .commentable import Commentable
from .thread import Thread
from .user import User

View File

@@ -0,0 +1,19 @@
# pylint: disable=missing-docstring
"""Provides base Commentable model class"""
from openedx.core.djangoapps.discussion_common.comment_client import models, settings
class Commentable(models.Model):
accessible_fields = ['id', 'commentable_id']
base_url = "{prefix}/commentables".format(prefix=settings.PREFIX)
type = 'commentable'
def retrieve(self, *args, **kwargs):
"""
Override default behavior because commentables don't actually exist in the comment service.
"""
self.attributes["commentable_id"] = self.attributes["id"]
self.retrieved = True
return self

View File

@@ -0,0 +1,189 @@
# pylint: disable=missing-docstring,unused-argument
import logging
from .utils import CommentClientRequestError, extract, perform_request
log = logging.getLogger(__name__)
class Model(object):
accessible_fields = ['id']
updatable_fields = ['id']
initializable_fields = ['id']
base_url = None
default_retrieve_params = {}
metric_tag_fields = []
DEFAULT_ACTIONS_WITH_ID = ['get', 'put', 'delete']
DEFAULT_ACTIONS_WITHOUT_ID = ['get_all', 'post']
DEFAULT_ACTIONS = DEFAULT_ACTIONS_WITH_ID + DEFAULT_ACTIONS_WITHOUT_ID
def __init__(self, *args, **kwargs):
self.attributes = extract(kwargs, self.accessible_fields)
self.retrieved = False
def __getattr__(self, name):
if name == 'id':
return self.attributes.get('id', None)
try:
return self.attributes[name]
except KeyError:
if self.retrieved or self.id is None:
raise AttributeError(u"Field {0} does not exist".format(name))
self.retrieve()
return self.__getattr__(name)
def __setattr__(self, name, value):
if name == 'attributes' or name not in self.accessible_fields + self.updatable_fields:
super(Model, self).__setattr__(name, value)
else:
self.attributes[name] = value
def __getitem__(self, key):
if key not in self.accessible_fields:
raise KeyError(u"Field {0} does not exist".format(key))
return self.attributes.get(key)
def __setitem__(self, key, value):
if key not in self.accessible_fields + self.updatable_fields:
raise KeyError(u"Field {0} does not exist".format(key))
self.attributes.__setitem__(key, value)
def items(self, *args, **kwargs):
return self.attributes.items(*args, **kwargs)
def get(self, *args, **kwargs):
return self.attributes.get(*args, **kwargs)
def to_dict(self):
self.retrieve()
return self.attributes
def retrieve(self, *args, **kwargs):
if not self.retrieved:
self._retrieve(*args, **kwargs)
self.retrieved = True
return self
def _retrieve(self, *args, **kwargs):
url = self.url(action='get', params=self.attributes)
response = perform_request(
'get',
url,
self.default_retrieve_params,
metric_tags=self._metric_tags,
metric_action='model.retrieve'
)
self._update_from_response(response)
@property
def _metric_tags(self):
"""
Returns a list of tags to be used when recording metrics about this model.
Each field named in ``self.metric_tag_fields`` is used as a tag value,
under the key ``<class>.<metric_field>``. The tag model_class is used to
record the class name of the model.
"""
tags = [
u'{}.{}:{}'.format(self.__class__.__name__, attr, self[attr])
for attr in self.metric_tag_fields
if attr in self.attributes
]
tags.append(u'model_class:{}'.format(self.__class__.__name__))
return tags
@classmethod
def find(cls, id): # pylint: disable=redefined-builtin
return cls(id=id)
def _update_from_response(self, response_data):
for k, v in response_data.items():
if k in self.accessible_fields:
self.__setattr__(k, v)
else:
log.warning(
u"Unexpected field {field_name} in model {model_name}".format(
field_name=k,
model_name=self.__class__.__name__
)
)
def updatable_attributes(self):
return extract(self.attributes, self.updatable_fields)
def initializable_attributes(self):
return extract(self.attributes, self.initializable_fields)
@classmethod
def before_save(cls, instance):
pass
@classmethod
def after_save(cls, instance):
pass
def save(self, params=None):
"""
Invokes Forum's POST/PUT service to create/update thread
"""
self.before_save(self)
if self.id: # if we have id already, treat this as an update
request_params = self.updatable_attributes()
if params:
request_params.update(params)
url = self.url(action='put', params=self.attributes)
response = perform_request(
'put',
url,
request_params,
metric_tags=self._metric_tags,
metric_action='model.update'
)
else: # otherwise, treat this as an insert
url = self.url(action='post', params=self.attributes)
response = perform_request(
'post',
url,
self.initializable_attributes(),
metric_tags=self._metric_tags,
metric_action='model.insert'
)
self.retrieved = True
self._update_from_response(response)
self.after_save(self)
def delete(self):
url = self.url(action='delete', params=self.attributes)
response = perform_request('delete', url, metric_tags=self._metric_tags, metric_action='model.delete')
self.retrieved = True
self._update_from_response(response)
@classmethod
def url_with_id(cls, params=None):
if params is None:
params = {}
return cls.base_url + '/' + str(params['id'])
@classmethod
def url_without_id(cls, params=None):
return cls.base_url
@classmethod
def url(cls, action, params=None):
if params is None:
params = {}
if cls.base_url is None:
raise CommentClientRequestError("Must provide base_url when using default url function")
if action not in cls.DEFAULT_ACTIONS:
raise ValueError(
u"Invalid action {0}. The supported action must be in {1}".format(action, str(cls.DEFAULT_ACTIONS))
)
elif action in cls.DEFAULT_ACTIONS_WITH_ID:
try:
return cls.url_with_id(params)
except KeyError:
raise CommentClientRequestError(u"Cannot perform action {0} without id".format(action))
else: # action must be in DEFAULT_ACTIONS_WITHOUT_ID now
return cls.url_without_id()

View File

@@ -0,0 +1 @@
requests

View File

@@ -0,0 +1,9 @@
# pylint: disable=missing-docstring
from django.conf import settings
if hasattr(settings, "COMMENTS_SERVICE_URL"):
SERVICE_HOST = settings.COMMENTS_SERVICE_URL
else:
SERVICE_HOST = 'http://localhost:4567'
PREFIX = SERVICE_HOST + '/api/v1'

View File

@@ -0,0 +1,236 @@
# pylint: disable=missing-docstring,protected-access,unused-argument
import logging
from eventtracking import tracker
from . import models
from . import settings
from . import utils
log = logging.getLogger(__name__)
class Thread(models.Model):
# accessible_fields can be set and retrieved on the model
accessible_fields = [
'id', 'title', 'body', 'anonymous', 'anonymous_to_peers', 'course_id',
'closed', 'tags', 'votes', 'commentable_id', 'username', 'user_id',
'created_at', 'updated_at', 'comments_count', 'unread_comments_count',
'at_position_list', 'children', 'type', 'highlighted_title',
'highlighted_body', 'endorsed', 'read', 'group_id', 'group_name', 'pinned',
'abuse_flaggers', 'resp_skip', 'resp_limit', 'resp_total', 'thread_type',
'endorsed_responses', 'non_endorsed_responses', 'non_endorsed_resp_total',
'context', 'last_activity_at',
]
# updateable_fields are sent in PUT requests
updatable_fields = [
'title', 'body', 'anonymous', 'anonymous_to_peers', 'course_id', 'read',
'closed', 'user_id', 'commentable_id', 'group_id', 'group_name', 'pinned', 'thread_type'
]
# metric_tag_fields are used by Datadog to record metrics about the model
metric_tag_fields = [
'course_id', 'group_id', 'pinned', 'closed', 'anonymous', 'anonymous_to_peers',
'endorsed', 'read'
]
# initializable_fields are sent in POST requests
initializable_fields = updatable_fields + ['thread_type', 'context']
base_url = "{prefix}/threads".format(prefix=settings.PREFIX)
default_retrieve_params = {'recursive': False}
type = 'thread'
@classmethod
def search(cls, query_params):
# NOTE: Params 'recursive' and 'with_responses' are currently not used by
# either the 'search' or 'get_all' actions below. Both already use
# with_responses=False internally in the comment service, so no additional
# optimization is required.
params = {
'page': 1,
'per_page': 20,
'course_id': query_params['course_id'],
}
params.update(
utils.strip_blank(utils.strip_none(query_params))
)
if query_params.get('text'):
url = cls.url(action='search')
else:
url = cls.url(action='get_all', params=utils.extract(params, 'commentable_id'))
if params.get('commentable_id'):
del params['commentable_id']
response = utils.perform_request(
'get',
url,
params,
metric_tags=[u'course_id:{}'.format(query_params['course_id'])],
metric_action='thread.search',
paged_results=True
)
if query_params.get('text'):
search_query = query_params['text']
course_id = query_params['course_id']
group_id = query_params['group_id'] if 'group_id' in query_params else None
requested_page = params['page']
total_results = response.get('total_results')
corrected_text = response.get('corrected_text')
# Record search result metric to allow search quality analysis.
# course_id is already included in the context for the event tracker
tracker.emit(
'edx.forum.searched',
{
'query': search_query,
'corrected_text': corrected_text,
'group_id': group_id,
'page': requested_page,
'total_results': total_results,
}
)
log.info(
u'forum_text_search query="{search_query}" corrected_text="{corrected_text}" course_id={course_id} '
u'group_id={group_id} page={requested_page} total_results={total_results}'.format(
search_query=search_query,
corrected_text=corrected_text,
course_id=course_id,
group_id=group_id,
requested_page=requested_page,
total_results=total_results
)
)
return utils.CommentClientPaginatedResult(
collection=response.get('collection', []),
page=response.get('page', 1),
num_pages=response.get('num_pages', 1),
thread_count=response.get('thread_count', 0),
corrected_text=response.get('corrected_text', None)
)
@classmethod
def url_for_threads(cls, params=None):
if params and params.get('commentable_id'):
return u"{prefix}/{commentable_id}/threads".format(
prefix=settings.PREFIX,
commentable_id=params['commentable_id'],
)
else:
return u"{prefix}/threads".format(prefix=settings.PREFIX)
@classmethod
def url_for_search_threads(cls, params=None):
return "{prefix}/search/threads".format(prefix=settings.PREFIX)
@classmethod
def url(cls, action, params=None):
if params is None:
params = {}
if action in ['get_all', 'post']:
return cls.url_for_threads(params)
elif action == 'search':
return cls.url_for_search_threads(params)
else:
return super(Thread, cls).url(action, params)
# TODO: This is currently overriding Model._retrieve only to add parameters
# for the request. Model._retrieve should be modified to handle this such
# that subclasses don't need to override for this.
def _retrieve(self, *args, **kwargs):
url = self.url(action='get', params=self.attributes)
request_params = {
'recursive': kwargs.get('recursive'),
'with_responses': kwargs.get('with_responses', False),
'user_id': kwargs.get('user_id'),
'mark_as_read': kwargs.get('mark_as_read', True),
'resp_skip': kwargs.get('response_skip'),
'resp_limit': kwargs.get('response_limit'),
}
request_params = utils.strip_none(request_params)
response = utils.perform_request(
'get',
url,
request_params,
metric_action='model.retrieve',
metric_tags=self._metric_tags
)
self._update_from_response(response)
def flagAbuse(self, user, voteable):
if voteable.type == 'thread':
url = _url_for_flag_abuse_thread(voteable.id)
else:
raise utils.CommentClientRequestError("Can only flag/unflag threads or comments")
params = {'user_id': user.id}
response = utils.perform_request(
'put',
url,
params,
metric_action='thread.abuse.flagged',
metric_tags=self._metric_tags
)
voteable._update_from_response(response)
def unFlagAbuse(self, user, voteable, removeAll):
if voteable.type == 'thread':
url = _url_for_unflag_abuse_thread(voteable.id)
else:
raise utils.CommentClientRequestError("Can only flag/unflag for threads or comments")
params = {'user_id': user.id}
#if you're an admin, when you unflag, remove ALL flags
if removeAll:
params['all'] = True
response = utils.perform_request(
'put',
url,
params,
metric_tags=self._metric_tags,
metric_action='thread.abuse.unflagged'
)
voteable._update_from_response(response)
def pin(self, user, thread_id):
url = _url_for_pin_thread(thread_id)
params = {'user_id': user.id}
response = utils.perform_request(
'put',
url,
params,
metric_tags=self._metric_tags,
metric_action='thread.pin'
)
self._update_from_response(response)
def un_pin(self, user, thread_id):
url = _url_for_un_pin_thread(thread_id)
params = {'user_id': user.id}
response = utils.perform_request(
'put',
url,
params,
metric_tags=self._metric_tags,
metric_action='thread.unpin'
)
self._update_from_response(response)
def _url_for_flag_abuse_thread(thread_id):
return "{prefix}/threads/{thread_id}/abuse_flag".format(prefix=settings.PREFIX, thread_id=thread_id)
def _url_for_unflag_abuse_thread(thread_id):
return "{prefix}/threads/{thread_id}/abuse_unflag".format(prefix=settings.PREFIX, thread_id=thread_id)
def _url_for_pin_thread(thread_id):
return "{prefix}/threads/{thread_id}/pin".format(prefix=settings.PREFIX, thread_id=thread_id)
def _url_for_un_pin_thread(thread_id):
return "{prefix}/threads/{thread_id}/unpin".format(prefix=settings.PREFIX, thread_id=thread_id)

View File

@@ -0,0 +1,240 @@
# pylint: disable=missing-docstring,protected-access
""" User model wrapper for comment service"""
from six import text_type
from . import models
from . import settings
from . import utils
class User(models.Model):
accessible_fields = [
'username', 'follower_ids', 'upvoted_ids', 'downvoted_ids',
'id', 'external_id', 'subscribed_user_ids', 'children', 'course_id',
'group_id', 'subscribed_thread_ids', 'subscribed_commentable_ids',
'subscribed_course_ids', 'threads_count', 'comments_count',
'default_sort_key'
]
updatable_fields = ['username', 'external_id', 'default_sort_key']
initializable_fields = updatable_fields
metric_tag_fields = ['course_id']
base_url = "{prefix}/users".format(prefix=settings.PREFIX)
default_retrieve_params = {'complete': True}
type = 'user'
@classmethod
def from_django_user(cls, user):
return cls(id=str(user.id),
external_id=str(user.id),
username=user.username)
def read(self, source):
"""
Calls cs_comments_service to mark thread as read for the user
"""
params = {'source_type': source.type, 'source_id': source.id}
utils.perform_request(
'post',
_url_for_read(self.id),
params,
metric_action='user.read',
metric_tags=self._metric_tags + ['target.type:{}'.format(source.type)],
)
def follow(self, source):
params = {'source_type': source.type, 'source_id': source.id}
utils.perform_request(
'post',
_url_for_subscription(self.id),
params,
metric_action='user.follow',
metric_tags=self._metric_tags + ['target.type:{}'.format(source.type)],
)
def unfollow(self, source):
params = {'source_type': source.type, 'source_id': source.id}
utils.perform_request(
'delete',
_url_for_subscription(self.id),
params,
metric_action='user.unfollow',
metric_tags=self._metric_tags + ['target.type:{}'.format(source.type)],
)
def vote(self, voteable, value):
if voteable.type == 'thread':
url = _url_for_vote_thread(voteable.id)
elif voteable.type == 'comment':
url = _url_for_vote_comment(voteable.id)
else:
raise utils.CommentClientRequestError("Can only vote / unvote for threads or comments")
params = {'user_id': self.id, 'value': value}
response = utils.perform_request(
'put',
url,
params,
metric_action='user.vote',
metric_tags=self._metric_tags + ['target.type:{}'.format(voteable.type)],
)
voteable._update_from_response(response)
def unvote(self, voteable):
if voteable.type == 'thread':
url = _url_for_vote_thread(voteable.id)
elif voteable.type == 'comment':
url = _url_for_vote_comment(voteable.id)
else:
raise utils.CommentClientRequestError("Can only vote / unvote for threads or comments")
params = {'user_id': self.id}
response = utils.perform_request(
'delete',
url,
params,
metric_action='user.unvote',
metric_tags=self._metric_tags + ['target.type:{}'.format(voteable.type)],
)
voteable._update_from_response(response)
def active_threads(self, query_params=None):
if query_params is None:
query_params = {}
if not self.course_id:
raise utils.CommentClientRequestError("Must provide course_id when retrieving active threads for the user")
url = _url_for_user_active_threads(self.id)
params = {'course_id': text_type(self.course_id)}
params.update(query_params)
response = utils.perform_request(
'get',
url,
params,
metric_action='user.active_threads',
metric_tags=self._metric_tags,
paged_results=True,
)
return response.get('collection', []), response.get('page', 1), response.get('num_pages', 1)
def subscribed_threads(self, query_params=None):
if query_params is None:
query_params = {}
if not self.course_id:
raise utils.CommentClientRequestError(
"Must provide course_id when retrieving subscribed threads for the user",
)
url = _url_for_user_subscribed_threads(self.id)
params = {'course_id': text_type(self.course_id)}
params.update(query_params)
response = utils.perform_request(
'get',
url,
params,
metric_action='user.subscribed_threads',
metric_tags=self._metric_tags,
paged_results=True
)
return utils.CommentClientPaginatedResult(
collection=response.get('collection', []),
page=response.get('page', 1),
num_pages=response.get('num_pages', 1),
thread_count=response.get('thread_count', 0)
)
def _retrieve(self, *args, **kwargs):
url = self.url(action='get', params=self.attributes)
retrieve_params = self.default_retrieve_params.copy()
retrieve_params.update(kwargs)
if self.attributes.get('course_id'):
retrieve_params['course_id'] = text_type(self.course_id)
if self.attributes.get('group_id'):
retrieve_params['group_id'] = self.group_id
try:
response = utils.perform_request(
'get',
url,
retrieve_params,
metric_action='model.retrieve',
metric_tags=self._metric_tags,
)
except utils.CommentClientRequestError as e:
if e.status_code == 404:
# attempt to gracefully recover from a previous failure
# to sync this user to the comments service.
self.save()
response = utils.perform_request(
'get',
url,
retrieve_params,
metric_action='model.retrieve',
metric_tags=self._metric_tags,
)
else:
raise
self._update_from_response(response)
def retire(self, retired_username):
url = _url_for_retire(self.id)
params = {'retired_username': retired_username}
utils.perform_request(
'post',
url,
params,
raw=True,
metric_action='user.retire',
metric_tags=self._metric_tags
)
def replace_username(self, new_username):
url = _url_for_username_replacement(self.id)
params = {"new_username": new_username}
utils.perform_request(
'post',
url,
params,
raw=True,
)
def _url_for_vote_comment(comment_id):
return "{prefix}/comments/{comment_id}/votes".format(prefix=settings.PREFIX, comment_id=comment_id)
def _url_for_vote_thread(thread_id):
return "{prefix}/threads/{thread_id}/votes".format(prefix=settings.PREFIX, thread_id=thread_id)
def _url_for_subscription(user_id):
return "{prefix}/users/{user_id}/subscriptions".format(prefix=settings.PREFIX, user_id=user_id)
def _url_for_user_active_threads(user_id):
return "{prefix}/users/{user_id}/active_threads".format(prefix=settings.PREFIX, user_id=user_id)
def _url_for_user_subscribed_threads(user_id):
return "{prefix}/users/{user_id}/subscribed_threads".format(prefix=settings.PREFIX, user_id=user_id)
def _url_for_read(user_id):
"""
Returns cs_comments_service url endpoint to mark thread as read for given user_id
"""
return "{prefix}/users/{user_id}/read".format(prefix=settings.PREFIX, user_id=user_id)
def _url_for_retire(user_id):
"""
Returns cs_comments_service url endpoint to retire a user (remove all post content, etc.)
"""
return "{prefix}/users/{user_id}/retire".format(prefix=settings.PREFIX, user_id=user_id)
def _url_for_username_replacement(user_id):
"""
Returns cs_comments_servuce url endpoint to replace the username of a user
"""
return "{prefix}/users/{user_id}/replace_username".format(prefix=settings.PREFIX, user_id=user_id)

View File

@@ -0,0 +1,154 @@
# pylint: disable=missing-docstring,unused-argument,broad-except
"""" Common utilities for comment client wrapper """
import logging
from uuid import uuid4
import requests
from django.utils.translation import get_language
from .settings import SERVICE_HOST as COMMENTS_SERVICE
log = logging.getLogger(__name__)
def strip_none(dic):
return dict([(k, v) for k, v in dic.iteritems() if v is not None])
def strip_blank(dic):
def _is_blank(v):
return isinstance(v, str) and len(v.strip()) == 0
return dict([(k, v) for k, v in dic.iteritems() if not _is_blank(v)])
def extract(dic, keys):
if isinstance(keys, str):
return strip_none({keys: dic.get(keys)})
else:
return strip_none({k: dic.get(k) for k in keys})
def perform_request(method, url, data_or_params=None, raw=False,
metric_action=None, metric_tags=None, paged_results=False):
# To avoid dependency conflict
from openedx.core.djangoapps.discussion_common.models import ForumsConfig
config = ForumsConfig.current()
if not config.enabled:
raise CommentClientMaintenanceError('service disabled')
if metric_tags is None:
metric_tags = []
metric_tags.append(u'method:{}'.format(method))
if metric_action:
metric_tags.append(u'action:{}'.format(metric_action))
if data_or_params is None:
data_or_params = {}
headers = {
'X-Edx-Api-Key': config.api_key,
'Accept-Language': get_language(),
}
request_id = uuid4()
request_id_dict = {'request_id': request_id}
if method in ['post', 'put', 'patch']:
data = data_or_params
params = request_id_dict
else:
data = None
params = data_or_params.copy()
params.update(request_id_dict)
response = requests.request(
method,
url,
data=data,
params=params,
headers=headers,
timeout=config.connection_timeout
)
metric_tags.append(u'status_code:{}'.format(response.status_code))
if response.status_code > 200:
metric_tags.append(u'result:failure')
else:
metric_tags.append(u'result:success')
if 200 < response.status_code < 500:
raise CommentClientRequestError(response.text, response.status_code)
# Heroku returns a 503 when an application is in maintenance mode
elif response.status_code == 503:
raise CommentClientMaintenanceError(response.text)
elif response.status_code == 500:
raise CommentClient500Error(response.text)
else:
if raw:
return response.text
else:
try:
data = response.json()
except ValueError:
raise CommentClientError(
u"Invalid JSON response for request {request_id}; first 100 characters: '{content}'".format(
request_id=request_id,
content=response.text[:100]
)
)
return data
class CommentClientError(Exception):
pass
class CommentClientRequestError(CommentClientError):
def __init__(self, msg, status_codes=400):
super(CommentClientRequestError, self).__init__(msg)
self.status_code = status_codes
class CommentClient500Error(CommentClientError):
pass
class CommentClientMaintenanceError(CommentClientError):
pass
class CommentClientPaginatedResult(object):
""" class for paginated results returned from comment services"""
def __init__(self, collection, page, num_pages, thread_count=0, corrected_text=None):
self.collection = collection
self.page = page
self.num_pages = num_pages
self.thread_count = thread_count
self.corrected_text = corrected_text
def check_forum_heartbeat():
"""
Check the forum connection via its built-in heartbeat service and create an answer which can be used in the LMS
heartbeat django application.
This function can be connected to the LMS heartbeat checker through the HEARTBEAT_CHECKS variable.
"""
# To avoid dependency conflict
from openedx.core.djangoapps.discussion_common.models import ForumsConfig
config = ForumsConfig.current()
if not config.enabled:
# If this check is enabled but forums disabled, don't connect, just report no error
return 'forum', True, 'OK'
try:
res = requests.get(
'%s/heartbeat' % COMMENTS_SERVICE,
timeout=config.connection_timeout
).json()
if res['OK']:
return 'forum', True, 'OK'
else:
return 'forum', False, res.get('check', 'Forum heartbeat failed')
except Exception as fail:
return 'forum', False, unicode(fail)

View File

@@ -0,0 +1,42 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.conf import settings
from django.db import migrations, models
from opaque_keys.edx.django.models import CourseKeyField
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Permission',
fields=[
('name', models.CharField(max_length=30, serialize=False, primary_key=True)),
],
options={
'db_table': 'django_comment_client_permission',
},
),
migrations.CreateModel(
name='Role',
fields=[
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
('name', models.CharField(max_length=30)),
('course_id', CourseKeyField(db_index=True, max_length=255, blank=True)),
('users', models.ManyToManyField(related_name='roles', to=settings.AUTH_USER_MODEL)),
],
options={
'db_table': 'django_comment_client_role',
},
),
migrations.AddField(
model_name='permission',
name='roles',
field=models.ManyToManyField(related_name='permissions', to='discussion_common.Role'),
),
]

View File

@@ -0,0 +1,32 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('discussion_common', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='ForumsConfig',
fields=[
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
('change_date', models.DateTimeField(auto_now_add=True, verbose_name='Change date')),
('enabled', models.BooleanField(default=False, verbose_name='Enabled')),
('connection_timeout', models.FloatField(default=5.0)),
('changed_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, editable=False, to=settings.AUTH_USER_MODEL, null=True, verbose_name='Changed by')),
],
options={
'ordering': ('-change_date',),
'abstract': False,
'db_table': 'django_comment_common_forumsconfig',
},
),
]

View File

@@ -0,0 +1,30 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
def add_default_enable(apps, schema_editor):
ForumsConfig = apps.get_model("django_comment_common", "ForumsConfig")
settings_count = ForumsConfig.objects.count()
if settings_count == 0:
# By default we want the comment client enabled, but this is *not* enabling
# discussions themselves by default, as in showing the Disucussions tab, or
# inline discussions, etc. It just allows the underlying service client to work.
settings = ForumsConfig(enabled=True)
settings.save()
def reverse_noop(apps, schema_editor):
return
class Migration(migrations.Migration):
dependencies = [
('discussion_common', '0002_forumsconfig'),
]
operations = [
migrations.RunPython(add_default_enable, reverse_code=reverse_noop),
]

View File

@@ -0,0 +1,19 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('discussion_common', '0003_enable_forums'),
]
operations = [
migrations.AlterField(
model_name='forumsconfig',
name='connection_timeout',
field=models.FloatField(default=5.0, help_text=b'Seconds to wait when trying to connect to the comment service.'),
),
]

View File

@@ -0,0 +1,28 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
from opaque_keys.edx.django.models import CourseKeyField
class Migration(migrations.Migration):
dependencies = [
('discussion_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', 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')])),
],
options={
'db_table': 'django_comment_common_coursediscussionsettings',
},
),
]

View File

@@ -0,0 +1,21 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.12 on 2018-04-23 21:09
from __future__ import unicode_literals
from django.db import migrations
import jsonfield.fields
class Migration(migrations.Migration):
dependencies = [
('discussion_common', '0005_coursediscussionsettings'),
]
operations = [
migrations.AddField(
model_name='coursediscussionsettings',
name='discussions_id_map',
field=jsonfield.fields.JSONField(blank=True, help_text=b'Key/value store mapping discussion IDs to discussion XBlock usage keys.', null=True),
),
]

View File

@@ -0,0 +1,27 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.13 on 2018-06-13 12:10
from __future__ import unicode_literals
from django.db import migrations
import jsonfield.fields
import opaque_keys.edx.django.models
class Migration(migrations.Migration):
dependencies = [
('discussion_common', '0006_coursediscussionsettings_discussions_id_map'),
]
operations = [
migrations.CreateModel(
name='DiscussionsIdMapping',
fields=[
('course_id', opaque_keys.edx.django.models.CourseKeyField(db_index=True, max_length=255, primary_key=True, serialize=False)),
('mapping', jsonfield.fields.JSONField(help_text=b'Key/value store mapping discussion IDs to discussion XBlock usage keys.')),
],
options={
'db_table': 'django_comment_common_discussionsidmapping',
},
),
]

View File

@@ -0,0 +1,17 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('discussion_common', '0007_discussionsidmapping'),
]
operations = [
migrations.RunSQL(
'CREATE INDEX dcc_role_users_user_role_idx ON django_comment_client_role_users(user_id, role_id);'
),
]

View File

@@ -0,0 +1,292 @@
# pylint: disable=missing-docstring,unused-argument,model-missing-unicode
import json
import logging
from config_models.models import ConfigurationModel
from django.conf import settings
from django.contrib.auth.models import User
from django.db import models
from django.db.models.signals import post_save
from django.dispatch import receiver
from django.utils.translation import ugettext_noop
from jsonfield.fields import JSONField
from opaque_keys.edx.django.models import CourseKeyField
from six import text_type
from openedx.core.djangoapps.xmodule_django.models import NoneToEmptyManager
from student.models import CourseEnrollment
from student.roles import GlobalStaff
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.exceptions import ItemNotFoundError
FORUM_ROLE_ADMINISTRATOR = ugettext_noop('Administrator')
FORUM_ROLE_MODERATOR = ugettext_noop('Moderator')
FORUM_ROLE_GROUP_MODERATOR = ugettext_noop('Group Moderator')
FORUM_ROLE_COMMUNITY_TA = ugettext_noop('Community TA')
FORUM_ROLE_STUDENT = ugettext_noop('Student')
@receiver(post_save, sender=CourseEnrollment)
def assign_default_role_on_enrollment(sender, instance, **kwargs):
"""
Assign forum default role 'Student'
"""
# The code below would remove all forum Roles from a user when they unenroll
# from a course. Concerns were raised that it should apply only to students,
# or that even the history of student roles is important for research
# purposes. Since this was new functionality being added in this release,
# I'm just going to comment it out for now and let the forums team deal with
# implementing the right behavior.
#
# # We've unenrolled the student, so remove all roles for this course
# if not instance.is_active:
# course_roles = list(Role.objects.filter(course_id=instance.course_id))
# instance.user.roles.remove(*course_roles)
# return
# We've enrolled the student, so make sure they have the Student role
assign_default_role(instance.course_id, instance.user)
def assign_default_role(course_id, user):
"""
Assign forum default role 'Student' to user
"""
assign_role(course_id, user, FORUM_ROLE_STUDENT)
def assign_role(course_id, user, rolename):
"""
Assign forum role `rolename` to user
"""
role, created = Role.objects.get_or_create(course_id=course_id, name=rolename)
if created:
logging.info(u"EDUCATOR-1635: Created role {} for course {}".format(role, course_id))
user.roles.add(role)
class Role(models.Model):
"""
Maps users to django_comment_client roles for a given course
.. no_pii:
"""
objects = NoneToEmptyManager()
name = models.CharField(max_length=30, null=False, blank=False)
users = models.ManyToManyField(User, related_name="roles")
course_id = CourseKeyField(max_length=255, blank=True, db_index=True)
class Meta(object):
# use existing table that was originally created from lms.djangoapps.discussion.django_comment_client app
db_table = 'django_comment_client_role'
def __unicode__(self):
return self.name + " for " + (text_type(self.course_id) if self.course_id else "all courses")
# TODO the name of this method is a little bit confusing,
# since it's one-off and doesn't handle inheritance later
def inherit_permissions(self, role):
"""
Make this role inherit permissions from the given role.
Permissions are only added, not removed. Does not handle inheritance.
"""
if role.course_id and role.course_id != self.course_id:
logging.warning(
u"%s cannot inherit permissions from %s due to course_id inconsistency",
self,
role,
)
for per in role.permissions.all():
self.add_permission(per)
def add_permission(self, permission):
self.permissions.add(Permission.objects.get_or_create(name=permission)[0])
def has_permission(self, permission):
"""
Returns True if this role has the given permission, False otherwise.
"""
course = modulestore().get_course(self.course_id)
if course is None:
raise ItemNotFoundError(self.course_id)
if permission_blacked_out(course, {self.name}, permission):
return False
return self.permissions.filter(name=permission).exists()
@staticmethod
def user_has_role_for_course(user, course_id, role_names):
"""
Returns True if the user has one of the given roles for the given course
"""
return Role.objects.filter(course_id=course_id, name__in=role_names, users=user).exists()
class Permission(models.Model):
"""
Permissions for django_comment_client
.. no_pii:
"""
name = models.CharField(max_length=30, null=False, blank=False, primary_key=True)
roles = models.ManyToManyField(Role, related_name="permissions")
class Meta(object):
# use existing table that was originally created from lms.djangoapps.discussion.django_comment_client app
db_table = 'django_comment_client_permission'
def __unicode__(self):
return self.name
def permission_blacked_out(course, role_names, permission_name):
"""
Returns true if a user in course with the given roles would have permission_name blacked out.
This will return true if it is a permission that the user might have normally had for the course, but does not have
right this moment because we are in a discussion blackout period (as defined by the settings on the course module).
Namely, they can still view, but they can't edit, update, or create anything. This only applies to students, as
moderators of any kind still have posting privileges during discussion blackouts.
"""
return (
not course.forum_posts_allowed and
role_names == {FORUM_ROLE_STUDENT} and
any([permission_name.startswith(prefix) for prefix in ['edit', 'update', 'create']])
)
def all_permissions_for_user_in_course(user, course_id):
"""
Returns all the permissions the user has in the given course.
"""
if not user.is_authenticated:
return {}
course = modulestore().get_course(course_id)
if course is None:
raise ItemNotFoundError(course_id)
roles = Role.objects.filter(users=user, course_id=course_id)
role_names = {role.name for role in roles}
permission_names = set()
for role in roles:
# Intentional n+1 query pattern to get permissions for each role because
# Aurora's query optimizer can't handle the join proplerly on 30M+ row
# tables (EDUCATOR-3374). Fortunately, there are very few forum roles.
for permission in role.permissions.all():
if not permission_blacked_out(course, role_names, permission.name):
permission_names.add(permission.name)
# Prevent a circular import
from openedx.core.djangoapps.discussion_common.utils import GLOBAL_STAFF_ROLE_PERMISSIONS
if GlobalStaff().has_user(user):
for permission in GLOBAL_STAFF_ROLE_PERMISSIONS:
permission_names.add(permission)
return permission_names
class ForumsConfig(ConfigurationModel):
"""
Config for the connection to the cs_comments_service forums backend.
.. no_pii:
"""
connection_timeout = models.FloatField(
default=5.0,
help_text="Seconds to wait when trying to connect to the comment service.",
)
class Meta(ConfigurationModel.Meta):
# use existing table that was originally created from django_comment_common app
db_table = 'django_comment_common_forumsconfig'
@property
def api_key(self):
"""The API key used to authenticate to the comments service."""
return getattr(settings, "COMMENTS_SERVICE_KEY", None)
def __unicode__(self):
"""
Simple representation so the admin screen looks less ugly.
"""
return u"ForumsConfig: timeout={}".format(self.connection_timeout)
class CourseDiscussionSettings(models.Model):
"""
Settings for course discussions
.. no_pii:
"""
course_id = CourseKeyField(
unique=True,
max_length=255,
db_index=True,
help_text="Which course are these settings associated with?",
)
discussions_id_map = JSONField(
null=True,
blank=True,
help_text="Key/value store mapping discussion IDs to discussion XBlock usage keys.",
)
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)
class Meta(object):
# use existing table that was originally created from django_comment_common app
db_table = 'django_comment_common_coursediscussionsettings'
@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)
class DiscussionsIdMapping(models.Model):
"""
This model is a performance optimization, updated on course publish.
.. no_pii:
"""
course_id = CourseKeyField(db_index=True, primary_key=True, max_length=255)
mapping = JSONField(
help_text="Key/value store mapping discussion IDs to discussion XBlock usage keys.",
)
class Meta(object):
# use existing table that was originally created from django_comment_common app
db_table = 'django_comment_common_discussionsidmapping'
@classmethod
def update_mapping(cls, course_key, discussions_id_map):
"""Update the mapping of discussions IDs to XBlock usage key strings."""
mapping_entry, created = cls.objects.get_or_create(
course_id=course_key,
defaults={
'mapping': discussions_id_map,
},
)
if not created:
mapping_entry.mapping = discussions_id_map
mapping_entry.save()

View File

@@ -0,0 +1,16 @@
# pylint: disable=invalid-name
"""Signals related to the comments service."""
from django.dispatch import Signal
thread_created = Signal(providing_args=['user', 'post'])
thread_edited = Signal(providing_args=['user', 'post'])
thread_voted = Signal(providing_args=['user', 'post'])
thread_deleted = Signal(providing_args=['user', 'post'])
thread_followed = Signal(providing_args=['user', 'post'])
thread_unfollowed = Signal(providing_args=['user', 'post'])
comment_created = Signal(providing_args=['user', 'post'])
comment_edited = Signal(providing_args=['user', 'post'])
comment_voted = Signal(providing_args=['user', 'post'])
comment_deleted = Signal(providing_args=['user', 'post'])
comment_endorsed = Signal(providing_args=['user', 'post'])

View File

@@ -0,0 +1,136 @@
# pylint: disable=missing-docstring
from django.test import TestCase
from opaque_keys.edx.locator import CourseLocator
from six import text_type
from openedx.core.djangoapps.course_groups.cohorts import CourseCohortsSettings
from openedx.core.djangoapps.discussion_common.models import Role, CourseDiscussionSettings
from openedx.core.djangoapps.discussion_common.utils import (
get_course_discussion_settings, set_course_discussion_settings,
)
from student.models import CourseEnrollment, User
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
class RoleAssignmentTest(TestCase):
"""
Basic checks to make sure our Roles get assigned and unassigned as students
are enrolled and unenrolled from a course.
"""
def setUp(self):
super(RoleAssignmentTest, self).setUp()
# Check a staff account because those used to get the Moderator role
self.staff_user = User.objects.create_user(
"patty",
"patty@fake.edx.org",
)
self.staff_user.is_staff = True
self.student_user = User.objects.create_user(
"hacky",
"hacky@fake.edx.org"
)
self.course_key = CourseLocator("edX", "Fake101", "2012")
CourseEnrollment.enroll(self.staff_user, self.course_key)
CourseEnrollment.enroll(self.student_user, self.course_key)
def test_enrollment_auto_role_creation(self):
student_role = Role.objects.get(
course_id=self.course_key,
name="Student"
)
self.assertEqual([student_role], list(self.staff_user.roles.all()))
self.assertEqual([student_role], list(self.student_user.roles.all()))
# The following was written on the assumption that unenrolling from a course
# should remove all forum Roles for that student for that course. This is
# not necessarily the case -- please see comments at the top of
# django_comment_client.models.assign_default_role(). Leaving it for the
# forums team to sort out.
#
# def test_unenrollment_auto_role_removal(self):
# another_student = User.objects.create_user("sol", "sol@fake.edx.org")
# CourseEnrollment.enroll(another_student, self.course_id)
#
# CourseEnrollment.unenroll(self.student_user, self.course_id)
# # Make sure we didn't delete the actual Role
# student_role = Role.objects.get(
# course_id=self.course_id,
# name="Student"
# )
# self.assertNotIn(student_role, self.student_user.roles.all())
# self.assertIn(student_role, another_student.roles.all())
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': basestring},
{'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(
text_type(value_error.exception),
exception_msg_template.format(field['name'], field['type'].__name__)
)

View File

@@ -0,0 +1,161 @@
# pylint: disable=missing-docstring
"""
Common comment client utility functions.
"""
from openedx.core.djangoapps.discussion_common.models import (
CourseDiscussionSettings,
FORUM_ROLE_ADMINISTRATOR,
FORUM_ROLE_COMMUNITY_TA,
FORUM_ROLE_GROUP_MODERATOR,
FORUM_ROLE_MODERATOR,
FORUM_ROLE_STUDENT,
Role
)
from openedx.core.djangoapps.course_groups.cohorts import get_legacy_discussion_settings
from openedx.core.lib.cache_utils import request_cached
class ThreadContext(object):
""" An enumeration that represents the context of a thread. Used primarily by the comments service. """
STANDALONE = 'standalone'
COURSE = 'course'
STUDENT_ROLE_PERMISSIONS = ["vote", "update_thread", "follow_thread", "unfollow_thread",
"update_comment", "create_sub_comment", "unvote", "create_thread",
"follow_commentable", "unfollow_commentable", "create_comment", ]
MODERATOR_ROLE_PERMISSIONS = ["edit_content", "delete_thread", "openclose_thread",
"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"]
GLOBAL_STAFF_ROLE_PERMISSIONS = ["see_all_cohorts"]
def _save_forum_role(course_key, name):
"""
Save and Update 'course_key' for all roles which are already created to keep course_id same
as actual passed course key
"""
role, created = Role.objects.get_or_create(name=name, course_id=course_key)
if created is False:
role.course_id = course_key
role.save()
return role
def seed_permissions_roles(course_key):
"""
Create and assign permissions for forum roles
"""
administrator_role = _save_forum_role(course_key, FORUM_ROLE_ADMINISTRATOR)
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)
student_role = _save_forum_role(course_key, FORUM_ROLE_STUDENT)
for per in STUDENT_ROLE_PERMISSIONS:
student_role.add_permission(per)
for per in MODERATOR_ROLE_PERMISSIONS:
moderator_role.add_permission(per)
for per in GROUP_MODERATOR_ROLE_PERMISSIONS:
group_moderator_role.add_permission(per)
for per in ADMINISTRATOR_ROLE_PERMISSIONS:
administrator_role.add_permission(per)
moderator_role.inherit_permissions(student_role)
group_moderator_role.inherit_permissions(student_role)
# For now, Community TA == Moderator, except for the styling.
community_ta_role.inherit_permissions(moderator_role)
administrator_role.inherit_permissions(moderator_role)
def are_permissions_roles_seeded(course_id):
"""
Returns whether the forums permissions for a course have been provisioned in
the database
"""
try:
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)
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)
except: # pylint: disable=bare-except
return False
for per in STUDENT_ROLE_PERMISSIONS:
if not student_role.has_permission(per):
return False
for per in MODERATOR_ROLE_PERMISSIONS + STUDENT_ROLE_PERMISSIONS:
if not moderator_role.has_permission(per):
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:
if not administrator_role.has_permission(per):
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': basestring,
'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(u"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

View File

@@ -19,7 +19,7 @@ from pytz import common_timezones_set, UTC
from six import text_type
from social_django.models import UserSocialAuth, Partial
from django_comment_common import models
from openedx.core.djangoapps.discussion_common import models
from openedx.core.djangoapps.site_configuration.helpers import get_value
from openedx.core.lib.api.test_utils import ApiTestCase, TEST_API_KEY
from openedx.core.lib.time_zone_utils import get_display_time_zone

View File

@@ -15,12 +15,13 @@ from rest_framework.views import APIView
from rest_framework.permissions import IsAuthenticated
from six import text_type
import accounts
from django_comment_common.models import Role
from edx_rest_framework_extensions.auth.session.authentication import SessionAuthenticationAllowInactiveUser
from opaque_keys import InvalidKeyError
from opaque_keys.edx import locator
from opaque_keys.edx.keys import CourseKey
from openedx.core.djangoapps.discussion_common.models import Role
from openedx.core.djangoapps.user_api import accounts
from openedx.core.djangoapps.user_api.accounts.api import check_account_exists
from openedx.core.djangoapps.user_api.api import (
RegistrationFormFactory,

View File

@@ -12,10 +12,10 @@ from django.http import HttpResponseForbidden
from django.shortcuts import redirect
from django.template.context_processors import csrf
from django.utils.translation import ugettext as _
from django_comment_common.models import assign_role
from lms.djangoapps.verify_student.models import ManualVerification
from opaque_keys.edx.locator import CourseLocator
from openedx.core.djangoapps.discussion_common.models import assign_role
from openedx.core.djangoapps.user_api.accounts.utils import generate_password
from openedx.features.course_experience import course_home_url_name
from student.forms import AccountCreationForm

View File

@@ -9,10 +9,10 @@ from django.test.client import Client
from mock import patch, Mock
from opaque_keys.edx.locator import CourseLocator
from django_comment_common.models import (
from openedx.core.djangoapps.discussion_common.models import (
Role, FORUM_ROLE_ADMINISTRATOR, FORUM_ROLE_MODERATOR, FORUM_ROLE_STUDENT
)
from django_comment_common.utils import seed_permissions_roles
from openedx.core.djangoapps.discussion_common.utils import seed_permissions_roles
from student.models import anonymous_id_for_user, CourseAccessRole, CourseEnrollment, UserProfile
from util.testing import UrlResetMixin

View File

@@ -16,12 +16,8 @@ from django.test.client import RequestFactory
from django.test.utils import override_settings
from django.contrib.auth.hashers import make_password
from django_comment_common.models import ForumsConfig
from lms.djangoapps.discussion.notification_prefs import NOTIFICATION_PREF_KEY
from openedx.core.djangoapps.user_authn.views.register import (
REGISTRATION_AFFILIATE_ID, REGISTRATION_UTM_CREATED_AT, REGISTRATION_UTM_PARAMETERS,
_skip_activation_email,
)
from openedx.core.djangoapps.discussion_common.models import ForumsConfig
from openedx.core.djangoapps.lang_pref import LANGUAGE_KEY
from openedx.core.djangoapps.site_configuration.tests.mixins import SiteMixin
from openedx.core.djangoapps.user_api.accounts import (
@@ -29,6 +25,10 @@ from openedx.core.djangoapps.user_api.accounts import (
)
from openedx.core.djangoapps.user_api.config.waffle import PREVENT_AUTH_USER_WRITES, waffle
from openedx.core.djangoapps.user_api.preferences.api import get_user_preference
from openedx.core.djangoapps.user_authn.views.register import (
REGISTRATION_AFFILIATE_ID, REGISTRATION_UTM_CREATED_AT, REGISTRATION_UTM_PARAMETERS,
_skip_activation_email,
)
from student.models import UserAttribute
from student.tests.factories import UserFactory
from third_party_auth.tests import factories as third_party_auth_factory
@@ -768,9 +768,9 @@ class TestCreateAccountValidation(TestCase):
@mock.patch.dict("student.models.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
@mock.patch("django_comment_common.comment_client.User.base_url", TEST_CS_URL)
@mock.patch("openedx.core.djangoapps.discussion_common.comment_client.User.base_url", TEST_CS_URL)
@mock.patch(
"django_comment_common.comment_client.utils.requests.request",
"openedx.core.djangoapps.discussion_common.comment_client.utils.requests.request",
return_value=mock.Mock(status_code=200, text='{}')
)
class TestCreateCommentsServiceUser(TransactionTestCase):

View File

@@ -3,8 +3,8 @@
from datetime import datetime
from pytz import UTC
from django_comment_common.models import Role
from django_comment_common.utils import seed_permissions_roles
from openedx.core.djangoapps.discussion_common.models import Role
from openedx.core.djangoapps.discussion_common.utils import seed_permissions_roles
from openedx.core.djangoapps.course_groups.models import CourseUserGroupPartitionGroup
from openedx.core.djangoapps.course_groups.tests.helpers import CohortFactory
from openedx.core.djangoapps.user_api.tests.factories import UserCourseTagFactory