Browse Source

send e-mail notifications on new replies

Rafał Pitoń 8 years ago
parent
commit
5318904ea8

+ 3 - 1
misago/conf/defaults.py

@@ -138,8 +138,10 @@ MISAGO_POSTING_MIDDLEWARES = (
     'misago.threads.api.postingendpoint.updatestats.UpdateStatsMiddleware',
     'misago.threads.api.postingendpoint.mentions.MentionsMiddleware',
     'misago.threads.api.postingendpoint.subscribe.SubscribeMiddleware',
-    # Note: always keep SaveChangesMiddleware middleware last one
+    # Note: always keep SaveChangesMiddleware middleware after all state-changing middlewares
     'misago.threads.api.postingendpoint.savechanges.SaveChangesMiddleware',
+    # Those middlewares are last because they don't change app state
+    'misago.threads.api.postingendpoint.emailnotification.EmailNotificationMiddleware',
 )
 
 MISAGO_THREAD_TYPES = (

+ 4 - 1
misago/core/context_processors.py

@@ -17,4 +17,7 @@ def site_address(request):
 
 
 def frontend_context(request):
-    return {'frontend_context': request.frontend_context}
+    if request.include_frontend_context:
+        return {'frontend_context': request.frontend_context}
+    else:
+        return {}

+ 9 - 6
misago/core/mail.py

@@ -13,8 +13,8 @@ def build_mail(request, recipient, subject, template, context=None):
     message_plain = render_to_string('%s.txt' % template, context)
     message_html = render_to_string('%s.html' % template, context)
 
-    message = djmail.EmailMultiAlternatives(subject, message_plain,
-                                            to=[recipient.email])
+    message = djmail.EmailMultiAlternatives(
+        subject, message_plain, to=[recipient.email])
     message.attach_alternative(message_html, "text/html")
 
     return message
@@ -29,9 +29,12 @@ def mail_users(request, recipients, subject, template, context=None):
     messages = []
 
     for recipient in recipients:
-        messages.append(
-            build_mail(request, recipient, subject, template, context))
+        messages.append(build_mail(request, recipient, subject, template, context))
 
     if messages:
-        connection = djmail.get_connection()
-        connection.send_messages(messages)
+        send_messages(messages)
+
+
+def send_messages(messages):
+    connection = djmail.get_connection()
+    connection.send_messages(messages)

+ 1 - 0
misago/core/middleware/frontendcontext.py

@@ -1,3 +1,4 @@
 class FrontendContextMiddleware(object):
     def process_request(self, request):
+        request.include_frontend_context = True
         request.frontend_context = {}

+ 4 - 0
misago/core/tests/test_context_processors.py

@@ -47,8 +47,12 @@ class FrontendContextTests(TestCase):
     def test_frontend_context(self):
         """frontend_context is available in templates"""
         mock_request = MockRequest(False, 'somewhere.com')
+        mock_request.include_frontend_context = True
         mock_request.frontend_context = {'someValue': 'Something'}
 
         self.assertEqual(
             context_processors.frontend_context(mock_request),
             {'frontend_context': {'someValue': 'Something'}})
+
+        mock_request.include_frontend_context = False
+        self.assertEqual(context_processors.frontend_context(mock_request), {})

+ 3 - 0
misago/core/tests/test_errorpages.py

@@ -50,6 +50,9 @@ class CustomErrorPagesTests(TestCase):
         self.misago_request.user = AnonymousUser()
         self.site_request.user = AnonymousUser()
 
+        self.misago_request.include_frontend_context = True
+        self.site_request.include_frontend_context = True
+
         self.misago_request.frontend_context = {}
         self.site_request.frontend_context = {}
 

+ 1 - 0
misago/core/tests/test_exceptionhandler_middleware.py

@@ -12,6 +12,7 @@ class ExceptionHandlerMiddlewareTests(TestCase):
     def setUp(self):
         self.request = RequestFactory().get(reverse('misago:index'))
         self.request.user = AnonymousUser()
+        self.request.include_frontend_context = True
         self.request.frontend_context = {}
 
     def test_middleware_returns_response_for_supported_exception(self):

+ 19 - 0
misago/templates/misago/emails/thread/reply.html

@@ -0,0 +1,19 @@
+{% extends "misago/emails/base.html" %}
+{% load i18n misago_capture %}
+
+
+{% block content %}
+{% capture trimmed as thread_link %}
+  <a href="{{ thread.get_absolute_url }}">{{ thread }}</a>
+{% endcapture %}
+{% blocktrans trimmed with user=recipient poster=user thread=thread_link|safe %}
+{{ user }}, you are receiving this message because {{ poster }} has replied to the thread {{ thread }} that you are subscribed to.
+{% endblocktrans %}
+<br>
+<br>
+{% trans "To go to this reply, click the below link:" %}
+<br>
+<br>
+<a href="{{ SITE_ADDRESS }}{{ post.get_absolute_url }}">{% trans "Go to reply" %}</a>
+<br>
+{% endblock content %}

+ 12 - 0
misago/templates/misago/emails/thread/reply.txt

@@ -0,0 +1,12 @@
+{% extends "misago/emails/base.txt" %}
+{% load i18n %}
+
+
+{% block content %}
+{% blocktrans trimmed with user=recipient poster=user thread=thread %}
+{{ user }}, you are receiving this message because {{ poster }} has replied to the thread "{{ thread }}" that you are subscribed to.
+{% endblocktrans %}
+
+{% trans "To go to this reply, click the below link:" %}
+{{ SITE_ADDRESS }}{{ post.get_absolute_url }}
+{% endblock content %}

+ 4 - 0
misago/threads/api/postingendpoint/__init__.py

@@ -28,6 +28,10 @@ class PostingEndpoint(object):
 
         self.__dict__.update(kwargs)
 
+        # some middlewares (eg. emailnotification) may call render()
+        # which will crash if this isn't set to false
+        request.include_frontend_context = False
+
         self.datetime = timezone.now()
         self.errors = {}
         self._is_validated = False

+ 55 - 0
misago/threads/api/postingendpoint/emailnotification.py

@@ -0,0 +1,55 @@
+from django.utils.translation import ugettext as _
+
+from misago.core.mail import build_mail, send_messages
+
+from ...permissions.threads import can_see_thread, can_see_post
+from . import PostingEndpoint, PostingMiddleware
+
+
+class EmailNotificationMiddleware(PostingMiddleware):
+    def __init__(self, **kwargs):
+        super(EmailNotificationMiddleware, self).__init__(**kwargs)
+
+        self.previous_last_post_on = self.thread.last_post_on
+
+    def use_this_middleware(self):
+        return self.mode == PostingEndpoint.REPLY
+
+    def post_save(self, serializer):
+        queryset = self.thread.subscription_set.filter(
+            send_email=True,
+            last_read_on__gt=self.previous_last_post_on
+        ).exclude(user=self.user).select_related('user')
+
+        notifications = []
+        for subscription in queryset.iterator():
+            if self.notify_user_of_post(subscription.user):
+                notifications.append(self.build_mail(subscription.user))
+
+        if notifications:
+            send_messages(notifications)
+
+    def notify_user_of_post(self, subscriber):
+        return can_see_thread(subscriber, self.thread) and can_see_post(subscriber, self.post)
+
+    def build_mail(self, subscriber):
+        if subscriber.id == self.thread.starter_id:
+            subject = _('%(user)s has replied to your thread "%(thread)s"')
+        else:
+            subject = _('%(user)s has replied to thread "%(thread)s" that you are watching')
+
+        subject_formats = {
+            'user': self.user.username,
+            'thread': self.thread.title
+        }
+
+        return build_mail(
+            self.request,
+            subscriber,
+            subject % subject_formats,
+            'misago/emails/thread/reply',
+            {
+                'thread': self.thread,
+                'post': self.post
+            }
+        )

+ 6 - 0
misago/threads/migrations/0001_initial.py

@@ -214,4 +214,10 @@ class Migration(migrations.Migration):
             },
             bases=(models.Model,),
         ),
