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/.travis.yml b/.travis.yml index 338886c..ed10cd2 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,52 +1,38 @@ +sudo: false 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 - - 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 - # (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" - 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.2" - 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.2" - 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: "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 install: - pip install --upgrade setuptools pip - - pip install -q $DJANGO + - pip install $DJANGO - pip install . + - pip list script: python -Wall setup.py test 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/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. 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..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: @@ -28,9 +33,8 @@ 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 +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/djrill/__init__.py b/djrill/__init__.py index 92b5432..951d0eb 100644 --- a/djrill/__init__.py +++ b/djrill/__init__.py @@ -1,79 +1,3 @@ -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 ._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) +from ._version import __version__, VERSION +from .exceptions import (MandrillAPIError, MandrillRecipientsRefused, + NotSerializableForMandrillError, NotSupportedByMandrillError) 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/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/exceptions.py b/djrill/exceptions.py index c7913b8..3b16354 100644 --- a/djrill/exceptions.py +++ b/djrill/exceptions.py @@ -1,33 +1,89 @@ import json from requests import HTTPError -import warnings -class MandrillAPIError(HTTPError): - """Exception for unsuccessful response from Mandrill API.""" - def __init__(self, status_code, response=None, log_message=None, *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 +class DjrillError(Exception): + """Base class for exceptions raised by Djrill + + Overrides __str__ to provide additional information about + Mandrill API call and response. + """ + + 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 + self.response = kwargs.get('response', None) + else: + self.response = kwargs.pop('response', None) + super(DjrillError, self).__init__(*args, **kwargs) 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 + 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: + to_emails = [to['email'] for to in self.payload['message']['to']] + description += " to %s" % ','.join(to_emails) + except KeyError: + pass + 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() - message += "\nMandrill response:\n" + json.dumps(json_response, indent=2) + description += "\n" + json.dumps(json_response, indent=2) except (AttributeError, KeyError, ValueError): # not JSON = ValueError try: - message += "\nMandrill response: " + self.response.text + description += " " + self.response.text except AttributeError: pass - return message + return description -class NotSupportedByMandrillError(ValueError): +class MandrillAPIError(DjrillError, HTTPError): + """Exception for unsuccessful response from Mandrill API.""" + + def __init__(self, *args, **kwargs): + super(MandrillAPIError, self).__init__(*args, **kwargs) + if self.response is not None: + self.status_code = self.response.status_code + + +class MandrillRecipientsRefused(DjrillError): + """Exception for send where all recipients are invalid or rejected.""" + + 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) + + +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 @@ -43,9 +99,19 @@ class NotSupportedByMandrillError(ValueError): """ -class RemovedInDjrill2(DeprecationWarning): - """Functionality due for deprecation in Djrill 2.0""" +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). -def removed_in_djrill_2(message, stacklevel=1): - warnings.warn(message, category=RemovedInDjrill2, stacklevel=stacklevel + 1) + """ + # 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/__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/mail/backends/djrill.py b/djrill/mail/backends/djrill.py index bb9d459..a0942b5 100644 --- a/djrill/mail/backends/djrill.py +++ b/djrill/mail/backends/djrill.py @@ -1,53 +1,23 @@ +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 +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 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 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 - - -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 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) +from ..._version import __version__ +from ...exceptions import (DjrillError, MandrillAPIError, MandrillRecipientsRefused, + NotSerializableForMandrillError, NotSupportedByMandrillError) class DjrillBackend(BaseEmailBackend): @@ -56,103 +26,223 @@ 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) - self.api_url = MANDRILL_API_URL - self.subaccount = getattr(settings, "MANDRILL_SUBACCOUNT", None) + try: + self.api_key = settings.MANDRILL_API_KEY + except AttributeError: + raise ImproperlyConfigured("Set MANDRILL_API_KEY in settings.py to use Djrill") - if not self.api_key: - raise ImproperlyConfigured("You have not set your mandrill api key " - "in the settings.py file.") + self.api_url = getattr(settings, "MANDRILL_API_URL", "https://mandrillapp.com/api/1.0") + if not self.api_url.endswith("/"): + self.api_url += "/" - self.api_send = self.api_url + "/messages/send.json" - self.api_send_template = self.api_url + "/messages/send-template.json" + self.global_settings = {} + 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.ignore_recipient_status = getattr(settings, "MANDRILL_IGNORE_RECIPIENT_STATUS", False) + self.session = None + + def open(self): + """ + 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: + 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 - num_sent = 0 - for message in email_messages: - sent = self._send(message) + created_session = self.open() + if not self.session: + return 0 # exception in self.open with fail_silently - if sent: - num_sent += 1 + num_sent = 0 + try: + for message in email_messages: + sent = self._send(message) + if sent: + num_sent += 1 + finally: + if created_session: + self.close() return num_sent 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: + api_method = "messages/send-template.json" + else: + 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. + + 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, cls=JSONDateUTCEncoder) + json_payload = self.serialize_payload(payload, message) except TypeError as err: # 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 - - response = requests.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: + raise MandrillAPIError(email_message=message, payload=payload, response=response) + return response - # add a mandrill_response for the sake of being explicit - message.mandrill_response = None + def parse_response(self, response, payload, message): + """Return parsed json from Mandrill API response - 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) - return False + Can raise MandrillAPIError if response is not valid JSON + """ + try: + return response.json() + except ValueError: + raise MandrillAPIError("Invalid JSON in Mandrill API response", + email_message=message, payload=payload, response=response) - # add the response from mandrill to the EmailMessage so callers can inspect it - message.mandrill_response = response.json() + def validate_response(self, parsed_response, response, payload, message): + """Validate parsed_response, raising exceptions for any problems. - return True + 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 all([status in ('invalid', 'rejected') for status in recipient_status]): + raise MandrillRecipientsRefused(email_message=message, payload=payload, response=response) + + # + # Payload construction + # def _build_standard_message_dict(self, message): """Create a Mandrill send message struct from a Django EmailMessage. @@ -204,13 +294,15 @@ 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) # 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) + # 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. @@ -237,18 +329,26 @@ 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] if hasattr(message, attr): msg_dict[attr] = getattr(message, attr) # Allow simple python dicts in place of Mandrill # [{name:name, value:value},...] arrays... + + # 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']) 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'] = [ @@ -283,14 +383,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 @@ -343,6 +445,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) @@ -361,25 +464,22 @@ class DjrillBackend(BaseEmailBackend): } 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 -############################################################################################ -# 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__]) + 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/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..b7f0a7e 100644 --- a/djrill/tests/__init__.py +++ b/djrill/tests/__init__.py @@ -1,8 +1,6 @@ -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 * -from djrill.tests.test_mandrill_webhook import * -from djrill.tests.test_mandrill_subaccounts 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/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/mock_backend.py b/djrill/tests/mock_backend.py index e119db2..0239cbc 100644 --- a/djrill/tests/mock_backend.py +++ b/djrill/tests/mock_backend.py @@ -1,28 +1,35 @@ 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 -from .utils import BackportedAssertions, override_settings + +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", 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): """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=MANDRILL_SUCCESS_RESPONSE, encoding='utf-8'): super(DjrillBackendMockAPITestCase.MockResponse, self).__init__() self.status_code = status_code self.encoding = encoding 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() @@ -40,7 +47,7 @@ class DjrillBackendMockAPITestCase(TestCase, BackportedAssertions): 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): @@ -57,7 +64,7 @@ class DjrillBackendMockAPITestCase(TestCase, BackportedAssertions): 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) 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 deleted file mode 100644 index 2bcb68b..0000000 --- a/djrill/tests/test_legacy.py +++ /dev/null @@ -1,171 +0,0 @@ -# Tests deprecated Djrill features - -from datetime import date, datetime -import warnings - -from django.core import mail -from django.test import TestCase - -from djrill import MandrillAPIError, NotSupportedByMandrillError, DjrillAdminSite -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 - - -class DjrillBackendDeprecationTests(DjrillBackendMockAPITestCase): - - def setUp(self): - 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: - 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 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(): - # 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: - 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 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 `. +* 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 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 +* Refactor Djrill backend and exceptions + + +Djrill 1.x and Earlier +---------------------- + Version 1.4: * Django 1.8 support diff --git a/docs/index.rst b/docs/index.rst index 4d7c489..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 @@ -36,9 +37,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..ec0b0f2 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -47,65 +47,71 @@ 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 +----------------------- + +You can optionally add any of these Djrill settings to your :file:`settings.py`. + + +.. 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_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_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 -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 +MANDRILL_SUBACCOUNT +~~~~~~~~~~~~~~~~~~~ +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- - - -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/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. diff --git a/docs/usage/sending_mail.rst b/docs/usage/sending_mail.rst index 0104082..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, @@ -106,7 +93,14 @@ Mandrill-Specific Options 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: +(or subclass) object. + +.. note:: + + 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... @@ -114,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. @@ -149,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 @@ -165,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 @@ -183,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`). :: @@ -226,10 +210,6 @@ Most of the options from the Mandrill .. 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 .. attribute:: google_analytics_domains @@ -267,24 +247,47 @@ 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 - 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:: - .. versionadded:: 0.7 + 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. -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. @@ -295,13 +298,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. @@ -323,10 +328,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. - -.. versionadded:: 0.8 - mandrill_response available for sent messages +If an error is returned by Mandrill while sending the message then :attr:`!mandrill_response` will be set to None. .. _djrill-exceptions: @@ -334,9 +336,6 @@ If an error is returned by Mandrill while sending the message then ``mandrill_re 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 @@ -344,6 +343,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 :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 + (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 @@ -352,3 +372,16 @@ 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.) + + +.. 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..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:: @@ -75,6 +72,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 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -93,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. 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', - ], - }, }, ], ) diff --git a/setup.py b/setup.py index 2c5ef2e..5c83549 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,12 +37,11 @@ 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", + "Programming Language :: Python :: 3.5", "License :: OSI Approved :: BSD License", "Topic :: Software Development :: Libraries :: Python Modules", "Framework :: Django",