Commit b9e5b0d5 by Matt Drayer Committed by Jonathan Piacenti

mattdrayer/progress-generator-no-zero-entries: Only generate when completions > 0

parent 7be6d73f
...@@ -14,6 +14,7 @@ from opaque_keys.edx.locations import SlashSeparatedCourseKey, AssetLocation ...@@ -14,6 +14,7 @@ from opaque_keys.edx.locations import SlashSeparatedCourseKey, AssetLocation
from contentstore.utils import reverse_url # pylint: disable=import-error from contentstore.utils import reverse_url # pylint: disable=import-error
from student.models import Registration # pylint: disable=import-error from student.models import Registration # pylint: disable=import-error
from xmodule.modulestore.split_mongo.split import SplitMongoModuleStore from xmodule.modulestore.split_mongo.split import SplitMongoModuleStore
from lms import startup
from xmodule.contentstore.django import contentstore from xmodule.contentstore.django import contentstore
from xmodule.modulestore import ModuleStoreEnum from xmodule.modulestore import ModuleStoreEnum
from xmodule.modulestore.inheritance import own_metadata from xmodule.modulestore.inheritance import own_metadata
...@@ -89,6 +90,9 @@ class CourseTestCase(ProceduralCourseTestMixin, ModuleStoreTestCase): ...@@ -89,6 +90,9 @@ class CourseTestCase(ProceduralCourseTestMixin, ModuleStoreTestCase):
self.course = CourseFactory.create() self.course = CourseFactory.create()
# initialize the Notification subsystem
startup.startup_notification_subsystem()
def create_non_staff_authed_user_client(self, authenticate=True): def create_non_staff_authed_user_client(self, authenticate=True):
""" """
Create a non-staff user, log them in (if authenticate=True), and return the client, user to use for testing. Create a non-staff user, log them in (if authenticate=True), and return the client, user to use for testing.
......
...@@ -19,6 +19,8 @@ from util.json_request import JsonResponse, JsonResponseBadRequest ...@@ -19,6 +19,8 @@ from util.json_request import JsonResponse, JsonResponseBadRequest
from util.date_utils import get_default_time_display from util.date_utils import get_default_time_display
from edxmako.shortcuts import render_to_response from edxmako.shortcuts import render_to_response
from bs4 import BeautifulSoup
from xmodule.course_module import DEFAULT_START_DATE from xmodule.course_module import DEFAULT_START_DATE
from xmodule.error_module import ErrorDescriptor from xmodule.error_module import ErrorDescriptor
from xmodule.modulestore.django import modulestore from xmodule.modulestore.django import modulestore
...@@ -33,6 +35,7 @@ from xmodule.modulestore.exceptions import ItemNotFoundError, DuplicateCourseErr ...@@ -33,6 +35,7 @@ from xmodule.modulestore.exceptions import ItemNotFoundError, DuplicateCourseErr
from opaque_keys import InvalidKeyError from opaque_keys import InvalidKeyError
from opaque_keys.edx.locations import Location from opaque_keys.edx.locations import Location
from opaque_keys.edx.keys import CourseKey from opaque_keys.edx.keys import CourseKey
from util.html import strip_tags
from django.views.decorators.csrf import ensure_csrf_cookie from django.views.decorators.csrf import ensure_csrf_cookie
from contentstore.course_info_model import get_course_updates, update_course_updates, delete_course_update from contentstore.course_info_model import get_course_updates, update_course_updates, delete_course_update
...@@ -102,6 +105,11 @@ __all__ = ['course_info_handler', 'course_handler', 'course_listing', ...@@ -102,6 +105,11 @@ __all__ = ['course_info_handler', 'course_handler', 'course_listing',
'textbooks_list_handler', 'textbooks_detail_handler', 'textbooks_list_handler', 'textbooks_detail_handler',
'group_configurations_list_handler', 'group_configurations_detail_handler'] 'group_configurations_list_handler', 'group_configurations_detail_handler']
log = logging.getLogger(__name__)
from student.tasks import publish_course_notifications_task
from edx_notifications.data import NotificationMessage
from edx_notifications.lib.publisher import get_notification_type
class AccessListFallback(Exception): class AccessListFallback(Exception):
""" """
...@@ -869,7 +877,95 @@ def course_info_update_handler(request, course_key_string, provided_id=None): ...@@ -869,7 +877,95 @@ def course_info_update_handler(request, course_key_string, provided_id=None):
# can be either and sometimes django is rewriting one to the other: # can be either and sometimes django is rewriting one to the other:
elif request.method in ('POST', 'PUT'): elif request.method in ('POST', 'PUT'):
try: try:
return JsonResponse(update_course_updates(usage_key, request.json, provided_id, request.user)) response = JsonResponse(update_course_updates(usage_key, request.json, provided_id, request.user))
if settings.FEATURES.get('ENABLE_NOTIFICATIONS', False) and request.method == 'POST':
# only send bulk notifications to users when there is
# new update/announcement in the course.
try:
# get the notification type.
notification_type = get_notification_type(u'open-edx.studio.announcements.new-announcement')
course = modulestore().get_course(course_key, depth=0)
excerpt = strip_tags(request.json['content'])
excerpt = excerpt.strip()
excerpt = excerpt.replace('\n','').replace('\r','')
announcement_date = request.json['date']
title = None
try:
# we have to try to parse out a 'title' which
# will be determine through a HTML convention of
# labeling a tag will class 'announcement-title'
parsed_html = BeautifulSoup(request.json['content'])
if not parsed_html.body:
# maybe doesn't have <body> outer tags
parsed_html = BeautifulSoup('<body>{}</body>'.format(request.json['content']))
if parsed_html.body:
title_tag_name = getattr(settings, 'NOTIFICATIONS_ANNOUNCEMENT_TITLE_TAG', 'p')
title_tag_class = getattr(settings, 'NOTIFICATIONS_ACCOUNCEMENT_TITLE_CLASS', 'announcement-title')
title_tag = parsed_html.body.find(title_tag_name, attrs={'class': title_tag_class})
if title_tag:
title = title_tag.text
if title:
# remove the title from the excerpt so that it doesn't
# count towards the length limit
excerpt = excerpt.replace(title, '')
except Exception, ex:
log.exception(ex)
if not title:
# default title, if we could not match the pattern
title = _('Announcement on {date}').format(date=announcement_date)
# now we have to truncate the notification excerpt to me
# some max length and append an ellipsis
max_len = getattr(settings, 'NOTIFICATIONS_MAX_EXCERPT_LEN', 65)
if len(excerpt) > max_len:
excerpt = "{}...".format(excerpt[:max_len])
notification_msg = NotificationMessage(
msg_type=notification_type,
namespace=unicode(course_key),
payload={
'_schema_version': '1',
'course_name': course.display_name,
'excerpt': excerpt,
'announcement_date': announcement_date,
'title': title,
}
)
# add in all the context parameters we'll need to
# generate a URL back to the website that will
# present the new course announcement
#
# IMPORTANT: This can be changed to msg.add_click_link() if we
# have a particular URL that we wish to use. In the initial use case,
# we need to make the link point to a different front end so
# we have to resolve the link when we dispatch the Message
#
notification_msg.add_click_link_params({
'course_id': unicode(course_key),
})
# Send the notification_msg to the Celery task
if settings.FEATURES.get('ENABLE_NOTIFICATIONS_CELERY', False):
publish_course_notifications_task.delay(course_key, notification_msg)
else:
publish_course_notifications_task(course_key, notification_msg)
except Exception, ex:
# Notifications aren't considered critical, so it's OK to fail
# log and then continue
log.exception(ex)
return response
except: except:
return HttpResponseBadRequest( return HttpResponseBadRequest(
"Failed to save", "Failed to save",
......
...@@ -55,6 +55,8 @@ from cms.lib.xblock.authoring_mixin import VISIBILITY_VIEW ...@@ -55,6 +55,8 @@ from cms.lib.xblock.authoring_mixin import VISIBILITY_VIEW
__all__ = [ __all__ = [
'orphan_handler', 'xblock_handler', 'xblock_view_handler', 'xblock_outline_handler', 'xblock_container_handler' 'orphan_handler', 'xblock_handler', 'xblock_view_handler', 'xblock_outline_handler', 'xblock_container_handler'
] ]
from .preview import get_available_xblock_services
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
...@@ -156,6 +158,9 @@ def xblock_handler(request, usage_key_string): ...@@ -156,6 +158,9 @@ def xblock_handler(request, usage_key_string):
return HttpResponse(status=406) return HttpResponse(status=406)
elif request.method == 'DELETE': elif request.method == 'DELETE':
# let xblock (and children) know they are about to be deleted
_notify_xblocks_by_usage_key(request.user, usage_key, usage_key.course_key, 'on_before_studio_delete')
_delete_item(usage_key, request.user) _delete_item(usage_key, request.user)
return JsonResponse() return JsonResponse()
else: # Since we have a usage_key, we are updating an existing xblock. else: # Since we have a usage_key, we are updating an existing xblock.
...@@ -516,8 +521,50 @@ def _save_xblock(user, xblock, data=None, children_strings=None, metadata=None, ...@@ -516,8 +521,50 @@ def _save_xblock(user, xblock, data=None, children_strings=None, metadata=None,
if publish == 'make_public': if publish == 'make_public':
modulestore().publish(xblock.location, user.id) modulestore().publish(xblock.location, user.id)
# Note that children aren't being returned until we have a use case. # notify the xblocks that they have been published
return JsonResponse(result, encoder=EdxJSONEncoder) _notify_xblocks(xblock, xblock.location.course_key, 'on_studio_published')
# Note that children aren't being returned until we have a use case.
return JsonResponse(result, encoder=EdxJSONEncoder)
def _notify_xblocks(xblock, course_id, callback_name):
"""
Notify xblock (and children) of a lifecycle activity, which basically looks to see if an xblock
has implemented the 'callback_name'
"""
def _notify_xblock_subtree(parent_xblock, course_id, callback_name, xblock_services):
"""
Recusively notifies all children, depth-first
"""
for child in parent_xblock.get_children():
_notify_xblock_subtree(child, course_id, callback_name, xblock_services)
# then notify parent, if they have implemented the callback_name
# function
if hasattr(parent_xblock, callback_name):
log.info('Notifying xblock {loc} on {name}...'.format(loc=parent_xblock.location, name=callback_name))
func = getattr(parent_xblock, callback_name)
func(course_id, xblock_services)
xblock_services = get_available_xblock_services()
_notify_xblock_subtree(xblock, course_id, callback_name, xblock_services)
def _notify_xblocks_by_usage_key(user, usage_key, course_id, callback_name):
"""
Helper function when we only have the xblock location available
"""
store = modulestore()
try:
xblock = store.get_item(usage_key, depth=None)
_notify_xblocks(xblock, course_id, callback_name)
except ItemNotFoundError:
log.error("_notify_xblocks_by_usage_key(): Can't find item at usage_key {}".format(usage_key))
except InvalidLocationError:
log.error("_notify_xblocks_by_usage_key(): Can't find item by location {}.".format(usage_key))
@login_required @login_required
......
...@@ -27,6 +27,7 @@ from xblock.exceptions import NoSuchHandlerError ...@@ -27,6 +27,7 @@ from xblock.exceptions import NoSuchHandlerError
from xblock.fragment import Fragment from xblock.fragment import Fragment
from student.auth import has_studio_read_access, has_studio_write_access from student.auth import has_studio_read_access, has_studio_write_access
from xblock_django.user_service import DjangoXBlockUserService from xblock_django.user_service import DjangoXBlockUserService
from xmodule.services import SettingsService, NotificationsService, CoursewareParentInfoService
from lms.djangoapps.lms_xblock.field_data import LmsFieldData from lms.djangoapps.lms_xblock.field_data import LmsFieldData
from cms.lib.xblock.field_data import CmsFieldData from cms.lib.xblock.field_data import CmsFieldData
...@@ -143,6 +144,29 @@ class StudioPermissionsService(object): ...@@ -143,6 +144,29 @@ class StudioPermissionsService(object):
return has_studio_write_access(self._request.user, course_key) return has_studio_write_access(self._request.user, course_key)
def get_available_xblock_services(request=None, field_data=None):
"""
Returns a dict of available services for xBlocks
"""
services = {
"i18n": ModuleI18nService(),
"settings": SettingsService(),
"courseware_parent_info": CoursewareParentInfoService(),
"library_tools": LibraryToolsService(modulestore()),
}
if request:
services['user'] = DjangoXBlockUserService(request.user)
if field_data:
services['field-data'] = field_data
if settings.FEATURES.get('ENABLE_NOTIFICATIONS', False):
services.update({
"notifications": NotificationsService()
})
return services
def _preview_module_system(request, descriptor, field_data): def _preview_module_system(request, descriptor, field_data):
""" """
Returns a ModuleSystem for the specified descriptor that is specialized for Returns a ModuleSystem for the specified descriptor that is specialized for
...@@ -177,6 +201,8 @@ def _preview_module_system(request, descriptor, field_data): ...@@ -177,6 +201,8 @@ def _preview_module_system(request, descriptor, field_data):
descriptor.runtime._services['studio_user_permissions'] = StudioPermissionsService(request) # pylint: disable=protected-access descriptor.runtime._services['studio_user_permissions'] = StudioPermissionsService(request) # pylint: disable=protected-access
services = get_available_xblock_services(request, field_data)
return PreviewModuleSystem( return PreviewModuleSystem(
static_url=settings.STATIC_URL, static_url=settings.STATIC_URL,
# TODO (cpennington): Do we want to track how instructors are using the preview problems? # TODO (cpennington): Do we want to track how instructors are using the preview problems?
...@@ -198,14 +224,8 @@ def _preview_module_system(request, descriptor, field_data): ...@@ -198,14 +224,8 @@ def _preview_module_system(request, descriptor, field_data):
error_descriptor_class=ErrorDescriptor, error_descriptor_class=ErrorDescriptor,
get_user_role=lambda: get_user_role(request.user, course_id), get_user_role=lambda: get_user_role(request.user, course_id),
# Get the raw DescriptorSystem, not the CombinedSystem # Get the raw DescriptorSystem, not the CombinedSystem
descriptor_runtime=descriptor._runtime, # pylint: disable=protected-access descriptor_runtime=descriptor.runtime,
services={ services=services,
"i18n": ModuleI18nService(),
"field-data": field_data,
"library_tools": LibraryToolsService(modulestore()),
"settings": SettingsService(),
"user": DjangoXBlockUserService(request.user),
},
) )
......
...@@ -11,9 +11,14 @@ from contentstore.utils import reverse_course_url, reverse_usage_url ...@@ -11,9 +11,14 @@ from contentstore.utils import reverse_course_url, reverse_usage_url
from opaque_keys.edx.keys import UsageKey from opaque_keys.edx.keys import UsageKey
from xmodule.modulestore.django import modulestore from xmodule.modulestore.django import modulestore
from student.tests.factories import CourseEnrollmentFactory, UserFactory
from edx_notifications.lib.consumer import get_notifications_count_for_user
from mock import patch
class CourseUpdateTest(CourseTestCase): class CourseUpdateTest(CourseTestCase):
"""
Test for course updates
"""
def create_update_url(self, provided_id=None, course_key=None): def create_update_url(self, provided_id=None, course_key=None):
if course_key is None: if course_key is None:
course_key = self.course.id course_key = self.course.id
...@@ -125,6 +130,29 @@ class CourseUpdateTest(CourseTestCase): ...@@ -125,6 +130,29 @@ class CourseUpdateTest(CourseTestCase):
payload = json.loads(resp.content) payload = json.loads(resp.content)
self.assertTrue(len(payload) == before_delete - 1) self.assertTrue(len(payload) == before_delete - 1)
@patch.dict("django.conf.settings.FEATURES", {"ENABLE_NOTIFICATIONS": True})
def test_notifications_enabled_when_new_updates_in_course(self):
# create new users and enroll them in the course.
test_user_1 = UserFactory.create(password='test_pass')
CourseEnrollmentFactory(user=test_user_1, course_id=self.course.id)
test_user_2 = UserFactory.create(password='test_pass')
CourseEnrollmentFactory(user=test_user_2, course_id=self.course.id)
content = 'Test update'
payload = {'content': content, 'date': 'Feb 19, 2015'}
url = self.create_update_url()
resp = self.client.ajax_post(
url, payload, REQUEST_METHOD="POST"
)
self.assertEqual(resp.status_code, 200)
self.assertHTMLEqual(content, json.loads(resp.content)['content'])
# now the enrolled users should get notification about the
# course update where they are enrolled as student.
self.assertTrue(get_notifications_count_for_user(test_user_1.id), 1)
self.assertTrue(get_notifications_count_for_user(test_user_2.id), 1)
def test_course_updates_compatibility(self): def test_course_updates_compatibility(self):
''' '''
Test that course updates doesn't break on old data (content in 'data' field). Test that course updates doesn't break on old data (content in 'data' field).
......
...@@ -349,3 +349,13 @@ if FEATURES['ENABLE_COURSEWARE_INDEX'] or FEATURES['ENABLE_LIBRARY_INDEX']: ...@@ -349,3 +349,13 @@ if FEATURES['ENABLE_COURSEWARE_INDEX'] or FEATURES['ENABLE_LIBRARY_INDEX']:
XBLOCK_SETTINGS = ENV_TOKENS.get('XBLOCK_SETTINGS', {}) XBLOCK_SETTINGS = ENV_TOKENS.get('XBLOCK_SETTINGS', {})
XBLOCK_SETTINGS.setdefault("VideoDescriptor", {})["licensing_enabled"] = FEATURES.get("LICENSING", False) XBLOCK_SETTINGS.setdefault("VideoDescriptor", {})["licensing_enabled"] = FEATURES.get("LICENSING", False)
XBLOCK_SETTINGS.setdefault("VideoModule", {})['YOUTUBE_API_KEY'] = AUTH_TOKENS.get('YOUTUBE_API_KEY', YOUTUBE_API_KEY) XBLOCK_SETTINGS.setdefault("VideoModule", {})['YOUTUBE_API_KEY'] = AUTH_TOKENS.get('YOUTUBE_API_KEY', YOUTUBE_API_KEY)
##### EDX-NOTIFICATIONS ######
NOTIFICATION_CLICK_LINK_URL_MAPS = ENV_TOKENS.get('NOTIFICATION_CLICK_LINK_URL_MAPS', NOTIFICATION_CLICK_LINK_URL_MAPS)
NOTIFICATION_STORE_PROVIDER = ENV_TOKENS.get('NOTIFICATION_STORE_PROVIDER', NOTIFICATION_STORE_PROVIDER)
NOTIFICATION_CHANNEL_PROVIDERS = ENV_TOKENS.get('NOTIFICATION_CHANNEL_PROVIDERS', NOTIFICATION_CHANNEL_PROVIDERS)
NOTIFICATION_CHANNEL_PROVIDER_TYPE_MAPS = ENV_TOKENS.get(
'NOTIFICATION_CHANNEL_PROVIDER_TYPE_MAPS',
NOTIFICATION_CHANNEL_PROVIDER_TYPE_MAPS
)
NOTIFICATION_MAX_LIST_SIZE = ENV_TOKENS.get('NOTIFICATION_MAX_LIST_SIZE', NOTIFICATION_MAX_LIST_SIZE)
...@@ -181,6 +181,15 @@ FEATURES = { ...@@ -181,6 +181,15 @@ FEATURES = {
# Can the visibility of the discussion tab be configured on a per-course basis? # Can the visibility of the discussion tab be configured on a per-course basis?
'ALLOW_HIDING_DISCUSSION_TAB': False, 'ALLOW_HIDING_DISCUSSION_TAB': False,
# Modulestore to use for new courses
'DEFAULT_STORE_FOR_NEW_COURSE': 'mongo',
# edx-notifications subsystem
'ENABLE_NOTIFICATIONS': False,
# Whether edx-notifications should use Celery for bulk operations
'ENABLE_NOTIFICATIONS_CELERY': False,
} }
ENABLE_JASMINE = False ENABLE_JASMINE = False
...@@ -1023,3 +1032,74 @@ CREDIT_PROVIDER_TIMESTAMP_EXPIRATION = 15 * 60 ...@@ -1023,3 +1032,74 @@ CREDIT_PROVIDER_TIMESTAMP_EXPIRATION = 15 * 60
################################ Deprecated Blocks Info ################################ ################################ Deprecated Blocks Info ################################
DEPRECATED_BLOCK_TYPES = ['peergrading', 'combinedopenended'] DEPRECATED_BLOCK_TYPES = ['peergrading', 'combinedopenended']
################################### EDX-NOTIFICATIONS SUBSYSTEM ######################################
INSTALLED_APPS += (
'edx_notifications',
'edx_notifications.server.web',
)
TEMPLATE_LOADERS += (
'django.template.loaders.filesystem.Loader',
'django.template.loaders.app_directories.Loader',
)
NOTIFICATION_STORE_PROVIDER = {
"class": "edx_notifications.stores.sql.store_provider.SQLNotificationStoreProvider",
"options": {
}
}
if not 'SOUTH_MIGRATION_MODULES' in vars() and not 'SOUTH_MIGRATION_MODULES' in globals():
SOUTH_MIGRATION_MODULES = {}
SOUTH_MIGRATION_MODULES.update({
'edx_notifications': 'edx_notifications.stores.sql.migrations',
})
# to prevent run-away queries from happening
NOTIFICATION_MAX_LIST_SIZE = 100
#
# Various mapping tables which is used by the MsgTypeToUrlLinkResolver
# to map a notification type to a statically defined URL path
#
# NOTE: NOTIFICATION_CLICK_LINK_URL_MAPS will usually get read in by the *.envs.json file
#
NOTIFICATION_CLICK_LINK_URL_MAPS = {
'open-edx.studio.announcements.*': '/courses/{course_id}/announcements',
'open-edx.lms.leaderboard.*': '/courses/{course_id}/cohort',
'open-edx.lms.discussions.*': '/courses/{course_id}/discussion/{commentable_id}/threads/{thread_id}',
'open-edx.xblock.group-project.*': '/courses/{course_id}/group_work?seqid={activity_location}',
}
# list all known channel providers
NOTIFICATION_CHANNEL_PROVIDERS = {
'durable': {
'class': 'edx_notifications.channels.durable.BaseDurableNotificationChannel',
'options': {
# list out all link resolvers
'link_resolvers': {
# right now the only defined resolver is 'type_to_url', which
# attempts to look up the msg type (key) via
# matching on the value
'msg_type_to_url': {
'class': 'edx_notifications.channels.link_resolvers.MsgTypeToUrlLinkResolver',
'config': {
'_click_link': NOTIFICATION_CLICK_LINK_URL_MAPS,
}
}
}
}
},
'null': {
'class': 'edx_notifications.channels.null.NullNotificationChannel',
'options': {}
}
}
# list all of the mappings of notification types to channel
NOTIFICATION_CHANNEL_PROVIDER_TYPE_MAPS = {
'*': 'durable', # default global mapping
}
...@@ -2,14 +2,24 @@ ...@@ -2,14 +2,24 @@
Module with code executed during Studio startup Module with code executed during Studio startup
""" """
import logging
from django.conf import settings from django.conf import settings
# Force settings to run so that the python path is modified # Force settings to run so that the python path is modified
settings.INSTALLED_APPS # pylint: disable=pointless-statement settings.INSTALLED_APPS # pylint: disable=pointless-statement
from openedx.core.lib.django_startup import autostartup from openedx.core.lib.django_startup import autostartup
from edx_notifications import startup
from monkey_patch import django_utils_translation from monkey_patch import django_utils_translation
from course_groups.scope_resolver import CourseGroupScopeResolver
from student.scope_resolver import CourseEnrollmentsScopeResolver, StudentEmailScopeResolver
from projects.scope_resolver import GroupProjectParticipantsScopeResolver
from edx_notifications.scopes import register_user_scope_resolver
log = logging.getLogger(__name__)
def run(): def run():
""" """
...@@ -23,6 +33,8 @@ def run(): ...@@ -23,6 +33,8 @@ def run():
if settings.FEATURES.get('USE_CUSTOM_THEME', False): if settings.FEATURES.get('USE_CUSTOM_THEME', False):
enable_theme() enable_theme()
if settings.FEATURES.get('ENABLE_NOTIFICATIONS', False):
startup_notification_subsystem()
def add_mimetypes(): def add_mimetypes():
...@@ -68,3 +80,29 @@ def enable_theme(): ...@@ -68,3 +80,29 @@ def enable_theme():
settings.STATICFILES_DIRS.append( settings.STATICFILES_DIRS.append(
(u'themes/{}'.format(settings.THEME_NAME), theme_root / 'static') (u'themes/{}'.format(settings.THEME_NAME), theme_root / 'static')
) )
def startup_notification_subsystem():
"""
Initialize the Notification subsystem
"""
try:
startup.initialize()
# register the two scope resolvers that the LMS will be providing
# to edx-notifications
register_user_scope_resolver('course_enrollments', CourseEnrollmentsScopeResolver())
register_user_scope_resolver('course_group', CourseGroupScopeResolver())
register_user_scope_resolver('group_project_participants', GroupProjectParticipantsScopeResolver())
register_user_scope_resolver('group_project_workgroup', GroupProjectParticipantsScopeResolver())
register_user_scope_resolver('student_email_resolver', StudentEmailScopeResolver())
except Exception, ex:
# Note this will fail when we try to run migrations as manage.py will call startup.py
# and startup.initialze() will try to manipulate some database tables.
# We need to research how to identify when we are being started up as part of
# a migration script
log.error(
'There was a problem initializing notifications subsystem. '
'This could be because the database tables have not yet been created and '
'./manage.py lms syncdb needs to run setup.py. Error was "{err_msg}". Continuing...'.format(err_msg=str(ex))
)
...@@ -8,6 +8,7 @@ from abc import ABCMeta, abstractmethod ...@@ -8,6 +8,7 @@ from abc import ABCMeta, abstractmethod
from django.contrib.auth.models import User from django.contrib.auth.models import User
import logging import logging
from django.conf import settings
from student.models import CourseAccessRole from student.models import CourseAccessRole
from xmodule_django.models import CourseKeyField from xmodule_django.models import CourseKeyField
...@@ -392,3 +393,24 @@ class UserBasedRole(object): ...@@ -392,3 +393,24 @@ class UserBasedRole(object):
* role (will be self.role--thus uninteresting) * role (will be self.role--thus uninteresting)
""" """
return CourseAccessRole.objects.filter(role=self.role, user=self.user) return CourseAccessRole.objects.filter(role=self.role, user=self.user)
def get_aggregate_exclusion_user_ids(course_key):
"""
This helper method will return the list of user ids that are marked in roles
that can be excluded from certain aggregate queries. The list of roles to exclude
can be defined in a AGGREGATION_EXCLUDE_ROLES settings variable
"""
exclude_user_ids = set()
exclude_role_list = getattr(settings, 'AGGREGATION_EXCLUDE_ROLES', [CourseObserverRole.ROLE])
for role in exclude_role_list:
users = CourseRole(role, course_key).users_with_role()
user_ids = set()
for user in users:
user_ids.add(user.id)
exclude_user_ids = exclude_user_ids.union(user_ids)
return exclude_user_ids
"""
A User Scope Resolver that can be used by edx-notifications
"""
import logging
from edx_notifications.scopes import NotificationUserScopeResolver
from student.models import CourseEnrollment
from django.contrib.auth.models import User
from opaque_keys.edx.keys import CourseKey
from opaque_keys import InvalidKeyError
from opaque_keys.edx.locations import SlashSeparatedCourseKey
log = logging.getLogger(__name__)
class CourseEnrollmentsScopeResolver(NotificationUserScopeResolver):
"""
Implementation of the NotificationUserScopeResolver abstract
interface defined in edx-notifications.
An instance of this class will be registered to handle
scope_name = 'course_enrollments' during system startup.
We will be passed in a course_id in the context
and we must return a Django ORM resultset or None if
we cannot match.
"""
def resolve(self, scope_name, scope_context, instance_context):
"""
The entry point to resolve a scope_name with a given scope_context
"""
if scope_name != 'course_enrollments':
# we can't resolve any other scopes
return None
if 'course_id' not in scope_context:
# did not receive expected parameters
return None
course_id = scope_context['course_id']
if not isinstance(course_id , CourseKey):
try:
course_key = CourseKey.from_string(course_id)
except InvalidKeyError:
course_key = SlashSeparatedCourseKey.from_deprecated_string(course_id)
else:
course_key = course_id
return CourseEnrollment.objects.values_list('user_id', flat=True).filter(
is_active=1,
course_id=course_key
)
class StudentEmailScopeResolver(NotificationUserScopeResolver):
"""
Implementation of the NotificationUserScopeResolver to
take in a user_id and return that user's email address
"""
def resolve(self, scope_name, scope_context, instance_context):
"""
The entry point to resolve a scope_name with a given scope_context
"""
if scope_name != 'student_email_resolver':
# we can't resolve any other scopes
return None
user_id = scope_context.get('user_id')
if not user_id:
return None
return User.objects.values_list('email', flat=True).filter(
id=user_id
)
"""
This file contains celery tasks for student course enrollment
"""
from celery.task import task
from student.models import CourseEnrollment
from edx_notifications.lib.publisher import bulk_publish_notification_to_users
import logging
log = logging.getLogger(__name__)
@task()
def publish_course_notifications_task(course_id, notification_msg, exclude_user_ids=None): # pylint: disable=invalid-name
"""
This function will call the edx_notifications api method "bulk_publish_notification_to_users"
and run as a new Celery task.
"""
# get the enrolled and active user_id list for this course.
user_ids = CourseEnrollment.objects.values_list('user_id', flat=True).filter(
is_active=1,
course_id=course_id
)
try:
bulk_publish_notification_to_users(user_ids, notification_msg, exclude_user_ids=exclude_user_ids)
except Exception, ex:
# Notifications are never critical, so we don't want to disrupt any
# other logic processing. So log and continue.
log.exception(ex)
"""
Test for student tasks.
"""
from student.tests.factories import UserFactory, CourseEnrollmentFactory
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory
from student.models import CourseEnrollment
from student.scope_resolver import CourseEnrollmentsScopeResolver, StudentEmailScopeResolver
class StudentTasksTestCase(ModuleStoreTestCase):
"""
Tests of student.roles
"""
def setUp(self):
"""
setUp stuff
"""
super(StudentTasksTestCase, self).setUp()
self.course = CourseFactory.create()
def test_resolve_course_enrollments(self):
"""
Test that the CourseEnrollmentsScopeResolver actually returns all enrollments
"""
test_user_1 = UserFactory.create(password='test_pass')
CourseEnrollmentFactory(user=test_user_1, course_id=self.course.id)
test_user_2 = UserFactory.create(password='test_pass')
CourseEnrollmentFactory(user=test_user_2, course_id=self.course.id)
test_user_3 = UserFactory.create(password='test_pass')
enrollment = CourseEnrollmentFactory(user=test_user_3, course_id=self.course.id)
# unenroll #3
enrollment.is_active = False
enrollment.save()
resolver = CourseEnrollmentsScopeResolver()
user_ids = resolver.resolve('course_enrollments', {'course_id': self.course.id}, None)
# should have first two, but the third should not be present
self.assertTrue(test_user_1.id in user_ids)
self.assertTrue(test_user_2.id in user_ids)
self.assertFalse(test_user_3.id in user_ids)
def test_bad_params(self):
"""
Makes sure the resolver returns None if all parameters aren't passed
"""
resolver = CourseEnrollmentsScopeResolver()
self.assertIsNone(resolver.resolve('bad', {'course_id': 'foo'}, None))
self.assertIsNone(resolver.resolve('course_enrollments', {'bad': 'foo'}, None))
def test_email_resolver(self):
"""
Make sure we can resolve emails
"""
test_user_1 = UserFactory.create(password='test_pass')
resolver = StudentEmailScopeResolver()
emails_resultset = resolver.resolve(
'student_email_resolver',
{
'user_id': test_user_1.id,
},
None
)
self.assertTrue(test_user_1.email in emails_resultset)
def test_bad_email_resolver(self):
"""
Cover some error cases
"""
resolver = StudentEmailScopeResolver()
self.assertIsNone(resolver.resolve('bad', {'course_id': 'foo'}, None))
self.assertIsNone(resolver.resolve('course_enrollments', {'bad': 'foo'}, None))
"""
Test for student tasks.
"""
from student.tasks import publish_course_notifications_task
from student.tests.factories import UserFactory, CourseEnrollmentFactory
from edx_notifications.data import NotificationMessage
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory
from edx_notifications.lib.publisher import get_notification_type
from xmodule.modulestore.django import modulestore
from edx_notifications.lib.consumer import get_notifications_count_for_user
from mock import patch
from lms import startup
class StudentTasksTestCase(ModuleStoreTestCase):
"""
Tests of student.roles
"""
def setUp(self):
super(StudentTasksTestCase, self).setUp()
self.course = CourseFactory.create()
@patch.dict("django.conf.settings.FEATURES", {"ENABLE_NOTIFICATIONS": True})
def test_course_bulk_notification_tests(self):
# create new users and enroll them in the course.
startup.startup_notification_subsystem()
test_user_1 = UserFactory.create(password='test_pass')
CourseEnrollmentFactory(user=test_user_1, course_id=self.course.id)
test_user_2 = UserFactory.create(password='test_pass')
CourseEnrollmentFactory(user=test_user_2, course_id=self.course.id)
notification_type = get_notification_type(u'open-edx.studio.announcements.new-announcement')
course = modulestore().get_course(self.course.id, depth=0)
notification_msg = NotificationMessage(
msg_type=notification_type,
namespace=unicode(self.course.id),
payload={
'_schema_version': '1',
'course_name': course.display_name,
}
)
# Send the notification_msg to the Celery task
publish_course_notifications_task.delay(self.course.id, notification_msg)
# now the enrolled users should get notification about the
# course update where they are enrolled as student.
self.assertTrue(get_notifications_count_for_user(test_user_1.id), 1)
self.assertTrue(get_notifications_count_for_user(test_user_2.id), 1)
"""
Helper HTML related utility functions
"""
from HTMLParser import HTMLParser
class MLStripper(HTMLParser):
"""
Overrides HTMLParser which returns a string with the HTML
stripped out
"""
def __init__(self):
"""
Initializer
"""
self.reset()
self.fed = []
def handle_data(self, d):
"""
Override of HTMLParser
"""
self.fed.append(d)
def get_data(self):
"""
Returns the stripped out string
"""
return ''.join(self.fed)
def strip_tags(html):
"""
Calls into the MLStripper to return a string with HTML stripped out
"""
s = MLStripper()
s.feed(html)
return s.get_data()
...@@ -3,6 +3,10 @@ Module contains various XModule/XBlock services ...@@ -3,6 +3,10 @@ Module contains various XModule/XBlock services
""" """
from django.conf import settings from django.conf import settings
import types
from xmodule.modulestore.django import modulestore
class SettingsService(object): class SettingsService(object):
""" """
...@@ -61,3 +65,74 @@ class SettingsService(object): ...@@ -61,3 +65,74 @@ class SettingsService(object):
xblock_settings_bucket = getattr(block, self.xblock_settings_bucket_selector, block.unmixed_class.__name__) xblock_settings_bucket = getattr(block, self.xblock_settings_bucket_selector, block.unmixed_class.__name__)
xblock_settings = settings.XBLOCK_SETTINGS if hasattr(settings, "XBLOCK_SETTINGS") else {} xblock_settings = settings.XBLOCK_SETTINGS if hasattr(settings, "XBLOCK_SETTINGS") else {}
return xblock_settings.get(xblock_settings_bucket, actual_default) return xblock_settings.get(xblock_settings_bucket, actual_default)
class NotificationsService(object):
"""
An xBlock service for xBlocks to talk to the Notification subsystem. This class basically introspects
and exposes all functions in the Publisher and Consumer libraries, so it is a direct pass through.
NOTE: This is a Singleton class. We should only have one instance of it!
"""
_instance = None
def __new__(cls, *args, **kwargs):
"""
This is the class factory to make sure this is a Singleton
"""
if not cls._instance:
cls._instance = super(NotificationsService, cls).__new__(
cls, *args, **kwargs)
return cls._instance
def __init__(self):
"""
Class initializer, which just inspects the libraries and exposes the same functions
as a direct pass through
"""
import edx_notifications.lib.publisher as notifications_publisher_lib
import edx_notifications.lib.consumer as notifications_consumer_lib
self._bind_to_module_functions(notifications_publisher_lib)
self._bind_to_module_functions(notifications_consumer_lib)
def _bind_to_module_functions(self, module):
"""
"""
for attr_name in dir(module):
attr = getattr(module, attr_name, None)
if isinstance(attr, types.FunctionType):
if not hasattr(self, attr_name):
setattr(self, attr_name, attr)
class CoursewareParentInfoService(object):
"""
An xBlock service that provides information about the courseware parent. This could be
used for - say - generating breadcumbs
"""
_instance = None
def __new__(cls, *args, **kwargs):
"""
This is the class factory to make sure this is a Singleton
"""
if not cls._instance:
cls._instance = super(CoursewareParentInfoService, cls).__new__(
cls, *args, **kwargs)
return cls._instance
def get_parent_info(self, module):
"""
Returns the location and display name of the parent
"""
parent_location = modulestore().get_parent_location(module)
parent_module = modulestore().get_item(parent_location)
return {
'location': parent_location,
'display_name': parent_module.display_name
}
...@@ -11,6 +11,11 @@ from django.test.utils import override_settings ...@@ -11,6 +11,11 @@ from django.test.utils import override_settings
from xblock.runtime import Mixologist from xblock.runtime import Mixologist
from xmodule.services import SettingsService from xmodule.services import SettingsService
from xmodule.services import SettingsService, NotificationsService
from edx_notifications.data import (
NotificationType
)
class _DummyBlock(object): class _DummyBlock(object):
...@@ -76,3 +81,31 @@ class TestSettingsService(TestCase): ...@@ -76,3 +81,31 @@ class TestSettingsService(TestCase):
block = mixologist.mix(_DummyBlock) block = mixologist.mix(_DummyBlock)
self.assertEqual(getattr(settings, 'XBLOCK_SETTINGS'), {"_DummyBlock": [1, 2, 3]}) self.assertEqual(getattr(settings, 'XBLOCK_SETTINGS'), {"_DummyBlock": [1, 2, 3]})
self.assertEqual(self.settings_service.get_settings_bucket(block), [1, 2, 3]) self.assertEqual(self.settings_service.get_settings_bucket(block), [1, 2, 3])
class TestNotificationsService(TestCase):
""" Test SettingsService """
def setUp(self):
""" Setting up tests """
super(TestNotificationsService, self).setUp()
self.notifications_service = NotificationsService()
def test_exposed_functions(self):
"""
Make sure the service exposes all of the edx_notifications library functions (that we know about for now)
"""
# publisher lib
self.assertTrue(hasattr(self.notifications_service, 'register_notification_type'))
self.assertTrue(hasattr(self.notifications_service, 'get_notification_type'))
self.assertTrue(hasattr(self.notifications_service, 'get_all_notification_types'))
self.assertTrue(hasattr(self.notifications_service, 'publish_notification_to_user'))
self.assertTrue(hasattr(self.notifications_service, 'bulk_publish_notification_to_users'))
# consumer lib
self.assertTrue(hasattr(self.notifications_service, 'get_notifications_count_for_user'))
self.assertTrue(hasattr(self.notifications_service, 'get_notification_for_user'))
self.assertTrue(hasattr(self.notifications_service, 'get_notifications_for_user'))
self.assertTrue(hasattr(self.notifications_service, 'mark_notification_read'))
self.assertTrue(hasattr(self.notifications_service, 'mark_all_user_notification_as_read'))
...@@ -34,11 +34,11 @@ from progress.models import StudentProgress ...@@ -34,11 +34,11 @@ from progress.models import StudentProgress
from projects.models import Project, Workgroup from projects.models import Project, Workgroup
from projects.serializers import ProjectSerializer, BasicWorkgroupSerializer from projects.serializers import ProjectSerializer, BasicWorkgroupSerializer
from student.models import CourseEnrollment, CourseEnrollmentAllowed from student.models import CourseEnrollment, CourseEnrollmentAllowed
from student.roles import CourseRole, CourseAccessRole, CourseInstructorRole, CourseStaffRole, CourseObserverRole, CourseAssistantRole, UserBasedRole from student.roles import CourseRole, CourseAccessRole, CourseInstructorRole, CourseStaffRole, CourseObserverRole, CourseAssistantRole, UserBasedRole, get_aggregate_exclusion_user_ids
from xmodule.modulestore.django import modulestore from xmodule.modulestore.django import modulestore
from api_manager.courseware_access import get_course, get_course_child, get_course_leaf_nodes, get_course_key, \ from api_manager.courseware_access import get_course, get_course_child, get_course_leaf_nodes, get_course_key, \
course_exists, get_modulestore, get_course_descriptor, get_aggregate_exclusion_user_ids course_exists, get_modulestore, get_course_descriptor
from api_manager.models import CourseGroupRelationship, CourseContentGroupRelationship, GroupProfile from api_manager.models import CourseGroupRelationship, CourseContentGroupRelationship, GroupProfile
from progress.models import CourseModuleCompletion from progress.models import CourseModuleCompletion
from api_manager.permissions import SecureAPIView, SecureListAPIView from api_manager.permissions import SecureAPIView, SecureListAPIView
......
...@@ -5,7 +5,6 @@ from django.conf import settings ...@@ -5,7 +5,6 @@ from django.conf import settings
from courseware import courses, module_render from courseware import courses, module_render
from courseware.model_data import FieldDataCache from courseware.model_data import FieldDataCache
from student.roles import CourseRole, CourseObserverRole
from opaque_keys import InvalidKeyError from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import CourseKey, UsageKey from opaque_keys.edx.keys import CourseKey, UsageKey
from opaque_keys.edx.locations import SlashSeparatedCourseKey, Location from opaque_keys.edx.locations import SlashSeparatedCourseKey, Location
...@@ -152,24 +151,3 @@ def get_course_child_content(request, user, course_key, child_descriptor): ...@@ -152,24 +151,3 @@ def get_course_child_content(request, user, course_key, child_descriptor):
field_data_cache, field_data_cache,
course_key) course_key)
return child_content return child_content
def get_aggregate_exclusion_user_ids(course_key):
"""
This helper method will return the list of user ids that are marked in roles
that can be excluded from certain aggregate queries. The list of roles to exclude
can be defined in a AGGREGATION_EXCLUDE_ROLES settings variable
"""
exclude_user_ids = set()
exclude_role_list = getattr(settings, 'AGGREGATION_EXCLUDE_ROLES', [CourseObserverRole.ROLE])
for role in exclude_role_list:
users = CourseRole(role, course_key).users_with_role()
user_ids = set()
for user in users:
user_ids.add(user.id)
exclude_user_ids = exclude_user_ids.union(user_ids)
return exclude_user_ids
...@@ -14,6 +14,7 @@ from api_manager.permissions import SecureAPIView ...@@ -14,6 +14,7 @@ from api_manager.permissions import SecureAPIView
from rest_framework import status from rest_framework import status
from rest_framework.response import Response from rest_framework.response import Response
from django.utils import timezone from django.utils import timezone
from django.template import RequestContext
from util.bad_request_rate_limiter import BadRequestRateLimiter from util.bad_request_rate_limiter import BadRequestRateLimiter
...@@ -120,6 +121,10 @@ class SessionsList(SecureAPIView): ...@@ -120,6 +121,10 @@ class SessionsList(SecureAPIView):
response_data['uri'] = '{}/{}'.format(base_uri, new_session.session_key) response_data['uri'] = '{}/{}'.format(base_uri, new_session.session_key)
response_status = status.HTTP_201_CREATED response_status = status.HTTP_201_CREATED
# generate a CSRF tokens for any web clients that may need to
# call into the LMS via Ajax (for example Notifications)
response_data['csrftoken'] = RequestContext(request, {}).get('csrf_token')
# update the last_login fields in the auth_user table for this user # update the last_login fields in the auth_user table for this user
user.last_login = timezone.now() user.last_login = timezone.now()
user.save() user.save()
......
...@@ -8,6 +8,7 @@ from django.utils.timezone import now ...@@ -8,6 +8,7 @@ from django.utils.timezone import now
from dateutil.parser import parse from dateutil.parser import parse
from dateutil.relativedelta import relativedelta, MO from dateutil.relativedelta import relativedelta, MO
from django.conf import settings from django.conf import settings
from student.roles import CourseRole, CourseObserverRole
def address_exists_in_network(ip_address, net_n_bits): def address_exists_in_network(ip_address, net_n_bits):
......
...@@ -39,6 +39,10 @@ from courseware.entrance_exams import ( ...@@ -39,6 +39,10 @@ from courseware.entrance_exams import (
get_entrance_exam_score, get_entrance_exam_score,
user_must_complete_entrance_exam user_must_complete_entrance_exam
) )
from lms_xblock.runtime import (
SettingsService,
)
from xmodule.services import NotificationsService, CoursewareParentInfoService
from edxmako.shortcuts import render_to_string from edxmako.shortcuts import render_to_string
from eventtracking import tracker from eventtracking import tracker
from lms.djangoapps.lms_xblock.field_data import LmsFieldData from lms.djangoapps.lms_xblock.field_data import LmsFieldData
...@@ -650,6 +654,21 @@ def get_module_system_for_user(user, field_data_cache, # TODO # pylint: disabl ...@@ -650,6 +654,21 @@ def get_module_system_for_user(user, field_data_cache, # TODO # pylint: disabl
user_is_staff = has_access(user, u'staff', descriptor.location, course_id) user_is_staff = has_access(user, u'staff', descriptor.location, course_id)
services_list = {
'i18n': ModuleI18nService(),
'fs': FSService(),
'field-data': field_data,
'settings': SettingsService(),
'courseware_parent_info': CoursewareParentInfoService(),
"reverification": ReverificationService(),
'user': DjangoXBlockUserService(user, user_is_staff=user_is_staff),
}
if settings.FEATURES.get('ENABLE_NOTIFICATIONS', False):
services_list.update({
"notifications": NotificationsService(),
})
system = LmsModuleSystem( system = LmsModuleSystem(
track_function=track_function, track_function=track_function,
render_template=render_to_string, render_template=render_to_string,
...@@ -692,13 +711,7 @@ def get_module_system_for_user(user, field_data_cache, # TODO # pylint: disabl ...@@ -692,13 +711,7 @@ def get_module_system_for_user(user, field_data_cache, # TODO # pylint: disabl
mixins=descriptor.runtime.mixologist._mixins, # pylint: disable=protected-access mixins=descriptor.runtime.mixologist._mixins, # pylint: disable=protected-access
wrappers=block_wrappers, wrappers=block_wrappers,
get_real_user=user_by_anonymous_id, get_real_user=user_by_anonymous_id,
services={ services=services_list,
'i18n': ModuleI18nService(),
'fs': FSService(),
'field-data': field_data,
'user': DjangoXBlockUserService(user, user_is_staff=user_is_staff),
"reverification": ReverificationService()
},
get_user_role=lambda: get_user_role(user, course_id), get_user_role=lambda: get_user_role(user, course_id),
descriptor_runtime=descriptor._runtime, # pylint: disable=protected-access descriptor_runtime=descriptor._runtime, # pylint: disable=protected-access
rebind_noauth_module_to_user=rebind_noauth_module_to_user, rebind_noauth_module_to_user=rebind_noauth_module_to_user,
......
...@@ -18,6 +18,7 @@ from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase ...@@ -18,6 +18,7 @@ from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
@patch.dict(settings.FEATURES, {'SIGNAL_ON_SCORE_CHANGED': True})
class GenerateGradebookEntriesTests(ModuleStoreTestCase): class GenerateGradebookEntriesTests(ModuleStoreTestCase):
""" """
Test suite for grade generation script Test suite for grade generation script
......
...@@ -13,7 +13,6 @@ from model_utils.models import TimeStampedModel ...@@ -13,7 +13,6 @@ from model_utils.models import TimeStampedModel
from student.models import CourseEnrollment from student.models import CourseEnrollment
from xmodule_django.models import CourseKeyField from xmodule_django.models import CourseKeyField
class StudentGradebook(TimeStampedModel): class StudentGradebook(TimeStampedModel):
""" """
StudentGradebook is essentiall a container used to cache calculated StudentGradebook is essentiall a container used to cache calculated
...@@ -98,23 +97,53 @@ class StudentGradebook(TimeStampedModel): ...@@ -98,23 +97,53 @@ class StudentGradebook(TimeStampedModel):
.order_by('-grade', 'modified')[:count] .order_by('-grade', 'modified')[:count]
# If a user_id value was provided, we need to provide some additional user-specific data to the caller # If a user_id value was provided, we need to provide some additional user-specific data to the caller
if user_id: if user_id:
user_grade = 0 result = cls.get_user_position(
user_time_scored = timezone.now() course_key,
try: user_id,
user_queryset = StudentGradebook.objects.get(course_id__exact=course_key, user__id=user_id) queryset=queryset,
except StudentGradebook.DoesNotExist: exclude_users=exclude_users
user_queryset = None )
if user_queryset: print 'result = {}'.format(result)
user_grade = user_queryset.grade data.update(result)
user_time_scored = user_queryset.created
users_above = queryset.filter(grade__gte=user_grade)\
.exclude(user__id=user_id)\
.exclude(grade=user_grade, modified__gt=user_time_scored)
data['user_position'] = len(users_above) + 1
data['user_grade'] = user_grade
return data return data
@classmethod
def get_user_position(cls, course_key, user_id, queryset=None, exclude_users=[]):
"""
Helper method to return the user's position in the leaderboard for Proficiency
"""
data = {'user_position': 0, 'user_grade': 0}
user_grade = 0
users_above = 0
user_time_scored = timezone.now()
try:
user_queryset = StudentGradebook.objects.get(course_id__exact=course_key, user__id=user_id)
except StudentGradebook.DoesNotExist:
user_queryset = None
if user_queryset:
user_grade = user_queryset.grade
user_time_scored = user_queryset.created
# if we were not passed in an existing queryset, build it up
if not queryset:
queryset = StudentGradebook.objects.select_related('user')\
.filter(
course_id__exact=course_key,
user__is_active=True,
user__courseenrollment__is_active=True,
user__courseenrollment__course_id__exact=course_key
).exclude(user__in=exclude_users)
users_above = queryset.filter(grade__gte=user_grade)\
.exclude(user__id=user_id)\
.exclude(grade=user_grade, modified__gt=user_time_scored)
data['user_position'] = len(users_above) + 1
data['user_grade'] = user_grade
return data
class StudentGradebookHistory(TimeStampedModel): class StudentGradebookHistory(TimeStampedModel):
""" """
......
""" """
Signal handlers supporting various gradebook use cases Signal handlers supporting various gradebook use cases
""" """
import logging
import sys
from django.dispatch import receiver from django.dispatch import receiver
from django.conf import settings
from django.db.models.signals import post_save, pre_save
from courseware import grades from courseware import grades
from courseware.signals import score_changed from courseware.signals import score_changed
from util.request import RequestMockWithoutMiddleware from util.request import RequestMockWithoutMiddleware
from util.signals import course_deleted from util.signals import course_deleted
from student.roles import get_aggregate_exclusion_user_ids
from gradebook.models import StudentGradebook, StudentGradebookHistory from gradebook.models import StudentGradebook, StudentGradebookHistory
from edx_notifications.lib.publisher import (
publish_notification_to_user,
get_notification_type
)
from edx_notifications.data import NotificationMessage
log = logging.getLogger(__name__)
@receiver(score_changed) @receiver(score_changed)
def on_score_changed(sender, **kwargs): def on_score_changed(sender, **kwargs):
...@@ -45,3 +58,82 @@ def on_course_deleted(sender, **kwargs): # pylint: disable=W0613 ...@@ -45,3 +58,82 @@ def on_course_deleted(sender, **kwargs): # pylint: disable=W0613
course_key = kwargs['course_key'] course_key = kwargs['course_key']
StudentGradebook.objects.filter(course_id=course_key).delete() StudentGradebook.objects.filter(course_id=course_key).delete()
StudentGradebookHistory.objects.filter(course_id=course_key).delete() StudentGradebookHistory.objects.filter(course_id=course_key).delete()
#
# Support for Notifications, these two receivers should actually be migrated into a new Leaderboard django app.
# For now, put the business logic here, but it is pretty decoupled through event signaling
# so we should be able to move these files easily when we are able to do so
#
@receiver(pre_save, sender=StudentGradebook)
def handle_studentgradebook_pre_save_signal(sender, instance, **kwargs):
"""
Handle the pre-save ORM event on CourseModuleCompletions
"""
if settings.FEATURES['ENABLE_NOTIFICATIONS']:
# attach the rank of the user before the save is completed
data = StudentGradebook.get_user_position(
instance.course_id,
instance.user.id,
exclude_users=get_aggregate_exclusion_user_ids(instance.course_id)
)
grade = data['user_grade']
leaderboard_rank = data['user_position'] if grade > 0.0 else 0
instance.presave_leaderboard_rank = leaderboard_rank
@receiver(post_save, sender=StudentGradebook)
def handle_studentgradebook_post_save_signal(sender, instance, **kwargs):
"""
Handle the pre-save ORM event on CourseModuleCompletions
"""
if settings.FEATURES['ENABLE_NOTIFICATIONS']:
# attach the rank of the user before the save is completed
data = StudentGradebook.get_user_position(
instance.course_id,
instance.user.id,
exclude_users=get_aggregate_exclusion_user_ids(instance.course_id)
)
leaderboard_rank = data['user_position']
grade = data['user_grade']
# logic for Notification trigger is when a user enters into the Leaderboard
if grade > 0.0:
leaderboard_size = getattr(settings, 'LEADERBOARD_SIZE', 3)
presave_leaderboard_rank = instance.presave_leaderboard_rank if instance.presave_leaderboard_rank else sys.maxint
if leaderboard_rank <= leaderboard_size and presave_leaderboard_rank > leaderboard_size:
try:
notification_msg = NotificationMessage(
msg_type=get_notification_type(u'open-edx.lms.leaderboard.gradebook.rank-changed'),
namespace=unicode(instance.course_id),
payload={
'_schema_version': '1',
'rank': leaderboard_rank,
'leaderboard_name': 'Proficiency',
}
)
#
# add in all the context parameters we'll need to
# generate a URL back to the website that will
# present the new course announcement
#
# IMPORTANT: This can be changed to msg.add_click_link() if we
# have a particular URL that we wish to use. In the initial use case,
# we need to make the link point to a different front end website
# so we need to resolve these links at dispatch time
#
notification_msg.add_click_link_params({
'course_id': unicode(instance.course_id),
})
publish_notification_to_user(int(instance.user.id), notification_msg)
except Exception, ex:
# Notifications are never critical, so we don't want to disrupt any
# other logic processing. So log and continue.
log.exception(ex)
...@@ -23,6 +23,10 @@ from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory ...@@ -23,6 +23,10 @@ from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
from gradebook.models import StudentGradebook, StudentGradebookHistory from gradebook.models import StudentGradebook, StudentGradebookHistory
from util.signals import course_deleted from util.signals import course_deleted
from edx_notifications.lib.consumer import get_notifications_count_for_user
from edx_notifications.startup import initialize as initialize_notifications
@override_settings(STUDENT_GRADEBOOK=True) @override_settings(STUDENT_GRADEBOOK=True)
class GradebookTests(ModuleStoreTestCase): class GradebookTests(ModuleStoreTestCase):
""" Test suite for Student Gradebook """ """ Test suite for Student Gradebook """
...@@ -156,6 +160,27 @@ class GradebookTests(ModuleStoreTestCase): ...@@ -156,6 +160,27 @@ class GradebookTests(ModuleStoreTestCase):
@patch.dict(settings.FEATURES, { @patch.dict(settings.FEATURES, {
'ALLOW_STUDENT_STATE_UPDATES_ON_CLOSED_COURSE': False, 'ALLOW_STUDENT_STATE_UPDATES_ON_CLOSED_COURSE': False,
'SIGNAL_ON_SCORE_CHANGED': True,
'ENABLE_NOTIFICATIONS': True
})
@patch.dict(settings.FEATURES, {})
def test_notifications_publishing(self):
initialize_notifications()
# assert user has no notifications
self.assertEqual(get_notifications_count_for_user(self.user.id), 0)
self._create_course()
module = self.get_module_for_user(self.user, self.course, self.problem)
grade_dict = {'value': 0.75, 'max_value': 1, 'user_id': self.user.id}
module.system.publish(module, 'grade', grade_dict)
# user should have had a notification published as he/her is now in the
# leaderboard
self.assertEqual(get_notifications_count_for_user(self.user.id), 1)
@patch.dict(settings.FEATURES, {
'ALLOW_STUDENT_STATE_UPDATES_ON_CLOSED_COURSE': False,
'SIGNAL_ON_SCORE_CHANGED': True 'SIGNAL_ON_SCORE_CHANGED': True
}) })
def test_open_course(self): def test_open_course(self):
......
...@@ -10,13 +10,14 @@ from rest_framework import status, viewsets ...@@ -10,13 +10,14 @@ from rest_framework import status, viewsets
from rest_framework.decorators import action from rest_framework.decorators import action
from rest_framework.response import Response from rest_framework.response import Response
from api_manager.courseware_access import get_course_key, get_aggregate_exclusion_user_ids from api_manager.courseware_access import get_course_key
from organizations.models import Organization from organizations.models import Organization
from api_manager.users.serializers import UserSerializer, SimpleUserSerializer from api_manager.users.serializers import UserSerializer, SimpleUserSerializer
from api_manager.groups.serializers import GroupSerializer from api_manager.groups.serializers import GroupSerializer
from api_manager.utils import str2bool from api_manager.utils import str2bool
from gradebook.models import StudentGradebook from gradebook.models import StudentGradebook
from student.models import CourseEnrollment from student.models import CourseEnrollment
from student.roles import get_aggregate_exclusion_user_ids
from .serializers import OrganizationSerializer from .serializers import OrganizationSerializer
......
...@@ -6,7 +6,7 @@ from optparse import make_option ...@@ -6,7 +6,7 @@ from optparse import make_option
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from django.conf import settings from django.conf import settings
from django.db.models import Q from django.db.models import Q, Min
from progress.models import StudentProgress, CourseModuleCompletion from progress.models import StudentProgress, CourseModuleCompletion
from student.models import CourseEnrollment from student.models import CourseEnrollment
...@@ -69,23 +69,45 @@ class Command(BaseCommand): ...@@ -69,23 +69,45 @@ class Command(BaseCommand):
# For each user... # For each user...
for user in users: for user in users:
status = 'skipped' status = 'skipped'
completions = CourseModuleCompletion.objects.filter(course_id=course.id, user_id=user.id)\ num_completions = CourseModuleCompletion.objects.filter(course_id=course.id, user_id=user.id)\
.exclude(cat_list).count() .exclude(cat_list).count()
try:
existing_record = StudentProgress.objects.get(user=user, course_id=course.id) if num_completions:
if existing_record.completions != completions:
existing_record.completions = completions start_date = CourseModuleCompletion.objects.filter(course_id=course.id, user_id=user.id)\
existing_record.save() .exclude(cat_list).aggregate(Min('created'))['created__min']
status = 'updated'
except StudentProgress.DoesNotExist: try:
new_record = StudentProgress.objects.create( existing_record = StudentProgress.objects.get(user=user, course_id=course.id)
user=user,
course_id=course.id, if existing_record.completions != num_completions:
completions=completions existing_record.completions = num_completions
) status = 'updated'
status = 'created'
log_msg = 'Progress entry {} -- Course: {}, User: {} (completions: {})'.format(status, course.id, user.id if existing_record.created != start_date:
, completions) existing_record.created = start_date
status = 'updated'
if status == 'updated':
existing_record.save()
except StudentProgress.DoesNotExist:
StudentProgress.objects.create(
user=user,
course_id=course.id,
completions=num_completions,
created=start_date
)
status = 'created'
log_msg = 'Progress entry {} -- Course: {}, User: {} (completions: {})'.format(
status,
course.id,
user.id,
num_completions
)
print log_msg print log_msg
log.info(log_msg) log.info(log_msg)
...@@ -4,9 +4,16 @@ Signal handlers supporting various progress use cases ...@@ -4,9 +4,16 @@ Signal handlers supporting various progress use cases
import sys import sys
import logging import logging
from django.dispatch import receiver from django.dispatch import receiver
from django.db.models.signals import post_save from django.db.models.signals import post_save, pre_save
from django.core.exceptions import ObjectDoesNotExist from django.core.exceptions import ObjectDoesNotExist
from django.conf import settings from django.conf import settings
from student.roles import get_aggregate_exclusion_user_ids
from edx_notifications.lib.publisher import (
publish_notification_to_user,
get_notification_type
)
from edx_notifications.data import NotificationMessage
from progress.models import StudentProgress, StudentProgressHistory, CourseModuleCompletion from progress.models import StudentProgress, StudentProgressHistory, CourseModuleCompletion
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
...@@ -43,3 +50,77 @@ def save_history(sender, instance, **kwargs): # pylint: disable=no-self-argumen ...@@ -43,3 +50,77 @@ def save_history(sender, instance, **kwargs): # pylint: disable=no-self-argumen
completions=instance.completions completions=instance.completions
) )
history_entry.save() history_entry.save()
#
# Support for Notifications, these two receivers should actually be migrated into a new Leaderboard django app.
# For now, put the business logic here, but it is pretty decoupled through event signaling
# so we should be able to move these files easily when we are able to do so
#
@receiver(pre_save, sender=StudentProgress)
def handle_progress_pre_save_signal(sender, instance, **kwargs):
"""
Handle the pre-save ORM event on CourseModuleCompletions
"""
if settings.FEATURES['ENABLE_NOTIFICATIONS']:
# If notifications feature is enabled, then we need to get the user's
# rank before the save is made, so that we can compare it to
# after the save and see if the position changes
instance.presave_leaderboard_rank = StudentProgress.get_user_position(
instance.course_id,
instance.user.id,
get_aggregate_exclusion_user_ids(instance.course_id)
)['position']
@receiver(post_save, sender=StudentProgress)
def handle_progress_post_save_signal(sender, instance, **kwargs):
"""
Handle the pre-save ORM event on CourseModuleCompletions
"""
if settings.FEATURES['ENABLE_NOTIFICATIONS']:
# If notifications feature is enabled, then we need to get the user's
# rank before the save is made, so that we can compare it to
# after the save and see if the position changes
leaderboard_rank = StudentProgress.get_user_position(
instance.course_id,
instance.user.id,
get_aggregate_exclusion_user_ids(instance.course_id)
)['position']
# logic for Notification trigger is when a user enters into the Leaderboard
leaderboard_size = getattr(settings, 'LEADERBOARD_SIZE', 3)
presave_leaderboard_rank = instance.presave_leaderboard_rank if instance.presave_leaderboard_rank else sys.maxint
if leaderboard_rank <= leaderboard_size and presave_leaderboard_rank > leaderboard_size:
try:
notification_msg = NotificationMessage(
msg_type=get_notification_type(u'open-edx.lms.leaderboard.progress.rank-changed'),
namespace=unicode(instance.course_id),
payload={
'_schema_version': '1',
'rank': leaderboard_rank,
'leaderboard_name': 'Progress',
}
)
#
# add in all the context parameters we'll need to
# generate a URL back to the website that will
# present the new course announcement
#
# IMPORTANT: This can be changed to msg.add_click_link() if we
# have a particular URL that we wish to use. In the initial use case,
# we need to make the link point to a different front end website
# so we need to resolve these links at dispatch time
#
notification_msg.add_click_link_params({
'course_id': unicode(instance.course_id),
})
publish_notification_to_user(int(instance.user.id), notification_msg)
except Exception, ex:
# Notifications are never critical, so we don't want to disrupt any
# other logic processing. So log and continue.
log.exception(ex)
...@@ -27,8 +27,12 @@ from courseware.model_data import FieldDataCache ...@@ -27,8 +27,12 @@ from courseware.model_data import FieldDataCache
from courseware import module_render from courseware import module_render
from util.signals import course_deleted from util.signals import course_deleted
from edx_notifications.startup import initialize as initialize_notifications
from edx_notifications.lib.consumer import get_notifications_count_for_user
@override_settings(STUDENT_GRADEBOOK=True) @override_settings(STUDENT_GRADEBOOK=True)
@patch.dict(settings.FEATURES, {'ENABLE_NOTIFICATIONS': True})
class CourseModuleCompletionTests(ModuleStoreTestCase): class CourseModuleCompletionTests(ModuleStoreTestCase):
""" Test suite for CourseModuleCompletion """ """ Test suite for CourseModuleCompletion """
...@@ -51,6 +55,8 @@ class CourseModuleCompletionTests(ModuleStoreTestCase): ...@@ -51,6 +55,8 @@ class CourseModuleCompletionTests(ModuleStoreTestCase):
self.user = UserFactory() self.user = UserFactory()
self._create_course() self._create_course()
initialize_notifications()
def _create_course(self, start=None, end=None): def _create_course(self, start=None, end=None):
self.course = CourseFactory.create( self.course = CourseFactory.create(
start=start, start=start,
...@@ -140,6 +146,31 @@ class CourseModuleCompletionTests(ModuleStoreTestCase): ...@@ -140,6 +146,31 @@ class CourseModuleCompletionTests(ModuleStoreTestCase):
) )
self.assertIsNotNone(completion_fetch) self.assertIsNotNone(completion_fetch)
def test_check_notifications(self):
"""
Save a CourseModuleCompletion and fetch it again
"""
module = self.get_module_for_user(self.user, self.course, self.problem4)
module.system.publish(module, 'progress', {})
completion_fetch = CourseModuleCompletion.objects.get(
user=self.user.id,
course_id=self.course.id,
content_id=self.problem4.location
)
self.assertIsNotNone(completion_fetch)
# since we are alone, then we should be listed as first
self.assertEqual(get_notifications_count_for_user(self.user.id), 1)
# progressing on a 2nd item, shouldn't change our positions, because
# we're the only one in this course
module = self.get_module_for_user(self.user, self.course, self.problem5)
module.system.publish(module, 'progress', {})
# since we are alone, then we should be listed as first
self.assertEqual(get_notifications_count_for_user(self.user.id), 1)
@patch.dict(settings.FEATURES, {'ALLOW_STUDENT_STATE_UPDATES_ON_CLOSED_COURSE': False}) @patch.dict(settings.FEATURES, {'ALLOW_STUDENT_STATE_UPDATES_ON_CLOSED_COURSE': False})
def test_save_completion_with_feature_flag(self): def test_save_completion_with_feature_flag(self):
""" """
......
...@@ -26,6 +26,19 @@ class Project(TimeStampedModel): ...@@ -26,6 +26,19 @@ class Project(TimeStampedModel):
""" Meta class for defining additional model characteristics """ """ Meta class for defining additional model characteristics """
unique_together = ("course_id", "content_id", "organization") unique_together = ("course_id", "content_id", "organization")
@classmethod
def get_user_ids_in_project_by_content_id(cls, course_id, content_id):
"""
Returns a database cursor for all users associated with a project
specified by a content_id
"""
query = Project.objects.select_related('workgroups__users').values_list('workgroups__users', flat=True).filter(
course_id=course_id,
content_id=content_id
)
return query
class Workgroup(TimeStampedModel): class Workgroup(TimeStampedModel):
""" """
...@@ -63,6 +76,19 @@ class Workgroup(TimeStampedModel): ...@@ -63,6 +76,19 @@ class Workgroup(TimeStampedModel):
workgroup_user = WorkgroupUser.objects.get(workgroup=self, user=user) workgroup_user = WorkgroupUser.objects.get(workgroup=self, user=user)
workgroup_user.delete() workgroup_user.delete()
@classmethod
def get_user_ids_in_workgroup(cls, workgroup_id):
"""
Returns a database cursor for all users associated with a project
specified by a content_id
"""
query = Workgroup.objects.select_related('users').values_list('users', flat=True).filter(
id=workgroup_id
)
return query
class WorkgroupUser(models.Model): class WorkgroupUser(models.Model):
"""A Folder to store some data between a client and its insurance""" """A Folder to store some data between a client and its insurance"""
......
"""
A User Scope Resolver that can be used by edx-notifications
"""
import logging
from edx_notifications.scopes import NotificationUserScopeResolver
from projects.models import Project, Workgroup
log = logging.getLogger(__name__)
class GroupProjectParticipantsScopeResolver(NotificationUserScopeResolver):
"""
Implementation of the NotificationUserScopeResolver abstract
interface defined in edx-notifications.
An instance of this class will be registered to handle
scope_name = 'group_project_participants' during system startup.
We will be passed in a content_id in the context
and we must return a Django ORM resultset or None if
we cannot match.
"""
def resolve(self, scope_name, scope_context, instance_context):
"""
The entry point to resolve a scope_name with a given scope_context
"""
if scope_name == 'group_project_participants':
content_id = scope_context.get('content_id')
course_id = scope_context.get('course_id')
if not content_id or not course_id:
return None
return Project.get_user_ids_in_project_by_content_id(course_id, content_id)
elif scope_name == 'group_project_workgroup':
workgroup_id = scope_context.get('workgroup_id')
if not workgroup_id:
return None
return Workgroup.get_user_ids_in_workgroup(workgroup_id)
else:
# we can't resolve any other scopes
return None
...@@ -13,6 +13,7 @@ from django.test import TestCase, Client ...@@ -13,6 +13,7 @@ from django.test import TestCase, Client
from django.test.utils import override_settings from django.test.utils import override_settings
from projects.models import Project, Workgroup from projects.models import Project, Workgroup
from projects.scope_resolver import GroupProjectParticipantsScopeResolver
TEST_API_KEY = str(uuid.uuid4()) TEST_API_KEY = str(uuid.uuid4())
...@@ -49,6 +50,12 @@ class ProjectsApiTests(TestCase): ...@@ -49,6 +50,12 @@ class ProjectsApiTests(TestCase):
is_active=True is_active=True
) )
self.test_user2 = User.objects.create(
email="test2@edx.org",
username="testing2",
is_active=True
)
self.test_project = Project.objects.create( self.test_project = Project.objects.create(
course_id=self.test_course_id, course_id=self.test_course_id,
content_id=self.test_course_content_id, content_id=self.test_course_content_id,
...@@ -61,6 +68,13 @@ class ProjectsApiTests(TestCase): ...@@ -61,6 +68,13 @@ class ProjectsApiTests(TestCase):
self.test_workgroup.add_user(self.test_user) self.test_workgroup.add_user(self.test_user)
self.test_workgroup.save() self.test_workgroup.save()
self.test_workgroup2 = Workgroup.objects.create(
name="Test Workgroup2",
project=self.test_project,
)
self.test_workgroup2.add_user(self.test_user2)
self.test_workgroup2.save()
self.client = SecureClient() self.client = SecureClient()
cache.clear() cache.clear()
...@@ -180,3 +194,70 @@ class ProjectsApiTests(TestCase): ...@@ -180,3 +194,70 @@ class ProjectsApiTests(TestCase):
self.assertEqual(response.status_code, 204) self.assertEqual(response.status_code, 204)
response = self.do_get(test_uri) response = self.do_get(test_uri)
self.assertEqual(response.status_code, 404) self.assertEqual(response.status_code, 404)
def test_get_user_ids(self):
cursor = Project.get_user_ids_in_project_by_content_id(
self.test_course_id,
self.test_course_content_id
)
user_ids = [user_id for user_id in cursor.all()]
self.assertEqual(len(user_ids), 2)
self.assertIn(self.test_user.id, user_ids)
self.assertIn(self.test_user2.id, user_ids)
def test_get_workgroup_user_ids(self):
cursor = Workgroup.get_user_ids_in_workgroup(self.test_workgroup.id)
user_ids = [user_id for user_id in cursor.all()]
self.assertEqual(len(user_ids), 1)
self.assertIn(self.test_user.id, user_ids)
cursor = Workgroup.get_user_ids_in_workgroup(self.test_workgroup2.id)
user_ids = [user_id for user_id in cursor.all()]
self.assertEqual(len(user_ids), 1)
self.assertIn(self.test_user2.id, user_ids)
def test_scope_resolver(self):
cursor = GroupProjectParticipantsScopeResolver().resolve(
'group_project_participants',
{
'course_id': self.test_course_id,
'content_id': self.test_course_content_id
},
None
)
user_ids = [user_id for user_id in cursor.all()]
self.assertEqual(len(user_ids), 2)
self.assertIn(self.test_user.id, user_ids)
self.assertIn(self.test_user2.id, user_ids)
def test_workgroup_scope_resolver(self):
cursor = GroupProjectParticipantsScopeResolver().resolve(
'group_project_workgroup',
{
'workgroup_id': self.test_workgroup.id,
},
None
)
user_ids = [user_id for user_id in cursor.all()]
self.assertEqual(len(user_ids), 1)
self.assertIn(self.test_user.id, user_ids)
cursor = GroupProjectParticipantsScopeResolver().resolve(
'group_project_workgroup',
{
'workgroup_id': self.test_workgroup2.id,
},
None
)
user_ids = [user_id for user_id in cursor.all()]
self.assertEqual(len(user_ids), 1)
self.assertIn(self.test_user2.id, user_ids)
"""
This Djangoapp supports the concept of a Social Engagement based on activity in the forums
"""
"""
Business logic tier regarding social engagement scores
"""
import sys
import logging
from datetime import datetime
import pytz
from django.conf import settings
from .models import StudentSocialEngagementScore
from lms.lib.comment_client.user import get_user_social_stats
from xmodule.modulestore.django import modulestore
from opaque_keys.edx.keys import CourseKey
from student.models import CourseEnrollment
from student.roles import get_aggregate_exclusion_user_ids
from lms.lib.comment_client.utils import CommentClientRequestError
from requests.exceptions import ConnectionError
from django.dispatch import receiver
from django.db.models.signals import post_save, pre_save
from edx_notifications.lib.publisher import (
publish_notification_to_user,
get_notification_type
)
from edx_notifications.data import NotificationMessage
log = logging.getLogger(__name__)
def update_user_engagement_score(course_id, user_id, compute_if_closed_course=False, course_descriptor=None):
"""
Compute the user's Engagement Score and store it in the
database. We will not update the record, if the score
is the same as it currently exists
"""
if not settings.FEATURES.get('ENABLE_SOCIAL_ENGAGEMENT', False):
return
course_key = course_id if isinstance(course_id, CourseKey) else CourseKey.from_string(course_id)
if not course_descriptor:
# it course descriptor was not passed in (as an optimization)
course_descriptor = modulestore().get_course(course_key)
if not course_descriptor:
# couldn't find course?!?
return
if not compute_if_closed_course and course_descriptor.end:
# if course is closed then don't bother. Note
# we can override this if we want to force
# update
now_utc = datetime.now(pytz.UTC)
if now_utc > course_descriptor.end:
log.info('update_user_engagement_score() is skipping because the course is closed...')
return
previous_score = StudentSocialEngagementScore.get_user_engagement_score(course_key, user_id)
try:
log.info('Updating social engagement score for user_id {} in course_key {}'.format(user_id, course_key))
# cs_comment_service works is slash separated course_id strings
slash_course_id = course_key.to_deprecated_string()
# get the course social stats, passing along a course end date to remove any activity after the course
# closure from the stats. Note that we are calling out to the cs_comment_service
# and so there might be a HTTP based communication error
social_stats = _get_user_social_stats(user_id, slash_course_id, course_descriptor.end)
if social_stats:
current_score = _compute_social_engagement_score(social_stats)
log.info('previous_score = {} current_score = {}'.format(previous_score, current_score))
if current_score > previous_score or previous_score is None:
StudentSocialEngagementScore.save_user_engagement_score(course_key, user_id, current_score)
except (CommentClientRequestError, ConnectionError), error:
log.exception(error)
def _get_user_social_stats(user_id, slash_course_id, end_date):
"""
Helper function which basically calls into the cs_comment_service. We wrap this,
to make it easier to write mock functions for unit testing
"""
stats = get_user_social_stats(user_id, slash_course_id, end_date=end_date)
log.info('raw stats = {}'.format(stats))
# the comment service returns the user_id as a string
user_id_str = str(user_id)
if user_id_str in stats:
return stats[user_id_str]
else:
return None
def _compute_social_engagement_score(social_metrics):
"""
For a list of social_stats, compute the social score
"""
# we can override this in configuration, but this
# is default values
social_metric_points = getattr(
settings,
'SOCIAL_METRIC_POINTS',
{
'num_threads': 10,
'num_comments': 2,
'num_replies': 1,
'num_upvotes': 5,
'num_thread_followers': 5,
'num_comments_generated': 1,
}
)
social_total = 0
for key, val in social_metric_points.iteritems():
social_total += social_metrics.get(key, 0) * val
return social_total
def update_course_engagement_scores(course_id, compute_if_closed_course=False, course_descriptor=None):
"""
Iterate over all active course enrollments and update the
students engagement scores
"""
course_key = course_id if isinstance(course_id, CourseKey) else CourseKey.from_string(course_id)
if not course_descriptor:
# pre-fetch course descriptor, so we don't have to refetch later
# over and over again
course_descriptor = modulestore().get_course(course_key)
if not course_descriptor:
return
user_ids = CourseEnrollment.objects.values_list('user_id', flat=True).filter(
is_active=1,
course_id=course_key
)
for user_id in user_ids:
update_user_engagement_score(course_key, user_id, compute_if_closed_course=compute_if_closed_course, course_descriptor=course_descriptor)
def update_all_courses_engagement_scores(compute_if_closed_course=False):
"""
Iterates over all courses in the modelstore and computes engagment
scores for all enrolled students
"""
courses = modulestore().get_courses()
for course in courses:
update_course_engagement_scores(
course.id,
compute_if_closed_course=compute_if_closed_course,
course_descriptor=course
)
#
# Support for Notifications, these two receivers should actually be migrated into a new Leaderboard django app.
# For now, put the business logic here, but it is pretty decoupled through event signaling
# so we should be able to move these files easily when we are able to do so
#
@receiver(pre_save, sender=StudentSocialEngagementScore)
def handle_progress_pre_save_signal(sender, instance, **kwargs):
"""
Handle the pre-save ORM event on StudentSocialEngagementScore
"""
if settings.FEATURES['ENABLE_NOTIFICATIONS']:
# If notifications feature is enabled, then we need to get the user's
# rank before the save is made, so that we can compare it to
# after the save and see if the position changes
instance.presave_leaderboard_rank = StudentSocialEngagementScore.get_user_leaderboard_position(
instance.course_id,
instance.user.id,
get_aggregate_exclusion_user_ids(instance.course_id)
)['position']
@receiver(post_save, sender=StudentSocialEngagementScore)
def handle_progress_post_save_signal(sender, instance, **kwargs):
"""
Handle the pre-save ORM event on CourseModuleCompletions
"""
if settings.FEATURES['ENABLE_NOTIFICATIONS']:
# If notifications feature is enabled, then we need to get the user's
# rank before the save is made, so that we can compare it to
# after the save and see if the position changes
leaderboard_rank = StudentSocialEngagementScore.get_user_leaderboard_position(
instance.course_id,
instance.user.id,
get_aggregate_exclusion_user_ids(instance.course_id)
)['position']
# logic for Notification trigger is when a user enters into the Leaderboard
leaderboard_size = getattr(settings, 'LEADERBOARD_SIZE', 3)
presave_leaderboard_rank = instance.presave_leaderboard_rank if instance.presave_leaderboard_rank else sys.maxint
if leaderboard_rank <= leaderboard_size and presave_leaderboard_rank > leaderboard_size:
try:
notification_msg = NotificationMessage(
msg_type=get_notification_type(u'open-edx.lms.leaderboard.engagement.rank-changed'),
namespace=unicode(instance.course_id),
payload={
'_schema_version': '1',
'rank': leaderboard_rank,
'leaderboard_name': 'Engagement',
}
)
#
# add in all the context parameters we'll need to
# generate a URL back to the website that will
# present the new course announcement
#
# IMPORTANT: This can be changed to msg.add_click_link() if we
# have a particular URL that we wish to use. In the initial use case,
# we need to make the link point to a different front end website
# so we need to resolve these links at dispatch time
#
notification_msg.add_click_link_params({
'course_id': unicode(instance.course_id),
})
publish_notification_to_user(int(instance.user.id), notification_msg)
except Exception, ex:
# Notifications are never critical, so we don't want to disrupt any
# other logic processing. So log and continue.
log.exception(ex)
"""
One-time data migration script -- shoulen't need to run it again
"""
import logging
from django.conf import settings
from optparse import make_option
from django.core.management.base import BaseCommand
from social_engagement.engagement import (
update_all_courses_engagement_scores,
update_course_engagement_scores,
update_user_engagement_score
)
from mock import patch
log = logging.getLogger(__name__)
class Command(BaseCommand):
"""
Creates (or updates) social engagement entries for the specified course(s) and/or user(s)
"""
# don't send out notifications on management command executions
@patch.dict(settings.FEATURES, {'ENABLE_NOTIFICATIONS': False})
def handle(self, *args, **options):
help = "Command to creaete or update social engagement entries"
option_list = BaseCommand.option_list + (
make_option(
"-c",
"--course_ids",
dest="course_ids",
help="List of courses for which to generate social engagement",
metavar="first/course/id,second/course/id"
),
make_option(
"-u",
"--user_ids",
dest="user_ids",
help="List of users for which to generate social engagement",
metavar="1234,2468,3579"
),
)
course_ids = options.get('course_ids')
user_ids = options.get('user_ids')
if course_ids:
# over a few specific courses?
for course_id in course_ids:
if user_ids:
# over a few specific users in those courses?
for user_id in user_ids:
update_user_engagement_score(course_id, user_id, compute_if_closed_course=True)
else:
update_course_engagement_scores(course_id, compute_if_closed_course=True)
else:
print 'Updating social engagement scores in all courses...'
update_all_courses_engagement_scores(compute_if_closed_course=True)
"""
Tests for the social_engagment subsystem
paver test_system -s lms --test_id=lms/djangoapps/social_engagements/tests/test_engagement.py
"""
from django.conf import settings
from mock import MagicMock, patch
from django.test import TestCase
from django.test.utils import override_settings
from courseware.tests.modulestore_config import TEST_DATA_MIXED_MODULESTORE
from student.tests.factories import UserFactory
from student.models import CourseEnrollment
from xmodule.modulestore.tests.factories import CourseFactory
from social_engagement.models import StudentSocialEngagementScore, StudentSocialEngagementScoreHistory
from social_engagement.engagement import update_user_engagement_score
from social_engagement.engagement import update_course_engagement_scores
from social_engagement.engagement import update_all_courses_engagement_scores
from edx_notifications.startup import initialize as initialize_notifications
from edx_notifications.lib.consumer import get_notifications_count_for_user
from social_engagement.management.commands.generate_engagement_entries import Command
@override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE)
@patch.dict(settings.FEATURES, {'ENABLE_NOTIFICATIONS': True})
@patch.dict(settings.FEATURES, {'ENABLE_SOCIAL_ENGAGEMENT': True})
class StudentEngagementTests(TestCase):
""" Test suite for CourseModuleCompletion """
def setUp(self):
self.user = UserFactory()
self.user2 = UserFactory()
self._create_course()
initialize_notifications()
def _create_course(self, start=None, end=None):
self.course = CourseFactory.create(
start=start,
end=end
)
self.course2 = CourseFactory.create(
org='foo',
course='bar',
run='baz',
start=start,
end=end
)
CourseEnrollment.enroll(self.user, self.course.id)
CourseEnrollment.enroll(self.user2, self.course.id)
CourseEnrollment.enroll(self.user, self.course2.id)
def test_management_command(self):
"""
Verify that we get None back
"""
self.assertIsNone(StudentSocialEngagementScore.get_user_engagement_score(self.course.id, self.user.id))
self.assertIsNone(StudentSocialEngagementScore.get_user_engagement_score(self.course.id, self.user2.id))
self.assertEqual(get_notifications_count_for_user(self.user.id), 0)
self.assertEqual(get_notifications_count_for_user(self.user2.id), 0)
with patch('social_engagement.engagement._get_user_social_stats') as mock_func:
mock_func.return_value = {
'num_threads': 1,
'num_comments': 1,
'num_replies': 1,
'num_upvotes': 1,
'num_thread_followers': 1,
'num_comments_generated': 1,
}
Command().handle()
# shouldn't be anything in there because course is closed
leaderboard = StudentSocialEngagementScore.generate_leaderboard(self.course.id)
self.assertEqual(len(leaderboard), 2)
leaderboard = StudentSocialEngagementScore.generate_leaderboard(self.course2.id)
self.assertEqual(len(leaderboard), 1)
self.assertEqual(get_notifications_count_for_user(self.user.id), 0)
self.assertEqual(get_notifications_count_for_user(self.user2.id), 0)
def test_management_command_course(self):
"""
Verify that we get None back
"""
self.assertIsNone(StudentSocialEngagementScore.get_user_engagement_score(self.course.id, self.user.id))
self.assertIsNone(StudentSocialEngagementScore.get_user_engagement_score(self.course.id, self.user2.id))
self.assertEqual(get_notifications_count_for_user(self.user.id), 0)
self.assertEqual(get_notifications_count_for_user(self.user2.id), 0)
with patch('social_engagement.engagement._get_user_social_stats') as mock_func:
mock_func.return_value = {
'num_threads': 1,
'num_comments': 1,
'num_replies': 1,
'num_upvotes': 1,
'num_thread_followers': 1,
'num_comments_generated': 1,
}
Command().handle(course_ids=[self.course.id])
# shouldn't be anything in there because course is closed
leaderboard = StudentSocialEngagementScore.generate_leaderboard(self.course.id)
self.assertEqual(len(leaderboard), 2)
leaderboard = StudentSocialEngagementScore.generate_leaderboard(self.course2.id)
self.assertEqual(len(leaderboard), 0)
self.assertEqual(get_notifications_count_for_user(self.user.id), 0)
self.assertEqual(get_notifications_count_for_user(self.user2.id), 0)
def test_management_command_user(self):
"""
Verify that we get None back
"""
self.assertIsNone(StudentSocialEngagementScore.get_user_engagement_score(self.course.id, self.user.id))
self.assertIsNone(StudentSocialEngagementScore.get_user_engagement_score(self.course.id, self.user2.id))
self.assertEqual(get_notifications_count_for_user(self.user.id), 0)
self.assertEqual(get_notifications_count_for_user(self.user2.id), 0)
with patch('social_engagement.engagement._get_user_social_stats') as mock_func:
mock_func.return_value = {
'num_threads': 1,
'num_comments': 1,
'num_replies': 1,
'num_upvotes': 1,
'num_thread_followers': 1,
'num_comments_generated': 1,
}
Command().handle(course_ids=[self.course.id], user_ids=[self.user.id])
# shouldn't be anything in there because course is closed
leaderboard = StudentSocialEngagementScore.generate_leaderboard(self.course.id)
self.assertEqual(len(leaderboard), 1)
self.assertEqual(leaderboard[0]['user__id'], self.user.id)
leaderboard = StudentSocialEngagementScore.generate_leaderboard(self.course2.id)
self.assertEqual(len(leaderboard), 0)
self.assertEqual(get_notifications_count_for_user(self.user.id), 0)
self.assertEqual(get_notifications_count_for_user(self.user2.id), 0)
# -*- coding: utf-8 -*-
import datetime
from south.db import db
from south.v2 import SchemaMigration
from django.db import models
class Migration(SchemaMigration):
def forwards(self, orm):
# Adding model 'StudentSocialEngagementScore'
db.create_table('social_engagement_studentsocialengagementscore', (
('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
('created', self.gf('model_utils.fields.AutoCreatedField')(default=datetime.datetime.now)),
('modified', self.gf('model_utils.fields.AutoLastModifiedField')(default=datetime.datetime.now)),
('user', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['auth.User'])),
('course_id', self.gf('xmodule_django.models.CourseKeyField')(db_index=True, max_length=255, blank=True)),
('score', self.gf('django.db.models.fields.IntegerField')(default=0, db_index=True)),
))
db.send_create_signal('social_engagement', ['StudentSocialEngagementScore'])
# Adding unique constraint on 'StudentSocialEngagementScore', fields ['user', 'course_id']
db.create_unique('social_engagement_studentsocialengagementscore', ['user_id', 'course_id'])
# Adding model 'StudentSocialEngagementScoreHistory'
db.create_table('social_engagement_studentsocialengagementscorehistory', (
('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
('created', self.gf('model_utils.fields.AutoCreatedField')(default=datetime.datetime.now)),
('modified', self.gf('model_utils.fields.AutoLastModifiedField')(default=datetime.datetime.now)),
('user', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['auth.User'])),
('course_id', self.gf('xmodule_django.models.CourseKeyField')(db_index=True, max_length=255, blank=True)),
('score', self.gf('django.db.models.fields.IntegerField')()),
))
db.send_create_signal('social_engagement', ['StudentSocialEngagementScoreHistory'])
def backwards(self, orm):
# Removing unique constraint on 'StudentSocialEngagementScore', fields ['user', 'course_id']
db.delete_unique('social_engagement_studentsocialengagementscore', ['user_id', 'course_id'])
# Deleting model 'StudentSocialEngagementScore'
db.delete_table('social_engagement_studentsocialengagementscore')
# Deleting model 'StudentSocialEngagementScoreHistory'
db.delete_table('social_engagement_studentsocialengagementscorehistory')
models = {
'auth.group': {
'Meta': {'object_name': 'Group'},
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}),
'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'})
},
'auth.permission': {
'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'},
'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '50'})
},
'auth.user': {
'Meta': {'object_name': 'User'},
'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}),
'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}),
'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'})
},
'contenttypes.contenttype': {
'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"},
'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '100'})
},
'social_engagement.studentsocialengagementscore': {
'Meta': {'unique_together': "(('user', 'course_id'),)", 'object_name': 'StudentSocialEngagementScore'},
'course_id': ('xmodule_django.models.CourseKeyField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}),
'created': ('model_utils.fields.AutoCreatedField', [], {'default': 'datetime.datetime.now'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'modified': ('model_utils.fields.AutoLastModifiedField', [], {'default': 'datetime.datetime.now'}),
'score': ('django.db.models.fields.IntegerField', [], {'default': '0', 'db_index': 'True'}),
'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"})
},
'social_engagement.studentsocialengagementscorehistory': {
'Meta': {'object_name': 'StudentSocialEngagementScoreHistory'},
'course_id': ('xmodule_django.models.CourseKeyField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}),
'created': ('model_utils.fields.AutoCreatedField', [], {'default': 'datetime.datetime.now'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'modified': ('model_utils.fields.AutoLastModifiedField', [], {'default': 'datetime.datetime.now'}),
'score': ('django.db.models.fields.IntegerField', [], {}),
'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"})
}
}
complete_apps = ['social_engagement']
\ No newline at end of file
"""
Django database models supporting the social_engagement app
"""
from django.contrib.auth.models import User
from django.db import models
from django.db.models import Q
from django.dispatch import receiver
from django.db.models.signals import post_save
from model_utils.models import TimeStampedModel
from xmodule_django.models import CourseKeyField
class StudentSocialEngagementScore(TimeStampedModel):
"""
StudentProgress is essentially a container used to store calculated progress of user
"""
user = models.ForeignKey(User, db_index=True, null=False)
course_id = CourseKeyField(db_index=True, max_length=255, blank=True, null=False)
score = models.IntegerField(default=0, db_index=True, null=False)
class Meta:
"""
Meta information for this Django model
"""
unique_together = (('user', 'course_id'),)
@classmethod
def get_user_engagement_score(cls, course_key, user_id):
"""
Returns the user's current engagement score or None
if there is no record yet
"""
try:
entry = cls.objects.get(course_id__exact=course_key, user__id=user_id)
except cls.DoesNotExist:
return None
return entry.score
@classmethod
def save_user_engagement_score(cls, course_key, user_id, score):
"""
Creates or updates an engagement score
"""
if cls.objects.filter(course_id__exact=course_key, user_id=user_id).exists():
entry = cls.objects.get(course_id__exact=course_key, user_id=user_id)
entry.score = score
else:
entry = cls(course_id=course_key, user_id=user_id, score=score)
entry.save()
@classmethod
def get_user_leaderboard_position(cls, course_key, user_id, exclude_users=None):
"""
Returns user's progress position and completions for a given course.
data = {"score": 22, "position": 4}
"""
data = {"score": 0, "position": 0}
try:
queryset = cls.objects.get(course_id__exact=course_key, user__id=user_id)
except cls.DoesNotExist:
queryset = None
if queryset:
user_score = queryset.score
query = cls.objects.filter(Q(score__gt=user_score),
course_id__exact=course_key, user__is_active=True)
if exclude_users:
query = query.exclude(user__id__in=exclude_users)
users_above = query.count()
data['position'] = users_above + 1 if user_score > 0 else 0
data['score'] = user_score
return data
@classmethod
def generate_leaderboard(cls, course_key, count=3, exclude_users=None, org_ids=None):
"""
Assembles a data set representing the Top N users, by progress, for a given course.
data = [
{'id': 123, 'username': 'testuser1', 'title', 'Engineer', 'avatar_url': 'http://gravatar.com/123/', 'score': 80},
{'id': 983, 'username': 'testuser2', 'title', 'Analyst', 'avatar_url': 'http://gravatar.com/983/', 'score': 70},
{'id': 246, 'username': 'testuser3', 'title', 'Product Owner', 'avatar_url': 'http://gravatar.com/246/', 'score': 62},
{'id': 357, 'username': 'testuser4', 'title', 'Director', 'avatar_url': 'http://gravatar.com/357/', 'completions': 58},
]
"""
queryset = cls.objects\
.filter(course_id__exact=course_key, user__is_active=True, user__courseenrollment__is_active=True,
user__courseenrollment__course_id__exact=course_key)
if exclude_users:
queryset = queryset.exclude(user__id__in=exclude_users)
if org_ids:
queryset = queryset.filter(user__organizations__in=org_ids)
queryset = queryset.values(
'user__id',
'user__username',
'user__profile__title',
'user__profile__avatar_url',
'score')\
.order_by('-score', 'modified')[:count]
return queryset
class StudentSocialEngagementScoreHistory(TimeStampedModel):
"""
A running audit trail for the StudentProgress model. Listens for
post_save events and creates/stores copies of progress entries.
"""
user = models.ForeignKey(User, db_index=True)
course_id = CourseKeyField(db_index=True, max_length=255, blank=True)
score = models.IntegerField()
@receiver(post_save, sender=StudentSocialEngagementScore)
def on_studentengagementscore_save(sender, instance, **kwargs):
"""
When a studentengagementscore is saved, we want to also store the
score value in the history table, so we have a complete history
of the student's engagement score
"""
history_entry = StudentSocialEngagementScoreHistory(
user=instance.user,
course_id=instance.course_id,
score=instance.score
)
history_entry.save()
"""
Tests for the social_engagement app
"""
...@@ -74,10 +74,13 @@ BROKER_HEARTBEAT_CHECKRATE = 2 ...@@ -74,10 +74,13 @@ BROKER_HEARTBEAT_CHECKRATE = 2
# Each worker should only fetch one message at a time # Each worker should only fetch one message at a time
CELERYD_PREFETCH_MULTIPLIER = 1 CELERYD_PREFETCH_MULTIPLIER = 1
if not 'SOUTH_MIGRATION_MODULES' in vars() and not 'SOUTH_MIGRATION_MODULES' in globals():
SOUTH_MIGRATION_MODULES = {}
# Skip djcelery migrations, since we don't use the database as the broker # Skip djcelery migrations, since we don't use the database as the broker
SOUTH_MIGRATION_MODULES = { SOUTH_MIGRATION_MODULES.update({
'djcelery': 'ignore', 'djcelery': 'ignore',
} })
# Rename the exchange and queues for each variant # Rename the exchange and queues for each variant
...@@ -729,3 +732,14 @@ API_ALLOWED_IP_ADDRESSES = ENV_TOKENS.get('API_ALLOWED_IP_ADDRESSES') ...@@ -729,3 +732,14 @@ API_ALLOWED_IP_ADDRESSES = ENV_TOKENS.get('API_ALLOWED_IP_ADDRESSES')
EXCLUDE_MIDDLEWARE_CLASSES = ENV_TOKENS.get('EXCLUDE_MIDDLEWARE_CLASSES', []) EXCLUDE_MIDDLEWARE_CLASSES = ENV_TOKENS.get('EXCLUDE_MIDDLEWARE_CLASSES', [])
MIDDLEWARE_CLASSES = tuple(_class for _class in MIDDLEWARE_CLASSES if _class not in EXCLUDE_MIDDLEWARE_CLASSES) MIDDLEWARE_CLASSES = tuple(_class for _class in MIDDLEWARE_CLASSES if _class not in EXCLUDE_MIDDLEWARE_CLASSES)
##### EDX-NOTIFICATIONS ######
NOTIFICATION_CLICK_LINK_URL_MAPS = ENV_TOKENS.get('NOTIFICATION_CLICK_LINK_URL_MAPS', NOTIFICATION_CLICK_LINK_URL_MAPS)
NOTIFICATION_STORE_PROVIDER = ENV_TOKENS.get('NOTIFICATION_STORE_PROVIDER', NOTIFICATION_STORE_PROVIDER)
NOTIFICATION_CHANNEL_PROVIDERS = ENV_TOKENS.get('NOTIFICATION_CHANNEL_PROVIDERS', NOTIFICATION_CHANNEL_PROVIDERS)
NOTIFICATION_CHANNEL_PROVIDER_TYPE_MAPS = ENV_TOKENS.get(
'NOTIFICATION_CHANNEL_PROVIDER_TYPE_MAPS',
NOTIFICATION_CHANNEL_PROVIDER_TYPE_MAPS
)
NOTIFICATION_MAX_LIST_SIZE = ENV_TOKENS.get('NOTIFICATION_MAX_LIST_SIZE', NOTIFICATION_MAX_LIST_SIZE)
...@@ -437,7 +437,17 @@ FEATURES = { ...@@ -437,7 +437,17 @@ FEATURES = {
'PROJECTS_APP': False, 'PROJECTS_APP': False,
# Enable the Organizations, # Enable the Organizations,
'ORGANIZATIONS_APP': False 'ORGANIZATIONS_APP': False,
# Enable the edx-notifications subssytem
'ENABLE_NOTIFICATIONS': False,
# Whether edx-notifications should use Celery for bulk operations
'ENABLE_NOTIFICATIONS_CELERY': False,
# whether to turn on Social Engagment scoring
# driven through the comment service
'ENABLE_SOCIAL_ENGAGEMENT': False,
} }
# Ignore static asset files on import which match this pattern # Ignore static asset files on import which match this pattern
...@@ -1257,6 +1267,10 @@ base_vendor_js = [ ...@@ -1257,6 +1267,10 @@ base_vendor_js = [
] ]
main_vendor_js = base_vendor_js + [ main_vendor_js = base_vendor_js + [
# Use the RequireJS-namespace.js rather than the 'undefine' version of it
# because otherwise our use of a 'text' plugin for RequireJS will fail
# 'js/RequireJS-namespace-undefine.js',
'js/RequireJS-namespace.js',
'js/vendor/json2.js', 'js/vendor/json2.js',
'js/vendor/jquery-ui.min.js', 'js/vendor/jquery-ui.min.js',
'js/vendor/jquery.qtip.min.js', 'js/vendor/jquery.qtip.min.js',
...@@ -1953,6 +1967,9 @@ INSTALLED_APPS = ( ...@@ -1953,6 +1967,9 @@ INSTALLED_APPS = (
# EDX API application # EDX API application
'api_manager', 'api_manager',
# Social Engagement
'social_engagement',
) )
######################### CSRF ######################################### ######################### CSRF #########################################
...@@ -2600,3 +2617,92 @@ CREDIT_PROVIDER_TIMESTAMP_EXPIRATION = 15 * 60 ...@@ -2600,3 +2617,92 @@ CREDIT_PROVIDER_TIMESTAMP_EXPIRATION = 15 * 60
# not expected to be active; this setting simply allows administrators to # not expected to be active; this setting simply allows administrators to
# route any messages intended for LTI users to a common domain. # route any messages intended for LTI users to a common domain.
LTI_USER_EMAIL_DOMAIN = 'lti.example.com' LTI_USER_EMAIL_DOMAIN = 'lti.example.com'
# TODO (ECOM-16): Remove once the A/B test of auto-registration completes
AUTO_REGISTRATION_AB_TEST_EXCLUDE_COURSES = set([
"HarvardX/SW12.2x/1T2014",
"HarvardX/SW12.3x/1T2014",
"HarvardX/SW12.4x/1T2014",
"HarvardX/SW12.5x/2T2014",
"HarvardX/SW12.6x/2T2014",
"HarvardX/HUM2.1x/3T2014",
"HarvardX/SW12x/2013_SOND",
"LinuxFoundationX/LFS101x/2T2014",
"HarvardX/CS50x/2014_T1",
"HarvardX/AmPoX.1/2014_T3",
"HarvardX/SW12.7x/3T2014",
"HarvardX/SW12.10x/1T2015",
"HarvardX/SW12.9x/3T2014",
"HarvardX/SW12.8x/3T2014",
])
################################### EDX-NOTIFICATIONS SUBSYSTEM ######################################
INSTALLED_APPS += (
'edx_notifications',
'edx_notifications.server.web',
)
TEMPLATE_LOADERS += (
'django.template.loaders.filesystem.Loader',
'django.template.loaders.app_directories.Loader',
)
NOTIFICATION_STORE_PROVIDER = {
"class": "edx_notifications.stores.sql.store_provider.SQLNotificationStoreProvider",
"options": {
}
}
if not 'SOUTH_MIGRATION_MODULES' in vars() and not 'SOUTH_MIGRATION_MODULES' in globals():
SOUTH_MIGRATION_MODULES = {}
SOUTH_MIGRATION_MODULES.update({
'edx_notifications': 'edx_notifications.stores.sql.migrations',
})
# to prevent run-away queries from happening
NOTIFICATION_MAX_LIST_SIZE = 100
#
# Various mapping tables which is used by the MsgTypeToUrlLinkResolver
# to map a notification type to a statically defined URL path
#
# NOTE: NOTIFICATION_CLICK_LINK_GROUP_URLS will usually get read in by the *.envs.json file
#
NOTIFICATION_CLICK_LINK_URL_MAPS = {
'open-edx.studio.announcements.*': '/courses/{course_id}/announcements',
'open-edx.lms.leaderboard.*': '/courses/{course_id}/cohort',
'open-edx.lms.discussions.*': '/courses/{course_id}/discussion/{commentable_id}/threads/{thread_id}',
'open-edx.xblock.group-project.*': '/courses/{course_id}/group_work?seqid={activity_location}',
}
# list all known channel providers
NOTIFICATION_CHANNEL_PROVIDERS = {
'durable': {
'class': 'edx_notifications.channels.durable.BaseDurableNotificationChannel',
'options': {
# list out all link resolvers
'link_resolvers': {
# right now the only defined resolver is 'type_to_url', which
# attempts to look up the msg type (key) via
# matching on the value
'msg_type_to_url': {
'class': 'edx_notifications.channels.link_resolvers.MsgTypeToUrlLinkResolver',
'config': {
'_click_link': NOTIFICATION_CLICK_LINK_URL_MAPS,
}
}
}
}
},
'null': {
'class': 'edx_notifications.channels.null.NullNotificationChannel',
'options': {}
}
}
# list all of the mappings of notification types to channel
NOTIFICATION_CHANNEL_PROVIDER_TYPE_MAPS = {
'*': 'durable', # default global mapping
}
...@@ -197,6 +197,21 @@ class Thread(models.Model): ...@@ -197,6 +197,21 @@ class Thread(models.Model):
) )
self._update_from_response(response) self._update_from_response(response)
def get_num_followers(self, include_self_follow=False):
url = _url_for_num_thread_followers(self.id)
params = {}
if not include_self_follow:
params ={
'exclude_user_id': self.user_id
}
response = perform_request(
'get',
url,
params
)
return response['num_followers']
def get_course_thread_stats(course_id): def get_course_thread_stats(course_id):
""" """
...@@ -228,3 +243,6 @@ def _url_for_un_pin_thread(thread_id): ...@@ -228,3 +243,6 @@ def _url_for_un_pin_thread(thread_id):
def _url_for_course_thread_stats(course_id): def _url_for_course_thread_stats(course_id):
return "{prefix}/courses/{course_id}/stats".format(prefix=settings.PREFIX, course_id=course_id) return "{prefix}/courses/{course_id}/stats".format(prefix=settings.PREFIX, course_id=course_id)
def _url_for_num_thread_followers(thread_id):
return "{prefix}/threads/{thread_id}/num_followers".format(prefix=settings.PREFIX, thread_id=thread_id)
...@@ -15,9 +15,15 @@ import logging ...@@ -15,9 +15,15 @@ import logging
from monkey_patch import django_utils_translation from monkey_patch import django_utils_translation
import analytics import analytics
from openedx.core.djangoapps.course_groups.scope_resolver import CourseGroupScopeResolver
from student.scope_resolver import CourseEnrollmentsScopeResolver, StudentEmailScopeResolver
from projects.scope_resolver import GroupProjectParticipantsScopeResolver
from edx_notifications.scopes import register_user_scope_resolver
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
from edx_notifications import startup
def run(): def run():
""" """
...@@ -29,6 +35,9 @@ def run(): ...@@ -29,6 +35,9 @@ def run():
add_mimetypes() add_mimetypes()
if settings.FEATURES.get('ENABLE_NOTIFICATIONS', False):
startup_notification_subsystem()
if settings.FEATURES.get('USE_CUSTOM_THEME', False): if settings.FEATURES.get('USE_CUSTOM_THEME', False):
enable_theme() enable_theme()
...@@ -142,3 +151,29 @@ def enable_third_party_auth(): ...@@ -142,3 +151,29 @@ def enable_third_party_auth():
from third_party_auth import settings as auth_settings from third_party_auth import settings as auth_settings
auth_settings.apply_settings(settings) auth_settings.apply_settings(settings)
def startup_notification_subsystem():
"""
Initialize the Notification subsystem
"""
try:
startup.initialize()
# register the two scope resolvers that the LMS will be providing
# to edx-notifications
register_user_scope_resolver('course_enrollments', CourseEnrollmentsScopeResolver())
register_user_scope_resolver('course_group', CourseGroupScopeResolver())
register_user_scope_resolver('group_project_participants', GroupProjectParticipantsScopeResolver())
register_user_scope_resolver('group_project_workgroup', GroupProjectParticipantsScopeResolver())
register_user_scope_resolver('student_email_resolver', StudentEmailScopeResolver())
except Exception, ex:
# Note this will fail when we try to run migrations as manage.py will call startup.py
# and startup.initialze() will try to manipulate some database tables.
# We need to research how to identify when we are being started up as part of
# a migration script
log.error(
'There was a problem initializing notifications subsystem. '
'This could be because the database tables have not yet been created and '
'./manage.py lms syncdb needs to run setup.py. Error was "{err_msg}". Continuing...'.format(err_msg=str(ex))
)
...@@ -724,3 +724,9 @@ urlpatterns += ( ...@@ -724,3 +724,9 @@ urlpatterns += (
url(r'^404$', handler404), url(r'^404$', handler404),
url(r'^500$', handler500), url(r'^500$', handler500),
) )
if settings.FEATURES.get('ENABLE_NOTIFICATIONS'):
# include into our URL patterns the HTTP RESTfule API that comes with edx-notifications
urlpatterns += (
url(r'^api/', include('edx_notifications.server.api.urls')),
)
"""
A User Scope Resolver that can be used by edx-notifications
"""
import logging
from edx_notifications.scopes import NotificationUserScopeResolver
from .models import CourseUserGroup
log = logging.getLogger(__name__)
class CourseGroupScopeResolver(NotificationUserScopeResolver):
"""
Implementation of the NotificationUserScopeResolver abstract
interface defined in edx-notifications.
An instance of this class will be registered to handle
scope_name = 'course_group' during system startup.
We will be passed in a group_id in the context
and we must return a Django ORM resultset or None if
we cannot match
"""
def resolve(self, scope_name, scope_context, instance_context):
"""
The entry point to resolve a scope_name with a given scope_context
"""
if scope_name != 'course_group':
# we can't resolve any other scopes
return None
if 'group_id' not in scope_context:
# did not receive expected parameters
return None
return CourseUserGroup.objects.values_list('users', flat=True).filter(
id=scope_context['group_id']
)
"""
This file contains celery tasks for student course enrollment
"""
from celery.task import task
from .models import CourseUserGroup
from edx_notifications.lib.publisher import bulk_publish_notification_to_users
from student.models import CourseEnrollment
import logging
log = logging.getLogger(__name__)
@task()
def publish_course_group_notification_task(course_group_id, notification_msg, exclude_user_ids=None): # pylint: disable=invalid-name
"""
This function will call the edx_notifications api method "bulk_publish_notification_to_users"
and run as a new Celery task in order to broadcast a message to an entire course cohort
"""
# get the enrolled and active user_id list for this course.
user_ids = CourseUserGroup.objects.values_list('users', flat=True).filter(
id=course_group_id
)
try:
bulk_publish_notification_to_users(user_ids, notification_msg, exclude_user_ids=exclude_user_ids)
except Exception, ex:
# Notifications are never critical, so we don't want to disrupt any
# other logic processing. So log and continue.
log.exception(ex)
"""
Test cases for scope_resolver.py
"""
from django.test import TestCase
from student.tests.factories import UserFactory
from course_groups.scope_resolver import CourseGroupScopeResolver
from .test_views import CohortFactory
class ScopeResolverTests(TestCase):
"""
Tests for the scope resolver
"""
def setUp(self):
"""Creates cohorts for testing"""
self.course_id = 'foo/bar/baz'
self.cohort1_users = [UserFactory.create() for _ in range(3)]
self.cohort2_users = [UserFactory.create() for _ in range(2)]
self.cohort3_users = [UserFactory.create() for _ in range(2)]
self.cohort1 = CohortFactory.create(course_id=self.course_id, users=self.cohort1_users)
self.cohort2 = CohortFactory.create(course_id=self.course_id, users=self.cohort2_users)
self.cohort3 = CohortFactory.create(course_id=self.course_id, users=self.cohort3_users)
def test_resolve_cohort(self):
"""
Given the defined cohorts in the setUp, make sure the
"""
resolver = CourseGroupScopeResolver()
user_ids = resolver.resolve('course_group', {'group_id': self.cohort1.id}, None).all()
self.assertEqual(
[user_id for user_id in user_ids],
[user.id for user in self.cohort1_users]
)
def test_bad_params(self):
"""
Given the defined cohorts in the setUp, make sure the
"""
resolver = CourseGroupScopeResolver()
self.assertIsNone(resolver.resolve('bad', {'group_id': self.cohort1.id}, None))
self.assertIsNone(resolver.resolve('course_group', {'bad': self.cohort1.id}, None))
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment