Commit 02832e16 by Bach Dániel

Merge branch 'feature-email-notification' into 'master'

Feature Email Notification

fixes #167
parents 3692fb68 4f0368f0
...@@ -79,6 +79,8 @@ ADMINS = ( ...@@ -79,6 +79,8 @@ ADMINS = (
('Root', 'root@localhost'), ('Root', 'root@localhost'),
) )
EMAIL_SUBJECT_PREFIX = get_env_variable('DJANGO_SUBJECT_PREFIX', '[CIRCLE] ')
# See: https://docs.djangoproject.com/en/dev/ref/settings/#managers # See: https://docs.djangoproject.com/en/dev/ref/settings/#managers
MANAGERS = ADMINS MANAGERS = ADMINS
########## END MANAGER CONFIGURATION ########## END MANAGER CONFIGURATION
......
...@@ -1092,14 +1092,13 @@ class TraitForm(forms.ModelForm): ...@@ -1092,14 +1092,13 @@ class TraitForm(forms.ModelForm):
class MyProfileForm(forms.ModelForm): class MyProfileForm(forms.ModelForm):
class Meta: class Meta:
fields = ('preferred_language', ) fields = ('preferred_language', 'email_notifications', )
model = Profile model = Profile
@property @property
def helper(self): def helper(self):
helper = FormHelper() helper = FormHelper()
helper.layout = Layout('preferred_language', ) helper.add_input(Submit("submit", _("Save")))
helper.add_input(Submit("submit", _("Change language")))
return helper return helper
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
...@@ -1107,6 +1106,19 @@ class MyProfileForm(forms.ModelForm): ...@@ -1107,6 +1106,19 @@ class MyProfileForm(forms.ModelForm):
return value return value
class UnsubscribeForm(forms.ModelForm):
class Meta:
fields = ('email_notifications', )
model = Profile
@property
def helper(self):
helper = FormHelper()
helper.add_input(Submit("submit", _("Save")))
return helper
class CirclePasswordChangeForm(PasswordChangeForm): class CirclePasswordChangeForm(PasswordChangeForm):
@property @property
......
...@@ -84,6 +84,9 @@ class Profile(Model): ...@@ -84,6 +84,9 @@ class Profile(Model):
help_text=_('Unique identifier of the person, e.g. a student number.')) help_text=_('Unique identifier of the person, e.g. a student number.'))
instance_limit = IntegerField(default=5) instance_limit = IntegerField(default=5)
use_gravatar = BooleanField(default=False) use_gravatar = BooleanField(default=False)
email_notifications = BooleanField(
verbose_name=_("Email notifications"), default=True,
help_text=_('Whether user wants to get digested email notifications.'))
def notify(self, subject, template, context={}, valid_until=None): def notify(self, subject, template, context={}, valid_until=None):
return Notification.send(self.user, subject, template, context, return Notification.send(self.user, subject, template, context,
......
# Copyright 2014 Budapest University of Technology and Economics (BME IK)
#
# This file is part of CIRCLE Cloud.
#
# CIRCLE is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option)
# any later version.
#
# CIRCLE is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
# details.
#
# You should have received a copy of the GNU General Public License along
# with CIRCLE. If not, see <http://www.gnu.org/licenses/>.
import logging
from django.conf import settings
from django.core.mail import send_mail
from django.core.urlresolvers import reverse
from django.db.models import Q
from django.template.loader import render_to_string
from django.utils import timezone
from django.utils.translation import ungettext, override
from manager.mancelery import celery
from ..models import Notification
from ..views import UnsubscribeFormView
logger = logging.getLogger(__name__)
@celery.task(ignore_result=True)
def send_email_notifications():
q = Q(status=Notification.STATUS.new) & (
Q(valid_until__lt=timezone.now()) | Q(valid_until=None))
from_email = settings.DEFAULT_FROM_EMAIL
recipients = {}
for i in Notification.objects.filter(q):
recipients.setdefault(i.to, [])
recipients[i.to].append(i)
for user, msgs in recipients.iteritems():
if (not user.profile or not user.email or not
user.profile.email_notifications):
logger.debug("%s gets no notifications", unicode(user))
continue
with override(user.profile.preferred_language):
context = {'user': user.profile, 'messages': msgs,
'url': (settings.DJANGO_URL.rstrip("/") +
reverse("dashboard.views.notifications")),
'unsub': (settings.DJANGO_URL.rstrip("/") + reverse(
"dashboard.views.unsubscribe",
args=[UnsubscribeFormView.get_token(user)])),
'site': settings.COMPANY_NAME}
subject = settings.EMAIL_SUBJECT_PREFIX + ungettext(
"%d new notification",
"%d new notifications", len(msgs)) % len(msgs)
body = render_to_string('dashboard/notifications/email.txt',
context)
try:
send_mail(subject, body, from_email, (user.email, ))
except:
logger.error("Failed to send mail to %s", user, exc_info=True)
else:
logger.info("Delivered notifications %s",
" ".join(unicode(i.pk) for i in msgs))
for i in msgs:
i.status = i.STATUS.delivered
i.save()
{% load i18n %}
{% blocktrans with u=user %}Dear {{u}},{% endblocktrans %}
{% blocktrans count n=messages|length %}You have a new notification:{% plural %}You have {{n}} new notifications:{% endblocktrans %}
{% for msg in messages %} * {{msg.subject}}
{% endfor %}
{% blocktrans with url=url count n=messages|length %}See it in detail on <{{url}}>.{% plural %} See them in detail on <{{url}}>.{% endblocktrans %}
--
{{site}} CIRCLE Cloud
{% trans "You can change your subscription without logging in:" %}
{{unsub}}
{% extends "dashboard/base.html" %}
{% load crispy_forms_tags %}
{% load i18n %}
{% block content %}
<div class="body-content">
<div class="panel panel-default" style="margin-top: 60px;">
<div class="panel-heading">
<h3 class="no-margin">
{% trans "Subscription settings" %}
</h3>
</div>
<div class="panel-body">
<form method="POST" action="">
{% csrf_token %}
{% crispy form %}
</form>
</div>
</div>
</div>
{% endblock %}
...@@ -21,10 +21,14 @@ from mock import patch, MagicMock ...@@ -21,10 +21,14 @@ from mock import patch, MagicMock
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from django.core.signing import TimestampSigner, JSONSerializer, b64_encode
from django.http import HttpRequest, Http404 from django.http import HttpRequest, Http404
from django.utils import baseconv
from ..models import Profile
from ..views import InstanceActivityDetail, InstanceActivity from ..views import InstanceActivityDetail, InstanceActivity
from ..views import vm_ops, Instance from ..views import vm_ops, Instance, UnsubscribeFormView
from .. import views
class ViewUserTestCase(unittest.TestCase): class ViewUserTestCase(unittest.TestCase):
...@@ -55,6 +59,61 @@ class ViewUserTestCase(unittest.TestCase): ...@@ -55,6 +59,61 @@ class ViewUserTestCase(unittest.TestCase):
self.assertEquals(view(request, pk=1234).render().status_code, 200) self.assertEquals(view(request, pk=1234).render().status_code, 200)
class ExpiredSigner(TimestampSigner):
def timestamp(self):
return baseconv.base62.encode(1)
@classmethod
def dumps(cls, obj, key=None, salt='django.core.signing',
serializer=JSONSerializer, compress=False):
data = serializer().dumps(obj)
base64d = b64_encode(data)
return cls(key, salt=salt).sign(base64d)
class SubscribeTestCase(unittest.TestCase):
@patch.object(views.UnsubscribeFormView, 'get_queryset')
@patch.object(views.UnsubscribeFormView, 'form_valid')
def test_change(self, iv, gq):
view = views.UnsubscribeFormView.as_view()
p = MagicMock(spec=Profile, email_notifications=True)
gq.return_value.get.return_value = p
token = UnsubscribeFormView.get_token(MagicMock(pk=1))
request = FakeRequestFactory(POST={})
self.assertEquals(view(request, token=token), iv.return_value)
gq.return_value.get.assert_called_with(user_id=1)
@patch.object(views.UnsubscribeFormView, 'get_queryset')
@patch.object(views.UnsubscribeFormView, 'form_valid')
def test_change_to_true(self, iv, gq):
view = views.UnsubscribeFormView.as_view()
p = MagicMock(spec=Profile, email_notifications=False)
gq.return_value.get.return_value = p
token = UnsubscribeFormView.get_token(MagicMock(pk=1))
request = FakeRequestFactory(POST={'email_notifications': 'on'})
self.assertEquals(view(request, token=token), iv.return_value)
gq.return_value.get.assert_called_with(user_id=1)
def test_404_for_invalid_token(self):
view = UnsubscribeFormView.as_view()
request = FakeRequestFactory()
with self.assertRaises(Http404):
view(request, token="foo:bar")
def test_redirect_for_old_token(self):
oldtoken = ExpiredSigner.dumps(1, salt=UnsubscribeFormView.get_salt())
view = UnsubscribeFormView.as_view()
request = FakeRequestFactory()
assert view(request, token=oldtoken)['location']
def test_post_redirect_for_old_token(self):
oldtoken = ExpiredSigner.dumps(1, salt=UnsubscribeFormView.get_salt())
view = UnsubscribeFormView.as_view()
request = FakeRequestFactory(POST={})
assert view(request, token=oldtoken)['location']
class VmOperationViewTestCase(unittest.TestCase): class VmOperationViewTestCase(unittest.TestCase):
def test_available(self): def test_available(self):
......
import unittest
from mock import patch, MagicMock
from django.contrib.auth.models import User
from ..models import Notification
from ..tasks import local_periodic_tasks
@patch.object(local_periodic_tasks, 'send_mail')
@patch.object(Notification, 'objects')
class EmailNotificationTestCase(unittest.TestCase):
nextpk = 0
def get_fake_notification(self, user=None, **kwargs):
self.nextpk += 1
if user is None:
user = MagicMock(spec=User, pk=self.nextpk)
user.profile.__unicode__.return_value = "user"
user.email = "mail"
user.profile.email_notifications = True
user.profile.preferred_language = "en"
params = {"to": user, "subject": "subj", "message": "msg",
"status": Notification.STATUS.new}
params.update(kwargs)
return MagicMock(spec=Notification, **params)
def test_not_sending(self, no, sm):
fake = [self.get_fake_notification()]
fake[0].to.profile.email_notifications = False
no.filter.return_value = fake
local_periodic_tasks.send_email_notifications()
assert not sm.called
def test_sending(self, no, sm):
fake = [self.get_fake_notification()]
no.filter.return_value = fake
local_periodic_tasks.send_email_notifications()
assert sm.called
assert all(i.status == i.STATUS.delivered for i in fake)
def test_sending_more(self, no, sm):
fake = [self.get_fake_notification(), self.get_fake_notification()]
fake.append(self.get_fake_notification(fake[0].to))
no.filter.return_value = fake
local_periodic_tasks.send_email_notifications()
self.assertEquals(sm.call_count, 2)
assert all(i.status == i.STATUS.delivered for i in fake)
def test_sending_some(self, no, sm):
fake = [self.get_fake_notification(), self.get_fake_notification()]
fake.append(self.get_fake_notification(fake[0].to))
fake[1].to.profile.email_notifications = False
no.filter.return_value = fake
local_periodic_tasks.send_email_notifications()
self.assertEquals(
[i.status == i.STATUS.delivered for i in fake],
[True, False, True])
self.assertEquals(sm.call_count, 1)
...@@ -35,7 +35,7 @@ from .views import ( ...@@ -35,7 +35,7 @@ from .views import (
TemplateChoose, TemplateChoose,
UserCreationView, UserCreationView,
get_vm_screenshot, get_vm_screenshot,
ProfileView, toggle_use_gravatar, ProfileView, toggle_use_gravatar, UnsubscribeFormView,
) )
urlpatterns = patterns( urlpatterns = patterns(
...@@ -140,6 +140,8 @@ urlpatterns = patterns( ...@@ -140,6 +140,8 @@ urlpatterns = patterns(
url(r'^profile/$', MyPreferencesView.as_view(), url(r'^profile/$', MyPreferencesView.as_view(),
name="dashboard.views.profile-preferences"), name="dashboard.views.profile-preferences"),
url(r'^subscribe/(?P<token>.*)/$', UnsubscribeFormView.as_view(),
name="dashboard.views.unsubscribe"),
url(r'^profile/(?P<username>[^/]+)/$', ProfileView.as_view(), url(r'^profile/(?P<username>[^/]+)/$', ProfileView.as_view(),
name="dashboard.views.profile"), name="dashboard.views.profile"),
url(r'^profile/(?P<username>[^/]+)/use_gravatar/$', toggle_use_gravatar), url(r'^profile/(?P<username>[^/]+)/use_gravatar/$', toggle_use_gravatar),
......
...@@ -59,7 +59,7 @@ from braces.views._access import AccessMixin ...@@ -59,7 +59,7 @@ from braces.views._access import AccessMixin
from .forms import ( from .forms import (
CircleAuthenticationForm, DiskAddForm, HostForm, LeaseForm, MyProfileForm, CircleAuthenticationForm, DiskAddForm, HostForm, LeaseForm, MyProfileForm,
NodeForm, TemplateForm, TraitForm, VmCustomizeForm, GroupCreateForm, NodeForm, TemplateForm, TraitForm, VmCustomizeForm, GroupCreateForm,
UserCreationForm, GroupProfileUpdateForm, UserCreationForm, GroupProfileUpdateForm, UnsubscribeForm,
CirclePasswordChangeForm CirclePasswordChangeForm
) )
...@@ -2173,7 +2173,7 @@ class AbstractVmFunctionView(AccessMixin, View): ...@@ -2173,7 +2173,7 @@ class AbstractVmFunctionView(AccessMixin, View):
@classmethod @classmethod
def get_token(cls, instance, user, *args): def get_token(cls, instance, user, *args):
t = tuple([getattr(i, 'pk', i) for i in [instance, user] + list(args)]) t = tuple([getattr(i, 'pk', i) for i in [instance, user] + list(args)])
return signing.dumps(t, salt=cls.get_salt()) return signing.dumps(t, salt=cls.get_salt(), compress=True)
@classmethod @classmethod
def get_token_url(cls, instance, user, *args): def get_token_url(cls, instance, user, *args):
...@@ -2605,6 +2605,47 @@ class MyPreferencesView(UpdateView): ...@@ -2605,6 +2605,47 @@ class MyPreferencesView(UpdateView):
return self.render_to_response(context) return self.render_to_response(context)
class UnsubscribeFormView(SuccessMessageMixin, UpdateView):
model = Profile
form_class = UnsubscribeForm
template_name = "dashboard/unsubscribe.html"
success_message = _("Successfully modified subscription.")
def get_success_url(self):
if self.request.user.is_authenticated():
return super(UnsubscribeFormView, self).get_success_url()
else:
return self.request.path
@classmethod
def get_salt(cls):
return unicode(cls)
@classmethod
def get_token(cls, user):
return signing.dumps(user.pk, salt=cls.get_salt(), compress=True)
def get_object(self, queryset=None):
key = self.kwargs['token']
try:
pk = signing.loads(key, salt=self.get_salt(), max_age=48*3600)
except signing.SignatureExpired:
raise
except (signing.BadSignature, ValueError, TypeError) as e:
logger.warning('Tried invalid token. Token: %s, user: %s. %s',
key, unicode(self.request.user), unicode(e))
raise Http404
else:
return (queryset or self.get_queryset()).get(user_id=pk)
def dispatch(self, request, *args, **kwargs):
try:
return super(UnsubscribeFormView, self).dispatch(
request, *args, **kwargs)
except signing.SignatureExpired:
return redirect('dashboard.views.profile-preferences')
def set_language_cookie(request, response, lang=None): def set_language_cookie(request, response, lang=None):
if lang is None: if lang is None:
try: try:
......
...@@ -63,6 +63,12 @@ celery.conf.update( ...@@ -63,6 +63,12 @@ celery.conf.update(
'schedule': timedelta(hours=1), 'schedule': timedelta(hours=1),
'options': {'queue': 'localhost.man'} 'options': {'queue': 'localhost.man'}
}, },
'dashboard.local_periodic_tasks': {
'task': 'dashboard.tasks.local_periodic_tasks.'
'send_email_notifications',
'schedule': timedelta(hours=24),
'options': {'queue': 'localhost.man'}
},
} }
) )
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment