Skip to content

Commit 41f19b6

Browse files
committed
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: Jeremy Kerr <jk@ozlabs.org>
1 parent c2c6a40 commit 41f19b6

21 files changed

+725
-15
lines changed

apps/patchwork/forms.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -227,5 +227,8 @@ def save(self, instance, commit = True):
227227
instance.save()
228228
return instance
229229

230-
class UserPersonLinkForm(forms.Form):
230+
class EmailForm(forms.Form):
231231
email = forms.EmailField(max_length = 200)
232+
233+
UserPersonLinkForm = EmailForm
234+
OptinoutRequestForm = EmailForm

apps/patchwork/models.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -379,6 +379,7 @@ class EmailConfirmation(models.Model):
379379
type = models.CharField(max_length = 20, choices = [
380380
('userperson', 'User-Person association'),
381381
('registration', 'Registration'),
382+
('optout', 'Email opt-out'),
382383
])
383384
email = models.CharField(max_length = 200)
384385
user = models.ForeignKey(User, null = True)
@@ -400,4 +401,8 @@ def save(self):
400401
self.key = self._meta.get_field('key').construct(str).hexdigest()
401402
super(EmailConfirmation, self).save()
402403

404+
class EmailOptout(models.Model):
405+
email = models.CharField(max_length = 200, primary_key = True)
403406

407+
def __unicode__(self):
408+
return self.email

