diff --git a/cms/djangoapps/contentstore/admin.py b/cms/djangoapps/contentstore/admin.py index 6fdb475b37..21904959f8 100644 --- a/cms/djangoapps/contentstore/admin.py +++ b/cms/djangoapps/contentstore/admin.py @@ -5,6 +5,7 @@ Admin site bindings for contentstore from django.contrib import admin from config_models.admin import ConfigurationModelAdmin -from contentstore.models import VideoUploadConfig +from contentstore.models import VideoUploadConfig, PushNotificationConfig admin.site.register(VideoUploadConfig, ConfigurationModelAdmin) +admin.site.register(PushNotificationConfig, ConfigurationModelAdmin) diff --git a/cms/djangoapps/contentstore/course_info_model.py b/cms/djangoapps/contentstore/course_info_model.py index 692f7a9dd4..7ba75ff215 100644 --- a/cms/djangoapps/contentstore/course_info_model.py +++ b/cms/djangoapps/contentstore/course_info_model.py @@ -23,6 +23,7 @@ from xmodule.modulestore.django import modulestore from xmodule.html_module import CourseInfoModule from xmodule_modifiers import get_course_update_items +from cms.djangoapps.contentstore.push_notification import enqueue_push_course_update # # This should be in a class which inherits from XmlDescriptor log = logging.getLogger(__name__) @@ -44,9 +45,13 @@ def get_course_updates(location, provided_id, user_id): def update_course_updates(location, update, passed_id=None, user=None): """ - Either add or update the given course update. It will add it if the passed_id is absent or None. It will update it if - it has an passed_id which has a valid value. Until updates have distinct values, the passed_id is the location url + an index - into the html structure. + Either add or update the given course update. + Add: + If the passed_id is absent or None, the course update is added. + If push_notification_selected is set in the update, a celery task for the push notification is created. + Update: + It will update it if it has a passed_id which has a valid value. + Until updates have distinct values, the passed_id is the location url + an index into the html structure. """ try: course_updates = modulestore().get_item(location) @@ -73,6 +78,7 @@ def update_course_updates(location, update, passed_id=None, user=None): "status": CourseInfoModule.STATUS_VISIBLE } course_update_items.append(course_update_dict) + enqueue_push_course_update(update, location.course_key) # update db record save_course_update_items(location, course_updates, course_update_items, user) diff --git a/cms/djangoapps/contentstore/features/course-updates.py b/cms/djangoapps/contentstore/features/course-updates.py index 6a39a29fee..afc02aa424 100644 --- a/cms/djangoapps/contentstore/features/course-updates.py +++ b/cms/djangoapps/contentstore/features/course-updates.py @@ -45,37 +45,37 @@ def check_no_update(_step, text): @step(u'I modify the text to "([^"]*)"$') def modify_update(_step, text): - button_css = 'div.post-preview a.edit-button' + button_css = 'div.post-preview .edit-button' world.css_click(button_css) change_text(text) @step(u'I change the update from "([^"]*)" to "([^"]*)"$') def change_existing_update(_step, before, after): - verify_text_in_editor_and_update('div.post-preview a.edit-button', before, after) + verify_text_in_editor_and_update('div.post-preview .edit-button', before, after) @step(u'I change the handout from "([^"]*)" to "([^"]*)"$') def change_existing_handout(_step, before, after): - verify_text_in_editor_and_update('div.course-handouts a.edit-button', before, after) + verify_text_in_editor_and_update('div.course-handouts .edit-button', before, after) @step(u'I delete the update$') def click_button(_step): - button_css = 'div.post-preview a.delete-button' + button_css = 'div.post-preview .delete-button' world.css_click(button_css) @step(u'I edit the date to "([^"]*)"$') def change_date(_step, new_date): - button_css = 'div.post-preview a.edit-button' + button_css = 'div.post-preview .edit-button' world.css_click(button_css) date_css = 'input.date' date = world.css_find(date_css) for i in range(len(date.value)): date._element.send_keys(Keys.END, Keys.BACK_SPACE) date._element.send_keys(new_date) - save_css = 'a.save-button' + save_css = '.save-button' world.css_click(save_css) @@ -87,7 +87,7 @@ def check_date(_step, date): @step(u'I modify the handout to "([^"]*)"$') def edit_handouts(_step, text): - edit_css = 'div.course-handouts > a.edit-button' + edit_css = 'div.course-handouts > .edit-button' world.css_click(edit_css) change_text(text) @@ -114,7 +114,7 @@ def check_handout_error(_step): @step(u'I see handout save button disabled') def check_handout_error(_step): - handout_save_button = 'form.edit-handouts-form a.save-button' + handout_save_button = 'form.edit-handouts-form .save-button' assert world.css_has_class(handout_save_button, 'is-disabled') @@ -125,19 +125,19 @@ def edit_handouts(_step, text): @step(u'I see handout save button re-enabled') def check_handout_error(_step): - handout_save_button = 'form.edit-handouts-form a.save-button' + handout_save_button = 'form.edit-handouts-form .save-button' assert not world.css_has_class(handout_save_button, 'is-disabled') @step(u'I save handout edit') def check_handout_error(_step): - save_css = 'a.save-button' + save_css = '.save-button' world.css_click(save_css) def change_text(text): type_in_codemirror(0, text) - save_css = 'a.save-button' + save_css = '.save-button' world.css_click(save_css) diff --git a/cms/djangoapps/contentstore/migrations/0003_auto__add_pushnotificationconfig.py b/cms/djangoapps/contentstore/migrations/0003_auto__add_pushnotificationconfig.py new file mode 100644 index 0000000000..7963916271 --- /dev/null +++ b/cms/djangoapps/contentstore/migrations/0003_auto__add_pushnotificationconfig.py @@ -0,0 +1,80 @@ +# -*- coding: utf-8 -*- +from south.utils import datetime_utils as 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 'PushNotificationConfig' + db.create_table('contentstore_pushnotificationconfig', ( + ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + ('change_date', self.gf('django.db.models.fields.DateTimeField')(auto_now_add=True, blank=True)), + ('changed_by', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['auth.User'], null=True, on_delete=models.PROTECT)), + ('enabled', self.gf('django.db.models.fields.BooleanField')(default=False)), + )) + db.send_create_signal('contentstore', ['PushNotificationConfig']) + + + def backwards(self, orm): + # Deleting model 'PushNotificationConfig' + db.delete_table('contentstore_pushnotificationconfig') + + + 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'}) + }, + 'contentstore.pushnotificationconfig': { + 'Meta': {'object_name': 'PushNotificationConfig'}, + 'change_date': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), + 'changed_by': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'null': 'True', 'on_delete': 'models.PROTECT'}), + 'enabled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}) + }, + 'contentstore.videouploadconfig': { + 'Meta': {'object_name': 'VideoUploadConfig'}, + 'change_date': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), + 'changed_by': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'null': 'True', 'on_delete': 'models.PROTECT'}), + 'enabled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'profile_whitelist': ('django.db.models.fields.TextField', [], {'blank': 'True'}) + }, + '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'}) + } + } + + complete_apps = ['contentstore'] \ No newline at end of file diff --git a/cms/djangoapps/contentstore/models.py b/cms/djangoapps/contentstore/models.py index d2a7973892..d2112bd9f1 100644 --- a/cms/djangoapps/contentstore/models.py +++ b/cms/djangoapps/contentstore/models.py @@ -19,3 +19,7 @@ class VideoUploadConfig(ConfigurationModel): def get_profile_whitelist(cls): """Get the list of profiles to include in the encoding download""" return [profile for profile in cls.current().profile_whitelist.split(",") if profile] + + +class PushNotificationConfig(ConfigurationModel): + """Configuration for mobile push notifications.""" diff --git a/cms/djangoapps/contentstore/push_notification.py b/cms/djangoapps/contentstore/push_notification.py new file mode 100644 index 0000000000..d6e8f65737 --- /dev/null +++ b/cms/djangoapps/contentstore/push_notification.py @@ -0,0 +1,62 @@ +""" +Helper methods for push notifications from Studio. +""" + +from django.conf import settings +from logging import exception as log_exception + +from contentstore.tasks import push_course_update_task +from contentstore.models import PushNotificationConfig +from xmodule.modulestore.django import modulestore +from parse_rest.installation import Push +from parse_rest.connection import register +from parse_rest.core import ParseError + + +def push_notification_enabled(): + """ + Returns whether the push notification feature is enabled. + """ + return PushNotificationConfig.is_enabled() + + +def enqueue_push_course_update(update, course_key): + """ + Enqueues a task for push notification for the given update for the given course if + (1) the feature is enabled and + (2) push_notification is selected for the update + """ + if push_notification_enabled() and update.get("push_notification_selected"): + course = modulestore().get_course(course_key) + if course: + push_course_update_task.delay( + unicode(course_key), + course.clean_id(padding_char='_'), + course.display_name + ) + + +def send_push_course_update(course_key_string, course_subscription_id, course_display_name): + """ + Sends a push notification for a course update, given the course's subscription_id and display_name. + """ + if settings.PARSE_KEYS: + try: + register( + settings.PARSE_KEYS["APPLICATION_ID"], + settings.PARSE_KEYS["REST_API_KEY"], + ) + Push.alert( + data={ + "course-id": course_key_string, + "action": "course.announcement", + "action-loc-key": "VIEW_BUTTON", + "loc-key": "COURSE_ANNOUNCEMENT_NOTIFICATION_BODY", + "loc-args": [course_display_name], + "title-loc-key": "COURSE_ANNOUNCEMENT_NOTIFICATION_TITLE", + "title-loc-args": [], + }, + channels=[course_subscription_id], + ) + except ParseError as error: + log_exception(error.message) diff --git a/cms/djangoapps/contentstore/tasks.py b/cms/djangoapps/contentstore/tasks.py index 9b3c24900c..b67600e238 100644 --- a/cms/djangoapps/contentstore/tasks.py +++ b/cms/djangoapps/contentstore/tasks.py @@ -115,3 +115,13 @@ def update_library_index(library_id, triggered_time_isoformat): LOGGER.error('Search indexing error for library %s - %s', library_id, unicode(exc)) else: LOGGER.debug('Search indexing successful for library %s', library_id) + + +@task() +def push_course_update_task(course_key_string, course_subscription_id, course_display_name): + """ + Sends a push notification for a course update. + """ + # TODO Use edx-notifications library instead (MA-638). + from .push_notification import send_push_course_update + send_push_course_update(course_key_string, course_subscription_id, course_display_name) diff --git a/cms/djangoapps/contentstore/tests/tests.py b/cms/djangoapps/contentstore/tests/tests.py index 350ef47f17..710351ae4c 100644 --- a/cms/djangoapps/contentstore/tests/tests.py +++ b/cms/djangoapps/contentstore/tests/tests.py @@ -6,12 +6,14 @@ import mock import unittest from ddt import ddt, data, unpack +from django.test import TestCase from django.test.utils import override_settings from django.core.cache import cache from django.conf import settings from django.contrib.auth.models import User from django.core.urlresolvers import reverse +from contentstore.models import PushNotificationConfig from contentstore.tests.utils import parse_json, user, registration, AjaxEnabledTestClient from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from contentstore.tests.test_course_settings import CourseTestCase @@ -349,3 +351,15 @@ class CourseKeyVerificationTestCase(CourseTestCase): ) resp = self.client.get_html(url) self.assertEqual(resp.status_code, status_code) + + +class PushNotificationConfigTestCase(TestCase): + """ + Tests PushNotificationConfig. + """ + def test_notifications_defaults(self): + self.assertFalse(PushNotificationConfig.is_enabled()) + + def test_notifications_enabled(self): + PushNotificationConfig(enabled=True).save() + self.assertTrue(PushNotificationConfig.is_enabled()) diff --git a/cms/djangoapps/contentstore/views/course.py b/cms/djangoapps/contentstore/views/course.py index d205457faa..916435fa05 100644 --- a/cms/djangoapps/contentstore/views/course.py +++ b/cms/djangoapps/contentstore/views/course.py @@ -69,6 +69,7 @@ from contentstore.views.entrance_exam import ( from .library import LIBRARIES_ENABLED from .item import create_xblock_info +from contentstore.push_notification import push_notification_enabled from course_creators.views import get_course_creator_status, add_user_with_status_unrequested from contentstore import utils from student.roles import ( @@ -778,7 +779,8 @@ def course_info_handler(request, course_key_string): 'context_course': course_module, 'updates_url': reverse_course_url('course_info_update_handler', course_key), 'handouts_locator': course_key.make_usage_key('course_info', 'handouts'), - 'base_asset_url': StaticContent.get_base_url_path_for_course_assets(course_module.id) + 'base_asset_url': StaticContent.get_base_url_path_for_course_assets(course_module.id), + 'push_notification_enabled': push_notification_enabled() } ) else: diff --git a/cms/djangoapps/contentstore/views/tests/test_course_updates.py b/cms/djangoapps/contentstore/views/tests/test_course_updates.py index a45c7b1db5..cb6a725a6c 100644 --- a/cms/djangoapps/contentstore/views/tests/test_course_updates.py +++ b/cms/djangoapps/contentstore/views/tests/test_course_updates.py @@ -2,7 +2,10 @@ unit tests for course_info views and models. """ import json +from mock import patch +from django.test.utils import override_settings +from contentstore.models import PushNotificationConfig from contentstore.tests.test_course_settings import CourseTestCase from contentstore.utils import reverse_course_url, reverse_usage_url from opaque_keys.edx.keys import UsageKey @@ -234,18 +237,19 @@ class CourseUpdateTest(CourseTestCase): payload = json.loads(resp.content) self.assertTrue(len(payload) == 1) - def test_post_course_update(self): + def post_course_update(self, send_push_notification=False): """ - Test that a user can successfully post on course updates and handouts of a course + Posts an update to the course """ course_update_url = self.create_update_url(course_key=self.course.id) # create a course via the view handler self.client.ajax_post(course_update_url) - block = u'updates' content = u"Sample update" payload = {'content': content, 'date': 'January 8, 2013'} + if send_push_notification: + payload['push_notification_selected'] = True resp = self.client.ajax_post(course_update_url, payload) # check that response status is 200 not 400 @@ -254,9 +258,19 @@ class CourseUpdateTest(CourseTestCase): payload = json.loads(resp.content) self.assertHTMLEqual(payload['content'], content) + @patch("contentstore.push_notification.send_push_course_update") + def test_post_course_update(self, mock_push_update): + """ + Test that a user can successfully post on course updates and handouts of a course + """ + self.post_course_update() + + # check that push notifications are not sent + self.assertFalse(mock_push_update.called) + updates_location = self.course.id.make_usage_key('course_info', 'updates') self.assertTrue(isinstance(updates_location, UsageKey)) - self.assertEqual(updates_location.name, block) + self.assertEqual(updates_location.name, u'updates') # check posting on handouts handouts_location = self.course.id.make_usage_key('course_info', 'handouts') @@ -265,8 +279,28 @@ class CourseUpdateTest(CourseTestCase): content = u"Sample handout" payload = {'data': content} resp = self.client.ajax_post(course_handouts_url, payload) + # check that response status is 200 not 500 self.assertEqual(resp.status_code, 200) payload = json.loads(resp.content) self.assertHTMLEqual(payload['data'], content) + + @patch("contentstore.push_notification.send_push_course_update") + def test_notifications_enabled_but_not_requested(self, mock_push_update): + PushNotificationConfig(enabled=True).save() + self.post_course_update() + self.assertFalse(mock_push_update.called) + + @patch("contentstore.push_notification.send_push_course_update") + def test_notifications_enabled_and_sent(self, mock_push_update): + PushNotificationConfig(enabled=True).save() + self.post_course_update(send_push_notification=True) + self.assertTrue(mock_push_update.called) + + @override_settings(PARSE_KEYS={"APPLICATION_ID": "TEST_APPLICATION_ID", "REST_API_KEY": "TEST_REST_API_KEY"}) + @patch("contentstore.push_notification.Push") + def test_notifications_sent_to_parse(self, mock_parse_push): + PushNotificationConfig(enabled=True).save() + self.post_course_update(send_push_notification=True) + self.assertTrue(mock_parse_push.alert.called) diff --git a/cms/envs/aws.py b/cms/envs/aws.py index 3eb5dc99b8..319ed99e46 100644 --- a/cms/envs/aws.py +++ b/cms/envs/aws.py @@ -320,6 +320,11 @@ DEPRECATED_ADVANCED_COMPONENT_TYPES = ENV_TOKENS.get( VIDEO_UPLOAD_PIPELINE = ENV_TOKENS.get('VIDEO_UPLOAD_PIPELINE', VIDEO_UPLOAD_PIPELINE) +################ PUSH NOTIFICATIONS ############### + +PARSE_KEYS = AUTH_TOKENS.get("PARSE_KEYS", {}) + + #date format the api will be formatting the datetime values API_DATE_FORMAT = '%Y-%m-%d' API_DATE_FORMAT = ENV_TOKENS.get('API_DATE_FORMAT', API_DATE_FORMAT) diff --git a/cms/static/coffee/spec/views/course_info_spec.coffee b/cms/static/coffee/spec/views/course_info_spec.coffee index e9846b80d7..8bf4e41f36 100644 --- a/cms/static/coffee/spec/views/course_info_spec.coffee +++ b/cms/static/coffee/spec/views/course_info_spec.coffee @@ -22,7 +22,7 @@ define ["js/views/course_info_handout", "js/views/course_info_update", "js/model delete window.analytics delete window.course_location_analytics - describe "Course Updates", -> + describe "Course Updates without Push notification", -> courseInfoTemplate = readFixtures('course_info_update.underscore') beforeEach -> @@ -100,7 +100,7 @@ define ["js/views/course_info_handout", "js/views/course_info_update", "js/model else modalCover.click() - it "does not rewrite links on save", -> + it "does send expected data on save", -> requests = AjaxHelpers["requests"](this) # Create a new update, verifying that the model is created @@ -116,9 +116,12 @@ define ["js/views/course_info_handout", "js/views/course_info_update", "js/model @courseInfoEdit.$el.find('.save-button').click() expect(model.save).toHaveBeenCalled() - # Verify content sent to server does not have rewritten links. - contentSaved = JSON.parse(requests[requests.length - 1].requestBody).content - expect(contentSaved).toEqual('/static/image.jpg') + # Verify push_notification_selected is set to false. + requestSent = JSON.parse(requests[requests.length - 1].requestBody) + expect(requestSent.push_notification_selected).toEqual(false) + + # Verify the link is not rewritten when saved. + expect(requestSent.content).toEqual('/static/image.jpg') it "does rewrite links for preview", -> # Create a new update. @@ -147,6 +150,41 @@ define ["js/views/course_info_handout", "js/views/course_info_update", "js/model it "does not remove existing course info on click outside modal", -> @cancelExistingCourseInfo(false) + describe "Course Updates WITH Push notification", -> + courseInfoTemplate = readFixtures('course_info_update.underscore') + + beforeEach -> + setFixtures($("