From a658e125959b4fa9973e969d604959f8ea4f63f1 Mon Sep 17 00:00:00 2001 From: medmunds Date: Thu, 14 May 2015 10:10:42 -0700 Subject: [PATCH 01/33] 2.0 development branch --- djrill/_version.py | 2 +- docs/history.rst | 15 ++++++++------- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/djrill/_version.py b/djrill/_version.py index 91f181c..0a535b0 100644 --- a/djrill/_version.py +++ b/djrill/_version.py @@ -1,4 +1,4 @@ -VERSION = (1, 5, 0, 'dev') # Remove the 'dev' component in release branches +VERSION = (2, 0, 0, 'dev') # Remove the 'dev' component in release branches __version__ = '.'.join([str(x) for x in VERSION[:3]]) # major.minor.patch if len(VERSION) > 3: # x.y.z-pre.release (note the hyphen) __version__ += '-' + '.'.join([str(x) for x in VERSION[3:]]) diff --git a/docs/history.rst b/docs/history.rst index c730a03..7d72a31 100644 --- a/docs/history.rst +++ b/docs/history.rst @@ -7,12 +7,13 @@ Among other things, this means that minor updates and breaking changes will always increment the major version number (1.x to 2.0). -Upcoming Changes in Djrill 2.0 ------------------------------- -Djrill 2.0 is under development and will include some breaking changes. -Although the changes won't impact most Djrill users, the current -version of Djrill (1.4) will try to warn you if you use things +Djrill 2.0 (in development) +--------------------------- + +Djrill 2.0 is under development and includes some breaking changes. +Although the changes won't impact most Djrill users, the previous +version of Djrill (1.4) tries to warn you if you use things that will change. (Warnings appear in the console when running Django in debug mode.) @@ -66,8 +67,8 @@ with :exc:`djrill.MandrillAPIError`. Djrill 1.4 will report a DeprecationWarning if you are still importing DjrillBackendHTTPError. -Change Log ----------- +Older Releases +-------------- Version 1.4: From 99ac0990813b1b841c8242c98c0c76d93a52ebb1 Mon Sep 17 00:00:00 2001 From: medmunds Date: Thu, 14 May 2015 11:00:52 -0700 Subject: [PATCH 02/33] Remove DjrillAdminSite Closes #78 --- MANIFEST.in | 1 - README.rst | 1 - djrill/__init__.py | 73 +--------- djrill/admin.py | 17 --- djrill/templates/djrill/_status.html | 9 -- djrill/templates/djrill/index.html | 18 --- djrill/templates/djrill/senders_list.html | 79 ----------- djrill/templates/djrill/status.html | 67 ---------- djrill/templates/djrill/tags_list.html | 91 ------------- djrill/templates/djrill/urls_list.html | 81 ----------- djrill/templatetags/__init__.py | 0 djrill/templatetags/djrill_future.py | 16 --- djrill/tests/__init__.py | 1 - djrill/tests/admin_urls.py | 18 --- djrill/tests/test_admin.py | 155 ---------------------- djrill/tests/test_legacy.py | 8 +- djrill/views.py | 139 +------------------ docs/history.rst | 29 ++-- docs/index.rst | 4 +- docs/installation.rst | 44 ------ runtests.py | 25 +--- 21 files changed, 29 insertions(+), 847 deletions(-) delete mode 100644 djrill/admin.py delete mode 100644 djrill/templates/djrill/_status.html delete mode 100644 djrill/templates/djrill/index.html delete mode 100644 djrill/templates/djrill/senders_list.html delete mode 100644 djrill/templates/djrill/status.html delete mode 100644 djrill/templates/djrill/tags_list.html delete mode 100644 djrill/templates/djrill/urls_list.html delete mode 100644 djrill/templatetags/__init__.py delete mode 100644 djrill/templatetags/djrill_future.py delete mode 100644 djrill/tests/admin_urls.py delete mode 100644 djrill/tests/test_admin.py diff --git a/MANIFEST.in b/MANIFEST.in index 69b3f6a..68d76c8 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,4 +1,3 @@ include README.rst AUTHORS.txt LICENSE -recursive-include djrill/templates *.html recursive-include djrill *.py prune djrill/tests diff --git a/README.rst b/README.rst index 4ef012b..6e0dab3 100644 --- a/README.rst +++ b/README.rst @@ -28,7 +28,6 @@ package. It includes: * Mandrill-specific extensions like tags, metadata, tracking, and MailChimp templates * Optional support for Mandrill inbound email and other webhook notifications, via Django signals -* An optional Django admin interface Djrill is released under the BSD license. It is tested against Django 1.3--1.8 (including Python 3 with Django 1.6+, and PyPy support with Django 1.5+). diff --git a/djrill/__init__.py b/djrill/__init__.py index 92b5432..3f5fb88 100644 --- a/djrill/__init__.py +++ b/djrill/__init__.py @@ -1,79 +1,10 @@ from django.conf import settings -from django.contrib.admin.sites import AdminSite -from django.utils.text import capfirst - -from djrill.exceptions import MandrillAPIError, NotSupportedByMandrillError, removed_in_djrill_2 +from djrill.exceptions import MandrillAPIError, NotSupportedByMandrillError from ._version import * + # This backend was developed against this API endpoint. # You can override in settings.py, if desired. MANDRILL_API_URL = getattr(settings, "MANDRILL_API_URL", "https://mandrillapp.com/api/1.0") - - -class DjrillAdminSite(AdminSite): - # This was originally adapted from https://github.com/jsocol/django-adminplus. - # If new versions of Django break DjrillAdminSite, it's worth checking to see - # whether django-adminplus has dealt with something similar. - - def __init__(self, *args, **kwargs): - removed_in_djrill_2( - "DjrillAdminSite will be removed in Djrill 2.0. " - "You should remove references to it from your code. " - "(All of its data is available in the Mandrill dashboard.)" - ) - super(DjrillAdminSite, self).__init__(*args, **kwargs) - - - index_template = "djrill/index.html" - custom_views = [] - custom_urls = [] - - def register_view(self, path, view, name, display_name=None): - """Add a custom admin view. - - * `path` is the path in the admin where the view will live, e.g. - http://example.com/admin/somepath - * `view` is any view function you can imagine. - * `name` is an optional pretty name for the list of custom views. If - empty, we'll guess based on view.__name__. - """ - self.custom_views.append((path, view, name, display_name)) - - def register_url(self, path, view, name): - self.custom_urls.append((path, view, name)) - - def get_urls(self): - """Add our custom views to the admin urlconf.""" - urls = super(DjrillAdminSite, self).get_urls() - try: - from django.conf.urls import include, url - except ImportError: - # Django 1.3 - #noinspection PyDeprecation - from django.conf.urls.defaults import include, url - for path, view, name, display_name in self.custom_views: - urls += [ - url(r'^%s$' % path, self.admin_view(view), name=name) - ] - for path, view, name in self.custom_urls: - urls += [ - url(r'^%s$' % path, self.admin_view(view), name=name) - ] - - return urls - - def index(self, request, extra_context=None): - """Make sure our list of custom views is on the index page.""" - if not extra_context: - extra_context = {} - custom_list = [(path, display_name if display_name else - capfirst(view.__name__)) for path, view, name, display_name in - self.custom_views] - # Sort views alphabetically. - custom_list.sort(key=lambda x: x[1]) - extra_context.update({ - 'custom_list': custom_list - }) - return super(DjrillAdminSite, self).index(request, extra_context) diff --git a/djrill/admin.py b/djrill/admin.py deleted file mode 100644 index 0ad70ee..0000000 --- a/djrill/admin.py +++ /dev/null @@ -1,17 +0,0 @@ -from django.contrib import admin - -from djrill.views import (DjrillIndexView, DjrillSendersListView, - DjrillTagListView, - DjrillUrlListView) - -# Only try to register Djrill admin views if DjrillAdminSite -# or django-adminplus is in use -if hasattr(admin.site,'register_view'): - admin.site.register_view("djrill/senders/", DjrillSendersListView.as_view(), - "djrill_senders", "senders") - admin.site.register_view("djrill/status/", DjrillIndexView.as_view(), - "djrill_status", "status") - admin.site.register_view("djrill/tags/", DjrillTagListView.as_view(), - "djrill_tags", "tags") - admin.site.register_view("djrill/urls/", DjrillUrlListView.as_view(), - "djrill_urls", "urls") diff --git a/djrill/templates/djrill/_status.html b/djrill/templates/djrill/_status.html deleted file mode 100644 index 6796adc..0000000 --- a/djrill/templates/djrill/_status.html +++ /dev/null @@ -1,9 +0,0 @@ -
-

Tools & Info

-

Status

- {% if status %} -

Mandrill is UP

- {% else %} -

Mandrill is DOWN