apps/patchwork/tests/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,3 +26,4 @@
2626
from patchwork.tests.confirm import *
2727
from patchwork.tests.registration import *
2828
from patchwork.tests.user import *
29+
from patchwork.tests.mail_settings import *
Lines changed: 302 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,302 @@
1+
# Patchwork - automated patch tracking system
2+
# Copyright (C) 2010 Jeremy Kerr <jk@ozlabs.org>
3+
#
4+
# This file is part of the Patchwork package.
5+
#
6+
# Patchwork is free software; you can redistribute it and/or modify
7+
# it under the terms of the GNU General Public License as published by
8+
# the Free Software Foundation; either version 2 of the License, or
9+
# (at your option) any later version.
10+
#
11+
# Patchwork is distributed in the hope that it will be useful,
12+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
13+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14+
# GNU General Public License for more details.
15+
#
16+
# You should have received a copy of the GNU General Public License
17+
# along with Patchwork; if not, write to the Free Software
18+
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
19+
20+
import unittest
21+
import re
22+
from django.test import TestCase
23+
from django.test.client import Client
24+
from django.core import mail
25+
from django.core.urlresolvers import reverse
26+
from django.contrib.auth.models import User
27+
from patchwork.models import EmailOptout, EmailConfirmation, Person
28+
from patchwork.tests.utils import create_user
29+
30+
class MailSettingsTest(TestCase):
31+
view = 'patchwork.views.mail.settings'
32+
url = reverse(view)
33+
34+
def testMailSettingsGET(self):
35+
response = self.client.get(self.url)
36+
self.assertEquals(response.status_code, 200)
37+
self.assertTrue(response.context['form'])
38+
39+
def testMailSettingsPOST(self):
40+
email = u'foo@example.com'
41+
response = self.client.post(self.url, {'email': email})
42+
self.assertEquals(response.status_code, 200)
43+
self.assertTemplateUsed(response, 'patchwork/mail-settings.html')
44+
self.assertEquals(response.context['email'], email)
45+
46+
def testMailSettingsPOSTEmpty(self):
47+
response = self.client.post(self.url, {'email': ''})
48+
self.assertEquals(response.status_code, 200)
49+
self.assertTemplateUsed(response, 'patchwork/mail-form.html')
50+
self.assertFormError(response, 'form', 'email',
51+
'This field is required.')
52+
53+
def testMailSettingsPOSTInvalid(self):
54+
response = self.client.post(self.url, {'email': 'foo'})
55+
self.assertEquals(response.status_code, 200)
56+
self.assertTemplateUsed(response, 'patchwork/mail-form.html')
57+
self.assertFormError(response, 'form', 'email',
58+
'Enter a valid e-mail address.')
59+
60+
def testMailSettingsPOSTOptedIn(self):
61+
email = u'foo@example.com'
62+
response = self.client.post(self.url, {'email': email})
63+
self.assertEquals(response.status_code, 200)
64+
self.assertTemplateUsed(response, 'patchwork/mail-settings.html')
65+
self.assertEquals(response.context['is_optout'], False)
66+
self.assertTrue('<strong>may</strong>' in response.content)
67+
optout_url = reverse('patchwork.views.mail.optout')
68+
self.assertTrue(('action="%s"' % optout_url) in response.content)
69+
70+
def testMailSettingsPOSTOptedOut(self):
71+
email = u'foo@example.com'
72+
EmailOptout(email = email).save()
73+
response = self.client.post(self.url, {'email': email})
74+
self.assertEquals(response.status_code, 200)
75+
self.assertTemplateUsed(response, 'patchwork/mail-settings.html')
76+
self.assertEquals(response.context['is_optout'], True)
77+
self.assertTrue('<strong>may not</strong>' in response.content)
78+
optin_url = reverse('patchwork.views.mail.optin')
79+
self.assertTrue(('action="%s"' % optin_url) in response.content)
80+
81+
class OptoutRequestTest(TestCase):
82+
view = 'patchwork.views.mail.optout'
83+
url = reverse(view)
84+
85+
def testOptOutRequestGET(self):
86+
response = self.client.get(self.url)
87+
self.assertRedirects(response, reverse('patchwork.views.mail.settings'))
88+
89+
def testOptoutRequestValidPOST(self):
90+
email = u'foo@example.com'
91+
response = self.client.post(self.url, {'email': email})
92+
93+
# check for a confirmation object
94+
self.assertEquals(EmailConfirmation.objects.count(), 1)
95+
conf = EmailConfirmation.objects.get(email = email)
96+
97+
# check confirmation page
98+
self.assertEquals(response.status_code, 200)
99+
self.assertEquals(response.context['confirmation'], conf)
100+
self.assertTrue(email in response.content)
101+
102+
# check email
103+
url = reverse('patchwork.views.confirm', kwargs = {'key': conf.key})
104+
self.assertEquals(len(mail.outbox), 1)
105+
msg = mail.outbox[0]
106+
self.assertEquals(msg.to, [email])
107+
self.assertEquals(msg.subject, 'Patchwork opt-out confirmation')
108+
self.assertTrue(url in msg.body)
109+
110+
def testOptoutRequestInvalidPOSTEmpty(self):
111+
response = self.client.post(self.url, {'email': ''})
112+
self.assertEquals(response.status_code, 200)
113+
self.assertFormError(response, 'form', 'email',
114+
'This field is required.')
115+
self.assertTrue(response.context['error'])
116+
self.assertTrue('email_sent' not in response.context)
117+
self.assertEquals(len(mail.outbox), 0)
118+
119+
def testOptoutRequestInvalidPOSTNonEmail(self):
120+
response = self.client.post(self.url, {'email': 'foo'})
121+
self.assertEquals(response.status_code, 200)
122+
self.assertFormError(response, 'form', 'email',
123+
'Enter a valid e-mail address.')
124+
self.assertTrue(response.context['error'])
125+
self.assertTrue('email_sent' not in response.context)
126+
self.assertEquals(len(mail.outbox), 0)
127+
128+
class OptoutTest(TestCase):
129+
view = 'patchwork.views.mail.optout'
130+
url = reverse(view)
131+
132+
def setUp(self):
133+
self.email = u'foo@example.com'
134+
self.conf = EmailConfirmation(type = 'optout', email = self.email)
135+
self.conf.save()
136+
137+
def testOptoutValidHash(self):
138+
url = reverse('patchwork.views.confirm',
139+
kwargs = {'key': self.conf.key})
140+
response = self.client.get(url)
141+
142+
self.assertEquals(response.status_code, 200)
143+
self.assertTemplateUsed(response, 'patchwork/optout.html')
144+
self.assertTrue(self.email in response.content)
145+
146+
# check that we've got an optout in the list
147+
self.assertEquals(EmailOptout.objects.count(), 1)
148+
self.assertEquals(EmailOptout.objects.all()[0].email, self.email)
149+
150+
# check that the confirmation is now inactive
151+
self.assertFalse(EmailConfirmation.objects.get(
152+
pk = self.conf.pk).active)
153+
154+
155+
class OptoutPreexistingTest(OptoutTest):
156+
"""Test that a duplicated opt-out behaves the same as the initial one"""
157+
def setUp(self):
158+
super(OptoutPreexistingTest, self).setUp()
159+
EmailOptout(email = self.email).save()
160+
161+
class OptinRequestTest(TestCase):
162+
view = 'patchwork.views.mail.optin'
163+
url = reverse(view)
164+
165+
def setUp(self):
166+
self.email = u'foo@example.com'
167+
EmailOptout(email = self.email).save()
168+
169+
def testOptInRequestGET(self):
170+
response = self.client.get(self.url)
171+
self.assertRedirects(response, reverse('patchwork.views.mail.settings'))
172+
173+
def testOptInRequestValidPOST(self):
174+
response = self.client.post(self.url, {'email': self.email})
175+
176+
# check for a confirmation object
177+
self.assertEquals(EmailConfirmation.objects.count(), 1)
178+
conf = EmailConfirmation.objects.get(email = self.email)
179+
180+
# check confirmation page
181+
self.assertEquals(response.status_code, 200)
182+
self.assertEquals(response.context['confirmation'], conf)
183+
self.assertTrue(self.email in response.content)
184+
185+
# check email
186+
url = reverse('patchwork.views.confirm', kwargs = {'key': conf.key})
187+
self.assertEquals(len(mail.outbox), 1)
188+
msg = mail.outbox[0]
189+
self.assertEquals(msg.to, [self.email])
190+
self.assertEquals(msg.subject, 'Patchwork opt-in confirmation')
191+
self.assertTrue(url in msg.body)
192+
193+
def testOptoutRequestInvalidPOSTEmpty(self):
194+
response = self.client.post(self.url, {'email': ''})
195+
self.assertEquals(response.status_code, 200)
196+
self.assertFormError(response, 'form', 'email',
197+
'This field is required.')
198+
self.assertTrue(response.context['error'])
199+
self.assertTrue('email_sent' not in response.context)
200+
self.assertEquals(len(mail.outbox), 0)
201+
202+
def testOptoutRequestInvalidPOSTNonEmail(self):
203+
response = self.client.post(self.url, {'email': 'foo'})
204+
self.assertEquals(response.status_code, 200)
205+
self.assertFormError(response, 'form', 'email',
206+
'Enter a valid e-mail address.')
207+
self.assertTrue(response.context['error'])
208+
self.assertTrue('email_sent' not in response.context)
209+
self.assertEquals(len(mail.outbox), 0)
210+
211+
class OptinTest(TestCase):
212+
213+
def setUp(self):
214+
self.email = u'foo@example.com'
215+
self.optout = EmailOptout(email = self.email)
216+
self.optout.save()
217+
self.conf = EmailConfirmation(type = 'optin', email = self.email)
218+
self.conf.save()
219+
220+
def testOptinValidHash(self):
221+
url = reverse('patchwork.views.confirm',
222+
kwargs = {'key': self.conf.key})
223+
response = self.client.get(url)
224+
225+
self.assertEquals(response.status_code, 200)
226+
self.assertTemplateUsed(response, 'patchwork/optin.html')
227+
self.assertTrue(self.email in response.content)
228+
229+
# check that there's no optout remaining
230+
self.assertEquals(EmailOptout.objects.count(), 0)
231+
232+
# check that the confirmation is now inactive
233+
self.assertFalse(EmailConfirmation.objects.get(
234+
pk = self.conf.pk).active)
235+
236+
class OptinWithoutOptoutTest(TestCase):
237+
"""Test an opt-in with no existing opt-out"""
238+
view = 'patchwork.views.mail.optin'
239+
url = reverse(view)
240+
241+
def testOptInWithoutOptout(self):
242+
email = u'foo@example.com'
243+
response = self.client.post(self.url, {'email': email})
244+
245+
# check for an error message
246+
self.assertEquals(response.status_code, 200)
247+
self.assertTrue(bool(response.context['error']))
248+
self.assertTrue('not on the patchwork opt-out list' in response.content)
249+
250+
class UserProfileOptoutFormTest(TestCase):
251+
"""Test that the correct optin/optout forms appear on the user profile
252+
page, for logged-in users"""
253+
254+
view = 'patchwork.views.user.profile'
255+
url = reverse(view)
256+
optout_url = reverse('patchwork.views.mail.optout')
257+
optin_url = reverse('patchwork.views.mail.optin')
258+
form_re_template = ('<form\s+[^>]*action="%(url)s"[^>]*>'
259+
'.*?<input\s+[^>]*value="%(email)s"[^>]*>.*?'
260+
'</form>')
261+
secondary_email = 'test2@example.com'
262+
263+
def setUp(self):
264+
self.user = create_user()
265+
self.client.login(username = self.user.username,
266+
password = self.user.username)
267+
268+
def _form_re(self, url, email):
269+
return re.compile(self.form_re_template % {'url': url, 'email': email},
270+
re.DOTALL)
271+
272+
def testMainEmailOptoutForm(self):
273+
form_re = self._form_re(self.optout_url, self.user.email)
274+
response = self.client.get(self.url)
275+
self.assertEquals(response.status_code, 200)
276+
self.assertTrue(form_re.search(response.content) is not None)
277+
278+
def testMainEmailOptinForm(self):
279+
EmailOptout(email = self.user.email).save()
280+
form_re = self._form_re(self.optin_url, self.user.email)
281+
response = self.client.get(self.url)
282+
self.assertEquals(response.status_code, 200)
283+
self.assertTrue(form_re.search(response.content) is not None)
284+
285+
def testSecondaryEmailOptoutForm(self):
286+
p = Person(email = self.secondary_email, user = self.user)
287+
p.save()
288+
289+
form_re = self._form_re(self.optout_url, p.email)
290+
response = self.client.get(self.url)
291+
self.assertEquals(response.status_code, 200)
292+
self.assertTrue(form_re.search(response.content) is not None)
293+
294+
def testSecondaryEmailOptinForm(self):
295+
p = Person(email = self.secondary_email, user = self.user)
296+
p.save()
297+
EmailOptout(email = p.email).save()
298+
299+
form_re = self._form_re(self.optin_url, self.user.email)
300+
response = self.client.get(self.url)
301+
self.assertEquals(response.status_code, 200)
302+
self.assertTrue(form_re.search(response.content) is not None)

apps/patchwork/urls.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,11 @@
7373
# submitter autocomplete
7474
(r'^submitter/$', 'patchwork.views.submitter_complete'),
7575

76+
# email setup
77+
(r'^mail/$', 'patchwork.views.mail.settings'),
78+
(r'^mail/optout/$', 'patchwork.views.mail.optout'),
79+
(r'^mail/optin/$', 'patchwork.views.mail.optin'),
80+
7681
# help!
7782
(r'^help/(?P<path>.*)$', 'patchwork.views.help'),
7883
)

apps/patchwork/views/base.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,10 +59,12 @@ def pwclient(request):
5959
return response
6060

6161
def confirm(request, key):
62-
import patchwork.views.user
62+
import patchwork.views.user, patchwork.views.mail
6363
views = {
6464
'userperson': patchwork.views.user.link_confirm,
6565
'registration': patchwork.views.user.register_confirm,
66+
'optout': patchwork.views.mail.optout_confirm,
67+
'optin': patchwork.views.mail.optin_confirm,
6668
}
6769

6870
conf = get_object_or_404(EmailConfirmation, key = key)

0 commit comments

Comments
 (0)