Commit 9dfb68c2 by Nimisha Asthagiri

Merge pull request #7676 from edx/mobile/notifications-backend

Mobile/notifications
parents 2fed57a0 11044ebd
...@@ -5,6 +5,7 @@ Admin site bindings for contentstore ...@@ -5,6 +5,7 @@ Admin site bindings for contentstore
from django.contrib import admin from django.contrib import admin
from config_models.admin import ConfigurationModelAdmin 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(VideoUploadConfig, ConfigurationModelAdmin)
admin.site.register(PushNotificationConfig, ConfigurationModelAdmin)
...@@ -23,6 +23,7 @@ from xmodule.modulestore.django import modulestore ...@@ -23,6 +23,7 @@ from xmodule.modulestore.django import modulestore
from xmodule.html_module import CourseInfoModule from xmodule.html_module import CourseInfoModule
from xmodule_modifiers import get_course_update_items 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 # # This should be in a class which inherits from XmlDescriptor
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
...@@ -44,9 +45,13 @@ def get_course_updates(location, provided_id, user_id): ...@@ -44,9 +45,13 @@ def get_course_updates(location, provided_id, user_id):
def update_course_updates(location, update, passed_id=None, user=None): 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 Either add or update the given course update.
it has an passed_id which has a valid value. Until updates have distinct values, the passed_id is the location url + an index Add:
into the html structure. 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: try:
course_updates = modulestore().get_item(location) course_updates = modulestore().get_item(location)
...@@ -73,6 +78,7 @@ def update_course_updates(location, update, passed_id=None, user=None): ...@@ -73,6 +78,7 @@ def update_course_updates(location, update, passed_id=None, user=None):
"status": CourseInfoModule.STATUS_VISIBLE "status": CourseInfoModule.STATUS_VISIBLE
} }
course_update_items.append(course_update_dict) course_update_items.append(course_update_dict)
enqueue_push_course_update(update, location.course_key)
# update db record # update db record
save_course_update_items(location, course_updates, course_update_items, user) save_course_update_items(location, course_updates, course_update_items, user)
......
...@@ -45,37 +45,37 @@ def check_no_update(_step, text): ...@@ -45,37 +45,37 @@ def check_no_update(_step, text):
@step(u'I modify the text to "([^"]*)"$') @step(u'I modify the text to "([^"]*)"$')
def modify_update(_step, text): 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) world.css_click(button_css)
change_text(text) change_text(text)
@step(u'I change the update from "([^"]*)" to "([^"]*)"$') @step(u'I change the update from "([^"]*)" to "([^"]*)"$')
def change_existing_update(_step, before, after): 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 "([^"]*)"$') @step(u'I change the handout from "([^"]*)" to "([^"]*)"$')
def change_existing_handout(_step, before, after): 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$') @step(u'I delete the update$')
def click_button(_step): def click_button(_step):
button_css = 'div.post-preview a.delete-button' button_css = 'div.post-preview .delete-button'
world.css_click(button_css) world.css_click(button_css)
@step(u'I edit the date to "([^"]*)"$') @step(u'I edit the date to "([^"]*)"$')
def change_date(_step, new_date): 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) world.css_click(button_css)
date_css = 'input.date' date_css = 'input.date'
date = world.css_find(date_css) date = world.css_find(date_css)
for i in range(len(date.value)): for i in range(len(date.value)):
date._element.send_keys(Keys.END, Keys.BACK_SPACE) date._element.send_keys(Keys.END, Keys.BACK_SPACE)
date._element.send_keys(new_date) date._element.send_keys(new_date)
save_css = 'a.save-button' save_css = '.save-button'
world.css_click(save_css) world.css_click(save_css)
...@@ -87,7 +87,7 @@ def check_date(_step, date): ...@@ -87,7 +87,7 @@ def check_date(_step, date):
@step(u'I modify the handout to "([^"]*)"$') @step(u'I modify the handout to "([^"]*)"$')
def edit_handouts(_step, text): 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) world.css_click(edit_css)
change_text(text) change_text(text)
...@@ -114,7 +114,7 @@ def check_handout_error(_step): ...@@ -114,7 +114,7 @@ def check_handout_error(_step):
@step(u'I see handout save button disabled') @step(u'I see handout save button disabled')
def check_handout_error(_step): 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') assert world.css_has_class(handout_save_button, 'is-disabled')
...@@ -125,19 +125,19 @@ def edit_handouts(_step, text): ...@@ -125,19 +125,19 @@ def edit_handouts(_step, text):
@step(u'I see handout save button re-enabled') @step(u'I see handout save button re-enabled')
def check_handout_error(_step): 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') assert not world.css_has_class(handout_save_button, 'is-disabled')
@step(u'I save handout edit') @step(u'I save handout edit')
def check_handout_error(_step): def check_handout_error(_step):
save_css = 'a.save-button' save_css = '.save-button'
world.css_click(save_css) world.css_click(save_css)
def change_text(text): def change_text(text):
type_in_codemirror(0, text) type_in_codemirror(0, text)
save_css = 'a.save-button' save_css = '.save-button'
world.css_click(save_css) world.css_click(save_css)
......
# -*- 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
...@@ -19,3 +19,7 @@ class VideoUploadConfig(ConfigurationModel): ...@@ -19,3 +19,7 @@ class VideoUploadConfig(ConfigurationModel):
def get_profile_whitelist(cls): def get_profile_whitelist(cls):
"""Get the list of profiles to include in the encoding download""" """Get the list of profiles to include in the encoding download"""
return [profile for profile in cls.current().profile_whitelist.split(",") if profile] return [profile for profile in cls.current().profile_whitelist.split(",") if profile]
class PushNotificationConfig(ConfigurationModel):
"""Configuration for mobile push notifications."""
"""
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)
...@@ -115,3 +115,13 @@ def update_library_index(library_id, triggered_time_isoformat): ...@@ -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)) LOGGER.error('Search indexing error for library %s - %s', library_id, unicode(exc))
else: else:
LOGGER.debug('Search indexing successful for library %s', library_id) 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)
...@@ -6,12 +6,14 @@ import mock ...@@ -6,12 +6,14 @@ import mock
import unittest import unittest
from ddt import ddt, data, unpack from ddt import ddt, data, unpack
from django.test import TestCase
from django.test.utils import override_settings from django.test.utils import override_settings
from django.core.cache import cache from django.core.cache import cache
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from contentstore.models import PushNotificationConfig
from contentstore.tests.utils import parse_json, user, registration, AjaxEnabledTestClient from contentstore.tests.utils import parse_json, user, registration, AjaxEnabledTestClient
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from contentstore.tests.test_course_settings import CourseTestCase from contentstore.tests.test_course_settings import CourseTestCase
...@@ -349,3 +351,15 @@ class CourseKeyVerificationTestCase(CourseTestCase): ...@@ -349,3 +351,15 @@ class CourseKeyVerificationTestCase(CourseTestCase):
) )
resp = self.client.get_html(url) resp = self.client.get_html(url)
self.assertEqual(resp.status_code, status_code) 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())
...@@ -69,6 +69,7 @@ from contentstore.views.entrance_exam import ( ...@@ -69,6 +69,7 @@ from contentstore.views.entrance_exam import (
from .library import LIBRARIES_ENABLED from .library import LIBRARIES_ENABLED
from .item import create_xblock_info 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 course_creators.views import get_course_creator_status, add_user_with_status_unrequested
from contentstore import utils from contentstore import utils
from student.roles import ( from student.roles import (
...@@ -778,7 +779,8 @@ def course_info_handler(request, course_key_string): ...@@ -778,7 +779,8 @@ def course_info_handler(request, course_key_string):
'context_course': course_module, 'context_course': course_module,
'updates_url': reverse_course_url('course_info_update_handler', course_key), 'updates_url': reverse_course_url('course_info_update_handler', course_key),
'handouts_locator': course_key.make_usage_key('course_info', 'handouts'), '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: else:
......
...@@ -2,7 +2,10 @@ ...@@ -2,7 +2,10 @@
unit tests for course_info views and models. unit tests for course_info views and models.
""" """
import json 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.tests.test_course_settings import CourseTestCase
from contentstore.utils import reverse_course_url, reverse_usage_url from contentstore.utils import reverse_course_url, reverse_usage_url
from opaque_keys.edx.keys import UsageKey from opaque_keys.edx.keys import UsageKey
...@@ -234,18 +237,19 @@ class CourseUpdateTest(CourseTestCase): ...@@ -234,18 +237,19 @@ class CourseUpdateTest(CourseTestCase):
payload = json.loads(resp.content) payload = json.loads(resp.content)
self.assertTrue(len(payload) == 1) 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) course_update_url = self.create_update_url(course_key=self.course.id)
# create a course via the view handler # create a course via the view handler
self.client.ajax_post(course_update_url) self.client.ajax_post(course_update_url)
block = u'updates'
content = u"Sample update" content = u"Sample update"
payload = {'content': content, 'date': 'January 8, 2013'} 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) resp = self.client.ajax_post(course_update_url, payload)
# check that response status is 200 not 400 # check that response status is 200 not 400
...@@ -254,9 +258,19 @@ class CourseUpdateTest(CourseTestCase): ...@@ -254,9 +258,19 @@ class CourseUpdateTest(CourseTestCase):
payload = json.loads(resp.content) payload = json.loads(resp.content)
self.assertHTMLEqual(payload['content'], 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') updates_location = self.course.id.make_usage_key('course_info', 'updates')
self.assertTrue(isinstance(updates_location, UsageKey)) self.assertTrue(isinstance(updates_location, UsageKey))
self.assertEqual(updates_location.name, block) self.assertEqual(updates_location.name, u'updates')
# check posting on handouts # check posting on handouts
handouts_location = self.course.id.make_usage_key('course_info', 'handouts') handouts_location = self.course.id.make_usage_key('course_info', 'handouts')
...@@ -265,8 +279,28 @@ class CourseUpdateTest(CourseTestCase): ...@@ -265,8 +279,28 @@ class CourseUpdateTest(CourseTestCase):
content = u"Sample handout" content = u"Sample handout"
payload = {'data': content} payload = {'data': content}
resp = self.client.ajax_post(course_handouts_url, payload) resp = self.client.ajax_post(course_handouts_url, payload)
# check that response status is 200 not 500 # check that response status is 200 not 500
self.assertEqual(resp.status_code, 200) self.assertEqual(resp.status_code, 200)
payload = json.loads(resp.content) payload = json.loads(resp.content)
self.assertHTMLEqual(payload['data'], 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)
...@@ -320,6 +320,11 @@ DEPRECATED_ADVANCED_COMPONENT_TYPES = ENV_TOKENS.get( ...@@ -320,6 +320,11 @@ DEPRECATED_ADVANCED_COMPONENT_TYPES = ENV_TOKENS.get(
VIDEO_UPLOAD_PIPELINE = ENV_TOKENS.get('VIDEO_UPLOAD_PIPELINE', VIDEO_UPLOAD_PIPELINE) 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 #date format the api will be formatting the datetime values
API_DATE_FORMAT = '%Y-%m-%d' API_DATE_FORMAT = '%Y-%m-%d'
API_DATE_FORMAT = ENV_TOKENS.get('API_DATE_FORMAT', API_DATE_FORMAT) API_DATE_FORMAT = ENV_TOKENS.get('API_DATE_FORMAT', API_DATE_FORMAT)
......
...@@ -22,7 +22,7 @@ define ["js/views/course_info_handout", "js/views/course_info_update", "js/model ...@@ -22,7 +22,7 @@ define ["js/views/course_info_handout", "js/views/course_info_update", "js/model
delete window.analytics delete window.analytics
delete window.course_location_analytics delete window.course_location_analytics
describe "Course Updates", -> describe "Course Updates without Push notification", ->
courseInfoTemplate = readFixtures('course_info_update.underscore') courseInfoTemplate = readFixtures('course_info_update.underscore')
beforeEach -> beforeEach ->
...@@ -100,7 +100,7 @@ define ["js/views/course_info_handout", "js/views/course_info_update", "js/model ...@@ -100,7 +100,7 @@ define ["js/views/course_info_handout", "js/views/course_info_update", "js/model
else else
modalCover.click() modalCover.click()
it "does not rewrite links on save", -> it "does send expected data on save", ->
requests = AjaxHelpers["requests"](this) requests = AjaxHelpers["requests"](this)
# Create a new update, verifying that the model is created # 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 ...@@ -116,9 +116,12 @@ define ["js/views/course_info_handout", "js/views/course_info_update", "js/model
@courseInfoEdit.$el.find('.save-button').click() @courseInfoEdit.$el.find('.save-button').click()
expect(model.save).toHaveBeenCalled() expect(model.save).toHaveBeenCalled()
# Verify content sent to server does not have rewritten links. # Verify push_notification_selected is set to false.
contentSaved = JSON.parse(requests[requests.length - 1].requestBody).content requestSent = JSON.parse(requests[requests.length - 1].requestBody)
expect(contentSaved).toEqual('/static/image.jpg') 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", -> it "does rewrite links for preview", ->
# Create a new update. # Create a new update.
...@@ -147,6 +150,41 @@ define ["js/views/course_info_handout", "js/views/course_info_update", "js/model ...@@ -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", -> it "does not remove existing course info on click outside modal", ->
@cancelExistingCourseInfo(false) @cancelExistingCourseInfo(false)
describe "Course Updates WITH Push notification", ->
courseInfoTemplate = readFixtures('course_info_update.underscore')
beforeEach ->
setFixtures($("<script>", {id: "course_info_update-tpl", type: "text/template"}).text(courseInfoTemplate))
appendSetFixtures courseInfoPage
@collection = new CourseUpdateCollection()
@collection.url = 'course_info_update/'
@courseInfoEdit = new CourseInfoUpdateView({
el: $('.course-updates'),
collection: @collection,
base_asset_url : 'base-asset-url/',
push_notification_enabled : true
})
@courseInfoEdit.render()
@event = {preventDefault : () -> 'no op'}
@courseInfoEdit.onNew(@event)
@requests = AjaxHelpers["requests"](this)
it "shows push notification checkbox as selected by default", ->
expect(@courseInfoEdit.$el.find('.toggle-checkbox')).toBeChecked()
it "sends correct default value for push_notification_selected", ->
@courseInfoEdit.$el.find('.save-button').click()
requestSent = JSON.parse(@requests[@requests.length - 1].requestBody)
expect(requestSent.push_notification_selected).toEqual(true)
it "sends correct value for push_notification_selected when it is unselected", ->
# unselect push notification
@courseInfoEdit.$el.find('.toggle-checkbox').attr('checked', false);
@courseInfoEdit.$el.find('.save-button').click()
requestSent = JSON.parse(@requests[@requests.length - 1].requestBody)
expect(requestSent.push_notification_selected).toEqual(false)
describe "Course Handouts", -> describe "Course Handouts", ->
handoutsTemplate = readFixtures('course_info_handouts.underscore') handoutsTemplate = readFixtures('course_info_handouts.underscore')
......
...@@ -3,7 +3,7 @@ define([ ...@@ -3,7 +3,7 @@ define([
'js/models/course_info', 'js/views/course_info_edit' 'js/models/course_info', 'js/views/course_info_edit'
], function($, CourseUpdateCollection, ModuleInfoModel, CourseInfoModel, CourseInfoEditView) { ], function($, CourseUpdateCollection, ModuleInfoModel, CourseInfoModel, CourseInfoEditView) {
'use strict'; 'use strict';
return function (updatesUrl, handoutsLocator, baseAssetUrl) { return function (updatesUrl, handoutsLocator, baseAssetUrl, push_notification_enabled) {
var course_updates = new CourseUpdateCollection(), var course_updates = new CourseUpdateCollection(),
course_handouts, editor; course_handouts, editor;
...@@ -18,7 +18,8 @@ define([ ...@@ -18,7 +18,8 @@ define([
updates : course_updates, updates : course_updates,
base_asset_url : baseAssetUrl, base_asset_url : baseAssetUrl,
handouts : course_handouts handouts : course_handouts
}) }),
push_notification_enabled: push_notification_enabled
}); });
editor.render(); editor.render();
}; };
......
...@@ -3,7 +3,9 @@ define(["backbone", "jquery", "jquery.ui"], function(Backbone, $) { ...@@ -3,7 +3,9 @@ define(["backbone", "jquery", "jquery.ui"], function(Backbone, $) {
var CourseUpdate = Backbone.Model.extend({ var CourseUpdate = Backbone.Model.extend({
defaults: { defaults: {
"date" : $.datepicker.formatDate('MM d, yy', new Date()), "date" : $.datepicker.formatDate('MM d, yy', new Date()),
"content" : "" "content" : "",
"push_notification_enabled": false,
"push_notification_selected" : false
} }
}); });
return CourseUpdate; return CourseUpdate;
......
...@@ -15,7 +15,8 @@ var CourseInfoEdit = BaseView.extend({ ...@@ -15,7 +15,8 @@ var CourseInfoEdit = BaseView.extend({
new CourseInfoUpdateView({ new CourseInfoUpdateView({
el: $('body.updates'), el: $('body.updates'),
collection: this.model.get('updates'), collection: this.model.get('updates'),
base_asset_url: this.model.get('base_asset_url') base_asset_url: this.model.get('base_asset_url'),
push_notification_enabled: this.options.push_notification_enabled
}); });
new CourseInfoHandoutView({ new CourseInfoHandoutView({
......
...@@ -3,6 +3,7 @@ define(["js/views/baseview", "codemirror", "js/models/course_update", ...@@ -3,6 +3,7 @@ define(["js/views/baseview", "codemirror", "js/models/course_update",
function(BaseView, CodeMirror, CourseUpdateModel, PromptView, NotificationView, CourseInfoHelper, ModalUtils) { function(BaseView, CodeMirror, CourseUpdateModel, PromptView, NotificationView, CourseInfoHelper, ModalUtils) {
var CourseInfoUpdateView = BaseView.extend({ var CourseInfoUpdateView = BaseView.extend({
// collection is CourseUpdateCollection // collection is CourseUpdateCollection
events: { events: {
"click .new-update-button" : "onNew", "click .new-update-button" : "onNew",
...@@ -29,7 +30,8 @@ define(["js/views/baseview", "codemirror", "js/models/course_update", ...@@ -29,7 +30,8 @@ define(["js/views/baseview", "codemirror", "js/models/course_update",
try { try {
CourseInfoHelper.changeContentToPreview( CourseInfoHelper.changeContentToPreview(
update, 'content', self.options['base_asset_url']); update, 'content', self.options['base_asset_url']);
var newEle = self.template({ updateModel : update }); // push notification is always disabled for existing updates
var newEle = self.template({ updateModel : update, push_notification_enabled : false });
$(updateEle).append(newEle); $(updateEle).append(newEle);
} catch (e) { } catch (e) {
// ignore // ignore
...@@ -47,7 +49,12 @@ define(["js/views/baseview", "codemirror", "js/models/course_update", ...@@ -47,7 +49,12 @@ define(["js/views/baseview", "codemirror", "js/models/course_update",
var newModel = new CourseUpdateModel(); var newModel = new CourseUpdateModel();
this.collection.add(newModel, {at : 0}); this.collection.add(newModel, {at : 0});
var $newForm = $(this.template({ updateModel : newModel })); var $newForm = $(
this.template({
updateModel : newModel,
push_notification_enabled : this.options.push_notification_enabled
})
);
var updateEle = this.$el.find("#course-update-list"); var updateEle = this.$el.find("#course-update-list");
$(updateEle).prepend($newForm); $(updateEle).prepend($newForm);
...@@ -74,7 +81,11 @@ define(["js/views/baseview", "codemirror", "js/models/course_update", ...@@ -74,7 +81,11 @@ define(["js/views/baseview", "codemirror", "js/models/course_update",
onSave: function(event) { onSave: function(event) {
event.preventDefault(); event.preventDefault();
var targetModel = this.eventModel(event); var targetModel = this.eventModel(event);
targetModel.set({ date : this.dateEntry(event).val(), content : this.$codeMirror.getValue() }); targetModel.set({
date : this.dateEntry(event).val(),
content : this.$codeMirror.getValue(),
push_notification_selected : this.push_notification_selected(event)
});
// push change to display, hide the editor, submit the change // push change to display, hide the editor, submit the change
var saving = new NotificationView.Mini({ var saving = new NotificationView.Mini({
title: gettext('Saving') title: gettext('Saving')
...@@ -196,6 +207,11 @@ define(["js/views/baseview", "codemirror", "js/models/course_update", ...@@ -196,6 +207,11 @@ define(["js/views/baseview", "codemirror", "js/models/course_update",
} }
this.$currentPost.find('form').hide(); this.$currentPost.find('form').hide();
this.$currentPost.find('.CodeMirror').remove(); this.$currentPost.find('.CodeMirror').remove();
// hide the push notification checkbox for subsequent edits to the Post
var push_notification_ele = this.$currentPost.find(".new-update-push-notification");
if (push_notification_ele)
push_notification_ele.hide();
} }
ModalUtils.hideModalCover(this.$modalCover); ModalUtils.hideModalCover(this.$modalCover);
...@@ -222,16 +238,15 @@ define(["js/views/baseview", "codemirror", "js/models/course_update", ...@@ -222,16 +238,15 @@ define(["js/views/baseview", "codemirror", "js/models/course_update",
if (li) return $(li).find(".date").first(); if (li) return $(li).find(".date").first();
}, },
contentEntry: function(event) { push_notification_selected: function(event) {
return $(event.currentTarget).closest("li").find(".new-update-content").first(); var push_notification_checkbox;
}, var li = $(event.currentTarget).closest("li");
if (li) {
dateDisplay: function(event) { push_notification_checkbox = li.find(".new-update-push-notification .toggle-checkbox");
return $(event.currentTarget).closest("li").find("#date-display").first(); if (push_notification_checkbox) {
}, return push_notification_checkbox.is(":checked");
}
contentDisplay: function(event) { }
return $(event.currentTarget).closest("li").find(".update-contents").first();
} }
}); });
......
...@@ -5,6 +5,7 @@ ...@@ -5,6 +5,7 @@
<%inherit file="base.html" /> <%inherit file="base.html" />
<%def name="online_help_token()"><% return "updates" %></%def> <%def name="online_help_token()"><% return "updates" %></%def>
<%namespace name='static' file='static_content.html'/> <%namespace name='static' file='static_content.html'/>
<%! import json %>
<!-- TODO decode course # from context_course into title --> <!-- TODO decode course # from context_course into title -->
<%block name="title">${_("Course Updates")}</%block> <%block name="title">${_("Course Updates")}</%block>
...@@ -21,7 +22,12 @@ ...@@ -21,7 +22,12 @@
<%block name="requirejs"> <%block name="requirejs">
require(["js/factories/course_info"], function(CourseInfoFactory) { require(["js/factories/course_info"], function(CourseInfoFactory) {
CourseInfoFactory("${updates_url}", "${handouts_locator | escapejs}", "${base_asset_url}"); CourseInfoFactory(
"${updates_url}",
"${handouts_locator | escapejs}",
"${base_asset_url}",
${json.dumps(push_notification_enabled)}
);
}); });
</%block> </%block>
......
...@@ -2,23 +2,29 @@ ...@@ -2,23 +2,29 @@
<!-- FIXME what style should we use for initially hidden? --> <!-- TODO decide whether this should use codemirror --> <!-- FIXME what style should we use for initially hidden? --> <!-- TODO decide whether this should use codemirror -->
<form class="new-update-form"> <form class="new-update-form">
<div class="row"> <div class="row">
<label class="inline-label">Date:</label> <label for="update-date-<%= updateModel.cid %>" class="inline-label">Date:</label>
<!-- TODO replace w/ date widget and actual date (problem is that persisted version is "Month day" not an actual date obj --> <!-- TODO replace w/ date widget and actual date (problem is that persisted version is "Month day" not an actual date obj -->
<input type="text" class="date" value="<%= updateModel.get('date') %>"> <input id="update-date-<%= updateModel.cid %>" type="text" class="date" value="<%= updateModel.get('date') %>">
</div> </div>
<div class="row"> <div class="row">
<textarea class="new-update-content text-editor"><%= updateModel.get('content') %></textarea> <textarea class="new-update-content text-editor"><%= updateModel.get('content') %></textarea>
</div> </div>
<%if (push_notification_enabled) { %>
<div class="row new-update-push-notification">
<input id="update-notification-checkbox-<%= updateModel.cid %>" type="checkbox" class="toggle-checkbox" data-tooltip="<%= gettext('Send push notification to mobile apps') %>" checked />
<label for="update-notification-checkbox-<%= updateModel.cid %>" class="inline-label"><%= gettext('Send notification to mobile apps') %></label>
</div>
<% } %>
<div class="row"> <div class="row">
<!-- cid rather than id b/c new ones have cid's not id's --> <!-- cid rather than id b/c new ones have cid's not id's -->
<a href="#" class="save-button" name="<%= updateModel.cid %>">Save</a> <button class="save-button" name="<%= updateModel.cid %>"><%= gettext('Post') %></button>
<a href="#" class="cancel-button" name="<%= updateModel.cid %>">Cancel</a> <button class="cancel-button" name="<%= updateModel.cid %>"><%= gettext('Cancel') %></button>
</div> </div>
</form> </form>
<div class="post-preview"> <div class="post-preview">
<div class="post-actions"> <div class="post-actions">
<a href="#" class="edit-button" name="<%- updateModel.cid %>"><span class="edit-icon"></span>Edit</a> <button class="edit-button" name="<%= updateModel.cid %>"><span class="edit-icon"></span>Edit</button>
<a href="#" class="delete-button" name="<%- updateModel.cid %>"><span class="delete-icon"></span>Delete</a> <button class="delete-button" name="<%= updateModel.cid %>"><span class="delete-icon"></span>Delete</button>
</div> </div>
<h2> <h2>
<span class="calendar-icon"></span><span class="date-display"><%= <span class="calendar-icon"></span><span class="date-display"><%=
......
...@@ -60,3 +60,8 @@ class ConfigurationModel(models.Model): ...@@ -60,3 +60,8 @@ class ConfigurationModel(models.Model):
cache.set(cls.cache_key_name(), current, cls.cache_timeout) cache.set(cls.cache_key_name(), current, cls.cache_timeout)
return current return current
@classmethod
def is_enabled(cls):
"""Returns True if this feature is configured as enabled, else False."""
return cls.current().enabled
...@@ -10,6 +10,7 @@ import requests ...@@ -10,6 +10,7 @@ import requests
from datetime import datetime from datetime import datetime
import dateutil.parser import dateutil.parser
from lazy import lazy from lazy import lazy
from base64 import b32encode
from xmodule.exceptions import UndefinedContext from xmodule.exceptions import UndefinedContext
from xmodule.seq_module import SequenceDescriptor, SequenceModule from xmodule.seq_module import SequenceDescriptor, SequenceModule
...@@ -1398,3 +1399,12 @@ class CourseDescriptor(CourseFields, SequenceDescriptor): ...@@ -1398,3 +1399,12 @@ class CourseDescriptor(CourseFields, SequenceDescriptor):
self.video_upload_pipeline is not None and self.video_upload_pipeline is not None and
'course_video_upload_token' in self.video_upload_pipeline 'course_video_upload_token' in self.video_upload_pipeline
) )
def clean_id(self, padding_char='='):
"""
Returns a unique deterministic base32-encoded ID for the course.
The optional padding_char parameter allows you to override the "=" character used for padding.
"""
return "course_{}".format(
b32encode(unicode(self.location.course_key)).replace('=', padding_char)
)
...@@ -60,6 +60,7 @@ class CourseField(serializers.RelatedField): ...@@ -60,6 +60,7 @@ class CourseField(serializers.RelatedField):
"course_updates": course_updates_url, "course_updates": course_updates_url,
"course_handouts": course_handouts_url, "course_handouts": course_handouts_url,
"course_about": course_about_url, "course_about": course_about_url,
"subscription_id": course.clean_id(padding_char='_'),
} }
......
...@@ -60,6 +60,7 @@ class TestUserEnrollmentApi(MobileAPITestCase, MobileAuthUserTestMixin, MobileEn ...@@ -60,6 +60,7 @@ class TestUserEnrollmentApi(MobileAPITestCase, MobileAuthUserTestMixin, MobileEn
self.assertTrue('course_handouts' in found_course) self.assertTrue('course_handouts' in found_course)
self.assertEqual(found_course['id'], unicode(self.course.id)) self.assertEqual(found_course['id'], unicode(self.course.id))
self.assertEqual(courses[0]['mode'], 'honor') self.assertEqual(courses[0]['mode'], 'honor')
self.assertEqual(courses[0]['course']['subscription_id'], self.course.clean_id(padding_char='_'))
def verify_failure(self, response): def verify_failure(self, response):
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
......
...@@ -221,6 +221,7 @@ class UserCourseEnrollmentsList(generics.ListAPIView): ...@@ -221,6 +221,7 @@ class UserCourseEnrollmentsList(generics.ListAPIView):
* video_outline: The URI to get the list of all vides the user can * video_outline: The URI to get the list of all vides the user can
access in the course. access in the course.
* id: The unique ID of the course. * id: The unique ID of the course.
* subscription_id: A unique "clean" (alphanumeric with '_') ID of the course.
* latest_updates: Reserved for future use. * latest_updates: Reserved for future use.
* end: The end date of the course. * end: The end date of the course.
* name: The name of the course. * name: The name of the course.
......
...@@ -20,6 +20,7 @@ ...@@ -20,6 +20,7 @@
-e git+https://github.com/jazkarta/edx-jsme.git@c5bfa5d361d6685d8c643838fc0055c25f8b7999#egg=edx-jsme -e git+https://github.com/jazkarta/edx-jsme.git@c5bfa5d361d6685d8c643838fc0055c25f8b7999#egg=edx-jsme
-e git+https://github.com/pmitros/django-pyfs.git@d175715e0fe3367ec0f1ee429c242d603f6e8b10#egg=djpyfs -e git+https://github.com/pmitros/django-pyfs.git@d175715e0fe3367ec0f1ee429c242d603f6e8b10#egg=djpyfs
git+https://github.com/mitocw/django-cas.git@60a5b8e5a62e63e0d5d224a87f0b489201a0c695#egg=django-cas git+https://github.com/mitocw/django-cas.git@60a5b8e5a62e63e0d5d224a87f0b489201a0c695#egg=django-cas
-e git+https://github.com/dgrtwo/ParsePy.git@7949b9f754d1445eff8e8f20d0e967b9a6420639#egg=parse_rest
# Our libraries: # Our libraries:
-e git+https://github.com/edx/XBlock.git@aed464a0e2f7478e93157150ac04133a745f5f46#egg=XBlock -e git+https://github.com/edx/XBlock.git@aed464a0e2f7478e93157150ac04133a745f5f46#egg=XBlock
......
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