import datetime from django.dispatch import receiver from django.contrib.gis.db import models from django.db.models.signals import post_save from django.contrib.sites.models import Site from django.template.loader import select_template from django.utils import timezone from django.utils.text import slugify from django.urls import reverse from django.core.mail import send_mail from django.conf import settings from django.utils.crypto import get_random_string from django.core.mail import EmailMultiAlternatives from django.template import Context, Template from bs4 import BeautifulSoup from taggit.managers import TaggableManager from utils.util import render_images, parse_video, markdown_to_html from taxonomy.models import TaggedItems from media.models import LuxImage, LuxImageSize from posts.models import Post # Possible actions that user can perform ACTIONS = ('subscribe', 'unsubscribe', 'update') def markdown_to_emailhtml(base_html): soup = BeautifulSoup(base_html, "lxml") for p in soup.find_all('p'): p.attrs['style']="margin-top:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;font-weight:normal;margin-bottom:1.4em;font-size:17px;line-height:1.5;hyphens:auto;color:#222222;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,sans-serif !important;" for i in soup.find_all('img'): i.attrs['width']="720" i.attrs['style']="margin-top:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;display:inline;margin-bottom:0;width:100% !important;max-width:100% !important;height:auto !important;max-height:auto !important;" for h in soup.find_all('hr'): h.attrs['style']="padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;width:20%;margin-top:40px;margin-bottom:40px;margin-right:0px;margin-left:0px;border-width:0;border-top-width:1px;border-top-style:solid;border-top-color:#ddd;" return str(soup)[12:-14] 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) slug = models.SlugField(db_index=True, unique=True) intro = models.TextField(blank=True, null=True) 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('lttr: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) def get_template_plain(self): return 'lttr/emails/%s_plain_text_email.txt' % self.slug def get_template_html(self): return 'lttr/emails/%s_html_email.html' % self.slug 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 = 'lttr/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, ]) html_template = select_template([ tpl_root + '%(newsletter)s/%(action)s.html' % tpl_subst, tpl_root + '%(action)s.html' % tpl_subst, ]) return (subject_template, text_template, html_template) def get_sender(self): return 'Scott Gilbertson ' @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 """ newsletter = models.ForeignKey(Newsletter, on_delete=models.CASCADE) post = models.ForeignKey(Post, null=True, blank=True, on_delete=models.SET_NULL) title = models.CharField(max_length=250, blank=True) subtitle = models.CharField(max_length=250, null=True, blank=True) body_html = models.TextField(blank=True) body_email_html = models.TextField(blank=True) body_markdown = models.TextField(blank=True) pub_date = models.DateTimeField(blank=True) featured_image = models.ForeignKey(LuxImage, on_delete=models.CASCADE, null=True, blank=True) class Meta: ordering = ('-title', '-pub_date') def __str__(self): return self.title def email_encode(self): return self.body_markdown def save(self, *args, **kwargs): created = self.pk is None if created and self.post: self.title = self.post.title self.subtitle = self.post.subtitle self.body_markdown = self.post.body_markdown self.pub_date = self.post.pub_date self.featured_image = self.post.featured_image self.issue = self.post.issue if not created: md = render_images(self.body_markdown) self.body_html = markdown_to_html(md) self.body_email_html = markdown_to_emailhtml(self.body_html) self.date_created = timezone.now() self.issue = self.post.issue if created and not self.featured_image: self.featured_image = LuxImage.objects.latest() super(NewsletterMailing, self).save() 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, blank=True, null=True) date_created = models.DateTimeField(blank=True, auto_now_add=True, editable=False) date_updated = models.DateTimeField(blank=True, auto_now=True, editable=False) newsletter = models.ForeignKey(Newsletter, on_delete=models.CASCADE) 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): if self.user: return self.user.username return self.email_field 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('lttr:newsletter_activate', kwargs={ 'slug': self.newsletter.slug, 'activation_code': self.activation_code }) def unsubscribe_activate_url(self): return reverse('lttr:newsletter_unsubscribe', kwargs={ 'slug': self.newsletter.slug, 'activation_code': self.activation_code }) def update_activate_url(self): return reverse('lttr:newsletter_update_activate', kwargs={ 'slug': self.newsletter.slug, 'action': 'update', 'activation_code': self.activation_code }) def get_address(name, email): if name: return u'%s <%s>' % (name, email) else: return u'%s' % email class StatusType(models.IntegerChoices): INIT = 0, ('Initialized') SENT = 1, ('Sent') ERROR = 2, ('Error') class MailingStatus(models.Model): newsletter_mailing = models.ForeignKey(NewsletterMailing, on_delete=models.CASCADE, verbose_name=('newsletter')) subscriber = models.ForeignKey(Subscriber, on_delete=models.CASCADE, verbose_name=('subscriber')) status = models.IntegerField(choices=StatusType.choices, null=True) creation_date = models.DateTimeField(('creation date'), auto_now_add=True) def newsletter(self): return self.newsletter_mailing.newsletter def __str__(self): return '%s : %s : %s' % (self.newsletter_mailing, self.subscriber, self.get_status_display()) class Meta: ordering = ('-creation_date',) verbose_name = ('subscriber mailing status') verbose_name_plural = ('subscriber mailing statuses') ''' from lttr.mailer import SendShit mailing = NewsletterMailing.objects.get(pk=1) newsletter = Newsletter.objects.get(pk=3) n = SendShit(newsletter, mailing, 1) n.send_mailings() ''' def send_notification_email(newsletter, message, instance): recipient_list = ['sng@luxagraf.net',] subject = ('[%(site)s] New Subscriber to "%(object)s"') % { 'site': Site.objects.get_current().name, 'object': newsletter, } send_mail(subject, message, settings.DEFAULT_FROM_EMAIL, recipient_list, fail_silently=True) @receiver(post_save, sender=Subscriber) def post_save_events(sender, update_fields, created, instance, **kwargs): if instance.subscribed: message = "%s has signed up for %s." %(instance.email_field, instance.newsletter) send_notification_email(instance.newsletter, message, instance)