├── .gitignore
├── README.txt
├── example
├── __init__.py
├── fixtures
│ └── newsletter_initial.json
├── manage.py
├── newsletter
├── settings.py
├── templates
│ ├── base.html
│ └── home.html
├── urls.py
└── views.py
├── newsletter
├── __init__.py
├── admin.py
├── core
│ ├── __init__.py
│ └── csv.py
├── forms.py
├── models.py
├── templates
│ ├── admin
│ │ └── newsletter
│ │ │ └── change_list.html
│ └── newsletter
│ │ ├── subscribe.html
│ │ └── success.html
├── tests.py
├── urls.py
└── views.py
└── setup.py
/.gitignore:
--------------------------------------------------------------------------------
1 | *.pyc
2 | *.bak
3 | *.db
4 | local_settings.py
5 |
--------------------------------------------------------------------------------
/README.txt:
--------------------------------------------------------------------------------
1 | Django-Newsletter
2 | =================
3 |
4 | This is a simple newsletter opt-in/opt-out reusable application for your Django powered web app.
5 |
6 | Many projects I develop need basic "newsletter" opt-in/out functionality on their site with the ability to export subscriptions to CSV files for uploading to newsletter management solutions like Campaign Monitor.
7 |
8 | So instead of reinventing the wheel each time I've selected the features most share and created this reusable Django app. I also provided basic example app to get you started.
9 |
10 | This app follows several "best practices" for reusable apps by allowing for template overrides and extra_context
11 | arguments and such.
12 |
13 | This is not a newsletter mailing application. The Django-Mailer application is the one you're looking for if you need a mail queuing and management, and possible Django-Notification depending on your needs.
14 |
15 | Features
16 | ===================
17 |
18 | 1. allow user to opt-in.
19 | 2. allow user to opt-out.
20 | 3. export subscribed users to a CSV file via the admin.
21 |
22 | Installation
23 | ============
24 |
25 | 1. add 'newsletter' directory to your Python path.
26 | 2. add 'newsletter' to your INSTALLED_APPS tuple found in your settings file.
27 | 3. execute ./manage.py syncdb to created database tables
28 | 4. Log into your admin and enjoy!
29 | 5. To customize the templates add a "newsletter" directory to your project's templates dir.
30 |
31 | Example Site
32 | ============
33 |
34 | I included an example site in the /example directory. You should be able to
35 | simply execute './manage.py syncdb' and then './manage.py runserver' and have
36 | the example site up and running. I assume your system has sqlite3 available -
37 | it is set as the default database with the DATABASE_NAME = 'dev.db'
38 |
39 | 1. From the repository root directory execute "cd example" to jump into the example dir.
40 |
41 | 2. Execute './manage.py syncdb' (This assumes that sqlite3 is available as it is set as the default database with th DATABASE_NAME = 'dev.db'.)
42 |
43 | 3. Executing '/manage.py loaddata fixtures/newsletter_initial.json' will load initial data for you for testing purposes.
44 |
45 | 4. Execute './manage.py runserver' and you will have the example site up and running. The home page will have links to get to the available views.
46 |
47 | 5. The admin is available at "/admin". Feel free to play around with it!
48 |
--------------------------------------------------------------------------------
/example/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/howiworkdaily/django-newsletter/7eea12f7977e5591cab6aeef41c3bab450fa2648/example/__init__.py
--------------------------------------------------------------------------------
/example/fixtures/newsletter_initial.json:
--------------------------------------------------------------------------------
1 | [{"pk": 1, "model": "newsletter.subscription", "fields": {"updated_on": "2009-01-25", "created_on": "2009-01-24", "email": "kevin@fooper.com", "subscribed": false}}, {"pk": 2, "model": "newsletter.subscription", "fields": {"updated_on": "2009-01-25", "created_on": "2009-01-24", "email": "jane@fooper.com", "subscribed": true}}, {"pk": 3, "model": "newsletter.subscription", "fields": {"updated_on": "2009-01-25", "created_on": "2009-01-24", "email": "test@fooper.com", "subscribed": true}}]
2 |
--------------------------------------------------------------------------------
/example/manage.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 |
3 | #add faq app to python path
4 | import sys
5 | from os.path import abspath, dirname, join
6 | sys.path.append(abspath(join(dirname(__file__), '..')))
7 |
8 | from django.core.management import execute_manager
9 | try:
10 | import settings # Assumed to be in the same directory.
11 | except ImportError:
12 | import sys
13 | sys.stderr.write("Error: Can't find the file 'settings.py' in the directory containing %r. It appears you've customized things.\nYou'll have to run django-admin.py, passing it your settings module.\n(If the file settings.py does indeed exist, it's causing an ImportError somehow.)\n" % __file__)
14 | sys.exit(1)
15 |
16 | if __name__ == "__main__":
17 | execute_manager(settings)
18 |
--------------------------------------------------------------------------------
/example/newsletter:
--------------------------------------------------------------------------------
1 | ../newsletter
--------------------------------------------------------------------------------
/example/settings.py:
--------------------------------------------------------------------------------
1 | # Django settings for [name] project.
2 | import os, os.path, sys
3 |
4 | #set DEBUG = False and django will send exception emails
5 | DEBUG = True
6 | TEMPLATE_DEBUG = DEBUG
7 | PROJECT_DIR = os.path.dirname(__file__)
8 |
9 | SUBSCRIPTION_DUPES_ALLOWED = True
10 |
11 | DATABASE_ENGINE = 'sqlite3' # 'postgresql_psycopg2', 'postgresql', 'mysql', 'sqlite3' or 'oracle'.
12 | DATABASE_NAME = 'dev.db' # Or path to database file if using sqlite3.
13 | DATABASE_USER = '' # Not used with sqlite3.
14 | DATABASE_PASSWORD = '' # Not used with sqlite3.
15 | DATABASE_HOST = '' # Set to empty string for localhost. Not used with sqlite3.
16 | DATABASE_PORT = '' # Set to empty string for default. Not used with sqlite3.
17 |
18 | # Local time zone for this installation. Choices can be found here:
19 | # http://en.wikipedia.org/wiki/List_of_tz_zones_by_name
20 | # although not all choices may be avilable on all operating systems.
21 | # If running in a Windows environment this must be set to the same as your
22 | # system time zone.
23 | TIME_ZONE = 'America/New_York'
24 |
25 | # Language code for this installation. All choices can be found here:
26 | # http://www.i18nguy.com/unicode/language-identifiers.html
27 | LANGUAGE_CODE = 'en-us'
28 |
29 | SITE_ID = 1
30 |
31 | # If you set this to False, Django will make some optimizations so as not
32 | # to load the internationalization machinery.
33 | USE_I18N = True
34 |
35 | # Absolute path to the directory that holds media.
36 | # Example: "/home/media/media.lawrence.com/"
37 | MEDIA_ROOT = os.path.join(PROJECT_DIR, 'media')
38 |
39 | # URL that handles the media served from MEDIA_ROOT. Make sure to use a
40 | # trailing slash if there is a path component (optional in other cases).
41 | # Examples: "http://media.lawrence.com", "http://example.com/media/"
42 | #MEDIA_URL = 'http://127.0.0.1:8000/site_media/'
43 | MEDIA_URL = '/media/' #
44 |
45 | # URL prefix for admin media -- CSS, JavaScript and images. Make sure to use a
46 | # trailing slash.
47 | # Examples: "http://foo.com/media/", "/media/".
48 | ADMIN_MEDIA_PREFIX = '/admin_media/'
49 |
50 | # Make this unique, and don't share it with anybody.
51 | SECRET_KEY = 'c#zi(mv^n+4te_sy$hpb*zdo7#f7ccmp9ro84yz9bmmfqj9y*c'
52 |
53 | # List of callables that know how to import templates from various sources.
54 | TEMPLATE_LOADERS = (
55 | 'django.template.loaders.filesystem.load_template_source',
56 | 'django.template.loaders.app_directories.load_template_source',
57 | )
58 |
59 | TEMPLATE_CONTEXT_PROCESSORS = (
60 | 'django.core.context_processors.auth',
61 | 'django.core.context_processors.media',
62 | )
63 |
64 | MIDDLEWARE_CLASSES = (
65 | #'django.middleware.cache.CacheMiddleware',
66 | 'django.middleware.common.CommonMiddleware',
67 | 'django.contrib.sessions.middleware.SessionMiddleware',
68 | 'django.contrib.auth.middleware.AuthenticationMiddleware',
69 | 'django.middleware.doc.XViewMiddleware',
70 | )
71 |
72 | ROOT_URLCONF = 'example.urls'
73 |
74 | INTERNAL_IPS = (
75 | '127.0.0.1',
76 | )
77 |
78 | TEMPLATE_DIRS = (
79 | # Put strings here, like "/home/html/django_templates" or "C:/www/django/templates".
80 | # Always use forward slashes, even on Windows.
81 | # Don't forget to use absolute paths, not relative paths.
82 | [os.path.join(PROJECT_DIR, "templates")]
83 | )
84 |
85 | INSTALLED_APPS = (
86 | 'django.contrib.auth',
87 | 'django.contrib.contenttypes',
88 | 'django.contrib.sessions',
89 | 'django.contrib.sites',
90 | 'django.contrib.admin',
91 | 'newsletter',
92 | )
93 |
94 |
95 | try:
96 | from local_settings import *
97 | except ImportError:
98 | pass
99 |
--------------------------------------------------------------------------------
/example/templates/base.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 | {% block content %}{% endblock %}
39 |
40 |
41 |
42 |
43 |
--------------------------------------------------------------------------------
/example/templates/home.html:
--------------------------------------------------------------------------------
1 | {% extends "base.html" %}
2 |
3 | {% block content %}
4 |
5 |
6 | Welcome to django-newsletter.
7 |
8 |
9 |
13 |
14 | {% endblock %}
--------------------------------------------------------------------------------
/example/urls.py:
--------------------------------------------------------------------------------
1 | from django.conf.urls.defaults import *
2 | from django.contrib import admin
3 | from django.conf import settings
4 | from django.views.generic.simple import direct_to_template
5 |
6 | admin.autodiscover()
7 |
8 | urlpatterns = patterns('',
9 |
10 | (r'^newsletter/', include('newsletter.urls')),
11 | (r'^admin/(.*)', admin.site.root),
12 | url (
13 | r'^$',
14 | direct_to_template,
15 | {'template': 'home.html'},
16 | name = 'home',
17 | ),
18 | )
19 |
20 | urlpatterns += patterns('',
21 | (r'^media/(?P.*)$', 'django.views.static.serve', {'document_root': settings.MEDIA_ROOT}),
22 | )
23 |
24 |
25 |
--------------------------------------------------------------------------------
/example/views.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/howiworkdaily/django-newsletter/7eea12f7977e5591cab6aeef41c3bab450fa2648/example/views.py
--------------------------------------------------------------------------------
/newsletter/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/howiworkdaily/django-newsletter/7eea12f7977e5591cab6aeef41c3bab450fa2648/newsletter/__init__.py
--------------------------------------------------------------------------------
/newsletter/admin.py:
--------------------------------------------------------------------------------
1 | from django.contrib import admin
2 | from newsletter.models import Subscription
3 | from newsletter.forms import SubscriptionForm
4 |
5 | class SubscriptionAdmin(admin.ModelAdmin):
6 |
7 | list_display = ('email', 'subscribed', 'created_on', )
8 | search_fields = ('email',)
9 | list_filter = ('subscribed',)
10 |
11 | admin.site.register(Subscription, SubscriptionAdmin)
12 |
--------------------------------------------------------------------------------
/newsletter/core/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/howiworkdaily/django-newsletter/7eea12f7977e5591cab6aeef41c3bab450fa2648/newsletter/core/__init__.py
--------------------------------------------------------------------------------
/newsletter/core/csv.py:
--------------------------------------------------------------------------------
1 | ################################################################
2 | # Source: http://www.djangosnippets.org/snippets/1151/
3 | # Many thanks!
4 | ################################################################
5 |
6 | import datetime
7 |
8 | from django.db.models.query import QuerySet, ValuesQuerySet
9 | from django.http import HttpResponse
10 |
11 | class ExcelResponse(HttpResponse):
12 |
13 | def __init__(self, data, output_name='excel_data', headers=None,
14 | force_csv=False, encoding='utf8'):
15 |
16 | # Make sure we've got the right type of data to work with
17 | valid_data = False
18 | if isinstance(data, ValuesQuerySet):
19 | data = list(data)
20 | elif isinstance(data, QuerySet):
21 | data = list(data.values())
22 | if hasattr(data, '__getitem__'):
23 | if isinstance(data[0], dict):
24 | if headers is None:
25 | headers = data[0].keys()
26 | data = [[row[col] for col in headers] for row in data]
27 | data.insert(0, headers)
28 | if hasattr(data[0], '__getitem__'):
29 | valid_data = True
30 | assert valid_data is True, "ExcelResponse requires a sequence of sequences"
31 |
32 | import StringIO
33 | output = StringIO.StringIO()
34 | # Excel has a limit on number of rows; if we have more than that, make a csv
35 | use_xls = False
36 | if len(data) <= 65536 and force_csv is not True:
37 | try:
38 | import xlwt
39 | except ImportError:
40 | # xlwt doesn't exist; fall back to csv
41 | pass
42 | else:
43 | use_xls = True
44 | if use_xls:
45 | book = xlwt.Workbook(encoding=encoding)
46 | sheet = book.add_sheet('Sheet 1')
47 | styles = {'datetime': xlwt.easyxf(num_format_str='yyyy-mm-dd hh:mm:ss'),
48 | 'date': xlwt.easyxf(num_format_str='yyyy-mm-dd'),
49 | 'time': xlwt.easyxf(num_format_str='hh:mm:ss'),
50 | 'default': xlwt.Style.default_style}
51 |
52 | for rowx, row in enumerate(data):
53 | for colx, value in enumerate(row):
54 | if isinstance(value, datetime.datetime):
55 | cell_style = styles['datetime']
56 | elif isinstance(value, datetime.date):
57 | cell_style = styles['date']
58 | elif isinstance(value, datetime.time):
59 | cell_style = styles['time']
60 | else:
61 | cell_style = styles['default']
62 | sheet.write(rowx, colx, value, style=cell_style)
63 | book.save(output)
64 | mimetype = 'application/vnd.ms-excel'
65 | file_ext = 'xls'
66 | else:
67 | for row in data:
68 | out_row = []
69 | for value in row:
70 | if not isinstance(value, basestring):
71 | value = unicode(value)
72 | value = value.encode(encoding)
73 | out_row.append(value.replace('"', '""'))
74 | output.write('"%s"\n' %
75 | '","'.join(out_row))
76 | mimetype = 'text/csv'
77 | file_ext = 'csv'
78 | output.seek(0)
79 | super(ExcelResponse, self).__init__(content=output.getvalue(),
80 | mimetype=mimetype)
81 | self['Content-Disposition'] = 'attachment;filename="%s.%s"' % \
82 | (output_name.replace('"', '\"'), file_ext)
83 |
--------------------------------------------------------------------------------
/newsletter/forms.py:
--------------------------------------------------------------------------------
1 | from django import forms
2 | from django.conf import settings
3 | from django.utils.translation import ugettext_lazy as _
4 | from newsletter.models import Subscription
5 |
6 | class SubscriptionForm(forms.ModelForm):
7 | '''
8 | TODO:
9 |
10 | '''
11 |
12 | class Meta:
13 | model = Subscription
14 |
15 |
16 |
17 |
18 |
19 |
--------------------------------------------------------------------------------
/newsletter/models.py:
--------------------------------------------------------------------------------
1 | from django.db import models
2 | from django.utils.translation import ugettext_lazy as _
3 | from django.conf import settings
4 | import datetime
5 |
6 | class SubscriptionBase(models.Model):
7 | '''
8 | A newsletter subscription base.
9 |
10 | '''
11 |
12 | subscribed = models.BooleanField(_('subscribed'), default=True)
13 | email = models.EmailField(_('email'), unique=True)
14 | created_on = models.DateField(_("created on"), blank=True)
15 | updated_on = models.DateField(_("updated on"), blank=True)
16 |
17 | class Meta:
18 | abstract = True
19 |
20 | @classmethod
21 | def is_subscribed(cls, email):
22 | '''
23 | Concept inspired by Satchmo. Thanks guys!
24 |
25 | '''
26 | try:
27 | return cls.objects.get(email=email).subscribed
28 | except cls.DoestNotExist, e:
29 | return False
30 |
31 |
32 | def __unicode__(self):
33 | return u'%s' % (self.email)
34 |
35 | def save(self, *args, **kwargs):
36 | self.updated_on = datetime.date.today()
37 | if not self.created_on:
38 | self.created_on = datetime.date.today()
39 | super(SubscriptionBase,self).save(*args, **kwargs)
40 |
41 | class Subscription(SubscriptionBase):
42 | '''
43 | Generic subscription
44 |
45 | '''
46 |
47 | def save(self, *args, **kwargs):
48 | super(Subscription,self).save()
49 |
--------------------------------------------------------------------------------
/newsletter/templates/admin/newsletter/change_list.html:
--------------------------------------------------------------------------------
1 | {% extends "admin/change_list.html" %}
2 | {% load i18n %}
3 | {% block object-tools %}
4 | {{ block.super }}
5 |
6 |
10 |
11 | {% endblock %}
--------------------------------------------------------------------------------
/newsletter/templates/newsletter/subscribe.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
40 |
41 |
42 |
43 | Opt-In/Opt-Out
44 |
45 |
60 |
61 |
62 |
63 |
--------------------------------------------------------------------------------
/newsletter/templates/newsletter/success.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | {{ message }}
7 |
8 |
9 |
--------------------------------------------------------------------------------
/newsletter/tests.py:
--------------------------------------------------------------------------------
1 | from django.test import TestCase
2 | from django.core.urlresolvers import reverse
3 |
4 | class ShopTest(TestCase):
5 |
6 | def setUp(self):
7 | pass
8 |
9 | def tearDown(self):
10 | pass
11 |
12 | def test_user_subscribe_twice_should_not_throw_error(self):
13 | post_data = {'email': "test@example.com", 'subscribed': True}
14 | for i in range(2):
15 | response = self.client.post(reverse('subscribe_detail'), post_data)
16 | self.assertTemplateUsed(response, "newsletter/success.html")
17 |
--------------------------------------------------------------------------------
/newsletter/urls.py:
--------------------------------------------------------------------------------
1 | from django.conf.urls.defaults import *
2 | from django.contrib import admin
3 | from django.views.generic.simple import direct_to_template
4 | from django.conf import settings
5 |
6 | admin.autodiscover()
7 |
8 | urlpatterns = patterns('newsletter.views',
9 |
10 | url (r'^admin/newsletter/subscription/download/csv/$',
11 | view='generate_csv',
12 | name='download_csv',
13 | ),
14 |
15 | url (r'^$',
16 | view='subscribe_detail',
17 | name='subscribe_detail',
18 | ),
19 |
20 | )
21 |
--------------------------------------------------------------------------------
/newsletter/views.py:
--------------------------------------------------------------------------------
1 | from django.shortcuts import render_to_response, get_object_or_404
2 | from django.template import RequestContext
3 | from django.http import *
4 | from django.conf import settings
5 | from django.template.loader import render_to_string
6 | from django.db.models import get_model
7 |
8 | from newsletter.models import Subscription
9 | from newsletter.forms import SubscriptionForm
10 | from newsletter.core import csv
11 |
12 | import datetime
13 | import re
14 |
15 | from django.contrib.admin.views.decorators import staff_member_required
16 |
17 | @staff_member_required
18 | def generate_csv(request, model_str="newsletter.subscription", data=None):
19 | '''
20 | TODO:
21 |
22 | '''
23 |
24 | if not data:
25 | model = get_model(*model_str.split('.'))
26 | data = model._default_manager.filter(subscribed=True)
27 |
28 | if len(data) == 0:
29 | data = [["no subscriptions"],]
30 | return csv.ExcelResponse(data)
31 |
32 | def subscribe_detail(request, form_class=SubscriptionForm,
33 | template_name='newsletter/subscribe.html',
34 | success_template='newsletter/success.html', extra_context={},
35 | model_str="newsletter.subscription"):
36 |
37 | if request.POST:
38 | try:
39 | model = get_model(*model_str.split('.'))
40 | instance = model._default_manager.get(email=request.POST['email'])
41 | except model.DoesNotExist:
42 | instance = None
43 | form = form_class(request.POST, instance = instance)
44 | if form.is_valid():
45 | subscribed = form.cleaned_data["subscribed"]
46 |
47 | form.save()
48 | if subscribed:
49 | message = getattr(settings,
50 | "NEWSLETTER_OPTIN_MESSAGE", "Success! You've been added.")
51 | else:
52 | message = getattr(settings,
53 | "NEWSLETTER_OPTOUT_MESSAGE",
54 | "You've been removed. Sorry to see ya go.")
55 |
56 | extra = {
57 | 'success': True,
58 | 'message': message,
59 | 'form': form_class(),
60 | }
61 | extra.update(extra_context)
62 |
63 | return render_to_response(success_template, extra,
64 | RequestContext(request))
65 | else:
66 | form = form_class()
67 |
68 | extra = {
69 | 'form': form,
70 | }
71 | extra.update(extra_context)
72 |
73 | return render_to_response(template_name, extra, RequestContext(request))
74 |
75 |
--------------------------------------------------------------------------------
/setup.py:
--------------------------------------------------------------------------------
1 | from setuptools import setup, find_packages
2 |
3 | setup(
4 | name='django-newsletter',
5 | version='0.3.0',
6 | description='A basic, reusable newsletter subscription (opt-in/out) application.',
7 | author='Kevin Fricovsky',
8 | author_email='kfricovsky@gmail.com',
9 | url='http://github.com/howiworkdaily/django-newsletter/tree/master',
10 | packages=find_packages(),
11 | classifiers=[
12 | 'Development Status :: Alpha',
13 | 'Environment :: Web Environment',
14 | 'Intended Audience :: Developers',
15 | 'License :: OSI Approved :: BSD License',
16 | 'Operating System :: OS Independent',
17 | 'Programming Language :: Python',
18 | 'Framework :: Django',
19 | ],
20 | include_package_data=True,
21 | zip_safe=False,
22 | install_requires=['setuptools'],
23 | )
24 |
25 |
--------------------------------------------------------------------------------