diff options
-rw-r--r-- | app/lttr/__init__.py | 0 | ||||
-rw-r--r-- | app/lttr/forms.py | 75 | ||||
-rw-r--r-- | app/lttr/migrations/0001_initial.py | 60 | ||||
-rw-r--r-- | app/lttr/migrations/0002_subscriber_email_field.py | 18 | ||||
-rw-r--r-- | app/lttr/migrations/__init__.py | 0 | ||||
-rw-r--r-- | app/lttr/models.py | 311 | ||||
-rw-r--r-- | app/lttr/modelsnl.py | 719 | ||||
-rw-r--r-- | app/lttr/urls.py | 24 | ||||
-rw-r--r-- | app/lttr/validators.py | 18 | ||||
-rw-r--r-- | app/lttr/views.py | 54 | ||||
-rw-r--r-- | config/base_urls.py | 2 | ||||
-rw-r--r-- | design/templates/lttr/subscriber_form.html | 37 |
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> → </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%} |