├── .gitignore ├── .travis.yml ├── AUTHORS.rst ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.rst ├── courriers ├── __init__.py ├── admin.py ├── backends │ ├── __init__.py │ ├── base.py │ ├── campaign.py │ ├── mailjet_rest.py │ └── simple.py ├── base_models.py ├── compat.py ├── forms.py ├── locale │ ├── de │ │ └── LC_MESSAGES │ │ │ ├── django.mo │ │ │ └── django.po │ ├── en │ │ └── LC_MESSAGES │ │ │ ├── django.mo │ │ │ └── django.po │ └── fr │ │ └── LC_MESSAGES │ │ ├── django.mo │ │ └── django.po ├── management │ ├── __init__.py │ └── commands │ │ └── __init__.py ├── migrations │ ├── 0001_initial.py │ └── __init__.py ├── models │ ├── __init__.py │ ├── newsletter.py │ ├── newsletteritem.py │ ├── newsletterlist.py │ └── newslettersegment.py ├── settings.py ├── signals.py ├── tasks.py ├── templates │ ├── admin │ │ └── courriers │ │ │ └── newsletter │ │ │ └── change_form.html │ └── courriers │ │ ├── newsletter_detail.html │ │ ├── newsletter_list.html │ │ ├── newsletter_list_subscribe_done.html │ │ ├── newsletter_list_subscribe_form.html │ │ ├── newsletter_list_unsubscribe.html │ │ ├── newsletter_list_unsubscribe_done.html │ │ ├── newsletter_raw_detail.html │ │ └── newsletter_raw_detail.txt ├── tests │ ├── __init__.py │ ├── celery.py │ ├── migrations │ │ ├── 0001_initial.py │ │ └── __init__.py │ ├── models.py │ ├── settings.py │ ├── templates │ │ └── 404.html │ └── tests.py ├── urls.py ├── utils.py └── views.py ├── manage.py ├── setup.py └── tox.ini /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | *.pot 3 | *.pyc 4 | *.orig 5 | local_settings.py 6 | .coverage 7 | temp.py 8 | .tox 9 | *.egg-info -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: required 2 | language: python 3 | python: 3.8 4 | 5 | install: pip install tox 6 | script: tox 7 | -------------------------------------------------------------------------------- /AUTHORS.rst: -------------------------------------------------------------------------------- 1 | Florent Messa 2 | 3 | Adèle Delamarche 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2013 Ulule 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.rst 2 | include MANIFEST.in 3 | include Makefile 4 | recursive-include courriers/templates *.html *.txt 5 | recursive-include courriers/locale * 6 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | pep8: 2 | flake8 courriers --ignore=E501,E127,E128,E124 3 | 4 | test: 5 | coverage run --branch --source=courriers manage.py test courriers 6 | coverage report --omit=courriers/test* --omit=courriers/migrations/* 7 | 8 | release: 9 | python setup.py register sdist upload 10 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | django-courriers 2 | ================ 3 | 4 | .. image:: https://secure.travis-ci.org/ulule/django-courriers.png?branch=master 5 | :alt: Build Status 6 | :target: http://travis-ci.org/ulule/django-courriers 7 | 8 | A generic application to manage your newsletters 9 | 10 | What it does? 11 | ------------- 12 | 13 | django-courriers has four models: 14 | 15 | - ``NewsletterList`` which represents a newsletter list 16 | - ``Newsletter`` which represents a newsletter 17 | - ``NewsletterItem`` an item of a newsletter. It could be a content-type 18 | - ``NewsletterSubscriber`` which represents a user who is subscribed to a newsletter 19 | 20 | 21 | You have the choice between two backends to manage and send your emails: 22 | 23 | - ``SimpleBackend``, a simple backend to send emails with Django and 24 | your current smtp configuration 25 | - ``MailJetBackend``, a `Mailjet`_ backend which uses `mailjet library`_ 26 | 27 | 28 | Installation 29 | ------------ 30 | 31 | 1. Download the package on GitHub_ or simply install it via PyPi 32 | 2. Add ``courriers`` to your ``INSTALLED_APPS`` :: 33 | 34 | INSTALLED_APPS = ( 35 | 'courriers', 36 | ) 37 | 38 | 3. Sync your database using ``syncdb`` command from django command line 39 | 4. Configure settings 40 | 41 | You have to specify which backend you want to use in your settings :: 42 | 43 | COURRIERS_BACKEND_CLASS = 'courriers.backends.simple.SimpleBackend' 44 | 45 | A quick reminder: you can also set your custom ``DEFAULT_FROM_EMAIL`` in Django settings. 46 | 47 | Backends 48 | -------- 49 | 50 | courriers.backends.simple.SimpleBackend 51 | ........................................ 52 | 53 | A simple backend to send your emails with Django and 54 | your current smtp configuration 55 | 56 | courriers.backends.mailjet.MailjetBackend 57 | .............................................. 58 | 59 | A backend to manage your newsletters with Mailjet. 60 | 61 | 62 | What you need to do for mailjet 63 | ................................. 64 | 65 | - Create an account on Mailjet 66 | - Get your API key and API Secret key 67 | - Add it to your settings with others options as described below 68 | - Install the `mailjet library`_ 69 | - Create a list or more if you have users 70 | from different countries 71 | 72 | With this backend you have to provide additional settings :: 73 | 74 | COURRIERS_MAILJET_API_KEY = 'Your API key' 75 | COURRIERS_MAILJET_API_SECRET_KEY = 'Your API Secret key' 76 | COURRIERS_DEFAULT_FROM_NAME = 'Your name' 77 | 78 | .. _GitHub: https://github.com/ulule/django-courriers 79 | .. _Mailjet: https://eu.mailjet.com/ 80 | .. _mailjet library: https://pypi.python.org/pypi/mailjet/ 81 | -------------------------------------------------------------------------------- /courriers/__init__.py: -------------------------------------------------------------------------------- 1 | version = (0, 9, 1) 2 | 3 | __version__ = ".".join(map(str, version)) 4 | -------------------------------------------------------------------------------- /courriers/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.conf.urls import url 3 | from django import forms 4 | from django.shortcuts import get_object_or_404 5 | from django.utils.translation import ugettext as _ 6 | from django.http import HttpResponseRedirect 7 | from django.urls import reverse 8 | 9 | from .models import Newsletter, NewsletterItem, NewsletterList, NewsletterSegment 10 | 11 | 12 | class NewsletterItemInline(admin.TabularInline): 13 | model = NewsletterItem 14 | 15 | 16 | class NewsletterAdminForm(forms.ModelForm): 17 | def clean(self, *args, **kwargs): 18 | segment = self.cleaned_data.get("newsletter_segment") 19 | newsletter_list = self.cleaned_data.get("newsletter_list") 20 | 21 | if ( 22 | segment 23 | and newsletter_list 24 | and segment.newsletter_list_id != newsletter_list.pk 25 | ): 26 | self.add_error( 27 | "newsletter_segment", 28 | "The segment is not attached to this newsletter list", 29 | ) 30 | 31 | class Meta: 32 | model = Newsletter 33 | exclude = () 34 | 35 | 36 | class NewsletterAdmin(admin.ModelAdmin): 37 | change_form_template = "admin/courriers/newsletter/change_form.html" 38 | 39 | list_display = ( 40 | "name", 41 | "headline", 42 | "published_at", 43 | "status", 44 | "newsletter_list_link", 45 | ) 46 | list_filter = ("published_at", "status") 47 | inlines = [NewsletterItemInline] 48 | form = NewsletterAdminForm 49 | 50 | def get_queryset(self, request): 51 | return ( 52 | super(NewsletterAdmin, self) 53 | .get_queryset(request) 54 | .select_related("newsletter_list") 55 | ) 56 | 57 | def get_urls(self): 58 | urls = super(NewsletterAdmin, self).get_urls() 59 | my_urls = [ 60 | url( 61 | r"^send/(?P(\d+))/$", 62 | self.send_newsletter, 63 | name="send_newsletter", 64 | ) 65 | ] 66 | return my_urls + urls 67 | 68 | def send_newsletter(self, request, newsletter_id): 69 | from courriers.backends import get_backend 70 | 71 | backend_klass = get_backend() 72 | backend = backend_klass() 73 | 74 | newsletter = get_object_or_404(Newsletter, pk=newsletter_id) 75 | backend.send_mails(newsletter) 76 | 77 | self.message_user(request, _('The newsletter "%s" has been sent.') % newsletter) 78 | return HttpResponseRedirect( 79 | reverse("admin:courriers_newsletter_change", args=(newsletter.id,)) 80 | ) 81 | 82 | def newsletter_list_link(self, obj): 83 | url = reverse( 84 | "admin:courriers_newsletterlist_change", args=(obj.newsletter_list.id,) 85 | ) 86 | return '%(name)s' % { 87 | "url": url, 88 | "name": obj.newsletter_list.name, 89 | } 90 | 91 | newsletter_list_link.allow_tags = True 92 | 93 | 94 | class NewsletterListAdmin(admin.ModelAdmin): 95 | list_display = ("name", "slug", "created_at") 96 | 97 | 98 | class NewsletterSegmentAdmin(admin.ModelAdmin): 99 | list_display = ("name", "newsletter_list", "lang") 100 | 101 | 102 | admin.site.register(Newsletter, NewsletterAdmin) 103 | admin.site.register(NewsletterList, NewsletterListAdmin) 104 | admin.site.register(NewsletterSegment, NewsletterSegmentAdmin) 105 | -------------------------------------------------------------------------------- /courriers/backends/__init__.py: -------------------------------------------------------------------------------- 1 | def get_backend(): 2 | from ..settings import BACKEND_CLASS 3 | from ..utils import load_class 4 | 5 | backend = load_class(BACKEND_CLASS) 6 | 7 | return backend 8 | -------------------------------------------------------------------------------- /courriers/backends/base.py: -------------------------------------------------------------------------------- 1 | class BaseBackend(object): 2 | def register(self, email, lang=None, user=None): 3 | raise NotImplemented 4 | 5 | def unregister(self, email, user=None): 6 | raise NotImplemented 7 | 8 | def exists(self, email, user=None): 9 | raise NotImplemented 10 | 11 | def send_mails(self, newsletter): 12 | raise NotImplemented 13 | -------------------------------------------------------------------------------- /courriers/backends/campaign.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from courriers.settings import FAIL_SILENTLY, DEFAULT_FROM_EMAIL, DEFAULT_FROM_NAME 4 | 5 | from django.conf import settings 6 | from django.core.exceptions import ImproperlyConfigured 7 | from django.utils import translation 8 | 9 | from .simple import SimpleBackend 10 | 11 | logger = logging.getLogger("courriers") 12 | 13 | 14 | class CampaignBackend(SimpleBackend): 15 | def send_mails(self, newsletter): 16 | if not newsletter.is_online(): 17 | raise Exception("This newsletter is not online. You can't send it.") 18 | 19 | nl_list = newsletter.newsletter_list 20 | nl_segment = newsletter.newsletter_segment 21 | self.send_campaign(newsletter, nl_list.list_id, nl_segment.segment_id) 22 | 23 | def _format_slug(self, *args): 24 | raise NotImplementedError 25 | 26 | def send_campaign(self, newsletter, list_id, segment_id): 27 | if not DEFAULT_FROM_EMAIL: 28 | raise ImproperlyConfigured( 29 | "You have to specify a DEFAULT_FROM_EMAIL in Django settings." 30 | ) 31 | if not DEFAULT_FROM_NAME: 32 | raise ImproperlyConfigured( 33 | "You have to specify a DEFAULT_FROM_NAME in Django settings." 34 | ) 35 | 36 | old_language = translation.get_language() 37 | language = newsletter.newsletter_segment.lang or settings.LANGUAGE_CODE 38 | 39 | translation.activate(language) 40 | 41 | try: 42 | self._send_campaign(newsletter, list_id, segment_id=segment_id) 43 | except Exception as e: 44 | logger.exception(e) 45 | 46 | if not FAIL_SILENTLY: 47 | raise e 48 | else: 49 | newsletter.sent = True 50 | newsletter.save(update_fields=("sent",)) 51 | 52 | translation.activate(old_language) 53 | 54 | def subscribe(self, email, list_id): 55 | pass 56 | 57 | def unsubscribe(self, email, list_id): 58 | pass 59 | -------------------------------------------------------------------------------- /courriers/backends/mailjet_rest.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import, unicode_literals 2 | 3 | from mailjet_rest import Client 4 | 5 | from django.core.exceptions import ImproperlyConfigured 6 | from django.template.loader import render_to_string 7 | 8 | from ..settings import ( 9 | MAILJET_API_KEY, 10 | DEFAULT_FROM_EMAIL, 11 | DEFAULT_FROM_NAME, 12 | MAILJET_API_SECRET_KEY, 13 | PRE_PROCESSORS, 14 | ) 15 | from .campaign import CampaignBackend 16 | from ..utils import load_class 17 | 18 | 19 | class MailjetRESTBackend(CampaignBackend): 20 | def __init__(self): 21 | if not MAILJET_API_KEY: 22 | raise ImproperlyConfigured( 23 | "Please specify your MAILJET API key in Django settings" 24 | ) 25 | 26 | if not MAILJET_API_SECRET_KEY: 27 | raise ImproperlyConfigured( 28 | "Please specify your MAILJET API SECRET key in Django settings" 29 | ) 30 | 31 | self.client = Client(auth=(MAILJET_API_KEY, MAILJET_API_SECRET_KEY)) 32 | 33 | def _send_campaign(self, newsletter, list_id, segment_id=None): 34 | subject = newsletter.name 35 | 36 | options = { 37 | "Subject": subject, 38 | "ContactsListID": list_id, 39 | "Locale": "en", 40 | "SenderEmail": DEFAULT_FROM_EMAIL, 41 | "Sender": DEFAULT_FROM_NAME, 42 | "SenderName": DEFAULT_FROM_NAME, 43 | "Title": subject, 44 | } 45 | 46 | if segment_id: 47 | options["SegmentationID"] = segment_id 48 | 49 | context = { 50 | "object": newsletter, 51 | "items": newsletter.items.select_related("newsletter"), 52 | "options": options, 53 | } 54 | 55 | html = render_to_string("courriers/newsletter_raw_detail.html", context) 56 | text = render_to_string("courriers/newsletter_raw_detail.txt", context) 57 | 58 | for pre_processor in PRE_PROCESSORS: 59 | html = load_class(pre_processor)(html) 60 | 61 | res = self.client.campaigndraft.create(data=options) 62 | 63 | result = res.json() 64 | 65 | campaign_id = result["Data"][0]["ID"] 66 | 67 | data = {"Html-part": html, "Text-part": text} 68 | 69 | self.client.campaigndraft_detailcontent.create(id=campaign_id, data=data) 70 | self.client.campaigndraft_send.create(id=campaign_id) 71 | 72 | def subscribe(self, list_id, email, lang=None, user=None): 73 | data = {"Action": "addforce", "Contacts": [{"Email": email}]} 74 | 75 | self.client.contactslist_ManageManyContacts.create(id=list_id, data=data) 76 | 77 | def unsubscribe(self, list_id, email, lang=None, user=None): 78 | data = {"Action": "unsub", "Contacts": [{"Email": email}]} 79 | 80 | self.client.contactslist_ManageManyContacts.create(id=list_id, data=data) 81 | -------------------------------------------------------------------------------- /courriers/backends/simple.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from .base import BaseBackend 3 | 4 | from django.core import mail 5 | from django.core.mail import EmailMultiAlternatives 6 | from django.template.loader import render_to_string 7 | from django.utils import translation 8 | from django.contrib.auth import get_user_model 9 | 10 | from ..settings import DEFAULT_FROM_EMAIL, PRE_PROCESSORS 11 | from ..utils import load_class 12 | 13 | User = get_user_model() 14 | 15 | 16 | class SimpleBackend(BaseBackend): 17 | def subscribe(self, list_id, email, lang=None, user=None): 18 | pass 19 | 20 | def unsubscribe(self, list_id, email, lang=None, user=None): 21 | pass 22 | 23 | def register(self, email, newsletter_list, lang=None, user=None): 24 | if not user: 25 | try: 26 | user = User.objects.get(email=email) 27 | except User.DoesNotExist: 28 | user = User.objects.create(email=email, username=email) 29 | 30 | user.subscribe(newsletter_list, lang=lang) 31 | 32 | def unregister(self, email, newsletter_list=None, user=None, lang=None): 33 | if not user: 34 | try: 35 | user = User.objects.get(email=email) 36 | except User.DoesNotExist: 37 | return 38 | 39 | user.unsubscribe(newsletter_list, lang=lang) 40 | 41 | def send_mails(self, newsletter, fail_silently=False, subscribers=None): 42 | connection = mail.get_connection(fail_silently=fail_silently) 43 | 44 | emails = [] 45 | 46 | old_language = translation.get_language() 47 | 48 | for subscriber in subscribers: 49 | if ( 50 | newsletter.newsletter_segment.lang 51 | and newsletter.newsletter_segment.lang != subscriber.lang 52 | ): 53 | continue 54 | 55 | translation.activate(subscriber.lang) 56 | 57 | email = EmailMultiAlternatives( 58 | newsletter.name, 59 | render_to_string( 60 | "courriers/newsletter_raw_detail.txt", 61 | {"object": newsletter, "subscriber": subscriber}, 62 | ), 63 | DEFAULT_FROM_EMAIL, 64 | [subscriber.email], 65 | connection=connection, 66 | ) 67 | 68 | html = render_to_string( 69 | "courriers/newsletter_raw_detail.html", 70 | { 71 | "object": newsletter, 72 | "items": newsletter.items.all().prefetch_related("newsletter"), 73 | "subscriber": subscriber, 74 | }, 75 | ) 76 | 77 | for pre_processor in PRE_PROCESSORS: 78 | html = load_class(pre_processor)(html) 79 | 80 | email.attach_alternative(html, "text/html") 81 | 82 | emails.append(email) 83 | 84 | translation.activate(old_language) 85 | 86 | results = connection.send_messages(emails) 87 | 88 | newsletter.sent = True 89 | newsletter.save(update_fields=("sent",)) 90 | 91 | return results 92 | -------------------------------------------------------------------------------- /courriers/base_models.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from django.db import models 4 | from django.contrib.contenttypes.models import ContentType 5 | from django.contrib.contenttypes.fields import GenericForeignKey 6 | from django.template.defaultfilters import slugify, truncatechars 7 | from django.utils.translation import gettext_lazy as _ 8 | from django.utils import timezone as datetime 9 | from django.urls import reverse 10 | from django.db.models.query import QuerySet 11 | 12 | from courriers.settings import ALLOWED_LANGUAGES 13 | 14 | 15 | def get_file_path(instance, filename): 16 | fname, ext = os.path.splitext(filename) 17 | filename = "{}{}".format(slugify(truncatechars(fname, 50)), ext) 18 | 19 | return os.path.join("courriers", "uploads", filename) 20 | 21 | 22 | class NewsletterList(models.Model): 23 | name = models.CharField(max_length=255) 24 | slug = models.SlugField(max_length=255, unique=True) 25 | description = models.TextField(blank=True, null=True) 26 | created_at = models.DateTimeField(auto_now_add=True) 27 | list_id = models.IntegerField(null=True) 28 | 29 | class Meta: 30 | abstract = True 31 | 32 | def __str__(self): 33 | return self.name or "" 34 | 35 | def get_absolute_url(self): 36 | return reverse("newsletter_list", kwargs={"slug": self.slug}) 37 | 38 | 39 | class NewsletterSegment(models.Model): 40 | name = models.CharField(max_length=255) 41 | segment_id = models.IntegerField() 42 | newsletter_list = models.ForeignKey( 43 | "courriers.NewsletterList", on_delete=models.PROTECT, related_name="lists" 44 | ) 45 | lang = models.CharField( 46 | max_length=10, blank=True, null=True, choices=ALLOWED_LANGUAGES 47 | ) 48 | 49 | class Meta: 50 | abstract = True 51 | 52 | def __str__(self): 53 | return self.name or "" 54 | 55 | 56 | class NewsletterQuerySet(QuerySet): 57 | def status_online(self): 58 | return self.filter( 59 | status=Newsletter.STATUS_ONLINE, published_at__lt=datetime.now() 60 | ).order_by("published_at") 61 | 62 | def get_previous(self, current_date): 63 | return ( 64 | self.status_online() 65 | .filter(published_at__lt=current_date) 66 | .order_by("-published_at") 67 | .first() 68 | ) 69 | 70 | def get_next(self, current_date): 71 | return ( 72 | self.status_online() 73 | .filter(published_at__gt=current_date) 74 | .order_by("-published_at") 75 | .first() 76 | ) 77 | 78 | 79 | class NewsletterManager(models.Manager): 80 | def get_queryset(self): 81 | return NewsletterQuerySet(self.model) 82 | 83 | def status_online(self): 84 | return self.get_queryset().status_online() 85 | 86 | def get_previous(self, current_date): 87 | return self.get_queryset().get_previous(current_date) 88 | 89 | def get_next(self, current_date): 90 | return self.get_queryset().get_next(current_date) 91 | 92 | 93 | class Newsletter(models.Model): 94 | STATUS_ONLINE = 1 95 | STATUS_DRAFT = 2 96 | 97 | STATUS_CHOICES = ((STATUS_ONLINE, _("Online")), (STATUS_DRAFT, _("Draft"))) 98 | 99 | name = models.CharField(max_length=255) 100 | published_at = models.DateTimeField(null=True) 101 | status = models.PositiveIntegerField( 102 | choices=STATUS_CHOICES, default=STATUS_DRAFT, db_index=True 103 | ) 104 | headline = models.TextField(blank=True, null=True) 105 | conclusion = models.TextField(blank=True, null=True) 106 | cover = models.ImageField(upload_to=get_file_path, blank=True, null=True) 107 | newsletter_list = models.ForeignKey( 108 | "courriers.NewsletterList", related_name="newsletters", on_delete=models.PROTECT 109 | ) 110 | newsletter_segment = models.ForeignKey( 111 | "courriers.NewsletterSegment", related_name="segments", on_delete=models.PROTECT 112 | ) 113 | sent = models.BooleanField(default=False, db_index=True) 114 | 115 | objects = NewsletterManager() 116 | 117 | class Meta: 118 | abstract = True 119 | 120 | def __str__(self): 121 | return self.name or "" 122 | 123 | def get_previous(self): 124 | return self.__class__.objects.filter( 125 | newsletter_list=self.newsletter_list_id 126 | ).get_previous(self.published_at) 127 | 128 | def get_next(self): 129 | return self.__class__.objects.filter( 130 | newsletter_list=self.newsletter_list_id 131 | ).get_next(self.published_at) 132 | 133 | def is_online(self): 134 | return self.status == self.STATUS_ONLINE 135 | 136 | def get_absolute_url(self): 137 | return reverse("newsletter_detail", args=[self.pk]) 138 | 139 | 140 | class NewsletterItem(models.Model): 141 | newsletter = models.ForeignKey( 142 | "courriers.Newsletter", related_name="items", on_delete=models.CASCADE 143 | ) 144 | content_type = models.ForeignKey( 145 | ContentType, on_delete=models.PROTECT, blank=True, null=True 146 | ) 147 | object_id = models.PositiveIntegerField(blank=True, null=True) 148 | content_object = GenericForeignKey("content_type", "object_id") 149 | name = models.CharField(max_length=255, blank=True, null=True) 150 | description = models.TextField(blank=True, null=True) 151 | image = models.ImageField(upload_to=get_file_path, blank=True, null=True) 152 | url = models.URLField(blank=True, null=True) 153 | position = models.PositiveIntegerField(null=True, blank=True) 154 | 155 | class Meta: 156 | ordering = ["position"] 157 | abstract = True 158 | 159 | def __str__(self): 160 | return self.name or "" 161 | -------------------------------------------------------------------------------- /courriers/compat.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | 3 | AUTH_USER_MODEL = getattr(settings, "AUTH_USER_MODEL", "auth.User") 4 | -------------------------------------------------------------------------------- /courriers/forms.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | from django.utils.translation import gettext_lazy as _ 3 | 4 | from .backends import get_backend 5 | from .tasks import subscribe, unsubscribe 6 | 7 | 8 | class SubscriptionForm(forms.Form): 9 | receiver = forms.EmailField( 10 | max_length=250, 11 | required=True, 12 | widget=forms.TextInput(attrs={"placeholder": _(u"Your email"), "size": "30"}), 13 | ) 14 | 15 | def __init__(self, *args, **kwargs): 16 | self.newsletter_list = kwargs.pop("newsletter_list", None) 17 | 18 | backend_klass = get_backend() 19 | 20 | self.backend = backend_klass() 21 | 22 | super(SubscriptionForm, self).__init__(*args, **kwargs) 23 | 24 | def save(self, user=None): 25 | subscribe.delay( 26 | self.cleaned_data.get("receiver"), 27 | self.newsletter_list.pk, 28 | user_id=user.pk if user else None, 29 | ) 30 | 31 | 32 | class UnsubscribeForm(forms.Form): 33 | email = forms.EmailField( 34 | max_length=250, 35 | required=True, 36 | widget=forms.TextInput(attrs={"placeholder": _(u"Your email"), "size": "30"}), 37 | ) 38 | from_all = forms.BooleanField(required=False) 39 | 40 | def __init__(self, *args, **kwargs): 41 | self.newsletter_list = kwargs.pop("newsletter_list", None) 42 | 43 | backend_klass = get_backend() 44 | 45 | self.backend = backend_klass() 46 | 47 | super(UnsubscribeForm, self).__init__(*args, **kwargs) 48 | 49 | def save(self, user=None): 50 | from_all = self.cleaned_data.get("from_all", False) 51 | 52 | if from_all or not self.newsletter_list: 53 | unsubscribe.delay( 54 | self.cleaned_data.get("email"), user_id=getattr(user, "pk", None) 55 | ) 56 | else: 57 | unsubscribe.delay( 58 | self.cleaned_data["email"], 59 | newsletter_list_id=self.newsletter_list.pk, 60 | user_id=getattr(user, "pk", None), 61 | ) 62 | -------------------------------------------------------------------------------- /courriers/locale/de/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ulule/django-courriers/c2c301a7c1a2d3c2765f3534381bc74ee7aa7632/courriers/locale/de/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /courriers/locale/de/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # Florian Schweikert , 2015. 5 | # 6 | msgid "" 7 | msgstr "" 8 | "Project-Id-Version: \n" 9 | "Report-Msgid-Bugs-To: \n" 10 | "POT-Creation-Date: 2015-08-18 18:38+0200\n" 11 | "PO-Revision-Date: 2015-08-18 18:47+0200\n" 12 | "Language: de\n" 13 | "MIME-Version: 1.0\n" 14 | "Content-Type: text/plain; charset=UTF-8\n" 15 | "Content-Transfer-Encoding: 8bit\n" 16 | "Plural-Forms: nplurals=2; plural=(n != 1);\n" 17 | "Last-Translator: \n" 18 | "Language-Team: \n" 19 | "X-Generator: Poedit 1.8.4\n" 20 | 21 | #: admin.py:41 22 | #, python-format 23 | msgid "The newsletter \"%s\" has been sent." 24 | msgstr "Die Newsletter \"%s\" wurde versendet." 25 | 26 | #: backends/mailchimp.py:26 27 | msgid "Please specify your MAILCHIMP API key in Django settings" 28 | msgstr "Bitte den ihren MAILCHIMP API Key in den Django settings angeben" 29 | 30 | #: backends/mailjet.py:30 31 | msgid "Please specify your MAILJET API key in Django settings" 32 | msgstr "Bitte den ihren MAILJET API Key in den Django settings angeben" 33 | 34 | #: backends/mailjet.py:33 35 | msgid "Please specify your MAILJET API SECRET key in Django settings" 36 | msgstr "Bitte den ihren MAILJET API SECRET Key in den Django settings angeben" 37 | 38 | #: forms.py:11 forms.py:50 39 | msgid "Your email" 40 | msgstr "Ihre Emailadresse" 41 | 42 | #: forms.py:37 43 | msgid "You already subscribe to this newsletter." 44 | msgstr "Sie haben diese Newsletter bereits abonniert." 45 | 46 | #: forms.py:68 47 | msgid "You are not subscribed to this newsletter." 48 | msgstr "Sie haben diese Newsletter nicht abonniert." 49 | 50 | #: models.py:118 51 | msgid "Online" 52 | msgstr "Online" 53 | 54 | #: models.py:119 55 | msgid "Draft" 56 | msgstr "Entwurf" 57 | 58 | #: templates/admin/courriers/newsletter/change_form.html:5 59 | msgid "Do you really want to send this newsletter?" 60 | msgstr "Wollen sie diese Newsletter wirklich versenden?" 61 | 62 | #: templates/admin/courriers/newsletter/change_form.html:8 63 | msgid "History" 64 | msgstr "Verlauf" 65 | 66 | #: templates/admin/courriers/newsletter/change_form.html:12 67 | msgid "View on site" 68 | msgstr "Auf der Webseite anzeigen" 69 | 70 | #: templates/courriers/newsletter_list_subscribe_done.html:2 71 | msgid "Thank you. You successfully subscribe to this newsletter !" 72 | msgstr "Vielen Dank. Sie haben diese Newsletter erfolgreich abonniert." 73 | 74 | #: templates/courriers/newsletter_list_subscribe_form.html:15 75 | msgid "Subscribe to the newsletter" 76 | msgstr "Newsletter abonnieren" 77 | 78 | #: templates/courriers/newsletter_list_subscribe_form.html:18 79 | msgid "Close" 80 | msgstr "Schließen" 81 | 82 | #: templates/courriers/newsletter_list_unsubscribe.html:15 83 | msgid "Your e-mail" 84 | msgstr "Ihre Emailadresse" 85 | 86 | #: templates/courriers/newsletter_list_unsubscribe_done.html:3 87 | msgid "Thank you. You're now unsubscribed from this newsletter." 88 | msgstr "Sie sind nun von dieser Newsletter abgemeldet." 89 | 90 | #: templates/courriers/newsletter_list_unsubscribe_done.html:5 91 | msgid "Thank you. You're now unsubscribed from all our newsletters." 92 | msgstr "Sie sind nun von allen unseren Newslettern abgemeldet." 93 | -------------------------------------------------------------------------------- /courriers/locale/en/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ulule/django-courriers/c2c301a7c1a2d3c2765f3534381bc74ee7aa7632/courriers/locale/en/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /courriers/locale/en/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # FIRST AUTHOR , YEAR. 5 | # 6 | #, fuzzy 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: PACKAGE VERSION\n" 10 | "Report-Msgid-Bugs-To: \n" 11 | "POT-Creation-Date: 2014-02-18 10:44+0100\n" 12 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 13 | "Last-Translator: FULL NAME \n" 14 | "Language-Team: LANGUAGE \n" 15 | "Language: \n" 16 | "MIME-Version: 1.0\n" 17 | "Content-Type: text/plain; charset=UTF-8\n" 18 | "Content-Transfer-Encoding: 8bit\n" 19 | 20 | #: admin.py:41 21 | #, python-format 22 | msgid "The newsletter \"%s\" has been sent." 23 | msgstr "" 24 | 25 | #: forms.py:10 forms.py:49 26 | msgid "Your email" 27 | msgstr "" 28 | 29 | #: forms.py:36 30 | msgid "You already subscribe to this newsletter." 31 | msgstr "" 32 | 33 | #: forms.py:67 34 | msgid "You are not subscribed to this newsletter." 35 | msgstr "" 36 | 37 | #: models.py:108 38 | msgid "Online" 39 | msgstr "" 40 | 41 | #: models.py:109 42 | msgid "Draft" 43 | msgstr "" 44 | 45 | #: backends/mailchimp.py:26 46 | msgid "Please specify your MAILCHIMP API key in Django settings" 47 | msgstr "" 48 | 49 | #: backends/mailjet.py:30 50 | msgid "Please specify your MAILJET API key in Django settings" 51 | msgstr "" 52 | 53 | #: backends/mailjet.py:33 54 | msgid "Please specify your MAILJET API SECRET key in Django settings" 55 | msgstr "" 56 | 57 | #: templates/admin/courriers/newsletter/change_form.html:5 58 | msgid "Do you really want to send this newsletter?" 59 | msgstr "" 60 | 61 | #: templates/admin/courriers/newsletter/change_form.html:8 62 | msgid "History" 63 | msgstr "" 64 | 65 | #: templates/admin/courriers/newsletter/change_form.html:12 66 | msgid "View on site" 67 | msgstr "" 68 | 69 | #: templates/courriers/newsletter_list_subscribe_done.html:2 70 | msgid "Thank you. You successfully subscribe to this newsletter !" 71 | msgstr "" 72 | 73 | #: templates/courriers/newsletter_list_subscribe_form.html:16 74 | msgid "Subscribe to the newsletter" 75 | msgstr "" 76 | 77 | #: templates/courriers/newsletter_list_subscribe_form.html:19 78 | msgid "Close" 79 | msgstr "" 80 | 81 | #: templates/courriers/newsletter_list_unsubscribe.html:16 82 | msgid "Your e-mail" 83 | msgstr "" 84 | 85 | #: templates/courriers/newsletter_list_unsubscribe_done.html:3 86 | msgid "Thank you. You're now unsubscribed from this newsletter." 87 | msgstr "" 88 | 89 | #: templates/courriers/newsletter_list_unsubscribe_done.html:5 90 | msgid "Thank you. You're now unsubscribed from all our newsletters." 91 | msgstr "" 92 | -------------------------------------------------------------------------------- /courriers/locale/fr/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ulule/django-courriers/c2c301a7c1a2d3c2765f3534381bc74ee7aa7632/courriers/locale/fr/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /courriers/locale/fr/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # FIRST AUTHOR , YEAR. 5 | # 6 | #, fuzzy 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: PACKAGE VERSION\n" 10 | "Report-Msgid-Bugs-To: \n" 11 | "POT-Creation-Date: 2014-02-18 10:45+0100\n" 12 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 13 | "Last-Translator: FULL NAME \n" 14 | "Language-Team: LANGUAGE \n" 15 | "Language: \n" 16 | "MIME-Version: 1.0\n" 17 | "Content-Type: text/plain; charset=UTF-8\n" 18 | "Content-Transfer-Encoding: 8bit\n" 19 | "Plural-Forms: nplurals=2; plural=(n > 1);\n" 20 | 21 | #: admin.py:41 22 | #, python-format 23 | msgid "The newsletter \"%s\" has been sent." 24 | msgstr "La newsletter \"%s\" a été envoyée." 25 | 26 | #: forms.py:10 forms.py:49 27 | msgid "Your email" 28 | msgstr "Votre email" 29 | 30 | #: forms.py:36 31 | msgid "You already subscribe to this newsletter." 32 | msgstr "Vous êtes déjà inscrit à cette newsletter." 33 | 34 | #: forms.py:67 35 | msgid "You are not subscribed to this newsletter." 36 | msgstr "Vous n'êtes pas inscrit à cette newsletter." 37 | 38 | #: models.py:108 39 | msgid "Online" 40 | msgstr "En ligne" 41 | 42 | #: models.py:109 43 | msgid "Draft" 44 | msgstr "Brouillon" 45 | 46 | #: backends/mailchimp.py:26 47 | msgid "Please specify your MAILCHIMP API key in Django settings" 48 | msgstr "Merci de spécifier votre API key MAILCHIMP dans les settings Django" 49 | 50 | #: backends/mailjet.py:30 51 | #, fuzzy 52 | msgid "Please specify your MAILJET API key in Django settings" 53 | msgstr "Merci de spécifier votre API key MAILCHIMP dans les settings Django" 54 | 55 | #: backends/mailjet.py:33 56 | #, fuzzy 57 | msgid "Please specify your MAILJET API SECRET key in Django settings" 58 | msgstr "Merci de spécifier votre API key MAILCHIMP dans les settings Django" 59 | 60 | #: templates/admin/courriers/newsletter/change_form.html:5 61 | #, fuzzy 62 | msgid "Do you really want to send this newsletter?" 63 | msgstr "Vous n'êtes pas inscrit à cette newsletter." 64 | 65 | #: templates/admin/courriers/newsletter/change_form.html:8 66 | msgid "History" 67 | msgstr "Historique" 68 | 69 | #: templates/admin/courriers/newsletter/change_form.html:12 70 | msgid "View on site" 71 | msgstr "Voir sur le site" 72 | 73 | #: templates/courriers/newsletter_list_subscribe_done.html:2 74 | msgid "Thank you. You successfully subscribe to this newsletter !" 75 | msgstr "Merci. Vous êtes maintenant inscrit à cette newsletter !" 76 | 77 | #: templates/courriers/newsletter_list_subscribe_form.html:16 78 | msgid "Subscribe to the newsletter" 79 | msgstr "S'inscrire à cette newsletter." 80 | 81 | #: templates/courriers/newsletter_list_subscribe_form.html:19 82 | msgid "Close" 83 | msgstr "" 84 | 85 | #: templates/courriers/newsletter_list_unsubscribe.html:16 86 | msgid "Your e-mail" 87 | msgstr "Votre email" 88 | 89 | #: templates/courriers/newsletter_list_unsubscribe_done.html:3 90 | msgid "Thank you. You're now unsubscribed from this newsletter." 91 | msgstr "Merci. Vous êtes maintenant désinscrit de cette newsletter." 92 | 93 | #: templates/courriers/newsletter_list_unsubscribe_done.html:5 94 | msgid "Thank you. You're now unsubscribed from all our newsletters." 95 | msgstr "Merci. Vous êtes maintenant désinscrit de toutes les newsletters." 96 | 97 | #~ msgid "List %s does not exist" 98 | #~ msgstr "La liste %s n'existe pas" 99 | 100 | #~ msgid "You have to specify a DEFAULT_FROM_EMAIL in Django settings." 101 | #~ msgstr "" 102 | #~ "Vous devez spécifier le paramètre DEFAULT_FROM_EMAIL dans les settings " 103 | #~ "Django." 104 | 105 | #~ msgid "You have to specify a DEFAULT_FROM_NAME in Django settings." 106 | #~ msgstr "" 107 | #~ "Vous devez spécifier le paramètre DEFAULT_FROM_NAME dans les settings " 108 | #~ "Django." 109 | 110 | #~ msgid "This newsletter is not online. You can't send it." 111 | #~ msgstr "Cette newsletter n'est pas publiée. Vous ne pouvez pas l'envoyer." 112 | -------------------------------------------------------------------------------- /courriers/management/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ulule/django-courriers/c2c301a7c1a2d3c2765f3534381bc74ee7aa7632/courriers/management/__init__.py -------------------------------------------------------------------------------- /courriers/management/commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ulule/django-courriers/c2c301a7c1a2d3c2765f3534381bc74ee7aa7632/courriers/management/commands/__init__.py -------------------------------------------------------------------------------- /courriers/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.1 on 2019-12-30 04:26 2 | 3 | import courriers.base_models 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | initial = True 11 | 12 | dependencies = [ 13 | ('contenttypes', '0002_remove_content_type_name'), 14 | ] 15 | 16 | operations = [ 17 | migrations.CreateModel( 18 | name='Newsletter', 19 | fields=[ 20 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 21 | ('name', models.CharField(max_length=255)), 22 | ('published_at', models.DateTimeField(null=True)), 23 | ('status', models.PositiveIntegerField(choices=[(1, 'Online'), (2, 'Draft')], db_index=True, default=2)), 24 | ('headline', models.TextField(blank=True, null=True)), 25 | ('conclusion', models.TextField(blank=True, null=True)), 26 | ('cover', models.ImageField(blank=True, null=True, upload_to=courriers.base_models.get_file_path)), 27 | ('sent', models.BooleanField(db_index=True, default=False)), 28 | ], 29 | options={ 30 | 'abstract': False, 31 | }, 32 | ), 33 | migrations.CreateModel( 34 | name='NewsletterList', 35 | fields=[ 36 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 37 | ('name', models.CharField(max_length=255)), 38 | ('slug', models.SlugField(max_length=255, unique=True)), 39 | ('description', models.TextField(blank=True, null=True)), 40 | ('created_at', models.DateTimeField(auto_now_add=True)), 41 | ('list_id', models.IntegerField(null=True)), 42 | ], 43 | options={ 44 | 'abstract': False, 45 | }, 46 | ), 47 | migrations.CreateModel( 48 | name='NewsletterSegment', 49 | fields=[ 50 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 51 | ('name', models.CharField(max_length=255)), 52 | ('segment_id', models.IntegerField()), 53 | ('lang', models.CharField(blank=True, choices=[('af', 'Afrikaans'), ('ar', 'Arabic'), ('ast', 'Asturian'), ('az', 'Azerbaijani'), ('bg', 'Bulgarian'), ('be', 'Belarusian'), ('bn', 'Bengali'), ('br', 'Breton'), ('bs', 'Bosnian'), ('ca', 'Catalan'), ('cs', 'Czech'), ('cy', 'Welsh'), ('da', 'Danish'), ('de', 'German'), ('dsb', 'Lower Sorbian'), ('el', 'Greek'), ('en', 'English'), ('en-au', 'Australian English'), ('en-gb', 'British English'), ('eo', 'Esperanto'), ('es', 'Spanish'), ('es-ar', 'Argentinian Spanish'), ('es-co', 'Colombian Spanish'), ('es-mx', 'Mexican Spanish'), ('es-ni', 'Nicaraguan Spanish'), ('es-ve', 'Venezuelan Spanish'), ('et', 'Estonian'), ('eu', 'Basque'), ('fa', 'Persian'), ('fi', 'Finnish'), ('fr', 'French'), ('fy', 'Frisian'), ('ga', 'Irish'), ('gd', 'Scottish Gaelic'), ('gl', 'Galician'), ('he', 'Hebrew'), ('hi', 'Hindi'), ('hr', 'Croatian'), ('hsb', 'Upper Sorbian'), ('hu', 'Hungarian'), ('hy', 'Armenian'), ('ia', 'Interlingua'), ('id', 'Indonesian'), ('io', 'Ido'), ('is', 'Icelandic'), ('it', 'Italian'), ('ja', 'Japanese'), ('ka', 'Georgian'), ('kab', 'Kabyle'), ('kk', 'Kazakh'), ('km', 'Khmer'), ('kn', 'Kannada'), ('ko', 'Korean'), ('lb', 'Luxembourgish'), ('lt', 'Lithuanian'), ('lv', 'Latvian'), ('mk', 'Macedonian'), ('ml', 'Malayalam'), ('mn', 'Mongolian'), ('mr', 'Marathi'), ('my', 'Burmese'), ('nb', 'Norwegian Bokmål'), ('ne', 'Nepali'), ('nl', 'Dutch'), ('nn', 'Norwegian Nynorsk'), ('os', 'Ossetic'), ('pa', 'Punjabi'), ('pl', 'Polish'), ('pt', 'Portuguese'), ('pt-br', 'Brazilian Portuguese'), ('ro', 'Romanian'), ('ru', 'Russian'), ('sk', 'Slovak'), ('sl', 'Slovenian'), ('sq', 'Albanian'), ('sr', 'Serbian'), ('sr-latn', 'Serbian Latin'), ('sv', 'Swedish'), ('sw', 'Swahili'), ('ta', 'Tamil'), ('te', 'Telugu'), ('th', 'Thai'), ('tr', 'Turkish'), ('tt', 'Tatar'), ('udm', 'Udmurt'), ('uk', 'Ukrainian'), ('ur', 'Urdu'), ('uz', 'Uzbek'), ('vi', 'Vietnamese'), ('zh-hans', 'Simplified Chinese'), ('zh-hant', 'Traditional Chinese')], max_length=10, null=True)), 54 | ('newsletter_list', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='lists', to='courriers.NewsletterList')), 55 | ], 56 | options={ 57 | 'abstract': False, 58 | }, 59 | ), 60 | migrations.CreateModel( 61 | name='NewsletterItem', 62 | fields=[ 63 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 64 | ('object_id', models.PositiveIntegerField(blank=True, null=True)), 65 | ('name', models.CharField(blank=True, max_length=255, null=True)), 66 | ('description', models.TextField(blank=True, null=True)), 67 | ('image', models.ImageField(blank=True, null=True, upload_to=courriers.base_models.get_file_path)), 68 | ('url', models.URLField(blank=True, null=True)), 69 | ('position', models.PositiveIntegerField(blank=True, null=True)), 70 | ('content_type', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='contenttypes.ContentType')), 71 | ('newsletter', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='courriers.Newsletter')), 72 | ], 73 | options={ 74 | 'ordering': ['position'], 75 | 'abstract': False, 76 | }, 77 | ), 78 | migrations.AddField( 79 | model_name='newsletter', 80 | name='newsletter_list', 81 | field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='newsletters', to='courriers.NewsletterList'), 82 | ), 83 | migrations.AddField( 84 | model_name='newsletter', 85 | name='newsletter_segment', 86 | field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='segments', to='courriers.NewsletterSegment'), 87 | ), 88 | ] 89 | -------------------------------------------------------------------------------- /courriers/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ulule/django-courriers/c2c301a7c1a2d3c2765f3534381bc74ee7aa7632/courriers/migrations/__init__.py -------------------------------------------------------------------------------- /courriers/models/__init__.py: -------------------------------------------------------------------------------- 1 | from courriers.utils import load_class 2 | 3 | from courriers import settings 4 | 5 | 6 | NewsletterList = load_class(settings.NEWSLETTERLIST_MODEL) 7 | 8 | NewsletterSegment = load_class(settings.NEWSLETTERSEGMENT_MODEL) 9 | 10 | Newsletter = load_class(settings.NEWSLETTER_MODEL) 11 | 12 | NewsletterItem = load_class(settings.NEWSLETTERITEM_MODEL) 13 | -------------------------------------------------------------------------------- /courriers/models/newsletter.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from courriers import base_models as base 3 | 4 | 5 | class Newsletter(base.Newsletter): 6 | class Meta(base.Newsletter.Meta): 7 | abstract = False 8 | -------------------------------------------------------------------------------- /courriers/models/newsletteritem.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from courriers import base_models as base 3 | 4 | 5 | class NewsletterItem(base.NewsletterItem): 6 | class Meta(base.NewsletterItem.Meta): 7 | abstract = False 8 | -------------------------------------------------------------------------------- /courriers/models/newsletterlist.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from courriers import base_models as base 3 | 4 | 5 | class NewsletterList(base.NewsletterList): 6 | class Meta(base.NewsletterList.Meta): 7 | abstract = False 8 | -------------------------------------------------------------------------------- /courriers/models/newslettersegment.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from courriers import base_models as base 3 | 4 | 5 | class NewsletterSegment(base.NewsletterSegment): 6 | class Meta(base.NewsletterSegment.Meta): 7 | abstract = False 8 | -------------------------------------------------------------------------------- /courriers/settings.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | 3 | 4 | BACKEND_CLASS = getattr( 5 | settings, "COURRIERS_BACKEND_CLASS", "courriers.backends.simple.SimpleBackend" 6 | ) 7 | 8 | MAILCHIMP_API_KEY = getattr(settings, "COURRIERS_MAILCHIMP_API_KEY", "") 9 | 10 | MAILJET_API_KEY = getattr(settings, "COURRIERS_MAILJET_API_KEY", "") 11 | 12 | MAILJET_CONTACTSLIST_LIMIT = getattr( 13 | settings, "COURRIERS_MAILJET_CONTACTSLIST_LIMIT", 1000 14 | ) 15 | 16 | MAILJET_CONTACTFILTER_LIMIT = getattr( 17 | settings, "COURRIERS_MAILJET_CONTACTFILTER_LIMIT", 1000 18 | ) 19 | 20 | MAILJET_API_SECRET_KEY = getattr(settings, "COURRIERS_MAILJET_API_SECRET_KEY", "") 21 | 22 | DEFAULT_FROM_EMAIL = getattr( 23 | settings, "COURRIERS_DEFAULT_FROM_EMAIL", settings.DEFAULT_FROM_EMAIL 24 | ) 25 | 26 | DEFAULT_FROM_NAME = getattr(settings, "COURRIERS_DEFAULT_FROM_NAME", "") 27 | 28 | ALLOWED_LANGUAGES = getattr(settings, "COURRIERS_ALLOWED_LANGUAGES", settings.LANGUAGES) 29 | 30 | PRE_PROCESSORS = getattr(settings, "COURRIERS_PRE_PROCESSORS", ()) 31 | 32 | PAGINATE_BY = getattr(settings, "COURRIERS_PAGINATE_BY", 9) 33 | 34 | FAIL_SILENTLY = getattr(settings, "COURRIERS_FAIL_SILENTLY", False) 35 | 36 | NEWSLETTERLIST_MODEL = getattr( 37 | settings, 38 | "COURRIERS_NEWSLETTERLIST_MODEL", 39 | "courriers.models.newsletterlist.NewsletterList", 40 | ) 41 | 42 | NEWSLETTER_MODEL = getattr( 43 | settings, "COURRIERS_NEWSLETTER_MODEL", "courriers.models.newsletter.Newsletter" 44 | ) 45 | 46 | NEWSLETTERITEM_MODEL = getattr( 47 | settings, 48 | "COURRIERS_NEWSLETTERITEM_MODEL", 49 | "courriers.models.newsletteritem.NewsletterItem", 50 | ) 51 | 52 | NEWSLETTERSEGMENT_MODEL = getattr( 53 | settings, 54 | "COURRIERS_NEWSLETTERSEGMENT_MODEL", 55 | "courriers.models.newslettersegment.NewsletterSegment", 56 | ) 57 | -------------------------------------------------------------------------------- /courriers/signals.py: -------------------------------------------------------------------------------- 1 | import django.dispatch 2 | 3 | unsubscribed = django.dispatch.Signal() 4 | 5 | subscribed = django.dispatch.Signal() 6 | -------------------------------------------------------------------------------- /courriers/tasks.py: -------------------------------------------------------------------------------- 1 | try: 2 | from celery.task import task 3 | except ImportError: 4 | from celery import shared_task as task 5 | 6 | 7 | @task(bind=True) 8 | def subscribe(self, email, newsletter_list_id, user_id=None, **kwargs): 9 | from courriers.backends import get_backend 10 | from courriers.models import NewsletterList 11 | from courriers import signals 12 | 13 | from django.contrib.auth import get_user_model 14 | 15 | User = get_user_model() 16 | 17 | backend = get_backend()() 18 | 19 | newsletter_list = None 20 | 21 | if newsletter_list_id: 22 | newsletter_list = NewsletterList.objects.get(pk=newsletter_list_id) 23 | 24 | user = None 25 | 26 | if user_id is not None: 27 | user = User.objects.get(pk=user_id) 28 | else: 29 | user = User.objects.filter(email=email).last() 30 | 31 | if user: 32 | signals.subscribed.send(sender=User, user=user, newsletter_list=newsletter_list) 33 | 34 | else: 35 | try: 36 | backend.subscribe(newsletter_list.list_id, email) 37 | except Exception as e: 38 | raise self.retry(exc=e, countdown=60) 39 | 40 | 41 | @task(bind=True) 42 | def unsubscribe(self, email, newsletter_list_id=None, user_id=None, **kwargs): 43 | from courriers.backends import get_backend 44 | from courriers.models import NewsletterList 45 | from courriers import signals 46 | 47 | from django.contrib.auth import get_user_model 48 | 49 | User = get_user_model() 50 | 51 | newsletter_lists = NewsletterList.objects.all() 52 | 53 | if newsletter_list_id: 54 | newsletter_lists = NewsletterList.objects.filter(pk=newsletter_list_id) 55 | 56 | user = None 57 | 58 | if user_id is not None: 59 | user = User.objects.get(pk=user_id) 60 | else: 61 | user = User.objects.filter(email=email).last() 62 | 63 | if user: 64 | for newsletter_list in newsletter_lists: 65 | signals.unsubscribed.send( 66 | sender=User, user=user, newsletter_list=newsletter_list 67 | ) 68 | else: 69 | backend = get_backend()() 70 | 71 | for newsletter in newsletter_lists: 72 | backend.unsubscribe(newsletter.list_id, email) 73 | -------------------------------------------------------------------------------- /courriers/templates/admin/courriers/newsletter/change_form.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/change_form.html" %} 2 | {% load i18n admin_urls %} 3 | {% block object-tools-items %} 4 |
  • 5 | Send this newsletter 6 |
  • 7 |
  • 8 | {% trans "History" %} 9 |
  • 10 | {% if has_absolute_url %} 11 |
  • 12 | {% trans "View on site" %} 13 |
  • 14 | {% endif%} 15 | {% endblock %} -------------------------------------------------------------------------------- /courriers/templates/courriers/newsletter_detail.html: -------------------------------------------------------------------------------- 1 | {% if previous_object %} 2 |

    Previous : {{ previous_object.name }}

    3 | {% endif %} 4 | {% if next_object %} 5 |

    Next : {{ next_object.name }}

    6 | {% endif %} 7 | 8 | {% if messages %} 9 |
      10 | {% for message in messages %} 11 | {{ message }} 12 | {% endfor %} 13 |
    14 | {% endif %} 15 | 16 | 17 |
    {% csrf_token %} 18 | {{ form.non_field_errors }} 19 | 20 | {% if form.subject.errors %} 21 |
      22 | {% for error in form.receiver.errors %} 23 |
    1. {{ error|escape }}
    2. 24 | {% endfor %} 25 |
    26 | {% endif %} 27 | 28 |
    29 | {{ form.receiver.errors }} 30 | 31 | {{ form.receiver }} 32 |
    33 |

    34 |
    35 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /courriers/templates/courriers/newsletter_list.html: -------------------------------------------------------------------------------- 1 | {% for newsletter in newsletters %} 2 |

    {{ newsletter.name }}

    3 | {% endfor %} 4 | -------------------------------------------------------------------------------- /courriers/templates/courriers/newsletter_list_subscribe_done.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 |

    {% trans "Thank you. You successfully subscribe to this newsletter !" %}

    -------------------------------------------------------------------------------- /courriers/templates/courriers/newsletter_list_subscribe_form.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | 17 | 20 | -------------------------------------------------------------------------------- /courriers/templates/courriers/newsletter_list_unsubscribe.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 |
    {% csrf_token %} 3 | {{ form.non_field_errors }} 4 | 5 | {% if form.subject.errors %} 6 |
      7 | {% for error in form.email.errors %} 8 |
    1. {{ error|escape }}
    2. 9 | {% endfor %} 10 |
    11 | {% endif %} 12 | 13 |
    14 | {{ form.email.errors }} 15 | 16 | {{ form.email }} 17 |
    18 |

    19 |
    20 | -------------------------------------------------------------------------------- /courriers/templates/courriers/newsletter_list_unsubscribe_done.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | {% if newsletter_list %} 3 |

    {% trans "Thank you. You're now unsubscribed from this newsletter." %}

    4 | {% else %} 5 |

    {% trans "Thank you. You're now unsubscribed from all our newsletters." %}

    6 | {% endif %} -------------------------------------------------------------------------------- /courriers/templates/courriers/newsletter_raw_detail.html: -------------------------------------------------------------------------------- 1 |

    {{ object.name }}

    2 | {% if object.headline %}

    {{ object.headline }}

    {% endif %} 3 |

    Published: {{ object.published_at|date }}

    4 | 5 |
      6 | {% for item in items %} 7 |
    • {{ item.description }}
    • 8 | {% endfor %} 9 |
    10 | 11 | -------------------------------------------------------------------------------- /courriers/templates/courriers/newsletter_raw_detail.txt: -------------------------------------------------------------------------------- 1 | {{ object.name }} 2 | {% if object.headline %}{{ object.headline }}{% endif %} 3 | 4 | {% for item in items %} 5 | {{ item.description }} 6 | {% endfor %} 7 | 8 | [[UNSUB_LINK_EN]] -------------------------------------------------------------------------------- /courriers/tests/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | from .celery import app as celery_app # noqa 3 | -------------------------------------------------------------------------------- /courriers/tests/celery.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | 3 | import os 4 | 5 | from celery import Celery 6 | 7 | from django.conf import settings 8 | 9 | # set the default Django settings module for the 'celery' program. 10 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "courriers.tests.settings") 11 | 12 | app = Celery("courriers") 13 | 14 | # Using a string here means the worker will not have to 15 | # pickle the object when using Windows. 16 | app.config_from_object("django.conf:settings") 17 | app.autodiscover_tasks(lambda: settings.INSTALLED_APPS) 18 | -------------------------------------------------------------------------------- /courriers/tests/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.1 on 2019-12-30 04:26 2 | 3 | from django.conf import settings 4 | import django.contrib.auth.models 5 | import django.contrib.auth.validators 6 | from django.db import migrations, models 7 | import django.db.models.deletion 8 | import django.utils.timezone 9 | 10 | 11 | class Migration(migrations.Migration): 12 | 13 | initial = True 14 | 15 | dependencies = [ 16 | ('courriers', '0001_initial'), 17 | ('auth', '0011_update_proxy_permissions'), 18 | ] 19 | 20 | operations = [ 21 | migrations.CreateModel( 22 | name='User', 23 | fields=[ 24 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 25 | ('password', models.CharField(max_length=128, verbose_name='password')), 26 | ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), 27 | ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), 28 | ('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')), 29 | ('first_name', models.CharField(blank=True, max_length=30, verbose_name='first name')), 30 | ('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')), 31 | ('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')), 32 | ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')), 33 | ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')), 34 | ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), 35 | ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.Group', verbose_name='groups')), 36 | ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.Permission', verbose_name='user permissions')), 37 | ], 38 | options={ 39 | 'abstract': False, 40 | }, 41 | managers=[ 42 | ('objects', django.contrib.auth.models.UserManager()), 43 | ], 44 | ), 45 | migrations.CreateModel( 46 | name='NewsletterSubscriber', 47 | fields=[ 48 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 49 | ('subscribed_at', models.DateTimeField(auto_now_add=True)), 50 | ('is_unsubscribed', models.BooleanField(db_index=True, default=False)), 51 | ('unsubscribed_at', models.DateTimeField(blank=True, null=True)), 52 | ('email', models.EmailField(max_length=250)), 53 | ('lang', models.CharField(blank=True, choices=[('af', 'Afrikaans'), ('ar', 'Arabic'), ('ast', 'Asturian'), ('az', 'Azerbaijani'), ('bg', 'Bulgarian'), ('be', 'Belarusian'), ('bn', 'Bengali'), ('br', 'Breton'), ('bs', 'Bosnian'), ('ca', 'Catalan'), ('cs', 'Czech'), ('cy', 'Welsh'), ('da', 'Danish'), ('de', 'German'), ('dsb', 'Lower Sorbian'), ('el', 'Greek'), ('en', 'English'), ('en-au', 'Australian English'), ('en-gb', 'British English'), ('eo', 'Esperanto'), ('es', 'Spanish'), ('es-ar', 'Argentinian Spanish'), ('es-co', 'Colombian Spanish'), ('es-mx', 'Mexican Spanish'), ('es-ni', 'Nicaraguan Spanish'), ('es-ve', 'Venezuelan Spanish'), ('et', 'Estonian'), ('eu', 'Basque'), ('fa', 'Persian'), ('fi', 'Finnish'), ('fr', 'French'), ('fy', 'Frisian'), ('ga', 'Irish'), ('gd', 'Scottish Gaelic'), ('gl', 'Galician'), ('he', 'Hebrew'), ('hi', 'Hindi'), ('hr', 'Croatian'), ('hsb', 'Upper Sorbian'), ('hu', 'Hungarian'), ('hy', 'Armenian'), ('ia', 'Interlingua'), ('id', 'Indonesian'), ('io', 'Ido'), ('is', 'Icelandic'), ('it', 'Italian'), ('ja', 'Japanese'), ('ka', 'Georgian'), ('kab', 'Kabyle'), ('kk', 'Kazakh'), ('km', 'Khmer'), ('kn', 'Kannada'), ('ko', 'Korean'), ('lb', 'Luxembourgish'), ('lt', 'Lithuanian'), ('lv', 'Latvian'), ('mk', 'Macedonian'), ('ml', 'Malayalam'), ('mn', 'Mongolian'), ('mr', 'Marathi'), ('my', 'Burmese'), ('nb', 'Norwegian Bokmål'), ('ne', 'Nepali'), ('nl', 'Dutch'), ('nn', 'Norwegian Nynorsk'), ('os', 'Ossetic'), ('pa', 'Punjabi'), ('pl', 'Polish'), ('pt', 'Portuguese'), ('pt-br', 'Brazilian Portuguese'), ('ro', 'Romanian'), ('ru', 'Russian'), ('sk', 'Slovak'), ('sl', 'Slovenian'), ('sq', 'Albanian'), ('sr', 'Serbian'), ('sr-latn', 'Serbian Latin'), ('sv', 'Swedish'), ('sw', 'Swahili'), ('ta', 'Tamil'), ('te', 'Telugu'), ('th', 'Thai'), ('tr', 'Turkish'), ('tt', 'Tatar'), ('udm', 'Udmurt'), ('uk', 'Ukrainian'), ('ur', 'Urdu'), ('uz', 'Uzbek'), ('vi', 'Vietnamese'), ('zh-hans', 'Simplified Chinese'), ('zh-hant', 'Traditional Chinese')], max_length=10, null=True)), 54 | ('newsletter_list', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='newsletter_subscribers', to='courriers.NewsletterList')), 55 | ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), 56 | ], 57 | ), 58 | ] 59 | -------------------------------------------------------------------------------- /courriers/tests/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ulule/django-courriers/c2c301a7c1a2d3c2765f3534381bc74ee7aa7632/courriers/tests/migrations/__init__.py -------------------------------------------------------------------------------- /courriers/tests/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.utils import timezone as datetime 3 | from django.contrib.auth.models import AbstractUser, UserManager 4 | from django.dispatch import receiver 5 | 6 | from courriers.compat import AUTH_USER_MODEL 7 | from courriers.settings import ALLOWED_LANGUAGES 8 | from courriers.models import NewsletterList 9 | from courriers import signals 10 | 11 | 12 | @receiver(signals.unsubscribed) 13 | def handle_unsubscribed(user, newsletter_list=None, **kwargs): 14 | user.unsubscribe(newsletter_list.pk if newsletter_list else None) 15 | 16 | 17 | @receiver(signals.subscribed) 18 | def handle_subscribed(user, newsletter_list=None, **kwargs): 19 | user.subscribe(newsletter_list.pk if newsletter_list else None) 20 | 21 | 22 | class User(AbstractUser): 23 | class Meta: 24 | abstract = False 25 | 26 | objects = UserManager() 27 | 28 | def subscribed(self, newsletter_list_id, lang=None): 29 | filters = {"user": self, "newsletter_list_id": newsletter_list_id} 30 | if lang: 31 | filters["lang"] = lang 32 | 33 | return NewsletterSubscriber.objects.filter(**filters).exists() 34 | 35 | def subscribe(self, newsletter_list_id, lang=None): 36 | if isinstance(newsletter_list_id, NewsletterList): 37 | newsletter_list_id = newsletter_list_id.pk 38 | 39 | if not self.subscribed(newsletter_list_id, lang=lang): 40 | NewsletterSubscriber.objects.create( 41 | subscribed_at=datetime.now(), 42 | user=self, 43 | email=self.email, 44 | lang=lang, 45 | newsletter_list_id=newsletter_list_id, 46 | ) 47 | else: 48 | ( 49 | NewsletterSubscriber.objects.filter( 50 | user=self, newsletter_list_id=newsletter_list_id, lang=lang 51 | ).update(unsubscribed_at=None, is_unsubscribed=False) 52 | ) 53 | 54 | def unsubscribe(self, newsletter_list_id=None, lang=None): 55 | if isinstance(newsletter_list_id, NewsletterList): 56 | newsletter_lists = [newsletter_list_id.pk] 57 | elif newsletter_list_id: 58 | newsletter_lists = NewsletterList.objects.filter( 59 | pk=newsletter_list_id 60 | ).values_list("pk", flat=True) 61 | else: 62 | newsletter_lists = NewsletterList.objects.all().values_list("pk", flat=True) 63 | 64 | qs = NewsletterSubscriber.objects.filter( 65 | user=self, email=self.email, newsletter_list_id__in=newsletter_lists 66 | ) 67 | 68 | if lang: 69 | qs = qs.filter(lang=lang) 70 | 71 | qs.update(unsubscribed_at=datetime.now(), is_unsubscribed=True) 72 | 73 | 74 | class NewsletterSubscriber(models.Model): 75 | subscribed_at = models.DateTimeField(auto_now_add=True) 76 | user = models.ForeignKey( 77 | AUTH_USER_MODEL, on_delete=models.CASCADE, blank=True, null=True 78 | ) 79 | is_unsubscribed = models.BooleanField(default=False, db_index=True) 80 | unsubscribed_at = models.DateTimeField(blank=True, null=True) 81 | email = models.EmailField(max_length=250) 82 | lang = models.CharField( 83 | max_length=10, blank=True, null=True, choices=ALLOWED_LANGUAGES 84 | ) 85 | newsletter_list = models.ForeignKey( 86 | NewsletterList, related_name="newsletter_subscribers", on_delete=models.CASCADE 87 | ) 88 | 89 | def __str__(self): 90 | return "%s for %s" % (self.email, self.newsletter_list) 91 | 92 | @property 93 | def subscribed(self): 94 | return self.is_unsubscribed is False 95 | -------------------------------------------------------------------------------- /courriers/tests/settings.py: -------------------------------------------------------------------------------- 1 | DATABASES = { 2 | "default": { 3 | "ENGINE": "django.db.backends.sqlite3", 4 | "NAME": ":memory:", 5 | } 6 | } 7 | 8 | SITE_ID = 1 9 | DEBUG = True 10 | 11 | INSTALLED_APPS = [ 12 | "django.contrib.admin", 13 | "django.contrib.auth", 14 | "django.contrib.contenttypes", 15 | "django.contrib.sessions", 16 | "django.contrib.sites", 17 | "django.contrib.messages", 18 | "courriers", 19 | "courriers.tests", 20 | ] 21 | 22 | AUTH_USER_MODEL = "tests.User" 23 | 24 | SECRET_KEY = "blabla" 25 | 26 | ROOT_URLCONF = "courriers.urls" 27 | 28 | try: 29 | from .temp import * # noqa 30 | except ImportError: 31 | pass 32 | 33 | 34 | EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend" 35 | 36 | 37 | LOGGING = { 38 | "version": 1, 39 | "disable_existing_loggers": False, 40 | "formatters": { 41 | "verbose": { 42 | "format": "%(levelname)s %(asctime)s %(module)s %(process)d %(thread)d %(message)s" 43 | } 44 | }, 45 | "handlers": { 46 | "stream": { 47 | "level": "DEBUG", 48 | "class": "logging.StreamHandler", 49 | "formatter": "verbose", 50 | } 51 | }, 52 | "loggers": { 53 | "django.request": {"handlers": ["stream"], "level": "DEBUG", "propagate": True}, 54 | "courriers": {"handlers": ["stream"], "level": "DEBUG", "propagate": True}, 55 | }, 56 | } 57 | 58 | CELERY_ALWAYS_EAGER = True 59 | CELERY_EAGER_PROPAGATES_EXCEPTIONS = True 60 | 61 | MIDDLEWARE = [ 62 | "django.contrib.sessions.middleware.SessionMiddleware", 63 | "django.middleware.common.CommonMiddleware", 64 | "django.middleware.csrf.CsrfViewMiddleware", 65 | "django.contrib.auth.middleware.AuthenticationMiddleware", 66 | "django.contrib.messages.middleware.MessageMiddleware", 67 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 68 | ] 69 | 70 | TEMPLATES = [ 71 | { 72 | "BACKEND": "django.template.backends.django.DjangoTemplates", 73 | "DIRS": [], 74 | "APP_DIRS": True, 75 | "OPTIONS": { 76 | "context_processors": [ 77 | "django.template.context_processors.debug", 78 | "django.template.context_processors.request", 79 | "django.contrib.auth.context_processors.auth", 80 | "django.contrib.messages.context_processors.messages", 81 | ] 82 | }, 83 | } 84 | ] 85 | 86 | DEFAULT_AUTO_FIELD = "django.db.models.AutoField" 87 | -------------------------------------------------------------------------------- /courriers/tests/templates/404.html: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ulule/django-courriers/c2c301a7c1a2d3c2765f3534381bc74ee7aa7632/courriers/tests/templates/404.html -------------------------------------------------------------------------------- /courriers/tests/tests.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import mock 3 | 4 | from django.test import TestCase 5 | from django.contrib.auth import get_user_model 6 | from django.urls import reverse 7 | from django.utils import timezone as datetime 8 | from django.core import mail 9 | 10 | from courriers.forms import SubscriptionForm, UnsubscribeForm 11 | from courriers.models import Newsletter, NewsletterList, NewsletterSegment 12 | from courriers import settings 13 | from courriers.tasks import subscribe, unsubscribe 14 | 15 | from .models import NewsletterSubscriber 16 | 17 | User = get_user_model() 18 | 19 | 20 | class BaseBackendTests(TestCase): 21 | def setUp(self): 22 | from courriers.backends import get_backend 23 | 24 | self.backend_klass = get_backend() 25 | self.backend = self.backend_klass() 26 | 27 | current = datetime.now().strftime("%Y-%m-%d %H:%M") 28 | 29 | self.monthly = NewsletterList.objects.create( 30 | name="TestMonthly", slug="testmonthly", list_id=1 31 | ) 32 | self.weekly = NewsletterList.objects.create( 33 | name="TestWeekly", slug="testweekly", list_id=2 34 | ) 35 | 36 | self.segment_monthly = NewsletterSegment.objects.create( 37 | name="monthly", segment_id=3, newsletter_list=self.monthly 38 | ) 39 | self.segment_weekly = NewsletterSegment.objects.create( 40 | name="weekly", segment_id=3, newsletter_list=self.weekly 41 | ) 42 | 43 | self.segment_monthly_fr = NewsletterSegment.objects.create( 44 | name="monthly fr", segment_id=3, newsletter_list=self.monthly, lang="fr" 45 | ) 46 | self.segment_weekly_fr = NewsletterSegment.objects.create( 47 | name="weekly fr", segment_id=4, newsletter_list=self.weekly, lang="fr" 48 | ) 49 | 50 | self.segment_monthly_en = NewsletterSegment.objects.create( 51 | name="monthly en", segment_id=4, newsletter_list=self.monthly, lang="en-us" 52 | ) 53 | 54 | self.nl_monthly = Newsletter.objects.create( 55 | name="3000 projets financés %s" % current, 56 | published_at=datetime.now() - datetime.timedelta(hours=2), 57 | status=Newsletter.STATUS_ONLINE, 58 | newsletter_list=self.monthly, 59 | newsletter_segment=self.segment_monthly, 60 | ) 61 | 62 | self.nl_monthly_fr = Newsletter.objects.create( 63 | name="3000 projets financés %s [monthly][fr]" % current, 64 | published_at=datetime.now() - datetime.timedelta(hours=2), 65 | status=Newsletter.STATUS_ONLINE, 66 | newsletter_list=self.monthly, 67 | newsletter_segment=self.segment_monthly_fr, 68 | ) 69 | self.nl_monthly_en = Newsletter.objects.create( 70 | name="3000 projects %s" % current, 71 | published_at=datetime.now() - datetime.timedelta(hours=2), 72 | status=Newsletter.STATUS_ONLINE, 73 | newsletter_list=self.monthly, 74 | newsletter_segment=self.segment_monthly_en, 75 | ) 76 | 77 | self.nl_weekly = Newsletter.objects.create( 78 | name="3000 projets financés %s [monthly][en-us]" % current, 79 | published_at=datetime.now() - datetime.timedelta(hours=2), 80 | status=Newsletter.STATUS_ONLINE, 81 | newsletter_list=self.weekly, 82 | newsletter_segment=self.segment_weekly, 83 | ) 84 | 85 | self.nl_weekly_fr = Newsletter.objects.create( 86 | name="3000 projets financés %s [weekly][fr]" % current, 87 | published_at=datetime.now() - datetime.timedelta(hours=2), 88 | status=Newsletter.STATUS_ONLINE, 89 | newsletter_list=self.weekly, 90 | newsletter_segment=self.segment_weekly_fr, 91 | ) 92 | 93 | self.user = User.objects.create_user("adele", "adele@ulule.com", "$ecret") 94 | 95 | def test_registration(self): 96 | self.backend.register("adele@ulule.com", self.monthly, "fr") 97 | self.backend.register("adele@ulule.com", self.weekly, "fr") 98 | 99 | subscriber = NewsletterSubscriber.objects.filter( 100 | email="adele@ulule.com", newsletter_list=self.monthly, is_unsubscribed=False 101 | ) 102 | self.assertEqual(subscriber.count(), 1) 103 | 104 | subscriber2 = NewsletterSubscriber.objects.filter( 105 | email="adele@ulule.com", newsletter_list=self.weekly, is_unsubscribed=False 106 | ) 107 | self.assertEqual(subscriber2.count(), 1) 108 | 109 | # Unsubscribe from all 110 | self.backend.unregister("adele@ulule.com") 111 | 112 | subscriber = NewsletterSubscriber.objects.filter( 113 | email="adele@ulule.com", is_unsubscribed=True 114 | ) 115 | self.assertEqual(subscriber.count(), 2) # subscriber is unsubscribed from all 116 | 117 | # Subscribe to all 118 | self.backend.register("adele@ulule.com", self.monthly, "fr") 119 | self.backend.register("adele@ulule.com", self.weekly, "fr") 120 | 121 | subscriber = NewsletterSubscriber.objects.get( 122 | email="adele@ulule.com", newsletter_list=self.monthly, is_unsubscribed=False 123 | ) 124 | 125 | # Unsubscribe from monthly 126 | self.backend.unregister(subscriber.email, self.monthly) 127 | 128 | unsubscriber = NewsletterSubscriber.objects.filter( 129 | email="adele@ulule.com", newsletter_list=self.monthly, is_unsubscribed=True 130 | ) 131 | self.assertEqual(unsubscriber.count(), 1) 132 | 133 | unsubscriber = NewsletterSubscriber.objects.filter( 134 | email="adele@ulule.com", newsletter_list=self.weekly, is_unsubscribed=False 135 | ) 136 | self.assertEqual(unsubscriber.count(), 1) 137 | 138 | 139 | class SimpleBackendTests(BaseBackendTests): 140 | def test_registration(self): 141 | super(SimpleBackendTests, self).test_registration() 142 | 143 | self.backend.register("adele@ulule.com", self.nl_monthly.newsletter_list) 144 | 145 | self.backend.send_mails( 146 | self.nl_monthly, 147 | subscribers=NewsletterSubscriber.objects.filter( 148 | newsletter_list=self.monthly 149 | ), 150 | ) 151 | 152 | self.assertEqual(len(mail.outbox), 1) 153 | out = len(mail.outbox) 154 | 155 | self.backend.register( 156 | "adele@ulule.com", self.nl_monthly_fr.newsletter_list, "fr" 157 | ) 158 | 159 | self.backend.send_mails( 160 | self.nl_monthly_fr, 161 | subscribers=NewsletterSubscriber.objects.filter( 162 | newsletter_list=self.monthly 163 | ), 164 | ) 165 | 166 | self.assertEqual(len(mail.outbox) - out, 1) 167 | out = len(mail.outbox) 168 | 169 | self.backend.register("adele@ulule.com", self.monthly, "en-us") 170 | 171 | self.backend.send_mails( 172 | self.nl_monthly_en, 173 | subscribers=NewsletterSubscriber.objects.filter( 174 | newsletter_list=self.monthly 175 | ), 176 | ) 177 | 178 | self.assertEqual(len(mail.outbox) - out, 1) 179 | 180 | 181 | class NewslettersViewsTests(TestCase): 182 | def setUp(self): 183 | self.monthly = NewsletterList.objects.create( 184 | name="TestMonthly", slug="testmonthly" 185 | ) 186 | self.segment_monthly = NewsletterSegment.objects.create( 187 | name="monthly fr", segment_id=3, newsletter_list=self.monthly, lang="fr" 188 | ) 189 | self.n1 = Newsletter.objects.create( 190 | name="Newsletter1", 191 | newsletter_list=self.monthly, 192 | newsletter_segment=self.segment_monthly, 193 | published_at=datetime.now(), 194 | status=Newsletter.STATUS_DRAFT, 195 | ) 196 | 197 | self.user = User.objects.create_user("adele", "adele@ulule.com", "$ecret") 198 | 199 | def test_newsletter_list(self): 200 | response = self.client.get(self.monthly.get_absolute_url()) 201 | 202 | self.assertEqual(response.status_code, 200) 203 | self.assertTemplateUsed(response, "courriers/newsletter_list.html") 204 | 205 | def test_newsletter_detail_view(self): 206 | response = self.client.get(self.n1.get_absolute_url()) 207 | self.assertEqual(response.status_code, 404) 208 | 209 | self.n1.status = Newsletter.STATUS_ONLINE 210 | self.n1.save() 211 | 212 | response = self.client.get(self.n1.get_absolute_url()) 213 | self.assertEqual(response.status_code, 200) 214 | 215 | self.assertTemplateUsed(response, "courriers/newsletter_detail.html") 216 | 217 | def test_newsletter_list_subscribe_view(self): 218 | response = self.client.get( 219 | reverse("newsletter_list_subscribe", kwargs={"slug": self.monthly.slug}) 220 | ) 221 | self.assertEqual(response.status_code, 200) 222 | 223 | response = self.client.get( 224 | reverse("newsletter_list_subscribe", kwargs={"slug": self.monthly.slug}), 225 | HTTP_X_REQUESTED_WITH="XMLHttpRequest", 226 | ) 227 | self.assertEqual(response.status_code, 200) 228 | 229 | self.assertTrue(isinstance(response.context["form"], SubscriptionForm)) 230 | 231 | def test_newsletter_list_unsubscribe_view(self): 232 | url = ( 233 | reverse("newsletter_list_unsubscribe", kwargs={"slug": "testmonthly"}) 234 | + "?email=adele@ulule.com" 235 | ) 236 | 237 | response = self.client.get(url) 238 | self.assertEqual(response.status_code, 200) 239 | self.assertTemplateUsed(response, "courriers/newsletter_list_unsubscribe.html") 240 | 241 | self.assertTrue(isinstance(response.context["form"], UnsubscribeForm)) 242 | 243 | response = self.client.get( 244 | url, 245 | {"form": response.context["form"]}, 246 | HTTP_X_REQUESTED_WITH="XMLHttpRequest", 247 | ) 248 | self.assertEqual(response.status_code, 200) 249 | 250 | def test_newsletter_list_unsubscribe_complete(self): 251 | # Without email param 252 | valid_data = {"email": "adele@ulule.com"} 253 | 254 | NewsletterSubscriber.objects.create( 255 | newsletter_list=self.monthly, email="adele@ulule.com", user=self.user 256 | ) 257 | 258 | response = self.client.post( 259 | reverse("newsletter_list_unsubscribe", kwargs={"slug": "testmonthly"}), 260 | data=valid_data, 261 | ) 262 | 263 | self.assertRedirects( 264 | response, 265 | expected_url=reverse( 266 | "newsletter_list_unsubscribe_done", args=[self.monthly.slug] 267 | ), 268 | status_code=302, 269 | target_status_code=200, 270 | ) 271 | 272 | subscriber = NewsletterSubscriber.objects.get(email="adele@ulule.com") 273 | 274 | self.assertFalse(subscriber.subscribed) 275 | 276 | def test_newsletter_list_all_unsubscribe_view(self): 277 | url = reverse("newsletter_list_unsubscribe") + "?email=adele@ulule.com" 278 | 279 | response = self.client.get(url) 280 | self.assertEqual(response.status_code, 200) 281 | self.assertTemplateUsed(response, "courriers/newsletter_list_unsubscribe.html") 282 | 283 | self.assertTrue(isinstance(response.context["form"], UnsubscribeForm)) 284 | 285 | response = self.client.get( 286 | url, 287 | {"form": response.context["form"]}, 288 | HTTP_X_REQUESTED_WITH="XMLHttpRequest", 289 | ) 290 | self.assertEqual(response.status_code, 200) 291 | 292 | def test_newsletter_list_all_unsubscribe_complete(self): 293 | url = reverse("newsletter_list_unsubscribe") + "?email=adele@ulule.com" 294 | NewsletterSubscriber.objects.create( 295 | newsletter_list=self.monthly, email="adele@ulule.com", user=self.user 296 | ) 297 | 298 | # GET 299 | response = self.client.get(url) 300 | self.assertEqual(response.status_code, 200) 301 | 302 | response = self.client.post(url, data={"email": "adele@ulule.com"}) 303 | 304 | self.assertRedirects( 305 | response, 306 | expected_url=reverse("newsletter_list_unsubscribe_done"), 307 | status_code=302, 308 | target_status_code=200, 309 | ) 310 | 311 | subscriber = NewsletterSubscriber.objects.get(email="adele@ulule.com") 312 | self.assertFalse(subscriber.subscribed) 313 | 314 | def test_newsletter_raw_detail_view(self): 315 | response = self.client.get(reverse("newsletter_raw_detail", kwargs={"pk": 1})) 316 | self.assertEqual(response.status_code, 200) 317 | self.assertTemplateUsed(response, "courriers/newsletter_raw_detail.html") 318 | 319 | 320 | class SubscribeFormTest(TestCase): 321 | def setUp(self): 322 | from courriers.backends import get_backend 323 | 324 | self.backend_klass = get_backend() 325 | self.backend = self.backend_klass() 326 | 327 | self.monthly = NewsletterList.objects.create( 328 | name="TestMonthly", slug="testmonthly" 329 | ) 330 | self.segment_monthly = NewsletterSegment.objects.create( 331 | name="monthly fr", segment_id=3, newsletter_list=self.monthly, lang="fr" 332 | ) 333 | 334 | def test_subscription_logged_in(self): 335 | self.client.login(username="thoas", password="secret") 336 | 337 | valid_data = {"receiver": "florent@ulule.com"} 338 | 339 | form = SubscriptionForm(data=valid_data, **{"newsletter_list": self.monthly}) 340 | 341 | self.assertTrue(form.is_valid()) 342 | 343 | user = User.objects.create(username="thoas", email="florent@ulule.com") 344 | form.save(user) 345 | 346 | new_subscriber = NewsletterSubscriber.objects.filter( 347 | email=valid_data["receiver"], user=user 348 | ) 349 | 350 | self.assertEqual(new_subscriber.count(), 1) 351 | 352 | self.backend.unregister("florent@ulule.com") 353 | 354 | def test_subscribe_task(self): 355 | user = User.objects.create(username="adele-ulule", email="adele@ulule.com") 356 | subscribe.apply_async( 357 | kwargs={ 358 | "email": "adele@ulule.com", 359 | "newsletter_list_id": self.monthly.pk, 360 | "lang": "fr", 361 | } 362 | ) 363 | 364 | new_subscriber = NewsletterSubscriber.objects.filter( 365 | email="adele@ulule.com", is_unsubscribed=False 366 | ) 367 | self.assertEqual(new_subscriber.count(), 1) 368 | 369 | 370 | class NewDatetime(datetime.datetime): 371 | @classmethod 372 | def now(cls): 373 | return cls(2014, 4, 9, 9, 56, 2, 342715) 374 | 375 | 376 | @mock.patch.object(settings, "BACKEND_CLASS", "courriers.backends.simple.SimpleBackend") 377 | class UnsubscribeFormTest(TestCase): 378 | def setUp(self): 379 | from courriers.backends import get_backend 380 | 381 | datetime.datetime = NewDatetime 382 | 383 | self.backend_klass = get_backend() 384 | self.backend = self.backend_klass() 385 | 386 | self.monthly = NewsletterList.objects.create( 387 | name="TestMonthly", slug="testmonthly" 388 | ) 389 | self.weekly = NewsletterList.objects.create( 390 | name="TestWeekly", slug="testweekly" 391 | ) 392 | self.daily = NewsletterList.objects.create(name="TestDaily", slug="testdaily") 393 | 394 | self.user = User.objects.create_user("adele", "adele@ulule.com", "$ecret") 395 | 396 | def test_unsubscription(self): 397 | self.backend.unregister("adele@ulule.com", self.monthly, lang="fr") 398 | 399 | self.backend.register("adele@ulule.com", self.monthly, lang="fr") 400 | self.backend.register("adele@ulule.com", self.weekly, lang="fr") 401 | self.backend.register("adele@ulule.com", self.daily, lang="fr") 402 | 403 | # Unsubscribe from monthly 404 | valid_data = {"email": "adele@ulule.com", "from_all": False} 405 | 406 | form = UnsubscribeForm( 407 | data=valid_data, 408 | initial={"email": "adele@ulule.com"}, 409 | **{"newsletter_list": self.monthly} 410 | ) 411 | 412 | self.assertTrue(form.is_valid()) 413 | 414 | form.save() 415 | 416 | old_subscriber = NewsletterSubscriber.objects.get( 417 | email=valid_data["email"], newsletter_list=self.monthly 418 | ) 419 | old_subscriber2 = NewsletterSubscriber.objects.get( 420 | email=valid_data["email"], newsletter_list=self.weekly 421 | ) 422 | 423 | self.assertEqual(old_subscriber.is_unsubscribed, True) 424 | self.assertEqual(old_subscriber.unsubscribed_at, datetime.now()) 425 | self.assertEqual(old_subscriber2.is_unsubscribed, False) 426 | 427 | # Unsubscribe from all 428 | valid_data = {"email": "adele@ulule.com", "from_all": True} 429 | 430 | form = UnsubscribeForm(data=valid_data, newsletter_list=self.weekly) 431 | 432 | self.assertTrue(form.is_valid()) 433 | 434 | form.save() 435 | 436 | old_subscriber = NewsletterSubscriber.objects.filter( 437 | email=valid_data["email"], newsletter_list=self.weekly 438 | ) 439 | old_subscriber2 = NewsletterSubscriber.objects.filter( 440 | email=valid_data["email"], newsletter_list=self.daily 441 | ) 442 | 443 | self.assertEqual(old_subscriber.get().is_unsubscribed, True) 444 | self.assertEqual(old_subscriber2.get().is_unsubscribed, True) 445 | self.assertEqual(old_subscriber2.get().unsubscribed_at, datetime.now()) 446 | 447 | def test_unsubscription_logged_in(self): 448 | user = User.objects.create_user("thoas", "florent@ulule.com", "secret") 449 | 450 | self.backend.register("florent@ulule.com", self.monthly, "fr", user=user) 451 | new_subscriber = NewsletterSubscriber.objects.filter( 452 | email="florent@ulule.com", user=user 453 | ) 454 | self.assertEqual(new_subscriber.count(), 1) 455 | 456 | valid_data = {"email": "florent@ulule.com", "from_all": False} 457 | 458 | form = UnsubscribeForm( 459 | data=valid_data, 460 | initial={"email": "florent@ulule.com"}, 461 | **{"newsletter_list": self.monthly} 462 | ) 463 | 464 | self.assertTrue(form.is_valid()) 465 | 466 | form.save(user) # Equivalent for saving with user logged in 467 | 468 | self.backend.unregister("florent@ulule.com") 469 | 470 | def test_unsubscribe_task(self): 471 | NewsletterSubscriber.objects.create( 472 | newsletter_list=self.monthly, email="adele@ulule.com", user=self.user 473 | ) 474 | 475 | unsubscribe.apply_async( 476 | kwargs={"email": "adele@ulule.com", "newsletter_list_id": self.monthly.pk} 477 | ) 478 | 479 | new_subscriber = NewsletterSubscriber.objects.filter( 480 | email="adele@ulule.com", newsletter_list=self.monthly, is_unsubscribed=True 481 | ) 482 | self.assertEqual(new_subscriber.count(), 1) 483 | 484 | 485 | if hasattr(settings, "COURRIERS_MAILJET_API_KEY") and hasattr( 486 | settings, "COURRIERS_MAILJET_API_SECRET_KEY" 487 | ): 488 | 489 | @mock.patch.object( 490 | settings, "BACKEND_CLASS", "courriers.backends.mailjet.MailjetBackend" 491 | ) 492 | class SubscribeMailjetFormTest(SubscribeFormTest): 493 | pass 494 | 495 | @mock.patch.object( 496 | settings, "BACKEND_CLASS", "courriers.backends.mailjet.MailjetBackend" 497 | ) 498 | class UnsubscribeMailjetFormTest(UnsubscribeFormTest): 499 | pass 500 | 501 | @mock.patch.object( 502 | settings, "BACKEND_CLASS", "courriers.backends.mailjet.MailjetBackend" 503 | ) 504 | class MailjetBackendTests(BaseBackendTests): 505 | def test_registration(self): 506 | super(MailjetBackendTests, self).test_registration() 507 | 508 | for newsletter in self.newsletters: 509 | self.backend.send_mails(newsletter) 510 | 511 | 512 | class NewsletterModelsTest(TestCase): 513 | def test_navigation(self): 514 | monthly = NewsletterList.objects.create(name="TestMonthly", slug="testmonthly") 515 | segment_monthly = NewsletterSegment.objects.create( 516 | name="monthly fr", segment_id=3, newsletter_list=monthly, lang="fr" 517 | ) 518 | 519 | Newsletter.objects.create( 520 | name="Newsletter4", 521 | status=Newsletter.STATUS_DRAFT, 522 | published_at=datetime.now() - datetime.timedelta(hours=4), 523 | newsletter_list=monthly, 524 | newsletter_segment=segment_monthly, 525 | ) 526 | n1 = Newsletter.objects.create( 527 | name="Newsletter1", 528 | status=Newsletter.STATUS_ONLINE, 529 | published_at=datetime.now() - datetime.timedelta(hours=3), 530 | newsletter_list=monthly, 531 | newsletter_segment=segment_monthly, 532 | ) 533 | n2 = Newsletter.objects.create( 534 | name="Newsletter2", 535 | status=Newsletter.STATUS_ONLINE, 536 | published_at=datetime.now() - datetime.timedelta(hours=2), 537 | newsletter_list=monthly, 538 | newsletter_segment=segment_monthly, 539 | ) 540 | n3 = Newsletter.objects.create( 541 | name="Newsletter3", 542 | status=Newsletter.STATUS_ONLINE, 543 | published_at=datetime.now() - datetime.timedelta(hours=1), 544 | newsletter_list=monthly, 545 | newsletter_segment=segment_monthly, 546 | ) 547 | 548 | self.assertEqual(n2.get_previous(), n1) 549 | self.assertEqual(n2.get_next(), n3) 550 | self.assertEqual(n1.get_previous(), None) 551 | -------------------------------------------------------------------------------- /courriers/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import url 2 | 3 | from .views import ( 4 | NewsletterListView, 5 | NewsletterDetailView, 6 | NewsletterListSubscribeView, 7 | NewsletterRawDetailView, 8 | NewsletterListUnsubscribeView, 9 | NewsletterListSubscribeDoneView, 10 | NewsletterListUnsubscribeDoneView, 11 | ) 12 | 13 | 14 | urlpatterns = [ 15 | url( 16 | r"^(?P(\d+))/detail/$", 17 | NewsletterDetailView.as_view(), 18 | name="newsletter_detail", 19 | ), 20 | url( 21 | r"^(?P(\w+))/subscribe/$", 22 | NewsletterListSubscribeView.as_view(), 23 | name="newsletter_list_subscribe", 24 | ), 25 | url( 26 | r"^(?P(\d+))/raw/$", 27 | NewsletterRawDetailView.as_view(), 28 | name="newsletter_raw_detail", 29 | ), 30 | url( 31 | r"^(?:(?P(\w+))/)?unsubscribe/$", 32 | NewsletterListUnsubscribeView.as_view(), 33 | name="newsletter_list_unsubscribe", 34 | ), 35 | url( 36 | r"^subscribe/done/$", 37 | NewsletterListSubscribeDoneView.as_view(), 38 | name="newsletter_list_subscribe_done", 39 | ), 40 | url( 41 | r"^unsubscribe/(?:(?P(\w+))/)?done/$", 42 | NewsletterListUnsubscribeDoneView.as_view(), 43 | name="newsletter_list_unsubscribe_done", 44 | ), 45 | url( 46 | r"^(?P(\w+))/(?:(?P(\w+))/)?(?:(?P(\d+))/)?$", 47 | NewsletterListView.as_view(), 48 | name="newsletter_list", 49 | ), 50 | ] 51 | -------------------------------------------------------------------------------- /courriers/utils.py: -------------------------------------------------------------------------------- 1 | from django.core import exceptions 2 | 3 | from importlib import import_module 4 | 5 | 6 | CLASS_PATH_ERROR = "django-courriers is unable to interpret settings value for %s. " "%s should be in the form of a tupple: " "('path.to.models.Class', 'app_label')." 7 | 8 | 9 | def load_class(class_path, setting_name=None): 10 | """ 11 | Loads a class given a class_path. The setting value may be a string or a 12 | tuple. 13 | 14 | The setting_name parameter is only there for pretty error output, and 15 | therefore is optional 16 | """ 17 | if not isinstance(class_path, str): 18 | try: 19 | class_path, app_label = class_path 20 | except Exception: 21 | if setting_name: 22 | raise exceptions.ImproperlyConfigured( 23 | CLASS_PATH_ERROR % (setting_name, setting_name) 24 | ) 25 | else: 26 | raise exceptions.ImproperlyConfigured( 27 | CLASS_PATH_ERROR % ("this setting", "It") 28 | ) 29 | 30 | try: 31 | class_module, class_name = class_path.rsplit(".", 1) 32 | except ValueError: 33 | if setting_name: 34 | txt = "%s isn't a valid module. Check your %s setting" % ( 35 | class_path, 36 | setting_name, 37 | ) 38 | else: 39 | txt = "%s isn't a valid module." % class_path 40 | raise exceptions.ImproperlyConfigured(txt) 41 | 42 | try: 43 | mod = import_module(class_module) 44 | except ImportError as e: 45 | if setting_name: 46 | txt = 'Error importing backend %s: "%s". Check your %s setting' % ( 47 | class_module, 48 | e, 49 | setting_name, 50 | ) 51 | else: 52 | txt = 'Error importing backend %s: "%s".' % (class_module, e) 53 | 54 | raise exceptions.ImproperlyConfigured(txt) 55 | 56 | try: 57 | clazz = getattr(mod, class_name) 58 | except AttributeError: 59 | if setting_name: 60 | txt = ( 61 | 'Backend module "%s" does not define a "%s" class. Check' 62 | " your %s setting" % (class_module, class_name, setting_name) 63 | ) 64 | else: 65 | txt = 'Backend module "%s" does not define a "%s" class.' % ( 66 | class_module, 67 | class_name, 68 | ) 69 | raise exceptions.ImproperlyConfigured(txt) 70 | return clazz 71 | 72 | 73 | def ajaxify_template_var(template_var): 74 | if isinstance(template_var, (list, tuple)): 75 | template_var = type(template_var)( 76 | ajaxify_template_name(name) for name in template_var 77 | ) 78 | elif isinstance(template_var, str): 79 | template_var = ajaxify_template_name(template_var) 80 | return template_var 81 | 82 | 83 | def ajaxify_template_name(name): 84 | if "." in name: 85 | name = "%s-ajax.%s" % tuple(name.rsplit(".", 1)) 86 | else: 87 | name += "-ajax" 88 | return name 89 | -------------------------------------------------------------------------------- /courriers/views.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from django.views.generic import ListView, DetailView, FormView, TemplateView 3 | from django.urls import reverse 4 | from django.http import HttpResponseRedirect 5 | from django.shortcuts import get_object_or_404 6 | from django.utils.functional import cached_property 7 | from django.views.generic.base import TemplateResponseMixin 8 | from django.utils import translation 9 | 10 | from .settings import PAGINATE_BY 11 | from .models import Newsletter, NewsletterList 12 | from .forms import SubscriptionForm, UnsubscribeForm 13 | from .utils import ajaxify_template_var 14 | 15 | 16 | class AJAXResponseMixin(TemplateResponseMixin): 17 | ajax_template_name = None 18 | 19 | def get_template_names(self): 20 | names = super(AJAXResponseMixin, self).get_template_names() 21 | 22 | if self.request.is_ajax(): 23 | if self.ajax_template_name: 24 | names = [self.ajax_template_name] + names 25 | else: 26 | names = ajaxify_template_var(names) + names 27 | return names 28 | 29 | 30 | class NewsletterListView(AJAXResponseMixin, ListView): 31 | model = Newsletter 32 | context_object_name = "newsletters" 33 | template_name = "courriers/newsletter_list.html" 34 | paginate_by = PAGINATE_BY 35 | 36 | def dispatch(self, *args, **kwargs): 37 | return super(NewsletterListView, self).dispatch(*args, **kwargs) 38 | 39 | @cached_property 40 | def newsletter_list(self): 41 | return get_object_or_404( 42 | NewsletterList.objects.all(), slug=self.kwargs.get("slug") 43 | ) 44 | 45 | def get_queryset(self): 46 | lang = translation.get_language() 47 | qs = self.newsletter_list.newsletters.status_online() 48 | if lang: 49 | qs = qs.filter(newsletter_segment__lang=lang) 50 | qs = qs.order_by("-published_at") 51 | return qs 52 | 53 | def get_context_data(self, **kwargs): 54 | context = super(NewsletterListView, self).get_context_data(**kwargs) 55 | context["newsletter_list"] = self.newsletter_list 56 | return context 57 | 58 | 59 | class NewsletterDetailView(AJAXResponseMixin, DetailView): 60 | model = Newsletter 61 | context_object_name = "newsletter" 62 | template_name = "courriers/newsletter_detail.html" 63 | 64 | def get_queryset(self): 65 | return self.model.objects.status_online() 66 | 67 | def get_context_data(self, **kwargs): 68 | context = super(NewsletterDetailView, self).get_context_data(**kwargs) 69 | 70 | context["newsletter_list"] = self.object.newsletter_list 71 | 72 | return context 73 | 74 | 75 | class BaseNewsletterListFormView(AJAXResponseMixin, FormView): 76 | model = NewsletterList 77 | context_object_name = "newsletter_list" 78 | 79 | @cached_property 80 | def object(self): 81 | slug = self.kwargs.get("slug", None) 82 | 83 | if slug: 84 | return get_object_or_404(self.model, slug=slug) 85 | 86 | return None 87 | 88 | def post(self, request, *args, **kwargs): 89 | self.get_context_data(**kwargs) 90 | 91 | form_class = self.get_form_class() 92 | form = self.get_form(form_class) 93 | 94 | if form.is_valid(): 95 | return self.form_valid(form) 96 | 97 | return self.form_invalid(form) 98 | 99 | def get_form_kwargs(self): 100 | kwargs = super(BaseNewsletterListFormView, self).get_form_kwargs() 101 | 102 | if self.object: 103 | kwargs["newsletter_list"] = self.object 104 | 105 | return kwargs 106 | 107 | def get_context_data(self, **kwargs): 108 | context = super(BaseNewsletterListFormView, self).get_context_data(**kwargs) 109 | 110 | if self.object: 111 | context[self.context_object_name] = self.object 112 | 113 | return context 114 | 115 | def form_valid(self, form): 116 | if self.request.user.is_authenticated: 117 | form.save(self.request.user) 118 | else: 119 | form.save() 120 | 121 | return HttpResponseRedirect(self.get_success_url()) 122 | 123 | 124 | class NewsletterListSubscribeView(BaseNewsletterListFormView): 125 | template_name = "courriers/newsletter_list_subscribe_form.html" 126 | form_class = SubscriptionForm 127 | 128 | def get_success_url(self): 129 | return reverse("newsletter_list_subscribe_done") 130 | 131 | 132 | class NewsletterRawDetailView(AJAXResponseMixin, DetailView): 133 | model = Newsletter 134 | template_name = "courriers/newsletter_raw_detail.html" 135 | 136 | def get_context_data(self, **kwargs): 137 | context = super(NewsletterRawDetailView, self).get_context_data(**kwargs) 138 | 139 | context["items"] = self.object.items.all() 140 | 141 | for item in context["items"]: 142 | item.newsletter = self.object 143 | 144 | return context 145 | 146 | 147 | class NewsletterListUnsubscribeView(BaseNewsletterListFormView): 148 | template_name = "courriers/newsletter_list_unsubscribe.html" 149 | 150 | def get_form_class(self): 151 | return UnsubscribeForm 152 | 153 | def get_initial(self): 154 | initial = super(NewsletterListUnsubscribeView, self).get_initial() 155 | email = self.request.GET.get("email", None) 156 | 157 | if email: 158 | initial["email"] = email 159 | 160 | return initial.copy() 161 | 162 | def get_success_url(self): 163 | if self.object: 164 | return reverse( 165 | "newsletter_list_unsubscribe_done", kwargs={"slug": self.object.slug} 166 | ) 167 | 168 | return reverse("newsletter_list_unsubscribe_done") 169 | 170 | 171 | class NewsletterListUnsubscribeDoneView(AJAXResponseMixin, TemplateView): 172 | template_name = "courriers/newsletter_list_unsubscribe_done.html" 173 | model = NewsletterList 174 | context_object_name = "newsletter_list" 175 | 176 | def get_context_data(self, **kwargs): 177 | context = super(NewsletterListUnsubscribeDoneView, self).get_context_data( 178 | **kwargs 179 | ) 180 | 181 | slug = self.kwargs.get("slug", None) 182 | 183 | if slug: 184 | context[self.context_object_name] = get_object_or_404(self.model, slug=slug) 185 | 186 | return context 187 | 188 | 189 | class NewsletterListSubscribeDoneView(AJAXResponseMixin, TemplateView): 190 | template_name = "courriers/newsletter_list_subscribe_done.html" 191 | model = NewsletterList 192 | context_object_name = "newsletter_list" 193 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | if __name__ == "__main__": 6 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'courriers.tests.settings') 7 | os.environ.setdefault('DJANGO_CONFIGURATION', 'Test') 8 | 9 | from django.core.management import execute_from_command_line 10 | 11 | execute_from_command_line(sys.argv) -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import os 3 | from setuptools import setup, find_packages 4 | 5 | version = __import__("courriers").__version__ 6 | 7 | root = os.path.abspath(os.path.dirname(__file__)) 8 | 9 | with open(os.path.join(root, "README.rst")) as f: 10 | README = f.read() 11 | 12 | setup( 13 | name="django-courriers", 14 | version=version, 15 | description="A generic application to manage your newsletters", 16 | long_description=README, 17 | author="Florent Messa", 18 | author_email="florent.messa@gmail.com", 19 | url="http://github.com/ulule/django-courriers", 20 | packages=find_packages(), 21 | zip_safe=False, 22 | include_package_data=True, 23 | install_requires=[ 24 | "django-separatedvaluesfield", 25 | ], 26 | classifiers=[ 27 | "Environment :: Web Environment", 28 | "Intended Audience :: Developers", 29 | "License :: OSI Approved :: MIT License", 30 | "Operating System :: OS Independent", 31 | "Programming Language :: Python", 32 | "Programming Language :: Python :: 3", 33 | "Programming Language :: Python :: 3.8", 34 | "Programming Language :: Python :: 3.9", 35 | "Topic :: Utilities", 36 | ], 37 | ) 38 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | {py38}-django{32} 4 | downloadcache = .tox/_download/ 5 | 6 | [testenv] 7 | basepython = 8 | py38: python3.8 9 | commands: 10 | make test 11 | deps = 12 | coverage 13 | six 14 | Pillow 15 | mailchimp 16 | celery 17 | mock 18 | {py38}-django32: Django>=3.2 19 | --------------------------------------------------------------------------------