summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--app/lttr/__init__.py0
-rw-r--r--app/lttr/forms.py75
-rw-r--r--app/lttr/migrations/0001_initial.py60
-rw-r--r--app/lttr/migrations/0002_subscriber_email_field.py18
-rw-r--r--app/lttr/migrations/__init__.py0
-rw-r--r--app/lttr/models.py311
-rw-r--r--app/lttr/modelsnl.py719
-rw-r--r--app/lttr/urls.py24
-rw-r--r--app/lttr/validators.py18
-rw-r--r--app/lttr/views.py54
-rw-r--r--config/base_urls.py2
-rw-r--r--design/templates/lttr/subscriber_form.html37
12 files changed, 1317 insertions, 1 deletions
diff --git a/app/lttr/__init__.py b/app/lttr/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/app/lttr/__init__.py
diff --git a/app/lttr/forms.py b/app/lttr/forms.py
new file mode 100644
index 0000000..e1d4709
--- /dev/null
+++ b/app/lttr/forms.py
@@ -0,0 +1,75 @@
+from django import forms
+from django.forms.utils import ValidationError
+
+from .validators import validate_email_nouser
+from .models import Subscriber
+
+
+class SubscribeForm(forms.ModelForm):
+
+ class Meta:
+ model = Subscriber
+ fields = ['user']
+
+
+class NewsletterForm(forms.ModelForm):
+ """ This is the base class for all forms managing subscriptions. """
+ email_field = forms.EmailField(label='Email Address:', widget=forms.EmailInput(attrs={'placeholder': 'Your email address'}))
+
+ class Meta:
+ model = Subscriber
+ fields = ('email_field',)
+
+ def __init__(self, *args, **kwargs):
+
+ assert 'newsletter' in kwargs, 'No newsletter specified'
+
+ newsletter = kwargs.pop('newsletter')
+
+ if 'ip' in kwargs:
+ ip = kwargs['ip']
+ del kwargs['ip']
+ else:
+ ip = None
+
+ super(NewsletterForm, self).__init__(*args, **kwargs)
+
+ self.instance.newsletter = newsletter
+
+ if ip:
+ self.instance.ip = ip
+
+
+class SubscribeRequestForm(NewsletterForm):
+ """
+ Request subscription to the newsletter. Will result in an activation email
+ being sent with a link where one can edit, confirm and activate one's
+ subscription.
+ """
+ email_field = forms.EmailField(
+ label=("e-mail"), validators=[validate_email_nouser], widget=forms.EmailInput(attrs={'placeholder': 'Your email address'})
+ )
+
+ def clean_email_field(self):
+ data = self.cleaned_data['email_field']
+
+ # Check whether we have already been subscribed to
+ try:
+ subscription = Subscriber.objects.get(
+ email_field__exact=data,
+ newsletter=self.instance.newsletter
+ )
+
+ if subscription.subscribed and not subscription.unsubscribed:
+ raise ValidationError(
+ "Your e-mail address has already been subscribed to."
+ )
+ else:
+ self.instance = subscription
+
+ self.instance = subscription
+
+ except Subscriber.DoesNotExist:
+ pass
+
+ return data
diff --git a/app/lttr/migrations/0001_initial.py b/app/lttr/migrations/0001_initial.py
new file mode 100644
index 0000000..9c06cd9
--- /dev/null
+++ b/app/lttr/migrations/0001_initial.py
@@ -0,0 +1,60 @@
+# Generated by Django 2.1.5 on 2019-02-09 07:10
+
+import datetime
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+import lttr.models
+import taggit.managers
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ ('taxonomy', '0001_initial'),
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='Newsletter',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('title', models.CharField(max_length=250)),
+ ],
+ ),
+ migrations.CreateModel(
+ name='NewsletterMailing',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('title', models.CharField(max_length=250)),
+ ('slug', models.SlugField(blank=True, unique_for_date='pub_date')),
+ ('pub_date', models.DateTimeField()),
+ ('status', models.IntegerField(choices=[(0, 'Not Published'), (1, 'Published')], default=0)),
+ ('date_created', models.DateTimeField(auto_now_add=True)),
+ ('tags', taggit.managers.TaggableManager(blank=True, help_text='Topics Covered', through='taxonomy.TaggedItems', to='taxonomy.LuxTag', verbose_name='Tags')),
+ ],
+ options={
+ 'ordering': ('-title', '-date_created'),
+ },
+ ),
+ migrations.CreateModel(
+ name='Subscriber',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('date_created', models.DateTimeField(auto_now_add=True)),
+ ('date_updated', models.DateTimeField(auto_now=True)),
+ ('ip', models.GenericIPAddressField(blank=True, null=True)),
+ ('create_date', models.DateTimeField(default=datetime.datetime.now, editable=False)),
+ ('activation_code', models.CharField(default=lttr.models.make_activation_code, max_length=40)),
+ ('subscribed', models.BooleanField(db_index=True, default=False)),
+ ('subscribe_date', models.DateTimeField(blank=True, null=True)),
+ ('unsubscribed', models.BooleanField(db_index=True, default=False)),
+ ('unsubscribe_date', models.DateTimeField(blank=True, null=True)),
+ ('newsletter', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='lttr.Newsletter')),
+ ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
+ ],
+ ),
+ ]
diff --git a/app/lttr/migrations/0002_subscriber_email_field.py b/app/lttr/migrations/0002_subscriber_email_field.py
new file mode 100644
index 0000000..60ddcfd
--- /dev/null
+++ b/app/lttr/migrations/0002_subscriber_email_field.py
@@ -0,0 +1,18 @@
+# Generated by Django 2.1.5 on 2019-02-09 14:16
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('lttr', '0001_initial'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='subscriber',
+ name='email_field',
+ field=models.EmailField(blank=True, db_column='email', db_index=True, max_length=254, null=True),
+ ),
+ ]
diff --git a/app/lttr/migrations/__init__.py b/app/lttr/migrations/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/app/lttr/migrations/__init__.py
diff --git a/app/lttr/models.py b/app/lttr/models.py
new file mode 100644
index 0000000..029022c
--- /dev/null
+++ b/app/lttr/models.py
@@ -0,0 +1,311 @@
+import datetime
+from django.contrib.gis.db import models
+from django.contrib.sites.models import Site
+from django.template.loader import select_template
+from django.core.mail import EmailMultiAlternatives
+from django.utils import timezone
+from django.utils.text import slugify
+from django.urls import reverse
+from django.conf import settings
+from django.utils.crypto import get_random_string
+
+from taggit.managers import TaggableManager
+
+from taxonomy.models import TaggedItems
+
+
+# Possible actions that user can perform
+ACTIONS = ('subscribe', 'unsubscribe', 'update')
+
+
+def make_activation_code():
+ """ Generate a unique activation code. """
+
+ # Use Django's crypto get_random_string() instead of rolling our own.
+ return get_random_string(length=40)
+
+
+class Newsletter(models.Model):
+ """ A model for Newletters. Might I one day have two? I might. """
+ title = models.CharField(max_length=250)
+
+ def __str__(self):
+ return self.title
+
+ def get_absolute_url(self):
+ return reverse("lttr:detail", kwargs={"slug": self.slug})
+
+ def subscribe_url(self):
+ return reverse('newsletter_subscribe_request', kwargs={'newsletter_slug': self.slug})
+
+ def unsubscribe_url(self):
+ return reverse('newsletter_unsubscribe_request', kwargs={'newsletter_slug': self.slug})
+
+ def update_url(self):
+ return reverse('newsletter_update_request', kwargs={'newsletter_slug': self.slug})
+
+ def archive_url(self):
+ return reverse('newsletter_archive', kwargs={'newsletter_slug': self.slug})
+
+ def get_subscriptions(self):
+ return Subscriber.objects.filter(newsletter=self, subscribed=True)
+
+ @classmethod
+ def get_default(cls):
+ try:
+ return cls.objects.all()[0].pk
+ except IndexError:
+ return None
+
+
+class NewsletterMailing(models.Model):
+ """ A model for Newletter Mailings, the things actually sent out """
+ title = models.CharField(max_length=250)
+ slug = models.SlugField(unique_for_date='pub_date', blank=True)
+ pub_date = models.DateTimeField()
+ tags = TaggableManager(through=TaggedItems, blank=True, help_text='Topics Covered')
+ PUB_STATUS = (
+ (0, 'Not Published'),
+ (1, 'Published'),
+ )
+ status = models.IntegerField(choices=PUB_STATUS, default=0)
+ date_created = models.DateTimeField(blank=True, auto_now_add=True, editable=False)
+
+ class Meta:
+ ordering = ('-title', '-date_created')
+
+ def __str__(self):
+ return self.title
+
+ def get_absolute_url(self):
+ return reverse("newsletter:mailing-detail", kwargs={"slug": self.slug})
+
+ def save(self, *args, **kwargs):
+ if not self.id:
+ self.date_created = timezone.now()
+ super(NewsletterMailing, self).save()
+
+ def get_templates(self, action):
+ """
+ Return a subject, text, HTML tuple with e-mail templates for
+ a particular action. Returns a tuple with subject, text and e-mail
+ template.
+ """
+
+ assert action in ACTIONS + ('message', ), 'Unknown action: %s' % action
+
+ # Common substitutions for filenames
+ tpl_subst = {
+ 'action': action,
+ 'newsletter': self.slug
+ }
+
+ # Common root path for all the templates
+ tpl_root = 'newsletter/message/'
+
+ subject_template = select_template([
+ tpl_root + '%(newsletter)s/%(action)s_subject.txt' % tpl_subst,
+ tpl_root + '%(action)s_subject.txt' % tpl_subst,
+ ])
+
+ text_template = select_template([
+ tpl_root + '%(newsletter)s/%(action)s.txt' % tpl_subst,
+ tpl_root + '%(action)s.txt' % tpl_subst,
+ ])
+
+ if self.send_html:
+ html_template = select_template([
+ tpl_root + '%(newsletter)s/%(action)s.html' % tpl_subst,
+ tpl_root + '%(action)s.html' % tpl_subst,
+ ])
+ else:
+ # HTML templates are not required
+ html_template = None
+
+ return (subject_template, text_template, html_template)
+
+
+class Subscriber(models.Model):
+ """ A model for Newletter Subscriber """
+ email_field = models.EmailField(db_column='email', db_index=True, blank=True, null=True)
+ user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
+ date_created = models.DateTimeField(blank=True, auto_now_add=True, editable=False)
+ date_updated = models.DateTimeField(blank=True, auto_now=True, editable=False)
+ ip = models.GenericIPAddressField(blank=True, null=True)
+ newsletter = models.ForeignKey(Newsletter, on_delete=models.CASCADE)
+ create_date = models.DateTimeField(editable=False, default=datetime.datetime.now)
+ activation_code = models.CharField(max_length=40, default=make_activation_code)
+ subscribed = models.BooleanField(default=False, db_index=True)
+ subscribe_date = models.DateTimeField(null=True, blank=True)
+ unsubscribed = models.BooleanField(default=False, db_index=True)
+ unsubscribe_date = models.DateTimeField(null=True, blank=True)
+
+ def __str__(self):
+ return self.user.username
+
+ def get_name(self):
+ if self.user:
+ return self.user.get_full_name()
+
+ def get_email(self):
+ if self.user:
+ return self.user.email
+ return self.email_field
+
+ def set_email(self, email):
+ if not self.user:
+ self.email_field = email
+ email = property(get_email, set_email)
+
+ def update(self, action):
+ """
+ Update subscription according to requested action:
+ subscribe/unsubscribe/update/, then save the changes.
+ """
+
+ assert action in ('subscribe', 'update', 'unsubscribe')
+
+ # If a new subscription or update, make sure it is subscribed
+ # Else, unsubscribe
+ if action == 'subscribe' or action == 'update':
+ self.subscribed = True
+ else:
+ self.unsubscribed = True
+
+ # This triggers the subscribe() and/or unsubscribe() methods, taking
+ # care of stuff like maintaining the (un)subscribe date.
+ self.save()
+
+ def _subscribe(self):
+ """
+ Internal helper method for managing subscription state
+ during subscription.
+ """
+
+ self.subscribe_date = datetime.datetime.now()
+ self.subscribed = True
+ self.unsubscribed = False
+
+ def _unsubscribe(self):
+ """
+ Internal helper method for managing subscription state
+ during unsubscription.
+ """
+ self.subscribed = False
+ self.unsubscribed = True
+ self.unsubscribe_date = datetime.datetime.now()
+
+ def save(self, *args, **kwargs):
+ """
+ Perform some basic validation and state maintenance of Subscription.
+ TODO: Move this code to a more suitable place (i.e. `clean()`) and
+ cleanup the code. Refer to comment below and
+ https://docs.djangoproject.com/en/dev/ref/models/instances/#django.db.models.Model.clean
+ """
+
+ # This is a lame way to find out if we have changed but using Django
+ # API internals is bad practice. This is necessary to discriminate
+ # from a state where we have never been subscribed but is mostly for
+ # backward compatibility. It might be very useful to make this just
+ # one attribute 'subscribe' later. In this case unsubscribed can be
+ # replaced by a method property.
+
+ if self.pk:
+ assert(Subscriber.objects.filter(pk=self.pk).count() == 1)
+
+ subscription = Subscriber.objects.get(pk=self.pk)
+ old_subscribed = subscription.subscribed
+ old_unsubscribed = subscription.unsubscribed
+
+ # If we are subscribed now and we used not to be so, subscribe.
+ # If we user to be unsubscribed but are not so anymore, subscribe.
+ if ((self.subscribed and not old_subscribed) or
+ (old_unsubscribed and not self.unsubscribed)):
+ self._subscribe()
+
+ assert not self.unsubscribed
+ assert self.subscribed
+
+ # If we are unsubcribed now and we used not to be so, unsubscribe.
+ # If we used to be subscribed but are not subscribed anymore,
+ # unsubscribe.
+ elif ((self.unsubscribed and not old_unsubscribed) or
+ (old_subscribed and not self.subscribed)):
+ self._unsubscribe()
+
+ assert not self.subscribed
+ assert self.unsubscribed
+ else:
+ if self.subscribed:
+ self._subscribe()
+ elif self.unsubscribed:
+ self._unsubscribe()
+
+ super(Subscriber, self).save(*args, **kwargs)
+
+ def get_recipient(self):
+ return get_address(self.name, self.email)
+
+ def send_activation_email(self, action):
+ assert action in ACTIONS, 'Unknown action: %s' % action
+
+ (subject_template, text_template, html_template) = \
+ self.newsletter.get_templates(action)
+
+ variable_dict = {
+ 'subscription': self,
+ 'site': Site.objects.get_current(),
+ 'newsletter': self.newsletter,
+ 'date': self.subscribe_date,
+ 'STATIC_URL': settings.STATIC_URL,
+ 'MEDIA_URL': settings.MEDIA_URL
+ }
+
+ subject = subject_template.render(variable_dict).strip()
+ text = text_template.render(variable_dict)
+
+ message = EmailMultiAlternatives(
+ subject, text,
+ from_email=self.newsletter.get_sender(),
+ to=[self.email]
+ )
+
+ if html_template:
+ message.attach_alternative(
+ html_template.render(variable_dict), "text/html"
+ )
+
+ message.send()
+
+ def subscribe_activate_url(self):
+ return reverse('newsletter_update_activate', kwargs={
+ 'newsletter_slug': self.newsletter.slug,
+ 'email': self.email,
+ 'action': 'subscribe',
+ 'activation_code': self.activation_code
+ })
+
+ def unsubscribe_activate_url(self):
+ return reverse('newsletter_update_activate', kwargs={
+ 'newsletter_slug': self.newsletter.slug,
+ 'email': self.email,
+ 'action': 'unsubscribe',
+ 'activation_code': self.activation_code
+ })
+
+ def update_activate_url(self):
+ return reverse('newsletter_update_activate', kwargs={
+ 'newsletter_slug': self.newsletter.slug,
+ 'email': self.email,
+ 'action': 'update',
+ 'activation_code': self.activation_code
+ })
+
+
+def get_address(name, email):
+ # Converting name to ascii for compatibility with django < 1.9.
+ # Remove this when django 1.8 is no longer supported.
+ if name:
+ return u'%s <%s>' % (name, email)
+ else:
+ return u'%s' % email
diff --git a/app/lttr/modelsnl.py b/app/lttr/modelsnl.py
new file mode 100644
index 0000000..9d2a7cd
--- /dev/null
+++ b/app/lttr/modelsnl.py
@@ -0,0 +1,719 @@
+import logging
+import time
+import django
+
+from django.conf import settings
+from django.contrib.sites.models import Site
+from django.contrib.sites.managers import CurrentSiteManager
+from django.core.mail import EmailMultiAlternatives
+from django.db import models
+from django.template.loader import select_template
+from django.utils.encoding import python_2_unicode_compatible
+from django.utils.functional import cached_property
+from django.utils.translation import ugettext_lazy as _
+from django.utils.translation import ugettext
+from django.utils.timezone import now
+
+from sorl.thumbnail import ImageField
+from distutils.version import LooseVersion
+
+
+from .compat import get_context, reverse
+from .utils import (
+ make_activation_code, get_default_sites, ACTIONS
+)
+
+logger = logging.getLogger(__name__)
+
+AUTH_USER_MODEL = getattr(settings, 'AUTH_USER_MODEL', 'auth.User')
+
+
+@python_2_unicode_compatible
+class Newsletter(models.Model):
+ site = models.ManyToManyField(Site, default=get_default_sites)
+
+ title = models.CharField(
+ max_length=200, verbose_name=_('newsletter title')
+ )
+ slug = models.SlugField(db_index=True, unique=True)
+
+ email = models.EmailField(
+ verbose_name=_('e-mail'), help_text=_('Sender e-mail')
+ )
+ sender = models.CharField(
+ max_length=200, verbose_name=_('sender'), help_text=_('Sender name')
+ )
+
+ visible = models.BooleanField(
+ default=True, verbose_name=_('visible'), db_index=True
+ )
+
+ send_html = models.BooleanField(
+ default=True, verbose_name=_('send html'),
+ help_text=_('Whether or not to send HTML versions of e-mails.')
+ )
+
+ objects = models.Manager()
+
+ # Automatically filter the current site
+ on_site = CurrentSiteManager()
+
+ def get_templates(self, action):
+ """
+ Return a subject, text, HTML tuple with e-mail templates for
+ a particular action. Returns a tuple with subject, text and e-mail
+ template.
+ """
+
+ assert action in ACTIONS + ('message', ), 'Unknown action: %s' % action
+
+ # Common substitutions for filenames
+ tpl_subst = {
+ 'action': action,
+ 'newsletter': self.slug
+ }
+
+ # Common root path for all the templates
+ tpl_root = 'newsletter/message/'
+
+ subject_template = select_template([
+ tpl_root + '%(newsletter)s/%(action)s_subject.txt' % tpl_subst,
+ tpl_root + '%(action)s_subject.txt' % tpl_subst,
+ ])
+
+ text_template = select_template([
+ tpl_root + '%(newsletter)s/%(action)s.txt' % tpl_subst,
+ tpl_root + '%(action)s.txt' % tpl_subst,
+ ])
+
+ if self.send_html:
+ html_template = select_template([
+ tpl_root + '%(newsletter)s/%(action)s.html' % tpl_subst,
+ tpl_root + '%(action)s.html' % tpl_subst,
+ ])
+ else:
+ # HTML templates are not required
+ html_template = None
+
+ return (subject_template, text_template, html_template)
+
+ def __str__(self):
+ return self.title
+
+ class Meta:
+ verbose_name = _('newsletter')
+ verbose_name_plural = _('newsletters')
+
+ def get_absolute_url(self):
+ return reverse('newsletter_detail', kwargs={'newsletter_slug': self.slug})
+
+ def subscribe_url(self):
+ return reverse('newsletter_subscribe_request', kwargs={'newsletter_slug': self.slug})
+
+ def unsubscribe_url(self):
+ return reverse('newsletter_unsubscribe_request', kwargs={'newsletter_slug': self.slug})
+
+ def update_url(self):
+ return reverse('newsletter_update_request', kwargs={'newsletter_slug': self.slug})
+
+ def archive_url(self):
+ return reverse('newsletter_archive', kwargs={'newsletter_slug': self.slug})
+
+ def get_sender(self):
+ return get_address(self.sender, self.email)
+
+ def get_subscriptions(self):
+ logger.debug(u'Looking up subscribers for %s', self)
+
+ return Subscription.objects.filter(newsletter=self, subscribed=True)
+
+ @classmethod
+ def get_default(cls):
+ try:
+ return cls.objects.all()[0].pk
+ except IndexError:
+ return None
+
+
+@python_2_unicode_compatible
+class Subscription(models.Model):
+ user = models.ForeignKey(
+ AUTH_USER_MODEL, blank=True, null=True, verbose_name=_('user'),
+ on_delete=models.CASCADE
+ )
+
+ name_field = models.CharField(
+ db_column='name', max_length=30, blank=True, null=True,
+ verbose_name=_('name'), help_text=_('optional')
+ )
+
+ def get_name(self):
+ if self.user:
+ return self.user.get_full_name()
+ return self.name_field
+
+ def set_name(self, name):
+ if not self.user:
+ self.name_field = name
+ name = property(get_name, set_name)
+
+ email_field = models.EmailField(
+ db_column='email', verbose_name=_('e-mail'), db_index=True,
+ blank=True, null=True
+ )
+
+ def get_email(self):
+ if self.user:
+ return self.user.email
+ return self.email_field
+
+ def set_email(self, email):
+ if not self.user:
+ self.email_field = email
+ email = property(get_email, set_email)
+
+ def update(self, action):
+ """
+ Update subscription according to requested action:
+ subscribe/unsubscribe/update/, then save the changes.
+ """
+
+ assert action in ('subscribe', 'update', 'unsubscribe')
+
+ # If a new subscription or update, make sure it is subscribed
+ # Else, unsubscribe
+ if action == 'subscribe' or action == 'update':
+ self.subscribed = True
+ else:
+ self.unsubscribed = True
+
+ logger.debug(
+ _(u'Updated subscription %(subscription)s to %(action)s.'),
+ {
+ 'subscription': self,
+ 'action': action
+ }
+ )
+
+ # This triggers the subscribe() and/or unsubscribe() methods, taking
+ # care of stuff like maintaining the (un)subscribe date.
+ self.save()
+
+ def _subscribe(self):
+ """
+ Internal helper method for managing subscription state
+ during subscription.
+ """
+ logger.debug(u'Subscribing subscription %s.', self)
+
+ self.subscribe_date = now()
+ self.subscribed = True
+ self.unsubscribed = False
+
+ def _unsubscribe(self):
+ """
+ Internal helper method for managing subscription state
+ during unsubscription.
+ """
+ logger.debug(u'Unsubscribing subscription %s.', self)
+
+ self.subscribed = False
+ self.unsubscribed = True
+ self.unsubscribe_date = now()
+
+ def save(self, *args, **kwargs):
+ """
+ Perform some basic validation and state maintenance of Subscription.
+ TODO: Move this code to a more suitable place (i.e. `clean()`) and
+ cleanup the code. Refer to comment below and
+ https://docs.djangoproject.com/en/dev/ref/models/instances/#django.db.models.Model.clean
+ """
+ assert self.user or self.email_field, \
+ _('Neither an email nor a username is set. This asks for '
+ 'inconsistency!')
+ assert ((self.user and not self.email_field) or
+ (self.email_field and not self.user)), \
+ _('If user is set, email must be null and vice versa.')
+
+ # This is a lame way to find out if we have changed but using Django
+ # API internals is bad practice. This is necessary to discriminate
+ # from a state where we have never been subscribed but is mostly for
+ # backward compatibility. It might be very useful to make this just
+ # one attribute 'subscribe' later. In this case unsubscribed can be
+ # replaced by a method property.
+
+ if self.pk:
+ assert(Subscription.objects.filter(pk=self.pk).count() == 1)
+
+ subscription = Subscription.objects.get(pk=self.pk)
+ old_subscribed = subscription.subscribed
+ old_unsubscribed = subscription.unsubscribed
+
+ # If we are subscribed now and we used not to be so, subscribe.
+ # If we user to be unsubscribed but are not so anymore, subscribe.
+ if ((self.subscribed and not old_subscribed) or
+ (old_unsubscribed and not self.unsubscribed)):
+ self._subscribe()
+
+ assert not self.unsubscribed
+ assert self.subscribed
+
+ # If we are unsubcribed now and we used not to be so, unsubscribe.
+ # If we used to be subscribed but are not subscribed anymore,
+ # unsubscribe.
+ elif ((self.unsubscribed and not old_unsubscribed) or
+ (old_subscribed and not self.subscribed)):
+ self._unsubscribe()
+
+ assert not self.subscribed
+ assert self.unsubscribed
+ else:
+ if self.subscribed:
+ self._subscribe()
+ elif self.unsubscribed:
+ self._unsubscribe()
+
+ super(Subscription, self).save(*args, **kwargs)
+
+ ip = models.GenericIPAddressField(_("IP address"), blank=True, null=True)
+
+ newsletter = models.ForeignKey(
+ Newsletter, verbose_name=_('newsletter'), on_delete=models.CASCADE
+ )
+
+ create_date = models.DateTimeField(editable=False, default=now)
+
+ activation_code = models.CharField(
+ verbose_name=_('activation code'), max_length=40,
+ default=make_activation_code
+ )
+
+ subscribed = models.BooleanField(
+ default=False, verbose_name=_('subscribed'), db_index=True
+ )
+ subscribe_date = models.DateTimeField(
+ verbose_name=_("subscribe date"), null=True, blank=True
+ )
+
+ # This should be a pseudo-field, I reckon.
+ unsubscribed = models.BooleanField(
+ default=False, verbose_name=_('unsubscribed'), db_index=True
+ )
+ unsubscribe_date = models.DateTimeField(
+ verbose_name=_("unsubscribe date"), null=True, blank=True
+ )
+
+ def __str__(self):
+ if self.name:
+ return _(u"%(name)s <%(email)s> to %(newsletter)s") % {
+ 'name': self.name,
+ 'email': self.email,
+ 'newsletter': self.newsletter
+ }
+
+ else:
+ return _(u"%(email)s to %(newsletter)s") % {
+ 'email': self.email,
+ 'newsletter': self.newsletter
+ }
+
+ class Meta:
+ verbose_name = _('subscription')
+ verbose_name_plural = _('subscriptions')
+ unique_together = ('user', 'email_field', 'newsletter')
+
+ def get_recipient(self):
+ return get_address(self.name, self.email)
+
+ def send_activation_email(self, action):
+ assert action in ACTIONS, 'Unknown action: %s' % action
+
+ (subject_template, text_template, html_template) = \
+ self.newsletter.get_templates(action)
+
+ variable_dict = {
+ 'subscription': self,
+ 'site': Site.objects.get_current(),
+ 'newsletter': self.newsletter,
+ 'date': self.subscribe_date,
+ 'STATIC_URL': settings.STATIC_URL,
+ 'MEDIA_URL': settings.MEDIA_URL
+ }
+
+ unescaped_context = get_context(variable_dict, autoescape=False)
+
+ subject = subject_template.render(unescaped_context).strip()
+ text = text_template.render(unescaped_context)
+
+ message = EmailMultiAlternatives(
+ subject, text,
+ from_email=self.newsletter.get_sender(),
+ to=[self.email]
+ )
+
+ if html_template:
+ escaped_context = get_context(variable_dict)
+
+ message.attach_alternative(
+ html_template.render(escaped_context), "text/html"
+ )
+
+ message.send()
+
+ logger.debug(
+ u'Activation email sent for action "%(action)s" to %(subscriber)s '
+ u'with activation code "%(action_code)s".', {
+ 'action_code': self.activation_code,
+ 'action': action,
+ 'subscriber': self
+ }
+ )
+
+ def subscribe_activate_url(self):
+ return reverse('newsletter_update_activate', kwargs={
+ 'newsletter_slug': self.newsletter.slug,
+ 'email': self.email,
+ 'action': 'subscribe',
+ 'activation_code': self.activation_code
+ })
+
+ def unsubscribe_activate_url(self):
+ return reverse('newsletter_update_activate', kwargs={
+ 'newsletter_slug': self.newsletter.slug,
+ 'email': self.email,
+ 'action': 'unsubscribe',
+ 'activation_code': self.activation_code
+ })
+
+ def update_activate_url(self):
+ return reverse('newsletter_update_activate', kwargs={
+ 'newsletter_slug': self.newsletter.slug,
+ 'email': self.email,
+ 'action': 'update',
+ 'activation_code': self.activation_code
+ })
+
+
+@python_2_unicode_compatible
+class Article(models.Model):
+ """
+ An Article within a Message which will be send through a Submission.
+ """
+
+ sortorder = models.PositiveIntegerField(
+ help_text=_('Sort order determines the order in which articles are '
+ 'concatenated in a post.'),
+ verbose_name=_('sort order'), blank=True
+ )
+
+ title = models.CharField(max_length=200, verbose_name=_('title'))
+ text = models.TextField(verbose_name=_('text'))
+
+ url = models.URLField(
+ verbose_name=_('link'), blank=True, null=True
+ )
+
+ # Make this a foreign key for added elegance
+ image = ImageField(
+ upload_to='newsletter/images/%Y/%m/%d', blank=True, null=True,
+ verbose_name=_('image')
+ )
+
+ # Message this article is associated with
+ # TODO: Refactor post to message (post is legacy notation).
+ post = models.ForeignKey(
+ 'Message', verbose_name=_('message'), related_name='articles',
+ on_delete=models.CASCADE
+ )
+
+ class Meta:
+ ordering = ('sortorder',)
+ verbose_name = _('article')
+ verbose_name_plural = _('articles')
+ unique_together = ('post', 'sortorder')
+
+ def __str__(self):
+ return self.title
+
+ def save(self, **kwargs):
+ if self.sortorder is None:
+ # If saving a new object get the next available Article ordering
+ # as to assure uniqueness.
+ self.sortorder = self.post.get_next_article_sortorder()
+
+ super(Article, self).save()
+
+
+def get_default_newsletter():
+ return Newsletter.get_default()
+
+@python_2_unicode_compatible
+class Message(models.Model):
+ """ Message as sent through a Submission. """
+
+ title = models.CharField(max_length=200, verbose_name=_('title'))
+ slug = models.SlugField(verbose_name=_('slug'))
+
+ newsletter = models.ForeignKey(
+ Newsletter, verbose_name=_('newsletter'), on_delete=models.CASCADE, default=get_default_newsletter
+ )
+
+ date_create = models.DateTimeField(
+ verbose_name=_('created'), auto_now_add=True, editable=False
+ )
+ date_modify = models.DateTimeField(
+ verbose_name=_('modified'), auto_now=True, editable=False
+ )
+
+ class Meta:
+ verbose_name = _('message')
+ verbose_name_plural = _('messages')
+ unique_together = ('slug', 'newsletter')
+
+ def __str__(self):
+ try:
+ return _(u"%(title)s in %(newsletter)s") % {
+ 'title': self.title,
+ 'newsletter': self.newsletter
+ }
+ except Newsletter.DoesNotExist:
+ logger.warning('No newsletter has been set for this message yet.')
+ return self.title
+
+ def get_next_article_sortorder(self):
+ """ Get next available sortorder for Article. """
+
+ next_order = self.articles.aggregate(
+ models.Max('sortorder')
+ )['sortorder__max']
+
+ if next_order:
+ return next_order + 10
+ else:
+ return 10
+
+ @cached_property
+ def _templates(self):
+ """Return a (subject_template, text_template, html_template) tuple."""
+ return self.newsletter.get_templates('message')
+
+ @property
+ def subject_template(self):
+ return self._templates[0]
+
+ @property
+ def text_template(self):
+ return self._templates[1]
+
+ @property
+ def html_template(self):
+ return self._templates[2]
+
+ @classmethod
+ def get_default(cls):
+ try:
+ return cls.objects.order_by('-date_create').all()[0]
+ except IndexError:
+ return None
+
+
+@python_2_unicode_compatible
+class Submission(models.Model):
+ """
+ Submission represents a particular Message as it is being submitted
+ to a list of Subscribers. This is where actual queueing and submission
+ happen.
+ """
+ class Meta:
+ verbose_name = _('submission')
+ verbose_name_plural = _('submissions')
+
+ def __str__(self):
+ return _(u"%(newsletter)s on %(publish_date)s") % {
+ 'newsletter': self.message,
+ 'publish_date': self.publish_date
+ }
+
+ @cached_property
+ def extra_headers(self):
+ return {
+ 'List-Unsubscribe': 'http://%s%s' % (
+ Site.objects.get_current().domain,
+ reverse('newsletter_unsubscribe_request',
+ args=[self.message.newsletter.slug])
+ ),
+ }
+
+ def submit(self):
+ subscriptions = self.subscriptions.filter(subscribed=True)
+
+ logger.info(
+ ugettext(u"Submitting %(submission)s to %(count)d people"),
+ {'submission': self, 'count': subscriptions.count()}
+ )
+
+ assert self.publish_date < now(), \
+ 'Something smells fishy; submission time in future.'
+
+ self.sending = True
+ self.save()
+
+ try:
+ for idx, subscription in enumerate(subscriptions, start=1):
+ if hasattr(settings, 'NEWSLETTER_EMAIL_DELAY'):
+ time.sleep(settings.NEWSLETTER_EMAIL_DELAY)
+ if hasattr(settings, 'NEWSLETTER_BATCH_SIZE') and settings.NEWSLETTER_BATCH_SIZE > 0:
+ if idx % settings.NEWSLETTER_BATCH_SIZE == 0:
+ time.sleep(settings.NEWSLETTER_BATCH_DELAY)
+ self.send_message(subscription)
+ self.sent = True
+
+ finally:
+ self.sending = False
+ self.save()
+
+ def send_message(self, subscription):
+ variable_dict = {
+ 'subscription': subscription,
+ 'site': Site.objects.get_current(),
+ 'submission': self,
+ 'message': self.message,
+ 'newsletter': self.newsletter,
+ 'date': self.publish_date,
+ 'STATIC_URL': settings.STATIC_URL,
+ 'MEDIA_URL': settings.MEDIA_URL
+ }
+
+ unescaped_context = get_context(variable_dict, autoescape=False)
+
+ subject = self.message.subject_template.render(
+ unescaped_context).strip()
+ text = self.message.text_template.render(unescaped_context)
+
+ message = EmailMultiAlternatives(
+ subject, text,
+ from_email=self.newsletter.get_sender(),
+ to=[subscription.get_recipient()],
+ headers=self.extra_headers,
+ )
+
+ if self.message.html_template:
+ escaped_context = get_context(variable_dict)
+
+ message.attach_alternative(
+ self.message.html_template.render(escaped_context),
+ "text/html"
+ )
+
+ try:
+ logger.debug(
+ ugettext(u'Submitting message to: %s.'),
+ subscription
+ )
+
+ message.send()
+
+ except Exception as e:
+ # TODO: Test coverage for this branch.
+ logger.error(
+ ugettext(u'Message %(subscription)s failed '
+ u'with error: %(error)s'),
+ {'subscription': subscription,
+ 'error': e}
+ )
+
+ @classmethod
+ def submit_queue(cls):
+ todo = cls.objects.filter(
+ prepared=True, sent=False, sending=False,
+ publish_date__lt=now()
+ )
+
+ for submission in todo:
+ submission.submit()
+
+ @classmethod
+ def from_message(cls, message):
+ logger.debug(ugettext('Submission of message %s'), message)
+ submission = cls()
+ submission.message = message
+ submission.newsletter = message.newsletter
+ submission.save()
+ try:
+ submission.subscriptions.set(message.newsletter.get_subscriptions())
+ except AttributeError: # Django < 1.10
+ submission.subscriptions = message.newsletter.get_subscriptions()
+ return submission
+
+ def save(self, **kwargs):
+ """ Set the newsletter from associated message upon saving. """
+ assert self.message.newsletter
+
+ self.newsletter = self.message.newsletter
+
+ return super(Submission, self).save()
+
+
+
+ def get_absolute_url(self):
+ assert self.newsletter.slug
+ assert self.message.slug
+
+ return reverse(
+ 'newsletter_archive_detail', kwargs={
+ 'newsletter_slug': self.newsletter.slug,
+ 'year': self.publish_date.year,
+ 'month': self.publish_date.month,
+ 'day': self.publish_date.day,
+ 'slug': self.message.slug
+ }
+ )
+
+ newsletter = models.ForeignKey(
+ Newsletter, verbose_name=_('newsletter'), editable=False,
+ on_delete=models.CASCADE
+ )
+ message = models.ForeignKey(
+ Message, verbose_name=_('message'), editable=True, null=False,
+ on_delete=models.CASCADE
+ )
+
+ subscriptions = models.ManyToManyField(
+ 'Subscription',
+ help_text=_('If you select none, the system will automatically find '
+ 'the subscribers for you.'),
+ blank=True, db_index=True, verbose_name=_('recipients'),
+ limit_choices_to={'subscribed': True}
+ )
+
+ publish_date = models.DateTimeField(
+ verbose_name=_('publication date'), blank=True, null=True,
+ default=now, db_index=True
+ )
+ publish = models.BooleanField(
+ default=True, verbose_name=_('publish'),
+ help_text=_('Publish in archive.'), db_index=True
+ )
+
+ prepared = models.BooleanField(
+ default=False, verbose_name=_('prepared'),
+ db_index=True, editable=False
+ )
+ sent = models.BooleanField(
+ default=False, verbose_name=_('sent'),
+ db_index=True, editable=False
+ )
+ sending = models.BooleanField(
+ default=False, verbose_name=_('sending'),
+ db_index=True, editable=False
+ )
+
+def get_address(name, email):
+ # Converting name to ascii for compatibility with django < 1.9.
+ # Remove this when django 1.8 is no longer supported.
+ if LooseVersion(django.get_version()) < LooseVersion('1.9'):
+ name = name.encode('ascii', 'ignore').decode('ascii').strip()
+ if name:
+ return u'%s <%s>' % (name, email)
+ else:
+ return u'%s' % email
diff --git a/app/lttr/urls.py b/app/lttr/urls.py
new file mode 100644
index 0000000..84b0bca
--- /dev/null
+++ b/app/lttr/urls.py
@@ -0,0 +1,24 @@
+from django.urls import path
+
+from . import views
+
+app_name = "lttr"
+
+urlpatterns = [
+ path(
+ r'<int:year>/<int:month>/<str:slug>',
+ views.NewsletterMailingDetail.as_view(),
+ name="detail"
+ ),
+ path(
+ r'<int:page>',
+ views.NewsletterListView.as_view(),
+ name="list"
+ ),
+ path(
+ r'',
+ views.NewsletterListView.as_view(),
+ {'page': 1},
+ name="list"
+ ),
+]
diff --git a/app/lttr/validators.py b/app/lttr/validators.py
new file mode 100644
index 0000000..754df3b
--- /dev/null
+++ b/app/lttr/validators.py
@@ -0,0 +1,18 @@
+from django.contrib.auth import get_user_model
+from django.forms.utils import ValidationError
+from django.utils.translation import ugettext_lazy as _
+
+
+def validate_email_nouser(email):
+ """
+ Check if the email address does not belong to an existing user.
+ """
+ # Check whether we should be subscribed to as a user
+ User = get_user_model()
+
+ if User.objects.filter(email__exact=email).exists():
+ raise ValidationError(_(
+ "The e-mail address '%(email)s' belongs to a user with an "
+ "account on this site. Please log in as that user "
+ "and try again."
+ ) % {'email': email})
diff --git a/app/lttr/views.py b/app/lttr/views.py
new file mode 100644
index 0000000..786bc5c
--- /dev/null
+++ b/app/lttr/views.py
@@ -0,0 +1,54 @@
+from django.views.generic import ListView, CreateView
+from django.views.generic.detail import DetailView
+from django.views.generic.dates import YearArchiveView, MonthArchiveView
+from django.contrib.syndication.views import Feed
+from django.db.models import Q
+from django.conf import settings
+
+from utils.views import PaginatedListView
+
+from .models import NewsletterMailing, Subscriber, Newsletter
+from .forms import SubscribeRequestForm
+
+
+class NewsletterMailingDetail(DetailView):
+ model = NewsletterMailing
+ slug_field = "slug"
+
+ def get_queryset(self):
+ queryset = super(NewsletterMailingDetail, self).get_queryset()
+ return queryset.select_related('location').prefetch_related('field_notes').prefetch_related('books')
+
+ def get_object(self, queryset=None):
+ obj = super(NewsletterMailingDetail, self).get_object(queryset=queryset)
+ self.location = obj.location
+ return obj
+
+ def get_context_data(self, **kwargs):
+ context = super(NewsletterMailingDetail, self).get_context_data(**kwargs)
+ return context
+
+
+class NewsletterListView(CreateView):
+ model = Subscriber
+ form_class = SubscribeRequestForm
+ """
+ Return a subscribe form and list of Newsletter posts in reverse chronological order
+ """
+
+ def get_form_kwargs(self):
+ kwargs = super(NewsletterListView, self).get_form_kwargs()
+ kwargs['newsletter'] = Newsletter.objects.get(title="Friends of a Long Year")
+ return kwargs
+
+ def get_context_data(self, **kwargs):
+ context = super(NewsletterListView, self).get_context_data(**kwargs)
+ context['mailings'] = NewsletterMailing.objects.filter(status=1)
+ return context
+
+ def form_valid(self, form, **kwargs):
+ form.instance.user = settings.AUTH_USER_MODEL.objects.get_or_create(email=form.instance.email)
+ self.object = form.save()
+ return super(NewsletterListView, self).form_valid(form)
+
+ #super(NewsletterListView, self).form_valid()
diff --git a/config/base_urls.py b/config/base_urls.py
index 195bc55..f8848da 100644
--- a/config/base_urls.py
+++ b/config/base_urls.py
@@ -38,7 +38,7 @@ urlpatterns = [
path(r'luximages/insert/', utils.views.insert_image),
path(r'sitemap.xml', sitemap, {'sitemaps': sitemaps}),
path(r'links/', include('links.urls')),
- path(r'lttr/', include('newsletter.urls')),
+ path(r'lttr/', include('lttr.urls')),
path(r'jrnl/', include('jrnl.urls')),
path(r'projects/', include('projects.urls')),
path(r'locations/', include('locations.urls')),
diff --git a/design/templates/lttr/subscriber_form.html b/design/templates/lttr/subscriber_form.html
new file mode 100644
index 0000000..ded2c5f
--- /dev/null
+++ b/design/templates/lttr/subscriber_form.html
@@ -0,0 +1,37 @@
+{% extends 'base.html' %}
+{% load typogrify_tags %}
+
+{% block pagetitle %}Luxagraf | Friends of a Long Year {% endblock %}
+{% block metadescription %}An infrequesnt mailing list about travel, photography, tools, walking, the natural world and other ephemera.{% endblock %}
+
+{% block primary %}<ul class="bl" id="breadcrumbs" itemscope itemtype="http://data-vocabulary.org/Breadcrumb">
+ <li><a href="/" title="luxagraf homepage" itemprop="url"><span itemprop="title">Home</span></a> &rarr; </li>
+ <li>Lttr</li>
+ </ul>
+ <main role="main" id="essay-archive" class="essay-archive archive-list">
+ <div class="essay-intro">
+ <h2>Sign up for the newsletter.</h2>
+ <form action="" method="post" class="generic-form flex">{% csrf_token %}
+ {% for field in form %}
+ <fieldset>
+ {{field.label_tag}}
+ {{field}}
+ </fieldset>
+ {%endfor%}
+ <input type="submit" name="post" class="submit-post btn btn-hollow" value="Subscribe" />
+ </form>
+ <p>Join <em>Friends of a Long Year</em>, an infrequent mailing that will keep you up-to-date with luxagraf and offer some thoughts on topics like travel, photography, the natural world, tools, walking and other ephemera. It comes about twice a month, sometimes less, sometimes more. Unsubscribing is easy. It's all self-hosted, secure, and <a href="/privacy" title="My privacy policy">private</a>.</p>
+ <p>The name comes from the great early 20th century explorer and desert rat, Mary Hunter Austin, whose collected essays, <a href="https://archive.org/details/lostbordersillu00brotgoog/page/n8"><cite>Lost Borders</cite></a> is dedicated to the "Friends of a Long Year". This somewhoat inscrutable dedication grabbed me, and seemed like the perfect name for this mailing list.</p>
+ </div>
+ <h1 class="topic-hed">Letters</h1>
+ <ul>{% for object in object_list %}
+ <li class="h-entry hentry" itemscope itemType="http://schema.org/Article">
+ <span class="date dt-published">{{object.pub_date|date:"F Y"}}</span>
+ <a href="{{object.get_absolute_url}}">
+ <h2>{{object.title|safe|smartypants|widont}}</h2>
+ <p class="p-summary">{% if object.sub_title %}{{object.sub_title|safe|smartypants}}{%else%}{{object.metadescription}}{%endif%}</p>
+ </a>
+ </li>
+ {%endfor%}</ul>
+ </main>
+{%endblock%}