├── .gitignore ├── AUTHORS ├── CHANGES.md ├── LICENSE ├── MANIFEST.in ├── README.rst ├── campaign ├── __init__.py ├── admin.py ├── apps.py ├── backends │ ├── __init__.py │ ├── base.py │ ├── debug.py │ ├── django_mailer.py │ ├── mailgun_api.py │ ├── mandrill_api.py │ └── send_mail.py ├── context.py ├── context_processors.py ├── fields.py ├── forms.py ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ ├── fetch_mailgun_rejects.py │ │ └── fetch_mandrill_rejects.py ├── migrations │ ├── 0001_initial.py │ ├── 0002_blacklistentry_reason.py │ ├── 0003_delete_bounceentry.py │ ├── 0004_newsletter_from_email.py │ ├── 0005_newsletter_from_name.py │ ├── 0006_campaign_send_permission.py │ ├── 0007_auto_20190806_1020.py │ ├── 0008_auto_20220907_2025.py │ ├── 0009_newsletter_default_newsletter_site.py │ └── __init__.py ├── models.py ├── signals.py ├── templates │ ├── admin │ │ └── campaign │ │ │ ├── blacklistentry │ │ │ └── change_list.html │ │ │ ├── campaign │ │ │ ├── change_form.html │ │ │ └── send_object.html │ │ │ └── subscriberlist │ │ │ ├── change_form.html │ │ │ └── preview.html │ └── campaign │ │ ├── base.html │ │ ├── subscribe.html │ │ └── unsubscribe.html ├── templatetags │ ├── __init__.py │ └── campaign_tags.py ├── tests.py ├── urls.py └── views.py ├── docs ├── backends.txt ├── concepts.txt ├── conf.py ├── index.txt ├── install.txt ├── overview.txt ├── settings.txt └── templates.txt ├── setup.py ├── tests ├── __init__.py ├── manage.py ├── settings.py ├── urls.py └── wsgi.py └── tox.ini /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[co] 2 | .tox 3 | dist/ 4 | .idea/ 5 | *.egg-info/ 6 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | Arne Brodowski 2 | Philipp Bosch -------------------------------------------------------------------------------- /CHANGES.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 5 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 6 | 7 | ## [0.6.3] - 2022-09-07 8 | ### Added 9 | - Ability to use custom subscriber lists 10 | 11 | ## [0.6.2] - 2022-08-23 12 | ### Added 13 | - Ability to pass a subscriber list when sending a campaign 14 | 15 | ## [0.6.1] - 2021-10-15 16 | ### Fixed 17 | - Fixed a problem with Django 3 compatibility 18 | 19 | ## [0.6.0] - 2021-09-15 20 | ### Added 21 | - Added support for Django 3.x 22 | 23 | ### Removed 24 | - Removed support for Python 2.x 25 | 26 | ## [0.5.1] - 2021-02-20 27 | ### Added 28 | - Added 'campaign_sent' signal, triggered whenever a Campaign is sent 29 | 30 | ### Fixed 31 | - Use hardcoded app_label 'campaign' in template resolution so app_label overrides via custom AppConfig don't interfere with our admin template overrides. 32 | 33 | ## [0.5.0] - 2021-01-29 34 | ### Added 35 | - Validation of SubscriberList filter condition 36 | - Validation of SubscriberList email field name 37 | - Preview of SubscriberList recipients 38 | - Basic skeleton for a test suite 39 | 40 | ## [0.4.1] - 2019-05-21 41 | ### Fixed 42 | - Fix a packaging problem 43 | 44 | ## [0.4.0] - 2019-05-21 [YANKED] 45 | ### Added 46 | - Management command to fetch bounces from Mailgun 47 | - Introduced a "send_campaign" permission 48 | - Support for Python 3 49 | 50 | ### Changed 51 | - Compatiblity for more Django versions 52 | - Use DISTINCT in subscriber query 53 | 54 | ## Start of Changelog 55 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2009-2021, Arne Brodowski 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are 6 | met: 7 | 8 | * Redistributions of source code must retain the above copyright 9 | notice, this list of conditions and the following disclaimer. 10 | 11 | * Redistributions in binary form must reproduce the above 12 | copyright notice, this list of conditions and the following 13 | disclaimer in the documentation and/or other materials provided 14 | with the distribution. 15 | 16 | * Neither the name of the author nor the names of other 17 | contributors may be used to endorse or promote products derived 18 | from this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 21 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 22 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 23 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 24 | OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 25 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 26 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 27 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 28 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 29 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 30 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 31 | 32 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include AUTHORS 2 | include LICENSE 3 | include README.rst 4 | include CHANGES.md 5 | include tox.ini 6 | recursive-include docs * 7 | recursive-include tests * 8 | recursive-include campaign/templates * -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ================================================= 2 | Newsletter management for the Django webframework 3 | ================================================= 4 | 5 | .. image:: https://img.shields.io/pypi/v/django-campaign.svg 6 | :target: https://pypi.python.org/pypi/django-campaign/ 7 | 8 | Django-campaign is a newsletter campaign management app for the Django 9 | webframework. It can manage multiple newsletters with different subscriberlists. 10 | 11 | Features 12 | -------- 13 | 14 | * Multiple newsletters 15 | * Multiple subscriberlists 16 | * Personalization of content for every Subscriber 17 | * Subscriber model lives in your code and can have whatever fields you want 18 | * Subscriberlists are defined as orm query parameters 19 | * Send mails from your own Server (through Django's email mechanism) 20 | * Send mails through Mandrill (a transactional email service from Mailchimp) 21 | * Send mails through Mailgun (a transactional email service) 22 | * Pluggable backends for integration with other email services 23 | * Make newsletters available online 24 | * Internal blacklist 25 | 26 | Upgrading 27 | --------- 28 | 29 | If you are upgrading from a 0.2.x release the following changes are noteworthy: 30 | 31 | * The south migrations where removed in favor of Django 1.7 native migrations 32 | * The 'debug' and 'django_mailer' backends are no longer used, because setting 33 | Django's EMAIL_BACKEND settings to the correct value has the same effect. 34 | 35 | 36 | 37 | Documentation 38 | ------------- 39 | 40 | The documentation is available in the docs folder and online at: 41 | http://django-campaign.readthedocs.org 42 | 43 | -------------------------------------------------------------------------------- /campaign/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = '0.6.3' 2 | 3 | default_app_config = 'campaign.apps.CampaignConfig' 4 | -------------------------------------------------------------------------------- /campaign/admin.py: -------------------------------------------------------------------------------- 1 | from functools import update_wrapper 2 | 3 | from django import forms 4 | from django.conf import settings 5 | from django.contrib import admin, messages 6 | from django.contrib.admin.options import IS_POPUP_VAR 7 | from django.contrib.admin.utils import unquote 8 | from django.core.exceptions import PermissionDenied 9 | from django.core.management import call_command 10 | from django.http import Http404, HttpResponseRedirect 11 | from django.shortcuts import render 12 | from django.urls import re_path 13 | from django.utils.encoding import force_str 14 | from django.utils.html import escape 15 | from django.utils.safestring import mark_safe 16 | from django.utils.translation import gettext as _ 17 | 18 | from campaign.forms import SubscriberListForm 19 | from campaign.models import ( 20 | BlacklistEntry, Campaign, MailTemplate, Newsletter, SubscriberList 21 | ) 22 | 23 | 24 | class CampaignAdmin(admin.ModelAdmin): 25 | filter_horizontal = ('recipients',) 26 | list_display = ('name', 'newsletter', 'sent', 'sent_at', 'online') 27 | change_form_template = "admin/campaign/campaign/change_form.html" 28 | send_template = None 29 | 30 | def has_send_permission(self, request, obj): 31 | """ 32 | Subclasses may override this and implement more granular permissions. 33 | """ 34 | return request.user.has_perm("campaign.send_campaign") 35 | 36 | 37 | def send_view(self, request, object_id, extra_context=None): 38 | """ 39 | Allows the admin to send out the mails for this campaign. 40 | """ 41 | model = self.model 42 | opts = model._meta 43 | 44 | try: 45 | obj = model._default_manager.get(pk=unquote(object_id)) 46 | except model.DoesNotExist: 47 | # Don't raise Http404 just yet, because we haven't checked 48 | # permissions yet. We don't want an unauthenticated user to be able 49 | # to determine whether a given object exists. 50 | obj = None 51 | 52 | if not self.has_send_permission(request, obj): 53 | raise PermissionDenied 54 | 55 | if obj is None: 56 | raise Http404(_('%(name)s object with primary key %(key)r does not exist.') % {'name': force_str(opts.verbose_name), 'key': escape(object_id)}) 57 | 58 | if request.method == 'POST': 59 | if not request.POST.get('send', None) == '1': 60 | raise PermissionDenied 61 | 62 | num_sent = obj.send() 63 | messages.success(request, _('The %(name)s "%(obj)s" was successfully sent. %(num_sent)s messages delivered.' % {'name': force_str(opts.verbose_name), 'obj': force_str(obj), 'num_sent': num_sent,})) 64 | return HttpResponseRedirect('../') 65 | 66 | 67 | def form_media(): 68 | css = ['css/forms.css',] 69 | return forms.Media(css={'screen': ['%sadmin/%s' % (settings.STATIC_URL, url) for url in css]}) 70 | 71 | media = self.media + form_media() 72 | 73 | context = { 74 | 'title': _('Send %s') % force_str(opts.verbose_name), 75 | 'object_id': object_id, 76 | 'object': obj, 77 | 'is_popup': (IS_POPUP_VAR in request.POST or IS_POPUP_VAR in request.GET), 78 | 'media': mark_safe(media), 79 | 'app_label': opts.app_label, 80 | 'opts': opts, 81 | } 82 | context.update(extra_context or {}) 83 | 84 | return render(request, self.send_template or 85 | ['admin/campaign/%s/send_object.html' % (opts.object_name.lower()), 86 | 'admin/campaign/send_object.html', 87 | 'admin/send_object.html'], context) 88 | 89 | 90 | def get_urls(self): 91 | def wrap(view): 92 | def wrapper(*args, **kwargs): 93 | return self.admin_site.admin_view(view)(*args, **kwargs) 94 | wrapper.model_admin = self 95 | return update_wrapper(wrapper, view) 96 | 97 | info = self.model._meta.app_label, self.model._meta.model_name 98 | 99 | super_urlpatterns = super(CampaignAdmin, self).get_urls() 100 | urlpatterns = [ 101 | re_path(r'^(.+)/send/$', 102 | wrap(self.send_view), 103 | name='%s_%s_send' % info), 104 | ] 105 | urlpatterns += super_urlpatterns 106 | 107 | return urlpatterns 108 | 109 | 110 | class BlacklistEntryAdmin(admin.ModelAdmin): 111 | list_display = ('email', 'added') 112 | changelist_template = "admin/campaign/blacklistentry/change_list.html" 113 | 114 | def fetch_mandrill_rejects(self, request): 115 | call_command('fetch_mandrill_rejects') 116 | msg = _("Successfully fetched Mandrill rejects") 117 | self.message_user(request, msg, messages.SUCCESS) 118 | return HttpResponseRedirect(request.META.get("HTTP_REFERER", '..')) 119 | 120 | 121 | def get_urls(self): 122 | def wrap(view): 123 | def wrapper(*args, **kwargs): 124 | return self.admin_site.admin_view(view)(*args, **kwargs) 125 | wrapper.model_admin = self 126 | return update_wrapper(wrapper, view) 127 | 128 | info = self.model._meta.app_label, self.model._meta.model_name 129 | 130 | super_urlpatterns = super(BlacklistEntryAdmin, self).get_urls() 131 | urlpatterns = [ 132 | re_path(r'^fetch_mandrill_rejects/$', 133 | wrap(self.fetch_mandrill_rejects), 134 | name='%s_%s_fetchmandrillrejects' % info), 135 | ] 136 | urlpatterns += super_urlpatterns 137 | 138 | return urlpatterns 139 | 140 | 141 | class SubscriberListAdmin(admin.ModelAdmin): 142 | form = SubscriberListForm 143 | change_form_template = "admin/campaign/subscriberlist/change_form.html" 144 | preview_template = None 145 | 146 | def preview_view(self, request, object_id, extra_context=None): 147 | """ 148 | Allows to view a preview of selected susbcribers. 149 | """ 150 | model = self.model 151 | opts = model._meta 152 | 153 | try: 154 | obj = model._default_manager.get(pk=unquote(object_id)) 155 | except model.DoesNotExist: 156 | # Don't raise Http404 just yet, because we haven't checked 157 | # permissions yet. We don't want an unauthenticated user to be able 158 | # to determine whether a given object exists. 159 | obj = None 160 | 161 | if obj is None: 162 | raise Http404(_('%(name)s object with primary key %(key)r does not exist.') % {'name': force_str(opts.verbose_name), 'key': escape(object_id)}) 163 | 164 | context = { 165 | 'title': _('Preview %s') % force_str(opts.verbose_name), 166 | 'object_id': object_id, 167 | 'object': obj, 168 | 'is_popup': (IS_POPUP_VAR in request.POST or IS_POPUP_VAR in request.GET), 169 | 'media': mark_safe(self.media), 170 | 'app_label': opts.app_label, 171 | 'opts': opts, 172 | } 173 | context.update(extra_context or {}) 174 | 175 | return render(request, self.preview_template or 176 | ['admin/campaign/%s/preview.html' % (opts.object_name.lower()), 177 | 'admin/campaign/preview.html', 178 | 'admin/preview.html'], context) 179 | 180 | def get_urls(self): 181 | def wrap(view): 182 | def wrapper(*args, **kwargs): 183 | return self.admin_site.admin_view(view)(*args, **kwargs) 184 | wrapper.model_admin = self 185 | return update_wrapper(wrapper, view) 186 | 187 | info = self.model._meta.app_label, self.model._meta.model_name 188 | 189 | super_urlpatterns = super(SubscriberListAdmin, self).get_urls() 190 | urlpatterns = [ 191 | re_path(r'^(.+)/preview/$', 192 | wrap(self.preview_view), 193 | name='%s_%s_preview' % info), 194 | ] 195 | urlpatterns += super_urlpatterns 196 | 197 | return urlpatterns 198 | 199 | 200 | admin.site.register(Campaign, CampaignAdmin) 201 | admin.site.register(BlacklistEntry, BlacklistEntryAdmin) 202 | admin.site.register(SubscriberList, SubscriberListAdmin) 203 | admin.site.register(MailTemplate) 204 | admin.site.register(Newsletter, list_display=('name', 'from_email', 'from_name')) 205 | -------------------------------------------------------------------------------- /campaign/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | from django.utils.translation import gettext_lazy as _ 3 | 4 | 5 | class CampaignConfig(AppConfig): 6 | name = 'campaign' 7 | verbose_name = _("campaign management") 8 | default_auto_field = 'django.db.models.AutoField' 9 | -------------------------------------------------------------------------------- /campaign/backends/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | import warnings 3 | from django.conf import settings 4 | from django.core.exceptions import ImproperlyConfigured 5 | 6 | __all__ = ('backend') 7 | 8 | CAMPAIGN_BACKEND = getattr(settings, 'CAMPAIGN_BACKEND', 'campaign.backends.send_mail') 9 | 10 | def get_backend(import_path=CAMPAIGN_BACKEND): 11 | if not '.' in import_path: 12 | warnings.warn("CAMPAIGN_BACKEND should be a fully qualified module name", 13 | DeprecationWarning) 14 | import_path = "campaign.backends.%s" % import_path 15 | try: 16 | mod = __import__(import_path, {}, {}, ['']) 17 | except ImportError as e_user: 18 | # No backend found, display an error message and a list of all 19 | # bundled backends. 20 | backend_dir = __path__[0] 21 | available_backends = [f.split('.py')[0] for f in os.listdir(backend_dir) if not f.startswith('_') and not f.startswith('.') and not f.endswith('.pyc')] 22 | available_backends.sort() 23 | if CAMPAIGN_BACKEND not in available_backends: 24 | raise ImproperlyConfigured("%s isn't an available campaign backend. Available options are: %s" % \ 25 | (CAMPAIGN_BACKEND, ', '.join(map(repr, available_backends)))) 26 | # if the CAMPAIGN_BACKEND is available in the backend directory 27 | # and an ImportError is raised, don't suppress it 28 | else: 29 | raise 30 | try: 31 | return getattr(mod, 'backend') 32 | except AttributeError: 33 | raise ImproperlyConfigured('Backend "%s" does not define a "backend" instance.' % import_path) 34 | 35 | #backend = get_backend(CAMPAIGN_BACKEND) -------------------------------------------------------------------------------- /campaign/backends/base.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | from django import template 4 | from django.conf import settings 5 | from django.core.mail import EmailMultiAlternatives 6 | from django.urls import reverse 7 | from django.contrib.sites.models import Site 8 | from campaign.context import MailContext 9 | 10 | 11 | class BaseBackend(object): 12 | """base backend for all campaign backends""" 13 | 14 | context_class = MailContext 15 | 16 | def send_campaign(self, campaign, subscriber_lists=None, fail_silently=False, **kwargs): 17 | """ 18 | Does the actual work 19 | """ 20 | from campaign.models import BlacklistEntry 21 | 22 | from_email = self.get_from_email(campaign) 23 | from_header = self.get_from_header(campaign, from_email) 24 | subject = campaign.template.subject 25 | text_template = template.Template(campaign.template.plain) 26 | if campaign.template.html is not None and campaign.template.html != "": 27 | html_template = template.Template(campaign.template.html) 28 | 29 | sent = 0 30 | used_addresses = [] 31 | for recipient_list in subscriber_lists or campaign.recipients.all(): 32 | for recipient in recipient_list.object_list(): 33 | # never send mail to blacklisted email addresses 34 | recipient_email = getattr(recipient, recipient_list.email_field_name) 35 | if not BlacklistEntry.objects.filter(email=recipient_email).count() and not recipient_email in used_addresses: 36 | msg = EmailMultiAlternatives(subject, to=[recipient_email,], from_email=from_header) 37 | context = self.context_class(recipient) 38 | context.update({'recipient_email': recipient_email}) 39 | if campaign.online: 40 | context.update({'view_online_url': reverse("campaign_view_online", kwargs={ 41 | 'object_id': campaign.pk}), 42 | 'site_url': Site.objects.get_current().domain}) 43 | msg.body = text_template.render(context) 44 | if campaign.template.html is not None and campaign.template.html != "": 45 | html_content = html_template.render(context) 46 | msg.attach_alternative(html_content, 'text/html') 47 | sent += self.send_mail(msg, fail_silently) 48 | used_addresses.append(recipient_email) 49 | return sent 50 | 51 | def send_mail(self, email, fail_silently=False): 52 | raise NotImplementedError 53 | 54 | def get_from_email(self, campaign): 55 | from_email = getattr(settings, 'CAMPAIGN_FROM_EMAIL', settings.DEFAULT_FROM_EMAIL) 56 | try: 57 | from_email = campaign.newsletter.from_email or from_email 58 | except: 59 | pass 60 | return from_email 61 | 62 | def get_from_header(self, campaign, from_email): 63 | try: 64 | from_name = campaign.newsletter.from_name or None 65 | except: 66 | from_name = None 67 | if from_name: 68 | from_header = u"%s <%s>" % (from_name, from_email) 69 | else: 70 | from_header = getattr(settings, 'CAMPAIGN_FROM_HEADERS', {}).get(from_email, from_email) 71 | return from_header 72 | -------------------------------------------------------------------------------- /campaign/backends/debug.py: -------------------------------------------------------------------------------- 1 | from django.core.exceptions import ImproperlyConfigured 2 | from campaign.backends.base import BaseBackend 3 | 4 | 5 | class DebugBackend(BaseBackend): 6 | def __init__(self): 7 | msg = ("The DebugBackend no longer exists. To print Emails to the " 8 | "console use the smtp_backend and set " 9 | "settings.EMAIL_BACKEND = " 10 | "'django.core.mail.backends.console.EmailBackend'") 11 | raise ImproperlyConfigured(msg) 12 | 13 | backend = DebugBackend() -------------------------------------------------------------------------------- /campaign/backends/django_mailer.py: -------------------------------------------------------------------------------- 1 | from django.core.exceptions import ImproperlyConfigured 2 | from campaign.backends.base import BaseBackend 3 | 4 | 5 | class DjangoMailerBackend(BaseBackend): 6 | def __init__(self): 7 | msg = ("The DjangoMailerBackend no longer exists. To queue and send " 8 | "Emails with django-mailer use the django-mailer email backend " 9 | "by setting EMAIL_BACKEND = 'mailer.backend.DbBackend'") 10 | raise ImproperlyConfigured(msg) 11 | 12 | backend = DjangoMailerBackend() 13 | -------------------------------------------------------------------------------- /campaign/backends/mailgun_api.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | import logging 4 | import json 5 | import requests 6 | from django.template import Context 7 | from django.conf import settings 8 | from django.urls import reverse 9 | from django.contrib.sites.models import Site 10 | from django import template 11 | from campaign.backends.base import BaseBackend 12 | from campaign.context import MailContext 13 | 14 | logger = logging.getLogger('django.campaign') 15 | 16 | 17 | class MailgunApiBackend(BaseBackend): 18 | """ 19 | Send your campaigns through the Mailgun Email Service. 20 | 21 | This backend assumes, that your Mailgun API-Key is configured in:: 22 | 23 | settings.MAILGUN_API_KEY 24 | 25 | And you need to change the CAMPAIGN_CONTEXT_PROCESSORS setting. The 26 | default 'campaign.context_processors.recipient' needs to be removed in 27 | favour of the 'campaign.context_processors.recipient_dict'! 28 | 29 | If no sending address is specified in the database, the From-Email is 30 | determined from the following settings in this order:: 31 | 32 | settings.MAILGUN_API_FROM_EMAIL # only used by this backend 33 | settings.CAMPAIGN_FROM_EMAIL # used by all backends that support it 34 | settings.DEFAULT_FROM_EMAIL # used by django 35 | 36 | You can provide additional values for the API call via:: 37 | 38 | settings.MAILGUN_API_SETTINGS 39 | 40 | (Defaults to "{}") 41 | 42 | For example to setup tracking you can set it to:: 43 | 44 | MAILGUN_API_SETTINGS = { 45 | "o:tracking": "yes", 46 | "o:tracking-opens": "yes", 47 | "o:tracking-clicks": "yes" 48 | } 49 | 50 | These settings will override the django-campaign defaults. 51 | 52 | If no sending name is specified in the database, the from header is 53 | either determined from the CAMPAIGN_FROM_HEADERS setting or only the 54 | plain email address is used. 55 | 56 | To specify a from-header (with display-name) for a specific address 57 | the following setting can be used:: 58 | 59 | settings.CAMPAIGN_FROM_HEADERS 60 | 61 | Example configuration:: 62 | 63 | CAMPAIGN_FROM_HEADERS = { 64 | "newsletter@example.com": "Example Newsletter ", 65 | "no-reply@test.com": "Test Sender " 66 | } 67 | 68 | These From-Headers are used, when an email is sent with the matching 69 | address. The setting is optional. 70 | 71 | Please note, that all Django-Template constructs in the MailTemplate are 72 | evaluated only once for all recipients. Personalizations happens at 73 | Mailgun, where each message is processed with 'recipient_vars'. 74 | It might be a good idea to wrap the recipient_vars placeholders in 75 | `{% if not viewed_online %}` conditionals, otherwise the raw placeholders 76 | will be displayed in the web-view of the newsletter. 77 | 78 | """ 79 | BATCH_SIZE = 1000 80 | 81 | def send_campaign(self, campaign, subscriber_lists=None, fail_silently=False, **kwargs): 82 | from campaign.models import BlacklistEntry 83 | 84 | subject = campaign.template.subject 85 | text_template = template.Template(campaign.template.plain) 86 | if campaign.template.html is not None and campaign.template.html != "": 87 | html_template = template.Template(campaign.template.html) 88 | else: 89 | html_template = None 90 | 91 | success_count = 0 92 | recipients = [] 93 | recipient_vars = {} 94 | 95 | for recipient_list in subscriber_lists or campaign.recipients.all(): 96 | for recipient in recipient_list.object_list(): 97 | # never send mail to blacklisted email addresses 98 | recipient_email = getattr(recipient, recipient_list.email_field_name) 99 | if not BlacklistEntry.objects.filter(email=recipient_email).count() and not recipient_email in recipients: 100 | recipients.append(recipient_email) 101 | 102 | context = MailContext(recipient) 103 | if campaign.online: 104 | context.update({ 105 | 'view_online_url': reverse("campaign_view_online", kwargs={ 106 | 'object_id': campaign.pk}), 107 | 'site_url': Site.objects.get_current().domain, 108 | 'recipient_email': recipient_email 109 | }) 110 | the_recipient_vars = {} 111 | for k, v in context.flatten().items(): 112 | the_recipient_vars.update({k: v}) 113 | recipient_vars.update({recipient_email: the_recipient_vars}) 114 | 115 | # assemble recipient data into batches of self.BATCH_SIZE 116 | batches = [] 117 | batch_r = [] 118 | batch_v = {} 119 | for r in recipients: 120 | batch_r.append(r) 121 | batch_v[r] = recipient_vars[r] 122 | if len(batch_r) >= self.BATCH_SIZE: 123 | batches.append((batch_r, batch_v)) 124 | batch_r = [] 125 | batch_v = {} 126 | if len(batch_r): 127 | batches.append((batch_r, batch_v)) 128 | 129 | for recipients, recipient_vars in batches: 130 | from_email = self.get_from_email(campaign) 131 | from_domain = from_email.split('@')[-1] 132 | from_header = self.get_from_header(campaign, from_email) 133 | api_url = getattr(settings, 'MAILGUN_API_URL', 'https://api.mailgun.net/v3/%s/messages') % from_domain 134 | auth = ("api", settings.MAILGUN_API_KEY) 135 | data = { 136 | 'to': recipients, 137 | 'from': from_header, 138 | 'recipient-variables': json.dumps(recipient_vars), 139 | 'subject': subject, 140 | 'text': text_template.render(Context()), 141 | } 142 | 143 | if html_template: 144 | data['html'] = html_template.render(Context()) 145 | 146 | # update data with user supplied values from settings 147 | data.update(getattr(settings, 'MAILGUN_API_SETTINGS', {})) 148 | 149 | try: 150 | result = requests.post(api_url, auth=auth, data=data) 151 | if result.status_code == 200: 152 | success_count += len(recipients) 153 | else: 154 | raise Exception(result.text) 155 | 156 | except Exception as e: 157 | logger.error('Mailgun error: %s - %s' % (e.__class__, e)) 158 | if not fail_silently: 159 | raise e 160 | 161 | return success_count 162 | 163 | def get_from_email(self, campaign): 164 | if hasattr(settings, 'MAILGUN_API_FROM_EMAIL'): 165 | from_email = settings.MAILGUN_API_FROM_EMAIL 166 | else: 167 | from_email = getattr(settings, 'CAMPAIGN_FROM_EMAIL', settings.DEFAULT_FROM_EMAIL) 168 | 169 | try: 170 | from_email = campaign.newsletter.from_email or from_email 171 | except: 172 | pass 173 | return from_email 174 | 175 | def get_from_header(self, campaign, from_email): 176 | try: 177 | from_name = campaign.newsletter.from_name or None 178 | except: 179 | from_name = None 180 | if from_name: 181 | from_header = "%s <%s>" % (from_name, from_email) 182 | else: 183 | from_header = getattr(settings, 'CAMPAIGN_FROM_HEADERS', {}).get(from_email, from_email) 184 | return from_header 185 | 186 | backend = MailgunApiBackend() 187 | -------------------------------------------------------------------------------- /campaign/backends/mandrill_api.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | import logging 4 | import mandrill 5 | from django.template import Context 6 | from django.conf import settings 7 | from django.urls import reverse 8 | from django.contrib.sites.models import Site 9 | from django import template 10 | from campaign.backends.base import BaseBackend 11 | from campaign.context import MailContext 12 | 13 | logger = logging.getLogger('django.campaign') 14 | 15 | 16 | class MandrillApiBackend(BaseBackend): 17 | """ 18 | Send your campaigns through the Mandrill Transactional Email Service. 19 | 20 | This backend assumes, that your Mandrill API-Key is configured in:: 21 | 22 | settings.MANDRILL_API_KEY 23 | 24 | And you need to change the CAMPAIGN_CONTEXT_PROCESSORS setting. The 25 | default 'campaign.context_processors.recipient' needs to be removed in 26 | favour of the 'campaign.context_processors.recipient_dict'! 27 | 28 | If no sending address is specified in the database, the From-Email is 29 | determined from the following settings in this order:: 30 | 31 | settings.MANDRILL_API_FROM_EMAIL # only used by this backend 32 | settings.CAMPAIGN_FROM_EMAIL # used by all backends that support it 33 | settings.DEFAULT_FROM_EMAIL # used by django 34 | 35 | You can provide additional values for the API call via:: 36 | 37 | settings.MANDRILL_API_SETTINGS 38 | 39 | (Defaults to "{}") 40 | 41 | For example to setup tracking you can set it to:: 42 | 43 | MANDRILL_API_SETTINGS = { 44 | "track_opens": True, 45 | "track_clicks": True, 46 | "from_name": "My Project", 47 | "important": False, 48 | } 49 | 50 | These settings will override the django-campaign defaults. 51 | 52 | Please note, that all Django-Template constructs in the MailTemplate are 53 | evaluated only once for all recipients. Personalizations happens at 54 | Mandrill, where each message is processed with 'merge_vars'. 55 | It might be a good idea to wrap the merge_var placeholders in 56 | `{% if not viewed_online %}` conditionals, otherwise the raw placeholders 57 | will be displayed in the web-view of the newsletter. 58 | 59 | """ 60 | def send_campaign(self, campaign, subscriber_lists=None, fail_silently=False, **kwargs): 61 | from campaign.models import BlacklistEntry 62 | 63 | subject = campaign.template.subject 64 | text_template = template.Template(campaign.template.plain) 65 | if campaign.template.html is not None and campaign.template.html != "": 66 | html_template = template.Template(campaign.template.html) 67 | else: 68 | html_template = None 69 | 70 | recipients = [] 71 | merge_vars = [] 72 | 73 | for recipient_list in subscriber_lists or campaign.recipients.all(): 74 | for recipient in recipient_list.object_list(): 75 | # never send mail to blacklisted email addresses 76 | recipient_email = getattr(recipient, recipient_list.email_field_name) 77 | if not BlacklistEntry.objects.filter(email=recipient_email).count() and not recipient_email in recipients: 78 | recipients.append({'email': recipient_email}) 79 | 80 | context = MailContext(recipient) 81 | if campaign.online: 82 | context.update({'view_online_url': reverse("campaign_view_online", kwargs={ 83 | 'object_id': campaign.pk}), 84 | 'site_url': Site.objects.get_current().domain, 85 | 'recipient_email': recipient_email}) 86 | the_merge_vars = [] 87 | for k, v in context.flatten().items(): 88 | the_merge_vars.append({'name': k, 'content': v}) 89 | merge_vars.append({'rcpt': recipient_email, 'vars': the_merge_vars}) 90 | 91 | from_email = self.get_from_email(campaign) 92 | 93 | try: 94 | mandrill_client = mandrill.Mandrill(settings.MANDRILL_API_KEY) 95 | message = { 96 | 'auto_html': False, 97 | 'auto_text': False, 98 | 'from_email': from_email, 99 | 'important': False, 100 | 'inline_css': False, 101 | 'merge': True, 102 | 'merge_vars': merge_vars, 103 | 'metadata': {'capaign_id': campaign.pk}, 104 | 'preserve_recipients': False, 105 | 'subject': subject, 106 | 'text': text_template.render(Context()), 107 | 'to': recipients, 108 | 'track_opens': True, 109 | 'view_content_link': False 110 | } 111 | 112 | if html_template: 113 | message['html'] = html_template.render(Context()) 114 | 115 | # update data with user supplied values from settings 116 | message.update(getattr(settings, 'MANDRILL_API_SETTINGS', {})) 117 | 118 | result = mandrill_client.messages.send(message=message, async=True) 119 | return len(result) 120 | 121 | except mandrill.Error as e: 122 | logger.error('Mandrill error: %s - %s' % (e.__class__, e)) 123 | if not fail_silently: 124 | raise e 125 | 126 | def get_from_email(self, campaign): 127 | if hasattr(settings, 'MANDRILL_API_FROM_EMAIL'): 128 | from_email = settings.MANDRILL_API_FROM_EMAIL 129 | else: 130 | from_email = getattr(settings, 'CAMPAIGN_FROM_EMAIL', settings.DEFAULT_FROM_EMAIL) 131 | 132 | try: 133 | from_email = campaign.newsletter.from_email or from_email 134 | except: 135 | pass 136 | return from_email 137 | 138 | backend = MandrillApiBackend() 139 | -------------------------------------------------------------------------------- /campaign/backends/send_mail.py: -------------------------------------------------------------------------------- 1 | from campaign.backends.base import BaseBackend 2 | 3 | 4 | class SendMailBackend(BaseBackend): 5 | """ 6 | Simple backend which uses Django's built-in mail sending mechanisms. 7 | 8 | If no sending address is specified in the database, the From-Email is 9 | determined from the following settings in this order:: 10 | 11 | settings.CAMPAIGN_FROM_EMAIL # used by all backends that support it 12 | settings.DEFAULT_FROM_EMAIL # used by django 13 | 14 | """ 15 | 16 | def send_mail(self, email, fail_silently=False): 17 | """ 18 | Parameters: 19 | 20 | ``email``: an instance of django.core.mail.EmailMessage 21 | ``fail_silently``: a boolean indicating if exceptions should bubble up 22 | 23 | """ 24 | return email.send(fail_silently=fail_silently) 25 | 26 | backend = SendMailBackend() 27 | -------------------------------------------------------------------------------- /campaign/context.py: -------------------------------------------------------------------------------- 1 | # heavily based on Django's RequestContext 2 | from django.conf import settings 3 | from django.core.exceptions import ImproperlyConfigured 4 | from django.template import Context 5 | 6 | _mail_context_processors = None 7 | 8 | # This is a function rather than module-level procedural code because we only 9 | # want it to execute if somebody uses MailContext. 10 | def get_mail_processors(): 11 | global _mail_context_processors 12 | if _mail_context_processors is None: 13 | processors = [] 14 | for path in getattr(settings, 'CAMPAIGN_CONTEXT_PROCESSORS', ('campaign.context_processors.recipient',)): 15 | i = path.rfind('.') 16 | module, attr = path[:i], path[i+1:] 17 | try: 18 | mod = __import__(module, {}, {}, [attr]) 19 | except ImportError as e: 20 | raise ImproperlyConfigured('Error importing campaign processor module %s: "%s"' % (module, e)) 21 | try: 22 | func = getattr(mod, attr) 23 | except AttributeError: 24 | raise ImproperlyConfigured('Module "%s" does not define a "%s" callable campaign processor' % (module, attr)) 25 | processors.append(func) 26 | _mail_context_processors = tuple(processors) 27 | return _mail_context_processors 28 | 29 | 30 | class MailContext(Context): 31 | """ 32 | This subclass of template.Context automatically populates itself using 33 | the processors defined in CAMPAIGN_CONTEXT_PROCESSORS. 34 | Additional processors can be specified as a list of callables 35 | using the "processors" keyword argument. 36 | """ 37 | def __init__(self, subscriber, dict_=None, processors=None, autoescape=True, 38 | use_l10n=None, use_tz=None): 39 | Context.__init__(self, dict_, autoescape=autoescape, 40 | use_l10n=use_l10n, use_tz=use_tz) 41 | if processors is None: 42 | processors = () 43 | else: 44 | processors = tuple(processors) 45 | updates = dict() 46 | for processor in get_mail_processors() + processors: 47 | updates.update(processor(subscriber)) 48 | 49 | self.update(updates) 50 | 51 | def flatten(self): 52 | """ 53 | Returns self.dicts as one dictionary 54 | """ 55 | flat = {} 56 | for d in self.dicts: 57 | flat.update(d) 58 | return flat 59 | -------------------------------------------------------------------------------- /campaign/context_processors.py: -------------------------------------------------------------------------------- 1 | def recipient(recipient): 2 | return {'recipient': recipient} 3 | 4 | 5 | def recipient_dict(recipient): 6 | recipient_dict = {} 7 | for k,v in recipient.__dict__.items(): 8 | if not k.startswith("_") and k in ('first_name', 'last_name', 'email', 9 | 'is_active', 'is_staff'): 10 | recipient_dict.update({'recipient_%s' % k: v}) 11 | return recipient_dict 12 | -------------------------------------------------------------------------------- /campaign/fields.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from django import forms 4 | from django.conf import settings 5 | from django.db import models 6 | 7 | 8 | class JSONWidget(forms.Textarea): 9 | def render(self, name, value, attrs=None, renderer=None): 10 | if not isinstance(value, str): 11 | value = json.dumps(value, indent=2) 12 | return super().render(name, value, attrs) 13 | 14 | 15 | class JSONFormField(forms.CharField): 16 | def __init__(self, *args, **kwargs): 17 | kwargs['widget'] = JSONWidget 18 | super().__init__(*args, **kwargs) 19 | 20 | def clean(self, value): 21 | if not value: return 22 | try: 23 | return json.loads(value) 24 | except Exception as exc: 25 | raise forms.ValidationError('JSON decode error: %s' % (str(exc),)) 26 | 27 | 28 | class JSONField(models.TextField): 29 | def formfield(self, **kwargs): 30 | return super().formfield(form_class=JSONFormField, **kwargs) 31 | 32 | def to_python(self, value): 33 | if isinstance(value, str): 34 | value = json.loads(value) 35 | return value 36 | 37 | def from_db_value(self, value, expression, connection): 38 | if isinstance(value, str): 39 | value = json.loads(value) 40 | return value 41 | 42 | def get_db_prep_save(self, value, connection=None): 43 | if value is not None: 44 | return json.dumps(value) 45 | 46 | def value_to_string(self, obj): 47 | value = self._get_val_from_obj(obj) 48 | return self.get_db_prep_value(value) 49 | -------------------------------------------------------------------------------- /campaign/forms.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | from django.contrib.contenttypes.models import ContentType 3 | from django.core.exceptions import FieldDoesNotExist, FieldError 4 | from django.utils.module_loading import import_string 5 | from django.utils.translation import gettext as _ 6 | 7 | from campaign.models import SubscriberList 8 | 9 | 10 | class SubscribeForm(forms.Form): 11 | email = forms.EmailField() 12 | 13 | 14 | class UnsubscribeForm(forms.Form): 15 | email = forms.EmailField() 16 | 17 | 18 | class SubscriberListForm(forms.ModelForm): 19 | class Meta: 20 | model = SubscriberList 21 | exclude = () 22 | 23 | def clean(self): 24 | super().clean() 25 | 26 | content_type = self.cleaned_data.get("content_type") 27 | email_field_name = self.cleaned_data.get("email_field_name") 28 | filter_condition = self.cleaned_data.get("filter_condition") 29 | custom_list = self.cleaned_data.get("custom_list") 30 | 31 | if not custom_list and not all([content_type, filter_condition]): 32 | self.add_error(None, _("Either custom_list or content_type and filter_condition must be set")) 33 | 34 | if custom_list: # Check if the defined module does exist and can be initialized, if not throw a hard exception instead of only adding an error 35 | import_string(custom_list)() 36 | else: 37 | Model = content_type.model_class() 38 | 39 | try: 40 | Model._meta.get_field(email_field_name) 41 | except FieldDoesNotExist: 42 | self.add_error( 43 | "email_field_name", 44 | _("Field not found on selected %s") % ContentType._meta.verbose_name 45 | ) 46 | 47 | try: 48 | Model._default_manager.filter(**{str(k): v for k, v in filter_condition.items()}) 49 | except (FieldError, AttributeError): 50 | self.add_error( 51 | "filter_condition", 52 | _("Could not query %s with this filter") % ContentType._meta.verbose_name 53 | ) 54 | -------------------------------------------------------------------------------- /campaign/management/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arneb/django-campaign/a861c8cb7384b00c30c84600e3f523e624a02dae/campaign/management/__init__.py -------------------------------------------------------------------------------- /campaign/management/commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arneb/django-campaign/a861c8cb7384b00c30c84600e3f523e624a02dae/campaign/management/commands/__init__.py -------------------------------------------------------------------------------- /campaign/management/commands/fetch_mailgun_rejects.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import requests 3 | from django.conf import settings 4 | from django.core.management.base import BaseCommand, CommandError 5 | from campaign.models import BlacklistEntry 6 | 7 | logger = logging.getLogger('django.campaign') 8 | 9 | 10 | class Command(BaseCommand): 11 | """ 12 | This command fetches all bounces from the Mailgun API and stores them 13 | in the local blacklist. This ensures that campaign doesn't send a mail to a 14 | bad recipient address more than once and that helps improving 15 | the deliverability rate. 16 | 17 | This backend assumes, that your Mailgun API-Key is configured in:: 18 | 19 | settings.MAILGUN_API_KEY 20 | 21 | Additionally you need to set all domains, for which bounces should 22 | be collected in:: 23 | 24 | settings.MAILGUN_DOMAINS 25 | 26 | """ 27 | help = "fetch bounces from mailgun and store in local blacklist" 28 | 29 | def handle(self, *args, **options): 30 | processed = 0 31 | valid = 0 32 | domain_list = getattr(settings, 'MAILGUN_DOMAINS', []) 33 | 34 | if not len(domain_list): 35 | raise CommandError('you need to confgure the MAILGUN_DOMAINS setting') 36 | 37 | for domain in domain_list: 38 | try: 39 | api_url = getattr(settings, 'MAILGUN_API_URL', 'https://api.mailgun.net/v3/%s/bounces') % domain 40 | x, y = self._fetch_rejects(api_url) 41 | processed += x 42 | valid += y 43 | except Exception as e: 44 | logger.error('Mailgun error: %s - %s' % (e.__class__, e)) 45 | raise CommandError(e) 46 | 47 | self.stdout.write("processed: %s" % processed) 48 | self.stdout.write("valid: %s" % valid) 49 | 50 | def _fetch_rejects(self, url): 51 | processed = 0 52 | valid = 0 53 | 54 | auth = ("api", settings.MAILGUN_API_KEY) 55 | response = requests.get(url, auth=auth) 56 | 57 | if response.status_code == 200: 58 | for reject in response.json()['items']: 59 | processed += 1 60 | if int(reject['code']) >= 500: 61 | valid += 1 62 | BlacklistEntry.objects.get_or_create( 63 | email=reject['address'], 64 | defaults={'reason': reject['error']} 65 | ) 66 | else: 67 | logger.warning('Mailgun response: %s' % result.status_code) 68 | 69 | pagination = response.json()["paging"] 70 | if pagination.get("next") != pagination.get("previous"): 71 | x, y = self._fetch_rejects(pagination.get("next")) 72 | processed += x 73 | valid += y 74 | 75 | return processed, valid 76 | -------------------------------------------------------------------------------- /campaign/management/commands/fetch_mandrill_rejects.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import mandrill 3 | from django.conf import settings 4 | from django.core.management.base import NoArgsCommand 5 | from campaign.models import BlacklistEntry 6 | 7 | logger = logging.getLogger('django.campaign') 8 | 9 | 10 | class Command(NoArgsCommand): 11 | """ 12 | This command fetches all rejects from the Mandrill API and stores them 13 | in the local blacklist. This ensures that campaign doesn't send a mail to a 14 | bad recipient address more than once and that helps improving 15 | the deliverability rate. 16 | 17 | This command assumes that your Mandrill API-Key is configured in:: 18 | 19 | settings.MANDRILL_API_KEY 20 | 21 | """ 22 | help = "fetch rejects from mandrill and store in local blacklist" 23 | 24 | def handle_noargs(self, **options): 25 | try: 26 | mandrill_client = mandrill.Mandrill(settings.MANDRILL_API_KEY) 27 | rejects = mandrill_client.rejects.list(include_expired=True) 28 | 29 | processed = 0 30 | valid = 0 31 | for reject in rejects: 32 | processed += 1 33 | if reject['reason'] in ('hard-bounce', 'spam', 'unsub'): 34 | defaults = {'reason': "%s: %s" % (reject['reason'], 35 | reject['detail'])} 36 | valid += 1 37 | BlacklistEntry.objects.get_or_create(email=reject['email'], 38 | defaults=defaults) 39 | 40 | except mandrill.Error as e: 41 | logger.error('Mandrill error: %s - %s' % (e.__class__, e)) 42 | raise e 43 | 44 | logger.info("processed: %s" % processed) 45 | logger.info("valid: %s" % valid) 46 | -------------------------------------------------------------------------------- /campaign/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models, migrations 5 | import django.utils.timezone 6 | import campaign.fields 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | dependencies = [ 12 | ('contenttypes', '__first__'), 13 | ] 14 | 15 | operations = [ 16 | migrations.CreateModel( 17 | name='BlacklistEntry', 18 | fields=[ 19 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), 20 | ('email', models.EmailField(max_length=75)), 21 | ('added', models.DateTimeField(default=django.utils.timezone.now, editable=False)), 22 | ], 23 | options={ 24 | 'ordering': ('-added',), 25 | 'verbose_name': 'blacklist entry', 26 | 'verbose_name_plural': 'blacklist entries', 27 | }, 28 | bases=(models.Model,), 29 | ), 30 | migrations.CreateModel( 31 | name='MailTemplate', 32 | fields=[ 33 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), 34 | ('name', models.CharField(max_length=255, verbose_name='Name')), 35 | ('plain', models.TextField(verbose_name='Plaintext Body')), 36 | ('html', models.TextField(null=True, verbose_name='HTML Body', blank=True)), 37 | ('subject', models.CharField(max_length=255, verbose_name='Subject')), 38 | ], 39 | options={ 40 | 'ordering': ('name',), 41 | 'verbose_name': 'mail template', 42 | 'verbose_name_plural': 'mail templates', 43 | }, 44 | bases=(models.Model,), 45 | ), 46 | migrations.CreateModel( 47 | name='Newsletter', 48 | fields=[ 49 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), 50 | ('name', models.CharField(max_length=255, verbose_name='Name')), 51 | ('description', models.TextField(null=True, verbose_name='Description', blank=True)), 52 | ], 53 | options={ 54 | 'ordering': ('name',), 55 | 'verbose_name': 'newsletter', 56 | 'verbose_name_plural': 'newsletters', 57 | }, 58 | bases=(models.Model,), 59 | ), 60 | migrations.CreateModel( 61 | name='BounceEntry', 62 | fields=[ 63 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), 64 | ('email', models.CharField(max_length=255, null=True, verbose_name='recipient', blank=True)), 65 | ('exception', models.TextField(null=True, verbose_name='exception', blank=True)), 66 | ], 67 | options={ 68 | 'ordering': ('email',), 69 | 'verbose_name': 'bounce entry', 70 | 'verbose_name_plural': 'bounce entries', 71 | }, 72 | bases=(models.Model,), 73 | ), 74 | migrations.CreateModel( 75 | name='SubscriberList', 76 | fields=[ 77 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), 78 | ('name', models.CharField(max_length=255, verbose_name='Name')), 79 | ('content_type', models.ForeignKey(to='contenttypes.ContentType', to_field='id', on_delete=models.CASCADE)), 80 | ('filter_condition', campaign.fields.JSONField(default='{}', help_text='Django ORM compatible lookup kwargs which are used to get the list of objects.')), 81 | ('email_field_name', models.CharField(help_text='Name of the model field which stores the recipients email address', max_length=64, verbose_name='Email-Field name')), 82 | ], 83 | options={ 84 | 'ordering': ('name',), 85 | 'verbose_name': 'subscriber list', 86 | 'verbose_name_plural': 'subscriber lists', 87 | }, 88 | bases=(models.Model,), 89 | ), 90 | migrations.CreateModel( 91 | name='Campaign', 92 | fields=[ 93 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), 94 | ('name', models.CharField(max_length=255, verbose_name='Name')), 95 | ('newsletter', models.ForeignKey(verbose_name='Newsletter', to_field='id', blank=True, to='campaign.Newsletter', null=True, on_delete=models.CASCADE)), 96 | ('template', models.ForeignKey(to='campaign.MailTemplate', to_field='id', verbose_name='Template', on_delete=models.CASCADE)), 97 | ('sent', models.BooleanField(default=False, verbose_name='sent out', editable=False)), 98 | ('sent_at', models.DateTimeField(null=True, verbose_name='sent at', blank=True)), 99 | ('online', models.BooleanField(default=True, help_text='make a copy available online', verbose_name='available online')), 100 | ('recipients', models.ManyToManyField(to='campaign.SubscriberList', verbose_name='Subscriber lists')), 101 | ], 102 | options={ 103 | 'ordering': ('name', 'sent'), 104 | 'verbose_name': 'campaign', 105 | 'verbose_name_plural': 'campaigns', 106 | }, 107 | bases=(models.Model,), 108 | ), 109 | ] 110 | -------------------------------------------------------------------------------- /campaign/migrations/0002_blacklistentry_reason.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models, migrations 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('campaign', '0001_initial'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AddField( 15 | model_name='blacklistentry', 16 | name='reason', 17 | field=models.TextField(null=True, verbose_name='reason', blank=True), 18 | preserve_default=True, 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /campaign/migrations/0003_delete_bounceentry.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models, migrations 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('campaign', '0002_blacklistentry_reason'), 11 | ] 12 | 13 | operations = [ 14 | migrations.DeleteModel( 15 | name='BounceEntry', 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /campaign/migrations/0004_newsletter_from_email.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models, migrations 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('campaign', '0003_delete_bounceentry'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AddField( 15 | model_name='newsletter', 16 | name='from_email', 17 | field=models.EmailField(max_length=75, null=True, verbose_name='Sending Address', blank=True), 18 | preserve_default=True, 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /campaign/migrations/0005_newsletter_from_name.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models, migrations 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('campaign', '0004_newsletter_from_email'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AddField( 15 | model_name='newsletter', 16 | name='from_name', 17 | field=models.CharField(max_length=255, null=True, verbose_name='Sender Name', blank=True), 18 | preserve_default=True, 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /campaign/migrations/0006_campaign_send_permission.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import migrations 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('campaign', '0005_newsletter_from_name'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterModelOptions( 15 | name='campaign', 16 | options={'ordering': ('name', 'sent'), 'verbose_name': 'campaign', 'verbose_name_plural': 'campaigns', 'permissions': (('send_campaign', 'Can send campaign'),)}, 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /campaign/migrations/0007_auto_20190806_1020.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.4 on 2019-08-06 10:20 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('campaign', '0006_campaign_send_permission'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='blacklistentry', 15 | name='email', 16 | field=models.EmailField(max_length=254), 17 | ), 18 | migrations.AlterField( 19 | model_name='campaign', 20 | name='online', 21 | field=models.BooleanField(blank=True, default=True, help_text='make a copy available online', verbose_name='available online'), 22 | ), 23 | migrations.AlterField( 24 | model_name='newsletter', 25 | name='from_email', 26 | field=models.EmailField(blank=True, max_length=254, null=True, verbose_name='Sending Address'), 27 | ), 28 | ] 29 | -------------------------------------------------------------------------------- /campaign/migrations/0008_auto_20220907_2025.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.17 on 2022-09-07 20:25 2 | 3 | import campaign.fields 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('campaign', '0007_auto_20190806_1020'), 12 | ] 13 | 14 | operations = [ 15 | migrations.AddField( 16 | model_name='subscriberlist', 17 | name='custom_list', 18 | field=models.CharField(blank=True, max_length=255, null=True), 19 | ), 20 | migrations.AlterField( 21 | model_name='subscriberlist', 22 | name='content_type', 23 | field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType', verbose_name='content type'), 24 | ), 25 | migrations.AlterField( 26 | model_name='subscriberlist', 27 | name='filter_condition', 28 | field=campaign.fields.JSONField(blank=True, default='{}', help_text='Django ORM compatible lookup kwargs which are used to get the list of objects.', null=True), 29 | ), 30 | ] 31 | -------------------------------------------------------------------------------- /campaign/migrations/0009_newsletter_default_newsletter_site.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.0.10 on 2023-08-14 18:57 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('sites', '0002_alter_domain_unique'), 11 | ('campaign', '0008_auto_20220907_2025.py'), 12 | ] 13 | 14 | operations = [ 15 | migrations.AddField( 16 | model_name='newsletter', 17 | name='default', 18 | field=models.BooleanField(default=False, verbose_name='Default'), 19 | ), 20 | migrations.AddField( 21 | model_name='newsletter', 22 | name='site', 23 | field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='sites.site', verbose_name='Site'), 24 | ), 25 | ] 26 | -------------------------------------------------------------------------------- /campaign/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arneb/django-campaign/a861c8cb7384b00c30c84600e3f523e624a02dae/campaign/migrations/__init__.py -------------------------------------------------------------------------------- /campaign/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.utils.translation import gettext_lazy as _ 3 | from django.utils import timezone 4 | from django.contrib.contenttypes.models import ContentType 5 | from django.contrib.sites.models import Site 6 | from django.conf import settings 7 | from django.utils.module_loading import import_string 8 | 9 | from campaign.fields import JSONField 10 | from campaign.backends import get_backend 11 | from campaign.signals import campaign_sent 12 | 13 | 14 | class Newsletter(models.Model): 15 | """ 16 | Represents a recurring newsletter which users can subscribe to. 17 | 18 | """ 19 | name = models.CharField(_("Name"), max_length=255) 20 | description = models.TextField(_("Description"), blank=True, null=True) 21 | from_email = models.EmailField(_("Sending Address"), blank=True, null=True) 22 | from_name = models.CharField(_("Sender Name"), max_length=255, blank=True, null=True) 23 | site = models.ForeignKey(Site, verbose_name=_("Site"), on_delete=models.SET_NULL, blank=True, null=True) 24 | default = models.BooleanField(_("Default"), default=False) 25 | 26 | def __str__(self): 27 | return self.name 28 | 29 | class Meta: 30 | verbose_name = _("newsletter") 31 | verbose_name_plural = _("newsletters") 32 | ordering = ('name',) 33 | 34 | 35 | class MailTemplate(models.Model): 36 | """ 37 | Holds a template for the email. Both, HTML and plaintext, versions 38 | can be stored. If both are present the email will be send out as HTML 39 | with an alternate plain part. If only plaintext is entered, the email will 40 | be send as text-only. HTML-only emails are currently not supported because 41 | I don't like them. 42 | 43 | """ 44 | name = models.CharField(_("Name"), max_length=255) 45 | plain = models.TextField(_("Plaintext Body")) 46 | html = models.TextField(_("HTML Body"), blank=True, null=True) 47 | subject = models.CharField(_("Subject"), max_length=255) 48 | 49 | def __str__(self): 50 | return self.name 51 | 52 | class Meta: 53 | verbose_name = _("mail template") 54 | verbose_name_plural = _("mail templates") 55 | ordering = ('name',) 56 | 57 | 58 | class SubscriberList(models.Model): 59 | """ 60 | A pointer to another Django model which holds the subscribers. 61 | 62 | """ 63 | name = models.CharField(_("Name"), max_length=255) 64 | content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE, verbose_name=ContentType._meta.verbose_name, null=True, blank=True) 65 | filter_condition = JSONField(default="{}", help_text=_("Django ORM compatible lookup kwargs which are used to get the list of objects."), null=True, blank=True) 66 | email_field_name = models.CharField(_("Email-Field name"), max_length=64, help_text=_("Name of the model field which stores the recipients email address")) 67 | custom_list = models.CharField(max_length=255, choices=getattr(settings, 'CAMPAIGN_CUSTOM_SUBSCRIBER_LISTS', []), null=True, blank=True) 68 | 69 | def __str__(self): 70 | return self.name 71 | 72 | def _get_filter(self): 73 | # simplejson likes to put unicode objects as dictionary keys 74 | # but keyword arguments must be str type 75 | fc = {} 76 | for k, v in self.filter_condition.items(): 77 | fc.update({str(k): v}) 78 | return fc 79 | 80 | def object_list(self): 81 | if self.custom_list: 82 | return import_string(self.custom_list)().object_list() 83 | else: 84 | return self.content_type.model_class()._default_manager.filter( 85 | **self._get_filter()).distinct() 86 | 87 | def object_count(self): 88 | if self.custom_list: 89 | return import_string(self.custom_list)().object_count() 90 | else: 91 | return self.object_list().count() 92 | 93 | 94 | class Meta: 95 | verbose_name = _("subscriber list") 96 | verbose_name_plural = _("subscriber lists") 97 | ordering = ('name',) 98 | 99 | 100 | class Campaign(models.Model): 101 | """ 102 | A Campaign is the central part of this app. Once a Campaign is created, 103 | has a MailTemplate and one or more SubscriberLists, it can be send out. 104 | Most of the time of Campain will have a one-to-one relationship with a 105 | MailTemplate, but templates may be reused in other Campaigns and maybe 106 | Campaigns will have support for multiple templates in the future, therefore 107 | the distinction. 108 | A Campaign optionally belongs to a Newsletter. 109 | 110 | """ 111 | name = models.CharField(_("Name"), max_length=255) 112 | newsletter = models.ForeignKey(Newsletter, verbose_name=_("Newsletter"), blank=True, null=True, on_delete=models.CASCADE) 113 | template = models.ForeignKey(MailTemplate, verbose_name=_("Template"), on_delete=models.CASCADE) 114 | recipients = models.ManyToManyField(SubscriberList, verbose_name=_("Subscriber lists")) 115 | sent = models.BooleanField(_("sent out"), default=False, editable=False) 116 | sent_at = models.DateTimeField(_("sent at"), blank=True, null=True) 117 | online = models.BooleanField(_("available online"), default=True, blank=True, help_text=_("make a copy available online")) 118 | 119 | def __str__(self): 120 | return self.name 121 | 122 | def send(self, subscriber_lists=None): 123 | """ 124 | Sends the mails to the recipients. 125 | """ 126 | backend = get_backend() 127 | num_sent = backend.send_campaign(self, subscriber_lists=subscriber_lists) 128 | self.sent = True 129 | self.sent_at = timezone.now() 130 | self.save() 131 | campaign_sent.send(sender=self.__class__, campaign=self) 132 | return num_sent 133 | 134 | class Meta: 135 | verbose_name = _("campaign") 136 | verbose_name_plural = _("campaigns") 137 | ordering = ('name', 'sent') 138 | permissions = ( 139 | ("send_campaign", _("Can send campaign")), 140 | ) 141 | 142 | 143 | class BlacklistEntry(models.Model): 144 | """ 145 | If a user has requested removal from the subscriber-list, he is added 146 | to the blacklist to prevent accidential adding of the same user again 147 | on subsequent imports from a data source. 148 | """ 149 | email = models.EmailField() 150 | added = models.DateTimeField(default=timezone.now, editable=False) 151 | reason = models.TextField(_("reason"), blank=True, null=True) 152 | 153 | def __str__(self): 154 | return self.email 155 | 156 | class Meta: 157 | verbose_name = _("blacklist entry") 158 | verbose_name_plural = _("blacklist entries") 159 | ordering = ('-added',) 160 | -------------------------------------------------------------------------------- /campaign/signals.py: -------------------------------------------------------------------------------- 1 | from django.dispatch import Signal 2 | 3 | # "campaign" Signal 4 | campaign_sent = Signal() 5 | -------------------------------------------------------------------------------- /campaign/templates/admin/campaign/blacklistentry/change_list.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/change_list.html" %} 2 | {% load i18n %} 3 | 4 | {% block object-tools-items %} 5 |
  • 6 | {% trans "Fetch and add Mandrill rejects" %} 7 |
  • 8 | {{ block.super }} 9 | {% endblock %} 10 | -------------------------------------------------------------------------------- /campaign/templates/admin/campaign/campaign/change_form.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/change_form.html" %} 2 | {% load i18n admin_urls %} 3 | 4 | {% block object-tools-items %} 5 | {{ block.super }} 6 |
  • {% blocktrans with verbose_name=opts.verbose_name %}Send {{ verbose_name }} {% endblocktrans %}
  • 7 | {% endblock %} 8 | -------------------------------------------------------------------------------- /campaign/templates/admin/campaign/campaign/send_object.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/base_site.html" %} 2 | {% load i18n %} 3 | 4 | {% block extrahead %}{{ block.super }} 5 | {{ media }} 6 | {% endblock %} 7 | 8 | {% block breadcrumbs %} 9 | 16 | {% endblock %} 17 | 18 | {% block content %} 19 |
    20 | 21 |
    {% csrf_token %} 22 |
    23 |
    24 |
    25 | {% if object.sent %} 26 |
    {% trans "This campaign has already been sent at least once. Are you sure that you want to send it again?" %}
    27 | {% endif %} 28 |

    {% blocktrans with opts.verbose_name|capfirst as model_name and object|truncatewords:"18" as object_name %} 29 | Clicking 'Send' below will send the {{ object_name }} {{ model_name }} to its recipients. 30 | {% endblocktrans %}

    31 |

    {% trans "Subscriber lists" %}

    32 |
      33 | {% for subscriber_list in object.recipients.all %} 34 | {% with subscriber_list.object_count as subscribercount %} 35 |
    • {{ subscriber_list.name }} – {% blocktrans with subscribercount as subscriber_count and subscribercount|pluralize as pluralized %}{{ subscriber_count }} recipient{{ pluralized }}{% endblocktrans %}
    • 36 | {% endwith %} 37 | {% endfor %} 38 |
    39 |
    40 |
    41 | 42 |
    43 | 44 |

    45 |
    46 | 47 |
    48 |
    49 |
    50 | {% endblock %} 51 | -------------------------------------------------------------------------------- /campaign/templates/admin/campaign/subscriberlist/change_form.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/change_form.html" %} 2 | {% load i18n admin_urls %} 3 | 4 | {% block object-tools-items %} 5 | {{ block.super }} 6 |
  • {% blocktrans with verbose_name=opts.verbose_name %}Preview {{ verbose_name }} {% endblocktrans %}
  • 7 | {% endblock %} 8 | -------------------------------------------------------------------------------- /campaign/templates/admin/campaign/subscriberlist/preview.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/base_site.html" %} 2 | {% load i18n %} 3 | {% load campaign_tags %} 4 | 5 | {% block extrahead %}{{ block.super }} 6 | {{ media }} 7 | {% endblock %} 8 | 9 | {% block breadcrumbs %} 10 | 17 | {% endblock %} 18 | 19 | {% block content %} 20 |
    21 |
    22 |
    23 |
    24 |

    {{ object.name }} ({% blocktrans with object.object_count as count and object.object_count|pluralize as pluralized %}{{ count }} recipient{{ pluralized }}{% endblocktrans %})

    25 |
      26 | {% for subscriber in object.object_list.all %} 27 |
    • {{ subscriber|get_field:object.email_field_name }}
    • 28 | {% endfor %} 29 |
    30 |
    31 |
    32 |
    33 |
    34 | {% endblock %} 35 | -------------------------------------------------------------------------------- /campaign/templates/campaign/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | django.campaign 4 | 5 | 6 | {% block content %} 7 | {% endblock %} 8 | 9 | -------------------------------------------------------------------------------- /campaign/templates/campaign/subscribe.html: -------------------------------------------------------------------------------- 1 | {% extends "campaign/base.html" %} 2 | {% load i18n %} 3 | 4 | {% block content %} 5 |

    {% trans "Subscribe to our newsletter" %}

    6 | {% ifequal action "subscribe" %} 7 | {% if success %} 8 |

    {% trans "You successfully subscribed to our newsletter." %}

    9 | {% else %} 10 |

    {% trans "Something went wrong and we could not subscribe you." %}

    11 | {% endif %} 12 | {% endifequal %} 13 |
    14 | {{ form.as_p }} 15 |

    16 |
    17 | {% endblock %} -------------------------------------------------------------------------------- /campaign/templates/campaign/unsubscribe.html: -------------------------------------------------------------------------------- 1 | {% extends "campaign/base.html" %} 2 | {% load i18n %} 3 | 4 | {% block content %} 5 |

    {% trans "Unsubscribe from our newsletter" %}

    6 | {% ifequal action "unsubscribe" %} 7 | {% if success %} 8 |

    {% trans "You successfully unscuscribed from our newsletter." %}

    9 | {% else %} 10 |

    {% trans "Something went wrong and we could not unsubscribe you." %}

    11 | {% endif %} 12 | {% endifequal %} 13 |
    14 | {{ form.as_p }} 15 |

    16 |
    17 | {% endblock %} -------------------------------------------------------------------------------- /campaign/templatetags/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arneb/django-campaign/a861c8cb7384b00c30c84600e3f523e624a02dae/campaign/templatetags/__init__.py -------------------------------------------------------------------------------- /campaign/templatetags/campaign_tags.py: -------------------------------------------------------------------------------- 1 | from django.template import Library 2 | 3 | 4 | register = Library() 5 | 6 | 7 | @register.filter 8 | def get_field(val, arg): 9 | return getattr(val, arg) 10 | -------------------------------------------------------------------------------- /campaign/tests.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import MagicMock 2 | 3 | from django.contrib.auth import get_user_model 4 | from django.contrib.contenttypes.models import ContentType 5 | try: 6 | from django.core.urlresolvers import reverse 7 | except ImportError: 8 | from django.urls import reverse 9 | from django.test import TestCase 10 | 11 | from campaign.forms import SubscriberListForm 12 | from campaign.models import ( 13 | BlacklistEntry, Campaign, MailTemplate, Newsletter, SubscriberList 14 | ) 15 | from campaign.signals import campaign_sent 16 | 17 | 18 | User = get_user_model() 19 | 20 | 21 | class SubscriberlistFormTestCase(TestCase): 22 | def test_all_fields_validation_valid(self): 23 | data = { 24 | "name": "Test list", 25 | "content_type": ContentType.objects.get_for_model(User).pk, 26 | "filter_condition": '{"is_active": true}', 27 | "email_field_name": "email" 28 | } 29 | form = SubscriberListForm(data) 30 | self.assertTrue(form.is_valid()) 31 | 32 | def test_email_field_name_validation_not_valid(self): 33 | data = { 34 | "name": "Test list", 35 | "content_type": ContentType.objects.get_for_model(User).pk, 36 | "filter_condition": '{"is_active": true}', 37 | "email_field_name": "foo" 38 | } 39 | form = SubscriberListForm(data) 40 | self.assertFalse(form.is_valid()) 41 | self.assertTrue("email_field_name" in form.errors) 42 | 43 | def test_filter_condition_validation_not_valid(self): 44 | data = { 45 | "name": "Test list", 46 | "content_type": ContentType.objects.get_for_model(User).pk, 47 | "filter_condition": '{"active": "foo"}', 48 | "email_field_name": "email" 49 | } 50 | form = SubscriberListForm(data) 51 | self.assertFalse(form.is_valid()) 52 | self.assertTrue("filter_condition" in form.errors) 53 | 54 | 55 | class AdminTestCase(TestCase): 56 | def setUp(self): 57 | user = User.objects.create_superuser("test", "test@test.com", "p") 58 | self.client.force_login(user) 59 | 60 | def test_changelists(self): 61 | for model in (BlacklistEntry, Campaign, MailTemplate, Newsletter, SubscriberList): 62 | response = self.client.get( 63 | reverse("admin:campaign_%s_changelist" % model._meta.model_name), 64 | follow=False, 65 | ) 66 | self.assertEqual(response.status_code, 200) 67 | content = response.content.decode("utf-8") 68 | self.assertTrue('
    [\d]+)/$', view_online, {}, name="campaign_view_online"), 8 | ] 9 | 10 | if getattr(settings, 'CAMPAIGN_SUBSCRIBE_CALLBACK', None): 11 | urlpatterns += [ 12 | re_path(r'^subscribe/$', subscribe, {}, name="campaign_subscribe"), 13 | ] 14 | 15 | if getattr(settings, 'CAMPAIGN_UNSUBSCRIBE_CALLBACK', None): 16 | urlpatterns += [ 17 | re_path(r'^unsubscribe/$', unsubscribe, {}, name="campaign_unsubscribe"), 18 | ] 19 | -------------------------------------------------------------------------------- /campaign/views.py: -------------------------------------------------------------------------------- 1 | from django import template, http 2 | from django.conf import settings 3 | from django.shortcuts import get_object_or_404, render 4 | from django.urls import reverse 5 | from django.contrib.sites.models import Site 6 | from django.core.exceptions import ImproperlyConfigured 7 | from campaign.models import Campaign, BlacklistEntry 8 | from campaign.forms import SubscribeForm, UnsubscribeForm 9 | 10 | 11 | def view_online(request, object_id): 12 | campaign = get_object_or_404(Campaign, pk=object_id, online=True) 13 | 14 | if campaign.template.html is not None and \ 15 | campaign.template.html != "" and \ 16 | not request.GET.get('txt', False): 17 | tpl = template.Template(campaign.template.html) 18 | content_type = 'text/html; charset=utf-8' 19 | else: 20 | tpl = template.Template(campaign.template.plain) 21 | content_type = 'text/plain; charset=utf-8' 22 | context = template.Context({}) 23 | if campaign.online: 24 | context.update({'view_online_url': reverse("campaign_view_online", kwargs={ 25 | 'object_id': campaign.pk}), 26 | 'viewed_online': True, 27 | 'site_url': Site.objects.get_current().domain}) 28 | return http.HttpResponse(tpl.render(context), 29 | content_type=content_type) 30 | 31 | 32 | def subscribe(request, template_name='campaign/subscribe.html', 33 | form_class=SubscribeForm, extra_context=None): 34 | context = extra_context or {} 35 | if request.method == 'POST': 36 | form = form_class(request.POST) 37 | if form.is_valid(): 38 | callback = _get_callback('CAMPAIGN_SUBSCRIBE_CALLBACK') 39 | if callback: 40 | success = callback(form.cleaned_data['email']) 41 | context.update({'success': success, 'action': 'subscribe'}) 42 | else: 43 | raise ImproperlyConfigured("CAMPAIGN_SUBSCRIBE_CALLBACK must be configured to use the subscribe view") 44 | else: 45 | form = form_class() 46 | context.update({'form': form}) 47 | return render(request, template_name, context) 48 | 49 | 50 | def unsubscribe(request, template_name='campaign/unsubscribe.html', 51 | form_class=UnsubscribeForm, extra_context=None): 52 | context = extra_context or {} 53 | if request.method == 'POST': 54 | form = form_class(request.POST) 55 | if form.is_valid(): 56 | callback = _get_callback('CAMPAIGN_UNSUBSCRIBE_CALLBACK') 57 | if callback: 58 | success = callback(form.cleaned_data['email']) 59 | context.update({'success': success, 'action': 'unsubscribe'}) 60 | else: 61 | raise ImproperlyConfigured("CAMPAIGN_UNSUBSCRIBE_CALLBACK must be configured to use the unsubscribe view") 62 | else: 63 | initial = {} 64 | if request.GET.get('email'): 65 | initial['email'] = request.GET.get('email') 66 | form = form_class(initial=initial) 67 | context.update({'form': form}) 68 | return render(request, template_name, context) 69 | 70 | 71 | def _get_callback(setting): 72 | callback = getattr(settings, setting, None) 73 | if callback is None: 74 | return None 75 | if callable(callback): 76 | return callback 77 | else: 78 | mod, name = callback.rsplit('.', 1) 79 | module = __import__(mod, {}, {}, ['']) 80 | return getattr(module, name) 81 | -------------------------------------------------------------------------------- /docs/backends.txt: -------------------------------------------------------------------------------- 1 | .. _ref-backends: 2 | 3 | ================= 4 | Backends 5 | ================= 6 | 7 | To decouple the actual sending of the e-mails from the application logic 8 | and therefore make django-campaign more scaleable version 0.2 introduces a 9 | concept of backends which encapsulate the whole process of sending the e-mails. 10 | 11 | 12 | Writing your own Backend 13 | ------------------------ 14 | 15 | Backends for django-campaign must adhere to the following API. Some of the 16 | methods are mandatory and some are optional, especially if you inherit from 17 | the ``base`` backend. 18 | 19 | The basic structure of a backend is as follows:: 20 | 21 | from campaign.backends.base import BaseBackend 22 | 23 | class ExampleBackend(BaseBackend): 24 | def __init__(self): 25 | # do some setup here, e.g. processing your own settings 26 | 27 | ... 28 | 29 | backend = ExampleBackend() 30 | 31 | If this code is stored in a file ``myproject/myapp/example.py`` then the 32 | setting to use this backend would be:: 33 | 34 | CAMPAIGN_BACKEND = 'myproject.myapp.example' 35 | 36 | Each backend must define a ``backend`` variable, which should be an instance 37 | of the backend. 38 | 39 | 40 | Backend Methods 41 | --------------- 42 | 43 | The following methods must be implemented by every backend: 44 | 45 | ``send_mail(self, email, fail_silently=False)`` 46 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 47 | 48 | This method takes an instance of ``django.core.mail.EmailMessage`` as argument 49 | and is responsible for whatever is needed to send this email to the recipient. 50 | 51 | The ``fail_silently`` argument specifies whether exceptions should bubble up or 52 | should be hidden from upper layers. 53 | 54 | -------------------------------------------------------------------------------- /docs/concepts.txt: -------------------------------------------------------------------------------- 1 | .. _ref-concepts: 2 | 3 | ========================================================= 4 | Philisophy behind some of the concepts of django-campaign 5 | ========================================================= 6 | 7 | Why is there no Subscriber-Model? 8 | --------------------------------- 9 | 10 | I've tried to use a bunch of different Subscriber-Models bundled with the app 11 | itself but none of them was usable for more than one use-case so I decided 12 | to drop the concept of a Subscriber-Model and instead added a mechanism for 13 | you to hook your own Subscriber (or User or whatever) model into the flow. 14 | 15 | By adding a SubscriberList Object with a pointer to the ContentType of your 16 | Model and by optionally adding lookup kwargs to narrow the selection you can 17 | specifiy which objects of your model class for a list of subscribers. You 18 | can even build SubscriberLists for different Models and send a Campaign in 19 | one step to multiple SubscriberLists. 20 | 21 | Adding a SubscriberList for all active Users present in the django.contrib.auth 22 | module one would simply add a SubscriberList object:: 23 | 24 | from django.contrib.auth.models import User 25 | from django.contrib.contenttypes.models import ContentType 26 | from campaing.models import SubscriberList 27 | 28 | obj = SubscriberList.objects.create( 29 | content_type=ContentType.objects.get_for_model(User), 30 | filter_condition={'is_active': True} 31 | ) 32 | 33 | Of course this can also be done using Django's built-in admin interface. Simply 34 | select Content type ``user`` and Filter condition ``{"is_active": true}``. 35 | 36 | Being able to add any number and combinations of ContentTypes and lookup kwargs 37 | and assining one or multiple SubscriberLists to a Campaign one should be able 38 | to map any real-world scenario to the workflow. If a subscriber is present in 39 | multiple SubscriberLists this is not a problem because the code makes sure 40 | that every Campaign is only sent once to every given email address. 41 | 42 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # django-campaign documentation build configuration file, created by 4 | # sphinx-quickstart on Thu Oct 15 12:48:22 2009. 5 | # 6 | # This file is execfile()d with the current directory set to its containing dir. 7 | # 8 | # Note that not all possible configuration values are present in this 9 | # autogenerated file. 10 | # 11 | # All configuration values have a default; values that are commented out 12 | # serve to show the default. 13 | 14 | import sys, os 15 | from os.path import abspath, dirname, join 16 | sys.path.insert(1, dirname(dirname(abspath(__file__)))) 17 | 18 | # If extensions (or modules to document with autodoc) are in another directory, 19 | # add these directories to sys.path here. If the directory is relative to the 20 | # documentation root, use os.path.abspath to make it absolute, like shown here. 21 | #sys.path.append(os.path.abspath('.')) 22 | 23 | # -- General configuration ----------------------------------------------------- 24 | 25 | # Add any Sphinx extension module names here, as strings. They can be extensions 26 | # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. 27 | extensions = [] 28 | 29 | # Add any paths that contain templates here, relative to this directory. 30 | templates_path = ['_templates'] 31 | 32 | # The suffix of source filenames. 33 | source_suffix = '.txt' 34 | 35 | # The encoding of source files. 36 | #source_encoding = 'utf-8' 37 | 38 | # The master toctree document. 39 | master_doc = 'index' 40 | 41 | # General information about the project. 42 | project = u'django-campaign' 43 | copyright = u'2014, Arne Brodowski' 44 | 45 | # The version info for the project you're documenting, acts as replacement for 46 | # |version| and |release|, also used in various other places throughout the 47 | # built documents. 48 | # 49 | # The short X.Y version. 50 | release = __import__('campaign').__version__ 51 | # The short X.Y version. 52 | version = ".".join(release.split('.')[:2]) 53 | 54 | # The language for content autogenerated by Sphinx. Refer to documentation 55 | # for a list of supported languages. 56 | #language = None 57 | 58 | # There are two options for replacing |today|: either, you set today to some 59 | # non-false value, then it is used: 60 | #today = '' 61 | # Else, today_fmt is used as the format for a strftime call. 62 | #today_fmt = '%B %d, %Y' 63 | 64 | # List of documents that shouldn't be included in the build. 65 | #unused_docs = [] 66 | 67 | # List of directories, relative to source directory, that shouldn't be searched 68 | # for source files. 69 | exclude_trees = ['_build'] 70 | 71 | # The reST default role (used for this markup: `text`) to use for all documents. 72 | #default_role = None 73 | 74 | # If true, '()' will be appended to :func: etc. cross-reference text. 75 | #add_function_parentheses = True 76 | 77 | # If true, the current module name will be prepended to all description 78 | # unit titles (such as .. function::). 79 | #add_module_names = True 80 | 81 | # If true, sectionauthor and moduleauthor directives will be shown in the 82 | # output. They are ignored by default. 83 | #show_authors = False 84 | 85 | # The name of the Pygments (syntax highlighting) style to use. 86 | pygments_style = 'sphinx' 87 | 88 | # A list of ignored prefixes for module index sorting. 89 | #modindex_common_prefix = [] 90 | 91 | 92 | # -- Options for HTML output --------------------------------------------------- 93 | 94 | # The theme to use for HTML and HTML Help pages. Major themes that come with 95 | # Sphinx are currently 'default' and 'sphinxdoc'. 96 | html_theme = 'default' 97 | 98 | # Theme options are theme-specific and customize the look and feel of a theme 99 | # further. For a list of options available for each theme, see the 100 | # documentation. 101 | #html_theme_options = {} 102 | 103 | # Add any paths that contain custom themes here, relative to this directory. 104 | #html_theme_path = [] 105 | 106 | # The name for this set of Sphinx documents. If None, it defaults to 107 | # " v documentation". 108 | #html_title = None 109 | 110 | # A shorter title for the navigation bar. Default is the same as html_title. 111 | #html_short_title = None 112 | 113 | # The name of an image file (relative to this directory) to place at the top 114 | # of the sidebar. 115 | #html_logo = None 116 | 117 | # The name of an image file (within the static path) to use as favicon of the 118 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 119 | # pixels large. 120 | #html_favicon = None 121 | 122 | # Add any paths that contain custom static files (such as style sheets) here, 123 | # relative to this directory. They are copied after the builtin static files, 124 | # so a file named "default.css" will overwrite the builtin "default.css". 125 | html_static_path = ['_static'] 126 | 127 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 128 | # using the given strftime format. 129 | #html_last_updated_fmt = '%b %d, %Y' 130 | 131 | # If true, SmartyPants will be used to convert quotes and dashes to 132 | # typographically correct entities. 133 | #html_use_smartypants = True 134 | 135 | # Custom sidebar templates, maps document names to template names. 136 | #html_sidebars = {} 137 | 138 | # Additional templates that should be rendered to pages, maps page names to 139 | # template names. 140 | #html_additional_pages = {} 141 | 142 | # If false, no module index is generated. 143 | #html_use_modindex = True 144 | 145 | # If false, no index is generated. 146 | #html_use_index = True 147 | 148 | # If true, the index is split into individual pages for each letter. 149 | #html_split_index = False 150 | 151 | # If true, links to the reST sources are added to the pages. 152 | #html_show_sourcelink = True 153 | 154 | # If true, an OpenSearch description file will be output, and all pages will 155 | # contain a tag referring to it. The value of this option must be the 156 | # base URL from which the finished HTML is served. 157 | #html_use_opensearch = '' 158 | 159 | # If nonempty, this is the file name suffix for HTML files (e.g. ".xhtml"). 160 | #html_file_suffix = '' 161 | 162 | # Output file base name for HTML help builder. 163 | htmlhelp_basename = 'django-campaigndoc' 164 | 165 | 166 | # -- Options for LaTeX output -------------------------------------------------- 167 | 168 | # The paper size ('letter' or 'a4'). 169 | #latex_paper_size = 'letter' 170 | 171 | # The font size ('10pt', '11pt' or '12pt'). 172 | #latex_font_size = '10pt' 173 | 174 | # Grouping the document tree into LaTeX files. List of tuples 175 | # (source start file, target name, title, author, documentclass [howto/manual]). 176 | latex_documents = [ 177 | ('index', 'django-campaign.tex', u'django-campaign Documentation', 178 | u'Arne Brodowski', 'manual'), 179 | ] 180 | 181 | # The name of an image file (relative to this directory) to place at the top of 182 | # the title page. 183 | #latex_logo = None 184 | 185 | # For "manual" documents, if this is true, then toplevel headings are parts, 186 | # not chapters. 187 | #latex_use_parts = False 188 | 189 | # Additional stuff for the LaTeX preamble. 190 | #latex_preamble = '' 191 | 192 | # Documents to append as an appendix to all manuals. 193 | #latex_appendices = [] 194 | 195 | # If false, no module index is generated. 196 | #latex_use_modindex = True 197 | -------------------------------------------------------------------------------- /docs/index.txt: -------------------------------------------------------------------------------- 1 | =============== 2 | django-campaign 3 | =============== 4 | 5 | **Newsletter and campaign management for the Django webframework.** 6 | 7 | Django-campaign is an application for the Django webframework to make 8 | sending out newsletters to one or more groups of subscribers easy. 9 | If you need to send newsletters to thousands of subscribers it is easy 10 | to integrate django-campaign with django-mailer or some email sending 11 | providers through their APIs. 12 | 13 | Some of the core features are: 14 | 15 | * Multipart Emails made easy - just add a plain-text *and* a html-template. 16 | * Full control over the Subscriber-Model and therefore the template context 17 | used to render the mails. 18 | * Add context processors to add whatever you need to a mail template based on 19 | the recipient. This makes it easy to personalize messages. 20 | * Allow viewing of the newsletters online and add a link to the web version 21 | to the outgoing emails. 22 | * simple and optional subscribe/unsubscribe handling 23 | 24 | 25 | Contents 26 | -------- 27 | 28 | .. toctree:: 29 | :maxdepth: 2 30 | 31 | install 32 | overview 33 | settings 34 | backends 35 | templates 36 | concepts 37 | 38 | -------------------------------------------------------------------------------- /docs/install.txt: -------------------------------------------------------------------------------- 1 | ============ 2 | Installation 3 | ============ 4 | 5 | Install django-campaign with ``easy_install`` or ``pip`` or directly from 6 | the GitHub repository. 7 | 8 | Then add ``campaign`` and 'django.contrib.sites' to your ``INSTALLED_APPS`` 9 | setting:: 10 | 11 | INSTALLED_APPS = ( 12 | ... 13 | 'django.contrib.sites', 14 | 'campaign', 15 | ... 16 | ) 17 | 18 | Add an entry to your URL-conf. Using ``campaign`` here is a matter of taste, 19 | feel free to mount the app under a different URL:: 20 | 21 | urlpatterns += patterns('', 22 | (r'^campaign/', include('campaign.urls')) 23 | ) 24 | 25 | 4. Then run ``manage.py migrate`` to create the neccessary database tables. 26 | -------------------------------------------------------------------------------- /docs/overview.txt: -------------------------------------------------------------------------------- 1 | ============================================ 2 | How to send Newsletters with django-campaign 3 | ============================================ 4 | 5 | Here is a very brief overview how to use django-campaign: 6 | 7 | * If you plan to send out different Newsletters to different SusbcriberLists, 8 | it might be a good idea to create a Newsletter objects through the Django 9 | Admin. 10 | 11 | * Setup one or more SubscriberList objects in the Django Admin interface or 12 | programmatically. 13 | 14 | * Create at least one MailTemplate object through the Django Admin or 15 | programmatically. 16 | 17 | * Create a Campaign object and assign the corresponding MailTemplate object, 18 | one or more SubscriberLists. 19 | 20 | * Send the Campaign through the Django Admin or programmatically. 21 | 22 | -------------------------------------------------------------------------------- /docs/settings.txt: -------------------------------------------------------------------------------- 1 | .. _ref-settings: 2 | 3 | ================== 4 | Available Settings 5 | ================== 6 | 7 | Here is a list of all available settings of django-campaign and their 8 | default values. All settings are prefixed with ``CAMPAIGN_``, although this 9 | is a bit verbose it helps to make it easy to identify these settings. 10 | 11 | 12 | CAMPAIGN_BACKEND 13 | ---------------- 14 | 15 | Default: ``'campaign.backends.send_mail'`` 16 | 17 | The backend used for the actual sending of the emails. The default backend 18 | ``campaign.backends.send_mail`` uses Django's built-in e-mail sending 19 | capabilities. 20 | 21 | Additionally the following backend is available: 22 | 23 | * ``'campaign.backends.mandrill_api'``: Uses Mandrill for sending the e-mails. 24 | 25 | Please see the :ref:`backend docs ` about implementing your 26 | own backend. 27 | 28 | 29 | CAMPAIGN_CONTEXT_PROCESSORS 30 | --------------------------- 31 | 32 | Default: ``('campaign.context_processors.recipient',)`` 33 | 34 | Similar to Django's Template Context Processors these are callables which take 35 | a Subscriber object as their argument and return a dictionary with items to 36 | add to the Template Context which is used to render the Mail. 37 | 38 | The following processors are availble within the django-campaign distribution: 39 | 40 | * ``recipient``: Implements the old default behaviour and adds the Subscriber 41 | object (a Django Model instance) to the mail context under the name 42 | `recipient`. 43 | 44 | * ``recipient_dict``: Serializes the Subscriber object to a dict before adding 45 | it to the context. This is neccesary, if you want to pass per recipient 46 | variables to a remote service. Use this a basis for your own campaign 47 | context processors. 48 | 49 | 50 | CAMPAIGN_SUBSCRIBE_CALLBACK 51 | --------------------------- 52 | 53 | Default: ``None`` 54 | 55 | If CAMPAIGN_SUBSCRIBE_CALLBACK is configured the handling of newsletter 56 | subscriptions via django-campaign will be enabled. 57 | 58 | You have to supply either a callable or an import-path to a callable, which 59 | accepts an email address as argument and returns either True or False to indicate 60 | if the action was performed successfully. 61 | 62 | Example settings.py:: 63 | 64 | CAMPAIGN_SUBSCRIBE_CALLBACK = "myproject.newsletter.utils.subscribe" 65 | 66 | Example implementation of the callback in your app:: 67 | 68 | def subscribe(email): 69 | s,c = Subscriber.objects.get_or_create(email=email, 70 | defaults={'newsletter': True}) 71 | return True 72 | 73 | It's up to you to decide where to store the Subscribers, as django-campaign is 74 | completely agnostic in this point. One or more Subscriber models can be 75 | defined via the admin interface for the :ref:`SubscriberList ` model. 76 | 77 | 78 | CAMPAIGN_UNSUBSCRIBE_CALLBACK 79 | ----------------------------- 80 | 81 | Default: ``None`` 82 | 83 | Please see CAMPAIGN_SUBSCRIBE_CALLBACK_ above and replace subscribe with 84 | unsubscribe. 85 | 86 | 87 | CAMPAIGN_CUSTOM_SUBSCRIBER_LISTS 88 | ----------------------------- 89 | 90 | Default: ``None`` 91 | 92 | SubscriberLists custom_list field uses set choices from CAMPAIGN_CUSTOM_SUBSCRIBER_LISTS as alternative way to declare subscribers. 93 | 94 | Example settings.py:: 95 | 96 | CAMPAIGN_CUSTOM_SUBSCRIBER_LISTS = [("myproject.newsletter.recipients.CustomRecipientList", "Custom recipient List"),] 97 | -------------------------------------------------------------------------------- /docs/templates.txt: -------------------------------------------------------------------------------- 1 | ========= 2 | Templates 3 | ========= 4 | 5 | 6 | E-Mail Templates 7 | ---------------- 8 | 9 | All e-mail templates are pure Django Templates, please see the `Django 10 | Template Documentation`_ for details. This document only contains some parts 11 | specific to django-campaign. 12 | 13 | 14 | .. _`Django Template Documentation`: http://docs.djangoproject.com/en/dev/topics/templates/ 15 | 16 | E-Mail Template Context 17 | ~~~~~~~~~~~~~~~~~~~~~~~ 18 | 19 | At the time the e-mail templates are rendered the following variables are 20 | available in the template context: 21 | 22 | * ``recipient`` - The object which receives the email. This can be whatever 23 | ContentType is specified in the SubscriberList that is currently processed. 24 | * ``recipient_email`` - The email address to which the current email is send. 25 | 26 | If the Campaign is marked for online viewing the context will also contain the 27 | following variables: 28 | 29 | * ``view_online_url`` - The URL at which the campaign can be seen online 30 | * ``viewed_online`` - If the campaign is viewed with a webbrowser this variable 31 | is ``True``, otherwise it is not present. This is usefull to hide the 'view online' 32 | links from if the campaign is viewed with a webbrowser. 33 | * ``site_url`` - The URL of the current django Site. See django.contrib.sites for 34 | more information. 35 | 36 | If any :ref:`CAMPAIGN_CONTEXT_PROCESSORS ` are defined 37 | their results are also available in the context at the time the email is sent. 38 | The results of the CAMPAIGN_CONTEXT_PROCESSORS will not be available if the 39 | campaign is viewed online. 40 | 41 | 42 | Other Templates 43 | --------------- 44 | 45 | If you use the built-in support for handling subscriptions and unsubscriptions 46 | you most probably want to override the template ``campaign/base.html`` 47 | somewhere in your projects TEMPLATE_DIRS. The bundled base.html template is 48 | only a placeholder to make developing and testing easier. 49 | 50 | Of course, the templates ``campaign/subscribe.html`` and ``campaign/unsubscribe.html`` 51 | can also be overwritten to adapt to your site. They are kept pretty simple 52 | and only demonstrate how things should work. -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from distutils.core import setup 2 | 3 | setup( 4 | name='django-campaign', 5 | version=__import__('campaign').__version__, 6 | description='A basic newsletter app for the Django webframework', 7 | long_description=open('README.rst').read(), 8 | author='Arne Brodowski', 9 | author_email='arne@rcs4u.de', 10 | license="BSD", 11 | url='https://github.com/arneb/django-campaign/', 12 | classifiers=[ 13 | 'Development Status :: 5 - Production/Stable', 14 | 'Environment :: Web Environment', 15 | 'Intended Audience :: Developers', 16 | 'License :: OSI Approved :: BSD License', 17 | 'Operating System :: OS Independent', 18 | 'Programming Language :: Python', 19 | 'Framework :: Django', 20 | ], 21 | packages = ( 22 | 'campaign', 23 | 'campaign.backends', 24 | 'campaign.management', 25 | 'campaign.management.commands', 26 | 'campaign.migrations', 27 | 'campaign.templatetags', 28 | ), 29 | package_data = { 30 | 'campaign': 31 | [ 32 | 'templates/admin/campaign/blacklistentry/*.html', 33 | 'templates/admin/campaign/campaign/*.html', 34 | 'templates/admin/campaign/subscriberlist/*.html', 35 | 'templates/campaign/*.html' 36 | ], 37 | } 38 | ) 39 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arneb/django-campaign/a861c8cb7384b00c30c84600e3f523e624a02dae/tests/__init__.py -------------------------------------------------------------------------------- /tests/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | 6 | sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.path.pardir)) 7 | 8 | 9 | def main(): 10 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'settings') 11 | 12 | from django.core.management import execute_from_command_line 13 | 14 | execute_from_command_line(sys.argv) 15 | 16 | 17 | if __name__ == '__main__': 18 | main() 19 | -------------------------------------------------------------------------------- /tests/settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...) 4 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 5 | 6 | 7 | # Quick-start development settings - unsuitable for production 8 | # See https://docs.djangoproject.com/en/2.2/howto/deployment/checklist/ 9 | 10 | # SECURITY WARNING: keep the secret key used in production secret! 11 | SECRET_KEY = 'only-for-the-test-suite' 12 | 13 | # SECURITY WARNING: don't run with debug turned on in production! 14 | DEBUG = True 15 | 16 | ALLOWED_HOSTS = [] 17 | 18 | SITE_ID = 1 19 | 20 | # Application definition 21 | 22 | INSTALLED_APPS = [ 23 | 'django.contrib.admin', 24 | 'django.contrib.auth', 25 | 'django.contrib.contenttypes', 26 | 'django.contrib.sessions', 27 | 'django.contrib.messages', 28 | 'django.contrib.staticfiles', 29 | 'django.contrib.sites', 30 | 'campaign', 31 | ] 32 | 33 | MIDDLEWARE = [ 34 | 'django.middleware.security.SecurityMiddleware', 35 | 'django.contrib.sessions.middleware.SessionMiddleware', 36 | 'django.middleware.common.CommonMiddleware', 37 | 'django.middleware.csrf.CsrfViewMiddleware', 38 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 39 | 'django.contrib.messages.middleware.MessageMiddleware', 40 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 41 | ] 42 | 43 | ROOT_URLCONF = 'urls' 44 | 45 | TEMPLATES = [ 46 | { 47 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 48 | 'DIRS': [], 49 | 'APP_DIRS': True, 50 | 'OPTIONS': { 51 | 'context_processors': [ 52 | 'django.template.context_processors.debug', 53 | 'django.template.context_processors.request', 54 | 'django.contrib.auth.context_processors.auth', 55 | 'django.contrib.messages.context_processors.messages', 56 | ], 57 | }, 58 | }, 59 | ] 60 | 61 | WSGI_APPLICATION = 'wsgi.application' 62 | 63 | 64 | # Database 65 | # https://docs.djangoproject.com/en/2.2/ref/settings/#databases 66 | 67 | DATABASES = { 68 | 'default': { 69 | 'ENGINE': 'django.db.backends.sqlite3', 70 | 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), 71 | } 72 | } 73 | 74 | 75 | # Password validation 76 | # https://docs.djangoproject.com/en/2.2/ref/settings/#auth-password-validators 77 | 78 | AUTH_PASSWORD_VALIDATORS = [] 79 | 80 | 81 | # Internationalization 82 | # https://docs.djangoproject.com/en/2.2/topics/i18n/ 83 | 84 | LANGUAGE_CODE = 'en-us' 85 | 86 | TIME_ZONE = 'UTC' 87 | 88 | USE_I18N = True 89 | 90 | USE_L10N = True 91 | 92 | USE_TZ = True 93 | 94 | 95 | # Static files (CSS, JavaScript, Images) 96 | # https://docs.djangoproject.com/en/2.2/howto/static-files/ 97 | 98 | STATIC_URL = '/static/' 99 | -------------------------------------------------------------------------------- /tests/urls.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.urls import path, include 3 | 4 | urlpatterns = [ 5 | path('admin/', admin.site.urls), 6 | path('campaign/', include('campaign.urls')), 7 | ] 8 | -------------------------------------------------------------------------------- /tests/wsgi.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from django.core.wsgi import get_wsgi_application 4 | 5 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'settings') 6 | 7 | application = get_wsgi_application() 8 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py{35,36,37,38}-dj{20,21,22}, py{37,38}-dj{30,31,32} 3 | 4 | [testenv] 5 | commands = {envpython} tests/manage.py test campaign --settings=settings 6 | basepython = 7 | py35: python3.5 8 | py36: python3.6 9 | py37: python3.7 10 | py38: python3.8 11 | deps = 12 | dj20: django>=2,<2.0.99 13 | dj21: django>=2.1,<2.1.99 14 | dj22: django>=2.2,<2.2.99 15 | dj30: django>=3.0,<3.0.99 16 | dj31: django>=3.1,<3.1.99 17 | dj32: django>=3.2,<3.2.99 18 | --------------------------------------------------------------------------------