This repository was archived by the owner on Feb 12, 2024. It is now read-only.
-
Notifications
You must be signed in to change notification settings - Fork 72
Add support for push notifications to our Android app #140
Merged
Merged
Changes from all commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
da93eee
Add base CourseAlert models
divad12 43e3201
Make CourseAlert an ABC; add methods to send and delete
divad12 3e47331
Add tests and fixtures for BaseCourseAlert
divad12 eba6620
Add GcmCourseAlert subclass to send out Android push notifications
divad12 4dc99c3
Send push notifications automatically on each sections update
divad12 efe171a
Add POST /api/v1/alerts/course/gcm endpoint and tests
divad12 fc30647
Add DELETE /alerts/course/gcm/<string:alert_id> endpoint and test
divad12 b632c22
Oops, fix bug with BaseCourseAlert.delete_expired
divad12 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 "<GcmCourseAlert: %s, %s, %s %s>" % ( | ||
| 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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') | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Add an assert that the remaining alert is the one that you expect?
Something like
self.assertEqual(SimpleCourseAlert.objects[0].course_id, 'cs241')There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What library are you using for testing @divad12?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@mduan Great catch :)
@JGulbronson nose in conjunction with unittest
Take a look at how we run our Python tests here: https://github.com/UWFlow/rmc#running-tests