+        migrations.AlterIndexTogether(
+            name='subscription',
+            index_together=set([
+                ('send_email', 'last_read_on'),
+            ]),
+        ),
     ]

+ 5 - 0
misago/threads/models/subscription.py

@@ -11,3 +11,8 @@ class Subscription(models.Model):
 
     last_read_on = models.DateTimeField(default=timezone.now)
     send_email = models.BooleanField(default=False)
+
+    class Meta:
+        index_together = [
+            ['send_email', 'last_read_on']
+        ]

+ 18 - 0
misago/threads/permissions/threads.py

@@ -547,6 +547,24 @@ def allow_edit_thread(user, target):
 can_edit_thread = return_boolean(allow_edit_thread)
 
 
+def allow_see_post(user, target):
+    category_acl = user.acl['categories'].get(target.category_id, {
+        'can_approve_content': False,
+        'can_hide_events': False
+    })
+
+    if not target.is_event and target.is_unapproved:
+        if user.is_anonymous():
+            raise Http404()
+
+        if not category_acl['can_approve_content'] and user.id != target.poster_id:
+            raise Http404()
+
+    if target.is_event and target.is_hidden and not category_acl['can_hide_events']:
+        raise Http404()
+can_see_post = return_boolean(allow_see_post)
+
+
 def allow_edit_post(user, target):
     if user.is_anonymous():
         raise PermissionDenied(_("You have to sign in to edit posts."))

