changeset 51:13b2561c909d

For issue #7, create a mailing list application. Still need to test that emails are being sent.
author Brian Neal <bgneal@gmail.com>
date Wed, 28 Mar 2012 21:13:05 -0500
parents 5913ddcebea4
children 7f9e76e7eb4d
files madeira/band/urls.py madeira/email_list/__init__.py madeira/email_list/admin.py madeira/email_list/forms.py madeira/email_list/management/__init__.py madeira/email_list/management/commands/__init__.py madeira/email_list/management/commands/import_old_email_list.py madeira/email_list/models.py madeira/email_list/tests/__init__.py madeira/email_list/tests/model_tests.py madeira/email_list/tests/view_tests.py madeira/email_list/urls.py madeira/email_list/views.py madeira/settings/base.py madeira/settings/test.py madeira/templates/email_list/email_subscribe.txt madeira/templates/email_list/email_unsubscribe.txt madeira/templates/email_list/subscribe_form.html madeira/templates/email_list/subscribe_request.html madeira/templates/email_list/subscribed.html madeira/templates/email_list/unsubscribe_request.html madeira/templates/email_list/unsubscribed.html madeira/urls.py
diffstat 20 files changed, 711 insertions(+), 5 deletions(-) [+]
line wrap: on
line diff
--- a/madeira/band/urls.py	Sat Mar 24 16:18:48 2012 -0500
+++ b/madeira/band/urls.py	Wed Mar 28 21:13:05 2012 -0500
@@ -7,11 +7,11 @@
    (r'^contact/$', 'contact'),
    (r'^gigs_old/$', 'gigs'),
    (r'^gigs_old/flyers$', 'flyers'),
