diff --git a/kitsune/notifications/decorators.py b/kitsune/notifications/decorators.py new file mode 100644 index 00000000000..363c66729f7 --- /dev/null +++ b/kitsune/notifications/decorators.py @@ -0,0 +1,11 @@ +notification_handlers = set() + + +def notification_handler(fn): + """ + Register a function to be called via Celery for every notification. + + This may be used as a decorator or as a simple function. + """ + notification_handlers.add(fn) + return fn diff --git a/kitsune/notifications/models.py b/kitsune/notifications/models.py index 76f5395310b..e0478b1830b 100644 --- a/kitsune/notifications/models.py +++ b/kitsune/notifications/models.py @@ -1,3 +1,4 @@ +import logging from datetime import datetime from django.contrib.auth.models import User @@ -7,8 +8,14 @@ import actstream.registry from actstream.models import Action +import requests +from requests.exceptions import RequestException from kitsune.sumo.models import ModelBase +from kitsune.notifications.decorators import notification_handler + + +logger = logging.getLogger('k.notifications') class Notification(ModelBase): @@ -41,7 +48,35 @@ class PushNotificationRegistration(ModelBase): @receiver(post_save, sender=Action, dispatch_uid='action_create_notifications') def add_notification_for_action(sender, instance, created, **kwargs): """When an Action is created, notify every user following something in the Action.""" - from kitsune.notifications import tasks # avoid circular import if not created: return + from kitsune.notifications import tasks # avoid circular import tasks.add_notification_for_action.delay(instance.id) + + +@receiver(post_save, sender=Notification, dispatch_uid='send_notification') +def send_notification(sender, instance, created, **kwargs): + if not created: + return + from kitsune.notifications import tasks # avoid circular import + tasks.send_notification.delay(instance.id) + + +@notification_handler +def simple_push(notification): + """ + Send simple push notifications to users that have opted in to them. + + This will be called as a part of a celery task. + """ + registrations = PushNotificationRegistration.objects.filter(creator=notification.owner) + for reg in registrations: + try: + r = requests.put(reg.push_url, 'version={}'.format(notification.id)) + # If something does wrong, the SimplePush server will give back + # json encoded error messages. + if r.status_code != 200: + logger.error('SimplePush error: %s %s', r.status_code, r.json()) + except RequestException as e: + # This will go to Sentry. + logger.error('SimplePush PUT failed: %s', e) diff --git a/kitsune/notifications/tasks.py b/kitsune/notifications/tasks.py index 0c564f70f08..cd062fe084b 100644 --- a/kitsune/notifications/tasks.py +++ b/kitsune/notifications/tasks.py @@ -6,9 +6,10 @@ from celery import task from kitsune.notifications.models import Notification +from kitsune.notifications.decorators import notification_handlers -@task() +@task(ignore_result=True) def add_notification_for_action(action_id): action = Action.objects.get(id=action_id) @@ -33,9 +34,19 @@ def add_notification_for_action(action_id): object_id=action.action_object.pk, actor_only=False) + query = query & ~Q(user=action.actor) + # execute the above query, iterate through the results, get every user # assocated with those Follow objects, and fire off a notification to # every one of them. Use a set to only notify each user once. users_to_notify = set(f.user for f in Follow.objects.filter(query)) notifications = [Notification(owner=u, action=action) for u in users_to_notify] Notification.objects.bulk_create(notifications) + + +@task(ignore_result=True) +def send_notification(notification_id): + """Call every notification handler for a notification.""" + notification = Notification.objects.get(id=notification_id) + for handler in notification_handlers: + handler(notification) diff --git a/kitsune/notifications/tests/test_signals.py b/kitsune/notifications/tests/test_signals.py index 92fdbee182b..a52e20eeabb 100644 --- a/kitsune/notifications/tests/test_signals.py +++ b/kitsune/notifications/tests/test_signals.py @@ -1,10 +1,12 @@ -from nose.tools import eq_ - from actstream.actions import follow from actstream.signals import action from actstream.models import Action, Follow +from mock import patch +from nose.tools import eq_ -from kitsune.notifications.models import Notification +from kitsune.notifications import models as notification_models +from kitsune.notifications.models import Notification, PushNotificationRegistration +from kitsune.notifications.tests import notification from kitsune.questions.tests import answer, question from kitsune.sumo.tests import TestCase from kitsune.users.tests import profile @@ -58,3 +60,33 @@ def test_following_action_object(self): eq_(notification.owner, follower) eq_(notification.action, act) + + def test_no_action_for_self(self): + """Test that a notification is not sent for actions the user took.""" + follower = profile().user + q = question(creator=follower, save=True) + # The above might make follows, which this test isn't about. Clear them out. + Follow.objects.all().delete() + follow(follower, q, actor_only=False) + + # Make a new action for the above. This should not trigger notifications. + action.send(q.creator, verb='edited', action_object=q) + act = Action.objects.order_by('-id')[0] + eq_(Notification.objects.filter(action=act).count(), 0) + + +@patch.object(notification_models, 'requests') +class TestSimplePushNotifier(TestCase): + + def test_simple_push_send(self, requests): + """Verify that SimplePush registrations are called.""" + u = profile().user + url = 'http://example.com/simple_push/asdf' + PushNotificationRegistration.objects.create(creator=u, push_url=url) + n = notification(owner=u, save=True) + requests.put.assert_called_once_with(url, 'version={}'.format(n.id)) + + def test_simple_push_not_sent(self, requests): + """Verify that no request is made when there is no SimplePush registration.""" + notification(save=True) + requests.put.assert_not_called() diff --git a/kitsune/questions/models.py b/kitsune/questions/models.py index ee74bc90174..2a5ee12b912 100755 --- a/kitsune/questions/models.py +++ b/kitsune/questions/models.py @@ -162,7 +162,7 @@ def save(self, update=False, *args, **kwargs): # actstream # Authors should automatically follow their own questions. - actstream.actions.follow(self.creator, self, actor_only=False) + actstream.actions.follow(self.creator, self, send_action=False, actor_only=False) def add_metadata(self, **kwargs): """Add (save to db) the passed in metadata. @@ -974,8 +974,9 @@ def save(self, update=True, no_notify=False, *args, **kwargs): QuestionReplyEvent(self).fire(exclude=self.creator) # actstream - actstream.actions.follow(self.creator, self, actor_only=False) - actstream.actions.follow(self.creator, self.question, actor_only=False) + actstream.actions.follow(self.creator, self, send_action=False, actor_only=False) + actstream.actions.follow( + self.creator, self.question, send_action=False, actor_only=False) if not new: # Clear the attached images cache.