Move django_comment_common to openedx/core/djangoapps/discussion_common
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
1
openedx/core/djangoapps/discussion_common/README.rst
Normal file
1
openedx/core/djangoapps/discussion_common/README.rst
Normal file
@@ -0,0 +1 @@
|
||||
See ``lms/djangoapps/discussion/README.rst``
|
||||
8
openedx/core/djangoapps/discussion_common/admin.py
Normal file
8
openedx/core/djangoapps/discussion_common/admin.py
Normal 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)
|
||||
@@ -0,0 +1 @@
|
||||
See ``lms/djangoapps/discussion/README.rst``
|
||||
@@ -0,0 +1,6 @@
|
||||
# pylint: disable=missing-docstring,wildcard-import
|
||||
from .comment_client import *
|
||||
from .utils import (
|
||||
CommentClientError, CommentClientRequestError,
|
||||
CommentClient500Error, CommentClientMaintenanceError
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -0,0 +1 @@
|
||||
requests
|
||||
@@ -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'
|
||||
@@ -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)
|
||||
240
openedx/core/djangoapps/discussion_common/comment_client/user.py
Normal file
240
openedx/core/djangoapps/discussion_common/comment_client/user.py
Normal 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)
|
||||
@@ -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)
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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',
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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),
|
||||
]
|
||||
@@ -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.'),
|
||||
),
|
||||
]
|
||||
@@ -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',
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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',
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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);'
|
||||
),
|
||||
]
|
||||
292
openedx/core/djangoapps/discussion_common/models.py
Normal file
292
openedx/core/djangoapps/discussion_common/models.py
Normal 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()
|
||||
16
openedx/core/djangoapps/discussion_common/signals.py
Normal file
16
openedx/core/djangoapps/discussion_common/signals.py
Normal 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'])
|
||||
136
openedx/core/djangoapps/discussion_common/tests.py
Normal file
136
openedx/core/djangoapps/discussion_common/tests.py
Normal 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__)
|
||||
)
|
||||
161
openedx/core/djangoapps/discussion_common/utils.py
Normal file
161
openedx/core/djangoapps/discussion_common/utils.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user