diff --git a/data/aggregator.py b/data/aggregator.py index 1f844330..08905583 100644 --- a/data/aggregator.py +++ b/data/aggregator.py @@ -342,6 +342,11 @@ def update_sections(): # Import from files to DB. rmc_processor.import_opendata_sections() + # Send push notifications about seat openings. + num_sent = m.GcmCourseAlert.send_eligible_alerts() + num_expired = m.GcmCourseAlert.delete_expired() + print 'Sent %s push notifications and expired %s' % (num_sent, num_expired) + def update_courses(): # First get an up to date list of departments and write to a text file diff --git a/models/__init__.py b/models/__init__.py index a83c253c..4a9d9fe8 100644 --- a/models/__init__.py +++ b/models/__init__.py @@ -15,3 +15,5 @@ from user_course import CritiqueCourse # @UnusedImport from section import SectionMeeting # @UnusedImport from section import Section # @UnusedImport +from course_alert import BaseCourseAlert # @UnusedImport +from course_alert import GcmCourseAlert # @UnusedImport diff --git a/models/course_alert.py b/models/course_alert.py new file mode 100644 index 00000000..8317be85 --- /dev/null +++ b/models/course_alert.py @@ -0,0 +1,172 @@ +import datetime +import json + +import mongoengine as me +import requests + +import course +import rmc.shared.secrets as s +import section +from rmc.shared import util + + +class BaseCourseAlert(me.Document): + """An abstract base class for notifying when a seat opens in a course. + + Subclasses must define the behaviour for sending the alert to the desired + audience. See GcmCourseAlert for an example subclass. + + Can optionally specify a single section of a course. + """ + + BASE_INDEXES = [ + 'course_id', + ('course_id', 'term_id', 'section_type', 'section_num'), + ] + + # These set of fields form a partial key; together with an audience + # identifier from the subclass, forms a complete key. + BASE_UNIQUE_FIELDS = ['course_id', 'term_id', 'section_type', + 'section_num'] + + meta = { + 'indexes': BASE_INDEXES, + 'abstract': True, + } + + # eg. earth121l + course_id = me.StringField(required=True) + + # eg. datetime.datetime(2013, 1, 7, 22, 30) + created_date = me.DateTimeField(required=True) + + # eg. datetime.datetime(2013, 1, 7, 22, 30) + expiry_date = me.DateTimeField(required=True) + + # Optional fields to specify section to alert on + + # eg. 2013_09. Note that this is our term ID, not Quest's 4-digit ID. + term_id = me.StringField(default='') + + # eg. LEC, TUT, EXAM. Note uppercase. + section_type = me.StringField(default='') + + # eg. 001 + section_num = me.StringField(default='') + + TO_DICT_FIELDS = ['id', 'course_id', 'created_date', 'expiry_date', + 'term_id', 'section_type', 'section_num'] + + def to_dict(self): + return util.to_dict(self, self.TO_DICT_FIELDS) + + def send_alert(self, sections): + """Sends an alert about a seat opening. + + Args: + sections: Sections that have spots available. + + Returns whether this alert was successfully sent. + """ + raise Exception('Sublasses must implement this method.') + + @classmethod + def send_eligible_alerts(cls): + """Checks if any alerts can be sent, and if so, sends them. + + Deletes alerts that were successfully sent. + + Returns how many alerts were successfully sent. + """ + alerts_sent = 0 + + for alert in cls.objects(): + query = {'course_id': alert.course_id} + + if alert.term_id: + query['term_id'] = alert.term_id + + if alert.section_type: + query['section_type'] = alert.section_type + + if alert.section_num: + query['section_num'] = alert.section_num + + sections = section.Section.objects(**query) + open_sections = filter( + lambda s: s.enrollment_capacity > s.enrollment_total, + sections) + + # TODO(david): Also log to Mixpanel or something. + if open_sections and alert.send_alert(open_sections): + alert.delete() + alerts_sent += 1 + + return alerts_sent + + @classmethod + def delete_expired(cls): + cls.objects(expiry_date__lt=datetime.datetime.now()).delete() + + +class GcmCourseAlert(BaseCourseAlert): + """Course alert using Google Cloud Messaging (GCM) push notifications. + + GCM is Android's main push notification mechanism. + """ + + meta = { + 'indexes': BaseCourseAlert.BASE_INDEXES + [ + 'registration_id', + ] + } + + # An ID issued by GCM that uniquely identifies a device-app pair. + registration_id = me.StringField(required=True, + unique_with=BaseCourseAlert.BASE_UNIQUE_FIELDS) + + # Optional user ID associated with this alert. + user_id = me.ObjectIdField() + + TO_DICT_FIELDS = BaseCourseAlert.TO_DICT_FIELDS + [ + 'registration_id', 'user_id'] + + def __repr__(self): + return "" % ( + self.course_id, + self.term_id, + self.section_type, + self.section_num, + ) + + def send_alert(self, sections): + """Sends a push notification using GCM's HTTP method. + + See http://developer.android.com/google/gcm/server.html and + http://developer.android.com/google/gcm/http.html. + + Overrides base class method. + """ + course_obj = course.Course.objects.with_id(self.course_id) + + # GCM has a limit on payload data size, so be conservative with the + # amount of data we're serializing. + data = { + 'registration_ids': [self.registration_id], + 'data': { + 'type': 'course_alert', + 'sections_open_count': len(sections), + 'course': course_obj.to_dict(), + }, + } + + headers = { + 'Content-Type': 'application/json', + 'Authorization': 'key=%s' % s.GOOGLE_SERVER_PROJECT_API_KEY, + } + + res = requests.post('https://android.googleapis.com/gcm/send', + data=json.dumps(data), headers=headers) + + # TODO(david): Implement exponential backoff for retries + return res.ok diff --git a/models/course_alert_test.py b/models/course_alert_test.py new file mode 100644 index 00000000..ff9fe709 --- /dev/null +++ b/models/course_alert_test.py @@ -0,0 +1,83 @@ +import datetime + +import rmc.models as m +import rmc.test.lib as testlib + + +class SimpleCourseAlert(m.BaseCourseAlert): + def send_alert(self, sections): + return True + + +class BaseCourseAlertTest(testlib.FixturesTestCase): + def tearDown(self): + # Clear DB for other tests + SimpleCourseAlert.objects.delete() + super(BaseCourseAlertTest, self).tearDown() + + def test_send_eligible_alerts(self): + # This class is full. Should not alert anything. + alert = SimpleCourseAlert( + course_id='spcom223', + created_date=datetime.datetime.now(), + expiry_date=datetime.datetime.max, + term_id='2014_01', + section_type='LEC', + section_num='003', + ) + alert.save() + + alerts_sent = SimpleCourseAlert.send_eligible_alerts() + self.assertEqual(alerts_sent, 0) + self.assertEqual(SimpleCourseAlert.objects.count(), 1) + + # Here's a non-full class to alert on. + alert = SimpleCourseAlert( + course_id='spcom223', + created_date=datetime.datetime.now(), + expiry_date=datetime.datetime.max, + term_id='2014_01', + section_type='LEC', + section_num='002', + ) + alert.save() + + self.assertEqual(SimpleCourseAlert.objects.count(), 2) + + alerts_sent = SimpleCourseAlert.send_eligible_alerts() + self.assertEqual(alerts_sent, 1) + self.assertEqual(SimpleCourseAlert.objects.count(), 1) + + # Here's a less restrictive query with multiple available sections + alert = SimpleCourseAlert( + course_id='spcom223', + created_date=datetime.datetime.now(), + expiry_date=datetime.datetime.max, + ) + alert.save() + + self.assertEqual(SimpleCourseAlert.objects.count(), 2) + + alerts_sent = SimpleCourseAlert.send_eligible_alerts() + self.assertEqual(alerts_sent, 1) + self.assertEqual(SimpleCourseAlert.objects.count(), 1) + + def test_delete_expired(self): + self.assertEqual(SimpleCourseAlert.objects.count(), 0) + + SimpleCourseAlert( + course_id='spcom223', + created_date=datetime.datetime.now(), + expiry_date=datetime.datetime.min, + ).save() + SimpleCourseAlert( + course_id='cs241', + created_date=datetime.datetime.now(), + expiry_date=datetime.datetime.max, + ).save() + + self.assertEqual(SimpleCourseAlert.objects.count(), 2) + + SimpleCourseAlert.delete_expired() + self.assertEqual(SimpleCourseAlert.objects.count(), 1) + self.assertEqual(SimpleCourseAlert.objects[0].course_id, 'cs241') diff --git a/server/api/v1.py b/server/api/v1.py index 4883a26f..c0d166cc 100644 --- a/server/api/v1.py +++ b/server/api/v1.py @@ -1,9 +1,11 @@ """Version 1 of Flow's public, officially-supported API.""" import collections +import datetime import bson import flask +import mongoengine as me import rmc.models as m import rmc.server.api.api_util as api_util @@ -547,6 +549,82 @@ def search_courses(): }) +############################################################################### +# Alerts + + +@api.route('/alerts/course/gcm', methods=['POST']) +def add_gcm_course_alert(): + """Adds an alert to notify when a seat opens up in a course/section via + GCM. + + GCM is used to send push notifications to our Android app. + + Requires the following parameters: + registration_id: Provided by GCM to identify the device-app pair + course_id: ID of the course to alert on + + Optional parameters: + created_date: Timestamp in millis + expiry_date: Timestamp in millis. Defaults to 1 year later + term_id: e.g. "2014_01" + section_type: e.g. "LEC" + section_num: e.g. "001" + user_id: ID of the logged in user + """ + params = flask.request.form + + created_date = datetime.datetime.now() + + expiry_date_param = params.get('expiry_date') + if expiry_date_param: + expiry_date = datetime.datetime.fromtimestamp(int(expiry_date_param)) + else: + expiry_date = created_date + datetime.timedelta(days=365) + + try: + alert_dict = { + 'registration_id': params['registration_id'], + 'course_id': params['course_id'], + 'created_date': created_date, + 'expiry_date': expiry_date, + 'term_id': params.get('term_id'), + 'section_type': params.get('section_type'), + 'section_num': params.get('section_num'), + 'user_id': params.get('user_id'), + } + except KeyError as e: + raise api_util.ApiBadRequestError( + 'Missing required parameter: %s' % e.message) + + alert = m.GcmCourseAlert(**alert_dict) + + try: + alert.save() + except me.NotUniqueError as e: + raise api_util.ApiBadRequestError( + 'Alert with the given parameters already exists.') + + return api_util.jsonify({ + 'gcm_course_alert': alert.to_dict(), + }) + + +@api.route('/alerts/course/gcm/', methods=['DELETE']) +def delete_gcm_course_alert(alert_id): + alert = m.GcmCourseAlert.objects.with_id(alert_id) + + if not alert: + raise api_util.ApiNotFoundError( + 'No GCM course alert with id %s found.' % alert_id) + + alert.delete() + + return api_util.jsonify({ + 'gcm_course_alert': alert.to_dict(), + }) + + ############################################################################### # Misc. diff --git a/server/api/v1_test.py b/server/api/v1_test.py index e6be0af8..62d6dab7 100644 --- a/server/api/v1_test.py +++ b/server/api/v1_test.py @@ -1,3 +1,4 @@ +import datetime import json import werkzeug.datastructures as datastructures @@ -7,6 +8,11 @@ class V1Test(testlib.FlaskTestCase): + def tearDown(self): + # Clear DB for other tests + m.GcmCourseAlert.objects.delete() + super(V1Test, self).tearDown() + def get_csrf_token_header(self): resp = self.app.get('/api/v1/csrf-token') headers = datastructures.Headers() @@ -118,4 +124,85 @@ def test_login_email(self): self.assertResponseOk(resp) self.assertTrue(resp.headers.get('Set-Cookie')) -# TODO(david): More tests! + def test_add_gcm_course_alert(self): + self.assertEqual(m.GcmCourseAlert.objects.count(), 0) + headers = self.get_csrf_token_header() + + # Try to add an alert with a missing required field + data = { + 'course_id': 'cs241', + } + resp = self.app.post( + '/api/v1/alerts/course/gcm', data=data, headers=headers) + self.assertEqual(resp.status_code, 400) + self.assertJsonResponse(resp, { + 'error': 'Missing required parameter: registration_id' + }) + self.assertEqual(m.GcmCourseAlert.objects.count(), 0) + + # This should work + data = { + 'registration_id': 'neverwouldhaveplayedsononchalant', + 'course_id': 'cs241', + } + resp = self.app.post( + '/api/v1/alerts/course/gcm', data=data, headers=headers) + self.assertResponseOk(resp) + self.assertEqual(m.GcmCourseAlert.objects.count(), 1) + + # Try adding the same thing. Should fail. + resp = self.app.post( + '/api/v1/alerts/course/gcm', data=data, headers=headers) + self.assertEqual(resp.status_code, 400) + self.assertJsonResponse(resp, { + 'error': 'Alert with the given parameters already exists.' + }) + self.assertEqual(m.GcmCourseAlert.objects.count(), 1) + + # Test with all parameters set + data = { + 'registration_id': 'ohmymymy', + 'course_id': 'psych101', + 'expiry_date': 1496592355, + 'term_id': '2014_01', + 'section_type': 'LEC', + 'section_num': '001', + 'user_id': '533e4f7d78d6fe562c16f17a', + } + resp = self.app.post( + '/api/v1/alerts/course/gcm', data=data, headers=headers) + self.assertResponseOk(resp) + self.assertEqual(m.GcmCourseAlert.objects.count(), 2) + + def test_delete_gcm_course_alert(self): + created_timestamp = 1396710772 + expiry_timestamp = 1496710772 + + alert = m.GcmCourseAlert( + registration_id='neverheardsilencequitethisloud', + course_id='sci238', + created_date=datetime.datetime.fromtimestamp(created_timestamp), + expiry_date=datetime.datetime.fromtimestamp(expiry_timestamp), + ) + alert.save() + self.assertEqual(m.GcmCourseAlert.objects.count(), 1) + + headers = self.get_csrf_token_header() + + resp = self.app.delete( + '/api/v1/alerts/course/gcm/%s' % alert.id, headers=headers) + self.assertResponseOk(resp) + self.assertJsonResponse(resp, { + 'gcm_course_alert': { + 'registration_id': 'neverheardsilencequitethisloud', + 'user_id': None, + 'term_id': '', + 'section_type': '', + 'expiry_date': 1496696372000, + 'created_date': 1396696372000, + 'course_id': 'sci238', + 'section_num': '', + 'id': str(alert.id), + } + }) + self.assertEqual(m.GcmCourseAlert.objects.count(), 0) diff --git a/shared/secrets.py.example b/shared/secrets.py.example index 4db44461..7f15bd1c 100644 --- a/shared/secrets.py.example +++ b/shared/secrets.py.example @@ -29,3 +29,7 @@ OPEN_DATA_API_KEY = '5da84ced3d60901f54666c2f338c908d' # This is a fake Flickr API key - if you need to use the Flickr API (just used # for downloading kittens at the moment), generate one yourself. # FLICKR_API_KEY = 'loveyouifyouloveacarfortheroadtrips' + +# Used for server projects that access Google APIs (in our case, for Android +# push notifications via GCM). Generate one at console.developers.google.com +GOOGLE_SERVER_PROJECT_API_KEY = 'ihadthetimeofmylifefightingdragonswithyou' diff --git a/test/fixtures/dump/rmc_test/section.bson b/test/fixtures/dump/rmc_test/section.bson new file mode 100644 index 00000000..53e6981b Binary files /dev/null and b/test/fixtures/dump/rmc_test/section.bson differ diff --git a/test/fixtures/dump/rmc_test/section.metadata.json b/test/fixtures/dump/rmc_test/section.metadata.json new file mode 100644 index 00000000..703d1508 --- /dev/null +++ b/test/fixtures/dump/rmc_test/section.metadata.json @@ -0,0 +1 @@ +{ "indexes" : [ { "v" : 1, "key" : { "_id" : 1 }, "ns" : "rmc.section", "name" : "_id_" }, { "v" : 1, "key" : { "course_id" : 1, "term_id" : 1, "section_type" : 1, "section_num" : 1 }, "unique" : true, "ns" : "rmc.section", "name" : "course_id_1_term_id_1_section_type_1_section_num_1", "background" : false, "dropDups" : false }, { "v" : 1, "key" : { "_types" : 1, "course_id" : 1, "term_id" : 1 }, "ns" : "rmc.section", "background" : false, "name" : "_types_1_course_id_1_term_id_1" } ] } \ No newline at end of file