diff --git a/README b/README index f2e9c0a..f6e807f 100644 --- a/README +++ b/README @@ -1,9 +1,9 @@ A basic newsletter app for the Django webframework -------------------------------------------------- -See docs/overview.txt for a brief overview. +* Please see the documentation in the docs folder. -You can reach the author of django-campaign at arne _at_ rcs4u.de. +* You can reach the author of django-campaign at arne _at_ rcs4u.de. -Bug-reports can be filed at the google code project page: -http://code.google.com/p/django-campaign/ +* Bug-reports can be filed at the google code project page: + http://code.google.com/p/django-campaign/ diff --git a/campaign/__init__.py b/campaign/__init__.py index fc6f1b8..2c30da2 100644 --- a/campaign/__init__.py +++ b/campaign/__init__.py @@ -1,2 +1,2 @@ -VERSION = (0, 1, 1) +VERSION = (0, 2, 'pre') __version__ = '.'.join(map(str, VERSION)) \ No newline at end of file diff --git a/campaign/admin.py b/campaign/admin.py index d243d0b..e2e1f5f 100644 --- a/campaign/admin.py +++ b/campaign/admin.py @@ -1,7 +1,6 @@ -import csv -from django import forms from django.shortcuts import render_to_response from django import template +from django import forms from django.utils.functional import update_wrapper from django.contrib import admin from django.http import HttpResponseRedirect @@ -10,110 +9,9 @@ from django.utils.translation import ugettext as _ from django.utils.encoding import force_unicode from django.utils.safestring import mark_safe -try: - from django.utils.simplejson import simplejson as json -except ImportError: - from django.utils import simplejson as json - -from campaign.models import MailTemplate, Subscriber, Campaign, BlacklistEntry, BounceEntry, SubscriberList -from campaign.forms import UploadForm - - -class SubscriberAdmin(admin.ModelAdmin): - - import_template=None - list_display = ('email', 'salutation') - - def has_import_permission(self, request): - """ - TODO: integrate with django's permission system - """ - return request.user.is_superuser - - def import_view(self, request, extra_context=None): - """ - Import email addresses and salutation from a uploaded text file. - """ - model = self.model - opts = model._meta - - if not self.has_import_permission(request): - raise PermissionDenied - - if request.method == 'POST': - form = UploadForm(request.POST, request.FILES) - if form.is_valid(): - num_import = 0 - - try: # try json - data = json.loads(form.cleaned_data['file'].read()) - - for entry in data: - try: - Subscriber.objects.create(email=entry['email'], salutation=force_unicode(entry['name'])) - num_import += 1 - except Exception, e: - pass - except: # may be csv data - try: - reader = csv.reader(form.cleaned_data['file'].readlines(), delimiter=',') - for entry in reader: - try: - Subscriber.objects.create(email=entry[0], salutation=force_unicode(entry[1])) - num_import += 1 - except Exception, e: - pass - except: - raise - - request.user.message_set.create(message=_(u'Successfully imported %(num_import)s %(name)s.' % {'name': force_unicode(opts.verbose_name_plural), 'num_import': num_import,})) - return HttpResponseRedirect('../') - - else: - form = UploadForm() - - def form_media(): - from django.conf import settings - css = ['css/forms.css',] - return forms.Media(css={'screen': ['%s%s' % (settings.ADMIN_MEDIA_PREFIX, url) for url in css]}) - - media = self.media + form_media() - context = { - 'title': _('Import %s') % force_unicode(opts.verbose_name_plural), - 'is_popup': request.REQUEST.has_key('_popup'), - 'media': mark_safe(media), - 'root_path': self.admin_site.root_path, - 'app_label': opts.app_label, - 'opts': opts, - 'form': form, - } - - context.update(extra_context or {}) - return render_to_response(self.import_template or - ['admin/%s/%s/import.html' % (opts.app_label, opts.object_name.lower()), - 'admin/%s/import.html' % opts.app_label, - 'admin/import.html'], context, context_instance=template.RequestContext(request)) - - - def get_urls(self): - from django.conf.urls.defaults import patterns, url - - def wrap(view): - def wrapper(*args, **kwargs): - return self.admin_site.admin_view(view)(*args, **kwargs) - return update_wrapper(wrapper, view) - - info = self.admin_site.name, self.model._meta.app_label, self.model._meta.module_name - - super_urlpatterns = super(SubscriberAdmin, self).get_urls() - urlpatterns = patterns('', - url(r'^import/$', - wrap(self.import_view), - name='%sadmin_%s_%s_import' % info), - ) - urlpatterns += super_urlpatterns - - return urlpatterns +from campaign.models import MailTemplate, Campaign, BlacklistEntry, \ +BounceEntry, SubscriberList + class CampaignAdmin(admin.ModelAdmin): filter_horizontal=('recipients',) @@ -206,7 +104,6 @@ def wrapper(*args, **kwargs): admin.site.register(Campaign, CampaignAdmin) admin.site.register(MailTemplate) -admin.site.register(Subscriber, SubscriberAdmin) admin.site.register(BlacklistEntry) admin.site.register(BounceEntry) -admin.site.register(SubscriberList, filter_horizontal=('subscribers',)) +admin.site.register(SubscriberList) diff --git a/campaign/backends/__init__.py b/campaign/backends/__init__.py new file mode 100644 index 0000000..9cc6a1b --- /dev/null +++ b/campaign/backends/__init__.py @@ -0,0 +1,32 @@ +import os +from django.conf import settings +from django.core.exceptions import ImproperlyConfigured + +__all__ = ('backend') + +CAMPAIGN_BACKEND = getattr(settings, 'CAMPAIGN_BACKEND', 'send_mail') + +def get_backend(import_path): + if not '.' in import_path: + import_path = "campaign.backends.%s" % import_path + try: + mod = __import__(import_path, {}, {}, ['']) + except ImportError, e_user: + # No backend found, display an error message and a list of all + # bundled backends. + backend_dir = __path__[0] + 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')] + available_backends.sort() + if CAMPAIGN_BACKEND not in available_backends: + raise ImproperlyConfigured("%s isn't an available campaign backend. Available options are: %s" % \ + (CAMPAIGN_BACKEND, ', '.join(map(repr, available_backends)))) + # if the CAMPAIGN_BACKEND is available in the backend directory + # and an ImportError is raised, don't suppress it + else: + raise + try: + return getattr(mod, 'backend') + except AttributeError: + raise ImproperlyConfigured('Backend "%s" does not define a "backend" instance.' % import_path) + +backend = get_backend(CAMPAIGN_BACKEND) \ No newline at end of file diff --git a/campaign/backends/debug.py b/campaign/backends/debug.py new file mode 100644 index 0000000..9d94fc9 --- /dev/null +++ b/campaign/backends/debug.py @@ -0,0 +1,12 @@ +# an example backend which just prints out the email instead of sending it + +class DebugBackend(object): + def send_mail(self, email, fail_silently=False): + print "Subject: %s" % email.subject + print "To: %s" % email.recipients() + print "======" + print email.message().as_string() # the actual email message + print "======" + return 0 + +backend = DebugBackend() \ No newline at end of file diff --git a/campaign/backends/django_mailer.py b/campaign/backends/django_mailer.py new file mode 100644 index 0000000..1b43ffb --- /dev/null +++ b/campaign/backends/django_mailer.py @@ -0,0 +1,14 @@ +# simple backend using django-mailer to queue and send the mails +from django.conf import settings +from mailer import send_mail + +class DjangoMailerBackend(object): + def send_mail(self, email, fail_silently=False): + subject = email.subject + body = email.body + # django_mailer does not support multi-part messages so we loose the + # html content + return send_mail(subject, body, settings.DEFAULT_FROM_EMAIL, email.recipients(), + fail_silently=fail_silently) + +backend = DjangoMailerBackend() \ No newline at end of file diff --git a/campaign/backends/send_mail.py b/campaign/backends/send_mail.py new file mode 100644 index 0000000..891b5dc --- /dev/null +++ b/campaign/backends/send_mail.py @@ -0,0 +1,15 @@ +# simple backend which uses Django's built-in mail sending mechanisms + +class SendMailBackend(object): + def send_mail(self, email, fail_silently=False): + """ + Parameters: + + ``email``: an instance of django.core.mail.EmailMessage + ``fail_silently``: a boolean indicating if exceptions should + bubble up + + """ + return email.send(fail_silently=fail_silently) + +backend = SendMailBackend() \ No newline at end of file diff --git a/campaign/context.py b/campaign/context.py new file mode 100644 index 0000000..e821301 --- /dev/null +++ b/campaign/context.py @@ -0,0 +1,44 @@ +# heavily based on Django's RequestContext +from django.conf import settings +from django.core.exceptions import ImproperlyConfigured +from django.template import Context + +_mail_context_processors = None + +# This is a function rather than module-level procedural code because we only +# want it to execute if somebody uses MailContext. +def get_mail_processors(): + global _mail_context_processors + if _mail_context_processors is None: + processors = [] + for path in getattr(settings, 'CAMPAIGN_CONTEXT_PROCESSORS', ()): + i = path.rfind('.') + module, attr = path[:i], path[i+1:] + try: + mod = __import__(module, {}, {}, [attr]) + except ImportError, e: + raise ImproperlyConfigured('Error importing campaign processor module %s: "%s"' % (module, e)) + try: + func = getattr(mod, attr) + except AttributeError: + raise ImproperlyConfigured('Module "%s" does not define a "%s" callable campaign processor' % (module, attr)) + processors.append(func) + _mail_context_processors = tuple(processors) + return _mail_context_processors + +class MailContext(Context): + """ + This subclass of template.Context automatically populates itself using + the processors defined in CAMPAIGN_CONTEXT_PROCESSORS. + Additional processors can be specified as a list of callables + using the "processors" keyword argument. + """ + def __init__(self, subscriber, dict=None, processors=None): + Context.__init__(self, dict) + if processors is None: + processors = () + else: + processors = tuple(processors) + for processor in get_mail_processors() + processors: + self.update(processor(subscriber)) + self.update({'recipient': subscriber}) diff --git a/campaign/fields.py b/campaign/fields.py new file mode 100644 index 0000000..971cb5c --- /dev/null +++ b/campaign/fields.py @@ -0,0 +1,41 @@ +from django.db import models +from django import forms +from django.utils import simplejson + +class JSONWidget(forms.Textarea): + def render(self, name, value, attrs=None): + if not isinstance(value, basestring): + value = simplejson.dumps(value, indent=2) + return super(JSONWidget, self).render(name, value, attrs) + +class JSONFormField(forms.CharField): + def __init__(self, *args, **kwargs): + kwargs['widget'] = JSONWidget + super(JSONFormField, self).__init__(*args, **kwargs) + + def clean(self, value): + if not value: return + try: + return simplejson.loads(value) + except Exception, exc: + raise forms.ValidationError(u'JSON decode error: %s' % (unicode(exc),)) + +class JSONField(models.TextField): + __metaclass__ = models.SubfieldBase + + def formfield(self, **kwargs): + return super(JSONField, self).formfield(form_class=JSONFormField, **kwargs) + + def to_python(self, value): + if isinstance(value, basestring): + value = simplejson.loads(value) + return value + + def get_db_prep_save(self, value): + if value is None: return + return simplejson.dumps(value) + + def value_to_string(self, obj): + value = self._get_val_from_obj(obj) + return self.get_db_prep_value(value) + \ No newline at end of file diff --git a/campaign/forms.py b/campaign/forms.py deleted file mode 100644 index 50dc941..0000000 --- a/campaign/forms.py +++ /dev/null @@ -1,27 +0,0 @@ -import csv -from django import forms -from django.utils.translation import ugettext as _ -try: - from django.utils.simplejson import simplejson as json -except ImportError: - from django.utils import simplejson as json - -class UploadForm(forms.Form): - """ - validates that the uploaded file contains parseable json data. - """ - file = forms.FileField() - - def clean_file(self): - try: - json.loads(self.cleaned_data['file'].read()) - self.cleaned_data['file'].seek(0) - return self.cleaned_data['file'] - except Exception, e: - try: - reader = csv.reader(self.cleaned_data['file'].readlines()) - self.cleaned_data['file'].seek(0) - return self.cleaned_data['file'] - except Exception, e: - raise forms.ValidationError(_(u"uploaded file must contain json or csv data")) - diff --git a/campaign/models.py b/campaign/models.py index 429bfa5..bea5ee0 100644 --- a/campaign/models.py +++ b/campaign/models.py @@ -2,9 +2,12 @@ from django import template from django.db import models from django.utils.translation import ugettext_lazy as _ -from django.contrib.auth.models import User -from django.core.mail import EmailMessage, SMTPConnection, EmailMultiAlternatives -from campaign.queue import SMTPQueue, SMTPLoggingConnection +from django.core.mail import EmailMultiAlternatives +from django.contrib.contenttypes.models import ContentType +from campaign.fields import JSONField +from campaign.context import MailContext +from campaign.backends import backend + class MailTemplate(models.Model): """ @@ -23,58 +26,42 @@ class MailTemplate(models.Model): def __unicode__(self): return self.name - - -class Recipient(models.Model): - """ - A recipient of a Mail doesn't have to correspond to a User object, but can. - If a User object is present the email address will automatically be filled - in, if none is given. - - """ - user = models.ForeignKey(User, blank=True, null=True, verbose_name=_(u"User")) - email = models.EmailField(_(u"email address"), blank=True, unique=True) - salutation = models.CharField(_(u"salutation"), blank=True, null=True, max_length=255) - added = models.DateTimeField(_(u"added"), editable=False) - - def __unicode__(self): - return self.email - - def save(self, *args, **kwargs): - if not self.pk: - self.added = datetime.now() - if self.user is not None and not self.email: - self.email = self.user.email - return super(Recipient, self).save(*args, **kwargs) - - class Meta: - abstract = True - - - -class Subscriber(Recipient): - """ - The actual Recipient of a Mail, see Recipient docstring for more info. - - """ class SubscriberList(models.Model): """ - A list of Subscriber objects. + A pointer to another Django model which holds the subscribers. """ name = models.CharField(_(u"Name"), max_length=255) - subscribers = models.ManyToManyField(Subscriber, null=True, verbose_name=_(u"Subscribers")) + content_type = models.ForeignKey(ContentType) + filter_condition = JSONField(default="{}", help_text=_(u"Django ORM compatible lookup kwargs which are used to get the list of objects.")) + email_field_name = models.CharField(_(u"Email-Field name"), max_length=64, help_text=_(u"Name of the model field which stores the recipients email address")) def __unicode__(self): return self.name + + def _get_filter(self): + # simplejson likes to put unicode objects as dictionary keys + # but keyword arguments must be str type + fc = {} + for k,v in self.filter_condition.iteritems(): + fc.update({str(k): v}) + return fc + + def object_list(self): + return self.content_type.model_class()._default_manager.filter(**self._get_filter()) + + def object_count(self): + return self.content_type.model_class()._default_manager.filter(**self._get_filter()).count() + + class Campaign(models.Model): """ A Campaign is the central part of this app. Once a Campaign is created, - has a MailTemplate and a number of Recipients, it can be send out. + has a MailTemplate and one or more SubscriberLists, it can be send out. Most of the time of Campain will have a one-to-one relationship with a MailTemplate, but templates may be reused in other Campaigns and maybe Campaigns will have support for multiple templates in the future, therefore @@ -89,20 +76,19 @@ class Campaign(models.Model): def __unicode__(self): return self.name - + def send(self): """ Sends the mails to the recipients. """ - connection = SMTPLoggingConnection() - num_sent = self._send(connection) + num_sent = self._send() self.sent = True self.save() return num_sent - def _send(self, connection): + def _send(self): """ Does the actual work """ @@ -114,32 +100,38 @@ def _send(self, connection): sent = 0 used_addresses = [] for recipient_list in self.recipients.all(): - for recipient in recipient_list.subscribers.all(): + for recipient in recipient_list.object_list(): # never send mail to blacklisted email addresses - if not BlacklistEntry.objects.filter(email=recipient.email).count() and not recipient.email in used_addresses: - msg = EmailMultiAlternatives(subject, connection=connection, to=[recipient.email,]) - msg.body = text_template.render(template.Context({'salutation': recipient.salutation,})) + recipient_email = getattr(recipient, recipient_list.email_field_name) + if not BlacklistEntry.objects.filter(email=recipient_email).count() and not recipient_email in used_addresses: + msg = EmailMultiAlternatives(subject, to=[recipient_email,]) + context = MailContext(recipient) + msg.body = text_template.render(context) if self.template.html is not None and self.template.html != u"": - html_content = html_template.render(template.Context({'salutation': recipient.salutation,})) + html_content = html_template.render(context) msg.attach_alternative(html_content, 'text/html') - sent += msg.send() - used_addresses.append(recipient.email) + sent += backend.send_mail(msg) + used_addresses.append(recipient_email) return sent -class BlacklistEntry(Recipient): +class BlacklistEntry(models.Model): """ If a user has requested removal from the subscriber-list, he is added - to the blacklist to prevent accidential adding of the same user again. + to the blacklist to prevent accidential adding of the same user again + on subsequent imports from a data source. """ - + email = models.EmailField() + added = models.DateTimeField(default=datetime.now, editable=False) -class BounceEntry(Recipient): +class BounceEntry(models.Model): """ Records bouncing Recipients. To be processed by a human. """ + email = models.CharField(_(u"recipient"), max_length=255, blank=True, null=True) exception = models.TextField(_(u"exception"), blank=True, null=True) + \ No newline at end of file diff --git a/campaign/queue.py b/campaign/queue.py deleted file mode 100644 index 9b6e9e6..0000000 --- a/campaign/queue.py +++ /dev/null @@ -1,98 +0,0 @@ -from django.core.mail import SMTPConnection - - - -class SMTPQueue(SMTPConnection): - """ - Can be used instead of Django's SMTPConnection class to queue - a bunch of emails for later sending. - """ - - def __init__(self, *args, **kwargs): - super(SMTPQueue, self).__init__(*args, **kwargs) - self._email_messages = [] - - - def send_messages(self, email_messages): - """ - Queues one or more EmailMessage objects and returns the number of email - messages queued. - """ - if not email_messages: - return - - num_sent = 0 - for message in email_messages: - sent = self._queue(message) - if sent: - num_sent += 1 - return num_sent - - - def _queue(self, email_message): - self._email_messages.append(email_message) - - - def defer(self): - """ - Save the state of this Queue for later sending - """ - try: - import cPickle as pickle - except ImportError: - import pickle - - return pickle.dumps(self) - - - def flush(self): - """ - Will send all queued EmailMessage objects. - """ - if not self._email_messages: - return - new_conn_created = self.open() - if not self.connection: - # We failed silently on open(). Trying to send would be pointless. - return - num_sent = 0 - for message in self._email_messages: - sent = self._send(message) - if sent: - num_sent += 1 - if new_conn_created: - self.close() - #print "sent: %s" % num_sent - return num_sent - - -class SMTPLoggingConnection(SMTPConnection): - """ - Logs bounces etc. - - """ - def send_messages(self, email_messages): - """ - Sends one or more EmailMessage objects and returns the number of email - messages sent. - """ - from campaign.models import BounceEntry - if not email_messages: - return - new_conn_created = self.open() - if not self.connection: - # We failed silently on open(). Trying to send would be pointless. - return - num_sent = 0 - for message in email_messages: - try: - sent = self._send(message) - except Exception, e: - BounceEntry.objects.create(email=message.recipients()[0], exception=str(e)) - sent = False - if sent: - num_sent += 1 - if new_conn_created: - self.close() - return num_sent - \ No newline at end of file diff --git a/campaign/templates/admin/campaign/campaign/send_object.html b/campaign/templates/admin/campaign/campaign/send_object.html index 689df88..0253a9c 100644 --- a/campaign/templates/admin/campaign/campaign/send_object.html +++ b/campaign/templates/admin/campaign/campaign/send_object.html @@ -31,7 +31,7 @@

{% trans "Subscriber lists" %}