-   url(r'^mail/$', 'mail', name='band-mail'),
-   url(r'^mail/confirm/([a-zA-Z0-9]+)$', 'mail_confirm', name='band-mail_confirm'),
-   (r'^mail/not_found$', 'mail_not_found'),
-   (r'^mail/thanks$', 'mail_thanks'),
-   (r'^mail/unsubscribe$', 'mail_unsubscribe'),
+   url(r'^mail_old/$', 'mail', name='band-mail'),
+   url(r'^mail_old/confirm/([a-zA-Z0-9]+)$', 'mail_confirm', name='band-mail_confirm'),
+   (r'^mail_old/not_found$', 'mail_not_found'),
+   (r'^mail_old/thanks$', 'mail_thanks'),
+   (r'^mail_old/unsubscribe$', 'mail_unsubscribe'),
    (r'^news_old/$', 'news'),
    (r'^photos/$', 'photos_index'),
    (r'^photos/(\d+)$', 'photo_detail'),
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/madeira/email_list/admin.py	Wed Mar 28 21:13:05 2012 -0500
@@ -0,0 +1,16 @@
+"""
+Automatic admin definitions for the email_list application.
+
+"""
+from django.contrib import admin
+
+from email_list.models import Subscriber
+
+
+class SubscriberAdmin(admin.ModelAdmin):
+   list_display = ['__unicode__', 'location', 'status']
+   list_filter = ['status']
+   search_fields = ['name', 'email']
+
+
+admin.site.register(Subscriber, SubscriberAdmin)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/madeira/email_list/forms.py	Wed Mar 28 21:13:05 2012 -0500
@@ -0,0 +1,105 @@
+"""
+Forms for the email_list application.
+
+"""
+from django import forms
+from django.conf import settings
+from django.core.urlresolvers import reverse
+from django.core.mail import send_mail
+from django.template.loader import render_to_string
+
+from email_list.models import Subscriber
+
+
+SUBSCRIBE_OPTS = [('sub', 'Subscribe'), ('unsub', 'Unsubscribe')]
+
+ALREADY_SUBSCRIBED = "This email address is already subscribed."
+NOT_SUBSCRIBED = "This email address is not on our list."
+
+
+class SubscriberForm(forms.Form):
+    name = forms.CharField(max_length=64, required=False)
+    email = forms.EmailField()
+    location = forms.CharField(max_length=64, required=False)
+    option = forms.ChoiceField(choices=SUBSCRIBE_OPTS)
+
+    def clean(self):
+        """
+        This method ensures the appropriate action can be carried out and raises
+        a validation error if not.
+
+        """
+        email = self.cleaned_data['email']
+
+        if self.cleaned_data['option'] == 'sub':
+            # is the user already subscribed (active)?
+            try:
+                subscriber = Subscriber.objects.get(email=email)
+            except Subscriber.DoesNotExist:
+                subscriber = Subscriber(email=email,
+                        name=self.cleaned_data['name'],
+                        location=self.cleaned_data['location'])
+            else:
+                if subscriber.is_active():
+                    raise forms.ValidationError(ALREADY_SUBSCRIBED)
+        else:
+            # is the user already unsubscribed or not subscribed?
+            try:
+                subscriber = Subscriber.objects.get(email=email)
+            except Subscriber.DoesNotExist:
+                raise forms.ValidationError(NOT_SUBSCRIBED)
+
+        # save the subscriber away for a future process() call
+        self.subscriber = subscriber
+
+        return self.cleaned_data
+
+    def is_subscribe(self):
+        """
+        This function can be called after an is_valid() call to determine if the
+        request was for a subscribe or unsubscribe.
+
+        """
+        return self.cleaned_data['option'] == 'sub'
+
+    def process(self):
+        """
+        Call this function if is_valid() returns True. It carries out the user's
+        subscription request. 
+
+        """
+        if self.is_subscribe():
+            self.subscriber.set_pending()
+        else:
+            self.subscriber.set_leaving()
+
+        self.subscriber.save()
+        send_email(self.subscriber)
+
+
+def send_email(subscriber):
+    """
+    This function sends out the appropriate email for the given subscriber.
+
+    """
+    config = settings.BAND_CONFIG
+    band = config['BAND_NAME']
+    from_email = config['BAND_EMAIL']
+
+    url = "http://%s%s" % (config['BAND_DOMAIN'],
+            reverse('email_list-confirm', kwargs={'key': subscriber.key}))
+
+    if subscriber.is_pending():
+        email_template = 'email_list/email_subscribe.txt'
+    else:
+        email_template = 'email_list/email_unsubscribe.txt'
+
+    msg = render_to_string(email_template, {
+        'band': band, 
+        'url': url, 
+        'band_domain': config['BAND_DOMAIN'],
+        })
+
+    subject = "[%s] Mailing List Confirmation" % band
+
+    send_mail(subject, msg, from_email, [subscriber.email])
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/madeira/email_list/management/commands/import_old_email_list.py	Wed Mar 28 21:13:05 2012 -0500
@@ -0,0 +1,51 @@
+"""
+import_old_email_list.py - For importing the email list from the older version
+of this website.
+
+"""
+import datetime
+
+from django.core.management.base import LabelCommand
+from django.utils import simplejson as json
+
+from email_list.models import Subscriber
+
+
+class Command(LabelCommand):
+    args = '<filename filename ...>'
+    help = 'Imports older email list data in JSON format'
+
+    def handle_label(self, filename, **options):
+        """
+        Process the file of older email list data in JSON. Convert to the new
+        model scheme.
+
+        """
+        with open(filename, 'rb') as f:
+            items = json.load(f)
+
+        for item in items:
+            if item['model'] == 'band.fan':
+                self.process_item(item)
+
+    def process_item(self, item):
+
+        fields = item['fields']
+
+        # Only process 'active' subscribers because the new application
+        # has changed the key generation scheme.
+
+        if fields['status'] != 'A':
+            return
+
+        subscriber = Subscriber(
+                id=item['pk'],
+                name=fields['name'].strip(),
+                email=fields['email'].strip(),
+                location=fields['location'].strip(),
+                status='A',
+                key='',
+                status_date=datetime.datetime.strptime(
+                    fields['status_date'], '%Y-%m-%d'))
+
+        subscriber.save()                    
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/madeira/email_list/models.py	Wed Mar 28 21:13:05 2012 -0500
@@ -0,0 +1,69 @@
+"""
+Models for the email_list application.
+
+"""
+import base64
+import datetime
+import hashlib
+
+from django.db import models
+from django.conf import settings
+
+
+class Subscriber(models.Model):
+    status_codes = [('P', 'Pending'), ('A', 'Active'), ('L', 'Leaving')]
+    key_length = 28
+
+    name = models.CharField(max_length=64, blank=True)
+    email = models.EmailField(db_index=True)
+    location = models.CharField(max_length=64, blank=True)
+    status = models.CharField(max_length=1, choices=status_codes, default='A')
+    key = models.CharField(max_length=key_length, editable=False, blank=True,
+            db_index=True)
+    status_date = models.DateTimeField(editable=False)
+
+    class Meta:
+        ordering = ['name', 'email']
+
+    def __unicode__(self):
+        if self.name:
+            return u'%s <%s>' % (self.name, self.email)
+        return self.email
+
+    def save(self, *args, **kwargs):
+        if not self.pk and not self.status_date:
+            self.status_date = datetime.datetime.now()
+
+        super(Subscriber, self).save(*args, **kwargs)
+
+    def set_pending(self):
+        self.status = 'P'
+        self.status_date = datetime.datetime.now()
+        self.gen_key()
+
+    def set_active(self):
+        self.status = 'A'
+        self.status_date = datetime.datetime.now()
+        self.key = ''
+
+    def set_leaving(self):
+        self.status = 'L'
+        self.status_date = datetime.datetime.now()
+        self.gen_key()
+
+    def is_pending(self):
+        return self.status == 'P'
+
+    def is_leaving(self):
+        return self.status == 'L'
+
+    def is_active(self):
+        return self.status == 'A'
+
+    def gen_key(self):
+        source = (settings.SECRET_KEY + self.email + self.name + self.location +
+                self.status + self.status_date.isoformat())
+
+        sha = hashlib.sha1()
+        sha.update(source)
+        self.key = base64.urlsafe_b64encode(sha.digest())
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/madeira/email_list/tests/__init__.py	Wed Mar 28 21:13:05 2012 -0500
@@ -0,0 +1,2 @@
+from model_tests import *
+from view_tests import *
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/madeira/email_list/tests/model_tests.py	Wed Mar 28 21:13:05 2012 -0500
@@ -0,0 +1,76 @@
+"""
+Model tests for the email_list application.
+
+"""
+import datetime
+
+from django.test import TestCase
+
+from email_list.models import Subscriber
+
+
+class SubscriberTestCase(TestCase):
+
+    def test_auto_save(self):
+
+        sub = Subscriber(name='', location='', email='test@example.com')
+        sub.save()
+
+        now = datetime.datetime.now()
+        self.assertTrue(now - sub.status_date < datetime.timedelta(seconds=2))
+
+        self.assertTrue(sub.status == 'A')
+        self.assertTrue(sub.is_active())
+        self.failIf(sub.is_pending())
+        self.failIf(sub.is_leaving())
+
+    def test_set_pending(self):
+
+        sub = Subscriber(name='', location='', email='test@example.com')
+        sub.set_pending()
+
+        now = datetime.datetime.now()
+        self.assertTrue(now - sub.status_date < datetime.timedelta(seconds=2))
+
+        self.assertTrue(sub.status == 'P')
+        self.failIf(sub.is_active())
+        self.assertTrue(sub.is_pending())
+        self.failIf(sub.is_leaving())
+
+        self.assertTrue(len(sub.key) == sub.key_length)
+
+    def test_set_active(self):
+
+        sub = Subscriber(name='', location='', email='test@example.com')
+        sub.set_active()
+
+        now = datetime.datetime.now()
+        self.assertTrue(now - sub.status_date < datetime.timedelta(seconds=2))
+
+        self.assertTrue(sub.status == 'A')
+        self.assertTrue(sub.is_active())
+        self.failIf(sub.is_pending())
+        self.failIf(sub.is_leaving())
+
+    def test_set_leaving(self):
+
+        sub = Subscriber(name='', location='', email='test@example.com')
+        sub.set_leaving()
+
+        now = datetime.datetime.now()
+        self.assertTrue(now - sub.status_date < datetime.timedelta(seconds=2))
+
+        self.assertTrue(sub.status == 'L')
+        self.failIf(sub.is_active())
+        self.failIf(sub.is_pending())
+        self.assertTrue(sub.is_leaving())
+
+        self.assertTrue(len(sub.key) == sub.key_length)
+
+    def test_gen_key(self):
+
+        sub = Subscriber(name='', location='', email='test@example.com')
+        sub.status_date = datetime.datetime.now()
+        sub.gen_key()
+        self.assertTrue(len(sub.key) == sub.key_length)
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/madeira/email_list/tests/view_tests.py	Wed Mar 28 21:13:05 2012 -0500
@@ -0,0 +1,199 @@
+"""
+View tests for the email_list application.
+
+"""
+from django.test import TestCase
+from django.core.urlresolvers import reverse
+
+from email_list.models import Subscriber
+import email_list.forms
+
+
+SUB_PARAMS = {
+    'name': 'John Doe',
+    'email': 'j.doe@example.com',
+    'location': 'USA',
+    'option': 'sub'
+}
+
+UNSUB_PARAMS = {
+    'name': '',
+    'email': 'j.doe@example.com',
+    'location': '',
+    'option': 'unsub'
+}
+
+class EmailListTestCase(TestCase):
+
+    def test_already_subscribed(self):
+        """
+        Test that subscribing twice fails with a form error.
+
+        """
+        sub = Subscriber(email=SUB_PARAMS['email'])
+        sub.set_active()
+        sub.save()
+
+        # Post a subscribe request
+
+        response = self.client.post(
+                reverse('email_list-main'),
+                SUB_PARAMS,
+                follow=True)
+
+        self.assertTrue(response.status_code, 200)
+        self.assertEqual(len(response.redirect_chain), 0)
+        self.assertContains(response, email_list.forms.ALREADY_SUBSCRIBED)
+
+    def test_not_subscribed(self):
+        """
+        Test that unsubscribing without being subscribed fails with a form error.
+
+        """
+        # Post a unsubscribe request
+
+        response = self.client.post(
+                reverse('email_list-main'),
+                UNSUB_PARAMS,
+                follow=True)
+
+        self.assertTrue(response.status_code, 200)
+        self.assertEqual(len(response.redirect_chain), 0)
+        self.assertContains(response, email_list.forms.NOT_SUBSCRIBED)
+
+    def test_normal_cycle(self):
+        """
+        Test a normal subscribe and unsubscribe cycle.
+
+        """
+        self.do_test_subscribe()
+        self.do_test_unsubscribe()
+
+    def test_subscribe_if_pending(self):
+        """
+        Ensure you can subscribe if you are already pending.
+
+        """
+        sub = Subscriber(email=SUB_PARAMS['email'])
+        sub.set_pending()
+        sub.save()
+        self.do_test_subscribe()
+
+    def test_subscribe_if_leaving(self):
+        """
+        Ensure you can subscribe if you are leaving.
+
+        """
+        sub = Subscriber(email=SUB_PARAMS['email'])
+        sub.set_leaving()
+        sub.save()
+        self.do_test_subscribe()
+
+    def test_unsubscribe_if_leaving(self):
+        """
+        Ensure you can unsubscribe if you are already leaving.
+
+        """
+        sub = Subscriber(email=SUB_PARAMS['email'])
+        sub.set_leaving()
+        sub.save()
+        self.do_test_unsubscribe()
+
+    def do_test_subscribe(self):
+        # Get the form view
+        response = self.client.get(reverse('email_list-main'))
+        self.assertEqual(response.status_code, 200)
+        self.assertTemplateUsed(response, 'email_list/subscribe_form.html')
+
+        # Post a subscribe request
+
+        response = self.client.post(
+                reverse('email_list-main'),
+                SUB_PARAMS,
+                follow=True)
+
+        self.assertTrue(response.status_code, 200)
+        self.assertEqual(len(response.redirect_chain), 1)
+        self.assertEqual(response.redirect_chain[0][0],
+                'http://testserver' + reverse('email_list-request_subscribe'))
+        self.assertEqual(response.redirect_chain[0][1], 302)
+
+        # verify subscriber is in pending state
+
+        try:
+            subscriber = Subscriber.objects.get(email=SUB_PARAMS['email'])
+        except Subscriber.DoesNotExist:
+            self.fail("No pending subscriber")
+
+        self.assertTrue(subscriber.is_pending())
+
+        # TODO: test email sent
+
+        # post to confirm
+
+        response = self.client.post(
+                reverse('email_list-confirm', kwargs={'key': subscriber.key}),
+                {},
+                follow=True)
+
+        self.assertTrue(response.status_code, 200)
+        self.assertEqual(len(response.redirect_chain), 1)
+        self.assertEqual(response.redirect_chain[0][0],
+                'http://testserver' + reverse('email_list-subscribed'))
+        self.assertEqual(response.redirect_chain[0][1], 302)
+
+        # verify active user
+        try:
+            subscriber = Subscriber.objects.get(email=SUB_PARAMS['email'])
+        except Subscriber.DoesNotExist:
+            self.fail("No active subscriber")
+
+        self.assertTrue(subscriber.is_active())
+
+    def do_test_unsubscribe(self):
+        # Get the form view
+        response = self.client.get(reverse('email_list-main'))
+        self.assertEqual(response.status_code, 200)
+        self.assertTemplateUsed(response, 'email_list/subscribe_form.html')
+
+        # Post a unsubscribe request
+
+        response = self.client.post(
+                reverse('email_list-main'),
+                UNSUB_PARAMS,
+                follow=True)
+
+        self.assertTrue(response.status_code, 200)
+        self.assertEqual(len(response.redirect_chain), 1)
+        self.assertEqual(response.redirect_chain[0][0],
+                'http://testserver' + reverse('email_list-request_unsubscribe'))
+        self.assertEqual(response.redirect_chain[0][1], 302)
+
+        # verify subscriber is in leaving state
+
+        try:
+            subscriber = Subscriber.objects.get(email=UNSUB_PARAMS['email'])
+        except Subscriber.DoesNotExist:
+            self.fail("No pending subscriber")
+
+        self.assertTrue(subscriber.is_leaving())
+
+        # TODO: test email sent
+
+        # post to confirm unsubscribe
+
+        response = self.client.post(
+                reverse('email_list-confirm', kwargs={'key': subscriber.key}),
+                {},
+                follow=True)
+
+        self.assertTrue(response.status_code, 200)
+        self.assertEqual(len(response.redirect_chain), 1)
+        self.assertEqual(response.redirect_chain[0][0],
+                'http://testserver' + reverse('email_list-unsubscribed'))
+        self.assertEqual(response.redirect_chain[0][1], 302)
+
+        # verify subscription has been removed
+
+        self.assertRaises(Subscriber.DoesNotExist, Subscriber.objects.get,
+                email=UNSUB_PARAMS['email'])
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/madeira/email_list/urls.py	Wed Mar 28 21:13:05 2012 -0500
@@ -0,0 +1,28 @@
+"""
+Urls for the email_list application.
+
+"""
+from django.conf.urls.defaults import patterns, url
+from django.views.generic import TemplateView
+
+
+urlpatterns = patterns('email_list.views',
+   url(r'^$',
+       'mailing_list',
+       name='email_list-main'),
+   url(r'^confirm/(?P<key>[-a-zA-Z0-9_=]{28})/$',
+       'confirm',
+       name='email_list-confirm'),
+   url(r'^request/subscribe/$',
+       TemplateView.as_view(template_name='email_list/subscribe_request.html'),
+       name='email_list-request_subscribe'),
+   url(r'^request/unsubscribe/$',
+       TemplateView.as_view(template_name='email_list/unsubscribe_request.html'),
+       name='email_list-request_unsubscribe'),
+   url(r'^subscribed/$',
+       TemplateView.as_view(template_name='email_list/subscribed.html'),
+       name='email_list-subscribed'),
+   url(r'^unsubscribed/$',
+       TemplateView.as_view(template_name='email_list/unsubscribed.html'),
+       name='email_list-unsubscribed'),
+)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/madeira/email_list/views.py	Wed Mar 28 21:13:05 2012 -0500
@@ -0,0 +1,58 @@
+"""
+Views for the email_list application.
+
+"""
+import logging
+
+from django.http import HttpResponseServerError
+from django.shortcuts import render, redirect, get_object_or_404
+from django.views.decorators.http import require_POST
+
+from email_list.forms import SubscriberForm
+from email_list.models import Subscriber
+
+
+logger = logging.getLogger(__name__)
+
+
+def mailing_list(request):
+    """
+    The main view for handling email list actions (subscribe or unsubscribe).
+
+    """
+    if request.method == 'POST':
+        form = SubscriberForm(request.POST)
+        if form.is_valid():
+            form.process()
+
+            if form.is_subscribe():
+                return redirect('email_list-request_subscribe')
+            else:
+                return redirect('email_list-request_unsubscribe')
+
+    else:
+        form = SubscriberForm()
+
+    return render(request, 'email_list/subscribe_form.html', {'form': form})
+
+
+@require_POST
+def confirm(request, key):
+    """
+    This view handles the confirmation of a subscribe or unsubscribe action.
+
+    """
+    subscriber = get_object_or_404(Subscriber, key=key)
+
+    if subscriber.is_pending():
+        subscriber.set_active()
+        subscriber.save()
+        return redirect('email_list-subscribed')
+    elif subscriber.is_leaving():
+        subscriber.delete()
+        return redirect('email_list-unsubscribed')
+
+    # This should not happen
+    logger.error("Trying to confirm subscriber %d, but status is %s",
+            subscriber.pk, subscriber.status)
+    return HttpResponseServerError()
--- a/madeira/settings/base.py	Sat Mar 24 16:18:48 2012 -0500
+++ b/madeira/settings/base.py	Wed Mar 28 21:13:05 2012 -0500
@@ -107,6 +107,7 @@
     'django.contrib.staticfiles',
     'articles',
     'band',
