mirror of
https://github.com/pacnpal/django-anymail.git
synced 2025-12-20 03:41:05 -05:00
24
.editorconfig
Normal file
24
.editorconfig
Normal file
@@ -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
|
||||
68
.travis.yml
68
.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
|
||||
|
||||
@@ -17,3 +17,4 @@ Sameer Al-Sakran
|
||||
Kyle Gibson
|
||||
Wes Winham
|
||||
nikolay-saskovets
|
||||
William Hector
|
||||
|
||||
4
CONTRIBUTING.md
Normal file
4
CONTRIBUTING.md
Normal file
@@ -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.
|
||||
@@ -1,4 +1,3 @@
|
||||
include README.rst AUTHORS.txt LICENSE
|
||||
recursive-include djrill/templates *.html
|
||||
recursive-include djrill *.py
|
||||
prune djrill/tests
|
||||
|
||||
@@ -20,6 +20,11 @@ Djrill: Mandrill Transactional Email for Django
|
||||
Djrill integrates the `Mandrill <http://mandrill.com>`_ transactional
|
||||
email service into Django.
|
||||
|
||||
**UPGRADING FROM DJRILL 1.x?**
|
||||
There are some **breaking changes in Djrill 2.0**. Please see the
|
||||
`upgrade instructions <http://djrill.readthedocs.org/en/latest/upgrading/>`_.
|
||||
|
||||
|
||||
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 <http://semver.org/>`_.
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:]])
|
||||
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
<div id="changelist-filter">
|
||||
<h2>Tools & Info</h2>
|
||||
<h3>Status</h3>
|
||||
{% if status %}
|
||||
<p>Mandrill is <strong>UP</strong></p>
|
||||
{% else %}
|
||||
<p>Mandrill is <strong>DOWN</strong></p>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -1,18 +0,0 @@
|
||||
{% extends "admin/index.html" %}
|
||||
|
||||
{% block sidebar %}
|
||||
{{ block.super }}
|
||||
|
||||
{% if custom_list %}
|
||||
<div class="module" style="float: left; width: 498px">
|
||||
<table style="width: 100%">
|
||||
<caption>Djrill</caption>
|
||||
<tbody>
|
||||
{% for path, name in custom_list %}
|
||||
<tr><td><a href="{{ path }}">{{ name|capfirst }}</a></td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -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 }}
|
||||
<link rel="stylesheet" type="text/css" href="{{ STATIC_URL }}admin/css/changelists.css" />
|
||||
{{ media.css }}
|
||||
{% if not actions_on_top and not actions_on_bottom %}
|
||||
<style>
|
||||
#changelist table thead th:first-child {width: inherit}
|
||||
</style>
|
||||
{% 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 %}
|
||||
<div class="breadcrumbs">
|
||||
<a href="../../">
|
||||
{% trans "Home" %}
|
||||
</a>
|
||||
›
|
||||
Djrill
|
||||
›
|
||||
Senders
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% endif %}
|
||||
|
||||
{% block coltype %}flex{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div id="content-main">
|
||||
|
||||
<div class="module filtered" id="changelist">
|
||||
{% block date_hierarchy %}{% endblock %}
|
||||
|
||||
{% block filters %}
|
||||
{% include "djrill/_status.html" %}
|
||||
{% endblock %}
|
||||
|
||||
{% block result_list %}
|
||||
{% if objects %}
|
||||
<div class="results">
|
||||
<table cellspacing="0" id="result_list">
|
||||
<thead>
|
||||
<tr>
|
||||
{% for header in objects.0.keys %}
|
||||
<th scope="col">{{ header|capfirst }}</th>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for result in objects %}
|
||||
<tr class="{% cycle 'row1' 'row2' %}">
|
||||
{% for item in result.values %}
|
||||
<td>{{ item }}</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
{% block pagination %}{% endblock %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,67 +0,0 @@
|
||||
{% extends "admin/base_site.html" %}
|
||||
{% load admin_list i18n %}
|
||||
{% load url from future %}
|
||||
{% block extrastyle %}
|
||||
{{ block.super }}
|
||||
<link rel="stylesheet" type="text/css" href="{{ STATIC_URL }}admin/css/changelists.css" />
|
||||
{{ media.css }}
|
||||
{% if not actions_on_top and not actions_on_bottom %}
|
||||
<style>
|
||||
#changelist table thead th:first-child {width: inherit}
|
||||
</style>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block extrahead %}
|
||||
{{ block.super }}
|
||||
{{ media.js }}
|
||||
{% if action_form %}{% if actions_on_top or actions_on_bottom %}
|
||||
<script type="text/javascript">
|
||||
(function($) {
|
||||
$(document).ready(function($) {
|
||||
$("tr input.action-select").actions();
|
||||
});
|
||||
})(django.jQuery);
|
||||
</script>
|
||||
{% endif %}{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block title %} Djrill Status | {% trans "Django site admin" %}{% endblock %}
|
||||
|
||||
{% block bodyclass %}change-list{% endblock %}
|
||||
|
||||
{% if not is_popup %}
|
||||
{% block breadcrumbs %}
|
||||
<div class="breadcrumbs">
|
||||
<a href="../../">
|
||||
{% trans "Home" %}
|
||||
</a>
|
||||
›
|
||||
Djrill
|
||||
›
|
||||
Status
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% endif %}
|
||||
|
||||
{% block coltype %}flex{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div id="content-main">
|
||||
{% block object-tools %}
|
||||
{% endblock %}
|
||||
<div>
|
||||
{% block search %}{% endblock %}
|
||||
{% block date_hierarchy %}{% endblock %}
|
||||
|
||||
{% block filters %}{% endblock %}
|
||||
{% block pagination %}{% endblock %}
|
||||
<dl>
|
||||
{% for term, value in status.items %}
|
||||
<dt>{{ term|capfirst }}</dt>
|
||||
<dd>{{ value }}</dd>
|
||||
{% endfor %}
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -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 }}
|
||||
<link rel="stylesheet" type="text/css" href="{{ STATIC_URL }}admin/css/changelists.css" />
|
||||
{{ media.css }}
|
||||
{% if not actions_on_top and not actions_on_bottom %}
|
||||
<style>
|
||||
#changelist table thead th:first-child {width: inherit}
|
||||
</style>
|
||||
{% 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 %}
|
||||
<div class="breadcrumbs">
|
||||
<a href="../../">
|
||||
{% trans "Home" %}
|
||||
</a>
|
||||
›
|
||||
Djrill
|
||||
›
|
||||
Tags
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% endif %}
|
||||
|
||||
{% block coltype %}flex{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div id="content-main">
|
||||
|
||||
<div class="module filtered" id="changelist">
|
||||
{% block search %} {% endblock %}
|
||||
|
||||
{% block date_hierarchy %}{% endblock %}
|
||||
|
||||
{% block filters %}
|
||||
{% include "djrill/_status.html" %}
|
||||
{% endblock %}
|
||||
|
||||
{% block result_list %}
|
||||
{% if objects %}
|
||||
<div class="results">
|
||||
<table cellspacing="0" id="result_list">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Tag</th>
|
||||
<th scope="col">ID</th>
|
||||
<th scope="col">Sent</th>
|
||||
<th scope="col">Opens</th>
|
||||
<th scope="col">Clicks</th>
|
||||
<th scope="col">Rejects</th>
|
||||
<th scope="col">Bounces</th>
|
||||
<th scope="col">Complaints</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for result in objects %}
|
||||
<tr class="{% cycle 'row1' 'row2' %}">
|
||||
<td>{{ result.tag }}</td>
|
||||
<td>{{ result.id }}</td>
|
||||
<td>{{ result.sent }}</td>
|
||||
<td>{{ result.opens }}</td>
|
||||
<td>{{ result.clicks }}</td>
|
||||
<td>{{ result.rejects }}</td>
|
||||
<td>{{ result.bounces }}</td>
|
||||
<td>{{ result.complaints }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
{% block pagination %}{% endblock %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -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 }}
|
||||
<link rel="stylesheet" type="text/css" href="{{ STATIC_URL }}admin/css/changelists.css" />
|
||||
{{ media.css }}
|
||||
{% if not actions_on_top and not actions_on_bottom %}
|
||||
<style>
|
||||
#changelist table thead th:first-child {width: inherit}
|
||||
</style>
|
||||
{% 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 %}
|
||||
<div class="breadcrumbs">
|
||||
<a href="../../">
|
||||
{% trans "Home" %}
|
||||
</a>
|
||||
›
|
||||
Djrill
|
||||
›
|
||||
URLs
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% endif %}
|
||||
|
||||
{% block coltype %}flex{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div id="content-main">
|
||||
|
||||
<div class="module filtered" id="changelist">
|
||||
{% block search %}{% endblock %}
|
||||
|
||||
{% block date_hierarchy %}{% endblock %}
|
||||
|
||||
{% block filters %}
|
||||
{% include "djrill/_status.html" %}
|
||||
{% endblock %}
|
||||
|
||||
{% block result_list %}
|
||||
{% if objects %}
|
||||
<div class="results">
|
||||
<table cellspacing="0" id="result_list">
|
||||
<thead>
|
||||
<tr>
|
||||
{% for header in objects.0.keys %}
|
||||
<th scope="col">{{ header|capfirst }}</th>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for result in objects %}
|
||||
<tr class="{% cycle 'row1' 'row2' %}">
|
||||
{% for item in result.values %}
|
||||
<td>{{ item }}</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
{% block pagination %}{% endblock %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -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)
|
||||
@@ -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 *
|
||||
|
||||
@@ -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)),
|
||||
]
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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 <kingkong@example.com>",
|
||||
"Cheetah <cheetah@example.com", "bubbles@example.com"]
|
||||
self.text_content = "Wonderful fallback text content."
|
||||
self.html_content = "<h1>That's a nice HTML email right there.</h1>"
|
||||
self.headers = {"Reply-To": "tarzan@example.com"}
|
||||
self.tags = ["track", "this"]
|
||||
|
||||
def test_djrill_message_success(self):
|
||||
msg = DjrillMessage(self.subject, self.text_content, self.from_email,
|
||||
self.to, tags=self.tags, headers=self.headers,
|
||||
from_name=self.from_name)
|
||||
|
||||
self.assertIsInstance(msg, DjrillMessage)
|
||||
self.assertEqual(msg.body, self.text_content)
|
||||
self.assertEqual(msg.recipients(), self.to)
|
||||
self.assertEqual(msg.tags, self.tags)
|
||||
self.assertEqual(msg.extra_headers, self.headers)
|
||||
self.assertEqual(msg.from_name, self.from_name)
|
||||
|
||||
def test_djrill_message_html_success(self):
|
||||
msg = DjrillMessage(self.subject, self.text_content, self.from_email,
|
||||
self.to, tags=self.tags)
|
||||
msg.attach_alternative(self.html_content, "text/html")
|
||||
|
||||
self.assertEqual(msg.alternatives[0][0], self.html_content)
|
||||
|
||||
def test_djrill_message_tag_failure(self):
|
||||
with self.assertRaises(ValueError):
|
||||
DjrillMessage(self.subject, self.text_content, self.from_email,
|
||||
self.to, tags=["_fail"])
|
||||
|
||||
def test_djrill_message_tag_skip(self):
|
||||
"""
|
||||
Test that tags over 50 chars are not included in the tags list.
|
||||
"""
|
||||
tags = ["works", "awesomesauce",
|
||||
"iwilltestmycodeiwilltestmycodeiwilltestmycodeiwilltestmycode"]
|
||||
msg = DjrillMessage(self.subject, self.text_content, self.from_email,
|
||||
self.to, tags=tags)
|
||||
|
||||
self.assertIsInstance(msg, DjrillMessage)
|
||||
self.assertIn(tags[0], msg.tags)
|
||||
self.assertIn(tags[1], msg.tags)
|
||||
self.assertNotIn(tags[2], msg.tags)
|
||||
|
||||
def test_djrill_message_no_options(self):
|
||||
"""DjrillMessage with only basic EmailMessage options should work"""
|
||||
msg = DjrillMessage(self.subject, self.text_content,
|
||||
self.from_email, self.to) # no Mandrill-specific options
|
||||
|
||||
self.assertIsInstance(msg, DjrillMessage)
|
||||
self.assertEqual(msg.body, self.text_content)
|
||||
self.assertEqual(msg.recipients(), self.to)
|
||||
self.assertFalse(hasattr(msg, 'tags'))
|
||||
self.assertFalse(hasattr(msg, 'from_name'))
|
||||
self.assertFalse(hasattr(msg, 'preserve_recipients'))
|
||||
|
||||
|
||||
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")
|
||||
self.assertIsInstance(ex, ValueError)
|
||||
@@ -1,26 +1,23 @@
|
||||
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
|
||||
from djrill import MandrillAPIError, MandrillRecipientsRefused
|
||||
|
||||
|
||||
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
|
||||
@@ -43,7 +40,7 @@ class DjrillIntegrationTests(TestCase, BackportedAssertions):
|
||||
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,21 +58,41 @@ class DjrillIntegrationTests(TestCase, BackportedAssertions):
|
||||
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
|
||||
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
|
||||
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):
|
||||
|
||||
@@ -2,28 +2,27 @@
|
||||
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
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
|
||||
|
||||
try:
|
||||
from unittest import SkipTest
|
||||
except ImportError:
|
||||
from django.utils.unittest import SkipTest
|
||||
|
||||
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, MandrillRecipientsRefused,
|
||||
NotSerializableForMandrillError, NotSupportedByMandrillError)
|
||||
|
||||
from djrill import MandrillAPIError, NotSupportedByMandrillError
|
||||
from .mock_backend import DjrillBackendMockAPITestCase
|
||||
from .utils import override_settings
|
||||
|
||||
|
||||
def decode_att(att):
|
||||
@@ -158,7 +157,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()
|
||||
@@ -342,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,
|
||||
@@ -518,7 +520,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()
|
||||
@@ -533,15 +535,211 @@ class DjrillMandrillFeatureTests(DjrillBackendMockAPITestCase):
|
||||
self.assertEqual(sent, 0)
|
||||
self.assertIsNone(msg.mandrill_response)
|
||||
|
||||
def test_json_serialization_warnings(self):
|
||||
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')}
|
||||
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(NotSerializableForMandrillError):
|
||||
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,
|
||||
'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,
|
||||
'subaccount': 'example-subaccount',
|
||||
'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': True,
|
||||
'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'])
|
||||
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'])
|
||||
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')
|
||||
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.assertTrue(data['async'])
|
||||
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.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.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']['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
|
||||
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': '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")
|
||||
|
||||
@@ -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):
|
||||
|
||||
72
djrill/tests/test_mandrill_session_sharing.py
Normal file
72
djrill/tests/test_mandrill_session_sharing.py
Normal file
@@ -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)
|
||||
@@ -1,46 +1,42 @@
|
||||
from django.core import mail
|
||||
from django.test.utils import override_settings
|
||||
|
||||
from .mock_backend import DjrillBackendMockAPITestCase
|
||||
from .utils import override_settings
|
||||
|
||||
|
||||
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")
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
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.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):
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
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
|
||||
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()
|
||||
142
djrill/views.py
142
djrill/views.py
@@ -1,89 +1,17 @@
|
||||
from base64 import b64encode
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
from django import forms
|
||||
from base64 import b64encode
|
||||
|
||||
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.http import HttpResponse
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from django.views.generic import View
|
||||
|
||||
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 .signals import webhook_event
|
||||
|
||||
|
||||
class DjrillWebhookSecretMixin(object):
|
||||
@@ -139,66 +67,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()
|
||||
@@ -210,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()
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
.. _history:
|
||||
|
||||
Release Notes
|
||||
=============
|
||||
|
||||
@@ -7,68 +9,32 @@ 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
|
||||
that will change. (Warnings appear in the console when running Django
|
||||
in debug mode.)
|
||||
|
||||
|
||||
**Djrill Admin site**
|
||||
|
||||
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.
|
||||
|
||||
Drill 1.4 will report a DeprecationWarning when you try to load
|
||||
the `DjrillAdminSite`. You should remove it from your code.
|
||||
|
||||
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.
|
||||
|
||||
|
||||
**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.
|
||||
|
||||
|
||||
**DjrillMessage class**
|
||||
|
||||
The ``DjrillMessage`` class has not been needed since Djrill 0.2.
|
||||
You can simply set Djrill message attributes on any Django
|
||||
:class:`~django.core.mail.EmailMessage` object.
|
||||
Djrill 1.4 will report a DeprecationWarning if you are still
|
||||
using DjrillMessage.
|
||||
|
||||
|
||||
**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.
|
||||
|
||||
|
||||
Change Log
|
||||
Djrill 2.x
|
||||
----------
|
||||
|
||||
Version 2.0:
|
||||
|
||||
* **Breaking Changes:** please see the :ref:`upgrade guide <upgrading>`.
|
||||
* 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 <django:topic-email-backends>`.)
|
||||
* 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
|
||||
|
||||
@@ -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 <contributing>`. 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 <contributing>`.
|
||||
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
|
||||
|
||||
@@ -47,65 +47,71 @@ Djrill includes optional support for Mandrill webhooks, including inbound email.
|
||||
See the Djrill :ref:`webhooks <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 <mandrill-send-support>`.)
|
||||
|
||||
.. 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 <subaccounts>`_.
|
||||
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',
|
||||
...
|
||||
)
|
||||
...
|
||||
|
||||
121
docs/upgrading.rst
Normal file
121
docs/upgrading.rst
Normal file
@@ -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 <history>` 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 <EMAIL_BACKEND>`,
|
||||
which raises :exc:`SMTPRecipientsRefused <smtplib.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.
|
||||
@@ -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 <https://mandrillapp.com/api/docs/messages.html#method=send>`_
|
||||
`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 <https://mandrillapp.com/api/docs/messages.html#method=send>`_ 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 <https://mandrillapp.com/settings/api>`_ 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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
25
runtests.py
25
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',
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
5
setup.py
5
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",
|
||||
|
||||
Reference in New Issue
Block a user