+ 171 - 0
misago/threads/tests/test_emailnotification_middleware.py

@@ -0,0 +1,171 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from copy import deepcopy
+from datetime import timedelta
+
+from django.contrib.auth import get_user_model
+from django.core import mail
+from django.core.urlresolvers import reverse
+from django.utils import timezone
+from django.utils.encoding import smart_str
+
+from misago.acl.testutils import override_acl
+from misago.categories.models import Category
+from misago.users.testutils import AuthenticatedUserTestCase
+
+from .. import testutils
+
+
+class EmailNotificationTests(AuthenticatedUserTestCase):
+    def setUp(self):
+        super(EmailNotificationTests, self).setUp()
+
+        self.category = Category.objects.get(slug='first-category')
+        self.thread = testutils.post_thread(
+            category=self.category,
+            started_on=timezone.now() - timedelta(seconds=5)
+        )
+        self.override_acl()
+
+        self.api_link = reverse('misago:api:thread-post-list', kwargs={
+            'thread_pk': self.thread.pk
+        })
+
+        self.other_user = get_user_model().objects.create_user('Bob', 'bob@boberson.com', 'pass123')
+
+    def override_acl(self):
+        new_acl = deepcopy(self.user.acl)
+        new_acl['categories'][self.category.pk].update({
+            'can_see': 1,
+            'can_browse': 1,
+            'can_start_threads': 1,
+            'can_reply_threads': 1,
+            'can_edit_posts': 1
+        })
+
+        override_acl(self.user, new_acl)
+
+    def override_other_user_acl(self, hide=False):
+        new_acl = deepcopy(self.other_user.acl)
+        new_acl['categories'][self.category.pk].update({
+            'can_see': 1,
+            'can_browse': 1,
+            'can_start_threads': 1,
+            'can_reply_threads': 1,
+            'can_edit_posts': 1
+        })
+
+        if hide:
+            new_acl['categories'][self.category.pk].update({
+                'can_browse': False
+            })
+
+        override_acl(self.other_user, new_acl)
+
+    def test_no_subscriptions(self):
+        """no emails are sent because noone subscibes to thread"""
+        response = self.client.post(self.api_link, data={
+            'post': 'This is test response!'
+        })
+        self.assertEqual(response.status_code, 200)
+
+        self.assertEqual(len(mail.outbox), 0)
+
+    def test_poster_not_notified(self):
+        """no emails are sent because only poster subscribes to thread"""
+        self.user.subscription_set.create(
+            thread=self.thread,
+            category=self.category,
+            last_read_on=timezone.now(),
+            send_email=True
+        )
+
+        response = self.client.post(self.api_link, data={
+            'post': 'This is test response!'
+        })
+        self.assertEqual(response.status_code, 200)
+
+        self.assertEqual(len(mail.outbox), 0)
+
+    def test_other_user_no_email_subscription(self):
+        """no emails are sent because subscriber has e-mails off"""
+        self.other_user.subscription_set.create(
+            thread=self.thread,
+            category=self.category,
+            last_read_on=timezone.now(),
+            send_email=False
+        )
+
+        response = self.client.post(self.api_link, data={
+            'post': 'This is test response!'
+        })
+        self.assertEqual(response.status_code, 200)
+
+        self.assertEqual(len(mail.outbox), 0)
+
+    def test_other_user_no_permission(self):
+        """no emails are sent because subscriber has no permission to read thread"""
+        self.other_user.subscription_set.create(
+            thread=self.thread,
+            category=self.category,
+            last_read_on=timezone.now(),
+            send_email=True
+        )
+        self.override_other_user_acl(hide=True)
+
+        response = self.client.post(self.api_link, data={
+            'post': 'This is test response!'
+        })
+        self.assertEqual(response.status_code, 200)
+
+        self.assertEqual(len(mail.outbox), 0)
+
+    def test_other_user_not_read(self):
+        """no emails are sent because subscriber didn't read previous post"""
+        self.other_user.subscription_set.create(
+            thread=self.thread,
+            category=self.category,
+            last_read_on=timezone.now(),
+            send_email=True
+        )
+        self.override_other_user_acl()
+
+        testutils.reply_thread(self.thread, posted_on=timezone.now())
+
+        response = self.client.post(self.api_link, data={
+            'post': 'This is test response!'
+        })
+        self.assertEqual(response.status_code, 200)
+
+        self.assertEqual(len(mail.outbox), 0)
+
+    def test_other_notified(self):
+        """email is sent to subscriber"""
+        self.other_user.subscription_set.create(
+            thread=self.thread,
+            category=self.category,
+            last_read_on=timezone.now(),
+            send_email=True
+        )
+        self.override_other_user_acl()
+
+        response = self.client.post(self.api_link, data={
+            'post': 'This is test response!'
+        })
+        self.assertEqual(response.status_code, 200)
+
+        self.assertEqual(len(mail.outbox), 1)
+        last_email = mail.outbox[-1]
+
+        self.assertIn(self.user.username, last_email.subject)
+        self.assertIn(self.thread.title, last_email.subject)
+
+        message = smart_str(last_email.body)
+
+        self.assertIn(self.user.username, message)
+        self.assertIn(self.thread.title, message)
+        self.assertIn(self.thread.get_absolute_url(), message)
+
+        last_post = self.thread.post_set.order_by('id').last()
+        self.assertIn(last_post.get_absolute_url(), message)

