summaryrefslogtreecommitdiff
path: root/app/lttr/modelsnl.py
diff options
context:
space:
mode:
authorluxagraf <sng@luxagraf.net>2019-02-11 13:51:21 -0600
committerluxagraf <sng@luxagraf.net>2019-02-11 13:51:21 -0600
commitee2803d2f85888ce42fd6371ef606f4a9c6fd88b (patch)
treed115dcdc3e4cf5640c0cd5f216bafe880f3c5574 /app/lttr/modelsnl.py
parente7d938a42424fd31d36d7cfdd1533d87f236b05a (diff)
added newsletter
Diffstat (limited to 'app/lttr/modelsnl.py')
-rw-r--r--app/lttr/modelsnl.py719
1 files changed, 719 insertions, 0 deletions
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