+    'email_list',
     'gigs',
     'mp3',
     'news',
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/madeira/settings/test.py	Wed Mar 28 21:13:05 2012 -0500
@@ -0,0 +1,15 @@
+"""
+Settings to use when running tests. Uses sqlite for speed.
+This idea was taken from
+http://blog.davidziegler.net/post/370368042/test-database-settings-in-django
+
+"""
+from settings.base import *
+from settings.local import *
+
+DATABASES = {
+    'default': {
+        'ENGINE': 'django.db.backends.sqlite3',
+        'NAME': 'test.db',
+    },
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/madeira/templates/email_list/email_subscribe.txt	Wed Mar 28 21:13:05 2012 -0500
@@ -0,0 +1,14 @@
+Hello,
+
+We have received a request for this email address to join our mailing list. In order for us to process this subscription, we need confirmation from you.
+
+To confirm your subscription to our mailing list, please visit the following link:
+
+{{ url }}
+
+If you did not request to join this mailing list, you may simply ignore this message.
+
+Thanks,
+
+{{ band }}
+http://{{ band_domain }}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/madeira/templates/email_list/email_unsubscribe.txt	Wed Mar 28 21:13:05 2012 -0500
@@ -0,0 +1,14 @@
+Hello,
+
+We have received a request for this email address to unsubscribe from our mailing list. In order for us to process this request, we need confirmation from you.
+
+To unsubscribe from our mailing list, please vist the following link:
+
+{{ url }}
+
+If you did not request to unsubscribe from our mailing list, you may simply ignore this message.
+
+Thanks,
+
+{{ band }}
+http://{{ band_domain }}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/madeira/templates/email_list/subscribe_form.html	Wed Mar 28 21:13:05 2012 -0500
@@ -0,0 +1,24 @@
+{% extends 'band/base.html' %}
+{% block title %}The Madeira | Mailing List{% endblock %}
+{% block content %}
+<h1>Madeira Mailing List</h1>
+<p>Get on the Madeira mailing list to receive updates about upcoming shows, releases, and website updates.
+This is a low volume list. We do not share your email address with anyone.</p>
+<fieldset><legend>Mailing List</legend>
+   <form method="post" action=".">{% csrf_token %}
+      {% if form.non_field_errors %}
+      <div>{{ form.non_field_errors }}</div>
+      {% endif %}
+      <table border="0" class="input-form">
+      {% for field in form %}
+         <tr>
+         <th>{{ field.label_tag }}{% if field.field.required %}*{% endif %}:</th>
+         <td>{{ field }}
+         {% if field.errors %}{{ field.errors }}{% endif %}</td>
+         </tr>
+      {% endfor %}
+      <tr><td><input type="submit" name="Submit" value="Submit" class="submit-button" /></td></tr>
+      </table>
+   </form>
+</fieldset>
+{% endblock %}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/madeira/templates/email_list/subscribe_request.html	Wed Mar 28 21:13:05 2012 -0500
@@ -0,0 +1,10 @@
+{% extends 'band/base.html' %}
+{% block title %}The Madeira | Mailing List Subscription Confirmation{% endblock %}
+{% block content %}
+<h1>Madeira Mailing List Subscription Confirmation</h1>
+<p>
+Thanks for subscribing to our email list! You should shortly receive a confirmation email
+with instructions on how to complete the subscription process.
+</p>
+<p>If you do not receive your email, please check any spam folders.</p>
+{% endblock %}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/madeira/templates/email_list/subscribed.html	Wed Mar 28 21:13:05 2012 -0500
@@ -0,0 +1,6 @@
+{% extends 'band/base.html' %}
+{% block title %}The Madeira | Mailing List Subscription Confirmation{% endblock %}
+{% block content %}
+<h1>Madeira Mailing List Subscription Confirmation</h1>
+<p>Congratulations! You have been successfully added to our email list.</p>
+{% endblock %}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/madeira/templates/email_list/unsubscribe_request.html	Wed Mar 28 21:13:05 2012 -0500
@@ -0,0 +1,11 @@
+{% extends 'band/base.html' %}
+{% block title %}The Madeira | Mailing List Unsubscribe Confirmation{% endblock %}
+{% block content %}
+<h1>Madeira Mailing List Unsubscribe Confirmation</h1>
+<p>
+We're sorry to see you unsubscribing from our email list!
+You should shortly receive a confirmation email with instructions on how to complete
+the removal process.
+</p>
+<p>If you don't receive this email, please check any spam folders.</p>
+{% endblock %}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/madeira/templates/email_list/unsubscribed.html	Wed Mar 28 21:13:05 2012 -0500
@@ -0,0 +1,6 @@
+{% extends 'band/base.html' %}
+{% block title %}The Madeira | Mailing List Subscription Removal{% endblock %}
+{% block content %}
+<h1>Madeira Mailing List Subscription Removal</h1>
+<p>You have been successfully removed from our email list.</p>
+{% endblock %}
--- a/madeira/urls.py	Sat Mar 24 16:18:48 2012 -0500
+++ b/madeira/urls.py	Wed Mar 28 21:13:05 2012 -0500
@@ -8,6 +8,7 @@
 urlpatterns = patterns('',
    (r'^', include('band.urls')),
    (r'^gigs/', include('gigs.urls')),
+   (r'^mail/', include('email_list.urls')),
    (r'^news/', include('news.urls')),
    (r'^press/', include('articles.urls')),
    (r'^songs/', include('mp3.urls')),