- {% endif %} -
diff --git a/djrill/templates/djrill/index.html b/djrill/templates/djrill/index.html deleted file mode 100644 index 0c268a3..0000000 --- a/djrill/templates/djrill/index.html +++ /dev/null @@ -1,18 +0,0 @@ -{% extends "admin/index.html" %} - -{% block sidebar %} - {{ block.super }} - - {% if custom_list %} -
- - - - {% for path, name in custom_list %} - - {% endfor %} - -
Djrill
{{ name|capfirst }}
-
- {% endif %} -{% endblock %} diff --git a/djrill/templates/djrill/senders_list.html b/djrill/templates/djrill/senders_list.html deleted file mode 100644 index df86a9d..0000000 --- a/djrill/templates/djrill/senders_list.html +++ /dev/null @@ -1,79 +0,0 @@ -{% extends "admin/base_site.html" %} -{% load admin_list i18n %} -{% load url from future %} -{% load cycle from djrill_future %} - -{% block extrastyle %} - {{ block.super }} - - {{ media.css }} - {% if not actions_on_top and not actions_on_bottom %} - - {% endif %} -{% endblock %} - -{% block extrahead %} -{{ block.super }} -{{ media.js }} -{% endblock %} - -{% block title %} Djrill Senders | {% trans "Django site admin" %}{% endblock %} - -{% block bodyclass %}change-list{% endblock %} - -{% if not is_popup %} - {% block breadcrumbs %} - - {% endblock %} -{% endif %} - -{% block coltype %}flex{% endblock %} - -{% block content %} -
- -
- {% block date_hierarchy %}{% endblock %} - - {% block filters %} - {% include "djrill/_status.html" %} - {% endblock %} - - {% block result_list %} - {% if objects %} -
- - - - {% for header in objects.0.keys %} - - {% endfor %} - - - - {% for result in objects %} - - {% for item in result.values %} - - {% endfor %} - - {% endfor %} - -
{{ header|capfirst }}
{{ item }}
-
- {% endif %} - {% endblock %} - {% block pagination %}{% endblock %} -
-
-{% endblock %} diff --git a/djrill/templates/djrill/status.html b/djrill/templates/djrill/status.html deleted file mode 100644 index 33dac6f..0000000 --- a/djrill/templates/djrill/status.html +++ /dev/null @@ -1,67 +0,0 @@ -{% extends "admin/base_site.html" %} -{% load admin_list i18n %} -{% load url from future %} -{% block extrastyle %} - {{ block.super }} - - {{ media.css }} - {% if not actions_on_top and not actions_on_bottom %} - - {% endif %} -{% endblock %} - -{% block extrahead %} -{{ block.super }} -{{ media.js }} -{% if action_form %}{% if actions_on_top or actions_on_bottom %} - -{% endif %}{% endif %} -{% endblock %} - -{% block title %} Djrill Status | {% trans "Django site admin" %}{% endblock %} - -{% block bodyclass %}change-list{% endblock %} - -{% if not is_popup %} - {% block breadcrumbs %} - - {% endblock %} -{% endif %} - -{% block coltype %}flex{% endblock %} - -{% block content %} -
- {% block object-tools %} - {% endblock %} -
- {% block search %}{% endblock %} - {% block date_hierarchy %}{% endblock %} - - {% block filters %}{% endblock %} - {% block pagination %}{% endblock %} -
- {% for term, value in status.items %} -
{{ term|capfirst }}
-
{{ value }}
- {% endfor %} -
-
-
-{% endblock %} diff --git a/djrill/templates/djrill/tags_list.html b/djrill/templates/djrill/tags_list.html deleted file mode 100644 index f6fb80f..0000000 --- a/djrill/templates/djrill/tags_list.html +++ /dev/null @@ -1,91 +0,0 @@ -{% extends "admin/base_site.html" %} -{% load admin_list i18n %} -{% load url from future %} -{% load cycle from djrill_future %} - -{% block extrastyle %} - {{ block.super }} - - {{ media.css }} - {% if not actions_on_top and not actions_on_bottom %} - - {% endif %} -{% endblock %} - -{% block extrahead %} -{{ block.super }} -{{ media.js }} -{% endblock %} - -{% block title %} Djrill Tags | {% trans "Django site admin" %}{% endblock %} - -{% block bodyclass %}change-list{% endblock %} - -{% if not is_popup %} - {% block breadcrumbs %} - - {% endblock %} -{% endif %} - -{% block coltype %}flex{% endblock %} - -{% block content %} -
- -
- {% block search %} {% endblock %} - - {% block date_hierarchy %}{% endblock %} - - {% block filters %} - {% include "djrill/_status.html" %} - {% endblock %} - - {% block result_list %} - {% if objects %} -
- - - - - - - - - - - - - - - {% for result in objects %} - - - - - - - - - - - {% endfor %} - -
TagIDSentOpensClicksRejectsBouncesComplaints
{{ result.tag }}{{ result.id }}{{ result.sent }}{{ result.opens }}{{ result.clicks }}{{ result.rejects }}{{ result.bounces }}{{ result.complaints }}
-
- {% endif %} - {% endblock %} - {% block pagination %}{% endblock %} -
-
-{% endblock %} diff --git a/djrill/templates/djrill/urls_list.html b/djrill/templates/djrill/urls_list.html deleted file mode 100644 index 71c3e23..0000000 --- a/djrill/templates/djrill/urls_list.html +++ /dev/null @@ -1,81 +0,0 @@ -{% extends "admin/base_site.html" %} -{% load admin_list i18n %} -{% load url from future %} -{% load cycle from djrill_future %} - -{% block extrastyle %} - {{ block.super }} - - {{ media.css }} - {% if not actions_on_top and not actions_on_bottom %} - - {% endif %} -{% endblock %} - -{% block extrahead %} -{{ block.super }} -{{ media.js }} -{% endblock %} - -{% block title %} Djrill URLs | {% trans "Django site admin" %}{% endblock %} - -{% block bodyclass %}change-list{% endblock %} - -{% if not is_popup %} - {% block breadcrumbs %} - - {% endblock %} -{% endif %} - -{% block coltype %}flex{% endblock %} - -{% block content %} -
- -
- {% block search %}{% endblock %} - - {% block date_hierarchy %}{% endblock %} - - {% block filters %} - {% include "djrill/_status.html" %} - {% endblock %} - - {% block result_list %} - {% if objects %} -
- - - - {% for header in objects.0.keys %} - - {% endfor %} - - - - {% for result in objects %} - - {% for item in result.values %} - - {% endfor %} - - {% endfor %} - -
{{ header|capfirst }}
{{ item }}
-
- {% endif %} - {% endblock %} - {% block pagination %}{% endblock %} -
-
-{% endblock %} diff --git a/djrill/templatetags/__init__.py b/djrill/templatetags/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/djrill/templatetags/djrill_future.py b/djrill/templatetags/djrill_future.py deleted file mode 100644 index 0555158..0000000 --- a/djrill/templatetags/djrill_future.py +++ /dev/null @@ -1,16 +0,0 @@ -# Future templatetags library that is also backwards compatible with -# older versions of Django (so long as Djrill's code is compatible -# with the future behavior). - -from django import template - -# Django 1.8 changes autoescape behavior in cycle tag. -# Djrill has been compatible with future behavior all along. -try: - from django.templatetags.future import cycle -except ImportError: - from django.template.defaulttags import cycle - - -register = template.Library() -register.tag(cycle) diff --git a/djrill/tests/__init__.py b/djrill/tests/__init__.py index 910905a..c1b7504 100644 --- a/djrill/tests/__init__.py +++ b/djrill/tests/__init__.py @@ -1,4 +1,3 @@ -from djrill.tests.test_admin import * from djrill.tests.test_legacy import * from djrill.tests.test_mandrill_send import * from djrill.tests.test_mandrill_send_template import * diff --git a/djrill/tests/admin_urls.py b/djrill/tests/admin_urls.py deleted file mode 100644 index c8e5192..0000000 --- a/djrill/tests/admin_urls.py +++ /dev/null @@ -1,18 +0,0 @@ -try: - from django.conf.urls import include, url -except ImportError: - # Django 1.3 - from django.conf.urls.defaults import include, url - -from django.contrib import admin - -from djrill import DjrillAdminSite - -# Set up the DjrillAdminSite as suggested in the docs - -admin.site = DjrillAdminSite() -admin.autodiscover() - -urlpatterns = [ - url(r'^admin/', include(admin.site.urls)), -] diff --git a/djrill/tests/test_admin.py b/djrill/tests/test_admin.py deleted file mode 100644 index 299cdd7..0000000 --- a/djrill/tests/test_admin.py +++ /dev/null @@ -1,155 +0,0 @@ -import sys -import warnings - -from django.test import TestCase -from django.contrib.auth.models import User -from django.contrib import admin -import six - -from djrill.exceptions import RemovedInDjrill2 -from djrill.tests.mock_backend import DjrillBackendMockAPITestCase -from djrill.tests.utils import override_settings - - -def reset_admin_site(): - """Return the Django admin globals to their original state""" - admin.site = admin.AdminSite() # restore default - if 'djrill.admin' in sys.modules: - del sys.modules['djrill.admin'] # force autodiscover to re-import - - -@override_settings(ROOT_URLCONF='djrill.tests.admin_urls') -class DjrillAdminTests(DjrillBackendMockAPITestCase): - """Test the Djrill admin site""" - - @classmethod - def setUpClass(cls): - super(DjrillAdminTests, cls).setUpClass() - # Other test cases may muck with the Django admin site globals, - # so return it to the default state before loading test_admin_urls - reset_admin_site() - - def run(self, result=None): - with warnings.catch_warnings(): - # DjrillAdminSite deprecation is tested in test_legacy - warnings.filterwarnings('ignore', category=RemovedInDjrill2, - message="DjrillAdminSite will be removed in Djrill 2.0") - # We don't care that the `cycle` template tag will be removed in Django 2.0, - # because we're planning to drop the Djrill admin templates before then. - warnings.filterwarnings('ignore', category=PendingDeprecationWarning, - message="Loading the `cycle` tag from the `future` library") - # We don't care that user messaging was deprecated in Django 1.3 - # (testing artifact of our runtests.py minimal Django settings) - warnings.filterwarnings('ignore', category=DeprecationWarning, - message="The user messaging API is deprecated.") - super(DjrillAdminTests, self).run(result) - - def setUp(self): - super(DjrillAdminTests, self).setUp() - # Must be authenticated staff to access admin site... - admin = User.objects.create_user('admin', 'admin@example.com', 'secret') - admin.is_staff = True - admin.save() - self.client.login(username='admin', password='secret') - - def test_admin_senders(self): - self.mock_post.return_value = self.MockResponse(raw=self.mock_api_content['users/senders.json']) - response = self.client.get('/admin/djrill/senders/') - self.assertEqual(response.status_code, 200) - self.assertContains(response, "Senders") - self.assertContains(response, "sender.example@mandrillapp.com") - - def test_admin_status(self): - self.mock_post.return_value = self.MockResponse(raw=self.mock_api_content['users/info.json']) - response = self.client.get('/admin/djrill/status/') - self.assertEqual(response.status_code, 200) - self.assertContains(response, "Status") - self.assertContains(response, "myusername") - - def test_admin_tags(self): - self.mock_post.return_value = self.MockResponse(raw=self.mock_api_content['tags/list.json']) - response = self.client.get('/admin/djrill/tags/') - self.assertEqual(response.status_code, 200) - self.assertContains(response, "Tags") - self.assertContains(response, "example-tag") - - def test_admin_urls(self): - self.mock_post.return_value = self.MockResponse(raw=self.mock_api_content['urls/list.json']) - response = self.client.get('/admin/djrill/urls/') - self.assertEqual(response.status_code, 200) - self.assertContains(response, "URLs") - self.assertContains(response, "example.com/example-page") - - def test_admin_index(self): - """Make sure Djrill section is included in the admin index page""" - response = self.client.get('/admin/') - self.assertEqual(response.status_code, 200) - self.assertContains(response, "Djrill") - - - mock_api_content = { - 'users/senders.json': six.b(''' - [ - { - "address": "sender.example@mandrillapp.com", - "created_at": "2013-01-01 15:30:27", - "sent": 42, "hard_bounces": 42, "soft_bounces": 42, "rejects": 42, "complaints": 42, - "unsubs": 42, "opens": 42, "clicks": 42, "unique_opens": 42, "unique_clicks": 42 - } - ] - '''), - - 'users/info.json': six.b(''' - { - "username": "myusername", - "created_at": "2013-01-01 15:30:27", - "public_id": "aaabbbccc112233", - "reputation": 42, - "hourly_quota": 42, - "backlog": 42, - "stats": { - "today": { "sent": 42, "hard_bounces": 42, "soft_bounces": 42, "rejects": 42, "complaints": 42, - "unsubs": 42, "opens": 42, "unique_opens": 42, "clicks": 42, "unique_clicks": 42 }, - "last_7_days": { "sent": 42, "hard_bounces": 42, "soft_bounces": 42, "rejects": 42, "complaints": 42, - "unsubs": 42, "opens": 42, "unique_opens": 42, "clicks": 42, "unique_clicks": 42 }, - "last_30_days": { "sent": 42, "hard_bounces": 42, "soft_bounces": 42, "rejects": 42, "complaints": 42, - "unsubs": 42, "opens": 42, "unique_opens": 42, "clicks": 42, "unique_clicks": 42 }, - "last_60_days": { "sent": 42, "hard_bounces": 42, "soft_bounces": 42, "rejects": 42, "complaints": 42, - "unsubs": 42, "opens": 42, "unique_opens": 42, "clicks": 42, "unique_clicks": 42 }, - "last_90_days": { "sent": 42, "hard_bounces": 42, "soft_bounces": 42, "rejects": 42, "complaints": 42, - "unsubs": 42, "opens": 42, "unique_opens": 42, "clicks": 42, "unique_clicks": 42 }, - "all_time": { "sent": 42, "hard_bounces": 42, "soft_bounces": 42, "rejects": 42, "complaints": 42, - "unsubs": 42, "opens": 42, "unique_opens": 42, "clicks": 42, "unique_clicks": 42 } - } - } - '''), - - 'tags/list.json': six.b(''' - [ - { - "tag": "example-tag", - "reputation": 42, - "sent": 42, "hard_bounces": 42, "soft_bounces": 42, "rejects": 42, "complaints": 42, - "unsubs": 42, "opens": 42, "clicks": 42, "unique_opens": 42, "unique_clicks": 42 - } - ] - '''), - - 'urls/list.json': six.b(''' - [ - { - "url": "http://example.com/example-page", - "sent": 42, - "clicks": 42, - "unique_clicks": 42 - } - ] - '''), - } - - -class DjrillNoAdminTests(TestCase): - def test_admin_autodiscover_without_djrill(self): - """Make sure autodiscover doesn't die without DjrillAdminSite""" - reset_admin_site() - admin.autodiscover() # test: this shouldn't error diff --git a/djrill/tests/test_legacy.py b/djrill/tests/test_legacy.py index 2bcb68b..3213508 100644 --- a/djrill/tests/test_legacy.py +++ b/djrill/tests/test_legacy.py @@ -6,7 +6,7 @@ import warnings from django.core import mail from django.test import TestCase -from djrill import MandrillAPIError, NotSupportedByMandrillError, DjrillAdminSite +from djrill import MandrillAPIError, NotSupportedByMandrillError from djrill.exceptions import RemovedInDjrill2 from djrill.mail import DjrillMessage from djrill.tests.mock_backend import DjrillBackendMockAPITestCase @@ -19,12 +19,6 @@ class DjrillBackendDeprecationTests(DjrillBackendMockAPITestCase): reset_warning_registry() super(DjrillBackendDeprecationTests, self).setUp() - def test_deprecated_admin_site(self): - """Djrill 2.0 drops the custom DjrillAdminSite""" - self.assertWarnsMessage(DeprecationWarning, - "DjrillAdminSite will be removed in Djrill 2.0", - DjrillAdminSite) - def test_deprecated_json_date_encoding(self): """Djrill 2.0+ avoids a blanket JSONDateUTCEncoder""" # Djrill allows dates for send_at, so shouldn't warn: diff --git a/djrill/views.py b/djrill/views.py index 3caaec9..cb226bb 100644 --- a/djrill/views.py +++ b/djrill/views.py @@ -2,88 +2,15 @@ from base64 import b64encode import hashlib import hmac import json -from django import forms from django.conf import settings -from django.contrib import messages from django.core.exceptions import ImproperlyConfigured -from django.views.generic import TemplateView, View +from django.views.generic import View from django.http import HttpResponse from django.utils.decorators import method_decorator from django.views.decorators.csrf import csrf_exempt -import requests - -from djrill import MANDRILL_API_URL, signals -from .compat import b - - -class DjrillAdminMedia(object): - def _media(self): - js = ["js/core.js", "js/jquery.min.js", "js/jquery.init.js"] - - return forms.Media(js=["%s%s" % (settings.STATIC_URL, url) for url in js]) - media = property(_media) - - -class DjrillApiMixin(object): - """ - Simple Mixin to grab the api info from the settings file. - """ - def __init__(self): - self.api_key = getattr(settings, "MANDRILL_API_KEY", None) - self.api_url = MANDRILL_API_URL - - if not self.api_key: - raise ImproperlyConfigured( - "You have not set your mandrill api key in the settings file.") - - def get_context_data(self, **kwargs): - kwargs = super(DjrillApiMixin, self).get_context_data(**kwargs) - - status = False - req = requests.post("%s/%s" % (self.api_url, "users/ping.json"), - data={"key": self.api_key}) - if req.status_code == 200: - status = True - - kwargs.update({"status": status}) - return kwargs - - -class DjrillApiJsonObjectsMixin(object): - """ - Mixin to grab json objects from the api. - """ - api_uri = None - - def get_api_uri(self): - if self.api_uri is None: - raise NotImplementedError( - "%(cls)s is missing an api_uri. " - "Define %(cls)s.api_uri or override %(cls)s.get_api_uri()." % { - "cls": self.__class__.__name__ - }) - - def get_json_objects(self, extra_dict=None, extra_api_uri=None): - request_dict = {"key": self.api_key} - if extra_dict: - request_dict.update(extra_dict) - payload = json.dumps(request_dict) - api_uri = extra_api_uri or self.api_uri - req = requests.post("%s/%s" % (self.api_url, api_uri), - data=payload) - if req.status_code == 200: - return req.text - messages.error(self.request, self._api_error_handler(req)) - return json.dumps("error") - - def _api_error_handler(self, req): - """ - If the API returns an error, display it to the user. - """ - content = json.loads(req.text) - return "Mandrill returned a %d response: %s" % (req.status_code, - content["message"]) +from djrill import signals +from djrill.compat import b class DjrillWebhookSecretMixin(object): @@ -139,66 +66,6 @@ class DjrillWebhookSignatureMixin(object): request, *args, **kwargs) -class DjrillIndexView(DjrillApiMixin, TemplateView): - template_name = "djrill/status.html" - - def get(self, request, *args, **kwargs): - - payload = json.dumps({"key": self.api_key}) - req = requests.post("%s/users/info.json" % self.api_url, data=payload) - - return self.render_to_response({"status": json.loads(req.text)}) - - -class DjrillSendersListView(DjrillAdminMedia, DjrillApiMixin, - DjrillApiJsonObjectsMixin, TemplateView): - - api_uri = "users/senders.json" - template_name = "djrill/senders_list.html" - - def get(self, request, *args, **kwargs): - objects = self.get_json_objects() - context = self.get_context_data() - context.update({ - "objects": json.loads(objects), - "media": self.media, - }) - - return self.render_to_response(context) - - -class DjrillTagListView(DjrillAdminMedia, DjrillApiMixin, - DjrillApiJsonObjectsMixin, TemplateView): - - api_uri = "tags/list.json" - template_name = "djrill/tags_list.html" - - def get(self, request, *args, **kwargs): - objects = self.get_json_objects() - context = self.get_context_data() - context.update({ - "objects": json.loads(objects), - "media": self.media, - }) - return self.render_to_response(context) - - -class DjrillUrlListView(DjrillAdminMedia, DjrillApiMixin, - DjrillApiJsonObjectsMixin, TemplateView): - - api_uri = "urls/list.json" - template_name = "djrill/urls_list.html" - - def get(self, request, *args, **kwargs): - objects = self.get_json_objects() - context = self.get_context_data() - context.update({ - "objects": json.loads(objects), - "media": self.media - }) - return self.render_to_response(context) - - class DjrillWebhookView(DjrillWebhookSecretMixin, DjrillWebhookSignatureMixin, View): def head(self, request, *args, **kwargs): return HttpResponse() diff --git a/docs/history.rst b/docs/history.rst index 7d72a31..745b2f7 100644 --- a/docs/history.rst +++ b/docs/history.rst @@ -18,19 +18,28 @@ that will change. (Warnings appear in the console when running Django in debug mode.) -**Djrill Admin site** +Breaking Changes in Djrill 2.0 +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -Djrill 2.0 will remove the custom Djrill admin site. It duplicates -information from Mandrill's dashboard, most Djrill users are unaware -it exists, and it has caused problems tracking Django admin changes. +Removed DjrillAdminSite + Earlier versions of Djrill included a custom Django admin site. + The equivalent functionality is available in Mandrill's dashboard. -Drill 1.4 will report a DeprecationWarning when you try to load -the `DjrillAdminSite`. You should remove it from your code. + You should remove any references to DjrillAdminSite from your + :file:`urls.py`. E.g.:: -Also, if you changed Django's :setting:`INSTALLED_APPS` setting to use -`'django.contrib.admin.apps.SimpleAdminConfig'`, you may be able to -switch that back to `'django.contrib.admin'` and let Django -handle the admin.autodiscover() for you. + .. code-block:: python + + # Remove these: + from djrill import DjrillAdminSite + admin.site = DjrillAdminSite() + + Also, on Django 1.7 or later if you had switched your :setting:`INSTALLED_APPS` + (in :file:`settings.py`) to use ``'django.contrib.admin.apps.SimpleAdminConfig'`` + you *may* want to switch back to the default ``'django.contrib.admin'`` + and remove the call to ``admin.autodiscover()`` in your :file:`urls.py`. + (Do this only if you changed to SimpleAdminConfig for Djrill, and aren't + creating custom admin sites for any other Django apps you use.) **Dates in merge data and other attributes** diff --git a/docs/index.rst b/docs/index.rst index 4d7c489..91d881a 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -36,9 +36,7 @@ Thanks ------ Thanks to the MailChimp team for asking us to build this nifty little app, and to all of Djrill's -:doc:`contributors `. Also thanks to James Socol on Github for his django-adminplus_ -library that got us off on the right foot for the custom admin views. +:doc:`contributors `. Oh, and, of course, Kenneth Reitz for the awesome requests_ library. .. _requests: http://docs.python-requests.org -.. _django-adminplus: https://github.com/jsocol/django-adminplus diff --git a/docs/installation.rst b/docs/installation.rst index 822a473..743f53b 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -65,47 +65,3 @@ with :ref:`Mandrill-specific sending options `.) .. _subaccounts: http://help.mandrill.com/entries/25523278-What-are-subaccounts- - - -Admin (Optional) ----------------- - -Djrill includes an optional Django admin interface, which allows you to: - -* Check the status of your Mandrill API connection -* See stats on email senders, tags and urls - -If you want to enable the Djrill admin interface, edit your base :file:`urls.py`: - - .. code-block:: python - :emphasize-lines: 4,6 - - ... - from django.contrib import admin - - from djrill import DjrillAdminSite - - admin.site = DjrillAdminSite() - admin.autodiscover() - ... - - urlpatterns = [ - ... - url(r'^admin/', include(admin.site.urls)), - ] - -If you are on **Django 1.7 or later,** you will also need to change the config used -by the django.contrib.admin app in your :file:`settings.py`: - - .. code-block:: python - :emphasize-lines: 4 - - ... - INSTALLED_APPS = ( - # For Django 1.7+, use SimpleAdminConfig because we'll call autodiscover... - 'django.contrib.admin.apps.SimpleAdminConfig', # instead of 'django.contrib.admin' - ... - 'djrill', - ... - ) - ... diff --git a/runtests.py b/runtests.py index 9482530..9056905 100644 --- a/runtests.py +++ b/runtests.py @@ -3,13 +3,9 @@ # python runtests.py import sys -from django import VERSION as django_version from django.conf import settings APP = 'djrill' -ADMIN = 'django.contrib.admin' -if django_version >= (1, 7): - ADMIN = 'django.contrib.admin.apps.SimpleAdminConfig' settings.configure( DEBUG=True, @@ -23,7 +19,7 @@ settings.configure( 'django.contrib.auth', 'django.contrib.contenttypes', 'django.contrib.sessions', - ADMIN, + 'django.contrib.admin', APP, ), MIDDLEWARE_CLASSES=( @@ -33,25 +29,10 @@ settings.configure( 'django.contrib.auth.middleware.AuthenticationMiddleware', ), TEMPLATES=[ - # Django 1.8 starter-project template settings - # (needed for test_admin) + # Djrill doesn't have any templates, but tests need a TEMPLATES + # setting to avoid warnings from the Django 1.8+ test client. { 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [ - # insert your TEMPLATE_DIRS here - ], - 'APP_DIRS': True, - 'OPTIONS': { - 'context_processors': [ - 'django.contrib.auth.context_processors.auth', - 'django.template.context_processors.debug', - 'django.template.context_processors.i18n', - 'django.template.context_processors.media', - 'django.template.context_processors.static', - 'django.template.context_processors.tz', - 'django.contrib.messages.context_processors.messages', - ], - }, }, ], ) From da260de1a0502f39311c60032ad32a2af00f6c6e Mon Sep 17 00:00:00 2001 From: medmunds Date: Thu, 14 May 2015 11:39:57 -0700 Subject: [PATCH 03/33] Drop support for Django 1.3, Python 2.6 and 3.2. * Shrink the Travis test matrix * Remove a lot of backported test code * Update requirements in setup.py * Update docs Closes #79 --- .travis.yml | 15 +--- README.rst | 2 +- djrill/tests/mock_backend.py | 5 +- djrill/tests/test_mandrill_integration.py | 12 +-- djrill/tests/test_mandrill_send.py | 12 +-- djrill/tests/test_mandrill_subaccounts.py | 4 +- djrill/tests/test_mandrill_webhook.py | 6 +- djrill/tests/utils.py | 89 ----------------------- docs/history.rst | 7 ++ setup.py | 4 +- 10 files changed, 25 insertions(+), 131 deletions(-) diff --git a/.travis.yml b/.travis.yml index 338886c..21f3e78 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,19 +1,10 @@ language: python matrix: include: - # Django 1.3: Python 2.6--2.7 - - python: "2.6" - env: DJANGO=django==1.3 - - python: "2.7" - env: DJANGO=django==1.3 - # Django 1.4: Python 2.6--2.7 - - python: "2.6" - env: DJANGO=django==1.4 + # Django 1.4: Python 2.6--2.7 (but we don't support 2.6) - python: "2.7" env: DJANGO=django==1.4 # Django 1.5: Python 2.7, pypy - # (As of Django 1.5, Python 2.6 no longer "highly recommended", - # and Python 3.2+ support was only "experimental", so skip those.) - python: "2.7" env: DJANGO=django==1.5 - python: "pypy" @@ -21,8 +12,6 @@ matrix: # Django 1.6: Python 2.7--3.3, pypy - python: "2.7" env: DJANGO=django==1.6 - - python: "3.2" - env: DJANGO=django==1.6 - python: "3.3" env: DJANGO=django==1.6 - python: "pypy" @@ -30,8 +19,6 @@ matrix: # Django 1.7: Python 2.7--3.4, pypy - python: "2.7" env: DJANGO=django==1.7 - - python: "3.2" - env: DJANGO=django==1.7 - python: "3.3" env: DJANGO=django==1.7 - python: "3.4" diff --git a/README.rst b/README.rst index 6e0dab3..a1ab707 100644 --- a/README.rst +++ b/README.rst @@ -29,7 +29,7 @@ package. It includes: * Optional support for Mandrill inbound email and other webhook notifications, via Django signals -Djrill is released under the BSD license. It is tested against Django 1.3--1.8 +Djrill is released under the BSD license. It is tested against Django 1.4--1.8 (including Python 3 with Django 1.6+, and PyPy support with Django 1.5+). Djrill uses `semantic versioning `_. diff --git a/djrill/tests/mock_backend.py b/djrill/tests/mock_backend.py index e119db2..a675cd9 100644 --- a/djrill/tests/mock_backend.py +++ b/djrill/tests/mock_backend.py @@ -4,13 +4,12 @@ import requests import six from django.test import TestCase - -from .utils import BackportedAssertions, override_settings +from django.test.utils import override_settings @override_settings(MANDRILL_API_KEY="FAKE_API_KEY_FOR_TESTING", EMAIL_BACKEND="djrill.mail.backends.djrill.DjrillBackend") -class DjrillBackendMockAPITestCase(TestCase, BackportedAssertions): +class DjrillBackendMockAPITestCase(TestCase): """TestCase that uses Djrill EmailBackend with a mocked Mandrill API""" class MockResponse(requests.Response): diff --git a/djrill/tests/test_mandrill_integration.py b/djrill/tests/test_mandrill_integration.py index a949326..99b0235 100644 --- a/djrill/tests/test_mandrill_integration.py +++ b/djrill/tests/test_mandrill_integration.py @@ -1,26 +1,22 @@ from __future__ import unicode_literals import os +import unittest from django.core import mail from django.test import TestCase +from django.test.utils import override_settings from djrill import MandrillAPIError -from djrill.tests.utils import BackportedAssertions, override_settings - -try: - from unittest import skipUnless -except ImportError: - from django.utils.unittest import skipUnless MANDRILL_TEST_API_KEY = os.getenv('MANDRILL_TEST_API_KEY') -@skipUnless(MANDRILL_TEST_API_KEY, +@unittest.skipUnless(MANDRILL_TEST_API_KEY, "Set MANDRILL_TEST_API_KEY environment variable to run integration tests") @override_settings(MANDRILL_API_KEY=MANDRILL_TEST_API_KEY, EMAIL_BACKEND="djrill.mail.backends.djrill.DjrillBackend") -class DjrillIntegrationTests(TestCase, BackportedAssertions): +class DjrillIntegrationTests(TestCase): """Mandrill API integration tests These tests run against the **live** Mandrill API, using the diff --git a/djrill/tests/test_mandrill_send.py b/djrill/tests/test_mandrill_send.py index 8bbbc9a..43e9cf4 100644 --- a/djrill/tests/test_mandrill_send.py +++ b/djrill/tests/test_mandrill_send.py @@ -10,20 +10,16 @@ from email.mime.image import MIMEImage import json import os import six - -try: - from unittest import SkipTest -except ImportError: - from django.utils.unittest import SkipTest +import unittest from django.core import mail from django.core.exceptions import ImproperlyConfigured from django.core.mail import make_msgid from django.test import TestCase +from django.test.utils import override_settings from djrill import MandrillAPIError, NotSupportedByMandrillError -from .mock_backend import DjrillBackendMockAPITestCase -from .utils import override_settings +from djrill.tests.mock_backend import DjrillBackendMockAPITestCase def decode_att(att): @@ -158,7 +154,7 @@ class DjrillBackendTests(DjrillBackendMockAPITestCase): headers={'X-Other': 'Keep'}) except TypeError: # Pre-Django 1.8 - raise SkipTest("Django version doesn't support EmailMessage(reply_to)") + raise unittest.SkipTest("Django version doesn't support EmailMessage(reply_to)") email.send() self.assert_mandrill_called("/messages/send.json") data = self.get_api_call_data() diff --git a/djrill/tests/test_mandrill_subaccounts.py b/djrill/tests/test_mandrill_subaccounts.py index 3d9a1e8..920221f 100644 --- a/djrill/tests/test_mandrill_subaccounts.py +++ b/djrill/tests/test_mandrill_subaccounts.py @@ -1,7 +1,7 @@ from django.core import mail +from django.test.utils import override_settings -from .mock_backend import DjrillBackendMockAPITestCase -from .utils import override_settings +from djrill.tests.mock_backend import DjrillBackendMockAPITestCase class DjrillMandrillSubaccountTests(DjrillBackendMockAPITestCase): diff --git a/djrill/tests/test_mandrill_webhook.py b/djrill/tests/test_mandrill_webhook.py index 52c3f64..9089a4c 100644 --- a/djrill/tests/test_mandrill_webhook.py +++ b/djrill/tests/test_mandrill_webhook.py @@ -6,10 +6,10 @@ import json from django.conf import settings from django.core.exceptions import ImproperlyConfigured from django.test import TestCase +from django.test.utils import override_settings -from ..compat import b -from ..signals import webhook_event -from .utils import override_settings +from djrill.compat import b +from djrill.signals import webhook_event class DjrillWebhookSecretMixinTests(TestCase): diff --git a/djrill/tests/utils.py b/djrill/tests/utils.py index fc372e9..add2a83 100644 --- a/djrill/tests/utils.py +++ b/djrill/tests/utils.py @@ -1,94 +1,5 @@ -import re -import six import sys -__all__ = ( - 'BackportedAssertions', - 'override_settings', - 'reset_warning_registry', -) - -try: - from django.test.utils import override_settings - -except ImportError: - # Back-port override_settings from Django 1.4 - # https://github.com/django/django/blob/stable/1.4.x/django/test/utils.py - from django.conf import settings, UserSettingsHolder - from django.utils.functional import wraps - - class override_settings(object): - """ - Acts as either a decorator, or a context manager. If it's a decorator it - takes a function and returns a wrapped function. If it's a contextmanager - it's used with the ``with`` statement. In either event entering/exiting - are called before and after, respectively, the function/block is executed. - """ - def __init__(self, **kwargs): - self.options = kwargs - self.wrapped = settings._wrapped - - def __enter__(self): - self.enable() - - def __exit__(self, exc_type, exc_value, traceback): - self.disable() - - def __call__(self, test_func): - from django.test import TransactionTestCase - if isinstance(test_func, type) and issubclass(test_func, TransactionTestCase): - original_pre_setup = test_func._pre_setup - original_post_teardown = test_func._post_teardown - def _pre_setup(innerself): - self.enable() - original_pre_setup(innerself) - def _post_teardown(innerself): - original_post_teardown(innerself) - self.disable() - test_func._pre_setup = _pre_setup - test_func._post_teardown = _post_teardown - return test_func - else: - @wraps(test_func) - def inner(*args, **kwargs): - with self: - return test_func(*args, **kwargs) - return inner - - def enable(self): - override = UserSettingsHolder(settings._wrapped) - for key, new_value in self.options.items(): - setattr(override, key, new_value) - settings._wrapped = override - # No setting_changed signal in Django 1.3 - # for key, new_value in self.options.items(): - # setting_changed.send(sender=settings._wrapped.__class__, - # setting=key, value=new_value) - - def disable(self): - settings._wrapped = self.wrapped - # No setting_changed signal in Django 1.3 - # for key in self.options: - # new_value = getattr(settings, key, None) - # setting_changed.send(sender=settings._wrapped.__class__, - # setting=key, value=new_value) - - -class BackportedAssertions(object): - """Handful of useful TestCase assertions backported to Python 2.6/Django 1.3""" - - # Backport from Python 2.7/3.1 - def assertIn(self, member, container, msg=None): - """Just like self.assertTrue(a in b), but with a nicer default message.""" - if member not in container: - self.fail(msg or '%r not found in %r' % (member, container)) - - # Backport from Django 1.4 - def assertRaisesMessage(self, expected_exception, expected_message, - callable_obj=None, *args, **kwargs): - return six.assertRaisesRegex(self, expected_exception, re.escape(expected_message), - callable_obj, *args, **kwargs) - # Backport from Django 1.8 (django.test.utils) # with fix suggested by https://code.djangoproject.com/ticket/21049 diff --git a/docs/history.rst b/docs/history.rst index 745b2f7..3e87ac5 100644 --- a/docs/history.rst +++ b/docs/history.rst @@ -21,6 +21,13 @@ in debug mode.) Breaking Changes in Djrill 2.0 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Dropped support for Django 1.3, Python 2.6, and Python 3.2 + Although Djrill may still work with these older configurations, + we no longer test against them. Djrill now requires Django 1.4 + or later and Python 2.7, 3.3, or 3.4. + + If you require earlier support, Djrill 1.4 remains available. + Removed DjrillAdminSite Earlier versions of Djrill included a custom Django admin site. The equivalent functionality is available in Mandrill's dashboard. diff --git a/setup.py b/setup.py index 2c5ef2e..43ae67e 100644 --- a/setup.py +++ b/setup.py @@ -29,7 +29,7 @@ setup( license="BSD License", packages=["djrill"], zip_safe=False, - install_requires=["requests>=1.0.0", "django>=1.3"], + install_requires=["requests>=1.0.0", "django>=1.4"], include_package_data=True, test_suite="runtests.runtests", tests_require=["mock", "six"], @@ -37,10 +37,8 @@ setup( "Programming Language :: Python", "Programming Language :: Python :: Implementation :: PyPy", "Programming Language :: Python :: Implementation :: CPython", - "Programming Language :: Python :: 2.6", "Programming Language :: Python :: 2.7", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.2", "Programming Language :: Python :: 3.3", "Programming Language :: Python :: 3.4", "License :: OSI Approved :: BSD License", From bac85511b5bc140a643c62f7ef2bd9fbac6a88f4 Mon Sep 17 00:00:00 2001 From: William Hector Date: Sat, 11 Jul 2015 21:38:05 +0100 Subject: [PATCH 04/33] Use requests.session to pool requests when mass sending mail Tests modified to patch the ression post and close session upon error. RE: http://stackoverflow.com/q/30982717/647002 --- djrill/mail/backends/djrill.py | 31 ++++++++++++++++++++++++++++++- djrill/tests/mock_backend.py | 6 +++--- 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/djrill/mail/backends/djrill.py b/djrill/mail/backends/djrill.py index bb9d459..a2aac59 100644 --- a/djrill/mail/backends/djrill.py +++ b/djrill/mail/backends/djrill.py @@ -62,6 +62,7 @@ class DjrillBackend(BaseEmailBackend): super(DjrillBackend, self).__init__(**kwargs) self.api_key = getattr(settings, "MANDRILL_API_KEY", None) self.api_url = MANDRILL_API_URL + self.session = None self.subaccount = getattr(settings, "MANDRILL_SUBACCOUNT", None) @@ -72,10 +73,33 @@ class DjrillBackend(BaseEmailBackend): self.api_send = self.api_url + "/messages/send.json" self.api_send_template = self.api_url + "/messages/send-template.json" + def open(self): + if not self.session: + try: + self.session = requests.Session() + except: + if not self.fail_silently: + raise + return False + return True + + + def close(self, error=False): + if self.session: + try: + self.session.close() + except: + if not self.fail_silently and not error: + raise + self.session = None + def send_messages(self, email_messages): if not email_messages: return 0 + if not self.open(): + return + num_sent = 0 for message in email_messages: sent = self._send(message) @@ -83,6 +107,8 @@ class DjrillBackend(BaseEmailBackend): if sent: num_sent += 1 + self.close() + return num_sent def _send(self, message): @@ -114,6 +140,7 @@ class DjrillBackend(BaseEmailBackend): except NotSupportedByMandrillError: if not self.fail_silently: + self.close(True) raise return False @@ -127,9 +154,10 @@ class DjrillBackend(BaseEmailBackend): err.args[0] + " in a Djrill message (perhaps it's a merge var?)." " Try converting it to a string or number first.", ) + err.args[1:] + self.close(True) raise err - response = requests.post(api_url, data=api_data) + response = self.session.post(api_url, data=api_data) if response.status_code != 200: @@ -143,6 +171,7 @@ class DjrillBackend(BaseEmailBackend): to['email'] for to in msg_dict.get('to', []) if 'email' in to) if 'from_email' in msg_dict: log_message += " from %s" % msg_dict['from_email'] + self.close(True) raise MandrillAPIError( status_code=response.status_code, response=response, diff --git a/djrill/tests/mock_backend.py b/djrill/tests/mock_backend.py index a675cd9..9fcf7b4 100644 --- a/djrill/tests/mock_backend.py +++ b/djrill/tests/mock_backend.py @@ -21,7 +21,7 @@ class DjrillBackendMockAPITestCase(TestCase): self.raw = six.BytesIO(raw) def setUp(self): - self.patch = patch('requests.post', autospec=True) + self.patch = patch('requests.Session.post', autospec=True) self.mock_post = self.patch.start() self.mock_post.return_value = self.MockResponse() @@ -39,7 +39,7 @@ class DjrillBackendMockAPITestCase(TestCase): raise AssertionError("Mandrill API was not called") (args, kwargs) = self.mock_post.call_args try: - post_url = kwargs.get('url', None) or args[0] + post_url = kwargs.get('url', None) or args[1] except IndexError: raise AssertionError("requests.post was called without an url (?!)") if not post_url.endswith(endpoint): @@ -56,7 +56,7 @@ class DjrillBackendMockAPITestCase(TestCase): raise AssertionError("Mandrill API was not called") (args, kwargs) = self.mock_post.call_args try: - post_data = kwargs.get('data', None) or args[1] + post_data = kwargs.get('data', None) or args[2] except IndexError: raise AssertionError("requests.post was called without data") return json.loads(post_data) From 883b23362c4400580185fba6ae9ecd97a311e2ef Mon Sep 17 00:00:00 2001 From: William Hector Date: Sun, 12 Jul 2015 01:19:59 +0100 Subject: [PATCH 05/33] Allow Mandrill specific options to be set globally in the settings file. This is useful to set options such as tracking_domain etc per instance when using subaccounts with Mandrill. --- djrill/mail/backends/djrill.py | 13 ++++ djrill/tests/test_mandrill_send.py | 117 +++++++++++++++++++++++++++++ docs/usage/sending_mail.rst | 10 +++ 3 files changed, 140 insertions(+) diff --git a/djrill/mail/backends/djrill.py b/djrill/mail/backends/djrill.py index a2aac59..66969b3 100644 --- a/djrill/mail/backends/djrill.py +++ b/djrill/mail/backends/djrill.py @@ -63,6 +63,12 @@ class DjrillBackend(BaseEmailBackend): self.api_key = getattr(settings, "MANDRILL_API_KEY", None) self.api_url = MANDRILL_API_URL self.session = None + self.global_settings = {} + for setting_key in getattr(settings, "MANDRILL_SETTINGS", {}): + if not isinstance(settings.MANDRILL_SETTINGS, dict): + raise ImproperlyConfigured("MANDRILL_SETTINGS must be a dict " + "in the settings.py file.") + self.global_settings[setting_key] = settings.MANDRILL_SETTINGS[setting_key] self.subaccount = getattr(settings, "MANDRILL_SUBACCOUNT", None) @@ -233,6 +239,8 @@ class DjrillBackend(BaseEmailBackend): 'async', 'ip_pool' ] for attr in mandrill_attrs: + if attr in self.global_settings: + api_params[attr] = self.global_settings[attr] if hasattr(message, attr): api_params[attr] = getattr(message, attr) @@ -270,11 +278,16 @@ class DjrillBackend(BaseEmailBackend): msg_dict['subaccount'] = self.subaccount for attr in mandrill_attrs: + if attr in self.global_settings: + msg_dict[attr] = self.global_settings[attr] if hasattr(message, attr): msg_dict[attr] = getattr(message, attr) # Allow simple python dicts in place of Mandrill # [{name:name, value:value},...] arrays... + if 'global_merge_vars' in self.global_settings: + msg_dict['global_merge_vars'] = self._expand_merge_vars( + self.global_settings['global_merge_vars']) if hasattr(message, 'global_merge_vars'): msg_dict['global_merge_vars'] = \ self._expand_merge_vars(message.global_merge_vars) diff --git a/djrill/tests/test_mandrill_send.py b/djrill/tests/test_mandrill_send.py index 43e9cf4..f547010 100644 --- a/djrill/tests/test_mandrill_send.py +++ b/djrill/tests/test_mandrill_send.py @@ -540,6 +540,123 @@ class DjrillMandrillFeatureTests(DjrillBackendMockAPITestCase): self.message.send() +@override_settings(MANDRILL_SETTINGS={ + 'from_name': 'Djrill Test', + 'important': True, + 'track_opens': True, + 'track_clicks': True, + 'auto_text': True, + 'auto_html': True, + 'inline_css': True, + 'url_strip_qs': True, + 'tags': ['djrill'], + 'preserve_recipients': True, + 'view_content_link': True, + 'tracking_domain': 'example.com', + 'signing_domain': 'example.com', + 'return_path_domain': 'example.com', + 'google_analytics_domains': ['example.com/test'], + 'google_analytics_campaign': ['UA-00000000-1'], + 'metadata': ['djrill'], + 'merge_language': 'mailchimp', + 'global_merge_vars': {'TEST': 'djrill'}, + 'async': False, + 'ip_pool': 'Pool1', + 'invalid': 'invalid', +}) +class DjrillMandrillGlobalFeatureTests(DjrillBackendMockAPITestCase): + """Test Djrill backend support for global ovveride Mandrill-specific features""" + + def setUp(self): + super(DjrillMandrillGlobalFeatureTests, self).setUp() + self.message = mail.EmailMessage('Subject', 'Text Body', + 'from@example.com', ['to@example.com']) + + def test_global_options(self): + """Test that any global settings get passed through + """ + self.message.send() + self.assert_mandrill_called("/messages/send.json") + data = self.get_api_call_data() + self.assertEqual(data['message']['from_name'], 'Djrill Test') + self.assertTrue(data['message']['important'], True) + self.assertTrue(data['message']['track_opens'], True) + self.assertTrue(data['message']['track_clicks'], True) + self.assertTrue(data['message']['auto_text'], True) + self.assertTrue(data['message']['auto_html'], True) + self.assertTrue(data['message']['inline_css'], True) + self.assertTrue(data['message']['url_strip_qs'], True) + self.assertEqual(data['message']['tags'], ['djrill']) + self.assertTrue(data['message']['preserve_recipients'], True) + self.assertTrue(data['message']['view_content_link'], True) + self.assertEqual(data['message']['tracking_domain'], 'example.com') + self.assertEqual(data['message']['signing_domain'], 'example.com') + self.assertEqual(data['message']['return_path_domain'], 'example.com') + self.assertEqual(data['message']['google_analytics_domains'], ['example.com/test']) + self.assertEqual(data['message']['google_analytics_campaign'], ['UA-00000000-1']) + self.assertEqual(data['message']['metadata'], ['djrill']) + self.assertEqual(data['message']['merge_language'], 'mailchimp') + self.assertEqual(data['message']['global_merge_vars'], + [{'name': 'TEST', 'content': 'djrill'}]) + self.assertFalse('merge_vars' in data['message']) + self.assertFalse('recipient_metadata' in data['message']) + # Options at top level of api params (not in message dict): + self.assertFalse('send_at' in data) + self.assertEqual(data['async'], False) + self.assertEqual(data['ip_pool'], 'Pool1') + # Option that shouldn't be added + self.assertFalse('invalid' in data['message']) + + def test_global_options_override(self): + """Test that manually settings options overrides global settings + """ + self.message.important = True + self.message.auto_text = True + self.message.auto_html = True + self.message.inline_css = True + self.message.preserve_recipients = True + self.message.view_content_link = False + self.message.tracking_domain = "click.example.com" + self.message.signing_domain = "example.com" + self.message.return_path_domain = "support.example.com" + self.message.subaccount = "marketing-dept" + self.message.async = True + self.message.ip_pool = "Bulk Pool" + self.message.send() + data = self.get_api_call_data() + self.assertEqual(data['message']['important'], True) + self.assertEqual(data['message']['auto_text'], True) + self.assertEqual(data['message']['auto_html'], True) + self.assertEqual(data['message']['inline_css'], True) + self.assertEqual(data['message']['preserve_recipients'], True) + self.assertEqual(data['message']['view_content_link'], False) + self.assertEqual(data['message']['tracking_domain'], "click.example.com") + self.assertEqual(data['message']['signing_domain'], "example.com") + self.assertEqual(data['message']['return_path_domain'], "support.example.com") + self.assertEqual(data['message']['subaccount'], "marketing-dept") + self.assertEqual(data['async'], True) + self.assertEqual(data['ip_pool'], "Bulk Pool") + + def test_global_options_override_tracking(self): + """Test that manually settings options overrides global settings + """ + self.message.track_opens = False + self.message.track_clicks = False + self.message.url_strip_qs = False + self.message.send() + data = self.get_api_call_data() + self.assertEqual(data['message']['track_opens'], False) + self.assertEqual(data['message']['track_clicks'], False) + self.assertEqual(data['message']['url_strip_qs'], False) + + def test_global_merge(self): + self.message.global_merge_vars = {'GREETING': "Hello"} + self.message.send() + data = self.get_api_call_data() + self.assertEqual(data['message']['global_merge_vars'], + [{'name': "GREETING", 'content': "Hello"}]) + + @override_settings(EMAIL_BACKEND="djrill.mail.backends.djrill.DjrillBackend") class DjrillImproperlyConfiguredTests(TestCase): """Test Djrill backend without Djrill-specific settings in place""" diff --git a/docs/usage/sending_mail.rst b/docs/usage/sending_mail.rst index 0104082..a7e51c0 100644 --- a/docs/usage/sending_mail.rst +++ b/docs/usage/sending_mail.rst @@ -103,11 +103,21 @@ Some notes and limitations: Mandrill-Specific Options ------------------------- +.. setting:: MANDRILL_SETTINGS + Most of the options from the Mandrill `messages/send API `_ `message` struct can be set directly on an :class:`~django.core.mail.EmailMessage` (or subclass) object: +Most of these options can be globally set in your project's :file:`settings.py` +using :setting:`MANDRILL_SETTINGS`. For Example:: + + MANDRILL_SETTINGS = { + 'tracking_domain': 'example.com', + 'track_opens': True, + } + .. These attributes are in the same order as they appear in the Mandrill API docs... .. attribute:: important From 7179734a088f58c3352b5ced0c62789775dbaadd Mon Sep 17 00:00:00 2001 From: William Hector Date: Tue, 14 Jul 2015 05:57:12 +0100 Subject: [PATCH 06/33] Allow global_merge_vars to be merged in with the per message dict, with keys in the latter taking precedent. Update the docs accordingly. --- AUTHORS.txt | 1 + djrill/mail/backends/djrill.py | 11 ++++++++--- djrill/tests/test_mandrill_send.py | 12 +++++++++++- docs/usage/sending_mail.rst | 8 ++++++++ 4 files changed, 28 insertions(+), 4 deletions(-) diff --git a/AUTHORS.txt b/AUTHORS.txt index b774736..d83829f 100644 --- a/AUTHORS.txt +++ b/AUTHORS.txt @@ -17,3 +17,4 @@ Sameer Al-Sakran Kyle Gibson Wes Winham nikolay-saskovets +William Hector diff --git a/djrill/mail/backends/djrill.py b/djrill/mail/backends/djrill.py index 66969b3..da233ca 100644 --- a/djrill/mail/backends/djrill.py +++ b/djrill/mail/backends/djrill.py @@ -285,12 +285,17 @@ class DjrillBackend(BaseEmailBackend): # Allow simple python dicts in place of Mandrill # [{name:name, value:value},...] arrays... + + # Allow merge of global and per message global_merge_var, the former taking precedent + global_merge_vars = {} if 'global_merge_vars' in self.global_settings: - msg_dict['global_merge_vars'] = self._expand_merge_vars( - self.global_settings['global_merge_vars']) + global_merge_vars.update(self.global_settings['global_merge_vars']) if hasattr(message, 'global_merge_vars'): + global_merge_vars.update(message.global_merge_vars) + if global_merge_vars: msg_dict['global_merge_vars'] = \ - self._expand_merge_vars(message.global_merge_vars) + self._expand_merge_vars(global_merge_vars) + if hasattr(message, 'merge_vars'): # For testing reproducibility, we sort the recipients msg_dict['merge_vars'] = [ diff --git a/djrill/tests/test_mandrill_send.py b/djrill/tests/test_mandrill_send.py index f547010..80cabc5 100644 --- a/djrill/tests/test_mandrill_send.py +++ b/djrill/tests/test_mandrill_send.py @@ -650,11 +650,21 @@ class DjrillMandrillGlobalFeatureTests(DjrillBackendMockAPITestCase): self.assertEqual(data['message']['url_strip_qs'], False) def test_global_merge(self): + # Test that global settings merge in self.message.global_merge_vars = {'GREETING': "Hello"} self.message.send() data = self.get_api_call_data() self.assertEqual(data['message']['global_merge_vars'], - [{'name': "GREETING", 'content': "Hello"}]) + [{'name': "GREETING", 'content': "Hello"}, + {'name': 'TEST', 'content': 'djrill'}]) + + def test_global_merge_overwrite(self): + # Test that global merge settings are overwritten + self.message.global_merge_vars = {'TEST': "Hello"} + self.message.send() + data = self.get_api_call_data() + self.assertEqual(data['message']['global_merge_vars'], + [{'name': 'TEST', 'content': 'Hello'}]) @override_settings(EMAIL_BACKEND="djrill.mail.backends.djrill.DjrillBackend") diff --git a/docs/usage/sending_mail.rst b/docs/usage/sending_mail.rst index a7e51c0..deca0dd 100644 --- a/docs/usage/sending_mail.rst +++ b/docs/usage/sending_mail.rst @@ -118,6 +118,10 @@ using :setting:`MANDRILL_SETTINGS`. For Example:: 'track_opens': True, } +.. note:: + ``merge_vars`` and ``recipient_metadata`` cannot be set globally. ``global_merge_vars`` is merged + (see :attribute:`global_merge_vars`) + .. These attributes are in the same order as they appear in the Mandrill API docs... .. attribute:: important @@ -211,6 +215,10 @@ using :setting:`MANDRILL_SETTINGS`. For Example:: Merge data must be strings or other JSON-serializable types. (See :ref:`formatting-merge-data` for details.) + .. note:: + + If using :setting:`MANDRILL_SETTINGS` then the message ``dict`` will be merged and overwrite any duplicates. + .. attribute:: merge_vars ``dict``: per-recipient merge variables (most useful with :ref:`mandrill-templates`). The keys From 1ce7f9837579412843da358d17b449c57337f3b6 Mon Sep 17 00:00:00 2001 From: Mike Edmunds Date: Fri, 25 Sep 2015 12:02:42 -0700 Subject: [PATCH 07/33] Add Django 1.9 alpha to test matrix --- .travis.yml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.travis.yml b/.travis.yml index 21f3e78..52a0505 100644 --- a/.travis.yml +++ b/.travis.yml @@ -32,6 +32,15 @@ matrix: env: DJANGO=django==1.8 - python: "pypy" env: DJANGO=django==1.8 + # Django 1.9 (prerelease): "Python 2.7, 3.4, or 3.5" + - python: "2.7" + env: DJANGO="--pre django" + - python: "3.4" + env: DJANGO="--pre django" + - python: "3.5" + env: DJANGO="--pre django" + - python: "pypy" + env: DJANGO="--pre django" install: - pip install --upgrade setuptools pip - pip install -q $DJANGO From 42848e0eed8e37dfcfde901deb94d2096a52c916 Mon Sep 17 00:00:00 2001 From: medmunds Date: Fri, 25 Sep 2015 14:48:32 -0700 Subject: [PATCH 08/33] Integration tests handle 'queued' response Rapid-fire pelting from our Travis tests sometimes causes the Mandrill API to queue a "send" call, rather than deal with it immediately. If that occurs, we generally have to just ignore that test (in that test run). --- djrill/tests/test_mandrill_integration.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/djrill/tests/test_mandrill_integration.py b/djrill/tests/test_mandrill_integration.py index 99b0235..6dc5d04 100644 --- a/djrill/tests/test_mandrill_integration.py +++ b/djrill/tests/test_mandrill_integration.py @@ -39,7 +39,7 @@ class DjrillIntegrationTests(TestCase): self.assertEqual(sent_count, 1) # noinspection PyUnresolvedReferences response = self.message.mandrill_response - self.assertEqual(response[0]['status'], 'sent') # successful send (could still bounce later) + self.assertIn(response[0]['status'], ['sent', 'queued']) # successful send (could still bounce later) self.assertEqual(response[0]['email'], 'to@example.com') self.assertGreater(len(response[0]['_id']), 0) @@ -61,6 +61,8 @@ class DjrillIntegrationTests(TestCase): self.assertEqual(sent_count, 1) # The send call is "successful"... # noinspection PyUnresolvedReferences response = self.message.mandrill_response + if response[0]['status'] == 'queued': + self.skipTest("Mandrill queued the send -- can't complete this test") self.assertEqual(response[0]['status'], 'invalid') # ... but the mail is not delivered def test_rejected_to(self): @@ -70,6 +72,8 @@ class DjrillIntegrationTests(TestCase): self.assertEqual(sent_count, 1) # The send call is "successful"... # noinspection PyUnresolvedReferences response = self.message.mandrill_response + if response[0]['status'] == 'queued': + self.skipTest("Mandrill queued the send -- can't complete this test") self.assertEqual(response[0]['status'], 'rejected') # ... but the mail is not delivered self.assertEqual(response[0]['reject_reason'], 'test') # ... and here's why From 3b4d1c1359fed0fecd0db095900173b3bf24cf9a Mon Sep 17 00:00:00 2001 From: medmunds Date: Fri, 25 Sep 2015 17:19:51 -0700 Subject: [PATCH 09/33] Travis: list installed package versions in build log --- .travis.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.travis.yml b/.travis.yml index 52a0505..7075267 100644 --- a/.travis.yml +++ b/.travis.yml @@ -45,4 +45,5 @@ install: - pip install --upgrade setuptools pip - pip install -q $DJANGO - pip install . + - pip list script: python -Wall setup.py test From e06f2cf7181a58e6307a1dda719d74dd42dc0ebd Mon Sep 17 00:00:00 2001 From: medmunds Date: Fri, 25 Sep 2015 17:50:44 -0700 Subject: [PATCH 10/33] Travis: make build matrix more readable (by humans) --- .travis.yml | 53 ++++++++++++++++++----------------------------------- 1 file changed, 18 insertions(+), 35 deletions(-) diff --git a/.travis.yml b/.travis.yml index 7075267..f6e40de 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,46 +1,29 @@ language: python matrix: include: - # Django 1.4: Python 2.6--2.7 (but we don't support 2.6) - - python: "2.7" - env: DJANGO=django==1.4 + # Django 1.4: Python 2.6--2.7 (but Djrill doesn't support 2.6) + - { env: DJANGO=django==1.4, python: 2.7 } # Django 1.5: Python 2.7, pypy - - python: "2.7" - env: DJANGO=django==1.5 - - python: "pypy" - env: DJANGO=django==1.5 + - { env: DJANGO=django==1.5, python: 2.7 } + - { env: DJANGO=django==1.5, python: pypy } # Django 1.6: Python 2.7--3.3, pypy - - python: "2.7" - env: DJANGO=django==1.6 - - python: "3.3" - env: DJANGO=django==1.6 - - python: "pypy" - env: DJANGO=django==1.6 + - { env: DJANGO=django==1.6, python: 2.7 } + - { env: DJANGO=django==1.6, python: 3.3 } + - { env: DJANGO=django==1.6, python: pypy } # Django 1.7: Python 2.7--3.4, pypy - - python: "2.7" - env: DJANGO=django==1.7 - - python: "3.3" - env: DJANGO=django==1.7 - - python: "3.4" - env: DJANGO=django==1.7 - - python: "pypy" - env: DJANGO=django==1.7 + - { env: DJANGO=django==1.7, python: 2.7 } + - { env: DJANGO=django==1.7, python: 3.3 } + - { env: DJANGO=django==1.7, python: 3.4 } + - { env: DJANGO=django==1.7, python: pypy } # Django 1.8: "Python 2.7 or above" - - python: "2.7" - env: DJANGO=django==1.8 - - python: "3.4" - env: DJANGO=django==1.8 - - python: "pypy" - env: DJANGO=django==1.8 + - { env: DJANGO=django==1.8, python: 2.7 } + - { env: DJANGO=django==1.8, python: 3.4 } + - { env: DJANGO=django==1.8, python: pypy } # Django 1.9 (prerelease): "Python 2.7, 3.4, or 3.5" - - python: "2.7" - env: DJANGO="--pre django" - - python: "3.4" - env: DJANGO="--pre django" - - python: "3.5" - env: DJANGO="--pre django" - - python: "pypy" - env: DJANGO="--pre django" + - { env: DJANGO="--pre django", python: 2.7 } + - { env: DJANGO="--pre django", python: 3.4 } + - { env: DJANGO="--pre django", python: 3.5 } + - { env: DJANGO="--pre django", python: pypy } install: - pip install --upgrade setuptools pip - pip install -q $DJANGO From f7bd9f3a25ebe7125d355cf3e76203b5de2d47d4 Mon Sep 17 00:00:00 2001 From: medmunds Date: Fri, 25 Sep 2015 18:23:45 -0700 Subject: [PATCH 11/33] Add Djrill version to User-Agent header (Makes version show up in Mandrill API logs) --- djrill/__init__.py | 2 +- djrill/mail/backends/djrill.py | 10 ++++++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/djrill/__init__.py b/djrill/__init__.py index 3f5fb88..6a49f82 100644 --- a/djrill/__init__.py +++ b/djrill/__init__.py @@ -1,7 +1,7 @@ from django.conf import settings from djrill.exceptions import MandrillAPIError, NotSupportedByMandrillError -from ._version import * +from ._version import __version__, VERSION # This backend was developed against this API endpoint. diff --git a/djrill/mail/backends/djrill.py b/djrill/mail/backends/djrill.py index da233ca..a5003d8 100644 --- a/djrill/mail/backends/djrill.py +++ b/djrill/mail/backends/djrill.py @@ -1,12 +1,12 @@ +from __future__ import absolute_import + from django.conf import settings from django.core.exceptions import ImproperlyConfigured from django.core.mail.backends.base import BaseEmailBackend from django.core.mail.message import sanitize_address, DEFAULT_ATTACHMENT_MIME_TYPE -# Oops: this file has the same name as our app, and cannot be renamed. -#from djrill import MANDRILL_API_URL, MandrillAPIError, NotSupportedByMandrillError -from ... import MANDRILL_API_URL, MandrillAPIError, NotSupportedByMandrillError -from ...exceptions import removed_in_djrill_2 +from djrill import MANDRILL_API_URL, MandrillAPIError, NotSupportedByMandrillError, __version__ +from djrill.exceptions import removed_in_djrill_2 from base64 import b64encode from datetime import date, datetime @@ -83,6 +83,8 @@ class DjrillBackend(BaseEmailBackend): if not self.session: try: self.session = requests.Session() + self.session.headers["User-Agent"] = "Djrill/%s %s" % ( + __version__, self.session.headers.get("User-Agent", "")) except: if not self.fail_silently: raise From 681af234bd50f98450f3c5baa7a0fc886b237d87 Mon Sep 17 00:00:00 2001 From: medmunds Date: Fri, 25 Sep 2015 18:29:15 -0700 Subject: [PATCH 12/33] Travis: avoid legacy infrastructure; enable caching --- .travis.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index f6e40de..820aa36 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,3 +1,4 @@ +sudo: false language: python matrix: include: @@ -24,9 +25,12 @@ matrix: - { env: DJANGO="--pre django", python: 3.4 } - { env: DJANGO="--pre django", python: 3.5 } - { env: DJANGO="--pre django", python: pypy } +cache: + directories: + - $HOME/.cache/pip install: - pip install --upgrade setuptools pip - - pip install -q $DJANGO + - pip install $DJANGO - pip install . - pip list script: python -Wall setup.py test From 209f9573ff22a553ec996f1f2f0c76d3b7533cf7 Mon Sep 17 00:00:00 2001 From: medmunds Date: Sun, 29 Nov 2015 12:37:00 -0800 Subject: [PATCH 13/33] Drop legacy DjrillMessage class --- djrill/mail/__init__.py | 54 ------------------------ djrill/tests/test_legacy.py | 82 ------------------------------------- docs/history.rst | 14 +++---- 3 files changed, 5 insertions(+), 145 deletions(-) diff --git a/djrill/mail/__init__.py b/djrill/mail/__init__.py index 80a5504..e69de29 100644 --- a/djrill/mail/__init__.py +++ b/djrill/mail/__init__.py @@ -1,54 +0,0 @@ -from django.core.mail import EmailMultiAlternatives - -from djrill.exceptions import removed_in_djrill_2 - - -# DjrillMessage class is deprecated as of 0.2.0, but retained for -# compatibility with existing code. (New code can just set Mandrill-specific -# options directly on an EmailMessage or EmailMultiAlternatives object.) -class DjrillMessage(EmailMultiAlternatives): - alternative_subtype = "mandrill" - - def __init__(self, subject='', body='', from_email=None, to=None, bcc=None, - connection=None, attachments=None, headers=None, alternatives=None, - cc=None, from_name=None, tags=None, track_opens=True, - track_clicks=True, preserve_recipients=None): - - removed_in_djrill_2( - "DjrillMessage will be removed in Djrill 2.0. " - "Use django.core.mail.EmailMultiAlternatives instead." - ) - - super(DjrillMessage, self).__init__(subject, body, from_email, to, bcc, - connection, attachments, headers, alternatives, cc) - - if from_name: - self.from_name = from_name - if tags: - self.tags = self._set_mandrill_tags(tags) - if track_opens is not None: - self.track_opens = track_opens - if track_clicks is not None: - self.track_clicks = track_clicks - if preserve_recipients is not None: - self.preserve_recipients = preserve_recipients - - def _set_mandrill_tags(self, tags): - """ - Check that all tags are below 50 chars and that they do not start - with an underscore. - - Raise ValueError if an underscore tag is passed in to - alert the user. Any tag over 50 chars is left out of the list. - """ - tag_list = [] - - for tag in tags: - if len(tag) <= 50 and not tag.startswith("_"): - tag_list.append(tag) - elif tag.startswith("_"): - raise ValueError( - "Tags starting with an underscore are reserved for " - "internal use and will cause errors with Mandrill's API") - - return tag_list diff --git a/djrill/tests/test_legacy.py b/djrill/tests/test_legacy.py index 3213508..b0d5cc3 100644 --- a/djrill/tests/test_legacy.py +++ b/djrill/tests/test_legacy.py @@ -8,7 +8,6 @@ from django.test import TestCase from djrill import MandrillAPIError, NotSupportedByMandrillError from djrill.exceptions import RemovedInDjrill2 -from djrill.mail import DjrillMessage from djrill.tests.mock_backend import DjrillBackendMockAPITestCase from djrill.tests.utils import reset_warning_registry @@ -37,12 +36,6 @@ class DjrillBackendDeprecationTests(DjrillBackendMockAPITestCase): self.assertEqual(data['message']['global_merge_vars'], [{'name': 'DATE', 'content': "2022-10-11 00:00:00"}]) - def test_deprecated_djrill_message_class(self): - """Djrill 0.2 deprecated DjrillMessage; 2.0 will drop it""" - self.assertWarnsMessage(DeprecationWarning, - "DjrillMessage will be removed in Djrill 2.0", - DjrillMessage) - def test_deprecated_djrill_backend_http_error(self): """Djrill 0.2 deprecated DjrillBackendHTTPError; 2.0 will drop it""" def try_import(): @@ -72,81 +65,6 @@ class DjrillBackendDeprecationTests(DjrillBackendMockAPITestCase): msg="Unexpected warnings %r" % [str(w) for w in relevant_warnings]) -class DjrillMessageTests(TestCase): - """Test the DjrillMessage class (deprecated as of Djrill v0.2.0) - - Maintained for compatibility with older code. - - """ - - def run(self, result=None): - with warnings.catch_warnings(): - # DjrillMessage deprecation is tested in test_deprecated_djrill_message_class above - warnings.filterwarnings('ignore', category=RemovedInDjrill2, - message="DjrillMessage will be removed in Djrill 2.0") - - def setUp(self): - self.subject = "Djrill baby djrill." - self.from_name = "Tarzan" - self.from_email = "test@example" - self.to = ["King Kong ", - "Cheetah Date: Sun, 29 Nov 2015 12:55:16 -0800 Subject: [PATCH 14/33] Drop legacy DjrillBackendHTTPError exception --- djrill/mail/backends/djrill.py | 23 ----------------------- djrill/tests/test_legacy.py | 23 +---------------------- docs/history.rst | 11 ++++------- 3 files changed, 5 insertions(+), 52 deletions(-) diff --git a/djrill/mail/backends/djrill.py b/djrill/mail/backends/djrill.py index a5003d8..d63faf4 100644 --- a/djrill/mail/backends/djrill.py +++ b/djrill/mail/backends/djrill.py @@ -409,26 +409,3 @@ class DjrillBackend(BaseEmailBackend): 'content': content_b64.decode('ascii'), } return mandrill_attachment, is_embedded_image - - -############################################################################################ -# Recreate this module, but with a warning on attempts to import deprecated properties. -# This is ugly, but (surprisingly) blessed: http://stackoverflow.com/a/7668273/647002 -import sys -import types - - -class ModuleWithDeprecatedProps(types.ModuleType): - def __init__(self, module): - self._orig_module = module # must keep a ref around, or it'll get deallocated - super(ModuleWithDeprecatedProps, self).__init__(module.__name__, module.__doc__) - self.__dict__.update(module.__dict__) - - @property - def DjrillBackendHTTPError(self): - removed_in_djrill_2("DjrillBackendHTTPError will be removed in Djrill 2.0. " - "Use djrill.MandrillAPIError instead.") - return MandrillAPIError - - -sys.modules[__name__] = ModuleWithDeprecatedProps(sys.modules[__name__]) diff --git a/djrill/tests/test_legacy.py b/djrill/tests/test_legacy.py index b0d5cc3..0298808 100644 --- a/djrill/tests/test_legacy.py +++ b/djrill/tests/test_legacy.py @@ -6,8 +6,7 @@ import warnings from django.core import mail from django.test import TestCase -from djrill import MandrillAPIError, NotSupportedByMandrillError -from djrill.exceptions import RemovedInDjrill2 +from djrill import NotSupportedByMandrillError from djrill.tests.mock_backend import DjrillBackendMockAPITestCase from djrill.tests.utils import reset_warning_registry @@ -36,15 +35,6 @@ class DjrillBackendDeprecationTests(DjrillBackendMockAPITestCase): self.assertEqual(data['message']['global_merge_vars'], [{'name': 'DATE', 'content': "2022-10-11 00:00:00"}]) - def test_deprecated_djrill_backend_http_error(self): - """Djrill 0.2 deprecated DjrillBackendHTTPError; 2.0 will drop it""" - def try_import(): - # noinspection PyUnresolvedReferences - from djrill.mail.backends.djrill import DjrillBackendHTTPError - self.assertWarnsMessage(DeprecationWarning, - "DjrillBackendHTTPError will be removed in Djrill 2.0", - try_import) - def assertWarnsMessage(self, warning, message, callable, *args, **kwds): """Checks that `callable` issues a warning of category `warning` containing `message`""" with warnings.catch_warnings(record=True) as warned: @@ -66,17 +56,6 @@ class DjrillBackendDeprecationTests(DjrillBackendMockAPITestCase): class DjrillLegacyExceptionTests(TestCase): - def test_DjrillBackendHTTPError(self): - """MandrillApiError was DjrillBackendHTTPError in 0.2.0""" - # ... and had to be imported from deep in the package: - with warnings.catch_warnings(): - warnings.filterwarnings('ignore', category=RemovedInDjrill2, - message="DjrillBackendHTTPError will be removed in Djrill 2.0") - # noinspection PyUnresolvedReferences - from djrill.mail.backends.djrill import DjrillBackendHTTPError - ex = MandrillAPIError("testing") - self.assertIsInstance(ex, DjrillBackendHTTPError) - def test_NotSupportedByMandrillError(self): """Unsupported features used to just raise ValueError in 0.2.0""" ex = NotSupportedByMandrillError("testing") diff --git a/docs/history.rst b/docs/history.rst index 615267a..260f936 100644 --- a/docs/history.rst +++ b/docs/history.rst @@ -53,6 +53,10 @@ Removed DjrillMessage class You should replace any uses of it with the standard :class:`~django.core.mail.EmailMessage` class. +Removed DjrillBackendHTTPError + This exception was deprecated in Djrill 0.3. Replace uses of it + with :exc:`djrill.MandrillAPIError`. + **Dates in merge data and other attributes** @@ -72,13 +76,6 @@ or datetime values used in any Mandrill message attributes other than `send_at`. See :ref:`formatting-merge-data` for other options. -**DjrillBackendHTTPError** - -The ``DjrillBackendHTTPError`` exception was replaced in Djrill 0.3 -with :exc:`djrill.MandrillAPIError`. Djrill 1.4 will report a -DeprecationWarning if you are still importing DjrillBackendHTTPError. - - Older Releases -------------- From 942a6df8c3b165e537b045fa267ddd8e9d9967bf Mon Sep 17 00:00:00 2001 From: medmunds Date: Sun, 29 Nov 2015 14:42:12 -0800 Subject: [PATCH 15/33] Drop unintended json date encoding --- djrill/mail/backends/djrill.py | 16 +--------- djrill/tests/test_legacy.py | 50 ------------------------------ djrill/tests/test_mandrill_send.py | 8 ++++- docs/history.rst | 28 ++++++----------- 4 files changed, 18 insertions(+), 84 deletions(-) diff --git a/djrill/mail/backends/djrill.py b/djrill/mail/backends/djrill.py index d63faf4..3c7d43d 100644 --- a/djrill/mail/backends/djrill.py +++ b/djrill/mail/backends/djrill.py @@ -6,7 +6,6 @@ from django.core.mail.backends.base import BaseEmailBackend from django.core.mail.message import sanitize_address, DEFAULT_ATTACHMENT_MIME_TYPE from djrill import MANDRILL_API_URL, MandrillAPIError, NotSupportedByMandrillError, __version__ -from djrill.exceptions import removed_in_djrill_2 from base64 import b64encode from datetime import date, datetime @@ -37,19 +36,6 @@ def encode_date_for_mandrill(dt): return dt -class JSONDateUTCEncoder(json.JSONEncoder): - """[deprecated] JSONEncoder that encodes dates in string format used by Mandrill.""" - def default(self, obj): - if isinstance(obj, date): - removed_in_djrill_2( - "You've used the date '%r' as a Djrill message attribute " - "(perhaps in merge vars or metadata). Djrill 2.0 will require " - "you to explicitly convert this date to a string." % obj - ) - return encode_date_for_mandrill(obj) - return super(JSONDateUTCEncoder, self).default(obj) - - class DjrillBackend(BaseEmailBackend): """ Mandrill API Email Backend @@ -153,7 +139,7 @@ class DjrillBackend(BaseEmailBackend): return False try: - api_data = json.dumps(api_params, cls=JSONDateUTCEncoder) + api_data = json.dumps(api_params) except TypeError as err: # Add some context to the "not JSON serializable" message if not err.args: diff --git a/djrill/tests/test_legacy.py b/djrill/tests/test_legacy.py index 0298808..3e632fd 100644 --- a/djrill/tests/test_legacy.py +++ b/djrill/tests/test_legacy.py @@ -1,58 +1,8 @@ # Tests deprecated Djrill features -from datetime import date, datetime -import warnings - -from django.core import mail from django.test import TestCase from djrill import NotSupportedByMandrillError -from djrill.tests.mock_backend import DjrillBackendMockAPITestCase -from djrill.tests.utils import reset_warning_registry - - -class DjrillBackendDeprecationTests(DjrillBackendMockAPITestCase): - - def setUp(self): - reset_warning_registry() - super(DjrillBackendDeprecationTests, self).setUp() - - def test_deprecated_json_date_encoding(self): - """Djrill 2.0+ avoids a blanket JSONDateUTCEncoder""" - # Djrill allows dates for send_at, so shouldn't warn: - message = mail.EmailMessage('Subject', 'Body', 'from@example.com', ['to@example.com']) - message.send_at = datetime(2022, 10, 11, 12, 13, 14, 567) - self.assertNotWarns(DeprecationWarning, message.send) - - # merge_vars need to be json-serializable, so should generate a warning: - message = mail.EmailMessage('Subject', 'Body', 'from@example.com', ['to@example.com']) - message.global_merge_vars = {'DATE': date(2022, 10, 11)} - self.assertWarnsMessage(DeprecationWarning, - "Djrill 2.0 will require you to explicitly convert this date to a string", - message.send) - # ... but should still encode the date (for now): - data = self.get_api_call_data() - self.assertEqual(data['message']['global_merge_vars'], - [{'name': 'DATE', 'content': "2022-10-11 00:00:00"}]) - - def assertWarnsMessage(self, warning, message, callable, *args, **kwds): - """Checks that `callable` issues a warning of category `warning` containing `message`""" - with warnings.catch_warnings(record=True) as warned: - warnings.simplefilter("always") - callable(*args, **kwds) - self.assertGreater(len(warned), 0, msg="No warnings issued") - self.assertTrue( - any(issubclass(w.category, warning) and message in str(w.message) for w in warned), - msg="%r(%r) not found in %r" % (warning, message, [str(w) for w in warned])) - - def assertNotWarns(self, warning, callable, *args, **kwds): - """Checks that `callable` does not issue any warnings of category `warning`""" - with warnings.catch_warnings(record=True) as warned: - warnings.simplefilter("always") - callable(*args, **kwds) - relevant_warnings = [w for w in warned if issubclass(w.category, warning)] - self.assertEqual(len(relevant_warnings), 0, - msg="Unexpected warnings %r" % [str(w) for w in relevant_warnings]) class DjrillLegacyExceptionTests(TestCase): diff --git a/djrill/tests/test_mandrill_send.py b/djrill/tests/test_mandrill_send.py index 80cabc5..83e318b 100644 --- a/djrill/tests/test_mandrill_send.py +++ b/djrill/tests/test_mandrill_send.py @@ -529,7 +529,7 @@ class DjrillMandrillFeatureTests(DjrillBackendMockAPITestCase): self.assertEqual(sent, 0) self.assertIsNone(msg.mandrill_response) - def test_json_serialization_warnings(self): + def test_json_serialization_errors(self): """Try to provide more information about non-json-serializable data""" self.message.global_merge_vars = {'PRICE': Decimal('19.99')} with self.assertRaisesMessage( @@ -539,6 +539,12 @@ class DjrillMandrillFeatureTests(DjrillBackendMockAPITestCase): ): self.message.send() + def test_dates_not_serialized(self): + """Pre-2.0 Djrill accidentally serialized dates to ISO""" + self.message.global_merge_vars = {'SHIP_DATE': date(2015, 12, 2)} + with self.assertRaises(TypeError): + self.message.send() + @override_settings(MANDRILL_SETTINGS={ 'from_name': 'Djrill Test', diff --git a/docs/history.rst b/docs/history.rst index 260f936..5f4d8c0 100644 --- a/docs/history.rst +++ b/docs/history.rst @@ -48,6 +48,16 @@ Removed DjrillAdminSite (Do this only if you changed to SimpleAdminConfig for Djrill, and aren't creating custom admin sites for any other Django apps you use.) +Removed unintended date-to-string conversion + If your code was relying on Djrill to automatically convert date or datetime + values to strings in :attr:`merge_vars`, :attr:`metadata`, or other Mandrill + message attributes, you must now explicitly do the string conversion + yourself. See :ref:`formatting-merge-data` for an explanation. + (Djrill 1.4 reported a DeprecationWarning for this case.) + + (The exception is :attr:`send_at`, which Djrill expects can be a date or + datetime.) + Removed DjrillMessage class The ``DjrillMessage`` class has not been needed since Djrill 0.2. You should replace any uses of it with the standard @@ -58,24 +68,6 @@ Removed DjrillBackendHTTPError with :exc:`djrill.MandrillAPIError`. -**Dates in merge data and other attributes** - -Djrill automatically converts :attr:`send_at` date and datetime -values to the ISO 8601 string format expected by the Mandrill API. - -Unintentionally, it also converts dates used in other Mandrill message -attributes (such as :attr:`merge_vars` or :attr:`metadata`) where it -might not be expected (or appropriate). - -Djrill 2.0 will remove this automatic date formatting, except -for attributes that are inherently dates (currently only `send_at`). - -To assist in detecting code relying on the (undocumented) current -behavior, Djrill 1.4 will report a DeprecationWarning for date -or datetime values used in any Mandrill message attributes other -than `send_at`. See :ref:`formatting-merge-data` for other options. - - Older Releases -------------- From 4089a355007618a52bd364ab7fc890846bfba277 Mon Sep 17 00:00:00 2001 From: medmunds Date: Sun, 29 Nov 2015 14:45:08 -0800 Subject: [PATCH 16/33] Drop legacy tests and deprecations --- djrill/exceptions.py | 8 -------- djrill/tests/__init__.py | 1 - djrill/tests/test_legacy.py | 12 ------------ djrill/tests/utils.py | 17 ----------------- 4 files changed, 38 deletions(-) delete mode 100644 djrill/tests/test_legacy.py delete mode 100644 djrill/tests/utils.py diff --git a/djrill/exceptions.py b/djrill/exceptions.py index c7913b8..edf818b 100644 --- a/djrill/exceptions.py +++ b/djrill/exceptions.py @@ -41,11 +41,3 @@ class NotSupportedByMandrillError(ValueError): avoid duplicating Mandrill's validation logic locally.) """ - - -class RemovedInDjrill2(DeprecationWarning): - """Functionality due for deprecation in Djrill 2.0""" - - -def removed_in_djrill_2(message, stacklevel=1): - warnings.warn(message, category=RemovedInDjrill2, stacklevel=stacklevel + 1) diff --git a/djrill/tests/__init__.py b/djrill/tests/__init__.py index c1b7504..1d0efac 100644 --- a/djrill/tests/__init__.py +++ b/djrill/tests/__init__.py @@ -1,4 +1,3 @@ -from djrill.tests.test_legacy import * from djrill.tests.test_mandrill_send import * from djrill.tests.test_mandrill_send_template import * from djrill.tests.test_mandrill_webhook import * diff --git a/djrill/tests/test_legacy.py b/djrill/tests/test_legacy.py deleted file mode 100644 index 3e632fd..0000000 --- a/djrill/tests/test_legacy.py +++ /dev/null @@ -1,12 +0,0 @@ -# Tests deprecated Djrill features - -from django.test import TestCase - -from djrill import NotSupportedByMandrillError - - -class DjrillLegacyExceptionTests(TestCase): - def test_NotSupportedByMandrillError(self): - """Unsupported features used to just raise ValueError in 0.2.0""" - ex = NotSupportedByMandrillError("testing") - self.assertIsInstance(ex, ValueError) diff --git a/djrill/tests/utils.py b/djrill/tests/utils.py deleted file mode 100644 index add2a83..0000000 --- a/djrill/tests/utils.py +++ /dev/null @@ -1,17 +0,0 @@ -import sys - - -# Backport from Django 1.8 (django.test.utils) -# with fix suggested by https://code.djangoproject.com/ticket/21049 -def reset_warning_registry(): - """ - Clear warning registry for all modules. This is required in some tests - because of a bug in Python that prevents warnings.simplefilter("always") - from always making warnings appear: http://bugs.python.org/issue4180 - - The bug was fixed in Python 3.4.2. - """ - key = "__warningregistry__" - for mod in list(sys.modules.values()): - if hasattr(mod, key): - getattr(mod, key).clear() From 3a6e0ebb30d0f2d9f12514875b181a7de00d397d Mon Sep 17 00:00:00 2001 From: medmunds Date: Mon, 30 Nov 2015 16:19:28 -0800 Subject: [PATCH 17/33] Clean up session sharing * Test cases * Fix premature session.close when caller is managing email backend connection * Ensure session closed correct in exceptions * Changelog (Also fixes bug where JSON serialization errors didn't respect fail_silently.) --- djrill/mail/backends/djrill.py | 79 ++++++++++++------- djrill/tests/__init__.py | 3 +- djrill/tests/test_mandrill_session_sharing.py | 72 +++++++++++++++++ docs/history.rst | 9 +++ 4 files changed, 132 insertions(+), 31 deletions(-) create mode 100644 djrill/tests/test_mandrill_session_sharing.py diff --git a/djrill/mail/backends/djrill.py b/djrill/mail/backends/djrill.py index 3c7d43d..dd10038 100644 --- a/djrill/mail/backends/djrill.py +++ b/djrill/mail/backends/djrill.py @@ -66,42 +66,61 @@ class DjrillBackend(BaseEmailBackend): self.api_send_template = self.api_url + "/messages/send-template.json" def open(self): - if not self.session: - try: - self.session = requests.Session() - self.session.headers["User-Agent"] = "Djrill/%s %s" % ( - __version__, self.session.headers.get("User-Agent", "")) - except: - if not self.fail_silently: - raise - return False - return True - - - def close(self, error=False): + """ + Ensure we have a requests Session to connect to the Mandrill API. + Returns True if a new session was created (and the caller must close it). + """ if self.session: - try: - self.session.close() - except: - if not self.fail_silently and not error: - raise + return False # already exists + + try: + self.session = requests.Session() + except requests.RequestException: + if not self.fail_silently: + raise + else: + self.session.headers["User-Agent"] = "Djrill/%s %s" % ( + __version__, self.session.headers.get("User-Agent", "")) + return True + + def close(self): + """ + Close the Mandrill API Session unconditionally. + + (You should call this only if you called open and it returned True; + else someone else created the session and will clean it up themselves.) + """ + if self.session is None: + return + try: + self.session.close() + except requests.RequestException: + if not self.fail_silently: + raise + finally: self.session = None def send_messages(self, email_messages): + """ + Sends one or more EmailMessage objects and returns the number of email + messages sent. + """ if not email_messages: return 0 - if not self.open(): - return + created_session = self.open() + if not self.session: + return 0 # exception in self.open with fail_silently num_sent = 0 - for message in email_messages: - sent = self._send(message) - - if sent: - num_sent += 1 - - self.close() + try: + for message in email_messages: + sent = self._send(message) + if sent: + num_sent += 1 + finally: + if created_session: + self.close() return num_sent @@ -134,13 +153,14 @@ class DjrillBackend(BaseEmailBackend): except NotSupportedByMandrillError: if not self.fail_silently: - self.close(True) raise return False try: api_data = json.dumps(api_params) except TypeError as err: + if self.fail_silently: + return False # Add some context to the "not JSON serializable" message if not err.args: err.args = ('',) @@ -148,7 +168,6 @@ class DjrillBackend(BaseEmailBackend): err.args[0] + " in a Djrill message (perhaps it's a merge var?)." " Try converting it to a string or number first.", ) + err.args[1:] - self.close(True) raise err response = self.session.post(api_url, data=api_data) @@ -165,7 +184,6 @@ class DjrillBackend(BaseEmailBackend): to['email'] for to in msg_dict.get('to', []) if 'email' in to) if 'from_email' in msg_dict: log_message += " from %s" % msg_dict['from_email'] - self.close(True) raise MandrillAPIError( status_code=response.status_code, response=response, @@ -378,6 +396,7 @@ class DjrillBackend(BaseEmailBackend): # b64encode requires bytes, so let's convert our content. try: + # noinspection PyUnresolvedReferences if isinstance(content, unicode): # Python 2.X unicode string content = content.encode(str_encoding) diff --git a/djrill/tests/__init__.py b/djrill/tests/__init__.py index 1d0efac..afefe93 100644 --- a/djrill/tests/__init__.py +++ b/djrill/tests/__init__.py @@ -1,6 +1,7 @@ from djrill.tests.test_mandrill_send import * from djrill.tests.test_mandrill_send_template import * -from djrill.tests.test_mandrill_webhook import * +from djrill.tests.test_mandrill_session_sharing import * from djrill.tests.test_mandrill_subaccounts import * +from djrill.tests.test_mandrill_webhook import * from djrill.tests.test_mandrill_integration import * diff --git a/djrill/tests/test_mandrill_session_sharing.py b/djrill/tests/test_mandrill_session_sharing.py new file mode 100644 index 0000000..ba128ee --- /dev/null +++ b/djrill/tests/test_mandrill_session_sharing.py @@ -0,0 +1,72 @@ +from decimal import Decimal +from mock import patch + +from django.core import mail + +from .mock_backend import DjrillBackendMockAPITestCase + + +class DjrillSessionSharingTests(DjrillBackendMockAPITestCase): + """Test Djrill backend sharing of single Mandrill API connection""" + + @patch('requests.Session.close', autospec=True) + def test_connection_sharing(self, mock_close): + """Djrill reuses one requests session when sending multiple messages""" + datatuple = ( + ('Subject 1', 'Body 1', 'from@example.com', ['to@example.com']), + ('Subject 2', 'Body 2', 'from@example.com', ['to@example.com']), + ) + mail.send_mass_mail(datatuple) + self.assertEqual(self.mock_post.call_count, 2) + session1 = self.mock_post.call_args_list[0][0] # arg[0] (self) is session + session2 = self.mock_post.call_args_list[1][0] + self.assertEqual(session1, session2) + self.assertEqual(mock_close.call_count, 1) + + @patch('requests.Session.close', autospec=True) + def test_caller_managed_connections(self, mock_close): + """Calling code can created long-lived connection that it opens and closes""" + connection = mail.get_connection() + connection.open() + mail.send_mail('Subject 1', 'body', 'from@example.com', ['to@example.com'], connection=connection) + session1 = self.mock_post.call_args[0] + self.assertEqual(mock_close.call_count, 0) # shouldn't be closed yet + + mail.send_mail('Subject 2', 'body', 'from@example.com', ['to@example.com'], connection=connection) + self.assertEqual(mock_close.call_count, 0) # still shouldn't be closed + session2 = self.mock_post.call_args[0] + self.assertEqual(session1, session2) # should have reused same session + + connection.close() + self.assertEqual(mock_close.call_count, 1) + + def test_session_closed_after_exception(self): + # fail loud case: + msg = mail.EmailMessage('Subject', 'Message', 'from@example.com', ['to1@example.com'],) + msg.global_merge_vars = {'PRICE': Decimal('19.99')} # will cause JSON serialization error + with patch('requests.Session.close', autospec=True) as mock_close: + with self.assertRaises(TypeError): + msg.send() + self.assertEqual(mock_close.call_count, 1) + + # fail silently case (EmailMessage caches backend on send, so must create new one): + msg = mail.EmailMessage('Subject', 'Message', 'from@example.com', ['to1@example.com'],) + msg.global_merge_vars = {'PRICE': Decimal('19.99')} + with patch('requests.Session.close', autospec=True) as mock_close: + sent = msg.send(fail_silently=True) + self.assertEqual(sent, 0) + self.assertEqual(mock_close.call_count, 1) + + # caller-supplied connection case: + with patch('requests.Session.close', autospec=True) as mock_close: + connection = mail.get_connection() + connection.open() + msg = mail.EmailMessage('Subject', 'Message', 'from@example.com', ['to1@example.com'], + connection=connection) + msg.global_merge_vars = {'PRICE': Decimal('19.99')} + with self.assertRaises(TypeError): + msg.send() + self.assertEqual(mock_close.call_count, 0) # wait for us to close it + + connection.close() + self.assertEqual(mock_close.call_count, 1) diff --git a/docs/history.rst b/docs/history.rst index 5f4d8c0..0567b56 100644 --- a/docs/history.rst +++ b/docs/history.rst @@ -68,6 +68,15 @@ Removed DjrillBackendHTTPError with :exc:`djrill.MandrillAPIError`. +Other Djrill 2.0 Changes +~~~~~~~~~~~~~~~~~~~~~~~~ + +* Use a single HTTP connection to the Mandrill API to improve performance + when sending multiple messages at once using :func:`~django.core.mail.send_mass_mail`. + (You can also directly manage your own long-lived Djrill connection across multiple sends, + by calling open and close on :ref:`Django's email backend `.) + + Older Releases -------------- From 632334b4260284df1a689873802d2c1e802c1f16 Mon Sep 17 00:00:00 2001 From: medmunds Date: Mon, 30 Nov 2015 17:20:27 -0800 Subject: [PATCH 18/33] Don't access MANDRILL_API_URL setting at module level Move MANDRILL_API_URL setting lookup into Djrill backend init (where it's used). Because "modules should not in general use settings stored in django.conf.settings at the top level". https://docs.djangoproject.com/en/dev/internals/contributing/writing-code/coding-style/#use-of-django-conf-settings --- djrill/__init__.py | 8 -------- djrill/mail/backends/djrill.py | 4 ++-- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/djrill/__init__.py b/djrill/__init__.py index 6a49f82..f4520b6 100644 --- a/djrill/__init__.py +++ b/djrill/__init__.py @@ -1,10 +1,2 @@ -from django.conf import settings - from djrill.exceptions import MandrillAPIError, NotSupportedByMandrillError from ._version import __version__, VERSION - - -# This backend was developed against this API endpoint. -# You can override in settings.py, if desired. -MANDRILL_API_URL = getattr(settings, "MANDRILL_API_URL", - "https://mandrillapp.com/api/1.0") diff --git a/djrill/mail/backends/djrill.py b/djrill/mail/backends/djrill.py index dd10038..58f6a90 100644 --- a/djrill/mail/backends/djrill.py +++ b/djrill/mail/backends/djrill.py @@ -5,7 +5,7 @@ from django.core.exceptions import ImproperlyConfigured from django.core.mail.backends.base import BaseEmailBackend from django.core.mail.message import sanitize_address, DEFAULT_ATTACHMENT_MIME_TYPE -from djrill import MANDRILL_API_URL, MandrillAPIError, NotSupportedByMandrillError, __version__ +from djrill import MandrillAPIError, NotSupportedByMandrillError, __version__ from base64 import b64encode from datetime import date, datetime @@ -47,7 +47,7 @@ class DjrillBackend(BaseEmailBackend): """ super(DjrillBackend, self).__init__(**kwargs) self.api_key = getattr(settings, "MANDRILL_API_KEY", None) - self.api_url = MANDRILL_API_URL + self.api_url = getattr(settings, "MANDRILL_API_URL", "https://mandrillapp.com/api/1.0") self.session = None self.global_settings = {} for setting_key in getattr(settings, "MANDRILL_SETTINGS", {}): From 8433e6d66044a2dcd15482cac19a54e114206152 Mon Sep 17 00:00:00 2001 From: medmunds Date: Mon, 30 Nov 2015 17:33:08 -0800 Subject: [PATCH 19/33] Clean up all imports * Use relative imports within djrill package * Standardize ordering * Remove absolute_import (it's standard in all python versions we now support) --- djrill/__init__.py | 3 ++- djrill/exceptions.py | 1 - djrill/mail/backends/djrill.py | 19 +++++++++---------- djrill/tests/__init__.py | 13 ++++++------- djrill/tests/mock_backend.py | 2 +- djrill/tests/test_mandrill_integration.py | 1 + djrill/tests/test_mandrill_send.py | 11 ++++++----- djrill/tests/test_mandrill_send_template.py | 3 ++- djrill/tests/test_mandrill_subaccounts.py | 2 +- djrill/tests/test_mandrill_webhook.py | 2 +- djrill/views.py | 11 ++++++----- 11 files changed, 35 insertions(+), 33 deletions(-) diff --git a/djrill/__init__.py b/djrill/__init__.py index f4520b6..571dc0b 100644 --- a/djrill/__init__.py +++ b/djrill/__init__.py @@ -1,2 +1,3 @@ -from djrill.exceptions import MandrillAPIError, NotSupportedByMandrillError from ._version import __version__, VERSION +from .exceptions import MandrillAPIError, NotSupportedByMandrillError + diff --git a/djrill/exceptions.py b/djrill/exceptions.py index edf818b..147d914 100644 --- a/djrill/exceptions.py +++ b/djrill/exceptions.py @@ -1,6 +1,5 @@ import json from requests import HTTPError -import warnings class MandrillAPIError(HTTPError): diff --git a/djrill/mail/backends/djrill.py b/djrill/mail/backends/djrill.py index 58f6a90..a2c5106 100644 --- a/djrill/mail/backends/djrill.py +++ b/djrill/mail/backends/djrill.py @@ -1,19 +1,18 @@ -from __future__ import absolute_import +import json +import mimetypes +import requests +from base64 import b64encode +from datetime import date, datetime +from email.mime.base import MIMEBase +from email.utils import parseaddr from django.conf import settings from django.core.exceptions import ImproperlyConfigured from django.core.mail.backends.base import BaseEmailBackend from django.core.mail.message import sanitize_address, DEFAULT_ATTACHMENT_MIME_TYPE -from djrill import MandrillAPIError, NotSupportedByMandrillError, __version__ - -from base64 import b64encode -from datetime import date, datetime -from email.mime.base import MIMEBase -from email.utils import parseaddr -import json -import mimetypes -import requests +from ..._version import __version__ +from ...exceptions import MandrillAPIError, NotSupportedByMandrillError def encode_date_for_mandrill(dt): diff --git a/djrill/tests/__init__.py b/djrill/tests/__init__.py index afefe93..b7f0a7e 100644 --- a/djrill/tests/__init__.py +++ b/djrill/tests/__init__.py @@ -1,7 +1,6 @@ -from djrill.tests.test_mandrill_send import * -from djrill.tests.test_mandrill_send_template import * -from djrill.tests.test_mandrill_session_sharing import * -from djrill.tests.test_mandrill_subaccounts import * -from djrill.tests.test_mandrill_webhook import * - -from djrill.tests.test_mandrill_integration import * +from .test_mandrill_integration import * +from .test_mandrill_send import * +from .test_mandrill_send_template import * +from .test_mandrill_session_sharing import * +from .test_mandrill_subaccounts import * +from .test_mandrill_webhook import * diff --git a/djrill/tests/mock_backend.py b/djrill/tests/mock_backend.py index 9fcf7b4..9679932 100644 --- a/djrill/tests/mock_backend.py +++ b/djrill/tests/mock_backend.py @@ -1,7 +1,7 @@ import json -from mock import patch import requests import six +from mock import patch from django.test import TestCase from django.test.utils import override_settings diff --git a/djrill/tests/test_mandrill_integration.py b/djrill/tests/test_mandrill_integration.py index 6dc5d04..bc8d7fa 100644 --- a/djrill/tests/test_mandrill_integration.py +++ b/djrill/tests/test_mandrill_integration.py @@ -1,4 +1,5 @@ from __future__ import unicode_literals + import os import unittest diff --git a/djrill/tests/test_mandrill_send.py b/djrill/tests/test_mandrill_send.py index 83e318b..62fe8c5 100644 --- a/djrill/tests/test_mandrill_send.py +++ b/djrill/tests/test_mandrill_send.py @@ -2,15 +2,15 @@ from __future__ import unicode_literals +import json +import os +import six +import unittest from base64 import b64decode from datetime import date, datetime, timedelta, tzinfo from decimal import Decimal from email.mime.base import MIMEBase from email.mime.image import MIMEImage -import json -import os -import six -import unittest from django.core import mail from django.core.exceptions import ImproperlyConfigured @@ -19,7 +19,8 @@ from django.test import TestCase from django.test.utils import override_settings from djrill import MandrillAPIError, NotSupportedByMandrillError -from djrill.tests.mock_backend import DjrillBackendMockAPITestCase + +from .mock_backend import DjrillBackendMockAPITestCase def decode_att(att): diff --git a/djrill/tests/test_mandrill_send_template.py b/djrill/tests/test_mandrill_send_template.py index 8a3760e..d45caa5 100644 --- a/djrill/tests/test_mandrill_send_template.py +++ b/djrill/tests/test_mandrill_send_template.py @@ -1,7 +1,8 @@ from django.core import mail from djrill import MandrillAPIError -from djrill.tests.mock_backend import DjrillBackendMockAPITestCase + +from .mock_backend import DjrillBackendMockAPITestCase class DjrillMandrillSendTemplateTests(DjrillBackendMockAPITestCase): diff --git a/djrill/tests/test_mandrill_subaccounts.py b/djrill/tests/test_mandrill_subaccounts.py index 920221f..09f1e97 100644 --- a/djrill/tests/test_mandrill_subaccounts.py +++ b/djrill/tests/test_mandrill_subaccounts.py @@ -1,7 +1,7 @@ from django.core import mail from django.test.utils import override_settings -from djrill.tests.mock_backend import DjrillBackendMockAPITestCase +from .mock_backend import DjrillBackendMockAPITestCase class DjrillMandrillSubaccountTests(DjrillBackendMockAPITestCase): diff --git a/djrill/tests/test_mandrill_webhook.py b/djrill/tests/test_mandrill_webhook.py index 9089a4c..661d42e 100644 --- a/djrill/tests/test_mandrill_webhook.py +++ b/djrill/tests/test_mandrill_webhook.py @@ -1,7 +1,7 @@ -from base64 import b64encode import hashlib import hmac import json +from base64 import b64encode from django.conf import settings from django.core.exceptions import ImproperlyConfigured diff --git a/djrill/views.py b/djrill/views.py index cb226bb..833bde6 100644 --- a/djrill/views.py +++ b/djrill/views.py @@ -1,16 +1,17 @@ -from base64 import b64encode import hashlib import hmac import json +from base64 import b64encode + from django.conf import settings from django.core.exceptions import ImproperlyConfigured -from django.views.generic import View from django.http import HttpResponse from django.utils.decorators import method_decorator from django.views.decorators.csrf import csrf_exempt +from django.views.generic import View -from djrill import signals -from djrill.compat import b +from .compat import b +from .signals import webhook_event class DjrillWebhookSecretMixin(object): @@ -77,7 +78,7 @@ class DjrillWebhookView(DjrillWebhookSecretMixin, DjrillWebhookSignatureMixin, V return HttpResponse(status=400) for event in data: - signals.webhook_event.send( + webhook_event.send( sender=None, event_type=event['event'], data=event) return HttpResponse() From d14b87c910d34ed703288d9e947b407be821b38c Mon Sep 17 00:00:00 2001 From: medmunds Date: Tue, 1 Dec 2015 13:26:21 -0800 Subject: [PATCH 20/33] Raise error for invalid/rejected recipients Raise new MandrillRecipientsRefused exception when Mandrill returns 'reject' or 'invalid' status for *all* recipients of a message. (Similar to Django's SMTP email backend raising SMTPRecipientsRefused.) Add setting MANDRILL_IGNORE_RECIPIENT_STATUS to override the new exception. Trap JSON parsing errors in Mandrill API response, and raise MandrillAPIError for them. (Helps with #93.) Closes #80. Closes #81. --- djrill/__init__.py | 3 +- djrill/exceptions.py | 40 ++++++++++++---- djrill/mail/backends/djrill.py | 31 +++++++++--- djrill/tests/mock_backend.py | 10 +++- djrill/tests/test_mandrill_integration.py | 48 ++++++++++++------- djrill/tests/test_mandrill_send.py | 57 ++++++++++++++++++++++- docs/history.rst | 12 +++++ docs/installation.rst | 18 ++++++- docs/usage/sending_mail.rst | 22 +++++++++ 9 files changed, 204 insertions(+), 37 deletions(-) diff --git a/djrill/__init__.py b/djrill/__init__.py index 571dc0b..621fdf1 100644 --- a/djrill/__init__.py +++ b/djrill/__init__.py @@ -1,3 +1,2 @@ from ._version import __version__, VERSION -from .exceptions import MandrillAPIError, NotSupportedByMandrillError - +from .exceptions import MandrillAPIError, MandrillRecipientsRefused, NotSupportedByMandrillError diff --git a/djrill/exceptions.py b/djrill/exceptions.py index 147d914..a8a83d6 100644 --- a/djrill/exceptions.py +++ b/djrill/exceptions.py @@ -2,6 +2,23 @@ import json from requests import HTTPError +def format_response(response): + """Return a string-formatted version of response + + Format json if available, else just return text. + Returns "" if neither json nor text available. + """ + try: + json_response = response.json() + return "\n" + json.dumps(json_response, indent=2) + except (AttributeError, KeyError, ValueError): # not JSON = ValueError + try: + return response.text + except AttributeError: + pass + return "" + + class MandrillAPIError(HTTPError): """Exception for unsuccessful response from Mandrill API.""" def __init__(self, status_code, response=None, log_message=None, *args, **kwargs): @@ -15,14 +32,21 @@ class MandrillAPIError(HTTPError): if self.log_message: message += "\n" + self.log_message # Include the Mandrill response, nicely formatted, if possible - try: - json_response = self.response.json() - message += "\nMandrill response:\n" + json.dumps(json_response, indent=2) - except (AttributeError, KeyError, ValueError): # not JSON = ValueError - try: - message += "\nMandrill response: " + self.response.text - except AttributeError: - pass + if self.response is not None: + message += "\nMandrill response: " + format_response(self.response) + return message + + +class MandrillRecipientsRefused(IOError): + """Exception for send where all recipients are invalid or rejected.""" + def __init__(self, message, response=None, *args, **kwargs): + super(MandrillRecipientsRefused, self).__init__(message, *args, **kwargs) + self.response = response + + def __str__(self): + message = self.args[0] + if self.response is not None: + message += "\nMandrill response: " + format_response(self.response) return message diff --git a/djrill/mail/backends/djrill.py b/djrill/mail/backends/djrill.py index a2c5106..432056e 100644 --- a/djrill/mail/backends/djrill.py +++ b/djrill/mail/backends/djrill.py @@ -12,7 +12,7 @@ from django.core.mail.backends.base import BaseEmailBackend from django.core.mail.message import sanitize_address, DEFAULT_ATTACHMENT_MIME_TYPE from ..._version import __version__ -from ...exceptions import MandrillAPIError, NotSupportedByMandrillError +from ...exceptions import MandrillAPIError, MandrillRecipientsRefused, NotSupportedByMandrillError def encode_date_for_mandrill(dt): @@ -56,6 +56,7 @@ class DjrillBackend(BaseEmailBackend): self.global_settings[setting_key] = settings.MANDRILL_SETTINGS[setting_key] self.subaccount = getattr(settings, "MANDRILL_SUBACCOUNT", None) + self.ignore_recipient_status = getattr(settings, "MANDRILL_IGNORE_RECIPIENT_STATUS", False) if not self.api_key: raise ImproperlyConfigured("You have not set your mandrill api key " @@ -124,6 +125,8 @@ class DjrillBackend(BaseEmailBackend): return num_sent def _send(self, message): + message.mandrill_response = None # until we have a response + if not message.recipients(): return False @@ -172,10 +175,6 @@ class DjrillBackend(BaseEmailBackend): response = self.session.post(api_url, data=api_data) if response.status_code != 200: - - # add a mandrill_response for the sake of being explicit - message.mandrill_response = None - if not self.fail_silently: log_message = "Failed to send a message" if 'to' in msg_dict: @@ -190,7 +189,27 @@ class DjrillBackend(BaseEmailBackend): return False # add the response from mandrill to the EmailMessage so callers can inspect it - message.mandrill_response = response.json() + try: + message.mandrill_response = response.json() + recipient_status = [item["status"] for item in message.mandrill_response] + except (ValueError, KeyError): + if not self.fail_silently: + raise MandrillAPIError( + status_code=response.status_code, + response=response, + log_message="Error parsing Mandrill API response") + return False + + # Error if *all* recipients are invalid or refused + # (This behavior parallels smtplib.SMTPRecipientsRefused from Django's SMTP EmailBackend) + if (not self.ignore_recipient_status and + all([status in ('invalid', 'rejected') for status in recipient_status])): + if not self.fail_silently: + raise MandrillRecipientsRefused( + "All message recipients were rejected or invalid", + response=response + ) + return False return True diff --git a/djrill/tests/mock_backend.py b/djrill/tests/mock_backend.py index 9679932..47e0dfa 100644 --- a/djrill/tests/mock_backend.py +++ b/djrill/tests/mock_backend.py @@ -7,6 +7,14 @@ from django.test import TestCase from django.test.utils import override_settings +MANDRILL_SUCCESS_RESPONSE = six.b("""[{ + "email": "to@example.com", + "status": "sent", + "_id": "abc123", + "reject_reason": null +}]""") + + @override_settings(MANDRILL_API_KEY="FAKE_API_KEY_FOR_TESTING", EMAIL_BACKEND="djrill.mail.backends.djrill.DjrillBackend") class DjrillBackendMockAPITestCase(TestCase): @@ -14,7 +22,7 @@ class DjrillBackendMockAPITestCase(TestCase): class MockResponse(requests.Response): """requests.post return value mock sufficient for DjrillBackend""" - def __init__(self, status_code=200, raw=six.b("{}"), encoding='utf-8'): + def __init__(self, status_code=200, raw=six.b(MANDRILL_SUCCESS_RESPONSE), encoding='utf-8'): super(DjrillBackendMockAPITestCase.MockResponse, self).__init__() self.status_code = status_code self.encoding = encoding diff --git a/djrill/tests/test_mandrill_integration.py b/djrill/tests/test_mandrill_integration.py index bc8d7fa..fbdfefd 100644 --- a/djrill/tests/test_mandrill_integration.py +++ b/djrill/tests/test_mandrill_integration.py @@ -7,7 +7,7 @@ from django.core import mail from django.test import TestCase from django.test.utils import override_settings -from djrill import MandrillAPIError +from djrill import MandrillAPIError, MandrillRecipientsRefused MANDRILL_TEST_API_KEY = os.getenv('MANDRILL_TEST_API_KEY') @@ -58,25 +58,41 @@ class DjrillIntegrationTests(TestCase): def test_invalid_to(self): # Example of detecting when a recipient is not a valid email address self.message.to = ['invalid@localhost'] - sent_count = self.message.send() - self.assertEqual(sent_count, 1) # The send call is "successful"... - # noinspection PyUnresolvedReferences - response = self.message.mandrill_response - if response[0]['status'] == 'queued': - self.skipTest("Mandrill queued the send -- can't complete this test") - self.assertEqual(response[0]['status'], 'invalid') # ... but the mail is not delivered + try: + self.message.send() + except MandrillRecipientsRefused: + # Mandrill refused to deliver the mail -- message.mandrill_response will tell you why: + # noinspection PyUnresolvedReferences + response = self.message.mandrill_response + self.assertEqual(response[0]['status'], 'invalid') + else: + # Sometimes Mandrill queues these test sends + # noinspection PyUnresolvedReferences + response = self.message.mandrill_response + if response[0]['status'] == 'queued': + self.skipTest("Mandrill queued the send -- can't complete this test") + else: + self.fail("Djrill did not raise MandrillRecipientsRefused for invalid recipient") def test_rejected_to(self): # Example of detecting when a recipient is on Mandrill's rejection blacklist self.message.to = ['reject@test.mandrillapp.com'] - sent_count = self.message.send() - self.assertEqual(sent_count, 1) # The send call is "successful"... - # noinspection PyUnresolvedReferences - response = self.message.mandrill_response - if response[0]['status'] == 'queued': - self.skipTest("Mandrill queued the send -- can't complete this test") - self.assertEqual(response[0]['status'], 'rejected') # ... but the mail is not delivered - self.assertEqual(response[0]['reject_reason'], 'test') # ... and here's why + try: + self.message.send() + except MandrillRecipientsRefused: + # Mandrill refused to deliver the mail -- message.mandrill_response will tell you why: + # noinspection PyUnresolvedReferences + response = self.message.mandrill_response + self.assertEqual(response[0]['status'], 'rejected') + self.assertEqual(response[0]['reject_reason'], 'test') + else: + # Sometimes Mandrill queues these test sends + # noinspection PyUnresolvedReferences + response = self.message.mandrill_response + if response[0]['status'] == 'queued': + self.skipTest("Mandrill queued the send -- can't complete this test") + else: + self.fail("Djrill did not raise MandrillRecipientsRefused for blacklist recipient") @override_settings(MANDRILL_API_KEY="Hey, that's not an API key!") def test_invalid_api_key(self): diff --git a/djrill/tests/test_mandrill_send.py b/djrill/tests/test_mandrill_send.py index 62fe8c5..ec910a0 100644 --- a/djrill/tests/test_mandrill_send.py +++ b/djrill/tests/test_mandrill_send.py @@ -18,7 +18,7 @@ from django.core.mail import make_msgid from django.test import TestCase from django.test.utils import override_settings -from djrill import MandrillAPIError, NotSupportedByMandrillError +from djrill import MandrillAPIError, MandrillRecipientsRefused, NotSupportedByMandrillError from .mock_backend import DjrillBackendMockAPITestCase @@ -515,7 +515,7 @@ class DjrillMandrillFeatureTests(DjrillBackendMockAPITestCase): def test_send_attaches_mandrill_response(self): """ The mandrill_response should be attached to the message when it is sent """ - response = [{'mandrill_response': 'would_be_here'}] + response = [{'email': 'to1@example.com', 'status': 'sent'}] self.mock_post.return_value = self.MockResponse(raw=six.b(json.dumps(response))) msg = mail.EmailMessage('Subject', 'Message', 'from@example.com', ['to1@example.com'],) sent = msg.send() @@ -530,6 +530,14 @@ class DjrillMandrillFeatureTests(DjrillBackendMockAPITestCase): self.assertEqual(sent, 0) self.assertIsNone(msg.mandrill_response) + def test_send_unparsable_mandrill_response(self): + """If the send succeeds, but a non-JSON API response, should raise an API exception""" + self.mock_post.return_value = self.MockResponse(status_code=500, raw=b"this isn't json") + msg = mail.EmailMessage('Subject', 'Message', 'from@example.com', ['to1@example.com'],) + with self.assertRaises(MandrillAPIError): + msg.send() + self.assertIsNone(msg.mandrill_response) + def test_json_serialization_errors(self): """Try to provide more information about non-json-serializable data""" self.message.global_merge_vars = {'PRICE': Decimal('19.99')} @@ -547,6 +555,51 @@ class DjrillMandrillFeatureTests(DjrillBackendMockAPITestCase): self.message.send() +class DjrillRecipientsRefusedTests(DjrillBackendMockAPITestCase): + """Djrill raises MandrillRecipientsRefused when *all* recipients are rejected or invalid""" + + def test_recipients_refused(self): + msg = mail.EmailMessage('Subject', 'Body', 'from@example.com', + ['invalid@localhost', 'reject@test.mandrillapp.com']) + self.mock_post.return_value = self.MockResponse(status_code=200, raw=b""" + [{ "email": "invalid@localhost", "status": "invalid" }, + { "email": "reject@test.mandrillapp.com", "status": "rejected" }]""") + with self.assertRaises(MandrillRecipientsRefused): + msg.send() + + def test_fail_silently(self): + self.mock_post.return_value = self.MockResponse(status_code=200, raw=b""" + [{ "email": "invalid@localhost", "status": "invalid" }, + { "email": "reject@test.mandrillapp.com", "status": "rejected" }]""") + sent = mail.send_mail('Subject', 'Body', 'from@example.com', + ['invalid@localhost', 'reject@test.mandrillapp.com'], + fail_silently=True) + self.assertEqual(sent, 0) + + def test_mixed_response(self): + """If *any* recipients are valid or queued, no exception is raised""" + msg = mail.EmailMessage('Subject', 'Body', 'from@example.com', + ['invalid@localhost', 'valid@example.com', + 'reject@test.mandrillapp.com', 'also.valid@example.com']) + self.mock_post.return_value = self.MockResponse(status_code=200, raw=b""" + [{ "email": "invalid@localhost", "status": "invalid" }, + { "email": "valid@example.com", "status": "sent" }, + { "email": "reject@test.mandrillapp.com", "status": "rejected" }, + { "email": "also.valid@example.com", "status": "queued" }]""") + sent = msg.send() + self.assertEqual(sent, 1) # one message sent, successfully, to 2 of 4 recipients + + @override_settings(MANDRILL_IGNORE_RECIPIENT_STATUS=True) + def test_settings_override(self): + """Setting restores Djrill 1.x behavior""" + self.mock_post.return_value = self.MockResponse(status_code=200, raw=b""" + [{ "email": "invalid@localhost", "status": "invalid" }, + { "email": "reject@test.mandrillapp.com", "status": "rejected" }]""") + sent = mail.send_mail('Subject', 'Body', 'from@example.com', + ['invalid@localhost', 'reject@test.mandrillapp.com']) + self.assertEqual(sent, 1) # refused message is included in sent count + + @override_settings(MANDRILL_SETTINGS={ 'from_name': 'Djrill Test', 'important': True, diff --git a/docs/history.rst b/docs/history.rst index 0567b56..656a6de 100644 --- a/docs/history.rst +++ b/docs/history.rst @@ -48,6 +48,18 @@ Removed DjrillAdminSite (Do this only if you changed to SimpleAdminConfig for Djrill, and aren't creating custom admin sites for any other Django apps you use.) +Added exception for invalid or rejected recipients + Djrill 2.0 raises a new :exc:`djrill.MandrillRecipientsRefused` exception when + all recipients of a message invalid or rejected by Mandrill. This parallels + the behavior of Django's default :setting:`SMTP email backend `, + which raises :exc:`SMTPRecipientsRefused ` when + all recipients are refused. + + Your email-sending code should handle this exception (along with other + exceptions that could occur during a send). However, if you want to retain the + Djrill 1.x behavior and treat invalid or rejected recipients as successful sends, + you can set :setting:`MANDRILL_IGNORE_RECIPIENT_STATUS` to ``True`` in your settings.py. + Removed unintended date-to-string conversion If your code was relying on Djrill to automatically convert date or datetime values to strings in :attr:`merge_vars`, :attr:`metadata`, or other Mandrill diff --git a/docs/installation.rst b/docs/installation.rst index 743f53b..5aaf15b 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -47,11 +47,25 @@ Djrill includes optional support for Mandrill webhooks, including inbound email. See the Djrill :ref:`webhooks ` section for configuration details. -Mandrill Subaccounts (Optional) -------------------------------- +Other Optional Settings +----------------------- + +.. setting:: MANDRILL_IGNORE_RECIPIENT_STATUS + +MANDRILL_IGNORE_RECIPIENT_STATUS +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Set to ``True`` to disable :exc:`djrill.MandrillRecipientsRefused` exceptions +on invalid or rejected recipients. (Default ``False``.) + +.. versionadded:: 2.0 + .. setting:: MANDRILL_SUBACCOUNT +MANDRILL_SUBACCOUNT +~~~~~~~~~~~~~~~~~~~ + If you are using Mandrill's `subaccounts`_ feature, you can globally set the subaccount for all messages sent through Djrill:: diff --git a/docs/usage/sending_mail.rst b/docs/usage/sending_mail.rst index deca0dd..2853284 100644 --- a/docs/usage/sending_mail.rst +++ b/docs/usage/sending_mail.rst @@ -362,6 +362,27 @@ Exceptions of :exc:`ValueError`). +.. exception:: djrill.MandrillRecipientsRefused + + If *all* recipients (to, cc, bcc) of a message are invalid or rejected by Mandrill + (e.g., because they are your Mandrill blacklist), the send call will raise a + :exc:`~!djrill.MandrillRecipientsRefused` exception. + You can examine the message's :ref:`mandrill_response property ` + to determine the cause of the error. + + If a single message is sent to multiple recipients, and *any* recipient is valid + (or the message is queued by Mandrill because of rate limiting or :attr:`send_at`), then + this exception will not be raised. You can still examine the mandrill_response + property after the send to determine the status of each recipient. + + You can disable this exception by setting :setting:`MANDRILL_IGNORE_RECIPIENT_STATUS` + to True in your settings.py, which will cause Djrill to treat any non-API-error response + from Mandrill as a successful send. + + .. versionadded:: 2.0 + Djrill 1.x behaved as if ``MANDRILL_IGNORE_RECIPIENT_STATUS = True``. + + .. exception:: djrill.MandrillAPIError If the Mandrill API fails or returns an error response, the send call will @@ -370,3 +391,4 @@ Exceptions help explain what went wrong. (Tip: you can also check Mandrill's `API error log `_ to view the full API request and error response.) + From 221530ae11d62c354d1fb6cc4420d574e8ffd45e Mon Sep 17 00:00:00 2001 From: medmunds Date: Tue, 1 Dec 2015 13:57:45 -0800 Subject: [PATCH 21/33] Fix tests on python 3 --- djrill/tests/mock_backend.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/djrill/tests/mock_backend.py b/djrill/tests/mock_backend.py index 47e0dfa..0239cbc 100644 --- a/djrill/tests/mock_backend.py +++ b/djrill/tests/mock_backend.py @@ -7,12 +7,12 @@ from django.test import TestCase from django.test.utils import override_settings -MANDRILL_SUCCESS_RESPONSE = six.b("""[{ +MANDRILL_SUCCESS_RESPONSE = b"""[{ "email": "to@example.com", "status": "sent", "_id": "abc123", "reject_reason": null -}]""") +}]""" @override_settings(MANDRILL_API_KEY="FAKE_API_KEY_FOR_TESTING", @@ -22,7 +22,7 @@ class DjrillBackendMockAPITestCase(TestCase): class MockResponse(requests.Response): """requests.post return value mock sufficient for DjrillBackend""" - def __init__(self, status_code=200, raw=six.b(MANDRILL_SUCCESS_RESPONSE), encoding='utf-8'): + def __init__(self, status_code=200, raw=MANDRILL_SUCCESS_RESPONSE, encoding='utf-8'): super(DjrillBackendMockAPITestCase.MockResponse, self).__init__() self.status_code = status_code self.encoding = encoding From b8cdc6ce8281ca234e10f00c329fbdd3d326e0e7 Mon Sep 17 00:00:00 2001 From: medmunds Date: Wed, 2 Dec 2015 10:52:20 -0800 Subject: [PATCH 22/33] Cleanup Djrill exceptions * Add common base DjrillException * Simplify backend by moving logic to describe errors into base DjrillException * Add NotSerializableForMandrillError for JSON serialization errors --- djrill/__init__.py | 3 +- djrill/exceptions.py | 117 ++++++++++++++++++++--------- djrill/mail/backends/djrill.py | 33 ++------ djrill/tests/test_mandrill_send.py | 19 +++-- docs/history.rst | 2 + docs/usage/sending_mail.rst | 12 +++ docs/usage/templates.rst | 3 + 7 files changed, 121 insertions(+), 68 deletions(-) diff --git a/djrill/__init__.py b/djrill/__init__.py index 621fdf1..951d0eb 100644 --- a/djrill/__init__.py +++ b/djrill/__init__.py @@ -1,2 +1,3 @@ from ._version import __version__, VERSION -from .exceptions import MandrillAPIError, MandrillRecipientsRefused, NotSupportedByMandrillError +from .exceptions import (MandrillAPIError, MandrillRecipientsRefused, + NotSerializableForMandrillError, NotSupportedByMandrillError) diff --git a/djrill/exceptions.py b/djrill/exceptions.py index a8a83d6..755eb12 100644 --- a/djrill/exceptions.py +++ b/djrill/exceptions.py @@ -2,55 +2,86 @@ import json from requests import HTTPError -def format_response(response): - """Return a string-formatted version of response +class DjrillError(Exception): + """Base class for exceptions raised by Djrill - Format json if available, else just return text. - Returns "" if neither json nor text available. + Overrides __str__ to provide additional information about + Mandrill API call and response. """ - try: - json_response = response.json() - return "\n" + json.dumps(json_response, indent=2) - except (AttributeError, KeyError, ValueError): # not JSON = ValueError + + def __init__(self, *args, **kwargs): + """ + Optional kwargs: + payload: data arg (*not* json-stringified) for the Mandrill send call + response: requests.Response from the send call + """ + self.payload = kwargs.pop('payload', None) + if isinstance(self, HTTPError): + # must leave response in kwargs for HTTPError + self.response = kwargs.get('response', None) + else: + self.response = kwargs.pop('response', None) + super(DjrillError, self).__init__(*args, **kwargs) + + def __str__(self): + parts = [ + " ".join([str(arg) for arg in self.args]), + self.describe_send(), + self.describe_response(), + ] + return "\n".join(filter(None, parts)) + + def describe_send(self): + """Return a string describing the Mandrill send in self.payload, or None""" + if self.payload is None: + return None + description = "Sending a message" try: - return response.text - except AttributeError: + to_emails = [to['email'] for to in self.payload['message']['to']] + description += " to %s" % ','.join(to_emails) + except KeyError: pass - return "" + try: + description += " from %s" % self.payload['message']['from_email'] + except KeyError: + pass + return description + + def describe_response(self): + """Return a formatted string of self.response, or None""" + if self.response is None: + return None + description = "Mandrill API response %d:" % self.response.status_code + try: + json_response = self.response.json() + description += "\n" + json.dumps(json_response, indent=2) + except (AttributeError, KeyError, ValueError): # not JSON = ValueError + try: + description += self.response.text + except AttributeError: + pass + return description -class MandrillAPIError(HTTPError): +class MandrillAPIError(DjrillError, HTTPError): """Exception for unsuccessful response from Mandrill API.""" - def __init__(self, status_code, response=None, log_message=None, *args, **kwargs): + + def __init__(self, *args, **kwargs): super(MandrillAPIError, self).__init__(*args, **kwargs) - self.status_code = status_code - self.response = response # often contains helpful Mandrill info - self.log_message = log_message - - def __str__(self): - message = "Mandrill API response %d" % self.status_code - if self.log_message: - message += "\n" + self.log_message - # Include the Mandrill response, nicely formatted, if possible if self.response is not None: - message += "\nMandrill response: " + format_response(self.response) - return message + self.status_code = self.response.status_code -class MandrillRecipientsRefused(IOError): +class MandrillRecipientsRefused(DjrillError): """Exception for send where all recipients are invalid or rejected.""" - def __init__(self, message, response=None, *args, **kwargs): + + def __init__(self, message=None, *args, **kwargs): + if message is None: + message = "All message recipients were rejected or invalid" super(MandrillRecipientsRefused, self).__init__(message, *args, **kwargs) - self.response = response - - def __str__(self): - message = self.args[0] - if self.response is not None: - message += "\nMandrill response: " + format_response(self.response) - return message -class NotSupportedByMandrillError(ValueError): +class NotSupportedByMandrillError(DjrillError, ValueError): """Exception for email features that Mandrill doesn't support. This is typically raised when attempting to send a Django EmailMessage that @@ -64,3 +95,21 @@ class NotSupportedByMandrillError(ValueError): avoid duplicating Mandrill's validation logic locally.) """ + + +class NotSerializableForMandrillError(DjrillError, TypeError): + """Exception for data that Djrill doesn't know how to convert to JSON. + + This typically results from including something like a date or Decimal + in your merge_vars (or other Mandrill-specific EmailMessage option). + + """ + # inherits from TypeError for backwards compatibility with Djrill 1.x + + def __init__(self, message=None, orig_err=None, *args, **kwargs): + if message is None: + message = "Don't know how to send this data to Mandrill. " \ + "Try converting it to a string or number first." + if orig_err is not None: + message += "\n%s" % str(orig_err) + super(NotSerializableForMandrillError, self).__init__(message, *args, **kwargs) diff --git a/djrill/mail/backends/djrill.py b/djrill/mail/backends/djrill.py index 432056e..12b8c5a 100644 --- a/djrill/mail/backends/djrill.py +++ b/djrill/mail/backends/djrill.py @@ -12,7 +12,8 @@ from django.core.mail.backends.base import BaseEmailBackend from django.core.mail.message import sanitize_address, DEFAULT_ATTACHMENT_MIME_TYPE from ..._version import __version__ -from ...exceptions import MandrillAPIError, MandrillRecipientsRefused, NotSupportedByMandrillError +from ...exceptions import (MandrillAPIError, MandrillRecipientsRefused, + NotSerializableForMandrillError, NotSupportedByMandrillError) def encode_date_for_mandrill(dt): @@ -164,28 +165,13 @@ class DjrillBackend(BaseEmailBackend): if self.fail_silently: return False # Add some context to the "not JSON serializable" message - if not err.args: - err.args = ('',) - err.args = ( - err.args[0] + " in a Djrill message (perhaps it's a merge var?)." - " Try converting it to a string or number first.", - ) + err.args[1:] - raise err + raise NotSerializableForMandrillError(orig_err=err) response = self.session.post(api_url, data=api_data) if response.status_code != 200: if not self.fail_silently: - log_message = "Failed to send a message" - if 'to' in msg_dict: - log_message += " to " + ','.join( - to['email'] for to in msg_dict.get('to', []) if 'email' in to) - if 'from_email' in msg_dict: - log_message += " from %s" % msg_dict['from_email'] - raise MandrillAPIError( - status_code=response.status_code, - response=response, - log_message=log_message) + raise MandrillAPIError(payload=api_params, response=response) return False # add the response from mandrill to the EmailMessage so callers can inspect it @@ -194,10 +180,8 @@ class DjrillBackend(BaseEmailBackend): recipient_status = [item["status"] for item in message.mandrill_response] except (ValueError, KeyError): if not self.fail_silently: - raise MandrillAPIError( - status_code=response.status_code, - response=response, - log_message="Error parsing Mandrill API response") + raise MandrillAPIError("Error parsing Mandrill API response", + payload=api_params, response=response) return False # Error if *all* recipients are invalid or refused @@ -205,10 +189,7 @@ class DjrillBackend(BaseEmailBackend): if (not self.ignore_recipient_status and all([status in ('invalid', 'rejected') for status in recipient_status])): if not self.fail_silently: - raise MandrillRecipientsRefused( - "All message recipients were rejected or invalid", - response=response - ) + raise MandrillRecipientsRefused(payload=api_params, response=response) return False return True diff --git a/djrill/tests/test_mandrill_send.py b/djrill/tests/test_mandrill_send.py index ec910a0..a767ad8 100644 --- a/djrill/tests/test_mandrill_send.py +++ b/djrill/tests/test_mandrill_send.py @@ -4,6 +4,7 @@ from __future__ import unicode_literals import json import os +import re import six import unittest from base64 import b64decode @@ -18,7 +19,8 @@ from django.core.mail import make_msgid from django.test import TestCase from django.test.utils import override_settings -from djrill import MandrillAPIError, MandrillRecipientsRefused, NotSupportedByMandrillError +from djrill import (MandrillAPIError, MandrillRecipientsRefused, + NotSerializableForMandrillError, NotSupportedByMandrillError) from .mock_backend import DjrillBackendMockAPITestCase @@ -339,6 +341,9 @@ class DjrillMandrillFeatureTests(DjrillBackendMockAPITestCase): self.message = mail.EmailMessage('Subject', 'Text Body', 'from@example.com', ['to@example.com']) + def assertStrContains(self, haystack, needle, msg=None): + six.assertRegex(self, haystack, re.escape(needle), msg) + def test_tracking(self): # First make sure we're not setting the API param if the track_click # attr isn't there. (The Mandrill account option of True for html, @@ -541,17 +546,17 @@ class DjrillMandrillFeatureTests(DjrillBackendMockAPITestCase): def test_json_serialization_errors(self): """Try to provide more information about non-json-serializable data""" self.message.global_merge_vars = {'PRICE': Decimal('19.99')} - with self.assertRaisesMessage( - TypeError, - "Decimal('19.99') is not JSON serializable in a Djrill message (perhaps " - "it's a merge var?). Try converting it to a string or number first." - ): + with self.assertRaises(NotSerializableForMandrillError) as cm: self.message.send() + err = cm.exception + self.assertTrue(isinstance(err, TypeError)) # Djrill 1.x re-raised TypeError from json.dumps + self.assertStrContains(str(err), "Don't know how to send this data to Mandrill") # our added context + self.assertStrContains(str(err), "Decimal('19.99') is not JSON serializable") # original message def test_dates_not_serialized(self): """Pre-2.0 Djrill accidentally serialized dates to ISO""" self.message.global_merge_vars = {'SHIP_DATE': date(2015, 12, 2)} - with self.assertRaises(TypeError): + with self.assertRaises(NotSerializableForMandrillError): self.message.send() diff --git a/docs/history.rst b/docs/history.rst index 656a6de..d91bdaa 100644 --- a/docs/history.rst +++ b/docs/history.rst @@ -88,6 +88,8 @@ Other Djrill 2.0 Changes (You can also directly manage your own long-lived Djrill connection across multiple sends, by calling open and close on :ref:`Django's email backend `.) +* Add :exc:`djrill.NotSerializableForMandrillError` + Older Releases -------------- diff --git a/docs/usage/sending_mail.rst b/docs/usage/sending_mail.rst index 2853284..18cf66a 100644 --- a/docs/usage/sending_mail.rst +++ b/docs/usage/sending_mail.rst @@ -392,3 +392,15 @@ Exceptions `API error log `_ to view the full API request and error response.) + +.. exception:: djrill.NotSerializableForMandrillError + + The send call will raise a :exc:`~!djrill.NotSerializableForMandrillError` exception + if the message has attached data which cannot be serialized to JSON for the Mandrill API. + + See :ref:`formatting-merge-data` for more information. + + .. versionadded:: 2.0 + Djrill 1.x raised a generic `TypeError` in this case. + :exc:`~!djrill.NotSerializableForMandrillError` is a subclass of `TypeError` + for compatibility with existing code. diff --git a/docs/usage/templates.rst b/docs/usage/templates.rst index 3673183..71f76b0 100644 --- a/docs/usage/templates.rst +++ b/docs/usage/templates.rst @@ -75,6 +75,9 @@ which means advanced template users can include dicts and lists as merge vars (for templates designed to handle objects and arrays). See the Python :class:`json.JSONEncoder` docs for a list of allowable types. +Djrill will raise :exc:`djrill.NotSerializableForMandrillError` if you attempt +to send a message with non-json-serializable data. + How To Use Default Mandrill Subject and From fields ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ From fe1e2d1ae508e7d8f5b866e48adcad9af16e1cf8 Mon Sep 17 00:00:00 2001 From: medmunds Date: Wed, 2 Dec 2015 12:33:27 -0800 Subject: [PATCH 23/33] Refactor backend * Break apart massive _send call * Try to facilitate subclassing * Centralize fail_silently handling during _send * Include original EmailMessage as exception attr --- djrill/exceptions.py | 4 +- djrill/mail/backends/djrill.py | 205 +++++++++++++++++++++------------ docs/history.rst | 8 ++ 3 files changed, 143 insertions(+), 74 deletions(-) diff --git a/djrill/exceptions.py b/djrill/exceptions.py index 755eb12..3b16354 100644 --- a/djrill/exceptions.py +++ b/djrill/exceptions.py @@ -12,9 +12,11 @@ class DjrillError(Exception): def __init__(self, *args, **kwargs): """ Optional kwargs: + email_message: the original EmailMessage being sent payload: data arg (*not* json-stringified) for the Mandrill send call response: requests.Response from the send call """ + self.email_message = kwargs.pop('email_message', None) self.payload = kwargs.pop('payload', None) if isinstance(self, HTTPError): # must leave response in kwargs for HTTPError @@ -57,7 +59,7 @@ class DjrillError(Exception): description += "\n" + json.dumps(json_response, indent=2) except (AttributeError, KeyError, ValueError): # not JSON = ValueError try: - description += self.response.text + description += " " + self.response.text except AttributeError: pass return description diff --git a/djrill/mail/backends/djrill.py b/djrill/mail/backends/djrill.py index 12b8c5a..3913773 100644 --- a/djrill/mail/backends/djrill.py +++ b/djrill/mail/backends/djrill.py @@ -12,30 +12,10 @@ from django.core.mail.backends.base import BaseEmailBackend from django.core.mail.message import sanitize_address, DEFAULT_ATTACHMENT_MIME_TYPE from ..._version import __version__ -from ...exceptions import (MandrillAPIError, MandrillRecipientsRefused, +from ...exceptions import (DjrillError, MandrillAPIError, MandrillRecipientsRefused, NotSerializableForMandrillError, NotSupportedByMandrillError) -def encode_date_for_mandrill(dt): - """Format a date or datetime for use as a Mandrill API date field - - datetime becomes "YYYY-MM-DD HH:MM:SS" - converted to UTC, if timezone-aware - microseconds removed - date becomes "YYYY-MM-DD 00:00:00" - anything else gets returned intact - """ - if isinstance(dt, datetime): - dt = dt.replace(microsecond=0) - if dt.utcoffset() is not None: - dt = (dt - dt.utcoffset()).replace(tzinfo=None) - return dt.isoformat(' ') - elif isinstance(dt, date): - return dt.isoformat() + ' 00:00:00' - else: - return dt - - class DjrillBackend(BaseEmailBackend): """ Mandrill API Email Backend @@ -63,9 +43,6 @@ class DjrillBackend(BaseEmailBackend): raise ImproperlyConfigured("You have not set your mandrill api key " "in the settings.py file.") - self.api_send = self.api_url + "/messages/send.json" - self.api_send_template = self.api_url + "/messages/send-template.json" - def open(self): """ Ensure we have a requests Session to connect to the Mandrill API. @@ -127,72 +104,133 @@ class DjrillBackend(BaseEmailBackend): def _send(self, message): message.mandrill_response = None # until we have a response - if not message.recipients(): return False - api_url = self.api_send - api_params = { - "key": self.api_key, - } - try: - msg_dict = self._build_standard_message_dict(message) - self._add_mandrill_options(message, msg_dict) - if getattr(message, 'alternatives', None): - self._add_alternatives(message, msg_dict) - self._add_attachments(message, msg_dict) - api_params['message'] = msg_dict + payload = self.get_base_payload() + self.build_send_payload(payload, message) + response = self.post_to_mandrill(payload, message) - # check if template is set in message to send it via - # api url: /messages/send-template.json - if hasattr(message, 'template_name'): - api_url = self.api_send_template - api_params['template_name'] = message.template_name - api_params['template_content'] = \ - self._expand_merge_vars(getattr(message, 'template_content', {})) + # add the response from mandrill to the EmailMessage so callers can inspect it + message.mandrill_response = self.parse_response(response, payload, message) + self.validate_response(message.mandrill_response, response, payload, message) - self._add_mandrill_toplevel_options(message, api_params) - - except NotSupportedByMandrillError: + except DjrillError: + # every *expected* error is derived from DjrillError; + # we deliberately don't silence unexpected errors if not self.fail_silently: raise return False + return True + + def get_base_payload(self): + """Return non-message-dependent payload for Mandrill send call + + (The return value will be modified for the send, so must be a copy + of any shared state.) + """ + payload = { + "key": self.api_key, + } + return payload + + def build_send_payload(self, payload, message): + """Modify payload to add all message-specific options for Mandrill send call. + + payload is a dict that will become the Mandrill send data + message is an EmailMessage, possibly with additional Mandrill-specific attrs + + Can raise NotSupportedByMandrillError for unsupported options in message. + """ + msg_dict = self._build_standard_message_dict(message) + self._add_mandrill_options(message, msg_dict) + if getattr(message, 'alternatives', None): + self._add_alternatives(message, msg_dict) + self._add_attachments(message, msg_dict) + payload.setdefault('message', {}).update(msg_dict) + if hasattr(message, 'template_name'): + payload['template_name'] = message.template_name + payload['template_content'] = \ + self._expand_merge_vars(getattr(message, 'template_content', {})) + self._add_mandrill_toplevel_options(message, payload) + + def get_api_url(self, payload, message): + """Return the correct Mandrill API url for sending payload + + Override this to substitute your own logic for determining API endpoint. + """ + if 'template_name' in payload: + return self.api_url + "/messages/send-template.json" + else: + return self.api_url + "/messages/send.json" + + def serialize_payload(self, payload, message): + """Return payload serialized to a json str. + + Override this to substitute your own JSON serializer (e.g., to handle dates) + """ + return json.dumps(payload) + + def post_to_mandrill(self, payload, message): + """Post payload to correct Mandrill send API endpoint, and return the response. + + payload is a dict to use as Mandrill send data + message is the original EmailMessage + return should be a requests.Response + + Can raise NotSerializableForMandrillError if payload is not serializable + Can raise MandrillAPIError for HTTP errors in the post + """ + api_url = self.get_api_url(payload, message) try: - api_data = json.dumps(api_params) + json_payload = self.serialize_payload(payload, message) except TypeError as err: - if self.fail_silently: - return False # Add some context to the "not JSON serializable" message - raise NotSerializableForMandrillError(orig_err=err) - - response = self.session.post(api_url, data=api_data) + raise NotSerializableForMandrillError( + orig_err=err, email_message=message, payload=payload) + response = self.session.post(api_url, data=json_payload) if response.status_code != 200: - if not self.fail_silently: - raise MandrillAPIError(payload=api_params, response=response) - return False + raise MandrillAPIError(email_message=message, payload=payload, response=response) + return response - # add the response from mandrill to the EmailMessage so callers can inspect it + def parse_response(self, response, payload, message): + """Return parsed json from Mandrill API response + + Can raise MandrillAPIError if response is not valid JSON + """ try: - message.mandrill_response = response.json() - recipient_status = [item["status"] for item in message.mandrill_response] - except (ValueError, KeyError): - if not self.fail_silently: - raise MandrillAPIError("Error parsing Mandrill API response", - payload=api_params, response=response) - return False + return response.json() + except ValueError: + raise MandrillAPIError("Invalid JSON in Mandrill API response", + email_message=message, payload=payload, response=response) + def validate_response(self, parsed_response, response, payload, message): + """Validate parsed_response, raising exceptions for any problems. + + Extend this to provide your own validation checks. + Validation exceptions should inherit from djrill.exceptions.DjrillException + for proper fail_silently behavior. + + The base version here checks for invalid or refused recipients. + """ + if self.ignore_recipient_status: + return + try: + recipient_status = [item["status"] for item in parsed_response] + except (KeyError, TypeError): + raise MandrillAPIError("Invalid Mandrill API response format", + email_message=message, payload=payload, response=response) # Error if *all* recipients are invalid or refused # (This behavior parallels smtplib.SMTPRecipientsRefused from Django's SMTP EmailBackend) - if (not self.ignore_recipient_status and - all([status in ('invalid', 'rejected') for status in recipient_status])): - if not self.fail_silently: - raise MandrillRecipientsRefused(payload=api_params, response=response) - return False + if all([status in ('invalid', 'rejected') for status in recipient_status]): + raise MandrillRecipientsRefused(email_message=message, payload=payload, response=response) - return True + # + # Payload construction + # def _build_standard_message_dict(self, message): """Create a Mandrill send message struct from a Django EmailMessage. @@ -251,8 +289,7 @@ class DjrillBackend(BaseEmailBackend): # Mandrill attributes that require conversion: if hasattr(message, 'send_at'): - api_params['send_at'] = encode_date_for_mandrill(message.send_at) - + api_params['send_at'] = self.encode_date_for_mandrill(message.send_at) def _make_mandrill_to_list(self, message, recipients, recipient_type="to"): """Create a Mandrill 'to' field from a list of emails. @@ -335,14 +372,16 @@ class DjrillBackend(BaseEmailBackend): if len(message.alternatives) > 1: raise NotSupportedByMandrillError( "Too many alternatives attached to the message. " - "Mandrill only accepts plain text and html emails.") + "Mandrill only accepts plain text and html emails.", + email_message=message) (content, mimetype) = message.alternatives[0] if mimetype != 'text/html': raise NotSupportedByMandrillError( "Invalid alternative mimetype '%s'. " "Mandrill only accepts plain text and html emails." - % mimetype) + % mimetype, + email_message=message) msg_dict['html'] = content @@ -413,3 +452,23 @@ class DjrillBackend(BaseEmailBackend): 'content': content_b64.decode('ascii'), } return mandrill_attachment, is_embedded_image + + @classmethod + def encode_date_for_mandrill(cls, dt): + """Format a date or datetime for use as a Mandrill API date field + + datetime becomes "YYYY-MM-DD HH:MM:SS" + converted to UTC, if timezone-aware + microseconds removed + date becomes "YYYY-MM-DD 00:00:00" + anything else gets returned intact + """ + if isinstance(dt, datetime): + dt = dt.replace(microsecond=0) + if dt.utcoffset() is not None: + dt = (dt - dt.utcoffset()).replace(tzinfo=None) + return dt.isoformat(' ') + elif isinstance(dt, date): + return dt.isoformat() + ' 00:00:00' + else: + return dt diff --git a/docs/history.rst b/docs/history.rst index d91bdaa..9f58ff0 100644 --- a/docs/history.rst +++ b/docs/history.rst @@ -79,6 +79,14 @@ Removed DjrillBackendHTTPError This exception was deprecated in Djrill 0.3. Replace uses of it with :exc:`djrill.MandrillAPIError`. +Refactored Djrill backend and exceptions + Several internal details of ``djrill.mail.backends.DjrillBackend`` + and Djrill's exception classes have been significantly updated for 2.0. + The intent is to make it easier to maintain and extend the backend + (including creating your own subclasses to override Djrill's default + behavior). As a result, though, any existing code that depended on + undocumented Djrill internals may need to be updated. + Other Djrill 2.0 Changes ~~~~~~~~~~~~~~~~~~~~~~~~ From 5c39e40ea1b43e064390dc29ba0e5dd687a27938 Mon Sep 17 00:00:00 2001 From: medmunds Date: Wed, 2 Dec 2015 14:21:53 -0800 Subject: [PATCH 24/33] Use urljoin to build api endpoint (String arithmetic was probably OK for our limited use case, but in general is a bad idea for constructing urls.) --- djrill/mail/backends/djrill.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/djrill/mail/backends/djrill.py b/djrill/mail/backends/djrill.py index 3913773..233ef60 100644 --- a/djrill/mail/backends/djrill.py +++ b/djrill/mail/backends/djrill.py @@ -5,6 +5,10 @@ from base64 import b64encode from datetime import date, datetime from email.mime.base import MIMEBase from email.utils import parseaddr +try: + from urlparse import urljoin # python 2 +except ImportError: + from urllib.parse import urljoin # python 3 from django.conf import settings from django.core.exceptions import ImproperlyConfigured @@ -28,6 +32,9 @@ class DjrillBackend(BaseEmailBackend): super(DjrillBackend, self).__init__(**kwargs) self.api_key = getattr(settings, "MANDRILL_API_KEY", None) self.api_url = getattr(settings, "MANDRILL_API_URL", "https://mandrillapp.com/api/1.0") + if not self.api_url.endswith("/"): + self.api_url += "/" + self.session = None self.global_settings = {} for setting_key in getattr(settings, "MANDRILL_SETTINGS", {}): @@ -162,9 +169,10 @@ class DjrillBackend(BaseEmailBackend): Override this to substitute your own logic for determining API endpoint. """ if 'template_name' in payload: - return self.api_url + "/messages/send-template.json" + api_method = "messages/send-template.json" else: - return self.api_url + "/messages/send.json" + api_method = "messages/send.json" + return urljoin(self.api_url, api_method) def serialize_payload(self, payload, message): """Return payload serialized to a json str. From aa46fadb485f8507ea8fc6fbe87a617da64d49d8 Mon Sep 17 00:00:00 2001 From: medmunds Date: Wed, 2 Dec 2015 15:58:23 -0800 Subject: [PATCH 25/33] Clean up global MANDRILL_SETTINGS * Clean up Djrill backend __init__ * Fold MANDRILL_SUBACCOUNT into global_settings logic * Add some missing override tests * Update docs --- djrill/mail/backends/djrill.py | 41 +++++---- djrill/tests/test_mandrill_send.py | 102 ++++++++++++---------- djrill/tests/test_mandrill_subaccounts.py | 48 +++++----- docs/history.rst | 3 + docs/installation.rst | 43 ++++++--- docs/usage/sending_mail.rst | 25 ++---- 6 files changed, 141 insertions(+), 121 deletions(-) diff --git a/djrill/mail/backends/djrill.py b/djrill/mail/backends/djrill.py index 233ef60..a0942b5 100644 --- a/djrill/mail/backends/djrill.py +++ b/djrill/mail/backends/djrill.py @@ -26,29 +26,33 @@ class DjrillBackend(BaseEmailBackend): """ def __init__(self, **kwargs): - """ - Set the API key, API url and set the action url. - """ + """Init options from Django settings""" super(DjrillBackend, self).__init__(**kwargs) - self.api_key = getattr(settings, "MANDRILL_API_KEY", None) + + try: + self.api_key = settings.MANDRILL_API_KEY + except AttributeError: + raise ImproperlyConfigured("Set MANDRILL_API_KEY in settings.py to use Djrill") + self.api_url = getattr(settings, "MANDRILL_API_URL", "https://mandrillapp.com/api/1.0") if not self.api_url.endswith("/"): self.api_url += "/" - self.session = None self.global_settings = {} - for setting_key in getattr(settings, "MANDRILL_SETTINGS", {}): - if not isinstance(settings.MANDRILL_SETTINGS, dict): - raise ImproperlyConfigured("MANDRILL_SETTINGS must be a dict " - "in the settings.py file.") - self.global_settings[setting_key] = settings.MANDRILL_SETTINGS[setting_key] + try: + self.global_settings.update(settings.MANDRILL_SETTINGS) + except AttributeError: + pass # no MANDRILL_SETTINGS setting + except (TypeError, ValueError): # e.g., not enumerable + raise ImproperlyConfigured("MANDRILL_SETTINGS must be a dict or mapping") + + try: + self.global_settings["subaccount"] = settings.MANDRILL_SUBACCOUNT + except AttributeError: + pass # no MANDRILL_SUBACCOUNT setting - self.subaccount = getattr(settings, "MANDRILL_SUBACCOUNT", None) self.ignore_recipient_status = getattr(settings, "MANDRILL_IGNORE_RECIPIENT_STATUS", False) - - if not self.api_key: - raise ImproperlyConfigured("You have not set your mandrill api key " - "in the settings.py file.") + self.session = None def open(self): """ @@ -298,6 +302,7 @@ class DjrillBackend(BaseEmailBackend): # Mandrill attributes that require conversion: if hasattr(message, 'send_at'): api_params['send_at'] = self.encode_date_for_mandrill(message.send_at) + # setting send_at in global_settings wouldn't make much sense def _make_mandrill_to_list(self, message, recipients, recipient_type="to"): """Create a Mandrill 'to' field from a list of emails. @@ -324,9 +329,6 @@ class DjrillBackend(BaseEmailBackend): 'google_analytics_domains', 'google_analytics_campaign', 'metadata'] - if self.subaccount: - msg_dict['subaccount'] = self.subaccount - for attr in mandrill_attrs: if attr in self.global_settings: msg_dict[attr] = self.global_settings[attr] @@ -336,7 +338,8 @@ class DjrillBackend(BaseEmailBackend): # Allow simple python dicts in place of Mandrill # [{name:name, value:value},...] arrays... - # Allow merge of global and per message global_merge_var, the former taking precedent + # Merge global and per message global_merge_vars + # (in conflicts, per-message vars win) global_merge_vars = {} if 'global_merge_vars' in self.global_settings: global_merge_vars.update(self.global_settings['global_merge_vars']) diff --git a/djrill/tests/test_mandrill_send.py b/djrill/tests/test_mandrill_send.py index a767ad8..c6a7a5a 100644 --- a/djrill/tests/test_mandrill_send.py +++ b/djrill/tests/test_mandrill_send.py @@ -617,6 +617,7 @@ class DjrillRecipientsRefusedTests(DjrillBackendMockAPITestCase): 'tags': ['djrill'], 'preserve_recipients': True, 'view_content_link': True, + 'subaccount': 'example-subaccount', 'tracking_domain': 'example.com', 'signing_domain': 'example.com', 'return_path_domain': 'example.com', @@ -625,7 +626,7 @@ class DjrillRecipientsRefusedTests(DjrillBackendMockAPITestCase): 'metadata': ['djrill'], 'merge_language': 'mailchimp', 'global_merge_vars': {'TEST': 'djrill'}, - 'async': False, + 'async': True, 'ip_pool': 'Pool1', 'invalid': 'invalid', }) @@ -644,16 +645,17 @@ class DjrillMandrillGlobalFeatureTests(DjrillBackendMockAPITestCase): self.assert_mandrill_called("/messages/send.json") data = self.get_api_call_data() self.assertEqual(data['message']['from_name'], 'Djrill Test') - self.assertTrue(data['message']['important'], True) - self.assertTrue(data['message']['track_opens'], True) - self.assertTrue(data['message']['track_clicks'], True) - self.assertTrue(data['message']['auto_text'], True) - self.assertTrue(data['message']['auto_html'], True) - self.assertTrue(data['message']['inline_css'], True) - self.assertTrue(data['message']['url_strip_qs'], True) + self.assertTrue(data['message']['important']) + self.assertTrue(data['message']['track_opens']) + self.assertTrue(data['message']['track_clicks']) + self.assertTrue(data['message']['auto_text']) + self.assertTrue(data['message']['auto_html']) + self.assertTrue(data['message']['inline_css']) + self.assertTrue(data['message']['url_strip_qs']) self.assertEqual(data['message']['tags'], ['djrill']) - self.assertTrue(data['message']['preserve_recipients'], True) - self.assertTrue(data['message']['view_content_link'], True) + self.assertTrue(data['message']['preserve_recipients']) + self.assertTrue(data['message']['view_content_link']) + self.assertEqual(data['message']['subaccount'], 'example-subaccount') self.assertEqual(data['message']['tracking_domain'], 'example.com') self.assertEqual(data['message']['signing_domain'], 'example.com') self.assertEqual(data['message']['return_path_domain'], 'example.com') @@ -666,8 +668,7 @@ class DjrillMandrillGlobalFeatureTests(DjrillBackendMockAPITestCase): self.assertFalse('merge_vars' in data['message']) self.assertFalse('recipient_metadata' in data['message']) # Options at top level of api params (not in message dict): - self.assertFalse('send_at' in data) - self.assertEqual(data['async'], False) + self.assertTrue(data['async']) self.assertEqual(data['ip_pool'], 'Pool1') # Option that shouldn't be added self.assertFalse('invalid' in data['message']) @@ -675,44 +676,53 @@ class DjrillMandrillGlobalFeatureTests(DjrillBackendMockAPITestCase): def test_global_options_override(self): """Test that manually settings options overrides global settings """ - self.message.important = True - self.message.auto_text = True - self.message.auto_html = True - self.message.inline_css = True - self.message.preserve_recipients = True + self.message.from_name = "override" + self.message.important = False + self.message.track_opens = False + self.message.track_clicks = False + self.message.auto_text = False + self.message.auto_html = False + self.message.inline_css = False + self.message.url_strip_qs = False + self.message.tags = ['override'] + self.message.preserve_recipients = False self.message.view_content_link = False - self.message.tracking_domain = "click.example.com" - self.message.signing_domain = "example.com" - self.message.return_path_domain = "support.example.com" - self.message.subaccount = "marketing-dept" - self.message.async = True + self.message.subaccount = "override" + self.message.tracking_domain = "override.example.com" + self.message.signing_domain = "override.example.com" + self.message.return_path_domain = "override.example.com" + self.message.google_analytics_domains = ['override.example.com'] + self.message.google_analytics_campaign = ['UA-99999999-1'] + self.message.metadata = ['override'] + self.message.merge_language = 'handlebars' + self.message.async = False self.message.ip_pool = "Bulk Pool" self.message.send() data = self.get_api_call_data() - self.assertEqual(data['message']['important'], True) - self.assertEqual(data['message']['auto_text'], True) - self.assertEqual(data['message']['auto_html'], True) - self.assertEqual(data['message']['inline_css'], True) - self.assertEqual(data['message']['preserve_recipients'], True) - self.assertEqual(data['message']['view_content_link'], False) - self.assertEqual(data['message']['tracking_domain'], "click.example.com") - self.assertEqual(data['message']['signing_domain'], "example.com") - self.assertEqual(data['message']['return_path_domain'], "support.example.com") - self.assertEqual(data['message']['subaccount'], "marketing-dept") - self.assertEqual(data['async'], True) - self.assertEqual(data['ip_pool'], "Bulk Pool") - - def test_global_options_override_tracking(self): - """Test that manually settings options overrides global settings - """ - self.message.track_opens = False - self.message.track_clicks = False - self.message.url_strip_qs = False - self.message.send() - data = self.get_api_call_data() - self.assertEqual(data['message']['track_opens'], False) - self.assertEqual(data['message']['track_clicks'], False) - self.assertEqual(data['message']['url_strip_qs'], False) + self.assertEqual(data['message']['from_name'], 'override') + self.assertFalse(data['message']['important']) + self.assertFalse(data['message']['track_opens']) + self.assertFalse(data['message']['track_clicks']) + self.assertFalse(data['message']['auto_text']) + self.assertFalse(data['message']['auto_html']) + self.assertFalse(data['message']['inline_css']) + self.assertFalse(data['message']['url_strip_qs']) + self.assertEqual(data['message']['tags'], ['override']) + self.assertFalse(data['message']['preserve_recipients']) + self.assertFalse(data['message']['view_content_link']) + self.assertEqual(data['message']['subaccount'], 'override') + self.assertEqual(data['message']['tracking_domain'], 'override.example.com') + self.assertEqual(data['message']['signing_domain'], 'override.example.com') + self.assertEqual(data['message']['return_path_domain'], 'override.example.com') + self.assertEqual(data['message']['google_analytics_domains'], ['override.example.com']) + self.assertEqual(data['message']['google_analytics_campaign'], ['UA-99999999-1']) + self.assertEqual(data['message']['metadata'], ['override']) + self.assertEqual(data['message']['merge_language'], 'handlebars') + self.assertEqual(data['message']['global_merge_vars'], + [{'name': 'TEST', 'content': 'djrill'}]) + # Options at top level of api params (not in message dict): + self.assertFalse(data['async']) + self.assertEqual(data['ip_pool'], 'Bulk Pool') def test_global_merge(self): # Test that global settings merge in diff --git a/djrill/tests/test_mandrill_subaccounts.py b/djrill/tests/test_mandrill_subaccounts.py index 09f1e97..b6e7430 100644 --- a/djrill/tests/test_mandrill_subaccounts.py +++ b/djrill/tests/test_mandrill_subaccounts.py @@ -7,40 +7,36 @@ from .mock_backend import DjrillBackendMockAPITestCase class DjrillMandrillSubaccountTests(DjrillBackendMockAPITestCase): """Test Djrill backend support for Mandrill subaccounts""" - def test_send_basic(self): - mail.send_mail('Subject here', 'Here is the message.', - 'from@example.com', ['to@example.com'], fail_silently=False) - self.assert_mandrill_called("/messages/send.json") + def test_no_subaccount_by_default(self): + mail.send_mail('Subject', 'Body', 'from@example.com', ['to@example.com']) data = self.get_api_call_data() - self.assertEqual(data['message']['subject'], "Subject here") - self.assertEqual(data['message']['text'], "Here is the message.") - self.assertFalse('from_name' in data['message']) - self.assertEqual(data['message']['from_email'], "from@example.com") - self.assertEqual(len(data['message']['to']), 1) - self.assertEqual(data['message']['to'][0]['email'], "to@example.com") self.assertFalse('subaccount' in data['message']) - @override_settings(MANDRILL_SUBACCOUNT="test_subaccount") - def test_send_from_subaccount(self): - mail.send_mail('Subject here', 'Here is the message.', - 'from@example.com', ['to@example.com'], fail_silently=False) - self.assert_mandrill_called("/messages/send.json") + @override_settings(MANDRILL_SETTINGS={'subaccount': 'test_subaccount'}) + def test_subaccount_setting(self): + mail.send_mail('Subject', 'Body', 'from@example.com', ['to@example.com']) data = self.get_api_call_data() - self.assertEqual(data['message']['subject'], "Subject here") - self.assertEqual(data['message']['text'], "Here is the message.") - self.assertFalse('from_name' in data['message']) - self.assertEqual(data['message']['from_email'], "from@example.com") - self.assertEqual(len(data['message']['to']), 1) - self.assertEqual(data['message']['to'][0]['email'], "to@example.com") self.assertEqual(data['message']['subaccount'], "test_subaccount") - @override_settings(MANDRILL_SUBACCOUNT="global_setting_subaccount") + @override_settings(MANDRILL_SETTINGS={'subaccount': 'global_setting_subaccount'}) def test_subaccount_message_overrides_setting(self): - message = mail.EmailMessage( - 'Subject here', 'Here is the message', - 'from@example.com', ['to@example.com']) + message = mail.EmailMessage('Subject', 'Body', 'from@example.com', ['to@example.com']) message.subaccount = "individual_message_subaccount" # should override global setting message.send() - self.assert_mandrill_called("/messages/send.json") + data = self.get_api_call_data() + self.assertEqual(data['message']['subaccount'], "individual_message_subaccount") + + # Djrill 1.x offered dedicated MANDRILL_SUBACCOUNT setting. + # In Djrill 2.x, you should use the MANDRILL_SETTINGS dict as in the earlier tests. + # But we still support the old setting for compatibility: + @override_settings(MANDRILL_SUBACCOUNT="legacy_setting_subaccount") + def test_subaccount_legacy_setting(self): + mail.send_mail('Subject', 'Body', 'from@example.com', ['to@example.com']) + data = self.get_api_call_data() + self.assertEqual(data['message']['subaccount'], "legacy_setting_subaccount") + + message = mail.EmailMessage('Subject', 'Body', 'from@example.com', ['to@example.com']) + message.subaccount = "individual_message_subaccount" # should override legacy setting + message.send() data = self.get_api_call_data() self.assertEqual(data['message']['subaccount'], "individual_message_subaccount") diff --git a/docs/history.rst b/docs/history.rst index 9f58ff0..f07f0b0 100644 --- a/docs/history.rst +++ b/docs/history.rst @@ -96,6 +96,9 @@ Other Djrill 2.0 Changes (You can also directly manage your own long-lived Djrill connection across multiple sends, by calling open and close on :ref:`Django's email backend `.) +* Add global :setting:`MANDRILL_SETTINGS` dict that can provide defaults + for most Djrill message options. + * Add :exc:`djrill.NotSerializableForMandrillError` diff --git a/docs/installation.rst b/docs/installation.rst index 5aaf15b..e639f72 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -61,21 +61,42 @@ on invalid or rejected recipients. (Default ``False``.) .. versionadded:: 2.0 +.. setting:: MANDRILL_SETTINGS + +MANDRILL_SETTINGS +~~~~~~~~~~~~~~~~~ + +You can supply global default options to apply to all messages sent through Djrill. +Set :setting:`!MANDRILL_SETTINGS` to a dict of these options. Example:: + + MANDRILL_SETTINGS = { + 'subaccount': 'client-347', + 'tracking_domain': 'example.com', + 'track_opens': True, + } + +See :ref:`mandrill-send-support` for a list of available options. (Everything +*except* :attr:`merge_vars`, :attr:`recipient_metadata`, and :attr:`send_at` +can be used with :setting:`!MANDRILL_SETTINGS`.) + +Attributes set on individual EmailMessage objects will override the global +:setting:`!MANDRILL_SETTINGS` for that message. :attr:`global_merge_vars` +on an EmailMessage will be merged with any ``global_merge_vars`` in +:setting:`!MANDRILL_SETTINGS` (with the ones on the EmailMessage taking +precedence if there are conflicting var names). + +.. versionadded:: 2.0 + + .. setting:: MANDRILL_SUBACCOUNT MANDRILL_SUBACCOUNT ~~~~~~~~~~~~~~~~~~~ -If you are using Mandrill's `subaccounts`_ feature, you can globally set the -subaccount for all messages sent through Djrill:: - - MANDRILL_SUBACCOUNT = "client-347" - -(You can also set or override the :attr:`subaccount` on each individual message, -with :ref:`Mandrill-specific sending options `.) - -.. versionadded:: 1.0 - MANDRILL_SUBACCOUNT global setting - +Prior to Djrill 2.0, the :setting:`!MANDRILL_SUBACCOUNT` setting could +be used to globally set the `Mandrill subaccount `_. +Although this is still supported for compatibility with existing code, +new code should set a global subaccount in :setting:`MANDRILL_SETTINGS` +as shown above. .. _subaccounts: http://help.mandrill.com/entries/25523278-What-are-subaccounts- diff --git a/docs/usage/sending_mail.rst b/docs/usage/sending_mail.rst index 18cf66a..264f237 100644 --- a/docs/usage/sending_mail.rst +++ b/docs/usage/sending_mail.rst @@ -103,24 +103,17 @@ Some notes and limitations: Mandrill-Specific Options ------------------------- -.. setting:: MANDRILL_SETTINGS - Most of the options from the Mandrill `messages/send API `_ `message` struct can be set directly on an :class:`~django.core.mail.EmailMessage` -(or subclass) object: - -Most of these options can be globally set in your project's :file:`settings.py` -using :setting:`MANDRILL_SETTINGS`. For Example:: - - MANDRILL_SETTINGS = { - 'tracking_domain': 'example.com', - 'track_opens': True, - } +(or subclass) object. .. note:: - ``merge_vars`` and ``recipient_metadata`` cannot be set globally. ``global_merge_vars`` is merged - (see :attribute:`global_merge_vars`) + + You can set global defaults for common options with the + :setting:`MANDRILL_SETTINGS` setting, to avoid having to + set them on every message. + .. These attributes are in the same order as they appear in the Mandrill API docs... @@ -215,10 +208,6 @@ using :setting:`MANDRILL_SETTINGS`. For Example:: Merge data must be strings or other JSON-serializable types. (See :ref:`formatting-merge-data` for details.) - .. note:: - - If using :setting:`MANDRILL_SETTINGS` then the message ``dict`` will be merged and overwrite any duplicates. - .. attribute:: merge_vars ``dict``: per-recipient merge variables (most useful with :ref:`mandrill-templates`). The keys @@ -244,8 +233,6 @@ using :setting:`MANDRILL_SETTINGS`. For Example:: .. attribute:: subaccount ``str``: the ID of one of your subaccounts to use for sending this message. - (The subaccount on an individual message will override any global - :setting:`MANDRILL_SUBACCOUNT` setting.) .. versionadded:: 0.7 From 9971c1780ffea54829cc0f877d7687eca172a62f Mon Sep 17 00:00:00 2001 From: medmunds Date: Wed, 2 Dec 2015 16:10:29 -0800 Subject: [PATCH 26/33] Document MANDRILL_API_URL --- docs/installation.rst | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/docs/installation.rst b/docs/installation.rst index e639f72..ec0b0f2 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -50,6 +50,9 @@ See the Djrill :ref:`webhooks ` section for configuration details. Other Optional Settings ----------------------- +You can optionally add any of these Djrill settings to your :file:`settings.py`. + + .. setting:: MANDRILL_IGNORE_RECIPIENT_STATUS MANDRILL_IGNORE_RECIPIENT_STATUS @@ -88,6 +91,18 @@ precedence if there are conflicting var names). .. versionadded:: 2.0 +.. setting:: MANDRILL_API_URL + +MANDRILL_API_URL +~~~~~~~~~~~~~~~~ + +The base url for calling the Mandrill API. The default is +``MANDRILL_API_URL = "https://mandrillapp.com/api/1.0"``, +which is the secure, production version of Mandrill's 1.0 API. + +(It's unlikely you would need to change this.) + + .. setting:: MANDRILL_SUBACCOUNT MANDRILL_SUBACCOUNT From d82d425aa888785ae45b54b42a1bc9246e285d27 Mon Sep 17 00:00:00 2001 From: medmunds Date: Wed, 2 Dec 2015 16:18:57 -0800 Subject: [PATCH 27/33] Clean up mandrill_reponse docs Document mandrill_response as an attribute (not just a generic topic). --- docs/usage/sending_mail.rst | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/docs/usage/sending_mail.rst b/docs/usage/sending_mail.rst index 264f237..7ccb26d 100644 --- a/docs/usage/sending_mail.rst +++ b/docs/usage/sending_mail.rst @@ -300,13 +300,15 @@ see :class:`DjrillMandrillFeatureTests` in :file:`tests/test_mandrill_send.py` f .. _mandrill-response: -Mandrill Response ------------------ +Response from Mandrill +---------------------- -A ``mandrill_response`` property is added to each :class:`~django.core.mail.EmailMessage` that you -send. This allows you to retrieve message ids, initial status information and more. +.. attribute:: mandrill_response -For an EmailMessage that is successfully sent to one or more email addresses, ``mandrill_response`` will +Djrill adds a :attr:`!mandrill_response` attribute to each :class:`~django.core.mail.EmailMessage` +as it sends it. This allows you to retrieve message ids, initial status information and more. + +For an EmailMessage that is successfully sent to one or more email addresses, :attr:`!mandrill_response` will be set to a ``list`` of ``dict``, where each entry has info for one email address. See the Mandrill docs for the `messages/send API `_ for full details. @@ -328,7 +330,7 @@ For this example, msg.mandrill_response might look like this:: } ] -If an error is returned by Mandrill while sending the message then ``mandrill_response`` will be set to None. +If an error is returned by Mandrill while sending the message then :attr:`!mandrill_response` will be set to None. .. versionadded:: 0.8 mandrill_response available for sent messages @@ -354,7 +356,7 @@ Exceptions If *all* recipients (to, cc, bcc) of a message are invalid or rejected by Mandrill (e.g., because they are your Mandrill blacklist), the send call will raise a :exc:`~!djrill.MandrillRecipientsRefused` exception. - You can examine the message's :ref:`mandrill_response property ` + You can examine the message's :attr:`mandrill_response` attribute to determine the cause of the error. If a single message is sent to multiple recipients, and *any* recipient is valid From 02641b09598ec2331065ba963b642d3c90f7c5ab Mon Sep 17 00:00:00 2001 From: medmunds Date: Wed, 2 Dec 2015 16:52:42 -0800 Subject: [PATCH 28/33] Improve send_at docs * Try to clear up timezones * Note requirement for funded account --- docs/usage/sending_mail.rst | 37 +++++++++++++++++++++++++++++++++---- 1 file changed, 33 insertions(+), 4 deletions(-) diff --git a/docs/usage/sending_mail.rst b/docs/usage/sending_mail.rst index 7ccb26d..e7233fd 100644 --- a/docs/usage/sending_mail.rst +++ b/docs/usage/sending_mail.rst @@ -282,14 +282,43 @@ Most of the options from the Mandrill .. attribute:: send_at - ``datetime`` or ``date`` or ``str``: instructs Mandrill to delay sending this message - until the specified time. (Djrill allows timezone-aware Python datetimes, and converts them - to UTC for Mandrill. Timezone-naive datetimes are assumed to be UTC.) + `datetime` or `date` or ``str``: instructs Mandrill to delay sending this message + until the specified time. Example:: + + msg.send_at = datetime.utcnow() + timedelta(hours=1) + + Mandrill requires a UTC string in the form ``YYYY-MM-DD HH:MM:SS``. + Djrill will convert python dates and datetimes to this form. + (Dates will be given a time of 00:00:00.) + + .. note:: Timezones + + Mandrill assumes :attr:`!send_at` is in the UTC timezone, + which is likely *not* the same as your local time. + + Djrill will convert timezone-*aware* datetimes to UTC for you. + But if you format your own string, supply a date, or a + *naive* datetime, you must make sure it is in UTC. + See the python `datetime` docs for more information. + + For example, ``msg.send_at = datetime.now() + timedelta(hours=1)`` + will try to schedule the message for an hour from the current time, + but *interpreted in the UTC timezone* (which isn't what you want). + If you're more than an hour west of the prime meridian, that will + be in the past (and the message will get sent immediately). If + you're east of there, the message might get sent quite a bit later + than you intended. One solution is to use `utcnow` as shown in + the earlier example. + + .. note:: + + Scheduled sending is a paid Mandrill feature. If you are using + a free Mandrill account, :attr:`!send_at` won't work. .. versionadded:: 0.7 -These Mandrill-specific properties work with *any* +All the Mandrill-specific attributes listed above work with *any* :class:`~django.core.mail.EmailMessage`-derived object, so you can use them with many other apps that add Django mail functionality. From 265c7460e0c21f17e9ee9fd3d138c1807ad1ae75 Mon Sep 17 00:00:00 2001 From: medmunds Date: Wed, 2 Dec 2015 16:59:34 -0800 Subject: [PATCH 29/33] Support Django 1.9 release --- .travis.yml | 12 +++++++----- README.rst | 2 +- docs/history.rst | 2 ++ setup.py | 1 + 4 files changed, 11 insertions(+), 6 deletions(-) diff --git a/.travis.yml b/.travis.yml index 820aa36..ed10cd2 100644 --- a/.travis.yml +++ b/.travis.yml @@ -20,11 +20,13 @@ matrix: - { env: DJANGO=django==1.8, python: 2.7 } - { env: DJANGO=django==1.8, python: 3.4 } - { env: DJANGO=django==1.8, python: pypy } - # Django 1.9 (prerelease): "Python 2.7, 3.4, or 3.5" - - { env: DJANGO="--pre django", python: 2.7 } - - { env: DJANGO="--pre django", python: 3.4 } - - { env: DJANGO="--pre django", python: 3.5 } - - { env: DJANGO="--pre django", python: pypy } + # Django 1.9: "Python 2.7, 3.4, or 3.5" + - { env: DJANGO=django==1.9, python: 2.7 } + - { env: DJANGO=django==1.9, python: 3.4 } + - { env: DJANGO=django==1.9, python: 3.5 } + - { env: DJANGO=django==1.9, python: pypy } + # Django 1.10 (prerelease) + #- { env: DJANGO="--pre django", python: 3.5 } cache: directories: - $HOME/.cache/pip diff --git a/README.rst b/README.rst index a1ab707..b132c0c 100644 --- a/README.rst +++ b/README.rst @@ -29,7 +29,7 @@ package. It includes: * Optional support for Mandrill inbound email and other webhook notifications, via Django signals -Djrill is released under the BSD license. It is tested against Django 1.4--1.8 +Djrill is released under the BSD license. It is tested against Django 1.4--1.9 (including Python 3 with Django 1.6+, and PyPy support with Django 1.5+). Djrill uses `semantic versioning `_. diff --git a/docs/history.rst b/docs/history.rst index f07f0b0..21f6139 100644 --- a/docs/history.rst +++ b/docs/history.rst @@ -91,6 +91,8 @@ Refactored Djrill backend and exceptions Other Djrill 2.0 Changes ~~~~~~~~~~~~~~~~~~~~~~~~ +* Add Django 1.9 support; drop Django 1.3, Python 2.6, and Python 3.2 + * Use a single HTTP connection to the Mandrill API to improve performance when sending multiple messages at once using :func:`~django.core.mail.send_mass_mail`. (You can also directly manage your own long-lived Djrill connection across multiple sends, diff --git a/setup.py b/setup.py index 43ae67e..5c83549 100644 --- a/setup.py +++ b/setup.py @@ -41,6 +41,7 @@ setup( "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.3", "Programming Language :: Python :: 3.4", + "Programming Language :: Python :: 3.5", "License :: OSI Approved :: BSD License", "Topic :: Software Development :: Libraries :: Python Modules", "Framework :: Django", From d496555813f0af008d38bb6225926d7058623ad8 Mon Sep 17 00:00:00 2001 From: medmunds Date: Wed, 2 Dec 2015 17:06:15 -0800 Subject: [PATCH 30/33] Docs: remove pre-2.0 versionadded/versionchanged (Cut the old-version clutter) --- docs/usage/sending_mail.rst | 39 +------------------------------------ docs/usage/templates.rst | 8 -------- docs/usage/webhooks.rst | 4 ---- 3 files changed, 1 insertion(+), 50 deletions(-) diff --git a/docs/usage/sending_mail.rst b/docs/usage/sending_mail.rst index e7233fd..cc57f6e 100644 --- a/docs/usage/sending_mail.rst +++ b/docs/usage/sending_mail.rst @@ -32,10 +32,6 @@ Some notes and limitations: to `!True` if you want recipients to be able to see who else was included in the "to" list. - .. versionchanged:: 0.9 - Previously, Djrill (and Mandrill) didn't distinguish "cc" from "to", - and allowed only a single "bcc" recipient. - .. _sending-html: @@ -70,12 +66,6 @@ Some notes and limitations: (For an example, see :meth:`~DjrillBackendTests.test_embedded_images` in :file:`tests/test_mandrill_send.py`.) - .. versionadded:: 0.3 - Attachments - - .. versionchanged:: 0.4 - Special handling for embedded images - .. _message-headers: **Headers** @@ -87,11 +77,8 @@ Some notes and limitations: headers={'Reply-To': "reply@example.com", 'List-Unsubscribe': "..."} ) - .. versionchanged:: 0.9 - In earlier versions, Djrill only allowed ``Reply-To`` and ``X-*`` headers, - matching previous Mandrill API restrictions. + .. note:: - .. versionchanged:: 1.4 Djrill also supports the `reply_to` param added to :class:`~django.core.mail.EmailMessage` in Django 1.8. (If you provide *both* a 'Reply-To' header and the `reply_to` param, @@ -121,8 +108,6 @@ Most of the options from the Mandrill ``Boolean``: whether Mandrill should send this message ahead of non-important ones. - .. versionadded:: 0.7 - .. attribute:: track_opens ``Boolean``: whether Mandrill should enable open-tracking for this message. @@ -156,8 +141,6 @@ Most of the options from the Mandrill ``Boolean``: whether Mandrill should inline CSS styles in the HTML. Default from your Mandrill account settings. - .. versionadded:: 0.4 - .. attribute:: url_strip_qs ``Boolean``: whether Mandrill should ignore any query parameters when aggregating @@ -172,8 +155,6 @@ Most of the options from the Mandrill ``Boolean``: set False on sensitive messages to instruct Mandrill not to log the content. - .. versionadded:: 0.7 - .. attribute:: tracking_domain ``str``: domain Mandrill should use to rewrite tracked links and host tracking pixels @@ -190,15 +171,11 @@ Most of the options from the Mandrill ``str``: domain Mandrill should use for the message's return-path. - .. versionadded:: 0.7 - .. attribute:: merge_language ``str``: the merge tag language if using merge tags -- e.g., "mailchimp" or "handlebars". Default from your Mandrill account settings. - .. versionadded:: 1.3 - .. attribute:: global_merge_vars ``dict``: merge variables to use for all recipients (most useful with :ref:`mandrill-templates`). :: @@ -234,8 +211,6 @@ Most of the options from the Mandrill ``str``: the ID of one of your subaccounts to use for sending this message. - .. versionadded:: 0.7 - .. attribute:: google_analytics_domains ``list`` of ``str``: domain names for links where Mandrill should add Google Analytics @@ -272,14 +247,10 @@ Most of the options from the Mandrill ``Boolean``: whether Mandrill should use an async mode optimized for bulk sending. - .. versionadded:: 0.7 - .. attribute:: ip_pool ``str``: name of one of your Mandrill dedicated IP pools to use for sending this message. - .. versionadded:: 0.7 - .. attribute:: send_at `datetime` or `date` or ``str``: instructs Mandrill to delay sending this message @@ -315,8 +286,6 @@ Most of the options from the Mandrill Scheduled sending is a paid Mandrill feature. If you are using a free Mandrill account, :attr:`!send_at` won't work. - .. versionadded:: 0.7 - All the Mandrill-specific attributes listed above work with *any* :class:`~django.core.mail.EmailMessage`-derived object, so you can use them with @@ -361,18 +330,12 @@ For this example, msg.mandrill_response might look like this:: If an error is returned by Mandrill while sending the message then :attr:`!mandrill_response` will be set to None. -.. versionadded:: 0.8 - mandrill_response available for sent messages - .. _djrill-exceptions: Exceptions ---------- -.. versionadded:: 0.3 - Djrill-specific exceptions - .. exception:: djrill.NotSupportedByMandrillError If the email tries to use features that aren't supported by Mandrill, the send diff --git a/docs/usage/templates.rst b/docs/usage/templates.rst index 71f76b0..ddc3f39 100644 --- a/docs/usage/templates.rst +++ b/docs/usage/templates.rst @@ -6,9 +6,6 @@ Sending Template Mail Mandrill Templates ------------------ -.. versionadded:: 0.3 - Mandrill template support - To use a *Mandrill* (MailChimp) template stored in your Mandrill account, set a :attr:`template_name` and (optionally) :attr:`template_content` on your :class:`~django.core.mail.EmailMessage` object:: @@ -96,16 +93,11 @@ your :class:`~django.core.mail.EmailMessage` object:: If `True`, Djrill will omit the subject, and Mandrill will use the default subject from the template. - .. versionadded:: 1.1 - .. attribute:: use_template_from If `True`, Djrill will omit the "from" field, and Mandrill will use the default "from" from the template. - .. versionadded:: 1.1 - - .. _django-templates: diff --git a/docs/usage/webhooks.rst b/docs/usage/webhooks.rst index 3f3e94e..3e0f6bc 100644 --- a/docs/usage/webhooks.rst +++ b/docs/usage/webhooks.rst @@ -11,10 +11,6 @@ Djrill includes optional support for Mandrill's webhook notifications. If enabled, it will send a Django signal for each event in a webhook. Your code can connect to this signal for further processing. -.. versionadded:: 0.5 - Webhook support - - .. warning:: Webhook Security Webhooks are ordinary urls---they're wide open to the internet. From c625b6f12f4a678ac853d010e231ea504ff548db Mon Sep 17 00:00:00 2001 From: medmunds Date: Wed, 2 Dec 2015 17:22:02 -0800 Subject: [PATCH 31/33] Add .editorconfig and CONTRIBUTING.md --- .editorconfig | 24 ++++++++++++++++++++++++ CONTRIBUTING.md | 4 ++++ 2 files changed, 28 insertions(+) create mode 100644 .editorconfig create mode 100644 CONTRIBUTING.md diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..6757d2b --- /dev/null +++ b/.editorconfig @@ -0,0 +1,24 @@ +# http://editorconfig.org + +root = true + +# Follow Django conventions for most files +[*] +charset = utf-8 +end_of_line = lf +indent_size = 4 +indent_style = space +insert_final_newline = true +trim_trailing_whitespace = true + +# Use 2 spaces for js and text(-like) files +[*.{html,js,json,md,rst,txt,yml}] +indent_size = 2 + +# Makefiles always use tabs for indentation +[Makefile] +indent_style = tab + +# Batch files use tabs for indentation +[*.bat] +indent_style = tab diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..25d7e7b --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,4 @@ +Djrill is maintained by its users. Your contributions are encouraged! + +Please see [Contributing](https://djrill.readthedocs.org/en/latest/contributing/) +in the Djrill documentation for more information. From 7706a5d39fdb8b732478df148855793f3afe5f42 Mon Sep 17 00:00:00 2001 From: medmunds Date: Wed, 2 Dec 2015 18:25:26 -0800 Subject: [PATCH 32/33] Docs: 2.0 upgrade guide, release notes --- README.rst | 5 ++ docs/history.rst | 106 ++++++--------------------------------- docs/index.rst | 1 + docs/upgrading.rst | 121 +++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 143 insertions(+), 90 deletions(-) create mode 100644 docs/upgrading.rst diff --git a/README.rst b/README.rst index b132c0c..d9a9dac 100644 --- a/README.rst +++ b/README.rst @@ -20,6 +20,11 @@ Djrill: Mandrill Transactional Email for Django Djrill integrates the `Mandrill `_ transactional email service into Django. +**UPGRADING FROM DJRILL 1.x?** +There are some **breaking changes in Djrill 2.0**. Please see the +`upgrade instructions `_. + + In general, Djrill "just works" with Django's built-in `django.core.mail` package. It includes: diff --git a/docs/history.rst b/docs/history.rst index 21f6139..7ee51b3 100644 --- a/docs/history.rst +++ b/docs/history.rst @@ -1,3 +1,5 @@ +.. _history: + Release Notes ============= @@ -8,104 +10,28 @@ and breaking changes will always increment the major version number (1.x to 2.0). -Djrill 2.0 (in development) ---------------------------- +Djrill 2.x +---------- -Djrill 2.0 is under development and includes some breaking changes. -Although the changes won't impact most Djrill users, the previous -version of Djrill (1.4) tries to warn you if you use things -that will change. (Warnings appear in the console when running Django -in debug mode.) - - -Breaking Changes in Djrill 2.0 -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -Dropped support for Django 1.3, Python 2.6, and Python 3.2 - Although Djrill may still work with these older configurations, - we no longer test against them. Djrill now requires Django 1.4 - or later and Python 2.7, 3.3, or 3.4. - - If you require earlier support, Djrill 1.4 remains available. - -Removed DjrillAdminSite - Earlier versions of Djrill included a custom Django admin site. - The equivalent functionality is available in Mandrill's dashboard. - - You should remove any references to DjrillAdminSite from your - :file:`urls.py`. E.g.:: - - .. code-block:: python - - # Remove these: - from djrill import DjrillAdminSite - admin.site = DjrillAdminSite() - - Also, on Django 1.7 or later if you had switched your :setting:`INSTALLED_APPS` - (in :file:`settings.py`) to use ``'django.contrib.admin.apps.SimpleAdminConfig'`` - you *may* want to switch back to the default ``'django.contrib.admin'`` - and remove the call to ``admin.autodiscover()`` in your :file:`urls.py`. - (Do this only if you changed to SimpleAdminConfig for Djrill, and aren't - creating custom admin sites for any other Django apps you use.) - -Added exception for invalid or rejected recipients - Djrill 2.0 raises a new :exc:`djrill.MandrillRecipientsRefused` exception when - all recipients of a message invalid or rejected by Mandrill. This parallels - the behavior of Django's default :setting:`SMTP email backend `, - which raises :exc:`SMTPRecipientsRefused ` when - all recipients are refused. - - Your email-sending code should handle this exception (along with other - exceptions that could occur during a send). However, if you want to retain the - Djrill 1.x behavior and treat invalid or rejected recipients as successful sends, - you can set :setting:`MANDRILL_IGNORE_RECIPIENT_STATUS` to ``True`` in your settings.py. - -Removed unintended date-to-string conversion - If your code was relying on Djrill to automatically convert date or datetime - values to strings in :attr:`merge_vars`, :attr:`metadata`, or other Mandrill - message attributes, you must now explicitly do the string conversion - yourself. See :ref:`formatting-merge-data` for an explanation. - (Djrill 1.4 reported a DeprecationWarning for this case.) - - (The exception is :attr:`send_at`, which Djrill expects can be a date or - datetime.) - -Removed DjrillMessage class - The ``DjrillMessage`` class has not been needed since Djrill 0.2. - You should replace any uses of it with the standard - :class:`~django.core.mail.EmailMessage` class. - -Removed DjrillBackendHTTPError - This exception was deprecated in Djrill 0.3. Replace uses of it - with :exc:`djrill.MandrillAPIError`. - -Refactored Djrill backend and exceptions - Several internal details of ``djrill.mail.backends.DjrillBackend`` - and Djrill's exception classes have been significantly updated for 2.0. - The intent is to make it easier to maintain and extend the backend - (including creating your own subclasses to override Djrill's default - behavior). As a result, though, any existing code that depended on - undocumented Djrill internals may need to be updated. - - -Other Djrill 2.0 Changes -~~~~~~~~~~~~~~~~~~~~~~~~ - -* Add Django 1.9 support; drop Django 1.3, Python 2.6, and Python 3.2 +Version 2.0: +* **Breaking Changes:** please see the :ref:`upgrade guide `. +* Add Django 1.9 support; drop Django 1.3, Python 2.6, and Python 3.2 support +* Add global :setting:`MANDRILL_SETTINGS` dict that can provide defaults + for most Djrill message options. +* Add :exc:`djrill.NotSerializableForMandrillError` * Use a single HTTP connection to the Mandrill API to improve performance when sending multiple messages at once using :func:`~django.core.mail.send_mass_mail`. (You can also directly manage your own long-lived Djrill connection across multiple sends, by calling open and close on :ref:`Django's email backend `.) - -* Add global :setting:`MANDRILL_SETTINGS` dict that can provide defaults - for most Djrill message options. - -* Add :exc:`djrill.NotSerializableForMandrillError` +* Remove DjrillAdminSite +* Remove unintended date-to-string conversion in JSON encoding +* Remove obsolete DjrillMessage class and DjrillBackendHTTPError +* Refactor Djrill backend and exceptions -Older Releases --------------- +Djrill 1.x and Earlier +---------------------- Version 1.4: diff --git a/docs/index.rst b/docs/index.rst index 91d881a..782a358 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -23,6 +23,7 @@ Documentation quickstart installation + upgrading usage/sending_mail usage/templates usage/multiple_backends diff --git a/docs/upgrading.rst b/docs/upgrading.rst new file mode 100644 index 0000000..ce03b85 --- /dev/null +++ b/docs/upgrading.rst @@ -0,0 +1,121 @@ +.. _upgrading: + +Upgrading from 1.x +================== + +Djrill 2.0 includes some breaking changes from 1.x. +These changes should have minimal (or no) impact on most Djrill users, +but if you are upgrading please review the major topics below +to see if they apply to you. + +Djrill 1.4 tried to warn you if you were using Djrill features +expected to change in 2.0. If you are seeing any deprecation warnings +with Djrill 1.4, you should fix them before upgrading to 2.0. +(Warnings appear in the console when running Django in debug mode.) + +Please see the :ref:`release notes ` for a list of new features +and improvements in Djrill 2.0. + + +Dropped support for Django 1.3, Python 2.6, and Python 3.2 +---------------------------------------------------------- + +Although Djrill may still work with these older configurations, +we no longer test against them. Djrill now requires Django 1.4 +or later and Python 2.7, 3.3, or 3.4. + +If you require support for these earlier versions, you should +not upgrade to Djrill 2.0. Djrill 1.4 remains available on +pypi, and will continue to receive security fixes. + + +Removed DjrillAdminSite +----------------------- + +Earlier versions of Djrill included a custom Django admin site. +The equivalent functionality is available in Mandrill's dashboard, +and Djrill 2.0 drops support for it. + +Although most Djrill users were unaware the admin site existed, +many did follow the earlier versions' instructions to enable it. + +If you have added DjrillAdminSite, you will need to remove it for Djrill 2.0. + +In your :file:`urls.py`: + + .. code-block:: python + + from djrill import DjrillAdminSite # REMOVE this + admin.site = DjrillAdminSite() # REMOVE this + + admin.autodiscover() # REMOVE this if you added it only for Djrill + +In your :file:`settings.py`: + + .. code-block:: python + + INSTALLED_APPS = ( + ... + # If you added SimpleAdminConfig only for Djrill: + 'django.contrib.admin.apps.SimpleAdminConfig', # REMOVE this + 'django.contrib.admin', # ADD this default back + ... + ) + +(These instructions assume you had changed to SimpleAdminConfig +solely for DjrillAdminSite. If you are using it for custom admin +sites with any other Django apps you use, you should leave it +SimpleAdminConfig in place, but still remove the references to +DjrillAdminSite.) + + +Added exception for invalid or rejected recipients +-------------------------------------------------- + +Djrill 2.0 raises a new :exc:`djrill.MandrillRecipientsRefused` exception when +all recipients of a message are invalid or rejected by Mandrill. (This parallels +the behavior of Django's default :setting:`SMTP email backend `, +which raises :exc:`SMTPRecipientsRefused ` when +all recipients are refused.) + +Your email-sending code should handle this exception (along with other +exceptions that could occur during a send). However, if you want to retain the +Djrill 1.x behavior and treat invalid or rejected recipients as successful sends, +you can set :setting:`MANDRILL_IGNORE_RECIPIENT_STATUS` to ``True`` in your settings.py. + + +Other 2.0 breaking changes +-------------------------- + +Code that will be affected by these changes is far less common than +for the changes listed above, but they may impact some uses: + +Removed unintended date-to-string conversion + If your code was inadvertently relying on Djrill to automatically + convert date or datetime values to strings in :attr:`merge_vars`, + :attr:`metadata`, or other Mandrill message attributes, you must + now explicitly do the string conversion yourself. + See :ref:`formatting-merge-data` for an explanation. + (Djrill 1.4 reported a DeprecationWarning for this case.) + + (This does not affect :attr:`send_at`, where Djrill specifically + allows date or datetime values.) + +Removed DjrillMessage class + The ``DjrillMessage`` class has not been needed since Djrill 0.2. + You should replace any uses of it with the standard + :class:`~django.core.mail.EmailMessage` class. + (Djrill 1.4 reported a DeprecationWarning for this case.) + +Removed DjrillBackendHTTPError + This exception was deprecated in Djrill 0.3. Replace uses of it + with :exc:`djrill.MandrillAPIError`. + (Djrill 1.4 reported a DeprecationWarning for this case.) + +Refactored Djrill backend and exceptions + Several internal details of ``djrill.mail.backends.DjrillBackend`` + and Djrill's exception classes have been significantly updated for 2.0. + The intent is to make it easier to maintain and extend the backend + (including creating your own subclasses to override Djrill's default + behavior). As a result, though, any existing code that depended on + undocumented Djrill internals may need to be updated. From ed779875522f6d35fdb034f27ea82e5507f7eef8 Mon Sep 17 00:00:00 2001 From: medmunds Date: Wed, 2 Dec 2015 18:39:08 -0800 Subject: [PATCH 33/33] Docs: couple more release notes --- docs/history.rst | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/history.rst b/docs/history.rst index 7ee51b3..11175ac 100644 --- a/docs/history.rst +++ b/docs/history.rst @@ -18,12 +18,14 @@ Version 2.0: * **Breaking Changes:** please see the :ref:`upgrade guide `. * Add Django 1.9 support; drop Django 1.3, Python 2.6, and Python 3.2 support * Add global :setting:`MANDRILL_SETTINGS` dict that can provide defaults - for most Djrill message options. + for most Djrill message options * Add :exc:`djrill.NotSerializableForMandrillError` * Use a single HTTP connection to the Mandrill API to improve performance when sending multiple messages at once using :func:`~django.core.mail.send_mass_mail`. (You can also directly manage your own long-lived Djrill connection across multiple sends, by calling open and close on :ref:`Django's email backend `.) +* Add Djrill version to user-agent header when calling Mandrill API +* Improve diagnostics in exceptions from Djrill * Remove DjrillAdminSite * Remove unintended date-to-string conversion in JSON encoding * Remove obsolete DjrillMessage class and DjrillBackendHTTPError