Skip to content

Commit

Permalink
[#1635] Initial implementation of email notifications for activity st…
Browse files Browse the repository at this point in the history
…reams

ckan/model/user.py
  Added @classmethod all()

ckan/model/dashboard.py
  Added last_activity_stream_email_notification and @classmethods
  get_last_activity_stream_email_notification,
  update_last_activity_stream_email_notification

logic/action/get.py:
 Added dashboard_email_notification_last_sent and get_email_notifications
 actions. These may not really need to be action functions as they may only be
 needed internally.

ckan/lib/email_notifications.py
  This is the email notifier job meant to be run by paster (but there's no
  paster command for it yet)

ckan/tests/lib/test_email_notifications.py
  The beginning of some tests
  • Loading branch information
Sean Hammond committed Nov 16, 2012
1 parent 373432f commit a678e0e
Show file tree
Hide file tree
Showing 5 changed files with 282 additions and 6 deletions.
128 changes: 128 additions & 0 deletions ckan/lib/email_notifications.py
@@ -0,0 +1,128 @@
'''
Code for generating email notifications for users (e.g. email notifications for
new activities in your dashboard activity stream) and emailing them to the
users.
'''
import datetime

import ckan.model as model
import ckan.logic as logic
import ckan.lib.mailer


def _notifications_for_activities(activities):
'''Return one or more email notifications covering the given activities.
This function handles grouping multiple activities into a single digest
email.
:param activities: the activities to consider
:type activities: list of activity dicts like those returned by
ckan.logic.action.get.dashboard_activity_list()
:returns: a list of email notifications
:rtype: list of dicts each with keys 'subject' and 'body'
'''
if not activities:
return []

# We just group all activities into a single "new activity" email that
# doesn't say anything about _what_ new activities they are.
# TODO: Here we could generate some smarter content for the emails e.g.
# say something about the contents of the activities, or single out
# certain types of activity to be sent in their own individual emails,
# etc.
notifications = [{
'subject': "You have new activity",
'body': "You have new activity"
}]

return notifications


def _notifications_from_dashboard_activity_list(user_id, since):
'''Return any email notifications from user_id's dashboard activity list
since `since`.
'''
# Get the user's dashboard activity stream.
context = {'model': model, 'session': model.Session, 'user': user_id}
activity_list = logic.get_action('dashboard_activity_list')(context, {})

# Filter out the user's own activities., so they don't get an email every
# time they themselves do something (we are not Trac).
activity_list = [activity for activity in activity_list
if activity['user_id'] != user_id]

# Filter out the old activities.
strptime = datetime.datetime.strptime
fmt = '%Y-%m-%dT%H:%M:%S.%f'
activity_list = [activity for activity in activity_list
if strptime(activity['timestamp'], fmt) > since]

return _notifications_for_activities(activity_list)


# A list of functions that provide email notifications for users from different
# sources. Add to this list if you want to implement a new source of email
# notifications.
_notifications_functions = [
_notifications_from_dashboard_activity_list,
]


def get_notifications(user_id, since):
'''Return any email notifications for `user_id` since `since`.
For example email notifications about activity streams will be returned for
any activities the occurred since `since`.
:param user_id: id of the user to return notifications for
:type user_id: string
:param since: datetime after which to return notifications from
:rtype since: datetime.datetime
:returns: a list of email notifications
:rtype: list of dicts with keys 'subject' and 'body'
'''
notifications = []
for function in _notifications_functions:
notifications.extend(function(user_id, since))
return notifications


def send_notification(user, email_dict):
'''Email `email_dict` to `user`.'''

if not user.get('email'):
# FIXME: Raise an exception.
return

try:
ckan.lib.mailer.mail_recipient(user['display_name'], user['email'],
email_dict['subject'], email_dict['body'])
except ckan.lib.mailer.MailerException:
raise


def get_and_send_notifications_for_user(user):
# FIXME: Get the time that the last email notification was sent to the
# user and the time that they last viewed their dashboard, set `since` to
# whichever is more recent.
since = datetime.datetime.min
notifications = get_notifications(user['id'], since)
# TODO: Handle failures from send_email_notification.
for notification in notifications:
send_notification(user, notification)


def get_and_send_notifications_for_all_users():
context = {'model': model, 'session': model.Session, 'ignore_auth': True,
'keep_sensitive_data': True}
users = logic.get_action('user_list')(context, {})
for user in users:
get_and_send_notifications_for_user(user)
27 changes: 27 additions & 0 deletions ckan/logic/action/get.py
Expand Up @@ -2204,6 +2204,33 @@ def dashboard_mark_activities_as_read(context, data_dict):
model.Dashboard.update_activity_stream_last_viewed(user_id)


def dashboard_email_notification_last_sent(context, data_dict):
model = context['model']
user = model.User.get(context['user']) # The authorized user.
last_viewed = model.Dashboard.get_activity_stream_last_viewed(user.id)
return last_viewed.timetuple()


def get_email_notifications(context, data_dict):
'''Return a list of new email notifications for the given user.
:param:
'''
if not context.has_key('user'):
raise logic.NotAuthorized

model = context['model']

userobj = model.User.get(context['user'])
if not userobj:
raise logic.NotAuthorized

activity_stream = logic.get_action('dashboard_activity_list')(context,
data_dict)


def _unpick_search(sort, allowed_fields=None, total=None):
''' This is a helper function that takes a sort string
eg 'name asc, last_modified desc' and returns a list of
Expand Down
44 changes: 38 additions & 6 deletions ckan/model/dashboard.py
Expand Up @@ -8,7 +8,9 @@
ondelete='CASCADE'),
primary_key=True, nullable=False),
sqlalchemy.Column('activity_stream_last_viewed', sqlalchemy.types.DateTime,
nullable=False)
nullable=False),
sqlalchemy.Column('last_activity_stream_email_notification',
sqlalchemy.types.DateTime, nullable=False)
)


Expand All @@ -18,13 +20,24 @@ class Dashboard(object):
def __init__(self, user_id):
self.user_id = user_id
self.activity_stream_last_viewed = datetime.datetime.now()
self.last_activity_stream_email_notification = datetime.datetime.now()

@classmethod
def get_activity_stream_last_viewed(cls, user_id):
def _get(cls, user_id):
'''
:raises: sqlalchemy.orm.exc.NoResultFound
'''
query = meta.Session.query(Dashboard)
query = query.filter(Dashboard.user_id == user_id)
row = query.one()
return row

@classmethod
def get_activity_stream_last_viewed(cls, user_id):
try:
row = query.one()
row = cls._get(user_id)
return row.activity_stream_last_viewed
except sqlalchemy.orm.exc.NoResultFound:
# No dashboard row has been created for this user so they have no
Expand All @@ -34,14 +47,33 @@ def get_activity_stream_last_viewed(cls, user_id):

@classmethod
def update_activity_stream_last_viewed(cls, user_id):
query = meta.Session.query(Dashboard)
query = query.filter(Dashboard.user_id == user_id)
try:
row = query.one()
row = cls._get(user_id)
row.activity_stream_last_viewed = datetime.datetime.now()
except sqlalchemy.orm.exc.NoResultFound:
row = Dashboard(user_id)
meta.Session.add(row)
meta.Session.commit()

@classmethod
def get_last_activity_stream_email_notification(cls, user_id):
try:
row = cls._get(user_id)
return row.activity_stream_last_viewed
except sqlalchemy.orm.exc.NoResultFound:
# No dashboard row has been created for this user so they have no
# last_activity_stream_email_notification date. Return the oldest
# date we can (i.e. all activities are new to this user).
return datetime.datetime.min

@classmethod
def update_last_activity_stream_email_notification(cls, user_id):
try:
row = cls._get(user_id)
row.last_activity_stream_email_notification = datetime.datetime.now()
except sqlalchemy.orm.exc.NoResultFound:
row = Dashboard(user_id)
meta.Session.add(row)
meta.Session.commit()

meta.mapper(Dashboard, dashboard_table)
11 changes: 11 additions & 0 deletions ckan/model/user.py
Expand Up @@ -23,6 +23,7 @@
Column('created', types.DateTime, default=datetime.datetime.now),
Column('reset_key', types.UnicodeText),
Column('about', types.UnicodeText),
Column('last_activity_streams_notification_email', types.DateTime),
)


Expand All @@ -48,6 +49,16 @@ def get(cls, user_reference):
cls.id == user_reference))
return query.first()

@classmethod
def all(cls):
'''Return all users in this CKAN instance.
:rtype: list of ckan.model.user.User objects
'''
q = meta.Session.query(cls)
return q.all()

@property
def display_name(self):
if self.fullname is not None and len(self.fullname.strip()) > 0:
Expand Down
78 changes: 78 additions & 0 deletions ckan/tests/lib/test_email_notifications.py
@@ -0,0 +1,78 @@
import json

import ckan.model as model
import ckan.tests.mock_mail_server as mock_mail_server
import ckan.lib.email_notifications as email_notifications
import ckan.tests
import ckan.tests.pylons_controller

import paste
import pylons.test


class TestEmailNotifications(mock_mail_server.SmtpServerHarness,
ckan.tests.pylons_controller.PylonsTestCase):

@classmethod
def setup_class(cls):
mock_mail_server.SmtpServerHarness.setup_class()
ckan.tests.pylons_controller.PylonsTestCase.setup_class()
ckan.tests.CreateTestData.create()
cls.app = paste.fixture.TestApp(pylons.test.pylonsapp)
joeadmin = model.User.get('joeadmin')
cls.joeadmin = {'id': joeadmin.id,
'apikey': joeadmin.apikey,
}

@classmethod
def teardown_class(self):
mock_mail_server.SmtpServerHarness.teardown_class()
ckan.tests.pylons_controller.PylonsTestCase.teardown_class()
model.repo.rebuild_db()

def test_01_no_email_notifications_after_registration(self):
'''Test that a newly registered user who is not following anything
doesn't get any email notifications.'''

# Clear any emails already sent due to CreateTestData.create().
email_notifications.get_and_send_notifications_for_all_users()
self.clear_smtp_messages()

# Register a new user.
params = {'name': 'sara',
'email': 'sara@sararollins.com',
'password': 'sara',
'fullname': 'Sara Rollins',
}
extra_environ = {'Authorization': str(self.joeadmin['apikey'])}
response = self.app.post('/api/action/user_create',
params=json.dumps(params), extra_environ=extra_environ).json
assert response['success'] is True

# Save the user for later tests to use.
TestEmailNotifications.user = response['result']

# No notification emails should be sent to anyone at this point.
email_notifications.get_and_send_notifications_for_all_users()
assert len(self.get_smtp_messages()) == 0

def test_02_fuck_yeah_email_notifications(self):

# You have to follow something or you don't get any emails.
params = {'id': 'warandpeace'}
extra_environ = {'Authorization': str(self.user['apikey'])}
response = self.app.post('/api/action/follow_dataset',
params=json.dumps(params), extra_environ=extra_environ).json
assert response['success'] is True

# Make someone else update the dataset we're following to create an
# email notification.
params = {'name': 'warandpeace', 'notes': 'updated'}
extra_environ = {'Authorization': str(self.joeadmin['apikey'])}
response = self.app.post('/api/action/package_update',
params=json.dumps(params), extra_environ=extra_environ).json
assert response['success'] is True

# One notification email should be sent to anyone at this point.
email_notifications.get_and_send_notifications_for_all_users()
assert len(self.get_smtp_messages()) == 1

0 comments on commit a678e0e

Please sign in to comment.