Commit 41f19b66 authored by Jeremy Kerr's avatar Jeremy Kerr
Browse files

Add email opt-out system



We're going to start generating emails on patchwork updates, so firstly
allow people to opt-out of all patchwork communications.

We do this with a 'mail settings' interface, allowing non-registered
users to set preferences on their email address. Logged-in users can do
this through the user profile view.
Signed-off-by: default avatarJeremy Kerr <jk@ozlabs.org>
parent c2c6a408
......@@ -227,5 +227,8 @@ class MultiplePatchForm(forms.Form):
instance.save()
return instance
class UserPersonLinkForm(forms.Form):
class EmailForm(forms.Form):
email = forms.EmailField(max_length = 200)
UserPersonLinkForm = EmailForm
OptinoutRequestForm = EmailForm
......@@ -379,6 +379,7 @@ class EmailConfirmation(models.Model):
type = models.CharField(max_length = 20, choices = [
('userperson', 'User-Person association'),
('registration', 'Registration'),
('optout', 'Email opt-out'),
])
email = models.CharField(max_length = 200)
user = models.ForeignKey(User, null = True)
......@@ -400,4 +401,8 @@ class EmailConfirmation(models.Model):
self.key = self._meta.get_field('key').construct(str).hexdigest()
super(EmailConfirmation, self).save()
class EmailOptout(models.Model):
email = models.CharField(max_length = 200, primary_key = True)
def __unicode__(self):
return self.email
......@@ -26,3 +26,4 @@ from patchwork.tests.filters import *
from patchwork.tests.confirm import *
from patchwork.tests.registration import *
from patchwork.tests.user import *
from patchwork.tests.mail_settings import *
# Patchwork - automated patch tracking system
# Copyright (C) 2010 Jeremy Kerr <jk@ozlabs.org>
#
# This file is part of the Patchwork package.
#
# Patchwork 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 2 of the License, or
# (at your option) any later version.
#
# Patchwork 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 Patchwork; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
import unittest
import re
from django.test import TestCase
from django.test.client import Client
from django.core import mail
from django.core.urlresolvers import reverse
from django.contrib.auth.models import User
from patchwork.models import EmailOptout, EmailConfirmation, Person
from patchwork.tests.utils import create_user
class MailSettingsTest(TestCase):
view = 'patchwork.views.mail.settings'
url = reverse(view)
def testMailSettingsGET(self):
response = self.client.get(self.url)
self.assertEquals(response.status_code, 200)
self.assertTrue(response.context['form'])
def testMailSettingsPOST(self):
email = u'foo@example.com'
response = self.client.post(self.url, {'email': email})
self.assertEquals(response.status_code, 200)
self.assertTemplateUsed(response, 'patchwork/mail-settings.html')
self.assertEquals(response.context['email'], email)
def testMailSettingsPOSTEmpty(self):
response = self.client.post(self.url, {'email': ''})
self.assertEquals(response.status_code, 200)
self.assertTemplateUsed(response, 'patchwork/mail-form.html')
self.assertFormError(response, 'form', 'email',
'This field is required.')
def testMailSettingsPOSTInvalid(self):
response = self.client.post(self.url, {'email': 'foo'})
self.assertEquals(response.status_code, 200)
self.assertTemplateUsed(response, 'patchwork/mail-form.html')
self.assertFormError(response, 'form', 'email',
'Enter a valid e-mail address.')
def testMailSettingsPOSTOptedIn(self):
email = u'foo@example.com'
response = self.client.post(self.url, {'email': email})
self.assertEquals(response.status_code, 200)
self.assertTemplateUsed(response, 'patchwork/mail-settings.html')
self.assertEquals(response.context['is_optout'], False)
self.assertTrue('<strong>may</strong>' in response.content)
optout_url = reverse('patchwork.views.mail.optout')
self.assertTrue(('action="%s"' % optout_url) in response.content)
def testMailSettingsPOSTOptedOut(self):
email = u'foo@example.com'
EmailOptout(email = email).save()
response = self.client.post(self.url, {'email': email})
self.assertEquals(response.status_code, 200)
self.assertTemplateUsed(response, 'patchwork/mail-settings.html')
self.assertEquals(response.context['is_optout'], True)
self.assertTrue('<strong>may not</strong>' in response.content)
optin_url = reverse('patchwork.views.mail.optin')
self.assertTrue(('action="%s"' % optin_url) in response.content)
class OptoutRequestTest(TestCase):
view = 'patchwork.views.mail.optout'
url = reverse(view)
def testOptOutRequestGET(self):
response = self.client.get(self.url)
self.assertRedirects(response, reverse('patchwork.views.mail.settings'))
def testOptoutRequestValidPOST(self):
email = u'foo@example.com'
response = self.client.post(self.url, {'email': email})
# check for a confirmation object
self.assertEquals(EmailConfirmation.objects.count(), 1)
conf = EmailConfirmation.objects.get(email = email)
# check confirmation page
self.assertEquals(response.status_code, 200)
self.assertEquals(response.context['confirmation'], conf)
self.assertTrue(email in response.content)
# check email
url = reverse('patchwork.views.confirm', kwargs = {'key': conf.key})
self.assertEquals(len(mail.outbox), 1)
msg = mail.outbox[0]
self.assertEquals(msg.to, [email])
self.assertEquals(msg.subject, 'Patchwork opt-out confirmation')
self.assertTrue(url in msg.body)
def testOptoutRequestInvalidPOSTEmpty(self):
response = self.client.post(self.url, {'email': ''})
self.assertEquals(response.status_code, 200)
self.assertFormError(response, 'form', 'email',
'This field is required.')
self.assertTrue(response.context['error'])
self.assertTrue('email_sent' not in response.context)
self.assertEquals(len(mail.outbox), 0)
def testOptoutRequestInvalidPOSTNonEmail(self):
response = self.client.post(self.url, {'email': 'foo'})
self.assertEquals(response.status_code, 200)
self.assertFormError(response, 'form', 'email',
'Enter a valid e-mail address.')
self.assertTrue(response.context['error'])
self.assertTrue('email_sent' not in response.context)
self.assertEquals(len(mail.outbox), 0)
class OptoutTest(TestCase):
view = 'patchwork.views.mail.optout'
url = reverse(view)
def setUp(self):
self.email = u'foo@example.com'
self.conf = EmailConfirmation(type = 'optout', email = self.email)
self.conf.save()
def testOptoutValidHash(self):
url = reverse('patchwork.views.confirm',
kwargs = {'key': self.conf.key})
response = self.client.get(url)
self.assertEquals(response.status_code, 200)
self.assertTemplateUsed(response, 'patchwork/optout.html')
self.assertTrue(self.email in response.content)
# check that we've got an optout in the list
self.assertEquals(EmailOptout.objects.count(), 1)
self.assertEquals(EmailOptout.objects.all()[0].email, self.email)
# check that the confirmation is now inactive
self.assertFalse(EmailConfirmation.objects.get(
pk = self.conf.pk).active)
class OptoutPreexistingTest(OptoutTest):
"""Test that a duplicated opt-out behaves the same as the initial one"""
def setUp(self):
super(OptoutPreexistingTest, self).setUp()
EmailOptout(email = self.email).save()
class OptinRequestTest(TestCase):
view = 'patchwork.views.mail.optin'
url = reverse(view)
def setUp(self):
self.email = u'foo@example.com'
EmailOptout(email = self.email).save()
def testOptInRequestGET(self):
response = self.client.get(self.url)
self.assertRedirects(response, reverse('patchwork.views.mail.settings'))
def testOptInRequestValidPOST(self):
response = self.client.post(self.url, {'email': self.email})
# check for a confirmation object
self.assertEquals(EmailConfirmation.objects.count(), 1)
conf = EmailConfirmation.objects.get(email = self.email)
# check confirmation page
self.assertEquals(response.status_code, 200)
self.assertEquals(response.context['confirmation'], conf)
self.assertTrue(self.email in response.content)
# check email
url = reverse('patchwork.views.confirm', kwargs = {'key': conf.key})
self.assertEquals(len(mail.outbox), 1)
msg = mail.outbox[0]
self.assertEquals(msg.to, [self.email])
self.assertEquals(msg.subject, 'Patchwork opt-in confirmation')
self.assertTrue(url in msg.body)
def testOptoutRequestInvalidPOSTEmpty(self):
response = self.client.post(self.url, {'email': ''})
self.assertEquals(response.status_code, 200)
self.assertFormError(response, 'form', 'email',
'This field is required.')
self.assertTrue(response.context['error'])
self.assertTrue('email_sent' not in response.context)
self.assertEquals(len(mail.outbox), 0)
def testOptoutRequestInvalidPOSTNonEmail(self):
response = self.client.post(self.url, {'email': 'foo'})
self.assertEquals(response.status_code, 200)
self.assertFormError(response, 'form', 'email',
'Enter a valid e-mail address.')
self.assertTrue(response.context['error'])
self.assertTrue('email_sent' not in response.context)
self.assertEquals(len(mail.outbox), 0)
class OptinTest(TestCase):
def setUp(self):
self.email = u'foo@example.com'
self.optout = EmailOptout(email = self.email)
self.optout.save()
self.conf = EmailConfirmation(type = 'optin', email = self.email)
self.conf.save()
def testOptinValidHash(self):
url = reverse('patchwork.views.confirm',
kwargs = {'key': self.conf.key})
response = self.client.get(url)
self.assertEquals(response.status_code, 200)
self.assertTemplateUsed(response, 'patchwork/optin.html')
self.assertTrue(self.email in response.content)
# check that there's no optout remaining
self.assertEquals(EmailOptout.objects.count(), 0)
# check that the confirmation is now inactive
self.assertFalse(EmailConfirmation.objects.get(
pk = self.conf.pk).active)
class OptinWithoutOptoutTest(TestCase):
"""Test an opt-in with no existing opt-out"""
view = 'patchwork.views.mail.optin'
url = reverse(view)
def testOptInWithoutOptout(self):
email = u'foo@example.com'
response = self.client.post(self.url, {'email': email})
# check for an error message
self.assertEquals(response.status_code, 200)
self.assertTrue(bool(response.context['error']))
self.assertTrue('not on the patchwork opt-out list' in response.content)
class UserProfileOptoutFormTest(TestCase):
"""Test that the correct optin/optout forms appear on the user profile
page, for logged-in users"""
view = 'patchwork.views.user.profile'
url = reverse(view)
optout_url = reverse('patchwork.views.mail.optout')
optin_url = reverse('patchwork.views.mail.optin')
form_re_template = ('<form\s+[^>]*action="%(url)s"[^>]*>'
'.*?<input\s+[^>]*value="%(email)s"[^>]*>.*?'
'</form>')
secondary_email = 'test2@example.com'
def setUp(self):
self.user = create_user()
self.client.login(username = self.user.username,
password = self.user.username)
def _form_re(self, url, email):
return re.compile(self.form_re_template % {'url': url, 'email': email},
re.DOTALL)
def testMainEmailOptoutForm(self):
form_re = self._form_re(self.optout_url, self.user.email)
response = self.client.get(self.url)
self.assertEquals(response.status_code, 200)
self.assertTrue(form_re.search(response.content) is not None)
def testMainEmailOptinForm(self):
EmailOptout(email = self.user.email).save()
form_re = self._form_re(self.optin_url, self.user.email)
response = self.client.get(self.url)
self.assertEquals(response.status_code, 200)
self.assertTrue(form_re.search(response.content) is not None)
def testSecondaryEmailOptoutForm(self):
p = Person(email = self.secondary_email, user = self.user)
p.save()
form_re = self._form_re(self.optout_url, p.email)
response = self.client.get(self.url)
self.assertEquals(response.status_code, 200)
self.assertTrue(form_re.search(response.content) is not None)
def testSecondaryEmailOptinForm(self):
p = Person(email = self.secondary_email, user = self.user)
p.save()
EmailOptout(email = p.email).save()
form_re = self._form_re(self.optin_url, self.user.email)
response = self.client.get(self.url)
self.assertEquals(response.status_code, 200)
self.assertTrue(form_re.search(response.content) is not None)
......@@ -73,6 +73,11 @@ urlpatterns = patterns('',
# submitter autocomplete
(r'^submitter/$', 'patchwork.views.submitter_complete'),
# email setup
(r'^mail/$', 'patchwork.views.mail.settings'),
(r'^mail/optout/$', 'patchwork.views.mail.optout'),
(r'^mail/optin/$', 'patchwork.views.mail.optin'),
# help!
(r'^help/(?P<path>.*)$', 'patchwork.views.help'),
)
......
......@@ -59,10 +59,12 @@ def pwclient(request):
return response
def confirm(request, key):
import patchwork.views.user
import patchwork.views.user, patchwork.views.mail
views = {
'userperson': patchwork.views.user.link_confirm,
'registration': patchwork.views.user.register_confirm,
'optout': patchwork.views.mail.optout_confirm,
'optin': patchwork.views.mail.optin_confirm,
}
conf = get_object_or_404(EmailConfirmation, key = key)
......
# Patchwork - automated patch tracking system
# Copyright (C) 2010 Jeremy Kerr <jk@ozlabs.org>
#
# This file is part of the Patchwork package.
#
# Patchwork 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 2 of the License, or
# (at your option) any later version.
#
# Patchwork 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 Patchwork; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
from patchwork.requestcontext import PatchworkRequestContext
from patchwork.models import EmailOptout, EmailConfirmation
from patchwork.forms import OptinoutRequestForm, EmailForm
from django.shortcuts import render_to_response
from django.template.loader import render_to_string
from django.conf import settings as conf_settings
from django.core.mail import send_mail
from django.core.urlresolvers import reverse
from django.http import HttpResponseRedirect
def settings(request):
context = PatchworkRequestContext(request)
if request.method == 'POST':
form = EmailForm(data = request.POST)
if form.is_valid():
email = form.cleaned_data['email']
is_optout = EmailOptout.objects.filter(email = email).count() > 0
context.update({
'email': email,
'is_optout': is_optout,
})
return render_to_response('patchwork/mail-settings.html', context)
else:
form = EmailForm()
context['form'] = form
return render_to_response('patchwork/mail-form.html', context)
def optout_confirm(request, conf):
context = PatchworkRequestContext(request)
email = conf.email.strip().lower()
# silently ignore duplicated optouts
if EmailOptout.objects.filter(email = email).count() == 0:
optout = EmailOptout(email = email)
optout.save()
conf.deactivate()
context['email'] = conf.email
return render_to_response('patchwork/optout.html', context)
def optin_confirm(request, conf):
context = PatchworkRequestContext(request)
email = conf.email.strip().lower()
EmailOptout.objects.filter(email = email).delete()
conf.deactivate()
context['email'] = conf.email
return render_to_response('patchwork/optin.html', context)
def optinout(request, action, description):
context = PatchworkRequestContext(request)
mail_template = 'patchwork/%s-request.mail' % action
html_template = 'patchwork/%s-request.html' % action
if request.method != 'POST':
return HttpResponseRedirect(reverse(settings))
form = OptinoutRequestForm(data = request.POST)
if not form.is_valid():
context['error'] = ('There was an error in the %s form. ' +
'Please review the form and re-submit.') % \
description
context['form'] = form
return render_to_response(html_template, context)
email = form.cleaned_data['email']
if action == 'optin' and \
EmailOptout.objects.filter(email = email).count() == 0:
context['error'] = ('The email address %s is not on the ' +
'patchwork opt-out list, so you don\'t ' +
'need to opt back in') % email
context['form'] = form
return render_to_response(html_template, context)
conf = EmailConfirmation(type = action, email = email)
conf.save()
context['confirmation'] = conf
mail = render_to_string(mail_template, context)
try:
send_mail('Patchwork %s confirmation' % description, mail,
conf_settings.DEFAULT_FROM_EMAIL, [email])
context['email'] = mail
context['email_sent'] = True
except Exception, ex:
context['error'] = 'An error occurred during confirmation . ' + \
'Please try again later.'
context['admins'] = conf_settings.ADMINS
return render_to_response(html_template, context)
def optout(request):
return optinout(request, 'optout', 'opt-out')
def optin(request):
return optinout(request, 'optin', 'opt-in')
......@@ -24,7 +24,8 @@ from django.shortcuts import render_to_response, get_object_or_404
from django.contrib import auth
from django.contrib.sites.models import Site
from django.http import HttpResponseRedirect
from patchwork.models import Project, Bundle, Person, EmailConfirmation, State
from patchwork.models import Project, Bundle, Person, EmailConfirmation, \
State, EmailOptout
from patchwork.forms import UserProfileForm, UserPersonLinkForm, \
RegistrationForm
from patchwork.filters import DelegateFilter
......@@ -99,7 +100,13 @@ def profile(request):
context['bundles'] = Bundle.objects.filter(owner = request.user)
context['profileform'] = form
people = Person.objects.filter(user = request.user)
optout_query = '%s.%s IN (SELECT %s FROM %s)' % (
Person._meta.db_table,
Person._meta.get_field('email').column,
EmailOptout._meta.get_field('email').column,
EmailOptout._meta.db_table)
people = Person.objects.filter(user = request.user) \
.extra(select = {'is_optout': optout_query})
context['linked_emails'] = people
context['linkform'] = UserPersonLinkForm()
......
......@@ -22,6 +22,7 @@ GRANT SELECT, UPDATE, INSERT, DELETE ON patchwork_project TO 'www-data'@localhos
GRANT SELECT, UPDATE, INSERT, DELETE ON patchwork_bundle TO 'www-data'@localhost;
GRANT SELECT, UPDATE, INSERT, DELETE ON patchwork_bundle_patches TO 'www-data'@localhost;
GRANT SELECT, UPDATE, INSERT, DELETE ON patchwork_patch TO 'www-data'@localhost;
GRANT SELECT, UPDATE, INSERT, DELETE ON patchwork_emailoptout TO 'www-data'@localhost;
-- allow the mail user (in this case, 'nobody') to add patches
GRANT INSERT, SELECT ON patchwork_patch TO 'nobody'@localhost;
......
......@@ -22,7 +22,8 @@ GRANT SELECT, UPDATE, INSERT, DELETE ON
patchwork_project,
patchwork_bundle,
patchwork_bundlepatch,
patchwork_patch
patchwork_patch,
patchwork_emailoptout
TO "www-data";
GRANT SELECT, UPDATE ON
auth_group_id_seq,
......
BEGIN;
CREATE TABLE "patchwork_emailoptout" (
"email" varchar(200) NOT NULL PRIMARY KEY
);
COMMIT;
......@@ -31,6 +31,8 @@
<a href="{% url auth_login %}">login</a>
<br/>
<a href="{% url patchwork.views.user.register %}">register</a>
<br/>
<a href="{% url patchwork.views.mail.settings %}">mail settings</a>
{% endif %}
</div>
<div style="clear: both;"></div>
......
{% extends "base.html" %}
{% block title %}mail settings{% endblock %}
{% block heading %}mail settings{% endblock %}
{% block body %}
<p>You can configure patchwork to send you mail on certain events,
or block automated mail altogether. Enter your email address to
view or change your email settings.</p>
<form method="post">
{% csrf_token %}
<table class="form registerform">
{% if form.errors %}
<tr>
<td colspan="2" class="error">
There was an error accessing your mail settings:
</td>
</tr>
{% endif %}
<tr>
<th>{{ form.email.label_tag }}</th>
<td>
{{form.email}}
{{form.email.errors}}
</td>
</tr>
<tr>
<td colspan="2" class="submitrow">
<input type="submit" value="Access mail settings"/>
</td>
</tr>
</table>
</form>
{% endblock %}
{% extends "base.html" %}
{% block title %}mail settings{% endblock %}