Skip to content
This repository was archived by the owner on Feb 12, 2024. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions data/aggregator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
172 changes: 172 additions & 0 deletions models/course_alert.py
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
83 changes: 83 additions & 0 deletions models/course_alert_test.py
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)
Copy link
Contributor

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')

Copy link
Member

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?

Copy link
Member Author

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

self.assertEqual(SimpleCourseAlert.objects[0].course_id, 'cs241')
78 changes: 78 additions & 0 deletions server/api/v1.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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/<string:alert_id>', 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.

Expand Down
Loading