@@ -8,14 +8,14 @@ from django.shortcuts import redirect | |||
from django.contrib.auth.decorators import login_required | |||
from django.contrib.admin.views.decorators import staff_member_required | |||
from django.contrib.auth import authenticate, login, logout | |||
from django.db.models import Q | |||
import sys | |||
import collections | |||
from taggit_templatetags2.views import TagCanvasListView | |||
from django.contrib.auth.models import User | |||
from django.contrib import messages | |||
""" from post_office import mail | |||
""" | |||
from post_office.models import EmailTemplate | |||
from post_office import mail | |||
import logging | |||
@@ -182,10 +182,17 @@ def blog_search_list_view(request): | |||
def tag_cloud(request): | |||
return render(request, 'tag_cloud.html', {}) | |||
""" mail.send( | |||
EmailTemplate.objects.create( | |||
name='weekly-update', | |||
subject='Hi' + User.objects.get(username=request.user) + '!', | |||
content='How are you feeling today?' + Post.objects.filter(published_date__lte=timezone.now()).order_by('published_date'), | |||
html_content='Hi <strong>{{ name }}</strong>, how are you feeling today?', | |||
) | |||
mail.send( | |||
'kleinhenz.e@gmail.com', # List of email addresses also accepted | |||
'esther.kleinhenz@web.de', | |||
subject='My email', | |||
message='Hi there!', | |||
html_message='Hi <strong>there</strong>!', | |||
) """ | |||
template='weekly-update', | |||
context={'name': 'alice'}, | |||
) |
@@ -47,6 +47,7 @@ INSTALLED_APPS = [ | |||
'taggit', | |||
'taggit_templatetags2', | |||
'kombu.transport.django', | |||
'post_office', | |||
] | |||
MIDDLEWARE = [ | |||
@@ -251,3 +252,12 @@ if DEBUG: | |||
DEBUG_TOOLBAR_CONFIG = { | |||
'INTERCEPT_REDIRECTS': False, | |||
} | |||
EMAIL_BACKEND = 'post_office.EmailBackend' | |||
EMAIL_HOST = 'smtp.web.de' | |||
EMAIL_HOST_USER = "esther.kleinhenz@web.de" | |||
EMAIL_PORT = 25 # default smtp port | |||
EMAIL_HOST_PASSWORD = "2mSchneeinMikkeli" | |||
EMAIL_USE_TLS = True | |||
DEFAULT_FROM_EMAIL = 'your.generic.test.email@web.de' |
@@ -0,0 +1,654 @@ | |||
================== | |||
Django Post Office | |||
================== | |||
Django Post Office is a simple app to send and manage your emails in Django. | |||
Some awesome features are: | |||
* Allows you to send email asynchronously | |||
* Multi backend support | |||
* Supports HTML email | |||
* Supports database based email templates | |||
* Built in scheduling support | |||
* Works well with task queues like `RQ <http://python-rq.org>`_ or `Celery <http://www.celeryproject.org>`_ | |||
* Uses multiprocessing (and threading) to send a large number of emails in parallel | |||
* Supports multilingual email templates (i18n) | |||
Dependencies | |||
============ | |||
* `django >= 1.8 <http://djangoproject.com/>`_ | |||
* `django-jsonfield <https://github.com/bradjasper/django-jsonfield>`_ | |||
Installation | |||
============ | |||
|Build Status| | |||
* Install from PyPI (or you `manually download from PyPI <http://pypi.python.org/pypi/django-post_office>`_):: | |||
pip install django-post_office | |||
* Add ``post_office`` to your INSTALLED_APPS in django's ``settings.py``: | |||
.. code-block:: python | |||
INSTALLED_APPS = ( | |||
# other apps | |||
"post_office", | |||
) | |||
* Run ``migrate``:: | |||
python manage.py migrate | |||
* Set ``post_office.EmailBackend`` as your ``EMAIL_BACKEND`` in django's ``settings.py``: | |||
.. code-block:: python | |||
EMAIL_BACKEND = 'post_office.EmailBackend' | |||
Quickstart | |||
========== | |||
Send a simple email is really easy: | |||
.. code-block:: python | |||
from post_office import mail | |||
mail.send( | |||
'recipient@example.com', # List of email addresses also accepted | |||
'from@example.com', | |||
subject='My email', | |||
message='Hi there!', | |||
html_message='Hi <strong>there</strong>!', | |||
) | |||
If you want to use templates, ensure that Django's admin interface is enabled. Create an | |||
``EmailTemplate`` instance via ``admin`` and do the following: | |||
.. code-block:: python | |||
from post_office import mail | |||
mail.send( | |||
'recipient@example.com', # List of email addresses also accepted | |||
'from@example.com', | |||
template='welcome_email', # Could be an EmailTemplate instance or name | |||
context={'foo': 'bar'}, | |||
) | |||
The above command will put your email on the queue so you can use the | |||
command in your webapp without slowing down the request/response cycle too much. | |||
To actually send them out, run ``python manage.py send_queued_mail``. | |||
You can schedule this management command to run regularly via cron:: | |||
* * * * * (/usr/bin/python manage.py send_queued_mail >> send_mail.log 2>&1) | |||
or, if you use uWSGI_ as application server, add this short snipped to the | |||
project's ``wsgi.py`` file: | |||
.. code-block:: python | |||
from django.core.wsgi import get_wsgi_application | |||
application = get_wsgi_application() | |||
# add this block of code | |||
try: | |||
import uwsgidecorators | |||
from django.core.management import call_command | |||
@uwsgidecorators.timer(10) | |||
def send_queued_mail(num): | |||
"""Send queued mail every 10 seconds""" | |||
call_command('send_queued_mail', processes=1) | |||
except ImportError: | |||
print("uwsgidecorators not found. Cron and timers are disabled") | |||
Alternatively you can also use the decorator ``@uwsgidecorators.cron(minute, hour, day, month, weekday)``. | |||
This will schedule a task at specific times. Use ``-1`` to signal any time, it corresponds to the ``*`` | |||
in cron. | |||
Please note that ``uwsgidecorators`` are available only, if the application has been started | |||
with **uWSGI**. However, Django's internal ``./manange.py runserver`` also access this file, | |||
therefore wrap the block into an exception handler as shown above. | |||
This configuration is very useful in environments, such as Docker containers, where you | |||
don't have a running cron-daemon. | |||
Usage | |||
===== | |||
mail.send() | |||
----------- | |||
``mail.send`` is the most important function in this library, it takes these | |||
arguments: | |||
+--------------------+----------+--------------------------------------------------+ | |||
| Argument | Required | Description | | |||
+--------------------+----------+--------------------------------------------------+ | |||
| recipients | Yes | list of recipient email addresses | | |||
+--------------------+----------+--------------------------------------------------+ | |||
| sender | No | Defaults to ``settings.DEFAULT_FROM_EMAIL``, | | |||
| | | display name is allowed (``John <john@a.com>``) | | |||
+--------------------+----------+--------------------------------------------------+ | |||
| subject | No | Email subject (if ``template`` is not specified) | | |||
+--------------------+----------+--------------------------------------------------+ | |||
| message | No | Email content (if ``template`` is not specified) | | |||
+--------------------+----------+--------------------------------------------------+ | |||
| html_message | No | HTML content (if ``template`` is not specified) | | |||
+--------------------+----------+--------------------------------------------------+ | |||
| template | No | ``EmailTemplate`` instance or name | | |||
+--------------------+----------+--------------------------------------------------+ | |||
| language | No | Language in which you want to send the email in | | |||
| | | (if you have multilingual email templates.) | | |||
+--------------------+----------+--------------------------------------------------+ | |||
| cc | No | list emails, will appear in ``cc`` field | | |||
+--------------------+----------+--------------------------------------------------+ | |||
| bcc | No | list of emails, will appear in `bcc` field | | |||
+--------------------+----------+--------------------------------------------------+ | |||
| attachments | No | Email attachments - A dictionary where the keys | | |||
| | | are the filenames and the values are either: | | |||
| | | | | |||
| | | * files | | |||
| | | * file-like objects | | |||
| | | * full path of the file | | |||
+--------------------+----------+--------------------------------------------------+ | |||
| context | No | A dictionary, used to render templated email | | |||
+--------------------+----------+--------------------------------------------------+ | |||
| headers | No | A dictionary of extra headers on the message | | |||
+--------------------+----------+--------------------------------------------------+ | |||
| scheduled_time | No | A date/datetime object indicating when the email | | |||
| | | should be sent | | |||
+--------------------+----------+--------------------------------------------------+ | |||
| priority | No | ``high``, ``medium``, ``low`` or ``now`` | | |||
| | | (send_immediately) | | |||
+--------------------+----------+--------------------------------------------------+ | |||
| backend | No | Alias of the backend you want to use. | | |||
| | | ``default`` will be used if not specified. | | |||
+--------------------+----------+--------------------------------------------------+ | |||
| render_on_delivery | No | Setting this to ``True`` causes email to be | | |||
| | | lazily rendered during delivery. ``template`` | | |||
| | | is required when ``render_on_delivery`` is True. | | |||
| | | This way content is never stored in the DB. | | |||
| | | May result in significant space savings. | | |||
+--------------------+----------+--------------------------------------------------+ | |||
Here are a few examples. | |||
If you just want to send out emails without using database templates. You can | |||
call the ``send`` command without the ``template`` argument. | |||
.. code-block:: python | |||
from post_office import mail | |||
mail.send( | |||
['recipient1@example.com'], | |||
'from@example.com', | |||
subject='Welcome!', | |||
message='Welcome home, {{ name }}!', | |||
html_message='Welcome home, <b>{{ name }}</b>!', | |||
headers={'Reply-to': 'reply@example.com'}, | |||
scheduled_time=date(2014, 1, 1), | |||
context={'name': 'Alice'}, | |||
) | |||
``post_office`` is also task queue friendly. Passing ``now`` as priority into | |||
``send_mail`` will deliver the email right away (instead of queuing it), | |||
regardless of how many emails you have in your queue: | |||
.. code-block:: python | |||
from post_office import mail | |||
mail.send( | |||
['recipient1@example.com'], | |||
'from@example.com', | |||
template='welcome_email', | |||
context={'foo': 'bar'}, | |||
priority='now', | |||
) | |||
This is useful if you already use something like `django-rq <https://github.com/ui/django-rq>`_ | |||
to send emails asynchronously and only need to store email related activities and logs. | |||
If you want to send an email with attachments: | |||
.. code-block:: python | |||
from django.core.files.base import ContentFile | |||
from post_office import mail | |||
mail.send( | |||
['recipient1@example.com'], | |||
'from@example.com', | |||
template='welcome_email', | |||
context={'foo': 'bar'}, | |||
priority='now', | |||
attachments={ | |||
'attachment1.doc': '/path/to/file/file1.doc', | |||
'attachment2.txt': ContentFile('file content'), | |||
'attachment3.txt': { 'file': ContentFile('file content'), 'mimetype': 'text/plain'}, | |||
} | |||
) | |||
Template Tags and Variables | |||
--------------------------- | |||
``post-office`` supports Django's template tags and variables. | |||
For example, if you put "Hello, {{ name }}" in the subject line and pass in | |||
``{'name': 'Alice'}`` as context, you will get "Hello, Alice" as subject: | |||
.. code-block:: python | |||
from post_office.models import EmailTemplate | |||
from post_office import mail | |||
EmailTemplate.objects.create( | |||
name='morning_greeting', | |||
subject='Morning, {{ name|capfirst }}', | |||
content='Hi {{ name }}, how are you feeling today?', | |||
html_content='Hi <strong>{{ name }}</strong>, how are you feeling today?', | |||
) | |||
mail.send( | |||
['recipient@example.com'], | |||
'from@example.com', | |||
template='morning_greeting', | |||
context={'name': 'alice'}, | |||
) | |||
# This will create an email with the following content: | |||
subject = 'Morning, Alice', | |||
content = 'Hi alice, how are you feeling today?' | |||
content = 'Hi <strong>alice</strong>, how are you feeling today?' | |||
Multilingual Email Templates | |||
---------------------------- | |||
You can easily create email templates in various different languanges. | |||
For example: | |||
.. code-block:: python | |||
template = EmailTemplate.objects.create( | |||
name='hello', | |||
subject='Hello world!', | |||
) | |||
# Add an Indonesian version of this template: | |||
indonesian_template = template.translated_templates.create( | |||
language='id', | |||
subject='Halo Dunia!' | |||
) | |||
Sending an email using template in a non default languange is | |||
also similarly easy: | |||
.. code-block:: python | |||
mail.send( | |||
['recipient@example.com'], | |||
'from@example.com', | |||
template=template, # Sends using the default template | |||
) | |||
mail.send( | |||
['recipient@example.com'], | |||
'from@example.com', | |||
template=template, | |||
language='id', # Sends using Indonesian template | |||
) | |||
Custom Email Backends | |||
--------------------- | |||
By default, ``post_office`` uses django's ``smtp.EmailBackend``. If you want to | |||
use a different backend, you can do so by configuring ``BACKENDS``. | |||
For example if you want to use `django-ses <https://github.com/hmarr/django-ses>`_:: | |||
POST_OFFICE = { | |||
'BACKENDS': { | |||
'default': 'smtp.EmailBackend', | |||
'ses': 'django_ses.SESBackend', | |||
} | |||
} | |||
You can then choose what backend you want to use when sending mail: | |||
.. code-block:: python | |||
# If you omit `backend_alias` argument, `default` will be used | |||
mail.send( | |||
['recipient@example.com'], | |||
'from@example.com', | |||
subject='Hello', | |||
) | |||
# If you want to send using `ses` backend | |||
mail.send( | |||
['recipient@example.com'], | |||
'from@example.com', | |||
subject='Hello', | |||
backend='ses', | |||
) | |||
Management Commands | |||
------------------- | |||
* ``send_queued_mail`` - send queued emails, those aren't successfully sent | |||
will be marked as ``failed``. Accepts the following arguments: | |||
+---------------------------+--------------------------------------------------+ | |||
| Argument | Description | | |||
+---------------------------+--------------------------------------------------+ | |||
| ``--processes`` or ``-p`` | Number of parallel processes to send email. | | |||
| | Defaults to 1 | | |||
+---------------------------+--------------------------------------------------+ | |||
| ``--lockfile`` or ``-L`` | Full path to file used as lock file. Defaults to | | |||
| | ``/tmp/post_office.lock`` | | |||
+---------------------------+--------------------------------------------------+ | |||
* ``cleanup_mail`` - delete all emails created before an X number of days | |||
(defaults to 90). | |||
+---------------------------+--------------------------------------------------+ | |||
| Argument | Description | | |||
+---------------------------+--------------------------------------------------+ | |||
| ``--days`` or ``-d`` | Email older than this argument will be deleted. | | |||
| | Defaults to 90 | | |||
+---------------------------+--------------------------------------------------+ | |||
| ``--delete-attachments`` | Flag to delete orphaned attachment records and | | |||
| or ``-da`` | files on disk. If flag is not set, | | |||
| | on disk attachments files won't be deleted. | | |||
+---------------------------+--------------------------------------------------+ | |||
You may want to set these up via cron to run regularly:: | |||
* * * * * (cd $PROJECT; python manage.py send_queued_mail --processes=1 >> $PROJECT/cron_mail.log 2>&1) | |||
0 1 * * * (cd $PROJECT; python manage.py cleanup_mail --days=30 --delete-attachments >> $PROJECT/cron_mail_cleanup.log 2>&1) | |||
Settings | |||
======== | |||
This section outlines all the settings and configurations that you can put | |||
in Django's ``settings.py`` to fine tune ``post-office``'s behavior. | |||
Batch Size | |||
---------- | |||
If you may want to limit the number of emails sent in a batch (sometimes useful | |||
in a low memory environment), use the ``BATCH_SIZE`` argument to limit the | |||
number of queued emails fetched in one batch. | |||
.. code-block:: python | |||
# Put this in settings.py | |||
POST_OFFICE = { | |||
'BATCH_SIZE': 50 | |||
} | |||
Default Priority | |||
---------------- | |||
The default priority for emails is ``medium``, but this can be altered by | |||
setting ``DEFAULT_PRIORITY``. Integration with asynchronous email backends | |||
(e.g. based on Celery) becomes trivial when set to ``now``. | |||
.. code-block:: python | |||
# Put this in settings.py | |||
POST_OFFICE = { | |||
'DEFAULT_PRIORITY': 'now' | |||
} | |||
Log Level | |||
--------- | |||
The default log level is 2 (logs both successful and failed deliveries) | |||
This behavior can be changed by setting ``LOG_LEVEL``. | |||
.. code-block:: python | |||
# Put this in settings.py | |||
POST_OFFICE = { | |||
'LOG_LEVEL': 1 # Log only failed deliveries | |||
} | |||
The different options are: | |||
* ``0`` logs nothing | |||
* ``1`` logs only failed deliveries | |||
* ``2`` logs everything (both successful and failed delivery attempts) | |||
Sending Order | |||
------------- | |||
The default sending order for emails is ``-priority``, but this can be altered by | |||
setting ``SENDING_ORDER``. For example, if you want to send queued emails in FIFO order : | |||
.. code-block:: python | |||
# Put this in settings.py | |||
POST_OFFICE = { | |||
'SENDING_ORDER': ['created'] | |||
} | |||
Context Field Serializer | |||
------------------------ | |||
If you need to store complex Python objects for deferred rendering | |||
(i.e. setting ``render_on_delivery=True``), you can specify your own context | |||
field class to store context variables. For example if you want to use | |||
`django-picklefield <https://github.com/gintas/django-picklefield/tree/master/src/picklefield>`_: | |||
.. code-block:: python | |||
# Put this in settings.py | |||
POST_OFFICE = { | |||
'CONTEXT_FIELD_CLASS': 'picklefield.fields.PickledObjectField' | |||
} | |||
``CONTEXT_FIELD_CLASS`` defaults to ``jsonfield.JSONField``. | |||
Logging | |||
------- | |||
You can configure ``post-office``'s logging from Django's ``settings.py``. For | |||
example: | |||
.. code-block:: python | |||
LOGGING = { | |||
"version": 1, | |||
"disable_existing_loggers": False, | |||
"formatters": { | |||
"post_office": { | |||
"format": "[%(levelname)s]%(asctime)s PID %(process)d: %(message)s", | |||
"datefmt": "%d-%m-%Y %H:%M:%S", | |||
}, | |||
}, | |||
"handlers": { | |||
"post_office": { | |||
"level": "DEBUG", | |||
"class": "logging.StreamHandler", | |||
"formatter": "post_office" | |||
}, | |||
# If you use sentry for logging | |||
'sentry': { | |||
'level': 'ERROR', | |||
'class': 'raven.contrib.django.handlers.SentryHandler', | |||
}, | |||
}, | |||
'loggers': { | |||
"post_office": { | |||
"handlers": ["post_office", "sentry"], | |||
"level": "INFO" | |||
}, | |||
}, | |||
} | |||
Threads | |||
------- | |||
``post-office`` >= 3.0 allows you to use multiple threads to dramatically speed up | |||
the speed at which emails are sent. By default, ``post-office`` uses 5 threads per process. | |||
You can tweak this setting by changing ``THREADS_PER_PROCESS`` setting. | |||
This may dramatically increase the speed of bulk email delivery, depending on which email | |||
backends you use. In my tests, multi threading speeds up email backends that use HTTP based | |||
(REST) delivery mechanisms but doesn't seem to help SMTP based backends. | |||
.. code-block:: python | |||
# Put this in settings.py | |||
POST_OFFICE = { | |||
'THREADS_PER_PROCESS': 10 | |||
} | |||
Performance | |||
=========== | |||
Caching | |||
------- | |||
if Django's caching mechanism is configured, ``post_office`` will cache | |||
``EmailTemplate`` instances . If for some reason you want to disable caching, | |||
set ``POST_OFFICE_CACHE`` to ``False`` in ``settings.py``: | |||
.. code-block:: python | |||
## All cache key will be prefixed by post_office:template: | |||
## To turn OFF caching, you need to explicitly set POST_OFFICE_CACHE to False in settings | |||
POST_OFFICE_CACHE = False | |||
## Optional: to use a non default cache backend, add a "post_office" entry in CACHES | |||
CACHES = { | |||
'post_office': { | |||
'BACKEND': 'django.core.cache.backends.memcached.PyLibMCCache', | |||
'LOCATION': '127.0.0.1:11211', | |||
} | |||
} | |||
send_many() | |||
----------- | |||
``send_many()`` is much more performant (generates less database queries) when | |||
sending a large number of emails. ``send_many()`` is almost identical to ``mail.send()``, | |||
with the exception that it accepts a list of keyword arguments that you'd | |||
usually pass into ``mail.send()``: | |||
.. code-block:: python | |||
from post_office import mail | |||
first_email = { | |||
'sender': 'from@example.com', | |||
'recipients': ['alice@example.com'], | |||
'subject': 'Hi!', | |||
'message': 'Hi Alice!' | |||
} | |||
second_email = { | |||
'sender': 'from@example.com', | |||
'recipients': ['bob@example.com'], | |||
'subject': 'Hi!', | |||
'message': 'Hi Bob!' | |||
} | |||
kwargs_list = [first_email, second_email] | |||
mail.send_many(kwargs_list) | |||
Attachments are not supported with ``mail.send_many()``. | |||
Running Tests | |||
============= | |||
To run the test suite:: | |||
`which django-admin.py` test post_office --settings=post_office.test_settings --pythonpath=. | |||
You can run the full test suite with:: | |||
tox | |||
or:: | |||
python setup.py test | |||
Changelog | |||
========= | |||
Version 3.1.0 (2018-07-24) | |||
-------------------------- | |||
* Improvements to attachments are handled. Thanks @SeiryuZ! | |||
* Added ``--delete-attachments`` flag to ``cleanup_mail`` management command. Thanks @Seiryuz! | |||
* I18n improvements. Thanks @vsevolod-skripnik and @delneg! | |||
* Django admin improvements. Thanks @kakulukia! | |||
Version 3.0.4 | |||
------------- | |||
* Added compatibility with Django 2.0. Thanks @PreActionTech and @PetrDlouhy! | |||
* Added natural key support to `EmailTemplate` model. Thanks @maximlomakin! | |||
Version 3.0.2 | |||
------------- | |||
- Fixed memory leak when multiprocessing is used. | |||
- Fixed a possible error when adding a new email from Django admin. Thanks @ivlevdenis! | |||
Version 3.0.2 | |||
------------- | |||
- `_send_bulk` now properly catches exceptions when preparing email messages. | |||
Version 3.0.1 | |||
------------- | |||
- Fixed an infinite loop bug in `send_queued_mail` management command. | |||
Version 3.0.0 | |||
------------- | |||
* `_send_bulk` now allows each process to use multiple threads to send emails. | |||
* Added support for mimetypes in email attachments. Thanks @clickonchris! | |||
* An `EmailTemplate` can now be used as defaults multiple times in one language. Thanks @sac7e! | |||
* `send_queued_mail` management command will now check whether there are more queued emails to be sent before exiting. | |||
* Drop support for Django < 1.8. Thanks @fendyh! | |||
Full changelog can be found `here <https://github.com/ui/django-post_office/blob/master/CHANGELOG.md>`_. | |||
Created and maintained by the cool guys at `Stamps <https://stamps.co.id>`_, | |||
Indonesia's most elegant CRM/loyalty platform. | |||
.. |Build Status| image:: https://travis-ci.org/ui/django-post_office.png?branch=master | |||
:target: https://travis-ci.org/ui/django-post_office | |||
.. _uWSGI: https://uwsgi-docs.readthedocs.org/en/latest/ | |||
@@ -0,0 +1 @@ | |||
pip |
@@ -0,0 +1,685 @@ | |||
Metadata-Version: 2.0 | |||
Name: django-post-office | |||
Version: 3.1.0 | |||
Summary: A Django app to monitor and send mail asynchronously, complete with template support. | |||
Home-page: https://github.com/ui/django-post_office | |||
Author: Selwin Ong | |||
Author-email: selwin.ong@gmail.com | |||
License: MIT | |||
Description-Content-Type: UNKNOWN | |||
Platform: UNKNOWN | |||
Classifier: Development Status :: 5 - Production/Stable | |||
Classifier: Environment :: Web Environment | |||
Classifier: Framework :: Django | |||
Classifier: Intended Audience :: Developers | |||
Classifier: License :: OSI Approved :: MIT License | |||
Classifier: Operating System :: OS Independent | |||
Classifier: Programming Language :: Python | |||
Classifier: Programming Language :: Python :: 2 | |||
Classifier: Programming Language :: Python :: 2.7 | |||
Classifier: Programming Language :: Python :: 3 | |||
Classifier: Programming Language :: Python :: 3.3 | |||
Classifier: Programming Language :: Python :: 3.4 | |||
Classifier: Programming Language :: Python :: 3.5 | |||
Classifier: Programming Language :: Python :: 3.6 | |||
Classifier: Topic :: Internet :: WWW/HTTP | |||
Classifier: Topic :: Software Development :: Libraries :: Python Modules | |||
Requires-Dist: django (>=1.8) | |||
Requires-Dist: jsonfield | |||
Provides-Extra: test | |||
Requires-Dist: tox (>=2.3); extra == 'test' | |||
================== | |||
Django Post Office | |||
================== | |||
Django Post Office is a simple app to send and manage your emails in Django. | |||
Some awesome features are: | |||
* Allows you to send email asynchronously | |||
* Multi backend support | |||
* Supports HTML email | |||
* Supports database based email templates | |||
* Built in scheduling support | |||
* Works well with task queues like `RQ <http://python-rq.org>`_ or `Celery <http://www.celeryproject.org>`_ | |||
* Uses multiprocessing (and threading) to send a large number of emails in parallel | |||
* Supports multilingual email templates (i18n) | |||
Dependencies | |||
============ | |||
* `django >= 1.8 <http://djangoproject.com/>`_ | |||
* `django-jsonfield <https://github.com/bradjasper/django-jsonfield>`_ | |||
Installation | |||
============ | |||
|Build Status| | |||
* Install from PyPI (or you `manually download from PyPI <http://pypi.python.org/pypi/django-post_office>`_):: | |||
pip install django-post_office | |||
* Add ``post_office`` to your INSTALLED_APPS in django's ``settings.py``: | |||
.. code-block:: python | |||
INSTALLED_APPS = ( | |||
# other apps | |||
"post_office", | |||
) | |||
* Run ``migrate``:: | |||
python manage.py migrate | |||
* Set ``post_office.EmailBackend`` as your ``EMAIL_BACKEND`` in django's ``settings.py``: | |||
.. code-block:: python | |||
EMAIL_BACKEND = 'post_office.EmailBackend' | |||
Quickstart | |||
========== | |||
Send a simple email is really easy: | |||
.. code-block:: python | |||
from post_office import mail | |||
mail.send( | |||
'recipient@example.com', # List of email addresses also accepted | |||
'from@example.com', | |||
subject='My email', | |||
message='Hi there!', | |||
html_message='Hi <strong>there</strong>!', | |||
) | |||
If you want to use templates, ensure that Django's admin interface is enabled. Create an | |||
``EmailTemplate`` instance via ``admin`` and do the following: | |||
.. code-block:: python | |||
from post_office import mail | |||
mail.send( | |||
'recipient@example.com', # List of email addresses also accepted | |||
'from@example.com', | |||
template='welcome_email', # Could be an EmailTemplate instance or name | |||
context={'foo': 'bar'}, | |||
) | |||
The above command will put your email on the queue so you can use the | |||
command in your webapp without slowing down the request/response cycle too much. | |||
To actually send them out, run ``python manage.py send_queued_mail``. | |||
You can schedule this management command to run regularly via cron:: | |||
* * * * * (/usr/bin/python manage.py send_queued_mail >> send_mail.log 2>&1) | |||
or, if you use uWSGI_ as application server, add this short snipped to the | |||
project's ``wsgi.py`` file: | |||
.. code-block:: python | |||
from django.core.wsgi import get_wsgi_application | |||
application = get_wsgi_application() | |||
# add this block of code | |||
try: | |||
import uwsgidecorators | |||
from django.core.management import call_command | |||
@uwsgidecorators.timer(10) | |||
def send_queued_mail(num): | |||
"""Send queued mail every 10 seconds""" | |||
call_command('send_queued_mail', processes=1) | |||
except ImportError: | |||
print("uwsgidecorators not found. Cron and timers are disabled") | |||
Alternatively you can also use the decorator ``@uwsgidecorators.cron(minute, hour, day, month, weekday)``. | |||
This will schedule a task at specific times. Use ``-1`` to signal any time, it corresponds to the ``*`` | |||
in cron. | |||
Please note that ``uwsgidecorators`` are available only, if the application has been started | |||
with **uWSGI**. However, Django's internal ``./manange.py runserver`` also access this file, | |||
therefore wrap the block into an exception handler as shown above. | |||
This configuration is very useful in environments, such as Docker containers, where you | |||
don't have a running cron-daemon. | |||
Usage | |||
===== | |||
mail.send() | |||
----------- | |||
``mail.send`` is the most important function in this library, it takes these | |||
arguments: | |||
+--------------------+----------+--------------------------------------------------+ | |||
| Argument | Required | Description | | |||
+--------------------+----------+--------------------------------------------------+ | |||
| recipients | Yes | list of recipient email addresses | | |||
+--------------------+----------+--------------------------------------------------+ | |||
| sender | No | Defaults to ``settings.DEFAULT_FROM_EMAIL``, | | |||
| | | display name is allowed (``John <john@a.com>``) | | |||
+--------------------+----------+--------------------------------------------------+ | |||
| subject | No | Email subject (if ``template`` is not specified) | | |||
+--------------------+----------+--------------------------------------------------+ | |||
| message | No | Email content (if ``template`` is not specified) | | |||
+--------------------+----------+--------------------------------------------------+ | |||
| html_message | No | HTML content (if ``template`` is not specified) | | |||
+--------------------+----------+--------------------------------------------------+ | |||
| template | No | ``EmailTemplate`` instance or name | | |||
+--------------------+----------+--------------------------------------------------+ | |||
| language | No | Language in which you want to send the email in | | |||
| | | (if you have multilingual email templates.) | | |||
+--------------------+----------+--------------------------------------------------+ | |||
| cc | No | list emails, will appear in ``cc`` field | | |||
+--------------------+----------+--------------------------------------------------+ | |||
| bcc | No | list of emails, will appear in `bcc` field | | |||
+--------------------+----------+--------------------------------------------------+ | |||
| attachments | No | Email attachments - A dictionary where the keys | | |||
| | | are the filenames and the values are either: | | |||
| | | | | |||
| | | * files | | |||
| | | * file-like objects | | |||
| | | * full path of the file | | |||
+--------------------+----------+--------------------------------------------------+ | |||
| context | No | A dictionary, used to render templated email | | |||
+--------------------+----------+--------------------------------------------------+ | |||
| headers | No | A dictionary of extra headers on the message | | |||
+--------------------+----------+--------------------------------------------------+ | |||
| scheduled_time | No | A date/datetime object indicating when the email | | |||
| | | should be sent | | |||
+--------------------+----------+--------------------------------------------------+ | |||
| priority | No | ``high``, ``medium``, ``low`` or ``now`` | | |||
| | | (send_immediately) | | |||
+--------------------+----------+--------------------------------------------------+ | |||
| backend | No | Alias of the backend you want to use. | | |||
| | | ``default`` will be used if not specified. | | |||
+--------------------+----------+--------------------------------------------------+ | |||
| render_on_delivery | No | Setting this to ``True`` causes email to be | | |||
| | | lazily rendered during delivery. ``template`` | | |||
| | | is required when ``render_on_delivery`` is True. | | |||
| | | This way content is never stored in the DB. | | |||
| | | May result in significant space savings. | | |||
+--------------------+----------+--------------------------------------------------+ | |||
Here are a few examples. | |||
If you just want to send out emails without using database templates. You can | |||
call the ``send`` command without the ``template`` argument. | |||
.. code-block:: python | |||
from post_office import mail | |||
mail.send( | |||
['recipient1@example.com'], | |||
'from@example.com', | |||
subject='Welcome!', | |||
message='Welcome home, {{ name }}!', | |||
html_message='Welcome home, <b>{{ name }}</b>!', | |||
headers={'Reply-to': 'reply@example.com'}, | |||
scheduled_time=date(2014, 1, 1), | |||
context={'name': 'Alice'}, | |||
) | |||
``post_office`` is also task queue friendly. Passing ``now`` as priority into | |||
``send_mail`` will deliver the email right away (instead of queuing it), | |||
regardless of how many emails you have in your queue: | |||
.. code-block:: python | |||
from post_office import mail | |||
mail.send( | |||
['recipient1@example.com'], | |||
'from@example.com', | |||
template='welcome_email', | |||
context={'foo': 'bar'}, | |||
priority='now', | |||
) | |||
This is useful if you already use something like `django-rq <https://github.com/ui/django-rq>`_ | |||
to send emails asynchronously and only need to store email related activities and logs. | |||
If you want to send an email with attachments: | |||
.. code-block:: python | |||
from django.core.files.base import ContentFile | |||
from post_office import mail | |||
mail.send( | |||
['recipient1@example.com'], | |||
'from@example.com', | |||
template='welcome_email', | |||
context={'foo': 'bar'}, | |||
priority='now', | |||
attachments={ | |||
'attachment1.doc': '/path/to/file/file1.doc', | |||
'attachment2.txt': ContentFile('file content'), | |||
'attachment3.txt': { 'file': ContentFile('file content'), 'mimetype': 'text/plain'}, | |||
} | |||
) | |||
Template Tags and Variables | |||
--------------------------- | |||
``post-office`` supports Django's template tags and variables. | |||
For example, if you put "Hello, {{ name }}" in the subject line and pass in | |||
``{'name': 'Alice'}`` as context, you will get "Hello, Alice" as subject: | |||
.. code-block:: python | |||
from post_office.models import EmailTemplate | |||
from post_office import mail | |||
EmailTemplate.objects.create( | |||
name='morning_greeting', | |||
subject='Morning, {{ name|capfirst }}', | |||
content='Hi {{ name }}, how are you feeling today?', | |||
html_content='Hi <strong>{{ name }}</strong>, how are you feeling today?', | |||
) | |||
mail.send( | |||
['recipient@example.com'], | |||
'from@example.com', | |||
template='morning_greeting', | |||
context={'name': 'alice'}, | |||
) | |||
# This will create an email with the following content: | |||
subject = 'Morning, Alice', | |||
content = 'Hi alice, how are you feeling today?' | |||
content = 'Hi <strong>alice</strong>, how are you feeling today?' | |||
Multilingual Email Templates | |||
---------------------------- | |||
You can easily create email templates in various different languanges. | |||
For example: | |||
.. code-block:: python | |||
template = EmailTemplate.objects.create( | |||
name='hello', | |||
subject='Hello world!', | |||
) | |||
# Add an Indonesian version of this template: | |||
indonesian_template = template.translated_templates.create( | |||
language='id', | |||
subject='Halo Dunia!' | |||
) | |||
Sending an email using template in a non default languange is | |||
also similarly easy: | |||
.. code-block:: python | |||
mail.send( | |||
['recipient@example.com'], | |||
'from@example.com', | |||
template=template, # Sends using the default template | |||
) | |||
mail.send( | |||
['recipient@example.com'], | |||
'from@example.com', | |||
template=template, | |||
language='id', # Sends using Indonesian template | |||
) | |||
Custom Email Backends | |||
--------------------- | |||
By default, ``post_office`` uses django's ``smtp.EmailBackend``. If you want to | |||
use a different backend, you can do so by configuring ``BACKENDS``. | |||
For example if you want to use `django-ses <https://github.com/hmarr/django-ses>`_:: | |||
POST_OFFICE = { | |||
'BACKENDS': { | |||
'default': 'smtp.EmailBackend', | |||
'ses': 'django_ses.SESBackend', | |||
} | |||
} | |||
You can then choose what backend you want to use when sending mail: | |||
.. code-block:: python | |||
# If you omit `backend_alias` argument, `default` will be used | |||
mail.send( | |||
['recipient@example.com'], | |||
'from@example.com', | |||
subject='Hello', | |||
) | |||
# If you want to send using `ses` backend | |||
mail.send( | |||
['recipient@example.com'], | |||
'from@example.com', | |||
subject='Hello', | |||
backend='ses', | |||
) | |||
Management Commands | |||
------------------- | |||
* ``send_queued_mail`` - send queued emails, those aren't successfully sent | |||
will be marked as ``failed``. Accepts the following arguments: | |||
+---------------------------+--------------------------------------------------+ | |||
| Argument | Description | | |||
+---------------------------+--------------------------------------------------+ | |||
| ``--processes`` or ``-p`` | Number of parallel processes to send email. | | |||
| | Defaults to 1 | | |||
+---------------------------+--------------------------------------------------+ | |||
| ``--lockfile`` or ``-L`` | Full path to file used as lock file. Defaults to | | |||
| | ``/tmp/post_office.lock`` | | |||
+---------------------------+--------------------------------------------------+ | |||
* ``cleanup_mail`` - delete all emails created before an X number of days | |||
(defaults to 90). | |||
+---------------------------+--------------------------------------------------+ | |||
| Argument | Description | | |||
+---------------------------+--------------------------------------------------+ | |||
| ``--days`` or ``-d`` | Email older than this argument will be deleted. | | |||
| | Defaults to 90 | | |||
+---------------------------+--------------------------------------------------+ | |||
| ``--delete-attachments`` | Flag to delete orphaned attachment records and | | |||
| or ``-da`` | files on disk. If flag is not set, | | |||
| | on disk attachments files won't be deleted. | | |||
+---------------------------+--------------------------------------------------+ | |||
You may want to set these up via cron to run regularly:: | |||
* * * * * (cd $PROJECT; python manage.py send_queued_mail --processes=1 >> $PROJECT/cron_mail.log 2>&1) | |||
0 1 * * * (cd $PROJECT; python manage.py cleanup_mail --days=30 --delete-attachments >> $PROJECT/cron_mail_cleanup.log 2>&1) | |||
Settings | |||
======== | |||
This section outlines all the settings and configurations that you can put | |||
in Django's ``settings.py`` to fine tune ``post-office``'s behavior. | |||
Batch Size | |||
---------- | |||
If you may want to limit the number of emails sent in a batch (sometimes useful | |||
in a low memory environment), use the ``BATCH_SIZE`` argument to limit the | |||
number of queued emails fetched in one batch. | |||
.. code-block:: python | |||
# Put this in settings.py | |||
POST_OFFICE = { | |||
'BATCH_SIZE': 50 | |||
} | |||
Default Priority | |||
---------------- | |||
The default priority for emails is ``medium``, but this can be altered by | |||
setting ``DEFAULT_PRIORITY``. Integration with asynchronous email backends | |||
(e.g. based on Celery) becomes trivial when set to ``now``. | |||
.. code-block:: python | |||
# Put this in settings.py | |||
POST_OFFICE = { | |||
'DEFAULT_PRIORITY': 'now' | |||
} | |||
Log Level | |||
--------- | |||
The default log level is 2 (logs both successful and failed deliveries) | |||
This behavior can be changed by setting ``LOG_LEVEL``. | |||
.. code-block:: python | |||
# Put this in settings.py | |||
POST_OFFICE = { | |||
'LOG_LEVEL': 1 # Log only failed deliveries | |||
} | |||
The different options are: | |||
* ``0`` logs nothing | |||
* ``1`` logs only failed deliveries | |||
* ``2`` logs everything (both successful and failed delivery attempts) | |||
Sending Order | |||
------------- | |||
The default sending order for emails is ``-priority``, but this can be altered by | |||
setting ``SENDING_ORDER``. For example, if you want to send queued emails in FIFO order : | |||
.. code-block:: python | |||
# Put this in settings.py | |||
POST_OFFICE = { | |||
'SENDING_ORDER': ['created'] | |||
} | |||
Context Field Serializer | |||
------------------------ | |||
If you need to store complex Python objects for deferred rendering | |||
(i.e. setting ``render_on_delivery=True``), you can specify your own context | |||
field class to store context variables. For example if you want to use | |||
`django-picklefield <https://github.com/gintas/django-picklefield/tree/master/src/picklefield>`_: | |||
.. code-block:: python | |||
# Put this in settings.py | |||
POST_OFFICE = { | |||
'CONTEXT_FIELD_CLASS': 'picklefield.fields.PickledObjectField' | |||
} | |||
``CONTEXT_FIELD_CLASS`` defaults to ``jsonfield.JSONField``. | |||
Logging | |||
------- | |||
You can configure ``post-office``'s logging from Django's ``settings.py``. For | |||
example: | |||
.. code-block:: python | |||
LOGGING = { | |||
"version": 1, | |||
"disable_existing_loggers": False, | |||
"formatters": { | |||
"post_office": { | |||
"format": "[%(levelname)s]%(asctime)s PID %(process)d: %(message)s", | |||
"datefmt": "%d-%m-%Y %H:%M:%S", | |||
}, | |||
}, | |||
"handlers": { | |||
"post_office": { | |||
"level": "DEBUG", | |||
"class": "logging.StreamHandler", | |||
"formatter": "post_office" | |||
}, | |||
# If you use sentry for logging | |||
'sentry': { | |||
'level': 'ERROR', | |||
'class': 'raven.contrib.django.handlers.SentryHandler', | |||
}, | |||
}, | |||
'loggers': { | |||
"post_office": { | |||
"handlers": ["post_office", "sentry"], | |||
"level": "INFO" | |||
}, | |||
}, | |||
} | |||
Threads | |||
------- | |||
``post-office`` >= 3.0 allows you to use multiple threads to dramatically speed up | |||
the speed at which emails are sent. By default, ``post-office`` uses 5 threads per process. | |||
You can tweak this setting by changing ``THREADS_PER_PROCESS`` setting. | |||
This may dramatically increase the speed of bulk email delivery, depending on which email | |||
backends you use. In my tests, multi threading speeds up email backends that use HTTP based | |||
(REST) delivery mechanisms but doesn't seem to help SMTP based backends. | |||
.. code-block:: python | |||
# Put this in settings.py | |||
POST_OFFICE = { | |||
'THREADS_PER_PROCESS': 10 | |||
} | |||
Performance | |||
=========== | |||
Caching | |||
------- | |||
if Django's caching mechanism is configured, ``post_office`` will cache | |||
``EmailTemplate`` instances . If for some reason you want to disable caching, | |||
set ``POST_OFFICE_CACHE`` to ``False`` in ``settings.py``: | |||
.. code-block:: python | |||
## All cache key will be prefixed by post_office:template: | |||
## To turn OFF caching, you need to explicitly set POST_OFFICE_CACHE to False in settings | |||
POST_OFFICE_CACHE = False | |||
## Optional: to use a non default cache backend, add a "post_office" entry in CACHES | |||
CACHES = { | |||
'post_office': { | |||
'BACKEND': 'django.core.cache.backends.memcached.PyLibMCCache', | |||
'LOCATION': '127.0.0.1:11211', | |||
} | |||
} | |||
send_many() | |||
----------- | |||
``send_many()`` is much more performant (generates less database queries) when | |||
sending a large number of emails. ``send_many()`` is almost identical to ``mail.send()``, | |||
with the exception that it accepts a list of keyword arguments that you'd | |||
usually pass into ``mail.send()``: | |||
.. code-block:: python | |||
from post_office import mail | |||
first_email = { | |||
'sender': 'from@example.com', | |||
'recipients': ['alice@example.com'], | |||
'subject': 'Hi!', | |||
'message': 'Hi Alice!' | |||
} | |||
second_email = { | |||
'sender': 'from@example.com', | |||
'recipients': ['bob@example.com'], | |||
'subject': 'Hi!', | |||
'message': 'Hi Bob!' | |||
} | |||
kwargs_list = [first_email, second_email] | |||
mail.send_many(kwargs_list) | |||
Attachments are not supported with ``mail.send_many()``. | |||
Running Tests | |||
============= | |||
To run the test suite:: | |||
`which django-admin.py` test post_office --settings=post_office.test_settings --pythonpath=. | |||
You can run the full test suite with:: | |||
tox | |||
or:: | |||
python setup.py test | |||
Changelog | |||
========= | |||
Version 3.1.0 (2018-07-24) | |||
-------------------------- | |||
* Improvements to attachments are handled. Thanks @SeiryuZ! | |||
* Added ``--delete-attachments`` flag to ``cleanup_mail`` management command. Thanks @Seiryuz! | |||
* I18n improvements. Thanks @vsevolod-skripnik and @delneg! | |||
* Django admin improvements. Thanks @kakulukia! | |||
Version 3.0.4 | |||
------------- | |||
* Added compatibility with Django 2.0. Thanks @PreActionTech and @PetrDlouhy! | |||
* Added natural key support to `EmailTemplate` model. Thanks @maximlomakin! | |||
Version 3.0.2 | |||
------------- | |||
- Fixed memory leak when multiprocessing is used. | |||
- Fixed a possible error when adding a new email from Django admin. Thanks @ivlevdenis! | |||
Version 3.0.2 | |||
------------- | |||
- `_send_bulk` now properly catches exceptions when preparing email messages. | |||
Version 3.0.1 | |||
------------- | |||
- Fixed an infinite loop bug in `send_queued_mail` management command. | |||
Version 3.0.0 | |||
------------- | |||
* `_send_bulk` now allows each process to use multiple threads to send emails. | |||
* Added support for mimetypes in email attachments. Thanks @clickonchris! | |||
* An `EmailTemplate` can now be used as defaults multiple times in one language. Thanks @sac7e! | |||
* `send_queued_mail` management command will now check whether there are more queued emails to be sent before exiting. | |||
* Drop support for Django < 1.8. Thanks @fendyh! | |||
Full changelog can be found `here <https://github.com/ui/django-post_office/blob/master/CHANGELOG.md>`_. | |||
Created and maintained by the cool guys at `Stamps <https://stamps.co.id>`_, | |||
Indonesia's most elegant CRM/loyalty platform. | |||
.. |Build Status| image:: https://travis-ci.org/ui/django-post_office.png?branch=master | |||
:target: https://travis-ci.org/ui/django-post_office | |||
.. _uWSGI: https://uwsgi-docs.readthedocs.org/en/latest/ | |||
@@ -0,0 +1,95 @@ | |||
django_post_office-3.1.0.dist-info/DESCRIPTION.rst,sha256=fq6f9SdPAs7jJl7BWe_S9ko0vLKU4Yarl_f1aTu68_Q,22039 | |||
django_post_office-3.1.0.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4 | |||
django_post_office-3.1.0.dist-info/METADATA,sha256=ax9BP4Si7dNXTUuIQ5bMgtr0u0JvkEQjvDKdP8qXavY,23265 | |||
django_post_office-3.1.0.dist-info/RECORD,, | |||
django_post_office-3.1.0.dist-info/WHEEL,sha256=kdsN-5OJAZIiHN-iO4Rhl82KyS0bDWf4uBwMbkNafr8,110 | |||
django_post_office-3.1.0.dist-info/metadata.json,sha256=EM2Mho7BIVj-ZWfq4Cl6uwNx6as_NqbCf1MMU0qaxLU,1353 | |||
django_post_office-3.1.0.dist-info/top_level.txt,sha256=UM3cswoGuzxOuDS20Qu9QVSXmMCn5t3ZZniWAtZ6Dn4,12 | |||
post_office/__init__.py,sha256=eVpmOKhL7n_V2TQ2upYQRcfmVbrKEFFYsM71Hv3F738,114 | |||
post_office/__pycache__/__init__.cpython-36.pyc,, | |||
post_office/__pycache__/admin.cpython-36.pyc,, | |||
post_office/__pycache__/apps.cpython-36.pyc,, | |||
post_office/__pycache__/backends.cpython-36.pyc,, | |||
post_office/__pycache__/cache.cpython-36.pyc,, | |||
post_office/__pycache__/compat.cpython-36.pyc,, | |||
post_office/__pycache__/connections.cpython-36.pyc,, | |||
post_office/__pycache__/fields.cpython-36.pyc,, | |||
post_office/__pycache__/lockfile.cpython-36.pyc,, | |||
post_office/__pycache__/logutils.cpython-36.pyc,, | |||
post_office/__pycache__/mail.cpython-36.pyc,, | |||
post_office/__pycache__/models.cpython-36.pyc,, | |||
post_office/__pycache__/settings.cpython-36.pyc,, | |||
post_office/__pycache__/test_settings.cpython-36.pyc,, | |||
post_office/__pycache__/test_urls.cpython-36.pyc,, | |||
post_office/__pycache__/utils.cpython-36.pyc,, | |||
post_office/__pycache__/validators.cpython-36.pyc,, | |||
post_office/__pycache__/views.cpython-36.pyc,, | |||
post_office/admin.py,sha256=wQqT_r9mBJ3E2t_aKSF2T8WHxgly6a4UmcyF3eyDA7Q,4922 | |||
post_office/apps.py,sha256=dv8AJIBvIec64Xy2BFKKd1R-8eEbLT0XBBiHXWQzS5Q,188 | |||
post_office/backends.py,sha256=5s2UTtFz2DIdaJMtzJV06bm4pP6IsaSrx1eHt3qK6yY,1855 | |||
post_office/cache.py,sha256=O39Foxqg8gxn2TBaTiT6wlyTKFE7Y6F-rSPLrB6ESkk,646 | |||
post_office/compat.py,sha256=5adNRlBcuvNqwDBjIDnbX2wVabGnj3fQTRr9e5PT-0I,1076 | |||
post_office/connections.py,sha256=REO_ns9KT42rhBFX4b8ABzBpkGiR8uO429j4DSOhKeU,1145 | |||
post_office/fields.py,sha256=9MgoIuXNm6mqrAuTqzMxfd4JiOSFx7DXWILXWnOU7ds,1973 | |||
post_office/locale/de/LC_MESSAGES/django.mo,sha256=JGLLjuEXSDqKzq0RnBjnIZX23aV0988oHNGCgD9J3sk,1632 | |||
post_office/locale/de/LC_MESSAGES/django.po,sha256=WBTJcJK_0SGC6O4nbE2DMaCLdrmQRHyE1RRRUbedmQE,2395 | |||
post_office/locale/it/LC_MESSAGES/django.mo,sha256=6hqUjb2Y3T21QnOya5bgY-gkMi4QhJxEoKv6QSAF9Mg,1611 | |||
post_office/locale/it/LC_MESSAGES/django.po,sha256=h-hoj6fTJwcgVB9vf9UKsEvzjWJ2nmYFoDj5JlYQro0,2379 | |||
post_office/locale/pl/LC_MESSAGES/django.mo,sha256=k_bOkQR787kAqHKUGxcRco5Mkggl2Hwr_bR3d05q6Ug,2698 | |||
post_office/locale/pl/LC_MESSAGES/django.po,sha256=tDDtQeMufPFoSYSgILfvgCSzqnpRG6V_Sngbwwhsv_4,4300 | |||
post_office/locale/ru_RU/LC_MESSAGES/django.mo,sha256=Op7j2z0Hii-uT6WVa7aqen60Rahz1RNXyp7GoHPnQ_E,3274 | |||
post_office/locale/ru_RU/LC_MESSAGES/django.po,sha256=UIsDRCnv0tc11sjFSUgJjOLcXGr-lHgIpp2ZOcAhrls,4901 | |||
post_office/lockfile.py,sha256=RS3c_b5jWi1z1pGH-7TsT8gwN5ueDdtHSpiIkbE64l4,4594 | |||
post_office/logutils.py,sha256=gTa5EeuZu3UHiyR-4XvGVl-LRJ1vM8lHoLW274wTDNs,1066 | |||
post_office/mail.py,sha256=O8kZsccs30rKpDAreIM6mC6YfdI72Mu0gv_Fb-ks8DY,10135 | |||
post_office/management/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 | |||
post_office/management/__pycache__/__init__.cpython-36.pyc,, | |||
post_office/management/commands/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 | |||
post_office/management/commands/__pycache__/__init__.cpython-36.pyc,, | |||
post_office/management/commands/__pycache__/cleanup_mail.cpython-36.pyc,, | |||
post_office/management/commands/__pycache__/send_queued_mail.cpython-36.pyc,, | |||
post_office/management/commands/cleanup_mail.py,sha256=LKO3SWjbIMzlXsbUDychhkX3Lt9AO8eZdtAdYo0Nin0,1412 | |||
post_office/management/commands/send_queued_mail.py,sha256=ALkDylHVflNLydBTI96iBl6xXJtCW-W1ziG4BFhOisA,1999 | |||
post_office/migrations/0001_initial.py,sha256=7TaB2WEljVeM1niR6N_cYWz4PwXT5mxirHRlfwIdJeg,4635 | |||
post_office/migrations/0002_add_i18n_and_backend_alias.py,sha256=BtWyGrAybC5n7VhUup3GiREZJk6iMgxmSgMAy0IzmeE,5489 | |||
post_office/migrations/0003_longer_subject.py,sha256=ZnXopRJEeY_gfvj01t5lthde98HJZa91W3GrmyXp3lg,2749 | |||
post_office/migrations/0004_auto_20160607_0901.py,sha256=37BgmxLeGYL0RzYI-5flsCKs3obgqzNQmWEVZyitTLQ,5025 | |||
post_office/migrations/0005_auto_20170515_0013.py,sha256=EuA_SPdWF1e-TVBTzrO-z1sFZbiJaPV1ClLhw_GPlvc,456 | |||
post_office/migrations/0006_attachment_mimetype.py,sha256=UBDnxzP1KWCaEy1nBYQW6Zuv2EB-36w82jBxVAlkhgY,435 | |||
post_office/migrations/0007_auto_20170731_1342.py,sha256=LK8zW5vrxdjG07w5NMr3kisgdx-aBu1PEu2H-LPaNgM,498 | |||
post_office/migrations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 | |||
post_office/migrations/__pycache__/0001_initial.cpython-36.pyc,, | |||
post_office/migrations/__pycache__/0002_add_i18n_and_backend_alias.cpython-36.pyc,, | |||
post_office/migrations/__pycache__/0003_longer_subject.cpython-36.pyc,, | |||
post_office/migrations/__pycache__/0004_auto_20160607_0901.cpython-36.pyc,, | |||
post_office/migrations/__pycache__/0005_auto_20170515_0013.cpython-36.pyc,, | |||
post_office/migrations/__pycache__/0006_attachment_mimetype.cpython-36.pyc,, | |||
post_office/migrations/__pycache__/0007_auto_20170731_1342.cpython-36.pyc,, | |||
post_office/migrations/__pycache__/__init__.cpython-36.pyc,, | |||
post_office/models.py,sha256=1WOu1px_vWwJzRAtmfoxfIRRx5jPOhjTS2cwltZ6GPM,10942 | |||
post_office/settings.py,sha256=PlKALPJvOo9AK326JjSMqI6U5-aO-aZSHqn8cSZUjaI,2584 | |||
post_office/test_settings.py,sha256=DkeMMDFiF6s2PCW0FoHv9pXJ8Lu5M5Vo25hdzKV-y3I,2293 | |||
post_office/test_urls.py,sha256=jFmkObKRb7-bE2nWqfQ49eSC51F3ayWRCrtymaEr378,123 | |||
post_office/tests/__init__.py,sha256=r8KNPnxhYYPrY_1Ko9SSo7Y1DcHlrZL2h6dRtqDePXk,287 | |||
post_office/tests/__pycache__/__init__.cpython-36.pyc,, | |||
post_office/tests/__pycache__/test_backends.cpython-36.pyc,, | |||
post_office/tests/__pycache__/test_cache.cpython-36.pyc,, | |||
post_office/tests/__pycache__/test_commands.cpython-36.pyc,, | |||
post_office/tests/__pycache__/test_connections.cpython-36.pyc,, | |||
post_office/tests/__pycache__/test_lockfile.cpython-36.pyc,, | |||
post_office/tests/__pycache__/test_mail.cpython-36.pyc,, | |||
post_office/tests/__pycache__/test_models.cpython-36.pyc,, | |||
post_office/tests/__pycache__/test_utils.cpython-36.pyc,, | |||
post_office/tests/__pycache__/test_views.cpython-36.pyc,, | |||
post_office/tests/test_backends.py,sha256=ZCP30v7ZO_sqsW-HHmIqQYWYE7DsQCQZGG7mxXaZhPc,4636 | |||
post_office/tests/test_cache.py,sha256=qKxuSytd8md9JgOFn_R1C0wLEt4ta_akN0c7A6Mwa9E,1437 | |||
post_office/tests/test_commands.py,sha256=NXijHn86wXaVrE1hcxiKvJKx-Tn4Ce6YmRPNqyt5U6A,6059 | |||
post_office/tests/test_connections.py,sha256=QL_EIy_Pjdv4dCNtLLEF_h2Vso3DrObcG1C4Ds06AIw,459 | |||
post_office/tests/test_lockfile.py,sha256=MMLgRhwV5xW72DlWR62yM_KMqQCutCfbDx8iWNs_Sas,1943 | |||
post_office/tests/test_mail.py,sha256=fJydVnGF5GRojLg2Oq2l_f_RyJQjVlmxMo5iFXUIREo,16449 | |||
post_office/tests/test_models.py,sha256=_1gtj0yL6ur1MQ2o_iq567TXAib1oReD1LB_LilNSvo,14960 | |||
post_office/tests/test_utils.py,sha256=_AqeqTZZmsGDQHosVeESOvzMCQ2uD_R0hbMCJrxDIso,8476 | |||
post_office/tests/test_views.py,sha256=feYG3bTnQDVRYDKCIsKX_UJerKEaegZXGze20NHrT_Y,1208 | |||
post_office/utils.py,sha256=aC8oilDFjQKBtNgZc__GhVX_y22cQrFPU5oKZ-CX-sc,4556 | |||
post_office/validators.py,sha256=q8umHXtqM4F93vh0g6m7x-U2eM347w9L6qVXXelF-v4,1409 | |||
post_office/views.py,sha256=F42JXgnqFqK0fajXeutyJJxwOszRxoLMNkIhfc4Z7KI,26 |
@@ -0,0 +1,6 @@ | |||
Wheel-Version: 1.0 | |||
Generator: bdist_wheel (0.30.0) | |||
Root-Is-Purelib: true | |||
Tag: py2-none-any | |||
Tag: py3-none-any | |||
@@ -0,0 +1 @@ | |||
{"classifiers": ["Development Status :: 5 - Production/Stable", "Environment :: Web Environment", "Framework :: Django", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 2", "Programming Language :: Python :: 2.7", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.3", "Programming Language :: Python :: 3.4", "Programming Language :: Python :: 3.5", "Programming Language :: Python :: 3.6", "Topic :: Internet :: WWW/HTTP", "Topic :: Software Development :: Libraries :: Python Modules"], "description_content_type": "UNKNOWN", "extensions": {"python.details": {"contacts": [{"email": "selwin.ong@gmail.com", "name": "Selwin Ong", "role": "author"}], "document_names": {"description": "DESCRIPTION.rst"}, "project_urls": {"Home": "https://github.com/ui/django-post_office"}}}, "extras": ["test"], "generator": "bdist_wheel (0.30.0)", "license": "MIT", "metadata_version": "2.0", "name": "django-post-office", "run_requires": [{"requires": ["django (>=1.8)", "jsonfield"]}, {"extra": "test", "requires": ["tox (>=2.3)"]}], "summary": "A Django app to monitor and send mail asynchronously, complete with template support.", "test_requires": [{"requires": ["tox (>=2.3)"]}], "version": "3.1.0"} |
@@ -0,0 +1 @@ | |||
post_office |
@@ -0,0 +1,121 @@ | |||
django-jsonfield | |||
---------------- | |||
django-jsonfield is a reusable Django field that allows you to store validated JSON in your model. | |||
It silently takes care of serialization. To use, simply add the field to one of your models. | |||
Python 3 & Django 1.8 through 1.11 supported! | |||
**Use PostgreSQL?** 1.0.0 introduced a breaking change to the underlying data type, so if you were using < 1.0.0 please read https://github.com/dmkoch/django-jsonfield/issues/57 before upgrading. Also, consider switching to Django's native JSONField that was added in Django 1.9. | |||
**Note:** There are a couple of third-party add-on JSONFields for Django. This project is django-jsonfield here on GitHub but is named `jsonfield on PyPI`_. There is another `django-jsonfield on Bitbucket`_, but that one is `django-jsonfield on PyPI`_. I realize this naming conflict is confusing and I am open to merging the two projects. | |||
.. _jsonfield on PyPI: https://pypi.python.org/pypi/jsonfield | |||
.. _django-jsonfield on Bitbucket: https://bitbucket.org/schinckel/django-jsonfield | |||
.. _django-jsonfield on PyPI: https://pypi.python.org/pypi/django-jsonfield | |||
**Note:** Django 1.9 added native PostgreSQL JSON support in `django.contrib.postgres.fields.JSONField`_. This module is still useful if you need to support JSON in databases other than PostgreSQL or are creating a third-party module that needs to be database-agnostic. But if you're an end user using PostgreSQL and want full-featured JSON support, I recommend using the built-in JSONField from Django instead of this module. | |||
.. _django.contrib.postgres.fields.JSONField: https://docs.djangoproject.com/en/dev/ref/contrib/postgres/fields/#jsonfield | |||
**Note:** Semver is followed after the 1.0 release. | |||
Installation | |||
------------ | |||
.. code-block:: python | |||
pip install jsonfield | |||
Usage | |||
----- | |||
.. code-block:: python | |||
from django.db import models | |||
from jsonfield import JSONField | |||
class MyModel(models.Model): | |||
json = JSONField() | |||
Advanced Usage | |||
-------------- | |||
By default python deserializes json into dict objects. This behavior differs from the standard json behavior because python dicts do not have ordered keys. | |||
To overcome this limitation and keep the sort order of OrderedDict keys the deserialisation can be adjusted on model initialisation: | |||
.. code-block:: python | |||
import collections | |||
class MyModel(models.Model): | |||
json = JSONField(load_kwargs={'object_pairs_hook': collections.OrderedDict}) | |||
Other Fields | |||
------------ | |||
**jsonfield.JSONCharField** | |||
If you need to use your JSON field in an index or other constraint, you can use **JSONCharField** which subclasses **CharField** instead of **TextField**. You'll also need to specify a **max_length** parameter if you use this field. | |||
Compatibility | |||
-------------- | |||
django-jsonfield aims to support the same versions of Django currently maintained by the main Django project. See `Django supported versions`_, currently: | |||
* Django 1.8 (LTS) with Python 2.7, 3.3, 3.4, or 3.5 | |||
* Django 1.9 with Python 2.7, 3.4, or 3.5 | |||
* Django 1.10 with Python 2.7, 3.4, or 3.5 | |||
* Django 1.11 (LTS) with Python 2.7, 3.4, 3.5 or 3.6 | |||
.. _Django supported versions: https://www.djangoproject.com/download/#supported-versions | |||
Testing django-jsonfield Locally | |||
-------------------------------- | |||
To test against all supported versions of Django: | |||
.. code-block:: shell | |||
$ docker-compose build && docker-compose up | |||
Or just one version (for example Django 1.10 on Python 3.5): | |||
.. code-block:: shell | |||
$ docker-compose build && docker-compose run tox tox -e py35-1.10 | |||
Travis CI | |||
--------- | |||
.. image:: https://travis-ci.org/dmkoch/django-jsonfield.svg?branch=master | |||
:target: https://travis-ci.org/dmkoch/django-jsonfield | |||
Contact | |||
------- | |||
Web: http://bradjasper.com | |||
Twitter: `@bradjasper`_ | |||
Email: `contact@bradjasper.com`_ | |||
.. _contact@bradjasper.com: mailto:contact@bradjasper.com | |||
.. _@bradjasper: https://twitter.com/bradjasper | |||
Changes | |||
------- | |||
Take a look at the `changelog`_. | |||
.. _changelog: https://github.com/dmkoch/django-jsonfield/blob/master/CHANGES.rst | |||
@@ -0,0 +1 @@ | |||
pip |
@@ -0,0 +1,143 @@ | |||
Metadata-Version: 2.0 | |||
Name: jsonfield | |||
Version: 2.0.2 | |||
Summary: A reusable Django field that allows you to store validated JSON in your model. | |||
Home-page: https://github.com/dmkoch/django-jsonfield/ | |||
Author: Dan Koch | |||
Author-email: dmkoch@gmail.com | |||
License: MIT | |||
Platform: UNKNOWN | |||
Classifier: Environment :: Web Environment | |||
Classifier: Intended Audience :: Developers | |||
Classifier: Operating System :: OS Independent | |||
Classifier: Programming Language :: Python | |||
Classifier: Programming Language :: Python :: 2.7 | |||
Classifier: Programming Language :: Python :: 3 | |||
Classifier: Programming Language :: Python :: 3.3 | |||
Classifier: Programming Language :: Python :: 3.4 | |||
Classifier: Programming Language :: Python :: 3.5 | |||
Classifier: Programming Language :: Python :: 3.6 | |||
Classifier: Framework :: Django | |||
Requires-Dist: Django (>=1.8.0) | |||
django-jsonfield | |||
---------------- | |||
django-jsonfield is a reusable Django field that allows you to store validated JSON in your model. | |||
It silently takes care of serialization. To use, simply add the field to one of your models. | |||
Python 3 & Django 1.8 through 1.11 supported! | |||
**Use PostgreSQL?** 1.0.0 introduced a breaking change to the underlying data type, so if you were using < 1.0.0 please read https://github.com/dmkoch/django-jsonfield/issues/57 before upgrading. Also, consider switching to Django's native JSONField that was added in Django 1.9. | |||
**Note:** There are a couple of third-party add-on JSONFields for Django. This project is django-jsonfield here on GitHub but is named `jsonfield on PyPI`_. There is another `django-jsonfield on Bitbucket`_, but that one is `django-jsonfield on PyPI`_. I realize this naming conflict is confusing and I am open to merging the two projects. | |||
.. _jsonfield on PyPI: https://pypi.python.org/pypi/jsonfield | |||
.. _django-jsonfield on Bitbucket: https://bitbucket.org/schinckel/django-jsonfield | |||
.. _django-jsonfield on PyPI: https://pypi.python.org/pypi/django-jsonfield | |||
**Note:** Django 1.9 added native PostgreSQL JSON support in `django.contrib.postgres.fields.JSONField`_. This module is still useful if you need to support JSON in databases other than PostgreSQL or are creating a third-party module that needs to be database-agnostic. But if you're an end user using PostgreSQL and want full-featured JSON support, I recommend using the built-in JSONField from Django instead of this module. | |||
.. _django.contrib.postgres.fields.JSONField: https://docs.djangoproject.com/en/dev/ref/contrib/postgres/fields/#jsonfield | |||
**Note:** Semver is followed after the 1.0 release. | |||
Installation | |||
------------ | |||
.. code-block:: python | |||
pip install jsonfield | |||
Usage | |||
----- | |||
.. code-block:: python | |||
from django.db import models | |||
from jsonfield import JSONField | |||
class MyModel(models.Model): | |||
json = JSONField() | |||
Advanced Usage | |||
-------------- | |||
By default python deserializes json into dict objects. This behavior differs from the standard json behavior because python dicts do not have ordered keys. | |||
To overcome this limitation and keep the sort order of OrderedDict keys the deserialisation can be adjusted on model initialisation: | |||
.. code-block:: python | |||
import collections | |||
class MyModel(models.Model): | |||
json = JSONField(load_kwargs={'object_pairs_hook': collections.OrderedDict}) | |||
Other Fields | |||
------------ | |||
**jsonfield.JSONCharField** | |||
If you need to use your JSON field in an index or other constraint, you can use **JSONCharField** which subclasses **CharField** instead of **TextField**. You'll also need to specify a **max_length** parameter if you use this field. | |||
Compatibility | |||
-------------- | |||
django-jsonfield aims to support the same versions of Django currently maintained by the main Django project. See `Django supported versions`_, currently: | |||
* Django 1.8 (LTS) with Python 2.7, 3.3, 3.4, or 3.5 | |||
* Django 1.9 with Python 2.7, 3.4, or 3.5 | |||
* Django 1.10 with Python 2.7, 3.4, or 3.5 | |||
* Django 1.11 (LTS) with Python 2.7, 3.4, 3.5 or 3.6 | |||
.. _Django supported versions: https://www.djangoproject.com/download/#supported-versions | |||
Testing django-jsonfield Locally | |||
-------------------------------- | |||
To test against all supported versions of Django: | |||
.. code-block:: shell | |||
$ docker-compose build && docker-compose up | |||
Or just one version (for example Django 1.10 on Python 3.5): | |||
.. code-block:: shell | |||
$ docker-compose build && docker-compose run tox tox -e py35-1.10 | |||
Travis CI | |||
--------- | |||
.. image:: https://travis-ci.org/dmkoch/django-jsonfield.svg?branch=master | |||
:target: https://travis-ci.org/dmkoch/django-jsonfield | |||
Contact | |||
------- | |||
Web: http://bradjasper.com | |||
Twitter: `@bradjasper`_ | |||
Email: `contact@bradjasper.com`_ | |||
.. _contact@bradjasper.com: mailto:contact@bradjasper.com | |||
.. _@bradjasper: https://twitter.com/bradjasper | |||
Changes | |||
------- | |||
Take a look at the `changelog`_. | |||
.. _changelog: https://github.com/dmkoch/django-jsonfield/blob/master/CHANGES.rst | |||
@@ -0,0 +1,19 @@ | |||
jsonfield-2.0.2.dist-info/DESCRIPTION.rst,sha256=ol_8lnYqTVXq5ExDwAmbBhSUylv5skifu1MSqZJUszU,4077 | |||
jsonfield-2.0.2.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4 | |||
jsonfield-2.0.2.dist-info/METADATA,sha256=fILysyClwPP4RqmlbgBEyjyhmBkYfqG_laVx_21m19M,4892 | |||
jsonfield-2.0.2.dist-info/RECORD,, | |||
jsonfield-2.0.2.dist-info/WHEEL,sha256=o2k-Qa-RMNIJmUdIc7KU6VWR_ErNRbWNlxDIpl7lm34,110 | |||
jsonfield-2.0.2.dist-info/metadata.json,sha256=EkZdQOU_zbNU_Uf6f6l2RPH3n9HoVNU0X8V0DbM5LIY,1010 | |||
jsonfield-2.0.2.dist-info/top_level.txt,sha256=vKhrOliM1tJJBXUhSXSoHcWs_90pUa7ogHvn-mzxGKQ,10 | |||
jsonfield/__init__.py,sha256=JzGSlVByVSYtIsp9iNf1S8pgxFdZ3ZY6y9Uys8hk2hs,53 | |||
jsonfield/__pycache__/__init__.cpython-36.pyc,, | |||
jsonfield/__pycache__/encoder.cpython-36.pyc,, | |||
jsonfield/__pycache__/fields.cpython-36.pyc,, | |||
jsonfield/__pycache__/models.cpython-36.pyc,, | |||
jsonfield/__pycache__/subclassing.cpython-36.pyc,, | |||
jsonfield/__pycache__/tests.cpython-36.pyc,, | |||
jsonfield/encoder.py,sha256=LamzI8S3leLOW0RG80-YUb2oxZxtUhuHXpKogYadL2w,2306 | |||
jsonfield/fields.py,sha256=09LBgXPcTxo6-rDwVewn8NhwtZ9yWe1DpoZehGVAcjk,6064 | |||
jsonfield/models.py,sha256=yXIA5LSYKowbs8bQWcU1TJ4Yc10MdEP5zWS5QD5P38E,43 | |||
jsonfield/subclassing.py,sha256=g1aGtzSlz3OisbQZgRtjDXVvQEqh0jNcMvZvBWHZ_ow,2236 | |||
jsonfield/tests.py,sha256=c0E2QV2l2gc3OLTVEOsePHl8Nt3gdaTcU02ShZksAOo,14949 |
@@ -0,0 +1,6 @@ | |||
Wheel-Version: 1.0 | |||
Generator: bdist_wheel (0.29.0) | |||
Root-Is-Purelib: true | |||
Tag: py2-none-any | |||
Tag: py3-none-any | |||
@@ -0,0 +1 @@ | |||
{"classifiers": ["Environment :: Web Environment", "Intended Audience :: Developers", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 2.7", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.3", "Programming Language :: Python :: 3.4", "Programming Language :: Python :: 3.5", "Programming Language :: Python :: 3.6", "Framework :: Django"], "extensions": {"python.details": {"contacts": [{"email": "dmkoch@gmail.com", "name": "Dan Koch", "role": "author"}], "document_names": {"description": "DESCRIPTION.rst"}, "project_urls": {"Home": "https://github.com/dmkoch/django-jsonfield/"}}}, "extras": [], "generator": "bdist_wheel (0.29.0)", "license": "MIT", "metadata_version": "2.0", "name": "jsonfield", "run_requires": [{"requires": ["Django (>=1.8.0)"]}], "summary": "A reusable Django field that allows you to store validated JSON in your model.", "test_requires": [{"requires": ["Django (>=1.8.0)"]}], "version": "2.0.2"} |
@@ -0,0 +1 @@ | |||
jsonfield |
@@ -0,0 +1 @@ | |||
from .fields import JSONField, JSONCharField # noqa |
@@ -0,0 +1,58 @@ | |||
from django.db.models.query import QuerySet | |||
from django.utils import six, timezone | |||
from django.utils.encoding import force_text | |||
from django.utils.functional import Promise | |||
import datetime | |||
import decimal | |||
import json | |||
import uuid | |||
class JSONEncoder(json.JSONEncoder): | |||
""" | |||
JSONEncoder subclass that knows how to encode date/time/timedelta, | |||
decimal types, generators and other basic python objects. | |||
Taken from https://github.com/tomchristie/django-rest-framework/blob/master/rest_framework/utils/encoders.py | |||
""" | |||
def default(self, obj): # noqa | |||
# For Date Time string spec, see ECMA 262 | |||
# http://ecma-international.org/ecma-262/5.1/#sec-15.9.1.15 | |||
if isinstance(obj, Promise): | |||
return force_text(obj) | |||
elif isinstance(obj, datetime.datetime): | |||
representation = obj.isoformat() | |||
if obj.microsecond: | |||
representation = representation[:23] + representation[26:] | |||
if representation.endswith('+00:00'): | |||
representation = representation[:-6] + 'Z' | |||
return representation | |||
elif isinstance(obj, datetime.date): | |||
return obj.isoformat() | |||
elif isinstance(obj, datetime.time): | |||
if timezone and timezone.is_aware(obj): | |||
raise ValueError("JSON can't represent timezone-aware times.") | |||
representation = obj.isoformat() | |||
if obj.microsecond: | |||
representation = representation[:12] | |||
return representation | |||
elif isinstance(obj, datetime.timedelta): | |||
return six.text_type(obj.total_seconds()) | |||
elif isinstance(obj, decimal.Decimal): | |||
# Serializers will coerce decimals to strings by default. | |||
return float(obj) | |||
elif isinstance(obj, uuid.UUID): | |||
return six.text_type(obj) | |||
elif isinstance(obj, QuerySet): | |||
return tuple(obj) | |||
elif hasattr(obj, 'tolist'): | |||
# Numpy arrays and array scalars. | |||
return obj.tolist() | |||
elif hasattr(obj, '__getitem__'): | |||
try: | |||
return dict(obj) | |||
except: | |||
pass | |||
elif hasattr(obj, '__iter__'): | |||
return tuple(item for item in obj) | |||
return super(JSONEncoder, self).default(obj) |
@@ -0,0 +1,183 @@ | |||
import copy | |||
from django.db import models | |||
from django.utils.translation import ugettext_lazy as _ | |||
try: | |||
from django.utils import six | |||
except ImportError: | |||
import six | |||
try: | |||
import json | |||
except ImportError: | |||
from django.utils import simplejson as json | |||
from django.forms import fields | |||
try: | |||
from django.forms.utils import ValidationError | |||
except ImportError: | |||
from django.forms.util import ValidationError | |||
from .subclassing import SubfieldBase | |||
from .encoder import JSONEncoder | |||
class JSONFormFieldBase(object): | |||
def __init__(self, *args, **kwargs): | |||
self.load_kwargs = kwargs.pop('load_kwargs', {}) | |||
super(JSONFormFieldBase, self).__init__(*args, **kwargs) | |||
def to_python(self, value): | |||
if isinstance(value, six.string_types) and value: | |||
try: | |||
return json.loads(value, **self.load_kwargs) | |||
except ValueError: | |||
raise ValidationError(_("Enter valid JSON")) | |||
return value | |||
def clean(self, value): | |||
if not value and not self.required: | |||
return None | |||
# Trap cleaning errors & bubble them up as JSON errors | |||
try: | |||
return super(JSONFormFieldBase, self).clean(value) | |||
except TypeError: | |||
raise ValidationError(_("Enter valid JSON")) | |||
class JSONFormField(JSONFormFieldBase, fields.CharField): | |||
pass | |||
class JSONCharFormField(JSONFormFieldBase, fields.CharField): | |||
pass | |||
class JSONFieldBase(six.with_metaclass(SubfieldBase, models.Field)): | |||
def __init__(self, *args, **kwargs): | |||
self.dump_kwargs = kwargs.pop('dump_kwargs', { | |||
'cls': JSONEncoder, | |||
'separators': (',', ':') | |||
}) | |||
self.load_kwargs = kwargs.pop('load_kwargs', {}) | |||
super(JSONFieldBase, self).__init__(*args, **kwargs) | |||
def pre_init(self, value, obj): | |||
"""Convert a string value to JSON only if it needs to be deserialized. | |||
SubfieldBase metaclass has been modified to call this method instead of | |||
to_python so that we can check the obj state and determine if it needs to be | |||
deserialized""" | |||
try: | |||
if obj._state.adding: | |||
# Make sure the primary key actually exists on the object before | |||
# checking if it's empty. This is a special case for South datamigrations | |||
# see: https://github.com/bradjasper/django-jsonfield/issues/52 | |||
if getattr(obj, "pk", None) is not None: | |||
if isinstance(value, six.string_types): | |||
try: | |||
return json.loads(value, **self.load_kwargs) | |||
except ValueError: | |||
raise ValidationError(_("Enter valid JSON")) | |||
except AttributeError: | |||
# south fake meta class doesn't create proper attributes | |||
# see this: | |||
# https://github.com/bradjasper/django-jsonfield/issues/52 | |||
pass | |||
return value | |||
def to_python(self, value): | |||
"""The SubfieldBase metaclass calls pre_init instead of to_python, however to_python | |||
is still necessary for Django's deserializer""" | |||
return value | |||
def get_prep_value(self, value): | |||
"""Convert JSON object to a string""" | |||
if self.null and value is None: | |||
return None | |||
return json.dumps(value, **self.dump_kwargs) | |||
def _get_val_from_obj(self, obj): | |||
# This function created to replace Django deprecated version | |||
# https://code.djangoproject.com/ticket/24716 | |||
if obj is not None: | |||
return getattr(obj, self.attname) | |||
else: | |||
return self.get_default() | |||
def value_to_string(self, obj): | |||
value = self._get_val_from_obj(obj) | |||
return self.get_db_prep_value(value, None) | |||
def value_from_object(self, obj): | |||
value = super(JSONFieldBase, self).value_from_object(obj) | |||
if self.null and value is None: | |||
return None | |||
return self.dumps_for_display(value) | |||
def dumps_for_display(self, value): | |||
return json.dumps(value, **self.dump_kwargs) | |||
def formfield(self, **kwargs): | |||
if "form_class" not in kwargs: | |||
kwargs["form_class"] = self.form_class | |||
field = super(JSONFieldBase, self).formfield(**kwargs) | |||
if isinstance(field, JSONFormFieldBase): | |||
field.load_kwargs = self.load_kwargs | |||
if not field.help_text: | |||
field.help_text = "Enter valid JSON" | |||
return field | |||
def get_default(self): | |||
""" | |||
Returns the default value for this field. | |||
The default implementation on models.Field calls force_unicode | |||
on the default, which means you can't set arbitrary Python | |||
objects as the default. To fix this, we just return the value | |||
without calling force_unicode on it. Note that if you set a | |||
callable as a default, the field will still call it. It will | |||
*not* try to pickle and encode it. | |||
""" | |||
if self.has_default(): | |||
if callable(self.default): | |||
return self.default() | |||
return copy.deepcopy(self.default) | |||
# If the field doesn't have a default, then we punt to models.Field. | |||
return super(JSONFieldBase, self).get_default() | |||
class JSONField(JSONFieldBase, models.TextField): | |||
"""JSONField is a generic textfield that serializes/deserializes JSON objects""" | |||
form_class = JSONFormField | |||
def dumps_for_display(self, value): | |||
kwargs = {"indent": 2} | |||
kwargs.update(self.dump_kwargs) | |||
return json.dumps(value, **kwargs) | |||
class JSONCharField(JSONFieldBase, models.CharField): | |||
"""JSONCharField is a generic textfield that serializes/deserializes JSON objects, | |||
stored in the database like a CharField, which enables it to be used | |||
e.g. in unique keys""" | |||
form_class = JSONCharFormField | |||
try: | |||
from south.modelsinspector import add_introspection_rules | |||
add_introspection_rules([], ["^jsonfield\.fields\.(JSONField|JSONCharField)"]) | |||
except ImportError: | |||
pass |
@@ -0,0 +1 @@ | |||
# Django needs this to see it as a project |
@@ -0,0 +1,62 @@ | |||
# This file was copied from django.db.models.fields.subclassing so that we could | |||
# change the Creator.__set__ behavior. Read the comment below for full details. | |||
""" | |||
Convenience routines for creating non-trivial Field subclasses, as well as | |||
backwards compatibility utilities. | |||
Add SubfieldBase as the __metaclass__ for your Field subclass, implement | |||
to_python() and the other necessary methods and everything will work seamlessly. | |||
""" | |||
class SubfieldBase(type): | |||
""" | |||
A metaclass for custom Field subclasses. This ensures the model's attribute | |||
has the descriptor protocol attached to it. | |||
""" | |||
def __new__(cls, name, bases, attrs): | |||
new_class = super(SubfieldBase, cls).__new__(cls, name, bases, attrs) | |||
new_class.contribute_to_class = make_contrib( | |||
new_class, attrs.get('contribute_to_class') | |||
) | |||
return new_class | |||
class Creator(object): | |||
""" | |||
A placeholder class that provides a way to set the attribute on the model. | |||
""" | |||
def __init__(self, field): | |||
self.field = field | |||
def __get__(self, obj, type=None): | |||
if obj is None: | |||
return self | |||
return obj.__dict__[self.field.name] | |||
def __set__(self, obj, value): | |||
# Usually this would call to_python, but we've changed it to pre_init | |||
# so that we can tell which state we're in. By passing an obj, | |||
# we can definitively tell if a value has already been deserialized | |||
# More: https://github.com/bradjasper/django-jsonfield/issues/33 | |||
obj.__dict__[self.field.name] = self.field.pre_init(value, obj) | |||
def make_contrib(superclass, func=None): | |||
""" | |||
Returns a suitable contribute_to_class() method for the Field subclass. | |||
If 'func' is passed in, it is the existing contribute_to_class() method on | |||
the subclass and it is called before anything else. It is assumed in this | |||
case that the existing contribute_to_class() calls all the necessary | |||
superclass methods. | |||
""" | |||
def contribute_to_class(self, cls, name): | |||
if func: | |||
func(self, cls, name) | |||
else: | |||
super(superclass, self).contribute_to_class(cls, name) | |||
setattr(cls, self.name, Creator(self)) | |||
return contribute_to_class |
@@ -0,0 +1,392 @@ | |||
from decimal import Decimal | |||
import django | |||
from django import forms | |||
from django.core.serializers import deserialize, serialize | |||
from django.core.serializers.base import DeserializationError | |||
from django.contrib.contenttypes.fields import GenericForeignKey | |||
from django.contrib.contenttypes.models import ContentType | |||
from django.db import models | |||
from django.test import TestCase | |||
try: | |||
import json | |||
except ImportError: | |||
from django.utils import simplejson as json | |||
from .fields import JSONField, JSONCharField | |||
try: | |||
from django.forms.utils import ValidationError | |||
except ImportError: | |||
from django.forms.util import ValidationError | |||
from django.utils.six import string_types | |||
from collections import OrderedDict | |||
class JsonModel(models.Model): | |||
json = JSONField() | |||
default_json = JSONField(default={"check": 12}) | |||
complex_default_json = JSONField(default=[{"checkcheck": 1212}]) | |||
empty_default = JSONField(default={}) | |||
class GenericForeignKeyObj(models.Model): | |||
name = models.CharField('Foreign Obj', max_length=255, null=True) | |||
class JSONModelWithForeignKey(models.Model): | |||
json = JSONField(null=True) | |||
foreign_obj = GenericForeignKey() | |||
object_id = models.PositiveIntegerField(blank=True, null=True, db_index=True) | |||
content_type = models.ForeignKey(ContentType, blank=True, null=True, | |||
on_delete=models.CASCADE) | |||
class JsonCharModel(models.Model): | |||
json = JSONCharField(max_length=100) | |||
default_json = JSONCharField(max_length=100, default={"check": 34}) | |||
class ComplexEncoder(json.JSONEncoder): | |||
def default(self, obj): | |||
if isinstance(obj, complex): | |||
return { | |||
'__complex__': True, | |||
'real': obj.real, | |||
'imag': obj.imag, | |||
} | |||
return json.JSONEncoder.default(self, obj) | |||
def as_complex(dct): | |||
if '__complex__' in dct: | |||
return complex(dct['real'], dct['imag']) | |||
return dct | |||
class JSONModelCustomEncoders(models.Model): | |||
# A JSON field that can store complex numbers | |||
json = JSONField( | |||
dump_kwargs={'cls': ComplexEncoder, "indent": 4}, | |||
load_kwargs={'object_hook': as_complex}, | |||
) | |||
class JSONModelWithForeignKeyTestCase(TestCase): | |||
def test_object_create(self): | |||
foreign_obj = GenericForeignKeyObj.objects.create(name='Brain') | |||
JSONModelWithForeignKey.objects.create(foreign_obj=foreign_obj) | |||
class JSONFieldTest(TestCase): | |||
"""JSONField Wrapper Tests""" | |||
json_model = JsonModel | |||
def test_json_field_create(self): | |||
"""Test saving a JSON object in our JSONField""" | |||
json_obj = { | |||
"item_1": "this is a json blah", | |||
"blergh": "hey, hey, hey"} | |||
obj = self.json_model.objects.create(json=json_obj) | |||
new_obj = self.json_model.objects.get(id=obj.id) | |||
self.assertEqual(new_obj.json, json_obj) | |||
def test_string_in_json_field(self): | |||
"""Test saving an ordinary Python string in our JSONField""" | |||
json_obj = 'blah blah' | |||
obj = self.json_model.objects.create(json=json_obj) | |||
new_obj = self.json_model.objects.get(id=obj.id) | |||
self.assertEqual(new_obj.json, json_obj) | |||
def test_float_in_json_field(self): | |||
"""Test saving a Python float in our JSONField""" | |||
json_obj = 1.23 | |||
obj = self.json_model.objects.create(json=json_obj) | |||
new_obj = self.json_model.objects.get(id=obj.id) | |||
self.assertEqual(new_obj.json, json_obj) | |||
def test_int_in_json_field(self): | |||
"""Test saving a Python integer in our JSONField""" | |||
json_obj = 1234567 | |||
obj = self.json_model.objects.create(json=json_obj) | |||
new_obj = self.json_model.objects.get(id=obj.id) | |||
self.assertEqual(new_obj.json, json_obj) | |||
def test_decimal_in_json_field(self): | |||
"""Test saving a Python Decimal in our JSONField""" | |||
json_obj = Decimal(12.34) | |||
obj = self.json_model.objects.create(json=json_obj) | |||
new_obj = self.json_model.objects.get(id=obj.id) | |||
# here we must know to convert the returned string back to Decimal, | |||
# since json does not support that format | |||
self.assertEqual(Decimal(new_obj.json), json_obj) | |||
def test_json_field_modify(self): | |||
"""Test modifying a JSON object in our JSONField""" | |||
json_obj_1 = {'a': 1, 'b': 2} | |||
json_obj_2 = {'a': 3, 'b': 4} | |||
obj = self.json_model.objects.create(json=json_obj_1) | |||
self.assertEqual(obj.json, json_obj_1) | |||
obj.json = json_obj_2 | |||
self.assertEqual(obj.json, json_obj_2) | |||
obj.save() | |||
self.assertEqual(obj.json, json_obj_2) | |||
self.assertTrue(obj) | |||
def test_json_field_load(self): | |||
"""Test loading a JSON object from the DB""" | |||
json_obj_1 = {'a': 1, 'b': 2} | |||
obj = self.json_model.objects.create(json=json_obj_1) | |||
new_obj = self.json_model.objects.get(id=obj.id) | |||
self.assertEqual(new_obj.json, json_obj_1) | |||
def test_json_list(self): | |||
"""Test storing a JSON list""" | |||
json_obj = ["my", "list", "of", 1, "objs", {"hello": "there"}] | |||
obj = self.json_model.objects.create(json=json_obj) | |||
new_obj = self.json_model.objects.get(id=obj.id) | |||
self.assertEqual(new_obj.json, json_obj) | |||
def test_empty_objects(self): | |||
"""Test storing empty objects""" | |||
for json_obj in [{}, [], 0, '', False]: | |||
obj = self.json_model.objects.create(json=json_obj) | |||
new_obj = self.json_model.objects.get(id=obj.id) | |||
self.assertEqual(json_obj, obj.json) | |||
self.assertEqual(json_obj, new_obj.json) | |||
def test_custom_encoder(self): | |||
"""Test encoder_cls and object_hook""" | |||
value = 1 + 3j # A complex number | |||
obj = JSONModelCustomEncoders.objects.create(json=value) | |||
new_obj = JSONModelCustomEncoders.objects.get(pk=obj.pk) | |||
self.assertEqual(value, new_obj.json) | |||
def test_django_serializers(self): | |||
"""Test serializing/deserializing jsonfield data""" | |||
for json_obj in [{}, [], 0, '', False, {'key': 'value', 'num': 42, | |||
'ary': list(range(5)), | |||
'dict': {'k': 'v'}}]: | |||
obj = self.json_model.objects.create(json=json_obj) | |||
new_obj = self.json_model.objects.get(id=obj.id) | |||
self.assert_(new_obj) | |||
queryset = self.json_model.objects.all() | |||
ser = serialize('json', queryset) | |||
for dobj in deserialize('json', ser): | |||
obj = dobj.object | |||
pulled = self.json_model.objects.get(id=obj.pk) | |||
self.assertEqual(obj.json, pulled.json) | |||
def test_default_parameters(self): | |||
"""Test providing a default value to the model""" | |||
model = JsonModel() | |||
model.json = {"check": 12} | |||
self.assertEqual(model.json, {"check": 12}) | |||
self.assertEqual(type(model.json), dict) | |||
self.assertEqual(model.default_json, {"check": 12}) | |||
self.assertEqual(type(model.default_json), dict) | |||
def test_invalid_json(self): | |||
# invalid json data {] in the json and default_json fields | |||
ser = '[{"pk": 1, "model": "jsonfield.jsoncharmodel", ' \ | |||
'"fields": {"json": "{]", "default_json": "{]"}}]' | |||
with self.assertRaises(DeserializationError) as cm: | |||
next(deserialize('json', ser)) | |||
# Django 2.0+ uses PEP 3134 exception chaining | |||
if django.VERSION < (2, 0,): | |||
inner = cm.exception.args[0] | |||
else: | |||
inner = cm.exception.__context__ | |||
self.assertTrue(isinstance(inner, ValidationError)) | |||
self.assertEqual('Enter valid JSON', inner.messages[0]) | |||
def test_integer_in_string_in_json_field(self): | |||
"""Test saving the Python string '123' in our JSONField""" | |||
json_obj = '123' | |||
obj = self.json_model.objects.create(json=json_obj) | |||
new_obj = self.json_model.objects.get(id=obj.id) | |||
self.assertEqual(new_obj.json, json_obj) | |||
def test_boolean_in_string_in_json_field(self): | |||
"""Test saving the Python string 'true' in our JSONField""" | |||
json_obj = 'true' | |||
obj = self.json_model.objects.create(json=json_obj) | |||
new_obj = self.json_model.objects.get(id=obj.id) | |||
self.assertEqual(new_obj.json, json_obj) | |||
def test_pass_by_reference_pollution(self): | |||
"""Make sure the default parameter is copied rather than passed by reference""" | |||
model = JsonModel() | |||
model.default_json["check"] = 144 | |||
model.complex_default_json[0]["checkcheck"] = 144 | |||
self.assertEqual(model.default_json["check"], 144) | |||
self.assertEqual(model.complex_default_json[0]["checkcheck"], 144) | |||
# Make sure when we create a new model, it resets to the default value | |||
# and not to what we just set it to (it would be if it were passed by reference) | |||
model = JsonModel() | |||
self.assertEqual(model.default_json["check"], 12) | |||
self.assertEqual(model.complex_default_json[0]["checkcheck"], 1212) | |||
def test_normal_regex_filter(self): | |||
"""Make sure JSON model can filter regex""" | |||
JsonModel.objects.create(json={"boom": "town"}) | |||
JsonModel.objects.create(json={"move": "town"}) | |||
JsonModel.objects.create(json={"save": "town"}) | |||
self.assertEqual(JsonModel.objects.count(), 3) | |||
self.assertEqual(JsonModel.objects.filter(json__regex=r"boom").count(), 1) | |||
self.assertEqual(JsonModel.objects.filter(json__regex=r"town").count(), 3) | |||
def test_save_blank_object(self): | |||
"""Test that JSON model can save a blank object as none""" | |||
model = JsonModel() | |||
self.assertEqual(model.empty_default, {}) | |||
model.save() | |||
self.assertEqual(model.empty_default, {}) | |||
model1 = JsonModel(empty_default={"hey": "now"}) | |||
self.assertEqual(model1.empty_default, {"hey": "now"}) | |||
model1.save() | |||
self.assertEqual(model1.empty_default, {"hey": "now"}) | |||
class JSONCharFieldTest(JSONFieldTest): | |||
json_model = JsonCharModel | |||
class OrderedJsonModel(models.Model): | |||
json = JSONField(load_kwargs={'object_pairs_hook': OrderedDict}) | |||
class OrderedDictSerializationTest(TestCase): | |||
def setUp(self): | |||
self.ordered_dict = OrderedDict([ | |||
('number', [1, 2, 3, 4]), | |||
('notes', True), | |||
('alpha', True), | |||
('romeo', True), | |||
('juliet', True), | |||
('bravo', True), | |||
]) | |||
self.expected_key_order = ['number', 'notes', 'alpha', 'romeo', 'juliet', 'bravo'] | |||
def test_ordered_dict_differs_from_normal_dict(self): | |||
self.assertEqual(list(self.ordered_dict.keys()), self.expected_key_order) | |||
self.assertNotEqual(dict(self.ordered_dict).keys(), self.expected_key_order) | |||
def test_default_behaviour_loses_sort_order(self): | |||
mod = JsonModel.objects.create(json=self.ordered_dict) | |||
self.assertEqual(list(mod.json.keys()), self.expected_key_order) | |||
mod_from_db = JsonModel.objects.get(id=mod.id) | |||
# mod_from_db lost ordering information during json.loads() | |||
self.assertNotEqual(mod_from_db.json.keys(), self.expected_key_order) | |||
def test_load_kwargs_hook_does_not_lose_sort_order(self): | |||
mod = OrderedJsonModel.objects.create(json=self.ordered_dict) | |||
self.assertEqual(list(mod.json.keys()), self.expected_key_order) | |||
mod_from_db = OrderedJsonModel.objects.get(id=mod.id) | |||
self.assertEqual(list(mod_from_db.json.keys()), self.expected_key_order) | |||
class JsonNotRequiredModel(models.Model): | |||
json = JSONField(blank=True, null=True) | |||
class JsonNotRequiredForm(forms.ModelForm): | |||
class Meta: | |||
model = JsonNotRequiredModel | |||
fields = '__all__' | |||
class JsonModelFormTest(TestCase): | |||
def test_blank_form(self): | |||
form = JsonNotRequiredForm(data={'json': ''}) | |||
self.assertFalse(form.has_changed()) | |||
def test_form_with_data(self): | |||
form = JsonNotRequiredForm(data={'json': '{}'}) | |||
self.assertTrue(form.has_changed()) | |||
class TestFieldAPIMethods(TestCase): | |||
def test_get_db_prep_value_method_with_null(self): | |||
json_field_instance = JSONField(null=True) | |||
value = {'a': 1} | |||
prepared_value = json_field_instance.get_db_prep_value( | |||
value, connection=None, prepared=False) | |||
self.assertIsInstance(prepared_value, string_types) | |||
self.assertDictEqual(value, json.loads(prepared_value)) | |||
self.assertIs(json_field_instance.get_db_prep_value( | |||
None, connection=None, prepared=True), None) | |||
self.assertIs(json_field_instance.get_db_prep_value( | |||
None, connection=None, prepared=False), None) | |||
def test_get_db_prep_value_method_with_not_null(self): | |||
json_field_instance = JSONField(null=False) | |||
value = {'a': 1} | |||
prepared_value = json_field_instance.get_db_prep_value( | |||
value, connection=None, prepared=False) | |||
self.assertIsInstance(prepared_value, string_types) | |||
self.assertDictEqual(value, json.loads(prepared_value)) | |||
self.assertIs(json_field_instance.get_db_prep_value( | |||
None, connection=None, prepared=True), None) | |||
self.assertEqual(json_field_instance.get_db_prep_value( | |||
None, connection=None, prepared=False), 'null') | |||
def test_get_db_prep_value_method_skips_prepared_values(self): | |||
json_field_instance = JSONField(null=False) | |||
value = {'a': 1} | |||
prepared_value = json_field_instance.get_db_prep_value( | |||
value, connection=None, prepared=True) | |||
self.assertIs(prepared_value, value) | |||
def test_get_prep_value_always_json_dumps_if_not_null(self): | |||
json_field_instance = JSONField(null=False) | |||
value = {'a': 1} | |||
prepared_value = json_field_instance.get_prep_value(value) | |||
self.assertIsInstance(prepared_value, string_types) | |||
self.assertDictEqual(value, json.loads(prepared_value)) | |||
already_json = json.dumps(value) | |||
double_prepared_value = json_field_instance.get_prep_value( | |||
already_json) | |||
self.assertDictEqual(value, | |||
json.loads(json.loads(double_prepared_value))) | |||
self.assertEqual(json_field_instance.get_prep_value(None), 'null') | |||
def test_get_prep_value_can_return_none_if_null(self): | |||
json_field_instance = JSONField(null=True) | |||
value = {'a': 1} | |||
prepared_value = json_field_instance.get_prep_value(value) | |||
self.assertIsInstance(prepared_value, string_types) | |||
self.assertDictEqual(value, json.loads(prepared_value)) | |||
already_json = json.dumps(value) | |||
double_prepared_value = json_field_instance.get_prep_value( | |||
already_json) | |||
self.assertDictEqual(value, | |||
json.loads(json.loads(double_prepared_value))) | |||
self.assertIs(json_field_instance.get_prep_value(None), None) |
@@ -0,0 +1,5 @@ | |||
VERSION = (3, 1, 0) | |||
from .backends import EmailBackend | |||
default_app_config = 'post_office.apps.PostOfficeConfig' |
@@ -0,0 +1,153 @@ | |||
# -*- coding: utf-8 -*- | |||
from __future__ import unicode_literals | |||
from django import forms | |||
from django.db import models | |||
from django.contrib import admin | |||
from django.conf import settings | |||
from django.forms.widgets import TextInput | |||
from django.utils import six | |||
from django.utils.text import Truncator | |||
from django.utils.translation import ugettext_lazy as _ | |||
from .fields import CommaSeparatedEmailField | |||
from .models import Attachment, Log, Email, EmailTemplate, STATUS | |||
def get_message_preview(instance): | |||
return (u'{0}...'.format(instance.message[:25]) if len(instance.message) > 25 | |||
else instance.message) | |||
get_message_preview.short_description = 'Message' | |||
class LogInline(admin.StackedInline): | |||
model = Log | |||
extra = 0 | |||
class CommaSeparatedEmailWidget(TextInput): | |||
def __init__(self, *args, **kwargs): | |||
super(CommaSeparatedEmailWidget, self).__init__(*args, **kwargs) | |||
self.attrs.update({'class': 'vTextField'}) | |||
def _format_value(self, value): | |||
# If the value is a string wrap it in a list so it does not get sliced. | |||
if not value: | |||
return '' | |||
if isinstance(value, six.string_types): | |||
value = [value, ] | |||
return ','.join([item for item in value]) | |||
def requeue(modeladmin, request, queryset): | |||
"""An admin action to requeue emails.""" | |||
queryset.update(status=STATUS.queued) | |||
requeue.short_description = 'Requeue selected emails' | |||
class EmailAdmin(admin.ModelAdmin): | |||
list_display = ('id', 'to_display', 'subject', 'template', | |||
'status', 'last_updated') | |||
search_fields = ['to', 'subject'] | |||
date_hierarchy = 'last_updated' | |||
inlines = [LogInline] | |||
list_filter = ['status'] | |||
formfield_overrides = { | |||
CommaSeparatedEmailField: {'widget': CommaSeparatedEmailWidget} | |||
} | |||
actions = [requeue] | |||
def get_queryset(self, request): | |||
return super(EmailAdmin, self).get_queryset(request).select_related('template') | |||
def to_display(self, instance): | |||
return ', '.join(instance.to) | |||
to_display.short_description = 'to' | |||
to_display.admin_order_field = 'to' | |||
class LogAdmin(admin.ModelAdmin): | |||
list_display = ('date', 'email', 'status', get_message_preview) | |||
class SubjectField(TextInput): | |||
def __init__(self, *args, **kwargs): | |||
super(SubjectField, self).__init__(*args, **kwargs) | |||
self.attrs.update({'style': 'width: 610px;'}) | |||
class EmailTemplateAdminForm(forms.ModelForm): | |||
language = forms.ChoiceField(choices=settings.LANGUAGES, required=False, | |||
help_text=_("Render template in alternative language"), | |||
label=_("Language")) | |||
class Meta: | |||
model = EmailTemplate | |||
fields = ('name', 'description', 'subject', | |||
'content', 'html_content', 'language', 'default_template') | |||
class EmailTemplateInline(admin.StackedInline): | |||
form = EmailTemplateAdminForm | |||
model = EmailTemplate | |||
extra = 0 | |||
fields = ('language', 'subject', 'content', 'html_content',) | |||
formfield_overrides = { | |||
models.CharField: {'widget': SubjectField} | |||
} | |||
def get_max_num(self, request, obj=None, **kwargs): | |||
return len(settings.LANGUAGES) | |||
class EmailTemplateAdmin(admin.ModelAdmin): | |||
form = EmailTemplateAdminForm | |||
list_display = ('name', 'description_shortened', 'subject', 'languages_compact', 'created') | |||
search_fields = ('name', 'description', 'subject') | |||
fieldsets = [ | |||
(None, { | |||
'fields': ('name', 'description'), | |||
}), | |||
(_("Default Content"), { | |||
'fields': ('subject', 'content', 'html_content'), | |||
}), | |||
] | |||
inlines = (EmailTemplateInline,) if settings.USE_I18N else () | |||
formfield_overrides = { | |||
models.CharField: {'widget': SubjectField} | |||
} | |||
def get_queryset(self, request): | |||
return self.model.objects.filter(default_template__isnull=True) | |||
def description_shortened(self, instance): | |||
return Truncator(instance.description.split('\n')[0]).chars(200) | |||
description_shortened.short_description = _("Description") | |||
description_shortened.admin_order_field = 'description' | |||
def languages_compact(self, instance): | |||
languages = [tt.language for tt in instance.translated_templates.order_by('language')] | |||
return ', '.join(languages) | |||
languages_compact.short_description = _("Languages") | |||
def save_model(self, request, obj, form, change): | |||
obj.save() | |||
# if the name got changed, also change the translated templates to match again | |||
if 'name' in form.changed_data: | |||
obj.translated_templates.update(name=obj.name) | |||
class AttachmentAdmin(admin.ModelAdmin): | |||
list_display = ('name', 'file', ) | |||
admin.site.register(Email, EmailAdmin) | |||
admin.site.register(Log, LogAdmin) | |||
admin.site.register(EmailTemplate, EmailTemplateAdmin) | |||
admin.site.register(Attachment, AttachmentAdmin) |
@@ -0,0 +1,7 @@ | |||
from django.apps import AppConfig | |||
from django.utils.translation import ugettext_lazy as _ | |||
class PostOfficeConfig(AppConfig): | |||
name = 'post_office' | |||
verbose_name = _("Post Office") |
@@ -0,0 +1,56 @@ | |||
from django.core.files.base import ContentFile | |||
from django.core.mail.backends.base import BaseEmailBackend | |||
from .settings import get_default_priority | |||
class EmailBackend(BaseEmailBackend): | |||
def open(self): | |||
pass | |||
def close(self): | |||
pass | |||
def send_messages(self, email_messages): | |||
""" | |||
Queue one or more EmailMessage objects and returns the number of | |||
email messages sent. | |||
""" | |||
from .mail import create | |||
from .utils import create_attachments | |||
if not email_messages: | |||
return | |||
for email_message in email_messages: | |||
subject = email_message.subject | |||
from_email = email_message.from_email | |||
message = email_message.body | |||
headers = email_message.extra_headers | |||
# Check whether email has 'text/html' alternative | |||
alternatives = getattr(email_message, 'alternatives', ()) | |||
for alternative in alternatives: | |||
if alternative[1].startswith('text/html'): | |||
html_message = alternative[0] | |||
break | |||
else: | |||
html_message = '' | |||
attachment_files = dict([(name, ContentFile(content)) | |||
for name, content, _ in email_message.attachments]) | |||
email = create(sender=from_email, | |||
recipients=email_message.to, cc=email_message.cc, | |||
bcc=email_message.bcc, subject=subject, | |||
message=message, html_message=html_message, | |||
headers=headers) | |||
if attachment_files: | |||
attachments = create_attachments(attachment_files) | |||
email.attachments.add(*attachments) | |||
if get_default_priority() == 'now': | |||
email.dispatch() |
@@ -0,0 +1,26 @@ | |||
from django.template.defaultfilters import slugify | |||
from .settings import get_cache_backend | |||
# Stripped down version of caching functions from django-dbtemplates | |||
# https://github.com/jezdez/django-dbtemplates/blob/develop/dbtemplates/utils/cache.py | |||
cache_backend = get_cache_backend() | |||
def get_cache_key(name): | |||
""" | |||
Prefixes and slugify the key name | |||
""" | |||
return 'post_office:template:%s' % (slugify(name)) | |||
def set(name, content): | |||
return cache_backend.set(get_cache_key(name), content) | |||
def get(name): | |||
return cache_backend.get(get_cache_key(name)) | |||
def delete(name): | |||
return cache_backend.delete(get_cache_key(name)) |
@@ -0,0 +1,46 @@ | |||
try: | |||
import importlib | |||
except ImportError: | |||
from django.utils import importlib | |||
try: | |||
from logging.config import dictConfig # Python >= 2.7 | |||
except ImportError: | |||
from django.utils.log import dictConfig # Django <= 1.9 | |||
import sys | |||
PY2 = sys.version_info[0] == 2 | |||
PY3 = sys.version_info[0] == 3 | |||
if PY3: | |||
string_types = str | |||
text_type = str | |||
else: | |||
string_types = basestring | |||
text_type = unicode | |||
try: | |||
from django.core.cache import caches # Django >= 1.7 | |||
def get_cache(name): | |||
return caches[name] | |||
except ImportError: | |||
from django.core.cache import get_cache | |||
try: | |||
from django.utils.encoding import smart_text # For Django >= 1.5 | |||
except ImportError: | |||
from django.utils.encoding import smart_unicode as smart_text | |||
# Django 1.4 doesn't have ``import_string`` or ``import_by_path`` | |||
def import_attribute(name): | |||
"""Return an attribute from a dotted path name (e.g. "path.to.func").""" | |||
module_name, attribute = name.rsplit('.', 1) | |||
module = importlib.import_module(module_name) | |||
return getattr(module, attribute) |
@@ -0,0 +1,44 @@ | |||
from threading import local | |||
from django.core.mail import get_connection | |||
from .settings import get_backend | |||
# Copied from Django 1.8's django.core.cache.CacheHandler | |||
class ConnectionHandler(object): | |||
""" | |||
A Cache Handler to manage access to Cache instances. | |||
Ensures only one instance of each alias exists per thread. | |||
""" | |||
def __init__(self): | |||
self._connections = local() | |||
def __getitem__(self, alias): | |||
try: | |||
return self._connections.connections[alias] | |||
except AttributeError: | |||
self._connections.connections = {} | |||
except KeyError: | |||
pass | |||
try: | |||
backend = get_backend(alias) | |||
except KeyError: | |||
raise KeyError('%s is not a valid backend alias' % alias) | |||
connection = get_connection(backend) | |||
connection.open() | |||
self._connections.connections[alias] = connection | |||
return connection | |||
def all(self): | |||
return getattr(self._connections, 'connections', {}).values() | |||
def close(self): | |||
for connection in self.all(): | |||
connection.close() | |||
connections = ConnectionHandler() |
@@ -0,0 +1,58 @@ | |||
from django.db.models import TextField | |||
from django.utils import six | |||
from django.utils.translation import ugettext_lazy as _ | |||
from .validators import validate_comma_separated_emails | |||
class CommaSeparatedEmailField(TextField): | |||
default_validators = [validate_comma_separated_emails] | |||
description = _("Comma-separated emails") | |||
def __init__(self, *args, **kwargs): | |||
kwargs['blank'] = True | |||
super(CommaSeparatedEmailField, self).__init__(*args, **kwargs) | |||
def formfield(self, **kwargs): | |||
defaults = { | |||
'error_messages': { | |||
'invalid': _('Only comma separated emails are allowed.'), | |||
} | |||
} | |||
defaults.update(kwargs) | |||
return super(CommaSeparatedEmailField, self).formfield(**defaults) | |||
def from_db_value(self, value, expression, connection, context): | |||
return self.to_python(value) | |||
def get_prep_value(self, value): | |||
""" | |||
We need to accomodate queries where a single email, | |||
or list of email addresses is supplied as arguments. For example: | |||
- Email.objects.filter(to='mail@example.com') | |||
- Email.objects.filter(to=['one@example.com', 'two@example.com']) | |||
""" | |||
if isinstance(value, six.string_types): | |||
return value | |||
else: | |||
return ', '.join(map(lambda s: s.strip(), value)) | |||
def to_python(self, value): | |||
if isinstance(value, six.string_types): | |||
if value == '': | |||
return [] | |||
else: | |||
return [s.strip() for s in value.split(',')] | |||
else: | |||
return value | |||
def south_field_triple(self): | |||
""" | |||
Return a suitable description of this field for South. | |||
Taken from smiley chris' easy_thumbnails | |||
""" | |||
from south.modelsinspector import introspector | |||
field_class = 'django.db.models.fields.TextField' | |||
args, kwargs = introspector(self) | |||
return (field_class, args, kwargs) |
@@ -0,0 +1,130 @@ | |||
# SOME DESCRIPTIVE TITLE. | |||
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER | |||
# This file is distributed under the same license as the PACKAGE package. | |||
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR. | |||
# | |||
#, fuzzy | |||
msgid "" | |||
msgstr "" | |||
"Project-Id-Version: PACKAGE VERSION\n" | |||
"Report-Msgid-Bugs-To: \n" | |||
"POT-Creation-Date: 2015-07-06 07:47+0200\n" | |||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" | |||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" | |||
"Language-Team: LANGUAGE <LL@li.org>\n" | |||
"Language: \n" | |||
"MIME-Version: 1.0\n" | |||
"Content-Type: text/plain; charset=UTF-8\n" | |||
"Content-Transfer-Encoding: 8bit\n" | |||
"Plural-Forms: nplurals=2; plural=(n != 1);\n" | |||
#: admin.py:97 | |||
#, fuzzy | |||
msgid "Default Content" | |||
msgstr "Inhalt" | |||
#: admin.py:111 | |||
msgid "Description" | |||
msgstr "Beschreibung" | |||
#: admin.py:117 | |||
msgid "Languages" | |||
msgstr "Sprachen" | |||
#: fields.py:11 | |||
msgid "Comma-separated emails" | |||
msgstr "Durch Kommas getrennte Emails" | |||
#: fields.py:20 | |||
msgid "Only comma separated emails are allowed." | |||
msgstr "Nur durch Kommas getrennte Emails sind erlaubt" | |||
#: models.py:38 | |||
msgid "low" | |||
msgstr "niedrig" | |||
#: models.py:38 | |||
msgid "medium" | |||
msgstr "mittel" | |||
#: models.py:39 | |||
msgid "high" | |||
msgstr "hoch" | |||
#: models.py:39 | |||
msgid "now" | |||
msgstr "sofort" | |||
#: models.py:40 models.py:169 | |||
msgid "sent" | |||
msgstr "gesendet" | |||
#: models.py:40 models.py:169 | |||
msgid "failed" | |||
msgstr "fehlgeschlagen" | |||
#: models.py:41 | |||
msgid "queued" | |||
msgstr "in der Warteschleife" | |||
#: models.py:44 | |||
msgid "Email From" | |||
msgstr "Email Von" | |||
#: models.py:45 | |||
msgid "Email To" | |||
msgstr "Email An" | |||
#: models.py:46 | |||
msgid "Cc" | |||
msgstr "Kopie" | |||
#: models.py:47 | |||
msgid "Bcc" | |||
msgstr "Blinde Kopie" | |||
#: models.py:48 models.py:194 | |||
msgid "Subject" | |||
msgstr "Betreff" | |||
#: models.py:49 | |||
msgid "Message" | |||
msgstr "Nachricht" | |||
#: models.py:50 | |||
msgid "HTML Message" | |||
msgstr "HTML Nachricht" | |||
#: models.py:188 | |||
msgid "e.g: 'welcome_email'" | |||
msgstr "z.B. 'welcome_email'" | |||
#: models.py:190 | |||
msgid "Description of this template." | |||
msgstr "Beschreibung dieser Vorlage" | |||
#: models.py:196 | |||
msgid "Content" | |||
msgstr "Inhalt" | |||
#: models.py:198 | |||
msgid "HTML content" | |||
msgstr "HTML Inhalt" | |||
#: models.py:200 | |||
msgid "Render template in alternative language" | |||
msgstr "Vorlage in alternativer Sprache rendern" | |||
#: models.py:208 | |||
#, fuzzy | |||
msgid "Email Template" | |||
msgstr "Email Vorlage" | |||
#: models.py:209 | |||
#, fuzzy | |||
msgid "Email Templates" | |||
msgstr "Email Vorlagen" | |||
#: models.py:236 | |||
msgid "The original filename" | |||
msgstr "Ursprünglicher Dateiname" |
@@ -0,0 +1,130 @@ | |||
# SOME DESCRIPTIVE TITLE. | |||
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER | |||
# This file is distributed under the same license as the PACKAGE package. | |||
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR. | |||
# | |||
#, fuzzy | |||
msgid "" | |||
msgstr "" | |||
"Project-Id-Version: PACKAGE VERSION\n" | |||
"Report-Msgid-Bugs-To: \n" | |||
"POT-Creation-Date: 2015-07-06 07:47+0200\n" | |||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" | |||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" | |||
"Language-Team: LANGUAGE <LL@li.org>\n" | |||
"Language: \n" | |||
"MIME-Version: 1.0\n" | |||
"Content-Type: text/plain; charset=UTF-8\n" | |||
"Content-Transfer-Encoding: 8bit\n" | |||
"Plural-Forms: nplurals=2; plural=(n != 1);\n" | |||
#: admin.py:97 | |||
#, fuzzy | |||
msgid "Default Content" | |||
msgstr "Contenuto" | |||
#: admin.py:111 | |||
msgid "Description" | |||
msgstr "Descrizione" | |||
#: admin.py:117 | |||
msgid "Languages" | |||
msgstr "Lingue" | |||
#: fields.py:11 | |||
msgid "Comma-separated emails" | |||
msgstr "Emails separati da virgole" | |||
#: fields.py:20 | |||
msgid "Only comma separated emails are allowed." | |||
msgstr "Sono consentiti soltanto emails separati da virgole" | |||
#: models.py:38 | |||
msgid "low" | |||
msgstr "bassa" | |||
#: models.py:38 | |||
msgid "medium" | |||
msgstr "media" | |||
#: models.py:39 | |||
msgid "high" | |||
msgstr "alta" | |||
#: models.py:39 | |||
msgid "now" | |||
msgstr "immediata" | |||
#: models.py:40 models.py:169 | |||
msgid "sent" | |||
msgstr "inviato" | |||
#: models.py:40 models.py:169 | |||
msgid "failed" | |||
msgstr "fallito" | |||
#: models.py:41 | |||
msgid "queued" | |||
msgstr "in attesa" | |||
#: models.py:44 | |||
msgid "Email From" | |||
msgstr "Email da" | |||
#: models.py:45 | |||
msgid "Email To" | |||
msgstr "Email per" | |||
#: models.py:46 | |||
msgid "Cc" | |||
msgstr "Copia" | |||
#: models.py:47 | |||
msgid "Bcc" | |||
msgstr "Bcc" | |||
#: models.py:48 models.py:194 | |||
msgid "Subject" | |||
msgstr "Soggetto" | |||
#: models.py:49 | |||
msgid "Message" | |||
msgstr "Messaggio" | |||
#: models.py:50 | |||
msgid "HTML Message" | |||
msgstr "HTML Messaggio" | |||
#: models.py:188 | |||
msgid "e.g: 'welcome_email'" | |||
msgstr "z.B. 'welcome_email'" | |||
#: models.py:190 | |||
msgid "Description of this template." | |||
msgstr "Descrizione di questa template." | |||
#: models.py:196 | |||
msgid "Content" | |||
msgstr "Contenuto" | |||
#: models.py:198 | |||
msgid "HTML content" | |||
msgstr "Contenuto in HTML" | |||
#: models.py:200 | |||
msgid "Render template in alternative language" | |||
msgstr "Rendere template in un altra lingua" | |||
#: models.py:208 | |||
#, fuzzy | |||
msgid "Email Template" | |||
msgstr "Email Template" | |||
#: models.py:209 | |||
#, fuzzy | |||
msgid "Email Templates" | |||
msgstr "Email Templates" | |||
#: models.py:236 | |||
msgid "The original filename" | |||
msgstr "Nome del file originale" |
@@ -0,0 +1,206 @@ | |||
# SOME DESCRIPTIVE TITLE. | |||
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER | |||
# This file is distributed under the same license as the PACKAGE package. | |||
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR. | |||
# | |||
#, fuzzy | |||
msgid "" | |||
msgstr "" | |||
"Project-Id-Version: PACKAGE VERSION\n" | |||
"Report-Msgid-Bugs-To: \n" | |||
"POT-Creation-Date: 2016-06-09 13:58+0200\n" | |||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" | |||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" | |||
"Language-Team: LANGUAGE <LL@li.org>\n" | |||
"Language: \n" | |||
"MIME-Version: 1.0\n" | |||
"Content-Type: text/plain; charset=UTF-8\n" | |||
"Content-Transfer-Encoding: 8bit\n" | |||
"Plural-Forms: nplurals=3; plural=(n==1 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 " | |||
"|| n%100>=20) ? 1 : 2);\n" | |||
#: post_office/admin.py:81 post_office/models.py:200 | |||
msgid "Render template in alternative language" | |||
msgstr "Wygeneruj szablon w alternatywnym języku" | |||
#: post_office/admin.py:82 post_office/models.py:199 | |||
msgid "Language" | |||
msgstr "Język" | |||
#: post_office/admin.py:111 | |||
msgid "Default Content" | |||
msgstr "Domyślna zawartość" | |||
#: post_office/admin.py:125 post_office/models.py:188 | |||
msgid "Description" | |||
msgstr "Opis" | |||
#: post_office/admin.py:131 | |||
msgid "Languages" | |||
msgstr "Języki" | |||
#: post_office/fields.py:11 | |||
msgid "Comma-separated emails" | |||
msgstr "Oddzielone przecinkami emaile" | |||
#: post_office/fields.py:20 | |||
msgid "Only comma separated emails are allowed." | |||
msgstr "Tylko oddzielone przecinkami emaile są dozwolone." | |||
#: post_office/models.py:36 | |||
msgid "low" | |||
msgstr "niski" | |||
#: post_office/models.py:36 | |||
msgid "medium" | |||
msgstr "średni" | |||
#: post_office/models.py:37 | |||
msgid "high" | |||
msgstr "wysoki" | |||
#: post_office/models.py:37 | |||
msgid "now" | |||
msgstr "natychmiastowy" | |||
#: post_office/models.py:38 post_office/models.py:164 | |||
msgid "sent" | |||
msgstr "wysłany" | |||
#: post_office/models.py:38 post_office/models.py:164 | |||
msgid "failed" | |||
msgstr "odrzucony" | |||
#: post_office/models.py:39 | |||
msgid "queued" | |||
msgstr "w kolejce" | |||
#: post_office/models.py:41 | |||
msgid "Email From" | |||
msgstr "Email od" | |||
#: post_office/models.py:43 | |||
msgid "Email To" | |||
msgstr "Email do" | |||
#: post_office/models.py:44 | |||
msgid "Cc" | |||
msgstr "Cc" | |||
#: post_office/models.py:46 post_office/models.py:193 | |||
msgid "Subject" | |||
msgstr "Temat" | |||
#: post_office/models.py:47 post_office/models.py:171 | |||
msgid "Message" | |||
msgstr "Wiadomość" | |||
#: post_office/models.py:48 | |||
msgid "HTML Message" | |||
msgstr "Wiadomość w HTML" | |||
#: post_office/models.py:55 post_office/models.py:169 | |||
msgid "Status" | |||
msgstr "Status" | |||
#: post_office/models.py:58 | |||
msgid "Priority" | |||
msgstr "Priorytet" | |||
#: post_office/models.py:63 | |||
msgid "The scheduled sending time" | |||
msgstr "Zaplanowany czas wysłania" | |||
#: post_office/models.py:65 | |||
msgid "Headers" | |||
msgstr "Nagłówki" | |||
#: post_office/models.py:67 | |||
msgid "Email template" | |||
msgstr "Szablon emaila" | |||
#: post_office/models.py:68 | |||
msgid "Context" | |||
msgstr "Kontekst" | |||
#: post_office/models.py:69 | |||
msgid "Backend alias" | |||
msgstr "Backend alias" | |||
#: post_office/models.py:74 | |||
msgctxt "Email address" | |||
msgid "Email" | |||
msgstr "Email" | |||
#: post_office/models.py:75 | |||
msgctxt "Email addresses" | |||
msgid "Emails" | |||
msgstr "Emaile" | |||
#: post_office/models.py:167 | |||
msgid "Email address" | |||
msgstr "Adres email" | |||
#: post_office/models.py:170 | |||
msgid "Exception type" | |||
msgstr "Typ wyjątku" | |||
#: post_office/models.py:175 | |||
msgid "Log" | |||
msgstr "Log" | |||
#: post_office/models.py:176 | |||
msgid "Logs" | |||
msgstr "Logi" | |||
#: post_office/models.py:187 post_office/models.py:241 | |||
msgid "Name" | |||
msgstr "Nazwa" | |||
#: post_office/models.py:187 | |||
msgid "e.g: 'welcome_email'" | |||
msgstr "np: 'powitalny_email'" | |||
#: post_office/models.py:189 | |||
msgid "Description of this template." | |||
msgstr "Opis tego szablonu" | |||
#: post_office/models.py:195 | |||
msgid "Content" | |||
msgstr "Zawartość" | |||
#: post_office/models.py:197 | |||
msgid "HTML content" | |||
msgstr "Zawartość w HTML" | |||
#: post_office/models.py:203 | |||
msgid "Default template" | |||
msgstr "Domyślna zawartość" | |||
#: post_office/models.py:208 | |||
msgid "Email Template" | |||
msgstr "Szablon emaila" | |||
#: post_office/models.py:209 | |||
msgid "Email Templates" | |||
msgstr "Szablony emaili" | |||
#: post_office/models.py:240 | |||
msgid "File" | |||
msgstr "Plik" | |||
#: post_office/models.py:241 | |||
msgid "The original filename" | |||
msgstr "Oryginalna nazwa pliku" | |||
#: post_office/models.py:243 | |||
msgid "Email addresses" | |||
msgstr "Adresy email" | |||
#: post_office/models.py:247 | |||
msgid "Attachment" | |||
msgstr "Załącznik" | |||
#: post_office/models.py:248 | |||
msgid "Attachments" | |||
msgstr "Załączniki" |
@@ -0,0 +1,211 @@ | |||
# SOME DESCRIPTIVE TITLE. | |||
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER | |||
# This file is distributed under the same license as the PACKAGE package. | |||
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR. | |||
# | |||
msgid "" | |||
msgstr "" | |||
"Project-Id-Version: \n" | |||
"Report-Msgid-Bugs-To: \n" | |||
"POT-Creation-Date: 2017-08-07 16:47+0300\n" | |||
"PO-Revision-Date: 2017-08-07 16:49+0300\n" | |||
"Language: ru_RU\n" | |||
"MIME-Version: 1.0\n" | |||
"Content-Type: text/plain; charset=UTF-8\n" | |||
"Content-Transfer-Encoding: 8bit\n" | |||
"Plural-Forms: nplurals=4; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n" | |||
"%10<=4 && (n%100<12 || n%100>14) ? 1 : n%10==0 || (n%10>=5 && n%10<=9) || (n" | |||
"%100>=11 && n%100<=14)? 2 : 3);\n" | |||
"Last-Translator: \n" | |||
"Language-Team: \n" | |||
"X-Generator: Poedit 1.8.11\n" | |||
#: post_office/admin.py:83 post_office/models.py:217 | |||
msgid "Render template in alternative language" | |||
msgstr "Отправить письмо на другом языке" | |||
#: post_office/admin.py:84 post_office/models.py:216 | |||
msgid "Language" | |||
msgstr "Язык" | |||
#: post_office/admin.py:113 | |||
msgid "Default Content" | |||
msgstr "Содержимое по умолчанию" | |||
#: post_office/admin.py:127 post_office/models.py:205 | |||
msgid "Description" | |||
msgstr "Описание" | |||
#: post_office/admin.py:133 | |||
msgid "Languages" | |||
msgstr "Языки" | |||
#: post_office/apps.py:7 | |||
msgid "Post Office" | |||
msgstr "Менеджер почты" | |||
#: post_office/fields.py:10 | |||
msgid "Comma-separated emails" | |||
msgstr "Список адресов, разделенных запятыми" | |||
#: post_office/fields.py:19 | |||
msgid "Only comma separated emails are allowed." | |||
msgstr "Разрешен только разделенный запятыми список адресов." | |||
#: post_office/models.py:34 | |||
msgid "low" | |||
msgstr "низкий" | |||
#: post_office/models.py:34 | |||
msgid "medium" | |||
msgstr "средний" | |||
#: post_office/models.py:35 | |||
msgid "high" | |||
msgstr "высокий" | |||
#: post_office/models.py:35 | |||
msgid "now" | |||
msgstr "сейчас" | |||
#: post_office/models.py:36 post_office/models.py:181 | |||
msgid "sent" | |||
msgstr "отправлен" | |||
#: post_office/models.py:36 post_office/models.py:181 | |||
msgid "failed" | |||
msgstr "ошибка" | |||
#: post_office/models.py:37 | |||
msgid "queued" | |||
msgstr "в очереди" | |||
#: post_office/models.py:39 | |||
msgid "Email From" | |||
msgstr "Отправитель" | |||
#: post_office/models.py:41 | |||
msgid "Email To" | |||
msgstr "Получатель" | |||
#: post_office/models.py:42 | |||
msgid "Cc" | |||
msgstr "Копия" | |||
#: post_office/models.py:44 post_office/models.py:210 | |||
msgid "Subject" | |||
msgstr "Тема" | |||
#: post_office/models.py:45 post_office/models.py:188 | |||
msgid "Message" | |||
msgstr "Сообщение" | |||
#: post_office/models.py:46 | |||
msgid "HTML Message" | |||
msgstr "HTML-сообщение" | |||
#: post_office/models.py:53 post_office/models.py:186 | |||
msgid "Status" | |||
msgstr "Статус" | |||
#: post_office/models.py:56 | |||
msgid "Priority" | |||
msgstr "Приоритет" | |||
#: post_office/models.py:61 | |||
msgid "The scheduled sending time" | |||
msgstr "Запланированное время отправки" | |||
#: post_office/models.py:63 | |||
msgid "Headers" | |||
msgstr "Заголовки" | |||
#: post_office/models.py:65 | |||
msgid "Email template" | |||
msgstr "Шаблон письма" | |||
#: post_office/models.py:67 | |||
msgid "Context" | |||
msgstr "Контекст" | |||
#: post_office/models.py:68 | |||
msgid "Backend alias" | |||
msgstr "Имя бекенда" | |||
#: post_office/models.py:73 | |||
msgctxt "Email address" | |||
msgid "Email" | |||
msgstr "Письмо" | |||
#: post_office/models.py:74 | |||
msgctxt "Email addresses" | |||
msgid "Emails" | |||
msgstr "Письма" | |||
#: post_office/models.py:184 | |||
msgid "Email address" | |||
msgstr "Email-адрес" | |||
#: post_office/models.py:187 | |||
msgid "Exception type" | |||
msgstr "Тип исключения" | |||
#: post_office/models.py:192 | |||
msgid "Log" | |||
msgstr "Лог" | |||
#: post_office/models.py:193 | |||
msgid "Logs" | |||
msgstr "Логи" | |||
#: post_office/models.py:204 post_office/models.py:258 | |||
msgid "Name" | |||
msgstr "Имя" | |||
#: post_office/models.py:204 | |||
msgid "e.g: 'welcome_email'" | |||
msgstr "например: 'welcome_email'" | |||
#: post_office/models.py:206 | |||
msgid "Description of this template." | |||
msgstr "Описание шаблона." | |||
#: post_office/models.py:212 | |||
msgid "Content" | |||
msgstr "Содержимое" | |||
#: post_office/models.py:214 | |||
msgid "HTML content" | |||
msgstr "HTML-содержимое" | |||
#: post_office/models.py:220 | |||
msgid "Default template" | |||
msgstr "Шаблон по умолчанию" | |||
#: post_office/models.py:225 | |||
msgid "Email Template" | |||
msgstr "Шаблон письма" | |||
#: post_office/models.py:226 | |||
msgid "Email Templates" | |||
msgstr "Шаблоны писем" | |||
#: post_office/models.py:257 | |||
msgid "File" | |||
msgstr "Файл" | |||
#: post_office/models.py:258 | |||
msgid "The original filename" | |||
msgstr "Исходное имя файла" | |||
#: post_office/models.py:260 | |||
msgid "Email addresses" | |||
msgstr "Email адреса" | |||
#: post_office/models.py:265 | |||
msgid "Attachment" | |||
msgstr "Вложение" | |||
#: post_office/models.py:266 | |||
msgid "Attachments" | |||
msgstr "Вложения" |
@@ -0,0 +1,148 @@ | |||
# This module is taken from https://gist.github.com/ionrock/3015700 | |||
# A file lock implementation that tries to avoid platform specific | |||
# issues. It is inspired by a whole bunch of different implementations | |||
# listed below. | |||
# - https://bitbucket.org/jaraco/yg.lockfile/src/6c448dcbf6e5/yg/lockfile/__init__.py | |||
# - http://svn.zope.org/zc.lockfile/trunk/src/zc/lockfile/__init__.py?rev=121133&view=markup | |||
# - http://stackoverflow.com/questions/489861/locking-a-file-in-python | |||
# - http://www.evanfosmark.com/2009/01/cross-platform-file-locking-support-in-python/ | |||
# - http://packages.python.org/lockfile/lockfile.html | |||
# There are some tests below and a blog posting conceptually the | |||
# problems I wanted to try and solve. The tests reflect these ideas. | |||
# - http://ionrock.wordpress.com/2012/06/28/file-locking-in-python/ | |||
# I'm not advocating using this package. But if you do happen to try it | |||
# out and have suggestions please let me know. | |||
import os | |||
import time | |||
class FileLocked(Exception): | |||
pass | |||
class FileLock(object): | |||
def __init__(self, lock_filename, timeout=None, force=False): | |||
self.lock_filename = '%s.lock' % lock_filename | |||
self.timeout = timeout | |||
self.force = force | |||
self._pid = str(os.getpid()) | |||
# Store pid in a file in the same directory as desired lockname | |||
self.pid_filename = os.path.join( | |||
os.path.dirname(self.lock_filename), | |||
self._pid, | |||
) + '.lock' | |||
def get_lock_pid(self): | |||
try: | |||
return int(open(self.lock_filename).read()) | |||
except IOError: | |||
# If we can't read symbolic link, there are two possibilities: | |||
# 1. The symbolic link is dead (point to non existing file) | |||
# 2. Symbolic link is not there | |||
# In either case, we can safely release the lock | |||
self.release() | |||
def valid_lock(self): | |||
""" | |||
See if the lock exists and is left over from an old process. | |||
""" | |||
lock_pid = self.get_lock_pid() | |||
# If we're unable to get lock_pid | |||
if lock_pid is None: | |||
return False | |||
# this is our process | |||
if self._pid == lock_pid: | |||
return True | |||
# it is/was another process | |||
# see if it is running | |||
try: | |||
os.kill(lock_pid, 0) | |||
except OSError: | |||
self.release() | |||
return False | |||
# it is running | |||
return True | |||
def is_locked(self, force=False): | |||
# We aren't locked | |||
if not self.valid_lock(): | |||
return False | |||
# We are locked, but we want to force it without waiting | |||
if not self.timeout: | |||
if self.force: | |||
self.release() | |||
return False | |||
else: | |||
# We're not waiting or forcing the lock | |||
raise FileLocked() | |||
# Locked, but want to wait for an unlock | |||
interval = .1 | |||
intervals = int(self.timeout / interval) | |||
while intervals: | |||
if self.valid_lock(): | |||
intervals -= 1 | |||
time.sleep(interval) | |||
#print('stopping %s' % intervals) | |||
else: | |||
return True | |||
# check one last time | |||
if self.valid_lock(): | |||
if self.force: | |||
self.release() | |||
else: | |||
# still locked :( | |||
raise FileLocked() | |||
def acquire(self): | |||
"""Create a pid filename and create a symlink (the actual lock file) | |||
across platforms that points to it. Symlink is used because it's an | |||
atomic operation across platforms. | |||
""" | |||
pid_file = os.open(self.pid_filename, os.O_CREAT | os.O_EXCL | os.O_RDWR) | |||
os.write(pid_file, str(os.getpid()).encode('utf-8')) | |||
os.close(pid_file) | |||
if hasattr(os, 'symlink'): | |||
os.symlink(self.pid_filename, self.lock_filename) | |||
else: | |||
# Windows platforms doesn't support symlinks, at least not through the os API | |||
self.lock_filename = self.pid_filename | |||
def release(self): | |||
"""Try to delete the lock files. Doesn't matter if we fail""" | |||
if self.lock_filename != self.pid_filename: | |||
try: | |||
os.unlink(self.lock_filename) | |||
except OSError: | |||
pass | |||
try: | |||
os.remove(self.pid_filename) | |||
except OSError: | |||
pass | |||
def __enter__(self): | |||
if not self.is_locked(): | |||
self.acquire() | |||
return self | |||
def __exit__(self, type, value, traceback): | |||
self.release() |
@@ -0,0 +1,37 @@ | |||
import logging | |||
from .compat import dictConfig | |||
# Taken from https://github.com/nvie/rq/blob/master/rq/logutils.py | |||
def setup_loghandlers(level=None): | |||
# Setup logging for post_office if not already configured | |||
logger = logging.getLogger('post_office') | |||
if not logger.handlers: | |||
dictConfig({ | |||
"version": 1, | |||
"disable_existing_loggers": False, | |||
"formatters": { | |||
"post_office": { | |||
"format": "[%(levelname)s]%(asctime)s PID %(process)d: %(message)s", | |||
"datefmt": "%Y-%m-%d %H:%M:%S", | |||
}, | |||
}, | |||
"handlers": { | |||
"post_office": { | |||
"level": "DEBUG", | |||
"class": "logging.StreamHandler", | |||
"formatter": "post_office" | |||
}, | |||
}, | |||
"loggers": { | |||
"post_office": { | |||
"handlers": ["post_office"], | |||
"level": level or "DEBUG" | |||
} | |||
} | |||
}) | |||
return logger |
@@ -0,0 +1,305 @@ | |||
from multiprocessing import Pool | |||
from multiprocessing.dummy import Pool as ThreadPool | |||
from django.conf import settings | |||
from django.core.exceptions import ValidationError | |||
from django.db import connection as db_connection | |||
from django.db.models import Q | |||
from django.template import Context, Template | |||
from django.utils.timezone import now | |||
from .connections import connections | |||
from .models import Email, EmailTemplate, Log, PRIORITY, STATUS | |||
from .settings import (get_available_backends, get_batch_size, | |||
get_log_level, get_sending_order, get_threads_per_process) | |||
from .utils import (get_email_template, parse_emails, parse_priority, | |||
split_emails, create_attachments) | |||
from .logutils import setup_loghandlers | |||
logger = setup_loghandlers("INFO") | |||
def create(sender, recipients=None, cc=None, bcc=None, subject='', message='', | |||
html_message='', context=None, scheduled_time=None, headers=None, | |||
template=None, priority=None, render_on_delivery=False, commit=True, | |||
backend=''): | |||
""" | |||
Creates an email from supplied keyword arguments. If template is | |||
specified, email subject and content will be rendered during delivery. | |||
""" | |||
priority = parse_priority(priority) | |||
status = None if priority == PRIORITY.now else STATUS.queued | |||
if recipients is None: | |||
recipients = [] | |||
if cc is None: | |||
cc = [] | |||
if bcc is None: | |||
bcc = [] | |||
if context is None: | |||
context = '' | |||
# If email is to be rendered during delivery, save all necessary | |||
# information | |||
if render_on_delivery: | |||
email = Email( | |||
from_email=sender, | |||
to=recipients, | |||
cc=cc, | |||
bcc=bcc, | |||
scheduled_time=scheduled_time, | |||
headers=headers, priority=priority, status=status, | |||
context=context, template=template, backend_alias=backend | |||
) | |||
else: | |||
if template: | |||
subject = template.subject | |||
message = template.content | |||
html_message = template.html_content | |||
_context = Context(context or {}) | |||
subject = Template(subject).render(_context) | |||
message = Template(message).render(_context) | |||
html_message = Template(html_message).render(_context) | |||
email = Email( | |||
from_email=sender, | |||
to=recipients, | |||
cc=cc, | |||
bcc=bcc, | |||
subject=subject, | |||
message=message, | |||
html_message=html_message, | |||
scheduled_time=scheduled_time, | |||
headers=headers, priority=priority, status=status, | |||
backend_alias=backend | |||
) | |||
if commit: | |||
email.save() | |||
return email | |||
def send(recipients=None, sender=None, template=None, context=None, subject='', | |||
message='', html_message='', scheduled_time=None, headers=None, | |||
priority=None, attachments=None, render_on_delivery=False, | |||
log_level=None, commit=True, cc=None, bcc=None, language='', | |||
backend=''): | |||
try: | |||
recipients = parse_emails(recipients) | |||
except ValidationError as e: | |||
raise ValidationError('recipients: %s' % e.message) | |||
try: | |||
cc = parse_emails(cc) | |||
except ValidationError as e: | |||
raise ValidationError('c: %s' % e.message) | |||
try: | |||
bcc = parse_emails(bcc) | |||
except ValidationError as e: | |||
raise ValidationError('bcc: %s' % e.message) | |||
if sender is None: | |||
sender = settings.DEFAULT_FROM_EMAIL | |||
priority = parse_priority(priority) | |||
if log_level is None: | |||
log_level = get_log_level() | |||
if not commit: | |||
if priority == PRIORITY.now: | |||
raise ValueError("send_many() can't be used with priority = 'now'") | |||
if attachments: | |||
raise ValueError("Can't add attachments with send_many()") | |||
if template: | |||
if subject: | |||
raise ValueError('You can\'t specify both "template" and "subject" arguments') | |||
if message: | |||
raise ValueError('You can\'t specify both "template" and "message" arguments') | |||
if html_message: | |||
raise ValueError('You can\'t specify both "template" and "html_message" arguments') | |||
# template can be an EmailTemplate instance or name | |||
if isinstance(template, EmailTemplate): | |||
template = template | |||
# If language is specified, ensure template uses the right language | |||
if language: | |||
if template.language != language: | |||
template = template.translated_templates.get(language=language) | |||
else: | |||
template = get_email_template(template, language) | |||
if backend and backend not in get_available_backends().keys(): | |||
raise ValueError('%s is not a valid backend alias' % backend) | |||
email = create(sender, recipients, cc, bcc, subject, message, html_message, | |||
context, scheduled_time, headers, template, priority, | |||
render_on_delivery, commit=commit, backend=backend) | |||
if attachments: | |||
attachments = create_attachments(attachments) | |||
email.attachments.add(*attachments) | |||
if priority == PRIORITY.now: | |||
email.dispatch(log_level=log_level) | |||
return email | |||
def send_many(kwargs_list): | |||
""" | |||
Similar to mail.send(), but this function accepts a list of kwargs. | |||
Internally, it uses Django's bulk_create command for efficiency reasons. | |||
Currently send_many() can't be used to send emails with priority = 'now'. | |||
""" | |||
emails = [] | |||
for kwargs in kwargs_list: | |||
emails.append(send(commit=False, **kwargs)) | |||
Email.objects.bulk_create(emails) | |||
def get_queued(): | |||
""" | |||
Returns a list of emails that should be sent: | |||
- Status is queued | |||
- Has scheduled_time lower than the current time or None | |||
""" | |||
return Email.objects.filter(status=STATUS.queued) \ | |||
.select_related('template') \ | |||
.filter(Q(scheduled_time__lte=now()) | Q(scheduled_time=None)) \ | |||
.order_by(*get_sending_order()).prefetch_related('attachments')[:get_batch_size()] | |||
def send_queued(processes=1, log_level=None): | |||
""" | |||
Sends out all queued mails that has scheduled_time less than now or None | |||
""" | |||
queued_emails = get_queued() | |||
total_sent, total_failed = 0, 0 | |||
total_email = len(queued_emails) | |||
logger.info('Started sending %s emails with %s processes.' % | |||
(total_email, processes)) | |||
if log_level is None: | |||
log_level = get_log_level() | |||
if queued_emails: | |||
# Don't use more processes than number of emails | |||
if total_email < processes: | |||
processes = total_email | |||
if processes == 1: | |||
total_sent, total_failed = _send_bulk(queued_emails, | |||
uses_multiprocessing=False, | |||
log_level=log_level) | |||
else: | |||
email_lists = split_emails(queued_emails, processes) | |||
pool = Pool(processes) | |||
results = pool.map(_send_bulk, email_lists) | |||
pool.terminate() | |||
total_sent = sum([result[0] for result in results]) | |||
total_failed = sum([result[1] for result in results]) | |||
message = '%s emails attempted, %s sent, %s failed' % ( | |||
total_email, | |||
total_sent, | |||
total_failed | |||
) | |||
logger.info(message) | |||
return (total_sent, total_failed) | |||
def _send_bulk(emails, uses_multiprocessing=True, log_level=None): | |||
# Multiprocessing does not play well with database connection | |||
# Fix: Close connections on forking process | |||
# https://groups.google.com/forum/#!topic/django-users/eCAIY9DAfG0 | |||
if uses_multiprocessing: | |||
db_connection.close() | |||
if log_level is None: | |||
log_level = get_log_level() | |||
sent_emails = [] | |||
failed_emails = [] # This is a list of two tuples (email, exception) | |||
email_count = len(emails) | |||
logger.info('Process started, sending %s emails' % email_count) | |||
def send(email): | |||
try: | |||
email.dispatch(log_level=log_level, commit=False, | |||
disconnect_after_delivery=False) | |||
sent_emails.append(email) | |||
logger.debug('Successfully sent email #%d' % email.id) | |||
except Exception as e: | |||
logger.debug('Failed to send email #%d' % email.id) | |||
failed_emails.append((email, e)) | |||
# Prepare emails before we send these to threads for sending | |||
# So we don't need to access the DB from within threads | |||
for email in emails: | |||
# Sometimes this can fail, for example when trying to render | |||
# email from a faulty Django template | |||
try: | |||
email.prepare_email_message() | |||
except Exception as e: | |||
failed_emails.append((email, e)) | |||
number_of_threads = min(get_threads_per_process(), email_count) | |||
pool = ThreadPool(number_of_threads) | |||
pool.map(send, emails) | |||
pool.close() | |||
pool.join() | |||
connections.close() | |||
# Update statuses of sent and failed emails | |||
email_ids = [email.id for email in sent_emails] | |||
Email.objects.filter(id__in=email_ids).update(status=STATUS.sent) | |||
email_ids = [email.id for (email, e) in failed_emails] | |||
Email.objects.filter(id__in=email_ids).update(status=STATUS.failed) | |||
# If log level is 0, log nothing, 1 logs only sending failures | |||
# and 2 means log both successes and failures | |||
if log_level >= 1: | |||
logs = [] | |||
for (email, exception) in failed_emails: | |||
logs.append( | |||
Log(email=email, status=STATUS.failed, | |||
message=str(exception), | |||
exception_type=type(exception).__name__) | |||
) | |||
if logs: | |||
Log.objects.bulk_create(logs) | |||
if log_level == 2: | |||
logs = [] | |||
for email in sent_emails: | |||
logs.append(Log(email=email, status=STATUS.sent)) | |||
if logs: | |||
Log.objects.bulk_create(logs) | |||
logger.info( | |||
'Process finished, %s attempted, %s sent, %s failed' % ( | |||
email_count, len(sent_emails), len(failed_emails) | |||
) | |||
) | |||
return len(sent_emails), len(failed_emails) |
@@ -0,0 +1,35 @@ | |||
import datetime | |||
from django.core.management.base import BaseCommand | |||
from django.utils.timezone import now | |||
from ...models import Attachment, Email | |||
class Command(BaseCommand): | |||
help = 'Place deferred messages back in the queue.' | |||
def add_arguments(self, parser): | |||
parser.add_argument('-d', '--days', | |||
type=int, default=90, | |||
help="Cleanup mails older than this many days, defaults to 90.") | |||
parser.add_argument('-da', '--delete-attachments', action='store_true', | |||
help="Delete orphaned attachments.") | |||
def handle(self, verbosity, days, delete_attachments, **options): | |||
# Delete mails and their related logs and queued created before X days | |||
cutoff_date = now() - datetime.timedelta(days) | |||
count = Email.objects.filter(created__lt=cutoff_date).count() | |||
Email.objects.only('id').filter(created__lt=cutoff_date).delete() | |||
print("Deleted {0} mails created before {1} ".format(count, cutoff_date)) | |||
if delete_attachments: | |||
attachments = Attachment.objects.filter(emails=None) | |||
attachments_count = len(attachments) | |||
for attachment in attachments: | |||
# Delete the actual file | |||
attachment.file.delete() | |||
attachments.delete() | |||
print("Deleted {0} attachments".format(attachments_count)) |
@@ -0,0 +1,60 @@ | |||
import tempfile | |||
import sys | |||
from django.core.management.base import BaseCommand | |||
from django.db import connection | |||
from django.db.models import Q | |||
from django.utils.timezone import now | |||
from ...lockfile import FileLock, FileLocked | |||
from ...mail import send_queued | |||
from ...models import Email, STATUS | |||
from ...logutils import setup_loghandlers | |||
logger = setup_loghandlers() | |||
default_lockfile = tempfile.gettempdir() + "/post_office" | |||
class Command(BaseCommand): | |||
def add_arguments(self, parser): | |||
parser.add_argument( | |||
'-p', '--processes', | |||
type=int, | |||
default=1, | |||
help='Number of processes used to send emails', | |||
) | |||
parser.add_argument( | |||
'-L', '--lockfile', | |||
default=default_lockfile, | |||
help='Absolute path of lockfile to acquire', | |||
) | |||
parser.add_argument( | |||
'-l', '--log-level', | |||
type=int, | |||
help='"0" to log nothing, "1" to only log errors', | |||
) | |||
def handle(self, *args, **options): | |||
logger.info('Acquiring lock for sending queued emails at %s.lock' % | |||
options['lockfile']) | |||
try: | |||
with FileLock(options['lockfile']): | |||
while 1: | |||
try: | |||
send_queued(options['processes'], | |||
options.get('log_level')) | |||
except Exception as e: | |||
logger.error(e, exc_info=sys.exc_info(), | |||
extra={'status_code': 500}) | |||
raise | |||
# Close DB connection to avoid multiprocessing errors | |||
connection.close() | |||
if not Email.objects.filter(status=STATUS.queued) \ | |||
.filter(Q(scheduled_time__lte=now()) | Q(scheduled_time=None)).exists(): | |||
break | |||
except FileLocked: | |||
logger.info('Failed to acquire lock, terminating now.') |
@@ -0,0 +1,93 @@ | |||
# -*- coding: utf-8 -*- | |||
from __future__ import unicode_literals | |||
from django.db import models, migrations | |||
import jsonfield.fields | |||
import post_office.fields | |||
import post_office.validators | |||
import post_office.models | |||
class Migration(migrations.Migration): | |||
dependencies = [ | |||
] | |||
operations = [ | |||
migrations.CreateModel( | |||
name='Attachment', | |||
fields=[ | |||
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), | |||
('file', models.FileField(upload_to=post_office.models.get_upload_path)), | |||
('name', models.CharField(help_text='The original filename', max_length=255)), | |||
], | |||
options={ | |||
}, | |||
bases=(models.Model,), | |||
), | |||
migrations.CreateModel( | |||
name='Email', | |||
fields=[ | |||
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), | |||
('from_email', models.CharField(max_length=254, validators=[post_office.validators.validate_email_with_name])), | |||
('to', post_office.fields.CommaSeparatedEmailField(blank=True)), | |||
('cc', post_office.fields.CommaSeparatedEmailField(blank=True)), | |||
('bcc', post_office.fields.CommaSeparatedEmailField(blank=True)), | |||
('subject', models.CharField(max_length=255, blank=True)), | |||
('message', models.TextField(blank=True)), | |||
('html_message', models.TextField(blank=True)), | |||
('status', models.PositiveSmallIntegerField(blank=True, null=True, db_index=True, choices=[(0, 'sent'), (1, 'failed'), (2, 'queued')])), | |||
('priority', models.PositiveSmallIntegerField(blank=True, null=True, choices=[(0, 'low'), (1, 'medium'), (2, 'high'), (3, 'now')])), | |||
('created', models.DateTimeField(auto_now_add=True, db_index=True)), | |||
('last_updated', models.DateTimeField(auto_now=True, db_index=True)), | |||
('scheduled_time', models.DateTimeField(db_index=True, null=True, blank=True)), | |||
('headers', jsonfield.fields.JSONField(null=True, blank=True)), | |||
('context', jsonfield.fields.JSONField(null=True, blank=True)), | |||
], | |||
options={ | |||
}, | |||
bases=(models.Model,), | |||
), | |||
migrations.CreateModel( | |||
name='EmailTemplate', | |||
fields=[ | |||
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), | |||
('name', models.CharField(help_text=b"e.g: 'welcome_email'", max_length=255)), | |||
('description', models.TextField(help_text='Description of this template.', blank=True)), | |||
('subject', models.CharField(blank=True, max_length=255, validators=[post_office.validators.validate_template_syntax])), | |||
('content', models.TextField(blank=True, validators=[post_office.validators.validate_template_syntax])), | |||
('html_content', models.TextField(blank=True, validators=[post_office.validators.validate_template_syntax])), | |||
('created', models.DateTimeField(auto_now_add=True)), | |||
('last_updated', models.DateTimeField(auto_now=True)), | |||
], | |||
options={ | |||
}, | |||
bases=(models.Model,), | |||
), | |||
migrations.CreateModel( | |||
name='Log', | |||
fields=[ | |||
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), | |||
('date', models.DateTimeField(auto_now_add=True)), | |||
('status', models.PositiveSmallIntegerField(choices=[(0, 'sent'), (1, 'failed')])), | |||
('exception_type', models.CharField(max_length=255, blank=True)), | |||
('message', models.TextField()), | |||
('email', models.ForeignKey(related_name='logs', editable=False, on_delete=models.deletion.CASCADE, to='post_office.Email', )), | |||
], | |||
options={ | |||
}, | |||
bases=(models.Model,), | |||
), | |||
migrations.AddField( | |||
model_name='email', | |||
name='template', | |||
field=models.ForeignKey(blank=True, on_delete=models.deletion.SET_NULL, to='post_office.EmailTemplate', null=True), | |||
preserve_default=True, | |||
), | |||
migrations.AddField( | |||
model_name='attachment', | |||
name='emails', | |||
field=models.ManyToManyField(related_name='attachments', to='post_office.Email'), | |||
preserve_default=True, | |||
), | |||
] |
@@ -0,0 +1,89 @@ | |||
# -*- coding: utf-8 -*- | |||
from __future__ import unicode_literals | |||
from django.db import models, migrations | |||
import post_office.validators | |||
import post_office.fields | |||
class Migration(migrations.Migration): | |||
dependencies = [ | |||
('post_office', '0001_initial'), | |||
] | |||
operations = [ | |||
migrations.AlterModelOptions( | |||
name='emailtemplate', | |||
options={'verbose_name': 'Email Template', 'verbose_name_plural': 'Email Templates'}, | |||
), | |||
migrations.AddField( | |||
model_name='email', | |||
name='backend_alias', | |||
field=models.CharField(default='', max_length=64, blank=True), | |||
), | |||
migrations.AddField( | |||
model_name='emailtemplate', | |||
name='default_template', | |||
field=models.ForeignKey(related_name='translated_templates', default=None, to='post_office.EmailTemplate', null=True, on_delete=models.deletion.SET_NULL), | |||
), | |||
migrations.AddField( | |||
model_name='emailtemplate', | |||
name='language', | |||
field=models.CharField(default='', help_text='Render template in alternative language', max_length=12, blank=True, choices=[(b'af', b'Afrikaans'), (b'ar', b'Arabic'), (b'ast', b'Asturian'), (b'az', b'Azerbaijani'), (b'bg', b'Bulgarian'), (b'be', b'Belarusian'), (b'bn', b'Bengali'), (b'br', b'Breton'), (b'bs', b'Bosnian'), (b'ca', b'Catalan'), (b'cs', b'Czech'), (b'cy', b'Welsh'), (b'da', b'Danish'), (b'de', b'German'), (b'el', b'Greek'), (b'en', b'English'), (b'en-au', b'Australian English'), (b'en-gb', b'British English'), (b'eo', b'Esperanto'), (b'es', b'Spanish'), (b'es-ar', b'Argentinian Spanish'), (b'es-mx', b'Mexican Spanish'), (b'es-ni', b'Nicaraguan Spanish'), (b'es-ve', b'Venezuelan Spanish'), (b'et', b'Estonian'), (b'eu', b'Basque'), (b'fa', b'Persian'), (b'fi', b'Finnish'), (b'fr', b'French'), (b'fy', b'Frisian'), (b'ga', b'Irish'), (b'gl', b'Galician'), (b'he', b'Hebrew'), (b'hi', b'Hindi'), (b'hr', b'Croatian'), (b'hu', b'Hungarian'), (b'ia', b'Interlingua'), (b'id', b'Indonesian'), (b'io', b'Ido'), (b'is', b'Icelandic'), (b'it', b'Italian'), (b'ja', b'Japanese'), (b'ka', b'Georgian'), (b'kk', b'Kazakh'), (b'km', b'Khmer'), (b'kn', b'Kannada'), (b'ko', b'Korean'), (b'lb', b'Luxembourgish'), (b'lt', b'Lithuanian'), (b'lv', b'Latvian'), (b'mk', b'Macedonian'), (b'ml', b'Malayalam'), (b'mn', b'Mongolian'), (b'mr', b'Marathi'), (b'my', b'Burmese'), (b'nb', b'Norwegian Bokmal'), (b'ne', b'Nepali'), (b'nl', b'Dutch'), (b'nn', b'Norwegian Nynorsk'), (b'os', b'Ossetic'), (b'pa', b'Punjabi'), (b'pl', b'Polish'), (b'pt', b'Portuguese'), (b'pt-br', b'Brazilian Portuguese'), (b'ro', b'Romanian'), (b'ru', b'Russian'), (b'sk', b'Slovak'), (b'sl', b'Slovenian'), (b'sq', b'Albanian'), (b'sr', b'Serbian'), (b'sr-latn', b'Serbian Latin'), (b'sv', b'Swedish'), (b'sw', b'Swahili'), (b'ta', b'Tamil'), (b'te', b'Telugu'), (b'th', b'Thai'), (b'tr', b'Turkish'), (b'tt', b'Tatar'), (b'udm', b'Udmurt'), (b'uk', b'Ukrainian'), (b'ur', b'Urdu'), (b'vi', b'Vietnamese'), (b'zh-cn', b'Simplified Chinese'), (b'zh-hans', b'Simplified Chinese'), (b'zh-hant', b'Traditional Chinese'), (b'zh-tw', b'Traditional Chinese')]), | |||
), | |||
migrations.AlterField( | |||
model_name='email', | |||
name='bcc', | |||
field=post_office.fields.CommaSeparatedEmailField(verbose_name='Bcc', blank=True), | |||
), | |||
migrations.AlterField( | |||
model_name='email', | |||
name='cc', | |||
field=post_office.fields.CommaSeparatedEmailField(verbose_name='Cc', blank=True), | |||
), | |||
migrations.AlterField( | |||
model_name='email', | |||
name='from_email', | |||
field=models.CharField(max_length=254, verbose_name='Email From', validators=[post_office.validators.validate_email_with_name]), | |||
), | |||
migrations.AlterField( | |||
model_name='email', | |||
name='html_message', | |||
field=models.TextField(verbose_name='HTML Message', blank=True), | |||
), | |||
migrations.AlterField( | |||
model_name='email', | |||
name='message', | |||
field=models.TextField(verbose_name='Message', blank=True), | |||
), | |||
migrations.AlterField( | |||
model_name='email', | |||
name='subject', | |||
field=models.CharField(max_length=255, verbose_name='Subject', blank=True), | |||
), | |||
migrations.AlterField( | |||
model_name='email', | |||
name='to', | |||
field=post_office.fields.CommaSeparatedEmailField(verbose_name='Email To', blank=True), | |||
), | |||
migrations.AlterField( | |||
model_name='emailtemplate', | |||
name='content', | |||
field=models.TextField(blank=True, verbose_name='Content', validators=[post_office.validators.validate_template_syntax]), | |||
), | |||
migrations.AlterField( | |||
model_name='emailtemplate', | |||
name='html_content', | |||
field=models.TextField(blank=True, verbose_name='HTML content', validators=[post_office.validators.validate_template_syntax]), | |||
), | |||
migrations.AlterField( | |||
model_name='emailtemplate', | |||
name='subject', | |||
field=models.CharField(blank=True, max_length=255, verbose_name='Subject', validators=[post_office.validators.validate_template_syntax]), | |||
), | |||
migrations.AlterUniqueTogether( | |||
name='emailtemplate', | |||
unique_together=set([('language', 'default_template')]), | |||
), | |||
] |
@@ -0,0 +1,25 @@ | |||
# -*- coding: utf-8 -*- | |||
# Generated by Django 1.9 on 2016-02-04 08:08 | |||
from __future__ import unicode_literals | |||
from django.db import migrations, models | |||
class Migration(migrations.Migration): | |||
dependencies = [ | |||
('post_office', '0002_add_i18n_and_backend_alias'), | |||
] | |||
operations = [ | |||
migrations.AlterField( | |||
model_name='email', | |||
name='subject', | |||
field=models.CharField(blank=True, max_length=989, verbose_name='Subject'), | |||
), | |||
migrations.AlterField( | |||
model_name='emailtemplate', | |||
name='language', | |||
field=models.CharField(blank=True, choices=[(b'af', b'Afrikaans'), (b'ar', b'Arabic'), (b'ast', b'Asturian'), (b'az', b'Azerbaijani'), (b'bg', b'Bulgarian'), (b'be', b'Belarusian'), (b'bn', b'Bengali'), (b'br', b'Breton'), (b'bs', b'Bosnian'), (b'ca', b'Catalan'), (b'cs', b'Czech'), (b'cy', b'Welsh'), (b'da', b'Danish'), (b'de', b'German'), (b'el', b'Greek'), (b'en', b'English'), (b'en-au', b'Australian English'), (b'en-gb', b'British English'), (b'eo', b'Esperanto'), (b'es', b'Spanish'), (b'es-ar', b'Argentinian Spanish'), (b'es-co', b'Colombian Spanish'), (b'es-mx', b'Mexican Spanish'), (b'es-ni', b'Nicaraguan Spanish'), (b'es-ve', b'Venezuelan Spanish'), (b'et', b'Estonian'), (b'eu', b'Basque'), (b'fa', b'Persian'), (b'fi', b'Finnish'), (b'fr', b'French'), (b'fy', b'Frisian'), (b'ga', b'Irish'), (b'gd', b'Scottish Gaelic'), (b'gl', b'Galician'), (b'he', b'Hebrew'), (b'hi', b'Hindi'), (b'hr', b'Croatian'), (b'hu', b'Hungarian'), (b'ia', b'Interlingua'), (b'id', b'Indonesian'), (b'io', b'Ido'), (b'is', b'Icelandic'), (b'it', b'Italian'), (b'ja', b'Japanese'), (b'ka', b'Georgian'), (b'kk', b'Kazakh'), (b'km', b'Khmer'), (b'kn', b'Kannada'), (b'ko', b'Korean'), (b'lb', b'Luxembourgish'), (b'lt', b'Lithuanian'), (b'lv', b'Latvian'), (b'mk', b'Macedonian'), (b'ml', b'Malayalam'), (b'mn', b'Mongolian'), (b'mr', b'Marathi'), (b'my', b'Burmese'), (b'nb', b'Norwegian Bokmal'), (b'ne', b'Nepali'), (b'nl', b'Dutch'), (b'nn', b'Norwegian Nynorsk'), (b'os', b'Ossetic'), (b'pa', b'Punjabi'), (b'pl', b'Polish'), (b'pt', b'Portuguese'), (b'pt-br', b'Brazilian Portuguese'), (b'ro', b'Romanian'), (b'ru', b'Russian'), (b'sk', b'Slovak'), (b'sl', b'Slovenian'), (b'sq', b'Albanian'), (b'sr', b'Serbian'), (b'sr-latn', b'Serbian Latin'), (b'sv', b'Swedish'), (b'sw', b'Swahili'), (b'ta', b'Tamil'), (b'te', b'Telugu'), (b'th', b'Thai'), (b'tr', b'Turkish'), (b'tt', b'Tatar'), (b'udm', b'Udmurt'), (b'uk', b'Ukrainian'), (b'ur', b'Urdu'), (b'vi', b'Vietnamese'), (b'zh-hans', b'Simplified Chinese'), (b'zh-hant', b'Traditional Chinese')], default='', help_text='Render template in alternative language', max_length=12), | |||
), | |||
] |
@@ -0,0 +1,120 @@ | |||
# -*- coding: utf-8 -*- | |||
# Generated by Django 1.9.6 on 2016-06-07 07:01 | |||
from __future__ import unicode_literals | |||
from django.db import migrations, models | |||
import django.db.models.deletion | |||
import jsonfield.fields | |||
import post_office.models | |||
class Migration(migrations.Migration): | |||
dependencies = [ | |||
('post_office', '0003_longer_subject'), | |||
] | |||
operations = [ | |||
migrations.AlterModelOptions( | |||
name='attachment', | |||
options={'verbose_name': 'Attachment', 'verbose_name_plural': 'Attachments'}, | |||
), | |||
migrations.AlterModelOptions( | |||
name='email', | |||
options={'verbose_name': 'Email', 'verbose_name_plural': 'Emails'}, | |||
), | |||
migrations.AlterModelOptions( | |||
name='log', | |||
options={'verbose_name': 'Log', 'verbose_name_plural': 'Logs'}, | |||
), | |||
migrations.AlterField( | |||
model_name='attachment', | |||
name='emails', | |||
field=models.ManyToManyField(related_name='attachments', to='post_office.Email', verbose_name='Email addresses'), | |||
), | |||
migrations.AlterField( | |||
model_name='attachment', | |||
name='file', | |||
field=models.FileField(upload_to=post_office.models.get_upload_path, verbose_name='File'), | |||
), | |||
migrations.AlterField( | |||
model_name='attachment', | |||
name='name', | |||
field=models.CharField(help_text='The original filename', max_length=255, verbose_name='Name'), | |||
), | |||
migrations.AlterField( | |||
model_name='email', | |||
name='backend_alias', | |||
field=models.CharField(blank=True, default='', max_length=64, verbose_name='Backend alias'), | |||
), | |||
migrations.AlterField( | |||
model_name='email', | |||
name='context', | |||
field=jsonfield.fields.JSONField(blank=True, null=True, verbose_name='Context'), | |||
), | |||
migrations.AlterField( | |||
model_name='email', | |||
name='headers', | |||
field=jsonfield.fields.JSONField(blank=True, null=True, verbose_name='Headers'), | |||
), | |||
migrations.AlterField( | |||
model_name='email', | |||
name='priority', | |||
field=models.PositiveSmallIntegerField(blank=True, choices=[(0, 'low'), (1, 'medium'), (2, 'high'), (3, 'now')], null=True, verbose_name='Priority'), | |||
), | |||
migrations.AlterField( | |||
model_name='email', | |||
name='scheduled_time', | |||
field=models.DateTimeField(blank=True, db_index=True, null=True, verbose_name='The scheduled sending time'), | |||
), | |||
migrations.AlterField( | |||
model_name='email', | |||
name='status', | |||
field=models.PositiveSmallIntegerField(blank=True, choices=[(0, 'sent'), (1, 'failed'), (2, 'queued')], db_index=True, null=True, verbose_name='Status'), | |||
), | |||
migrations.AlterField( | |||
model_name='email', | |||
name='template', | |||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='post_office.EmailTemplate', verbose_name='Email template'), | |||
), | |||
migrations.AlterField( | |||
model_name='emailtemplate', | |||
name='default_template', | |||
field=models.ForeignKey(default=None, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='translated_templates', to='post_office.EmailTemplate', verbose_name='Default template'), | |||
), | |||
migrations.AlterField( | |||
model_name='emailtemplate', | |||
name='description', | |||
field=models.TextField(blank=True, help_text='Description of this template.', verbose_name='Description'), | |||
), | |||
migrations.AlterField( | |||
model_name='emailtemplate', | |||
name='language', | |||
field=models.CharField(blank=True, default='', help_text='Render template in alternative language', max_length=12, verbose_name='Language'), | |||
), | |||
migrations.AlterField( | |||
model_name='emailtemplate', | |||
name='name', | |||
field=models.CharField(help_text="e.g: 'welcome_email'", max_length=255, verbose_name='Name'), | |||
), | |||
migrations.AlterField( | |||
model_name='log', | |||
name='email', | |||
field=models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, related_name='logs', to='post_office.Email', verbose_name='Email address'), | |||
), | |||
migrations.AlterField( | |||
model_name='log', | |||
name='exception_type', | |||
field=models.CharField(blank=True, max_length=255, verbose_name='Exception type'), | |||
), | |||
migrations.AlterField( | |||
model_name='log', | |||
name='message', | |||
field=models.TextField(verbose_name='Message'), | |||
), | |||
migrations.AlterField( | |||
model_name='log', | |||
name='status', | |||
field=models.PositiveSmallIntegerField(choices=[(0, 'sent'), (1, 'failed')], verbose_name='Status'), | |||
), | |||
] |
@@ -0,0 +1,19 @@ | |||
# -*- coding: utf-8 -*- | |||
# Generated by Django 1.11.1 on 2017-05-15 00:13 | |||
from __future__ import unicode_literals | |||
from django.db import migrations | |||
class Migration(migrations.Migration): | |||
dependencies = [ | |||
('post_office', '0004_auto_20160607_0901'), | |||
] | |||
operations = [ | |||
migrations.AlterUniqueTogether( | |||
name='emailtemplate', | |||
unique_together=set([('name', 'language', 'default_template')]), | |||
), | |||
] |
@@ -0,0 +1,19 @@ | |||
# -*- coding: utf-8 -*- | |||
from __future__ import unicode_literals | |||
from django.db import models, migrations | |||
class Migration(migrations.Migration): | |||
dependencies = [ | |||
('post_office', '0005_auto_20170515_0013'), | |||
] | |||
operations = [ | |||
migrations.AddField( | |||
model_name='attachment', | |||
name='mimetype', | |||
field=models.CharField(default='', max_length=255, blank=True), | |||
), | |||
] |
@@ -0,0 +1,19 @@ | |||
# -*- coding: utf-8 -*- | |||
# Generated by Django 1.11.3 on 2017-07-31 11:42 | |||
from __future__ import unicode_literals | |||
from django.db import migrations | |||
class Migration(migrations.Migration): | |||
dependencies = [ | |||
('post_office', '0006_attachment_mimetype'), | |||
] | |||
operations = [ | |||
migrations.AlterModelOptions( | |||
name='emailtemplate', | |||
options={'ordering': ['name'], 'verbose_name': 'Email Template', 'verbose_name_plural': 'Email Templates'}, | |||
), | |||
] |
@@ -0,0 +1,284 @@ | |||
# -*- coding: utf-8 -*- | |||
from __future__ import unicode_literals | |||
import os | |||
from collections import namedtuple | |||
from uuid import uuid4 | |||
from django.core.mail import EmailMessage, EmailMultiAlternatives | |||
from django.db import models | |||
from django.template import Context, Template | |||
from django.utils.encoding import python_2_unicode_compatible | |||
from django.utils.translation import pgettext_lazy | |||
from django.utils.translation import ugettext_lazy as _ | |||
from django.utils import timezone | |||
from jsonfield import JSONField | |||
from post_office import cache | |||
from post_office.fields import CommaSeparatedEmailField | |||
from .compat import text_type, smart_text | |||
from .connections import connections | |||
from .settings import context_field_class, get_log_level | |||
from .validators import validate_email_with_name, validate_template_syntax | |||
PRIORITY = namedtuple('PRIORITY', 'low medium high now')._make(range(4)) | |||
STATUS = namedtuple('STATUS', 'sent failed queued')._make(range(3)) | |||
@python_2_unicode_compatible | |||
class Email(models.Model): | |||
""" | |||
A model to hold email information. | |||
""" | |||
PRIORITY_CHOICES = [(PRIORITY.low, _("low")), (PRIORITY.medium, _("medium")), | |||
(PRIORITY.high, _("high")), (PRIORITY.now, _("now"))] | |||
STATUS_CHOICES = [(STATUS.sent, _("sent")), (STATUS.failed, _("failed")), | |||
(STATUS.queued, _("queued"))] | |||
from_email = models.CharField(_("Email From"), max_length=254, | |||
validators=[validate_email_with_name]) | |||
to = CommaSeparatedEmailField(_("Email To")) | |||
cc = CommaSeparatedEmailField(_("Cc")) | |||
bcc = CommaSeparatedEmailField(_("Bcc")) | |||
subject = models.CharField(_("Subject"), max_length=989, blank=True) | |||
message = models.TextField(_("Message"), blank=True) | |||
html_message = models.TextField(_("HTML Message"), blank=True) | |||
""" | |||
Emails with 'queued' status will get processed by ``send_queued`` command. | |||
Status field will then be set to ``failed`` or ``sent`` depending on | |||
whether it's successfully delivered. | |||
""" | |||
status = models.PositiveSmallIntegerField( | |||
_("Status"), | |||
choices=STATUS_CHOICES, db_index=True, | |||
blank=True, null=True) | |||
priority = models.PositiveSmallIntegerField(_("Priority"), | |||
choices=PRIORITY_CHOICES, | |||
blank=True, null=True) | |||
created = models.DateTimeField(auto_now_add=True, db_index=True) | |||
last_updated = models.DateTimeField(db_index=True, auto_now=True) | |||
scheduled_time = models.DateTimeField(_('The scheduled sending time'), | |||
blank=True, null=True, db_index=True) | |||
headers = JSONField(_('Headers'), blank=True, null=True) | |||
template = models.ForeignKey('post_office.EmailTemplate', blank=True, | |||
null=True, verbose_name=_('Email template'), | |||
on_delete=models.CASCADE) | |||
context = context_field_class(_('Context'), blank=True, null=True) | |||
backend_alias = models.CharField(_('Backend alias'), blank=True, default='', | |||
max_length=64) | |||
class Meta: | |||
app_label = 'post_office' | |||
verbose_name = pgettext_lazy("Email address", "Email") | |||
verbose_name_plural = pgettext_lazy("Email addresses", "Emails") | |||
def __init__(self, *args, **kwargs): | |||
super(Email, self).__init__(*args, **kwargs) | |||
self._cached_email_message = None | |||
def __str__(self): | |||
return u'%s' % self.to | |||
def email_message(self): | |||
""" | |||
Returns Django EmailMessage object for sending. | |||
""" | |||
if self._cached_email_message: | |||
return self._cached_email_message | |||
return self.prepare_email_message() | |||
def prepare_email_message(self): | |||
""" | |||
Returns a django ``EmailMessage`` or ``EmailMultiAlternatives`` object, | |||
depending on whether html_message is empty. | |||
""" | |||
subject = smart_text(self.subject) | |||
if self.template is not None: | |||
_context = Context(self.context) | |||
subject = Template(self.template.subject).render(_context) | |||
message = Template(self.template.content).render(_context) | |||
html_message = Template(self.template.html_content).render(_context) | |||
else: | |||
subject = self.subject | |||
message = self.message | |||
html_message = self.html_message | |||
connection = connections[self.backend_alias or 'default'] | |||
if html_message: | |||
msg = EmailMultiAlternatives( | |||
subject=subject, body=message, from_email=self.from_email, | |||
to=self.to, bcc=self.bcc, cc=self.cc, | |||
headers=self.headers, connection=connection) | |||
msg.attach_alternative(html_message, "text/html") | |||
else: | |||
msg = EmailMessage( | |||
subject=subject, body=message, from_email=self.from_email, | |||
to=self.to, bcc=self.bcc, cc=self.cc, | |||
headers=self.headers, connection=connection) | |||
for attachment in self.attachments.all(): | |||
msg.attach(attachment.name, attachment.file.read(), mimetype=attachment.mimetype or None) | |||
attachment.file.close() | |||
self._cached_email_message = msg | |||
return msg | |||
def dispatch(self, log_level=None, | |||
disconnect_after_delivery=True, commit=True): | |||
""" | |||
Sends email and log the result. | |||
""" | |||
try: | |||
self.email_message().send() | |||
status = STATUS.sent | |||
message = '' | |||
exception_type = '' | |||
except Exception as e: | |||
status = STATUS.failed | |||
message = str(e) | |||
exception_type = type(e).__name__ | |||
# If run in a bulk sending mode, reraise and let the outer | |||
# layer handle the exception | |||
if not commit: | |||
raise | |||
if commit: | |||
self.status = status | |||
self.save(update_fields=['status']) | |||
if log_level is None: | |||
log_level = get_log_level() | |||
# If log level is 0, log nothing, 1 logs only sending failures | |||
# and 2 means log both successes and failures | |||
if log_level == 1: | |||
if status == STATUS.failed: | |||
self.logs.create(status=status, message=message, | |||
exception_type=exception_type) | |||
elif log_level == 2: | |||
self.logs.create(status=status, message=message, | |||
exception_type=exception_type) | |||
return status | |||
def save(self, *args, **kwargs): | |||
self.full_clean() | |||
return super(Email, self).save(*args, **kwargs) | |||
@python_2_unicode_compatible | |||
class Log(models.Model): | |||
""" | |||
A model to record sending email sending activities. | |||
""" | |||
STATUS_CHOICES = [(STATUS.sent, _("sent")), (STATUS.failed, _("failed"))] | |||
email = models.ForeignKey(Email, editable=False, related_name='logs', | |||
verbose_name=_('Email address'), on_delete=models.CASCADE) | |||
date = models.DateTimeField(auto_now_add=True) | |||
status = models.PositiveSmallIntegerField(_('Status'), choices=STATUS_CHOICES) | |||
exception_type = models.CharField(_('Exception type'), max_length=255, blank=True) | |||
message = models.TextField(_('Message')) | |||
class Meta: | |||
app_label = 'post_office' | |||
verbose_name = _("Log") | |||
verbose_name_plural = _("Logs") | |||
def __str__(self): | |||
return text_type(self.date) | |||
class EmailTemplateManager(models.Manager): | |||
def get_by_natural_key(self, name, language, default_template): | |||
return self.get(name=name, language=language, default_template=default_template) | |||
@python_2_unicode_compatible | |||
class EmailTemplate(models.Model): | |||
""" | |||
Model to hold template information from db | |||
""" | |||
name = models.CharField(_('Name'), max_length=255, help_text=_("e.g: 'welcome_email'")) | |||
description = models.TextField(_('Description'), blank=True, | |||
help_text=_("Description of this template.")) | |||
created = models.DateTimeField(auto_now_add=True) | |||
last_updated = models.DateTimeField(auto_now=True) | |||
subject = models.CharField(max_length=255, blank=True, | |||
verbose_name=_("Subject"), validators=[validate_template_syntax]) | |||
content = models.TextField(blank=True, | |||
verbose_name=_("Content"), validators=[validate_template_syntax]) | |||
html_content = models.TextField(blank=True, | |||
verbose_name=_("HTML content"), validators=[validate_template_syntax]) | |||
language = models.CharField(max_length=12, | |||
verbose_name=_("Language"), | |||
help_text=_("Render template in alternative language"), | |||
default='', blank=True) | |||
default_template = models.ForeignKey('self', related_name='translated_templates', | |||
null=True, default=None, verbose_name=_('Default template'), on_delete=models.CASCADE) | |||
objects = EmailTemplateManager() | |||
class Meta: | |||
app_label = 'post_office' | |||
unique_together = ('name', 'language', 'default_template') | |||
verbose_name = _("Email Template") | |||
verbose_name_plural = _("Email Templates") | |||
ordering = ['name'] | |||
def __str__(self): | |||
return u'%s %s' % (self.name, self.language) | |||
def natural_key(self): | |||
return (self.name, self.language, self.default_template) | |||
def save(self, *args, **kwargs): | |||
# If template is a translation, use default template's name | |||
if self.default_template and not self.name: | |||
self.name = self.default_template.name | |||
template = super(EmailTemplate, self).save(*args, **kwargs) | |||
cache.delete(self.name) | |||
return template | |||
def get_upload_path(instance, filename): | |||
"""Overriding to store the original filename""" | |||
if not instance.name: | |||
instance.name = filename # set original filename | |||
date = timezone.now().date() | |||
filename = '{name}.{ext}'.format(name=uuid4().hex, | |||
ext=filename.split('.')[-1]) | |||
return os.path.join('post_office_attachments', str(date.year), | |||
str(date.month), str(date.day), filename) | |||
@python_2_unicode_compatible | |||
class Attachment(models.Model): | |||
""" | |||
A model describing an email attachment. | |||
""" | |||
file = models.FileField(_('File'), upload_to=get_upload_path) | |||
name = models.CharField(_('Name'), max_length=255, help_text=_("The original filename")) | |||
emails = models.ManyToManyField(Email, related_name='attachments', | |||
verbose_name=_('Email addresses')) | |||
mimetype = models.CharField(max_length=255, default='', blank=True) | |||
class Meta: | |||
app_label = 'post_office' | |||
verbose_name = _("Attachment") | |||
verbose_name_plural = _("Attachments") | |||
def __str__(self): | |||
return self.name |
@@ -0,0 +1,95 @@ | |||
import warnings | |||
from django.conf import settings | |||
from django.core.cache.backends.base import InvalidCacheBackendError | |||
from .compat import import_attribute, get_cache | |||
def get_backend(alias='default'): | |||
return get_available_backends()[alias] | |||
def get_available_backends(): | |||
""" Returns a dictionary of defined backend classes. For example: | |||
{ | |||
'default': 'django.core.mail.backends.smtp.EmailBackend', | |||
'locmem': 'django.core.mail.backends.locmem.EmailBackend', | |||
} | |||
""" | |||
backends = get_config().get('BACKENDS', {}) | |||
if backends: | |||
return backends | |||
# Try to get backend settings from old style | |||
# POST_OFFICE = { | |||
# 'EMAIL_BACKEND': 'mybackend' | |||
# } | |||
backend = get_config().get('EMAIL_BACKEND') | |||
if backend: | |||
warnings.warn('Please use the new POST_OFFICE["BACKENDS"] settings', | |||
DeprecationWarning) | |||
backends['default'] = backend | |||
return backends | |||
# Fall back to Django's EMAIL_BACKEND definition | |||
backends['default'] = getattr( | |||
settings, 'EMAIL_BACKEND', | |||
'django.core.mail.backends.smtp.EmailBackend') | |||
# If EMAIL_BACKEND is set to use PostOfficeBackend | |||
# and POST_OFFICE_BACKEND is not set, fall back to SMTP | |||
if 'post_office.EmailBackend' in backends['default']: | |||
backends['default'] = 'django.core.mail.backends.smtp.EmailBackend' | |||
return backends | |||
def get_cache_backend(): | |||
if hasattr(settings, 'CACHES'): | |||
if "post_office" in settings.CACHES: | |||
return get_cache("post_office") | |||
else: | |||
# Sometimes this raises InvalidCacheBackendError, which is ok too | |||
try: | |||
return get_cache("default") | |||
except InvalidCacheBackendError: | |||
pass | |||
return None | |||
def get_config(): | |||
""" | |||
Returns Post Office's configuration in dictionary format. e.g: | |||
POST_OFFICE = { | |||
'BATCH_SIZE': 1000 | |||
} | |||
""" | |||
return getattr(settings, 'POST_OFFICE', {}) | |||
def get_batch_size(): | |||
return get_config().get('BATCH_SIZE', 100) | |||
def get_threads_per_process(): | |||
return get_config().get('THREADS_PER_PROCESS', 5) | |||
def get_default_priority(): | |||
return get_config().get('DEFAULT_PRIORITY', 'medium') | |||
def get_log_level(): | |||
return get_config().get('LOG_LEVEL', 2) | |||
def get_sending_order(): | |||
return get_config().get('SENDING_ORDER', ['-priority']) | |||
CONTEXT_FIELD_CLASS = get_config().get('CONTEXT_FIELD_CLASS', | |||
'jsonfield.JSONField') | |||
context_field_class = import_attribute(CONTEXT_FIELD_CLASS) |
@@ -0,0 +1,84 @@ | |||
# -*- coding: utf-8 -*- | |||
import django | |||
from distutils.version import StrictVersion | |||
DATABASES = { | |||
'default': { | |||
'ENGINE': 'django.db.backends.sqlite3', | |||
}, | |||
} | |||
# Default values: True | |||
# POST_OFFICE_CACHE = True | |||
# POST_OFFICE_TEMPLATE_CACHE = True | |||
CACHES = { | |||
'default': { | |||
'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', | |||
'TIMEOUT': 36000, | |||
'KEY_PREFIX': 'post-office', | |||
}, | |||
'post_office': { | |||
'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', | |||
'TIMEOUT': 36000, | |||
'KEY_PREFIX': 'post-office', | |||
} | |||
} | |||
POST_OFFICE = { | |||
'BACKENDS': { | |||
'default': 'django.core.mail.backends.dummy.EmailBackend', | |||
'locmem': 'django.core.mail.backends.locmem.EmailBackend', | |||
'error': 'post_office.tests.test_backends.ErrorRaisingBackend', | |||
'smtp': 'django.core.mail.backends.smtp.EmailBackend', | |||
'connection_tester': 'post_office.tests.test_mail.ConnectionTestingBackend', | |||
} | |||
} | |||
INSTALLED_APPS = ( | |||
'django.contrib.admin', | |||
'django.contrib.auth', | |||
'django.contrib.contenttypes', | |||
'django.contrib.sessions', | |||
'post_office', | |||
) | |||
SECRET_KEY = 'a' | |||
ROOT_URLCONF = 'post_office.test_urls' | |||
DEFAULT_FROM_EMAIL = 'webmaster@example.com' | |||
if StrictVersion(str(django.get_version())) < '1.10': | |||
MIDDLEWARE_CLASSES = ( | |||
'django.contrib.sessions.middleware.SessionMiddleware', | |||
'django.contrib.auth.middleware.AuthenticationMiddleware', | |||
) | |||
else: | |||
MIDDLEWARE = [ | |||
'django.contrib.sessions.middleware.SessionMiddleware', | |||
'django.contrib.auth.middleware.AuthenticationMiddleware', | |||
] | |||
TEMPLATES = [ | |||
{ | |||
'BACKEND': 'django.template.backends.django.DjangoTemplates', | |||
'DIRS': [], | |||
'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', | |||
], | |||
}, | |||
}, | |||
] |
@@ -0,0 +1,6 @@ | |||
from django.conf.urls import url | |||
from django.contrib import admin | |||
urlpatterns = [ | |||
url(r'^admin/', admin.site.urls), | |||
] |
@@ -0,0 +1,8 @@ | |||
from .test_backends import BackendTest | |||
from .test_commands import CommandTest | |||
from .test_lockfile import LockTest | |||
from .test_mail import MailTest | |||
from .test_models import ModelTest | |||
from .test_utils import UtilsTest | |||
from .test_cache import CacheTest | |||
from .test_views import AdminViewTest |
@@ -0,0 +1,113 @@ | |||
from django.conf import settings | |||
from django.core.mail import EmailMultiAlternatives, send_mail, EmailMessage | |||
from django.core.mail.backends.base import BaseEmailBackend | |||
from django.test import TestCase | |||
from django.test.utils import override_settings | |||
from ..models import Email, STATUS, PRIORITY | |||
from ..settings import get_backend | |||
class ErrorRaisingBackend(BaseEmailBackend): | |||
""" | |||
An EmailBackend that always raises an error during sending | |||
to test if django_mailer handles sending error correctly | |||
""" | |||
def send_messages(self, email_messages): | |||
raise Exception('Fake Error') | |||
class BackendTest(TestCase): | |||
@override_settings(EMAIL_BACKEND='post_office.EmailBackend') | |||
def test_email_backend(self): | |||
""" | |||
Ensure that email backend properly queue email messages. | |||
""" | |||
send_mail('Test', 'Message', 'from@example.com', ['to@example.com']) | |||
email = Email.objects.latest('id') | |||
self.assertEqual(email.subject, 'Test') | |||
self.assertEqual(email.status, STATUS.queued) | |||
self.assertEqual(email.priority, PRIORITY.medium) | |||
def test_email_backend_setting(self): | |||
""" | |||
""" | |||
old_email_backend = getattr(settings, 'EMAIL_BACKEND', None) | |||
old_post_office_backend = getattr(settings, 'POST_OFFICE_BACKEND', None) | |||
if hasattr(settings, 'EMAIL_BACKEND'): | |||
delattr(settings, 'EMAIL_BACKEND') | |||
if hasattr(settings, 'POST_OFFICE_BACKEND'): | |||
delattr(settings, 'POST_OFFICE_BACKEND') | |||
previous_settings = settings.POST_OFFICE | |||
delattr(settings, 'POST_OFFICE') | |||
# If no email backend is set, backend should default to SMTP | |||
self.assertEqual(get_backend(), 'django.core.mail.backends.smtp.EmailBackend') | |||
# If EMAIL_BACKEND is set to PostOfficeBackend, use SMTP to send by default | |||
setattr(settings, 'EMAIL_BACKEND', 'post_office.EmailBackend') | |||
self.assertEqual(get_backend(), 'django.core.mail.backends.smtp.EmailBackend') | |||
# If EMAIL_BACKEND is set on new dictionary-styled settings, use that | |||
setattr(settings, 'POST_OFFICE', {'EMAIL_BACKEND': 'test'}) | |||
self.assertEqual(get_backend(), 'test') | |||
delattr(settings, 'POST_OFFICE') | |||
if old_email_backend: | |||
setattr(settings, 'EMAIL_BACKEND', old_email_backend) | |||
else: | |||
delattr(settings, 'EMAIL_BACKEND') | |||
setattr(settings, 'POST_OFFICE', previous_settings) | |||
@override_settings(EMAIL_BACKEND='post_office.EmailBackend') | |||
def test_sending_html_email(self): | |||
""" | |||
"text/html" attachments to Email should be persisted into the database | |||
""" | |||
message = EmailMultiAlternatives('subject', 'body', 'from@example.com', | |||
['recipient@example.com']) | |||
message.attach_alternative('html', "text/html") | |||
message.send() | |||
email = Email.objects.latest('id') | |||
self.assertEqual(email.html_message, 'html') | |||
@override_settings(EMAIL_BACKEND='post_office.EmailBackend') | |||
def test_headers_sent(self): | |||
""" | |||
Test that headers are correctly set on the outgoing emails. | |||
""" | |||
message = EmailMessage('subject', 'body', 'from@example.com', | |||
['recipient@example.com'], | |||
headers={'Reply-To': 'reply@example.com'}) | |||
message.send() | |||
email = Email.objects.latest('id') | |||
self.assertEqual(email.headers, {'Reply-To': 'reply@example.com'}) | |||
@override_settings(EMAIL_BACKEND='post_office.EmailBackend') | |||
def test_backend_attachments(self): | |||
message = EmailMessage('subject', 'body', 'from@example.com', | |||
['recipient@example.com']) | |||
message.attach('attachment.txt', 'attachment content') | |||
message.send() | |||
email = Email.objects.latest('id') | |||
self.assertEqual(email.attachments.count(), 1) | |||
self.assertEqual(email.attachments.all()[0].name, 'attachment.txt') | |||
self.assertEqual(email.attachments.all()[0].file.read(), b'attachment content') | |||
@override_settings( | |||
EMAIL_BACKEND='post_office.EmailBackend', | |||
POST_OFFICE={ | |||
'DEFAULT_PRIORITY': 'now', | |||
'BACKENDS': {'default': 'django.core.mail.backends.dummy.EmailBackend'} | |||
} | |||
) | |||
def test_default_priority_now(self): | |||
# If DEFAULT_PRIORITY is "now", mails should be sent right away | |||
send_mail('Test', 'Message', 'from1@example.com', ['to@example.com']) | |||
email = Email.objects.latest('id') | |||
self.assertEqual(email.status, STATUS.sent) |
@@ -0,0 +1,41 @@ | |||
from django.conf import settings | |||
from django.test import TestCase | |||
from post_office import cache | |||
from ..settings import get_cache_backend | |||
class CacheTest(TestCase): | |||
def test_get_backend_settings(self): | |||
"""Test basic get backend function and its settings""" | |||
# Sanity check | |||
self.assertTrue('post_office' in settings.CACHES) | |||
self.assertTrue(get_cache_backend()) | |||
# If no post office key is defined, it should return default | |||
del(settings.CACHES['post_office']) | |||
self.assertTrue(get_cache_backend()) | |||
# If no caches key in settings, it should return None | |||
delattr(settings, 'CACHES') | |||
self.assertEqual(None, get_cache_backend()) | |||
def test_get_cache_key(self): | |||
""" | |||
Test for converting names to cache key | |||
""" | |||
self.assertEqual('post_office:template:test', cache.get_cache_key('test')) | |||
self.assertEqual('post_office:template:test-slugify', cache.get_cache_key('test slugify')) | |||
def test_basic_cache_operations(self): | |||
""" | |||
Test basic cache operations | |||
""" | |||
# clean test cache | |||
cache.cache_backend.clear() | |||
self.assertEqual(None, cache.get('test-cache')) | |||
cache.set('test-cache', 'awesome content') | |||
self.assertTrue('awesome content', cache.get('test-cache')) | |||
cache.delete('test-cache') | |||
self.assertEqual(None, cache.get('test-cache')) |
@@ -0,0 +1,150 @@ | |||
import datetime | |||
import os | |||
from django.core.files.base import ContentFile | |||
from django.core.management import call_command | |||
from django.test import TestCase | |||
from django.test.utils import override_settings | |||
from django.utils.timezone import now | |||
from ..models import Attachment, Email, STATUS | |||
class CommandTest(TestCase): | |||
def test_cleanup_mail_with_orphaned_attachments(self): | |||
self.assertEqual(Email.objects.count(), 0) | |||
email = Email.objects.create(to=['to@example.com'], | |||
from_email='from@example.com', | |||
subject='Subject') | |||
email.created = now() - datetime.timedelta(31) | |||
email.save() | |||
attachment = Attachment() | |||
attachment.file.save( | |||
'test.txt', content=ContentFile('test file content'), save=True | |||
) | |||
email.attachments.add(attachment) | |||
attachment_path = attachment.file.name | |||
# We have orphaned attachment now | |||
call_command('cleanup_mail', days=30) | |||
self.assertEqual(Email.objects.count(), 0) | |||
self.assertEqual(Attachment.objects.count(), 1) | |||
# Actually cleanup orphaned attachments | |||
call_command('cleanup_mail', '-da', days=30) | |||
self.assertEqual(Email.objects.count(), 0) | |||
self.assertEqual(Attachment.objects.count(), 0) | |||
# Check that the actual file has been deleted as well | |||
self.assertFalse(os.path.exists(attachment_path)) | |||
# Check if the email attachment's actual file have been deleted | |||
Email.objects.all().delete() | |||
email = Email.objects.create(to=['to@example.com'], | |||
from_email='from@example.com', | |||
subject='Subject') | |||
email.created = now() - datetime.timedelta(31) | |||
email.save() | |||
attachment = Attachment() | |||
attachment.file.save( | |||
'test.txt', content=ContentFile('test file content'), save=True | |||
) | |||
email.attachments.add(attachment) | |||
attachment_path = attachment.file.name | |||
# Simulate that the files have been deleted by accidents | |||
os.remove(attachment_path) | |||
# No exceptions should break the cleanup | |||
call_command('cleanup_mail', '-da', days=30) | |||
self.assertEqual(Email.objects.count(), 0) | |||
self.assertEqual(Attachment.objects.count(), 0) | |||
def test_cleanup_mail(self): | |||
""" | |||
The ``cleanup_mail`` command deletes mails older than a specified | |||
amount of days | |||
""" | |||
self.assertEqual(Email.objects.count(), 0) | |||
# The command shouldn't delete today's email | |||
email = Email.objects.create(from_email='from@example.com', | |||
to=['to@example.com']) | |||
call_command('cleanup_mail', days=30) | |||
self.assertEqual(Email.objects.count(), 1) | |||
# Email older than 30 days should be deleted | |||
email.created = now() - datetime.timedelta(31) | |||
email.save() | |||
call_command('cleanup_mail', days=30) | |||
self.assertEqual(Email.objects.count(), 0) | |||
TEST_SETTINGS = { | |||
'BACKENDS': { | |||
'default': 'django.core.mail.backends.dummy.EmailBackend', | |||
}, | |||
'BATCH_SIZE': 1 | |||
} | |||
@override_settings(POST_OFFICE=TEST_SETTINGS) | |||
def test_send_queued_mail(self): | |||
""" | |||
Ensure that ``send_queued_mail`` behaves properly and sends all queued | |||
emails in two batches. | |||
""" | |||
# Make sure that send_queued_mail with empty queue does not raise error | |||
call_command('send_queued_mail', processes=1) | |||
Email.objects.create(from_email='from@example.com', | |||
to=['to@example.com'], status=STATUS.queued) | |||
Email.objects.create(from_email='from@example.com', | |||
to=['to@example.com'], status=STATUS.queued) | |||
call_command('send_queued_mail', processes=1) | |||
self.assertEqual(Email.objects.filter(status=STATUS.sent).count(), 2) | |||
self.assertEqual(Email.objects.filter(status=STATUS.queued).count(), 0) | |||
def test_successful_deliveries_logging(self): | |||
""" | |||
Successful deliveries are only logged when log_level is 2. | |||
""" | |||
email = Email.objects.create(from_email='from@example.com', | |||
to=['to@example.com'], status=STATUS.queued) | |||
call_command('send_queued_mail', log_level=0) | |||
self.assertEqual(email.logs.count(), 0) | |||
email = Email.objects.create(from_email='from@example.com', | |||
to=['to@example.com'], status=STATUS.queued) | |||
call_command('send_queued_mail', log_level=1) | |||
self.assertEqual(email.logs.count(), 0) | |||
email = Email.objects.create(from_email='from@example.com', | |||
to=['to@example.com'], status=STATUS.queued) | |||
call_command('send_queued_mail', log_level=2) | |||
self.assertEqual(email.logs.count(), 1) | |||
def test_failed_deliveries_logging(self): | |||
""" | |||
Failed deliveries are logged when log_level is 1 and 2. | |||
""" | |||
email = Email.objects.create(from_email='from@example.com', | |||
to=['to@example.com'], status=STATUS.queued, | |||
backend_alias='error') | |||
call_command('send_queued_mail', log_level=0) | |||
self.assertEqual(email.logs.count(), 0) | |||
email = Email.objects.create(from_email='from@example.com', | |||
to=['to@example.com'], status=STATUS.queued, | |||
backend_alias='error') | |||
call_command('send_queued_mail', log_level=1) | |||
self.assertEqual(email.logs.count(), 1) | |||
email = Email.objects.create(from_email='from@example.com', | |||
to=['to@example.com'], status=STATUS.queued, | |||
backend_alias='error') | |||
call_command('send_queued_mail', log_level=2) | |||
self.assertEqual(email.logs.count(), 1) |
@@ -0,0 +1,13 @@ | |||
from django.core.mail import backends | |||
from django.test import TestCase | |||
from .test_backends import ErrorRaisingBackend | |||
from ..connections import connections | |||
class ConnectionTest(TestCase): | |||
def test_get_connection(self): | |||
# Ensure ConnectionHandler returns the right connection | |||
self.assertTrue(isinstance(connections['error'], ErrorRaisingBackend)) | |||
self.assertTrue(isinstance(connections['locmem'], backends.locmem.EmailBackend)) |
@@ -0,0 +1,75 @@ | |||
import time | |||
import os | |||
from django.test import TestCase | |||
from ..lockfile import FileLock, FileLocked | |||
def setup_fake_lock(lock_file_name): | |||
pid = os.getpid() | |||
lockfile = '%s.lock' % pid | |||
try: | |||
os.remove(lock_file_name) | |||
except OSError: | |||
pass | |||
os.symlink(lockfile, lock_file_name) | |||
class LockTest(TestCase): | |||
def test_process_killed_force_unlock(self): | |||
pid = os.getpid() | |||
lockfile = '%s.lock' % pid | |||
setup_fake_lock('test.lock') | |||
with open(lockfile, 'w+') as f: | |||
f.write('9999999') | |||
assert os.path.exists(lockfile) | |||
with FileLock('test'): | |||
assert True | |||
def test_force_unlock_in_same_process(self): | |||
pid = os.getpid() | |||
lockfile = '%s.lock' % pid | |||
os.symlink(lockfile, 'test.lock') | |||
with open(lockfile, 'w+') as f: | |||
f.write(str(os.getpid())) | |||
with FileLock('test', force=True): | |||
assert True | |||
def test_exception_after_timeout(self): | |||
pid = os.getpid() | |||
lockfile = '%s.lock' % pid | |||
setup_fake_lock('test.lock') | |||
with open(lockfile, 'w+') as f: | |||
f.write(str(os.getpid())) | |||
try: | |||
with FileLock('test', timeout=1): | |||
assert False | |||
except FileLocked: | |||
assert True | |||
def test_force_after_timeout(self): | |||
pid = os.getpid() | |||
lockfile = '%s.lock' % pid | |||
setup_fake_lock('test.lock') | |||
with open(lockfile, 'w+') as f: | |||
f.write(str(os.getpid())) | |||
timeout = 1 | |||
start = time.time() | |||
with FileLock('test', timeout=timeout, force=True): | |||
assert True | |||
end = time.time() | |||
assert end - start > timeout | |||
def test_get_lock_pid(self): | |||
"""Ensure get_lock_pid() works properly""" | |||
with FileLock('test', timeout=1, force=True) as lock: | |||
self.assertEqual(lock.get_lock_pid(), int(os.getpid())) |
@@ -0,0 +1,400 @@ | |||
# -*- coding: utf-8 -*- | |||
from __future__ import unicode_literals | |||
from datetime import date, datetime | |||
from django.core import mail | |||
from django.core.files.base import ContentFile | |||
from django.conf import settings | |||
from django.test import TestCase | |||
from django.test.utils import override_settings | |||
from ..settings import get_batch_size, get_log_level, get_threads_per_process | |||
from ..models import Email, EmailTemplate, Attachment, PRIORITY, STATUS | |||
from ..mail import (create, get_queued, | |||
send, send_many, send_queued, _send_bulk) | |||
connection_counter = 0 | |||
class ConnectionTestingBackend(mail.backends.base.BaseEmailBackend): | |||
''' | |||
An EmailBackend that increments a global counter when connection is opened | |||
''' | |||
def open(self): | |||
global connection_counter | |||
connection_counter += 1 | |||
def send_messages(self, email_messages): | |||
pass | |||
class MailTest(TestCase): | |||
@override_settings(EMAIL_BACKEND='django.core.mail.backends.locmem.EmailBackend') | |||
def test_send_queued_mail(self): | |||
""" | |||
Check that only queued messages are sent. | |||
""" | |||
kwargs = { | |||
'to': ['to@example.com'], | |||
'from_email': 'bob@example.com', | |||
'subject': 'Test', | |||
'message': 'Message', | |||
} | |||
failed_mail = Email.objects.create(status=STATUS.failed, **kwargs) | |||
none_mail = Email.objects.create(status=None, **kwargs) | |||
# This should be the only email that gets sent | |||
queued_mail = Email.objects.create(status=STATUS.queued, **kwargs) | |||
send_queued() | |||
self.assertNotEqual(Email.objects.get(id=failed_mail.id).status, STATUS.sent) | |||
self.assertNotEqual(Email.objects.get(id=none_mail.id).status, STATUS.sent) | |||
self.assertEqual(Email.objects.get(id=queued_mail.id).status, STATUS.sent) | |||
@override_settings(EMAIL_BACKEND='django.core.mail.backends.locmem.EmailBackend') | |||
def test_send_queued_mail_multi_processes(self): | |||
""" | |||
Check that send_queued works well with multiple processes | |||
""" | |||
kwargs = { | |||
'to': ['to@example.com'], | |||
'from_email': 'bob@example.com', | |||
'subject': 'Test', | |||
'message': 'Message', | |||
'status': STATUS.queued | |||
} | |||
# All three emails should be sent | |||
self.assertEqual(Email.objects.filter(status=STATUS.sent).count(), 0) | |||
for i in range(3): | |||
Email.objects.create(**kwargs) | |||
total_sent, total_failed = send_queued(processes=2) | |||
self.assertEqual(total_sent, 3) | |||
def test_send_bulk(self): | |||
""" | |||
Ensure _send_bulk() properly sends out emails. | |||
""" | |||
email = Email.objects.create( | |||
to=['to@example.com'], from_email='bob@example.com', | |||
subject='send bulk', message='Message', status=STATUS.queued, | |||
backend_alias='locmem') | |||
_send_bulk([email], uses_multiprocessing=False) | |||
self.assertEqual(Email.objects.get(id=email.id).status, STATUS.sent) | |||
self.assertEqual(len(mail.outbox), 1) | |||
self.assertEqual(mail.outbox[0].subject, 'send bulk') | |||
@override_settings(EMAIL_BACKEND='post_office.tests.test_mail.ConnectionTestingBackend') | |||
def test_send_bulk_reuses_open_connection(self): | |||
""" | |||
Ensure _send_bulk() only opens connection once to send multiple emails. | |||
""" | |||
global connection_counter | |||
self.assertEqual(connection_counter, 0) | |||
email = Email.objects.create(to=['to@example.com'], | |||
from_email='bob@example.com', subject='', | |||
message='', status=STATUS.queued, backend_alias='connection_tester') | |||
email_2 = Email.objects.create(to=['to@example.com'], | |||
from_email='bob@example.com', subject='', | |||
message='', status=STATUS.queued, | |||
backend_alias='connection_tester') | |||
_send_bulk([email, email_2]) | |||
self.assertEqual(connection_counter, 1) | |||
def test_get_queued(self): | |||
""" | |||
Ensure get_queued returns only emails that should be sent | |||
""" | |||
kwargs = { | |||
'to': 'to@example.com', | |||
'from_email': 'bob@example.com', | |||
'subject': 'Test', | |||
'message': 'Message', | |||
} | |||
self.assertEqual(list(get_queued()), []) | |||
# Emails with statuses failed, sent or None shouldn't be returned | |||
Email.objects.create(status=STATUS.failed, **kwargs) | |||
Email.objects.create(status=None, **kwargs) | |||
Email.objects.create(status=STATUS.sent, **kwargs) | |||
self.assertEqual(list(get_queued()), []) | |||
# Email with queued status and None as scheduled_time should be included | |||
queued_email = Email.objects.create(status=STATUS.queued, | |||
scheduled_time=None, **kwargs) | |||
self.assertEqual(list(get_queued()), [queued_email]) | |||
# Email scheduled for the future should not be included | |||
Email.objects.create(status=STATUS.queued, | |||
scheduled_time=date(2020, 12, 13), **kwargs) | |||
self.assertEqual(list(get_queued()), [queued_email]) | |||
# Email scheduled in the past should be included | |||
past_email = Email.objects.create(status=STATUS.queued, | |||
scheduled_time=date(2010, 12, 13), **kwargs) | |||
self.assertEqual(list(get_queued()), [queued_email, past_email]) | |||
def test_get_batch_size(self): | |||
""" | |||
Ensure BATCH_SIZE setting is read correctly. | |||
""" | |||
previous_settings = settings.POST_OFFICE | |||
self.assertEqual(get_batch_size(), 100) | |||
setattr(settings, 'POST_OFFICE', {'BATCH_SIZE': 10}) | |||
self.assertEqual(get_batch_size(), 10) | |||
settings.POST_OFFICE = previous_settings | |||
def test_get_threads_per_process(self): | |||
""" | |||
Ensure THREADS_PER_PROCESS setting is read correctly. | |||
""" | |||
previous_settings = settings.POST_OFFICE | |||
self.assertEqual(get_threads_per_process(), 5) | |||
setattr(settings, 'POST_OFFICE', {'THREADS_PER_PROCESS': 10}) | |||
self.assertEqual(get_threads_per_process(), 10) | |||
settings.POST_OFFICE = previous_settings | |||
def test_get_log_level(self): | |||
""" | |||
Ensure LOG_LEVEL setting is read correctly. | |||
""" | |||
previous_settings = settings.POST_OFFICE | |||
self.assertEqual(get_log_level(), 2) | |||
setattr(settings, 'POST_OFFICE', {'LOG_LEVEL': 1}) | |||
self.assertEqual(get_log_level(), 1) | |||
# Restore ``LOG_LEVEL`` | |||
setattr(settings, 'POST_OFFICE', {'LOG_LEVEL': 2}) | |||
settings.POST_OFFICE = previous_settings | |||
def test_create(self): | |||
""" | |||
Test basic email creation | |||
""" | |||
# Test that email is persisted only when commit=True | |||
email = create( | |||
sender='from@example.com', recipients=['to@example.com'], | |||
commit=False | |||
) | |||
self.assertEqual(email.id, None) | |||
email = create( | |||
sender='from@example.com', recipients=['to@example.com'], | |||
commit=True | |||
) | |||
self.assertNotEqual(email.id, None) | |||
# Test that email is created with the right status | |||
email = create( | |||
sender='from@example.com', recipients=['to@example.com'], | |||
priority=PRIORITY.now | |||
) | |||
self.assertEqual(email.status, None) | |||
email = create( | |||
sender='from@example.com', recipients=['to@example.com'], | |||
priority=PRIORITY.high | |||
) | |||
self.assertEqual(email.status, STATUS.queued) | |||
# Test that email is created with the right content | |||
context = { | |||
'subject': 'My subject', | |||
'message': 'My message', | |||
'html': 'My html', | |||
} | |||
now = datetime.now() | |||
email = create( | |||
sender='from@example.com', recipients=['to@example.com'], | |||
subject='Test {{ subject }}', message='Test {{ message }}', | |||
html_message='Test {{ html }}', context=context, | |||
scheduled_time=now, headers={'header': 'Test header'}, | |||
) | |||
self.assertEqual(email.from_email, 'from@example.com') | |||
self.assertEqual(email.to, ['to@example.com']) | |||
self.assertEqual(email.subject, 'Test My subject') | |||
self.assertEqual(email.message, 'Test My message') | |||
self.assertEqual(email.html_message, 'Test My html') | |||
self.assertEqual(email.scheduled_time, now) | |||
self.assertEqual(email.headers, {'header': 'Test header'}) | |||
def test_send_many(self): | |||
"""Test send_many creates the right emails """ | |||
kwargs_list = [ | |||
{'sender': 'from@example.com', 'recipients': ['a@example.com']}, | |||
{'sender': 'from@example.com', 'recipients': ['b@example.com']}, | |||
] | |||
send_many(kwargs_list) | |||
self.assertEqual(Email.objects.filter(to=['a@example.com']).count(), 1) | |||
def test_send_with_attachments(self): | |||
attachments = { | |||
'attachment_file1.txt': ContentFile('content'), | |||
'attachment_file2.txt': ContentFile('content'), | |||
} | |||
email = send(recipients=['a@example.com', 'b@example.com'], | |||
sender='from@example.com', message='message', | |||
subject='subject', attachments=attachments) | |||
self.assertTrue(email.pk) | |||
self.assertEqual(email.attachments.count(), 2) | |||
def test_send_with_render_on_delivery(self): | |||
""" | |||
Ensure that mail.send() create email instances with appropriate | |||
fields being saved | |||
""" | |||
template = EmailTemplate.objects.create( | |||
subject='Subject {{ name }}', | |||
content='Content {{ name }}', | |||
html_content='HTML {{ name }}' | |||
) | |||
context = {'name': 'test'} | |||
email = send(recipients=['a@example.com', 'b@example.com'], | |||
template=template, context=context, | |||
render_on_delivery=True) | |||
self.assertEqual(email.subject, '') | |||
self.assertEqual(email.message, '') | |||
self.assertEqual(email.html_message, '') | |||
self.assertEqual(email.template, template) | |||
# context shouldn't be persisted when render_on_delivery = False | |||
email = send(recipients=['a@example.com'], | |||
template=template, context=context, | |||
render_on_delivery=False) | |||
self.assertEqual(email.context, None) | |||
def test_send_with_attachments_multiple_recipients(self): | |||
"""Test reusing the same attachment objects for several email objects""" | |||
attachments = { | |||
'attachment_file1.txt': ContentFile('content'), | |||
'attachment_file2.txt': ContentFile('content'), | |||
} | |||
email = send(recipients=['a@example.com', 'b@example.com'], | |||
sender='from@example.com', message='message', | |||
subject='subject', attachments=attachments) | |||
self.assertEqual(email.attachments.count(), 2) | |||
self.assertEqual(Attachment.objects.count(), 2) | |||
def test_create_with_template(self): | |||
"""If render_on_delivery is True, subject and content | |||
won't be rendered, context also won't be saved.""" | |||
template = EmailTemplate.objects.create( | |||
subject='Subject {{ name }}', | |||
content='Content {{ name }}', | |||
html_content='HTML {{ name }}' | |||
) | |||
context = {'name': 'test'} | |||
email = create( | |||
sender='from@example.com', recipients=['to@example.com'], | |||
template=template, context=context, render_on_delivery=True | |||
) | |||
self.assertEqual(email.subject, '') | |||
self.assertEqual(email.message, '') | |||
self.assertEqual(email.html_message, '') | |||
self.assertEqual(email.context, context) | |||
self.assertEqual(email.template, template) | |||
def test_create_with_template_and_empty_context(self): | |||
"""If render_on_delivery is False, subject and content | |||
will be rendered, context won't be saved.""" | |||
template = EmailTemplate.objects.create( | |||
subject='Subject {% now "Y" %}', | |||
content='Content {% now "Y" %}', | |||
html_content='HTML {% now "Y" %}' | |||
) | |||
context = None | |||
email = create( | |||
sender='from@example.com', recipients=['to@example.com'], | |||
template=template, context=context | |||
) | |||
today = date.today() | |||
current_year = today.year | |||
self.assertEqual(email.subject, 'Subject %d' % current_year) | |||
self.assertEqual(email.message, 'Content %d' % current_year) | |||
self.assertEqual(email.html_message, 'HTML %d' % current_year) | |||
self.assertEqual(email.context, None) | |||
self.assertEqual(email.template, None) | |||
def test_backend_alias(self): | |||
"""Test backend_alias field is properly set.""" | |||
email = send(recipients=['a@example.com'], | |||
sender='from@example.com', message='message', | |||
subject='subject') | |||
self.assertEqual(email.backend_alias, '') | |||
email = send(recipients=['a@example.com'], | |||
sender='from@example.com', message='message', | |||
subject='subject', backend='locmem') | |||
self.assertEqual(email.backend_alias, 'locmem') | |||
with self.assertRaises(ValueError): | |||
send(recipients=['a@example.com'], sender='from@example.com', | |||
message='message', subject='subject', backend='foo') | |||
@override_settings(LANGUAGES=(('en', 'English'), ('ru', 'Russian'))) | |||
def test_send_with_template(self): | |||
"""If render_on_delivery is False, subject and content | |||
will be rendered, context won't be saved.""" | |||
template = EmailTemplate.objects.create( | |||
subject='Subject {{ name }}', | |||
content='Content {{ name }}', | |||
html_content='HTML {{ name }}' | |||
) | |||
russian_template = EmailTemplate( | |||
default_template=template, | |||
language='ru', | |||
subject='предмет {{ name }}', | |||
content='содержание {{ name }}', | |||
html_content='HTML {{ name }}' | |||
) | |||
russian_template.save() | |||
context = {'name': 'test'} | |||
email = send(recipients=['to@example.com'], sender='from@example.com', | |||
template=template, context=context) | |||
email = Email.objects.get(id=email.id) | |||
self.assertEqual(email.subject, 'Subject test') | |||
self.assertEqual(email.message, 'Content test') | |||
self.assertEqual(email.html_message, 'HTML test') | |||
self.assertEqual(email.context, None) | |||
self.assertEqual(email.template, None) | |||
# check, if we use the Russian version | |||
email = send(recipients=['to@example.com'], sender='from@example.com', | |||
template=russian_template, context=context) | |||
email = Email.objects.get(id=email.id) | |||
self.assertEqual(email.subject, 'предмет test') | |||
self.assertEqual(email.message, 'содержание test') | |||
self.assertEqual(email.html_message, 'HTML test') | |||
self.assertEqual(email.context, None) | |||
self.assertEqual(email.template, None) | |||
# Check that send picks template with the right language | |||
email = send(recipients=['to@example.com'], sender='from@example.com', | |||
template=template, context=context, language='ru') | |||
email = Email.objects.get(id=email.id) | |||
self.assertEqual(email.subject, 'предмет test') | |||
email = send(recipients=['to@example.com'], sender='from@example.com', | |||
template=template, context=context, language='ru', | |||
render_on_delivery=True) | |||
self.assertEqual(email.template.language, 'ru') | |||
def test_send_bulk_with_faulty_template(self): | |||
template = EmailTemplate.objects.create( | |||
subject='{% if foo %}Subject {{ name }}', | |||
content='Content {{ name }}', | |||
html_content='HTML {{ name }}' | |||
) | |||
email = Email.objects.create(to='to@example.com', from_email='from@example.com', | |||
template=template, status=STATUS.queued) | |||
_send_bulk([email], uses_multiprocessing=False) | |||
email = Email.objects.get(id=email.id) | |||
self.assertEqual(email.status, STATUS.failed) |
@@ -0,0 +1,332 @@ | |||
import django | |||
import json | |||
import os | |||
from datetime import datetime, timedelta | |||
from django.conf import settings as django_settings | |||
from django.core import mail | |||
from django.core import serializers | |||
from django.core.files.base import ContentFile | |||
from django.core.mail import EmailMessage, EmailMultiAlternatives | |||
from django.forms.models import modelform_factory | |||
from django.test import TestCase | |||
from django.utils import timezone | |||
from ..models import Email, Log, PRIORITY, STATUS, EmailTemplate, Attachment | |||
from ..mail import send | |||
class ModelTest(TestCase): | |||
def test_email_message(self): | |||
""" | |||
Test to make sure that model's "email_message" method | |||
returns proper email classes. | |||
""" | |||
# If ``html_message`` is set, ``EmailMultiAlternatives`` is expected | |||
email = Email.objects.create(to=['to@example.com'], | |||
from_email='from@example.com', subject='Subject', | |||
message='Message', html_message='<p>HTML</p>') | |||
message = email.email_message() | |||
self.assertEqual(type(message), EmailMultiAlternatives) | |||
self.assertEqual(message.from_email, 'from@example.com') | |||
self.assertEqual(message.to, ['to@example.com']) | |||
self.assertEqual(message.subject, 'Subject') | |||
self.assertEqual(message.body, 'Message') | |||
self.assertEqual(message.alternatives, [('<p>HTML</p>', 'text/html')]) | |||
# Without ``html_message``, ``EmailMessage`` class is expected | |||
email = Email.objects.create(to=['to@example.com'], | |||
from_email='from@example.com', subject='Subject', | |||
message='Message') | |||
message = email.email_message() | |||
self.assertEqual(type(message), EmailMessage) | |||
self.assertEqual(message.from_email, 'from@example.com') | |||
self.assertEqual(message.to, ['to@example.com']) | |||
self.assertEqual(message.subject, 'Subject') | |||
self.assertEqual(message.body, 'Message') | |||
def test_email_message_render(self): | |||
""" | |||
Ensure Email instance with template is properly rendered. | |||
""" | |||
template = EmailTemplate.objects.create( | |||
subject='Subject {{ name }}', | |||
content='Content {{ name }}', | |||
html_content='HTML {{ name }}' | |||
) | |||
context = {'name': 'test'} | |||
email = Email.objects.create(to=['to@example.com'], template=template, | |||
from_email='from@e.com', context=context) | |||
message = email.email_message() | |||
self.assertEqual(message.subject, 'Subject test') | |||
self.assertEqual(message.body, 'Content test') | |||
self.assertEqual(message.alternatives[0][0], 'HTML test') | |||
def test_dispatch(self): | |||
""" | |||
Ensure that email.dispatch() actually sends out the email | |||
""" | |||
email = Email.objects.create(to=['to@example.com'], from_email='from@example.com', | |||
subject='Test dispatch', message='Message', backend_alias='locmem') | |||
email.dispatch() | |||
self.assertEqual(mail.outbox[0].subject, 'Test dispatch') | |||
def test_status_and_log(self): | |||
""" | |||
Ensure that status and log are set properly on successful sending | |||
""" | |||
email = Email.objects.create(to=['to@example.com'], from_email='from@example.com', | |||
subject='Test', message='Message', backend_alias='locmem', id=333) | |||
# Ensure that after dispatch status and logs are correctly set | |||
email.dispatch() | |||
log = Log.objects.latest('id') | |||
self.assertEqual(email.status, STATUS.sent) | |||
self.assertEqual(log.email, email) | |||
def test_status_and_log_on_error(self): | |||
""" | |||
Ensure that status and log are set properly on sending failure | |||
""" | |||
email = Email.objects.create(to=['to@example.com'], from_email='from@example.com', | |||
subject='Test', message='Message', | |||
backend_alias='error') | |||
# Ensure that after dispatch status and logs are correctly set | |||
email.dispatch() | |||
log = Log.objects.latest('id') | |||
self.assertEqual(email.status, STATUS.failed) | |||
self.assertEqual(log.email, email) | |||
self.assertEqual(log.status, STATUS.failed) | |||
self.assertEqual(log.message, 'Fake Error') | |||
self.assertEqual(log.exception_type, 'Exception') | |||
def test_errors_while_getting_connection_are_logged(self): | |||
""" | |||
Ensure that status and log are set properly on sending failure | |||
""" | |||
email = Email.objects.create(to=['to@example.com'], subject='Test', | |||
from_email='from@example.com', | |||
message='Message', backend_alias='random') | |||
# Ensure that after dispatch status and logs are correctly set | |||
email.dispatch() | |||
log = Log.objects.latest('id') | |||
self.assertEqual(email.status, STATUS.failed) | |||
self.assertEqual(log.email, email) | |||
self.assertEqual(log.status, STATUS.failed) | |||
self.assertIn('is not a valid', log.message) | |||
def test_default_sender(self): | |||
email = send(['to@example.com'], subject='foo') | |||
self.assertEqual(email.from_email, | |||
django_settings.DEFAULT_FROM_EMAIL) | |||
def test_send_argument_checking(self): | |||
""" | |||
mail.send() should raise an Exception if: | |||
- "template" is used with "subject", "message" or "html_message" | |||
- recipients is not in tuple or list format | |||
""" | |||
self.assertRaises(ValueError, send, ['to@example.com'], 'from@a.com', | |||
template='foo', subject='bar') | |||
self.assertRaises(ValueError, send, ['to@example.com'], 'from@a.com', | |||
template='foo', message='bar') | |||
self.assertRaises(ValueError, send, ['to@example.com'], 'from@a.com', | |||
template='foo', html_message='bar') | |||
self.assertRaises(ValueError, send, 'to@example.com', 'from@a.com', | |||
template='foo', html_message='bar') | |||
self.assertRaises(ValueError, send, cc='cc@example.com', sender='from@a.com', | |||
template='foo', html_message='bar') | |||
self.assertRaises(ValueError, send, bcc='bcc@example.com', sender='from@a.com', | |||
template='foo', html_message='bar') | |||
def test_send_with_template(self): | |||
""" | |||
Ensure mail.send correctly creates templated emails to recipients | |||
""" | |||
Email.objects.all().delete() | |||
headers = {'Reply-to': 'reply@email.com'} | |||
email_template = EmailTemplate.objects.create(name='foo', subject='bar', | |||
content='baz') | |||
scheduled_time = datetime.now() + timedelta(days=1) | |||
email = send(recipients=['to1@example.com', 'to2@example.com'], sender='from@a.com', | |||
headers=headers, template=email_template, | |||
scheduled_time=scheduled_time) | |||
self.assertEqual(email.to, ['to1@example.com', 'to2@example.com']) | |||
self.assertEqual(email.headers, headers) | |||
self.assertEqual(email.scheduled_time, scheduled_time) | |||
# Test without header | |||
Email.objects.all().delete() | |||
email = send(recipients=['to1@example.com', 'to2@example.com'], sender='from@a.com', | |||
template=email_template) | |||
self.assertEqual(email.to, ['to1@example.com', 'to2@example.com']) | |||
self.assertEqual(email.headers, None) | |||
def test_send_without_template(self): | |||
headers = {'Reply-to': 'reply@email.com'} | |||
scheduled_time = datetime.now() + timedelta(days=1) | |||
email = send(sender='from@a.com', | |||
recipients=['to1@example.com', 'to2@example.com'], | |||
cc=['cc1@example.com', 'cc2@example.com'], | |||
bcc=['bcc1@example.com', 'bcc2@example.com'], | |||
subject='foo', message='bar', html_message='baz', | |||
context={'name': 'Alice'}, headers=headers, | |||
scheduled_time=scheduled_time, priority=PRIORITY.low) | |||
self.assertEqual(email.to, ['to1@example.com', 'to2@example.com']) | |||
self.assertEqual(email.cc, ['cc1@example.com', 'cc2@example.com']) | |||
self.assertEqual(email.bcc, ['bcc1@example.com', 'bcc2@example.com']) | |||
self.assertEqual(email.subject, 'foo') | |||
self.assertEqual(email.message, 'bar') | |||
self.assertEqual(email.html_message, 'baz') | |||
self.assertEqual(email.headers, headers) | |||
self.assertEqual(email.priority, PRIORITY.low) | |||
self.assertEqual(email.scheduled_time, scheduled_time) | |||
# Same thing, but now with context | |||
email = send(['to1@example.com'], 'from@a.com', | |||
subject='Hi {{ name }}', message='Message {{ name }}', | |||
html_message='<b>{{ name }}</b>', | |||
context={'name': 'Bob'}, headers=headers) | |||
self.assertEqual(email.to, ['to1@example.com']) | |||
self.assertEqual(email.subject, 'Hi Bob') | |||
self.assertEqual(email.message, 'Message Bob') | |||
self.assertEqual(email.html_message, '<b>Bob</b>') | |||
self.assertEqual(email.headers, headers) | |||
def test_invalid_syntax(self): | |||
""" | |||
Ensures that invalid template syntax will result in validation errors | |||
when saving a ModelForm of an EmailTemplate. | |||
""" | |||
data = dict( | |||
name='cost', | |||
subject='Hi there!{{ }}', | |||
content='Welcome {{ name|titl }} to the site.', | |||
html_content='{% block content %}<h1>Welcome to the site</h1>' | |||
) | |||
EmailTemplateForm = modelform_factory(EmailTemplate, | |||
exclude=['template']) | |||
form = EmailTemplateForm(data) | |||
self.assertFalse(form.is_valid()) | |||
self.assertEqual(form.errors['default_template'], [u'This field is required.']) | |||
self.assertEqual(form.errors['content'], [u"Invalid filter: 'titl'"]) | |||
self.assertIn(form.errors['html_content'], | |||
[[u'Unclosed tags: endblock '], | |||
[u"Unclosed tag on line 1: 'block'. Looking for one of: endblock."]]) | |||
self.assertIn(form.errors['subject'], | |||
[[u'Empty variable tag'], [u'Empty variable tag on line 1']]) | |||
def test_string_priority(self): | |||
""" | |||
Regression test for: | |||
https://github.com/ui/django-post_office/issues/23 | |||
""" | |||
email = send(['to1@example.com'], 'from@a.com', priority='low') | |||
self.assertEqual(email.priority, PRIORITY.low) | |||
def test_default_priority(self): | |||
email = send(recipients=['to1@example.com'], sender='from@a.com') | |||
self.assertEqual(email.priority, PRIORITY.medium) | |||
def test_string_priority_exception(self): | |||
invalid_priority_send = lambda: send(['to1@example.com'], 'from@a.com', priority='hgh') | |||
with self.assertRaises(ValueError) as context: | |||
invalid_priority_send() | |||
self.assertEqual( | |||
str(context.exception), | |||
'Invalid priority, must be one of: low, medium, high, now' | |||
) | |||
def test_send_recipient_display_name(self): | |||
""" | |||
Regression test for: | |||
https://github.com/ui/django-post_office/issues/73 | |||
""" | |||
email = send(recipients=['Alice Bob <email@example.com>'], sender='from@a.com') | |||
self.assertTrue(email.to) | |||
def test_attachment_filename(self): | |||
attachment = Attachment() | |||
attachment.file.save( | |||
'test.txt', | |||
content=ContentFile('test file content'), | |||
save=True | |||
) | |||
self.assertEqual(attachment.name, 'test.txt') | |||
# Test that it is saved to the correct subdirectory | |||
date = timezone.now().date() | |||
expected_path = os.path.join('post_office_attachments', str(date.year), | |||
str(date.month), str(date.day)) | |||
self.assertTrue(expected_path in attachment.file.name) | |||
def test_attachments_email_message(self): | |||
email = Email.objects.create(to=['to@example.com'], | |||
from_email='from@example.com', | |||
subject='Subject') | |||
attachment = Attachment() | |||
attachment.file.save( | |||
'test.txt', content=ContentFile('test file content'), save=True | |||
) | |||
email.attachments.add(attachment) | |||
message = email.email_message() | |||
# https://docs.djangoproject.com/en/1.11/releases/1.11/#email | |||
if django.VERSION >= (1, 11,): | |||
self.assertEqual(message.attachments, | |||
[('test.txt', 'test file content', 'text/plain')]) | |||
else: | |||
self.assertEqual(message.attachments, | |||
[('test.txt', b'test file content', None)]) | |||
def test_attachments_email_message_with_mimetype(self): | |||
email = Email.objects.create(to=['to@example.com'], | |||
from_email='from@example.com', | |||
subject='Subject') | |||
attachment = Attachment() | |||
attachment.file.save( | |||
'test.txt', content=ContentFile('test file content'), save=True | |||
) | |||
attachment.mimetype = 'text/plain' | |||
attachment.save() | |||
email.attachments.add(attachment) | |||
message = email.email_message() | |||
if django.VERSION >= (1, 11,): | |||
self.assertEqual(message.attachments, | |||
[('test.txt', 'test file content', 'text/plain')]) | |||
else: | |||
self.assertEqual(message.attachments, | |||
[('test.txt', b'test file content', 'text/plain')]) | |||
def test_translated_template_uses_default_templates_name(self): | |||
template = EmailTemplate.objects.create(name='name') | |||
id_template = template.translated_templates.create(language='id') | |||
self.assertEqual(id_template.name, template.name) | |||
def test_models_repr(self): | |||
self.assertEqual(repr(EmailTemplate(name='test', language='en')), | |||
'<EmailTemplate: test en>') | |||
self.assertEqual(repr(Email(to=['test@example.com'])), | |||
"<Email: ['test@example.com']>") | |||
def test_natural_key(self): | |||
template = EmailTemplate.objects.create(name='name') | |||
self.assertEqual(template, EmailTemplate.objects.get_by_natural_key(*template.natural_key())) | |||
data = serializers.serialize('json', [template], use_natural_primary_keys=True) | |||
self.assertNotIn('pk', json.loads(data)[0]) | |||
deserialized_objects = serializers.deserialize('json', data, use_natural_primary_keys=True) | |||
list(deserialized_objects)[0].save() | |||
self.assertEqual(EmailTemplate.objects.count(), 1) |
@@ -0,0 +1,203 @@ | |||
from django.core.files.base import ContentFile | |||
from django.core.exceptions import ValidationError | |||
from django.test import TestCase | |||
from django.test.utils import override_settings | |||
from ..models import Email, STATUS, PRIORITY, EmailTemplate, Attachment | |||
from ..utils import (create_attachments, get_email_template, parse_emails, | |||
parse_priority, send_mail, split_emails) | |||
from ..validators import validate_email_with_name, validate_comma_separated_emails | |||
@override_settings(EMAIL_BACKEND='django.core.mail.backends.locmem.EmailBackend') | |||
class UtilsTest(TestCase): | |||
def test_mail_status(self): | |||
""" | |||
Check that send_mail assigns the right status field to Email instances | |||
""" | |||
send_mail('subject', 'message', 'from@example.com', ['to@example.com'], | |||
priority=PRIORITY.medium) | |||
email = Email.objects.latest('id') | |||
self.assertEqual(email.status, STATUS.queued) | |||
# Emails sent with "now" priority is sent right away | |||
send_mail('subject', 'message', 'from@example.com', ['to@example.com'], | |||
priority=PRIORITY.now) | |||
email = Email.objects.latest('id') | |||
self.assertEqual(email.status, STATUS.sent) | |||
def test_email_validator(self): | |||
# These should validate | |||
validate_email_with_name('email@example.com') | |||
validate_email_with_name('Alice Bob <email@example.com>') | |||
Email.objects.create(to=['to@example.com'], from_email='Alice <from@example.com>', | |||
subject='Test', message='Message', status=STATUS.sent) | |||
# Should also support international domains | |||
validate_email_with_name('Alice Bob <email@example.co.id>') | |||
# These should raise ValidationError | |||
self.assertRaises(ValidationError, validate_email_with_name, 'invalid') | |||
self.assertRaises(ValidationError, validate_email_with_name, 'Al <ab>') | |||
def test_comma_separated_email_list_validator(self): | |||
# These should validate | |||
validate_comma_separated_emails(['email@example.com']) | |||
validate_comma_separated_emails( | |||
['email@example.com', 'email2@example.com', 'email3@example.com'] | |||
) | |||
validate_comma_separated_emails(['Alice Bob <email@example.com>']) | |||
# Should also support international domains | |||
validate_comma_separated_emails(['email@example.co.id']) | |||
# These should raise ValidationError | |||
self.assertRaises(ValidationError, validate_comma_separated_emails, | |||
['email@example.com', 'invalid_mail', 'email@example.com']) | |||
def test_get_template_email(self): | |||
# Sanity Check | |||
name = 'customer/happy-holidays' | |||
self.assertRaises(EmailTemplate.DoesNotExist, get_email_template, name) | |||
template = EmailTemplate.objects.create(name=name, content='test') | |||
# First query should hit database | |||
self.assertNumQueries(1, lambda: get_email_template(name)) | |||
# Second query should hit cache instead | |||
self.assertNumQueries(0, lambda: get_email_template(name)) | |||
# It should return the correct template | |||
self.assertEqual(template, get_email_template(name)) | |||
# Repeat with language support | |||
template = EmailTemplate.objects.create(name=name, content='test', | |||
language='en') | |||
# First query should hit database | |||
self.assertNumQueries(1, lambda: get_email_template(name, 'en')) | |||
# Second query should hit cache instead | |||
self.assertNumQueries(0, lambda: get_email_template(name, 'en')) | |||
# It should return the correct template | |||
self.assertEqual(template, get_email_template(name, 'en')) | |||
def test_template_caching_settings(self): | |||
"""Check if POST_OFFICE_CACHE and POST_OFFICE_TEMPLATE_CACHE understood | |||
correctly | |||
""" | |||
def is_cache_used(suffix='', desired_cache=False): | |||
"""Raise exception if real cache usage not equal to desired_cache value | |||
""" | |||
# to avoid cache cleaning - just create new template | |||
name = 'can_i/suport_cache_settings%s' % suffix | |||
self.assertRaises( | |||
EmailTemplate.DoesNotExist, get_email_template, name | |||
) | |||
EmailTemplate.objects.create(name=name, content='test') | |||
# First query should hit database anyway | |||
self.assertNumQueries(1, lambda: get_email_template(name)) | |||
# Second query should hit cache instead only if we want it | |||
self.assertNumQueries( | |||
0 if desired_cache else 1, | |||
lambda: get_email_template(name) | |||
) | |||
return | |||
# default - use cache | |||
is_cache_used(suffix='with_default_cache', desired_cache=True) | |||
# disable cache | |||
with self.settings(POST_OFFICE_CACHE=False): | |||
is_cache_used(suffix='cache_disabled_global', desired_cache=False) | |||
with self.settings(POST_OFFICE_TEMPLATE_CACHE=False): | |||
is_cache_used( | |||
suffix='cache_disabled_for_templates', desired_cache=False | |||
) | |||
with self.settings(POST_OFFICE_CACHE=True, POST_OFFICE_TEMPLATE_CACHE=False): | |||
is_cache_used( | |||
suffix='cache_disabled_for_templates_but_enabled_global', | |||
desired_cache=False | |||
) | |||
return | |||
def test_split_emails(self): | |||
""" | |||
Check that split emails correctly divide email lists for multiprocessing | |||
""" | |||
for i in range(225): | |||
Email.objects.create(from_email='from@example.com', to=['to@example.com']) | |||
expected_size = [57, 56, 56, 56] | |||
email_list = split_emails(Email.objects.all(), 4) | |||
self.assertEqual(expected_size, [len(emails) for emails in email_list]) | |||
def test_create_attachments(self): | |||
attachments = create_attachments({ | |||
'attachment_file1.txt': ContentFile('content'), | |||
'attachment_file2.txt': ContentFile('content'), | |||
}) | |||
self.assertEqual(len(attachments), 2) | |||
self.assertIsInstance(attachments[0], Attachment) | |||
self.assertTrue(attachments[0].pk) | |||
self.assertEqual(attachments[0].file.read(), b'content') | |||
self.assertTrue(attachments[0].name.startswith('attachment_file')) | |||
self.assertEquals(attachments[0].mimetype, u'') | |||
def test_create_attachments_with_mimetype(self): | |||
attachments = create_attachments({ | |||
'attachment_file1.txt': { | |||
'file': ContentFile('content'), | |||
'mimetype': 'text/plain' | |||
}, | |||
'attachment_file2.jpg': { | |||
'file': ContentFile('content'), | |||
'mimetype': 'text/plain' | |||
} | |||
}) | |||
self.assertEqual(len(attachments), 2) | |||
self.assertIsInstance(attachments[0], Attachment) | |||
self.assertTrue(attachments[0].pk) | |||
self.assertEquals(attachments[0].file.read(), b'content') | |||
self.assertTrue(attachments[0].name.startswith('attachment_file')) | |||
self.assertEquals(attachments[0].mimetype, 'text/plain') | |||
def test_create_attachments_open_file(self): | |||
attachments = create_attachments({ | |||
'attachment_file.py': __file__, | |||
}) | |||
self.assertEqual(len(attachments), 1) | |||
self.assertIsInstance(attachments[0], Attachment) | |||
self.assertTrue(attachments[0].pk) | |||
self.assertTrue(attachments[0].file.read()) | |||
self.assertEquals(attachments[0].name, 'attachment_file.py') | |||
self.assertEquals(attachments[0].mimetype, u'') | |||
def test_parse_priority(self): | |||
self.assertEqual(parse_priority('now'), PRIORITY.now) | |||
self.assertEqual(parse_priority('high'), PRIORITY.high) | |||
self.assertEqual(parse_priority('medium'), PRIORITY.medium) | |||
self.assertEqual(parse_priority('low'), PRIORITY.low) | |||
def test_parse_emails(self): | |||
# Converts a single email to list of email | |||
self.assertEqual( | |||
parse_emails('test@example.com'), | |||
['test@example.com'] | |||
) | |||
# None is converted into an empty list | |||
self.assertEqual(parse_emails(None), []) | |||
# Raises ValidationError if email is invalid | |||
self.assertRaises( | |||
ValidationError, | |||
parse_emails, 'invalid_email' | |||
) | |||
self.assertRaises( | |||
ValidationError, | |||
parse_emails, ['invalid_email', 'test@example.com'] | |||
) |
@@ -0,0 +1,35 @@ | |||
from django.contrib.auth.models import User | |||
from django.test.client import Client | |||
from django.test import TestCase | |||
try: | |||
from django.urls import reverse | |||
except ImportError: | |||
from django.core.urlresolvers import reverse | |||
from post_office import mail | |||
from post_office.models import Email | |||
admin_username = 'real_test_admin' | |||
admin_email = 'read@admin.com' | |||
admin_pass = 'admin_pass' | |||
class AdminViewTest(TestCase): | |||
def setUp(self): | |||
user = User.objects.create_superuser(admin_username, admin_email, admin_pass) | |||
self.client = Client() | |||
self.client.login(username=user.username, password=admin_pass) | |||
# Small test to make sure the admin interface is loaded | |||
def test_admin_interface(self): | |||
response = self.client.get(reverse('admin:index')) | |||
self.assertEqual(response.status_code, 200) | |||
def test_admin_change_page(self): | |||
"""Ensure that changing an email object in admin works.""" | |||
mail.send(recipients=['test@example.com'], headers={'foo': 'bar'}) | |||
email = Email.objects.latest('id') | |||
response = self.client.get(reverse('admin:post_office_email_change', args=[email.id])) | |||
self.assertEqual(response.status_code, 200) |
@@ -0,0 +1,138 @@ | |||
from django.conf import settings | |||
from django.core.exceptions import ValidationError | |||
from django.core.files import File | |||
from django.utils.encoding import force_text | |||
from post_office import cache | |||
from .compat import string_types | |||
from .models import Email, PRIORITY, STATUS, EmailTemplate, Attachment | |||
from .settings import get_default_priority | |||
from .validators import validate_email_with_name | |||
def send_mail(subject, message, from_email, recipient_list, html_message='', | |||
scheduled_time=None, headers=None, priority=PRIORITY.medium): | |||
""" | |||
Add a new message to the mail queue. This is a replacement for Django's | |||
``send_mail`` core email method. | |||
""" | |||
subject = force_text(subject) | |||
status = None if priority == PRIORITY.now else STATUS.queued | |||
emails = [] | |||
for address in recipient_list: | |||
emails.append( | |||
Email.objects.create( | |||
from_email=from_email, to=address, subject=subject, | |||
message=message, html_message=html_message, status=status, | |||
headers=headers, priority=priority, scheduled_time=scheduled_time | |||
) | |||
) | |||
if priority == PRIORITY.now: | |||
for email in emails: | |||
email.dispatch() | |||
return emails | |||
def get_email_template(name, language=''): | |||
""" | |||
Function that returns an email template instance, from cache or DB. | |||
""" | |||
use_cache = getattr(settings, 'POST_OFFICE_CACHE', True) | |||
if use_cache: | |||
use_cache = getattr(settings, 'POST_OFFICE_TEMPLATE_CACHE', True) | |||
if not use_cache: | |||
return EmailTemplate.objects.get(name=name, language=language) | |||
else: | |||
composite_name = '%s:%s' % (name, language) | |||
email_template = cache.get(composite_name) | |||
if email_template is not None: | |||
return email_template | |||
else: | |||
email_template = EmailTemplate.objects.get(name=name, | |||
language=language) | |||
cache.set(composite_name, email_template) | |||
return email_template | |||
def split_emails(emails, split_count=1): | |||
# Group emails into X sublists | |||
# taken from http://www.garyrobinson.net/2008/04/splitting-a-pyt.html | |||
# Strange bug, only return 100 email if we do not evaluate the list | |||
if list(emails): | |||
return [emails[i::split_count] for i in range(split_count)] | |||
def create_attachments(attachment_files): | |||
""" | |||
Create Attachment instances from files | |||
attachment_files is a dict of: | |||
* Key - the filename to be used for the attachment. | |||
* Value - file-like object, or a filename to open OR a dict of {'file': file-like-object, 'mimetype': string} | |||
Returns a list of Attachment objects | |||
""" | |||
attachments = [] | |||
for filename, filedata in attachment_files.items(): | |||
if isinstance(filedata, dict): | |||
content = filedata.get('file', None) | |||
mimetype = filedata.get('mimetype', None) | |||
else: | |||
content = filedata | |||
mimetype = None | |||
opened_file = None | |||
if isinstance(content, string_types): | |||
# `content` is a filename - try to open the file | |||
opened_file = open(content, 'rb') | |||
content = File(opened_file) | |||
attachment = Attachment() | |||
if mimetype: | |||
attachment.mimetype = mimetype | |||
attachment.file.save(filename, content=content, save=True) | |||
attachments.append(attachment) | |||
if opened_file is not None: | |||
opened_file.close() | |||
return attachments | |||
def parse_priority(priority): | |||
if priority is None: | |||
priority = get_default_priority() | |||
# If priority is given as a string, returns the enum representation | |||
if isinstance(priority, string_types): | |||
priority = getattr(PRIORITY, priority, None) | |||
if priority is None: | |||
raise ValueError('Invalid priority, must be one of: %s' % | |||
', '.join(PRIORITY._fields)) | |||
return priority | |||
def parse_emails(emails): | |||
""" | |||
A function that returns a list of valid email addresses. | |||
This function will also convert a single email address into | |||
a list of email addresses. | |||
None value is also converted into an empty list. | |||
""" | |||
if isinstance(emails, string_types): | |||
emails = [emails] | |||
elif emails is None: | |||
emails = [] | |||
for email in emails: | |||
try: | |||
validate_email_with_name(email) | |||
except ValidationError: | |||
raise ValidationError('%s is not a valid email address' % email) | |||
return emails |
@@ -0,0 +1,50 @@ | |||
from django.core.exceptions import ValidationError | |||
from django.core.validators import validate_email | |||
from django.template import Template, TemplateSyntaxError, TemplateDoesNotExist | |||
from django.utils.encoding import force_text | |||
from .compat import text_type | |||
def validate_email_with_name(value): | |||
""" | |||
Validate email address. | |||
Both "Recipient Name <email@example.com>" and "email@example.com" are valid. | |||
""" | |||
value = force_text(value) | |||
if '<' and '>' in value: | |||
start = value.find('<') + 1 | |||
end = value.find('>') | |||
if start < end: | |||
recipient = value[start:end] | |||
else: | |||
recipient = value | |||
validate_email(recipient) | |||
def validate_comma_separated_emails(value): | |||
""" | |||
Validate every email address in a comma separated list of emails. | |||
""" | |||
if not isinstance(value, (tuple, list)): | |||
raise ValidationError('Email list must be a list/tuple.') | |||
for email in value: | |||
try: | |||
validate_email_with_name(email) | |||
except ValidationError: | |||
raise ValidationError('Invalid email: %s' % email, code='invalid') | |||
def validate_template_syntax(source): | |||
""" | |||
Basic Django Template syntax validation. This allows for robuster template | |||
authoring. | |||
""" | |||
try: | |||
Template(source) | |||
except (TemplateSyntaxError, TemplateDoesNotExist) as err: | |||
raise ValidationError(text_type(err)) |
@@ -0,0 +1 @@ | |||
# Create your views here. |