Normalize send_at date/datetime/timestamp in BasePayload.

Interpret dates and naive datetimes as Django's
current_timezone (rather than UTC like Djrill did).
This should be more likely to behave as expected
when running with a non-UTC TIME_ZONE setting.
This commit is contained in:
medmunds
2016-03-05 11:22:14 -08:00
parent 0a5bca1426
commit a6c0eb5974
3 changed files with 64 additions and 35 deletions

View File

@@ -1,5 +1,8 @@
from datetime import date, datetime
from django.conf import settings from django.conf import settings
from django.core.mail.backends.base import BaseEmailBackend from django.core.mail.backends.base import BaseEmailBackend
from django.utils.timezone import is_naive, get_current_timezone, make_aware, utc
from ..exceptions import AnymailError, AnymailUnsupportedFeature from ..exceptions import AnymailError, AnymailUnsupportedFeature
from ..utils import Attachment, ParsedEmail, UNSET, combine, last, get_anymail_setting from ..utils import Attachment, ParsedEmail, UNSET, combine, last, get_anymail_setting
@@ -191,7 +194,7 @@ class BasePayload(object):
anymail_message_attrs = ( anymail_message_attrs = (
# Anymail expando-props # Anymail expando-props
('metadata', combine, None), ('metadata', combine, None),
('send_at', last, None), # normalize to datetime? ('send_at', last, 'aware_datetime'),
('tags', combine, None), ('tags', combine, None),
('track_clicks', last, None), ('track_clicks', last, None),
('track_opens', last, None), ('track_opens', last, None),
@@ -219,6 +222,7 @@ class BasePayload(object):
if not callable(converter): if not callable(converter):
converter = getattr(self, converter) converter = getattr(self, converter)
value = converter(value) value = converter(value)
if value is not UNSET:
if attr == 'body': if attr == 'body':
setter = self.set_html_body if message.content_subtype == 'html' else self.set_text_body setter = self.set_html_body if message.content_subtype == 'html' else self.set_text_body
else: else:
@@ -246,6 +250,29 @@ class BasePayload(object):
str_encoding = self.message.encoding or settings.DEFAULT_CHARSET str_encoding = self.message.encoding or settings.DEFAULT_CHARSET
return [Attachment(attachment, str_encoding) for attachment in attachments] return [Attachment(attachment, str_encoding) for attachment in attachments]
def aware_datetime(self, value):
"""Converts a date or datetime or timestamp to an aware datetime.
Naive datetimes are assumed to be in Django's current_timezone.
Dates are interpreted as midnight that date, in Django's current_timezone.
Integers are interpreted as POSIX timestamps (which are inherently UTC).
Anything else (e.g., str) is returned unchanged, which won't be portable.
"""
if isinstance(value, datetime):
dt = value
else:
if isinstance(value, date):
dt = datetime(value.year, value.month, value.day) # naive, midnight
else:
try:
dt = datetime.utcfromtimestamp(value).replace(tzinfo=utc)
except (TypeError, ValueError):
return value
if is_naive(dt):
dt = make_aware(dt, get_current_timezone())
return dt
# #
# Abstract implementation # Abstract implementation
# #

View File

@@ -57,21 +57,15 @@ def _expand_merge_vars(vardict):
def encode_date_for_mandrill(dt): def encode_date_for_mandrill(dt):
"""Format a date or datetime for use as a Mandrill API date field """Format a datetime for use as a Mandrill API date field
datetime becomes "YYYY-MM-DD HH:MM:SS" Mandrill expects "YYYY-MM-DD HH:MM:SS" in UTC
converted to UTC, if timezone-aware
microseconds removed
date becomes "YYYY-MM-DD 00:00:00"
anything else gets returned intact
""" """
if isinstance(dt, datetime): if isinstance(dt, datetime):
dt = dt.replace(microsecond=0) dt = dt.replace(microsecond=0)
if dt.utcoffset() is not None: if dt.utcoffset() is not None:
dt = (dt - dt.utcoffset()).replace(tzinfo=None) dt = (dt - dt.utcoffset()).replace(tzinfo=None)
return dt.isoformat(' ') return dt.isoformat(' ')
elif isinstance(dt, date):
return dt.isoformat() + ' 00:00:00'
else: else:
return dt return dt
@@ -128,9 +122,10 @@ class MandrillPayload(RequestsPayload):
def add_alternative(self, content, mimetype): def add_alternative(self, content, mimetype):
if mimetype != 'text/html': if mimetype != 'text/html':
self.unsupported_feature("alternative part with mimetype '%s'" % mimetype) self.unsupported_feature("alternative part with mimetype '%s'" % mimetype)
if "html" in self.data["message"]: elif "html" in self.data["message"]:
self.unsupported_feature("multiple html parts") self.unsupported_feature("multiple html parts")
self.data["message"]["html"] = content else:
self.set_html_body(content)
def add_attachment(self, attachment): def add_attachment(self, attachment):
key = "images" if attachment.inline else "attachments" key = "images" if attachment.inline else "attachments"

View File

@@ -8,7 +8,7 @@ import re
import six import six
import unittest import unittest
from base64 import b64decode from base64 import b64decode
from datetime import date, datetime, timedelta, tzinfo from datetime import date, datetime
from decimal import Decimal from decimal import Decimal
from email.mime.base import MIMEBase from email.mime.base import MIMEBase
from email.mime.image import MIMEImage from email.mime.image import MIMEImage
@@ -18,6 +18,7 @@ from django.core.exceptions import ImproperlyConfigured
from django.core.mail import make_msgid from django.core.mail import make_msgid
from django.test import TestCase from django.test import TestCase
from django.test.utils import override_settings from django.test.utils import override_settings
from django.utils.timezone import get_fixed_timezone, override as override_current_timezone
from anymail.exceptions import (AnymailAPIError, AnymailRecipientsRefused, from anymail.exceptions import (AnymailAPIError, AnymailRecipientsRefused,
AnymailSerializationError, AnymailUnsupportedFeature) AnymailSerializationError, AnymailUnsupportedFeature)
@@ -451,34 +452,40 @@ class DjrillMandrillFeatureTests(DjrillBackendMockAPITestCase):
]) ])
def test_send_at(self): def test_send_at(self):
# String passed unchanged utc_plus_6 = get_fixed_timezone(6 * 60)
utc_minus_8 = get_fixed_timezone(-8 * 60)
with override_current_timezone(utc_plus_6):
# Timezone-naive datetime assumed to be Django current_timezone
self.message.send_at = datetime(2022, 10, 11, 12, 13, 14, 567)
self.message.send()
data = self.get_api_call_data()
self.assertEqual(data['send_at'], "2022-10-11 06:13:14") # 12:13 UTC+6 == 06:13 UTC
# Timezone-aware datetime converted to UTC:
self.message.send_at = datetime(2016, 3, 4, 5, 6, 7, tzinfo=utc_minus_8)
self.message.send()
data = self.get_api_call_data()
self.assertEqual(data['send_at'], "2016-03-04 13:06:07") # 05:06 UTC-8 == 13:06 UTC
# Date-only treated as midnight in current timezone
self.message.send_at = date(2022, 10, 22)
self.message.send()
data = self.get_api_call_data()
self.assertEqual(data['send_at'], "2022-10-21 18:00:00") # 00:00 UTC+6 == 18:00-1d UTC
# POSIX timestamp
self.message.send_at = 1651820889 # 2022-05-06 07:08:09 UTC
self.message.send()
data = self.get_api_call_data()
self.assertEqual(data['send_at'], "2022-05-06 07:08:09")
# String passed unchanged (this is *not* portable between ESPs)
self.message.send_at = "2013-11-12 01:02:03" self.message.send_at = "2013-11-12 01:02:03"
self.message.send() self.message.send()
data = self.get_api_call_data() data = self.get_api_call_data()
self.assertEqual(data['send_at'], "2013-11-12 01:02:03") self.assertEqual(data['send_at'], "2013-11-12 01:02:03")
# Timezone-naive datetime assumed to be UTC
self.message.send_at = datetime(2022, 10, 11, 12, 13, 14, 567)
self.message.send()
data = self.get_api_call_data()
self.assertEqual(data['send_at'], "2022-10-11 12:13:14")
# Timezone-aware datetime converted to UTC:
class GMTminus8(tzinfo):
def utcoffset(self, dt): return timedelta(hours=-8)
def dst(self, dt): return timedelta(0)
self.message.send_at = datetime(2016, 3, 4, 5, 6, 7, tzinfo=GMTminus8())
self.message.send()
data = self.get_api_call_data()
self.assertEqual(data['send_at'], "2016-03-04 13:06:07")
# Date-only treated as midnight UTC
self.message.send_at = date(2022, 10, 22)
self.message.send()
data = self.get_api_call_data()
self.assertEqual(data['send_at'], "2022-10-22 00:00:00")
def test_default_omits_options(self): def test_default_omits_options(self):
"""Make sure by default we don't send any Mandrill-specific options. """Make sure by default we don't send any Mandrill-specific options.