+ 15 - 11
misago/users/context_processors.py

@@ -5,21 +5,22 @@ from .serializers import AnonymousUserSerializer, AuthenticatedUserSerializer
 
 
 def user_links(request):
-    request.frontend_context.update({
-        'REQUEST_ACTIVATION_URL': reverse('misago:request-activation'),
-        'FORGOTTEN_PASSWORD_URL': reverse('misago:forgotten-password'),
+    if request.include_frontend_context:
+        request.frontend_context.update({
+            'REQUEST_ACTIVATION_URL': reverse('misago:request-activation'),
+            'FORGOTTEN_PASSWORD_URL': reverse('misago:forgotten-password'),
 
-        'BANNED_URL': reverse('misago:banned'),
+            'BANNED_URL': reverse('misago:banned'),
 
-        'USERCP_URL': reverse('misago:options'),
-        'USERS_LIST_URL': reverse('misago:users'),
+            'USERCP_URL': reverse('misago:options'),
+            'USERS_LIST_URL': reverse('misago:users'),
 
-        'AUTH_API': reverse('misago:api:auth'),
-        'USERS_API': reverse('misago:api:user-list'),
+            'AUTH_API': reverse('misago:api:auth'),
+            'USERS_API': reverse('misago:api:user-list'),
 
-        'CAPTCHA_API': reverse('misago:api:captcha-question'),
-        'USERNAME_CHANGES_API': reverse('misago:api:usernamechange-list'),
-    })
+            'CAPTCHA_API': reverse('misago:api:captcha-question'),
+            'USERNAME_CHANGES_API': reverse('misago:api:usernamechange-list'),
+        })
 
     return {
         'USERCP_URL': usercp.get_default_link(),
@@ -29,6 +30,9 @@ def user_links(request):
 
 
 def preload_user_json(request):
+    if not request.include_frontend_context:
+        return {}
+
     request.frontend_context.update({
         'isAuthenticated': request.user.is_authenticated(),
     })