diff --git a/src/postorius/models.py b/src/postorius/models.py index 06a055a..7dcb8cd 100644 --- a/src/postorius/models.py +++ b/src/postorius/models.py @@ -15,15 +15,24 @@ # # You should have received a copy of the GNU General Public License along with # Postorius. If not, see . +from __future__ import ( + absolute_import, division, print_function, unicode_literals) + + +import random +import hashlib import logging +from datetime import datetime, timedelta from django.conf import settings from django.contrib.auth.models import User -from django.db.models.signals import pre_delete, pre_save +from django.core.urlresolvers import reverse +from django.core.mail import send_mail from django.db import models -from django.dispatch import receiver from django.http import Http404 -from mailmanclient import Client, MailmanConnectionError +from django.template import Context +from django.template.loader import get_template +from mailmanclient import MailmanConnectionError from postorius.utils import get_client from urllib2 import HTTPError @@ -86,7 +95,6 @@ def create(self, **kwargs): try: method = getattr(get_client(), 'create_' + self.resource_name) - print kwargs return method(**kwargs) except AttributeError, e: raise MailmanApiError(e) @@ -173,3 +181,110 @@ """Member model class. """ objects = MailmanRestManager('member', 'members') + + +class AddressConfirmationProfileManager(models.Manager): + """ + Manager class for AddressConfirmationProfile. + """ + + def create_profile(self, email, user): + # Create or update a profile + # Guarantee an email bytestr type that can be fed to hashlib. + email_str = email + if isinstance(email_str, unicode): + email_str = email_str.encode('utf-8') + activation_key = hashlib.sha1( + str(random.random())+email_str).hexdigest() + # Either update an existing profile record for the given email address + try: + profile = self.get(email=email) + profile.activation_key = activation_key + profile.created = datetime.now() + profile.save() + # ... or create a new one. + except AddressConfirmationProfile.DoesNotExist: + profile = self.create( + email=email, activation_key=activation_key, user=user) + return profile + + +class AddressConfirmationProfile(models.Model): + """ + Profile model for temporarily storing an activation key to register + an email address. + """ + email = models.EmailField() + activation_key = models.CharField(max_length=40) + created = models.DateTimeField(auto_now_add=True) + user = models.ForeignKey(User) + + objects = AddressConfirmationProfileManager() + + def __unicode__(self): + return u'Address Confirmation Profile for {0}'.format(self.email) + + @property + def is_expired(self): + """ + a profile expires after 1 day by default. + This can be configured in the settings. + + >>> EMAIL_CONFIRMATION_EXPIRATION_DELTA = timedelta(days=2) + + """ + expiration_delta = getattr( + settings, 'EMAIL_CONFIRMATION_EXPIRATION_DELTA', timedelta(days=1)) + age = datetime.now() - self.created + return age > expiration_delta + + def _create_host_url(self, request): + # Create the host url + protocol = 'https' + if not request.is_secure(): + protocol = 'http' + server_name = request.META['SERVER_NAME'] + if server_name[-1] == '/': + server_name = server_name[:len(server_name) - 1] + return '{0}://{1}'.format(protocol, server_name) + + def send_confirmation_link(self, request, template_context=None, + template_path=None): + """ + Send out a message containing a link to activate the given address. + + The following settings are recognized: + + >>> EMAIL_CONFIRMATION_TEMPLATE = 'postorius/address_confirmation_message.txt' + >>> EMAIL_CONFIRMATION_FROM = 'Confirmation needed' + >>> EMAIL_CONFIRMATION_SUBJECT = 'postmaster@list.org' + + :param request: The HTTP request object. + :type request: HTTPRequest + :param template_context: The context used when rendering the template. + Falls back to host url and activation link. + :type template_context: django.template.Context + """ + # create the host url and the activation link need for the template + host_url = self._create_host_url(request) + # Get the url string from url conf. + url = reverse('address_activation_link', + kwargs={'activation_key': self.activation_key}) + activation_link = '{0}{1}'.format(host_url, url) + # Detect the right template path, either from the param, + # the setting or the default + if not template_path: + template_path = getattr(settings, + 'EMAIL_CONFIRMATION_TEMPLATE', + 'postorius/address_confirmation_message.txt') + # Create a template context (if there is none) containing + # the activation_link and the host_url. + if not template_context: + template_context = Context( + {'activation_link': activation_link, 'host_url': host_url}) + email_subject = getattr( + settings, 'EMAIL_CONFIRMATION_SUBJECT', u'Confirmation needed') + send_mail(email_subject, + get_template(template_path).render(template_context), + getattr(settings, 'EMAIL_CONFIRMATION_FROM'), + [self.email]) diff --git a/src/postorius/templates/postorius/address_confirmation_message.txt b/src/postorius/templates/postorius/address_confirmation_message.txt new file mode 100644 index 0000000..8166a13 --- /dev/null +++ b/src/postorius/templates/postorius/address_confirmation_message.txt @@ -0,0 +1,6 @@ +Please click the link below to add your email address to your mailing list +profile at {{ host_url }}: + +{{ activation_link }} + +Thanks! diff --git a/src/postorius/templates/postorius/address_notification_message.txt b/src/postorius/templates/postorius/address_notification_message.txt new file mode 100644 index 0000000..73bae33 --- /dev/null +++ b/src/postorius/templates/postorius/address_notification_message.txt @@ -0,0 +1,7 @@ +Someone has requested to add {{email}} to their mailing list profile at {{ host_url }}. + +If you are that person, please click the link below to confirm. + +{{ activation_link }} + +Thanks! diff --git a/src/postorius/templates/postorius/menu/user_nav.html b/src/postorius/templates/postorius/menu/user_nav.html index cccc25a..3c552ca 100644 --- a/src/postorius/templates/postorius/menu/user_nav.html +++ b/src/postorius/templates/postorius/menu/user_nav.html @@ -8,5 +8,6 @@
  • {% trans "Profile" %}
  • {% trans "Subscription Settings" %}
  • {% trans "Subscriptions" %}
  • +
  • {% trans "Add Email Address" %}
  • diff --git a/src/postorius/templates/postorius/user_address_activation.html b/src/postorius/templates/postorius/user_address_activation.html index 53e8c0d..aee976c 100644 --- a/src/postorius/templates/postorius/user_address_activation.html +++ b/src/postorius/templates/postorius/user_address_activation.html @@ -1,10 +1,12 @@ {% extends postorius_base_template %} {% load url from future %} {% load i18n %} + {% block main %} -{% include 'postorius/menu/user_nav.html' %} + {% include 'postorius/menu/user_nav.html' %}

    {% trans 'Add another email address to your account' %}

    +
    {% csrf_token %} {{ form.as_p }} diff --git a/src/postorius/templates/postorius/user_address_activation_link.html b/src/postorius/templates/postorius/user_address_activation_link.html new file mode 100644 index 0000000..b493058 --- /dev/null +++ b/src/postorius/templates/postorius/user_address_activation_link.html @@ -0,0 +1,6 @@ +{% extends postorius_base_template %} +{% load url from future %} +{% load i18n %} + +{% block main %} +{% endblock main %} diff --git a/src/postorius/tests/test_address_activation.py b/src/postorius/tests/test_address_activation.py index 506c292..7c53a34 100644 --- a/src/postorius/tests/test_address_activation.py +++ b/src/postorius/tests/test_address_activation.py @@ -2,19 +2,26 @@ unicode_literals) +from datetime import datetime, timedelta from django.contrib.auth.models import User -from django.conf import settings from django.core.urlresolvers import reverse +from django.core import mail from django.test.client import Client, RequestFactory from django.test.utils import override_settings from django.utils import unittest +from mailmanclient._client import _Connection from mock import patch from postorius.forms import AddressActivationForm +from postorius.models import AddressConfirmationProfile +from postorius import views from postorius.views.user import AddressActivationView class TestAddressActivationForm(unittest.TestCase): + """ + Test the activation form. + """ def test_valid_email_is_valid(self): data = { @@ -80,81 +87,132 @@ def test_post_invalid_form_shows_error_msg(self): # Entering an invalid email address should render an error message. - response = self.client.post(reverse('address_activation'), - { - 'email': 'invalid_email', - 'user_email': self.user.email, - }) - self.assertTrue('Enter a valid email address.' in - response.content) + response = self.client.post(reverse('address_activation'), { + 'email': 'invalid_email', + 'user_email': self.user.email}) + self.assertTrue('Enter a valid email address.' in response.content) - @patch.object(AddressActivationView, '_handle_address') - def test_post_valid_form_renders_success_template(self, handle_address_mock): + @patch.object(AddressConfirmationProfile, 'send_confirmation_link') + def test_post_valid_form_renders_success_template( + self, mock_send_confirmation_link): # Entering a valid email should render the activation_sent template. - response = self.client.post(reverse('address_activation'), - { - 'email': 'new_address@example.org', - 'user_email': self.user.email, - }) + response = self.client.post(reverse('address_activation'), { + 'email': 'new_address@example.org', + 'user_email': self.user.email}) + self.assertEqual(mock_send_confirmation_link.call_count, 1) self.assertTrue('postorius/user_address_activation_sent.html' in [t.name for t in response.templates]) - @patch.object(AddressActivationView, '_handle_address') - def test_post_valid_form_calls_handle_address_method(self, - handle_address_mock): - # Entering a valid email should call _handle_address with the request's - # user instance as well as the email address to activate. - response = self.client.post(reverse('address_activation'), - { - 'email': 'new_address@example.org', - 'user_email': self.user.email, - }) - self.assertEqual(handle_address_mock.call_count, 1) - args, kwargs = handle_address_mock.call_args - self.assertTrue(isinstance(args[0], User)) - self.assertEqual(args[1], 'new_address@example.org') - -class TestAddressActivationStart(unittest.TestCase): +class TestAddressConfirmationProfile(unittest.TestCase): """ - Tests the initiation of the address activation (sending the appropriate - emails, generating the token etc.). + Test the confirmation of an email address activation (validating token, + expiration, Mailman API calls etc.). """ def setUp(self): - self.foo_user = User.objects.create_user( - 'foo', email='foo@example.org', password='pass') - self.bar_user = User.objects.create_user( - 'bar', email='bar@example.org', password='pass') + # Create a user and profile. + self.user = User.objects.create_user( + username=u'ler_mm', email=u'ler@mailman.mostdesirable.org', + password=u'pwd') + self.profile = AddressConfirmationProfile.objects.create_profile( + u'les@example.org', self.user) + # Create a test request object + self.request = RequestFactory().get('/') def tearDown(self): - self.foo_user.delete() - self.bar_user.delete() + self.profile.delete() + self.user.delete() - @patch.object(AddressActivationView, '_start_confirmation') - @patch.object(AddressActivationView, '_notify_existing_user') - def test_existing_user_detected( - self, notify_existing_user_mock, start_confirmation_mock): - # Using the email address of an existing user should hit the - # _notify_existing_user method. - AddressActivationView._handle_address(self.foo_user, 'bar@example.org') - self.assertEqual(notify_existing_user_mock.call_count, 1) - self.assertEqual(start_confirmation_mock.call_count, 0) + def test_profile_creation(self): + # Profile is created and has all necessary properties. + self.assertEqual(self.profile.email, u'les@example.org') + self.assertEqual(len(self.profile.activation_key), 40) + self.assertTrue(type(self.profile.created), datetime) - @patch.object(AddressActivationView, '_start_confirmation') - @patch.object(AddressActivationView, '_notify_existing_user') - def test_confirmation_start( - self, notify_existing_user_mock, start_confirmation_mock): - # Using the email address of an existing user should hit the - # _notify_existing_user method. - AddressActivationView._handle_address(self.foo_user, 'new@address.org') - self.assertEqual(notify_existing_user_mock.call_count, 0) - self.assertEqual(start_confirmation_mock.call_count, 1) + def test_no_duplicate_profiles(self): + # Creating a new profile returns an existing record + # (if one exists), instead of creating a new one. + new_profile = AddressConfirmationProfile.objects.create_profile( + u'les@example.org', + User.objects.create(email=u'ler@mailman.mostdesirable.org')) + self.assertEqual(self.profile, new_profile) + def test_unicode_representation(self): + # Correct unicode representation? + self.assertEqual(unicode(self.profile), + 'Address Confirmation Profile for les@example.org') -class TestAddressActivationConfirmation(unittest.TestCase): + def test_profile_not_expired_default_setting(self): + # A profile created less then a day ago is not expired by default. + delta = timedelta(hours=23) + now = datetime.now() + self.profile.created = now - delta + self.assertFalse(self.profile.is_expired) + + def test_profile_is_expired_default_setting(self): + # A profile older than 1 day is expired by default. + delta = timedelta(days=1, hours=1) + now = datetime.now() + self.profile.created = now - delta + self.assertTrue(self.profile.is_expired) + + @override_settings( + EMAIL_CONFIRMATION_EXPIRATION_DELTA=timedelta(hours=5)) + def test_profile_not_expired(self): + # A profile older than the timedelta set in the settings is + # expired. + delta = timedelta(hours=6) + now = datetime.now() + self.profile.created = now - delta + self.assertTrue(self.profile.is_expired) + + @override_settings( + EMAIL_BACKEND='django.core.mail.backends.locmem.EmailBackend', + EMAIL_CONFIRMATION_FROM='mailman@mostdesirable.org') + def test_confirmation_link(self): + # The profile obj can send out a confirmation email. + # set the activation key to a fixed string for testing + self.profile.activation_key = '6323fba0097781fdb887cfc37a1122ee7c8bb0b0' + self.profile.send_confirmation_link(self.request) + self.assertEqual(mail.outbox[0].to[0], u'les@example.org') + self.assertEqual(mail.outbox[0].subject, u'Confirmation needed') + self.assertEqual(mail.outbox[0].body, """\ +Please click the link below to add your email address to your mailing list +profile at http://testserver: + +http://testserver/postorius/users/address_activation/\ +6323fba0097781fdb887cfc37a1122ee7c8bb0b0/ + +Thanks! +""") + +class TestAddressActivationLinkSuccess(unittest.TestCase): """ - Tests the confirmation of an email address activation (validating token, - expiration, Mailman API calls etc.). + This tests the activation link view if the key is valid and the profile is + not expired. """ - pass + + def setUp(self): + # Set up a profile with a predictable key + self.user = User.objects.create_user( + username='ler', email=u'ler@mailman.mostdesirable.org', + password='pwd') + self.profile = AddressConfirmationProfile.objects.create_profile( + 'les@mailman.mostdesirable.org', self.user) + self.profile.activation_key = '6323fba0097781fdb887cfc37a1122ee7c8bb0b0' + self.profile.save() + + def tearDown(self): + self.profile.delete() + self.user.delete() + + @patch.object(views.user, '_add_address') + def test_mailman(self, mock_call): + # An activation key pointing to a valid profile adds the address to the user + self.response = Client().get( + reverse('address_activation_link', kwargs={ + 'activation_key': '6323fba0097781fdb887cfc37a1122ee7c8bb0b0'})) + self.assertEqual(mock_call.call_count, 1) + + diff --git a/src/postorius/urls.py b/src/postorius/urls.py index 0f2bdb0..df2be43 100644 --- a/src/postorius/urls.py +++ b/src/postorius/urls.py @@ -111,4 +111,6 @@ url(r'^users/address_activation/$', AddressActivationView.as_view(), name='address_activation'), + url(r'^users/address_activation/(?P[A-Za-z0-9]{40})/$', + 'address_activation_link', name='address_activation_link'), ) diff --git a/src/postorius/views/user.py b/src/postorius/views/user.py index 6ce803d..1701b59 100644 --- a/src/postorius/views/user.py +++ b/src/postorius/views/user.py @@ -17,37 +17,31 @@ # Postorius. If not, see . -import re -import sys -import json import logging -from django.conf import settings from django.forms.formsets import formset_factory from django.contrib import messages from django.contrib.auth import logout, authenticate, login from django.contrib.auth.decorators import (login_required, - permission_required, user_passes_test) -from django.contrib.auth.forms import (AuthenticationForm, PasswordResetForm, - SetPasswordForm, PasswordChangeForm) +from django.contrib.auth.forms import AuthenticationForm from django.contrib.auth.models import User -from django.core.urlresolvers import reverse -from django.http import HttpResponse, HttpResponseRedirect from django.shortcuts import render_to_response, redirect -from django.template import Context, loader, RequestContext +from django.template import RequestContext from django.utils.decorators import method_decorator from django.utils.translation import gettext as _ from django.views.generic import TemplateView from urllib2 import HTTPError from postorius import utils -from postorius.models import (Domain, List, Member, MailmanUser, - MailmanApiError, Mailman404Error) +from postorius.models import ( + MailmanUser, MailmanConnectionError, MailmanApiError, Mailman404Error, + AddressConfirmationProfile) from postorius.forms import * from postorius.auth.decorators import * -from postorius.views.generic import MailingListView, MailmanUserView +from postorius.views.generic import MailmanUserView +from smtplib import SMTPException class UserMailmanSettingsView(MailmanUserView): @@ -238,26 +232,6 @@ record. Forms are processes and email notifications are sent accordingly. """ - @staticmethod - def _notify_existing_user(address): - pass - - @staticmethod - def _start_confirmation(address): - pass - - @staticmethod - def _handle_address(user, address): - try: - # A user exists for that email address, so this user should be - # that some tried to add this email address to their MM user record. - User.objects.get(email=address) - AddressActivationView._notify_existing_user(address) - except User.DoesNotExist: - # There's currently no user record for this email address. - # Start the confirmation process. - AddressActivationView._start_confirmation(address) - @method_decorator(login_required) def get(self, request): form = AddressActivationForm(initial={'user_email': request.user.email}) @@ -267,11 +241,15 @@ @method_decorator(login_required) def post(self, request): - print(request.POST) form = AddressActivationForm(request.POST) if form.is_valid(): - print('IS_VALID') - self._handle_address(request.user, form.cleaned_data['email']) + profile = AddressConfirmationProfile.objects.create_profile( + email=form.cleaned_data['email'], user=request.user) + try: + profile.send_confirmation_link(request) + except SMTPException: + messages.error(request, 'The email confirmation message could ' + 'not be sent.') return render_to_response('postorius/user_address_activation_sent.html', context_instance=RequestContext(request)) return render_to_response('postorius/user_address_activation.html', @@ -413,3 +391,28 @@ return render_to_response(template, {'user_id': user_id, 'email_id': email_id}, context_instance=RequestContext(request)) + + +def _add_address(user_email, address): + try: + mailman_user = utils.get_client().get_user(user_email) + mailman_user.add_address(address) + except (MailmanApiError, MailmanConnectionError): + pass + + +def address_activation_link(request, activation_key): + """ + Checks the given activation_key. If it is valid, the saved address will be + added to mailman. Also, the corresponding profile record will be removed. + If the key is not valid, it will be ignored. + """ + try: + profile = AddressConfirmationProfile.objects.get( + activation_key=activation_key) + if not profile.is_expired: + _add_address(profile.user.email, profile.email) + except profile.DoesNotExist: + pass + return render_to_response('postorius/user_address_activation_link.html', + {}, context_instance=RequestContext(request))