├── .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 |

Django-newsletter{% block title %}{% endblock %}

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 |
46 | 47 |

48 | 49 | {{ form.email }} 50 | {% if form.email.errors %}{{ form.email.errors }}{% endif %} 51 |

52 | 53 |

54 | 55 | {{ form.subscribed }} 56 |

57 | 58 |

Submit

59 |
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 | --------------------------------------------------------------------------------