From 182c55051ffc01bda24ab3d3a8e1687c19b42da2 Mon Sep 17 00:00:00 2001 From: Micah Lyle Date: Wed, 28 Nov 2018 21:10:56 -0800 Subject: [PATCH 1/2] Honor USE_TZ and TIME_ZONE settings by potentially casting to aware --- .gitignore | 3 + localized_recurrence/fields.py | 10 ++- localized_recurrence/models.py | 37 ++++++--- localized_recurrence/tests/models_tests.py | 88 +++++++++++++++++++++- 4 files changed, 123 insertions(+), 15 deletions(-) diff --git a/.gitignore b/.gitignore index 1d36513..7a5c2df 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,6 @@ venv/ # Pycharm .idea/ + +# VS Code +.vscode/ diff --git a/localized_recurrence/fields.py b/localized_recurrence/fields.py index 8cd85c6..79af0f3 100644 --- a/localized_recurrence/fields.py +++ b/localized_recurrence/fields.py @@ -109,6 +109,10 @@ def value_to_string(self, obj): return str(time_delta_value) +DAYS_RE = re.compile(r"(?P-?[0-9]*) days?, (?P[0-9]+):(?P[0-9]+):(?P[0-9]+\.?[0-9]*)") +NO_DAYS_RE = re.compile(r"(?P[0-9]+):(?P[0-9]+):(?P[0-9]+\.?[0-9]*)") + + def parse_timedelta_string(string): """Parses strings from datetime.timedelta.__str__. @@ -118,10 +122,8 @@ def parse_timedelta_string(string): datetime.timedelta.__str__ returns a string in the form [D day[s], ][H]H:MM:SS[.UUUUUU], where D is negative for negative t. """ - days_re = "(?P-?[0-9]*) days?, (?P[0-9]+):(?P[0-9]+):(?P[0-9]+\.?[0-9]*)" - no_days_re = "(?P[0-9]+):(?P[0-9]+):(?P[0-9]+\.?[0-9]*)" - match_days = re.match(days_re, string) - match_no_days = re.match(no_days_re, string) + match_days = re.match(DAYS_RE, string) + match_no_days = re.match(NO_DAYS_RE, string) if match_days: return timedelta(**{k: float(v) for k, v in match_days.groupdict().items()}) elif match_no_days: diff --git a/localized_recurrence/models.py b/localized_recurrence/models.py index b0dbe56..aa5775c 100644 --- a/localized_recurrence/models.py +++ b/localized_recurrence/models.py @@ -2,7 +2,9 @@ import calendar from dateutil.relativedelta import relativedelta +from django.conf import settings from django.db import models +from django.utils import timezone from django.utils.encoding import python_2_unicode_compatible from timezone_field import TimeZoneField import fleming @@ -18,6 +20,13 @@ ) +def default_scheduled(): + tzinfo = None + if settings.USE_TZ: + tzinfo = pytz.timezone(settings.TIME_ZONE) + return datetime(1970, 1, 1, tzinfo=tzinfo) + + class LocalizedRecurrenceQuerySet(models.query.QuerySet): def update_schedule(self, time=None): """ @@ -125,8 +134,8 @@ class LocalizedRecurrence(models.Model): interval = models.CharField(max_length=18, default='DAY', choices=INTERVAL_CHOICES) offset = models.DurationField(default=timedelta(0)) timezone = TimeZoneField(default='UTC') - previous_scheduled = models.DateTimeField(default=datetime(1970, 1, 1)) - next_scheduled = models.DateTimeField(default=datetime(1970, 1, 1)) + previous_scheduled = models.DateTimeField(default=default_scheduled) + next_scheduled = models.DateTimeField(default=default_scheduled) objects = LocalizedRecurrenceManager() @@ -221,15 +230,23 @@ def utc_of_next_schedule(self, current_time): return next_scheduled_utc +def _prepare_datetime_field_value(value, make_aware=None): + if make_aware is None: + make_aware = settings.USE_TZ + if value is None or not make_aware or timezone.is_aware(value): + return value + return timezone.make_aware(value) + + def _update_schedule(recurrences, time=None): - """ - Update the schedule times for all the provided recurrences. - """ - time = time or datetime.utcnow() - for recurrence in recurrences: - recurrence.next_scheduled = recurrence.utc_of_next_schedule(time) - recurrence.previous_scheduled = time - recurrence.save() + """ + Update the schedule times for all the provided recurrences. + """ + time = time or datetime.utcnow() + for recurrence in recurrences: + recurrence.next_scheduled = _prepare_datetime_field_value(recurrence.utc_of_next_schedule(time)) + recurrence.previous_scheduled = _prepare_datetime_field_value(time) + recurrence.save() def _replace_with_offset(dt, offset, interval): diff --git a/localized_recurrence/tests/models_tests.py b/localized_recurrence/tests/models_tests.py index a869eae..63e3f98 100644 --- a/localized_recurrence/tests/models_tests.py +++ b/localized_recurrence/tests/models_tests.py @@ -1,11 +1,13 @@ from datetime import datetime, timedelta +import django from django.test import TestCase from django_dynamic_fixture import G +from django.utils import timezone import pytz from ..models import LocalizedRecurrence, LocalizedRecurrenceQuerySet -from ..models import _replace_with_offset, _update_schedule +from ..models import _replace_with_offset, _update_schedule, _prepare_datetime_field_value class LocalizedRecurrenceUpdateTest(TestCase): @@ -548,3 +550,87 @@ def test_bad_interval(self): interval_in = 'blah' with self.assertRaises(ValueError): _replace_with_offset(dt_in, td_in, interval_in) + + +class LocalizedRecurrenceUseTzSetting(TestCase): + + def test_with_and_without_use_tz(self): + lr_with_use_tz = G(LocalizedRecurrence, timezone=pytz.timezone('US/Eastern')) + lr_with_use_tz_utc = G(LocalizedRecurrence, timezone=pytz.UTC) + with self.settings(USE_TZ=True, TIME_ZONE='UTC'): + lr_with_use_tz.update_schedule() + lr_with_use_tz_utc.update_schedule() + self.assertTrue(timezone.is_aware(lr_with_use_tz.next_scheduled)) + self.assertTrue(timezone.is_aware(lr_with_use_tz_utc.next_scheduled)) + + lr_without_use_tz = G(LocalizedRecurrence) + with self.settings(USE_TZ=False): + lr_without_use_tz.update_schedule() + self.assertFalse(timezone.is_aware(lr_without_use_tz.next_scheduled)) + + self.assertEqual(lr_with_use_tz_utc.next_scheduled.replace(tzinfo=None), lr_without_use_tz.next_scheduled) + + def test_prepare_datetime_field(self): + self.assertIsNone(_prepare_datetime_field_value(None)) + naive_now = datetime.now() + aware_now = naive_now.astimezone(pytz.timezone('US/Pacific')) + with self.settings(USE_TZ=True): + self.assertEqual(naive_now, _prepare_datetime_field_value(naive_now, make_aware=False)) + self.assertEqual(aware_now, _prepare_datetime_field_value(aware_now)) + + def test_next_and_previous_scheduled_defaults(self): + with self.settings(USE_TZ=True, TIME_ZONE='UTC'): + lr = G(LocalizedRecurrence, timezone=pytz.timezone('US/Eastern')) + self.assertEqual(lr.previous_scheduled, datetime(1970, 1, 1, tzinfo=pytz.UTC)) + self.assertEqual(lr.next_scheduled, datetime(1970, 1, 1, tzinfo=pytz.UTC)) + with self.settings(USE_TZ=True, TIME_ZONE='US/Pacific'): + lr = G(LocalizedRecurrence, timezone=pytz.timezone('US/Eastern')) + self.assertEqual(lr.previous_scheduled, datetime(1970, 1, 1, tzinfo=pytz.timezone('US/Pacific'))) + self.assertEqual(lr.next_scheduled, datetime(1970, 1, 1, tzinfo=pytz.timezone('US/Pacific'))) + with self.settings(USE_TZ=False, TIME_ZONE='US/Pacific'): + lr = G(LocalizedRecurrence, timezone=pytz.timezone('US/Eastern')) + self.assertEqual(lr.previous_scheduled, datetime(1970, 1, 1)) + self.assertEqual(lr.next_scheduled, datetime(1970, 1, 1)) + + def test_database_with_or_without_tz_in_utc(self): + scheduled_with_utc_tz = datetime(2018, 10, 10, tzinfo=pytz.UTC) + scheduled_with_est_tz = datetime(2018, 10, 10, tzinfo=pytz.timezone('US/Eastern')) + scheduled_without_tz = datetime(2018, 10, 10) + # SQLite backend does not support timezone-aware datetimes when USE_TZ is False. + with self.settings(USE_TZ=True, TIME_ZONE="UTC"): + lr_with_utc_tz = G(LocalizedRecurrence, next_scheduled=scheduled_with_utc_tz, + previous_scheduled=scheduled_with_utc_tz, timezone=pytz.UTC) + lr_with_est_tz = G(LocalizedRecurrence, next_scheduled=scheduled_with_est_tz, + previous_scheduled=scheduled_with_est_tz, timezone=pytz.timezone('US/Eastern')) + lr_without_tz = G(LocalizedRecurrence, timezone=None, next_scheduled=scheduled_without_tz, + previous_scheduled=scheduled_without_tz) + with self.settings(USE_TZ=True, TIME_ZONE="UTC"): + self.assertEqual(lr_with_utc_tz.next_scheduled, lr_without_tz.next_scheduled.replace(tzinfo=pytz.UTC)) + self.assertEqual(lr_with_utc_tz.next_scheduled.replace(tzinfo=None), + lr_with_est_tz.next_scheduled.replace(tzinfo=None)) + + with self.settings(USE_TZ=False): + # Now make next_scheduled non-aware, and make sure that still works + # (Django automatically converts non-aware to aware in the database) + lr_with_utc_tz.previous_scheduled = datetime(2018, 10, 11) + lr_with_utc_tz.next_scheduled = datetime(2018, 10, 11) + lr_with_utc_tz.save() + lr_with_utc_tz.refresh_from_db() + self.assertEqual(lr_with_utc_tz.next_scheduled, + lr_without_tz.next_scheduled + timedelta(days=1)) + + version = django.VERSION + # assertWarns is only Django 2.1+ + if version[0] >= 2 and version[1] >= 1: # pragma: no cover + with self.settings(USE_TZ=True, TIME_ZONE="UTC"): + # Now make next_scheduled non-aware, and make sure that still works + with self.assertWarnsRegex(RuntimeWarning, r"DateTimeField LocalizedRecurrence\." + r"(next|previous)\_scheduled " + r"received a naive datetime \(2018-10-12 00:00:00\) " + r"while time zone support is active\."): + lr_with_utc_tz.previous_scheduled = datetime(2018, 10, 12) + lr_with_utc_tz.next_scheduled = datetime(2018, 10, 12) + lr_with_utc_tz.save() + lr_with_utc_tz.refresh_from_db() + self.assertEqual(lr_with_utc_tz.next_scheduled.replace(tzinfo=None), + lr_without_tz.next_scheduled + timedelta(days=2)) From 92f11cee3127ad4123c26b035d47de0b43c431de Mon Sep 17 00:00:00 2001 From: Micah Lyle Date: Thu, 31 Jan 2019 15:25:06 -0800 Subject: [PATCH 2/2] Added a migration --- .../migrations/0005_auto_20190131_1286.py | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 localized_recurrence/migrations/0005_auto_20190131_1286.py diff --git a/localized_recurrence/migrations/0005_auto_20190131_1286.py b/localized_recurrence/migrations/0005_auto_20190131_1286.py new file mode 100644 index 0000000..276e7d4 --- /dev/null +++ b/localized_recurrence/migrations/0005_auto_20190131_1286.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.16 on 2019-01-31 23:15 +from __future__ import unicode_literals + +from django.db import migrations, models + +import localized_recurrence.models + + +class Migration(migrations.Migration): + dependencies = [ + ('localized_recurrence', '0004_auto_20161108_2151'), + ] + operations = [ + migrations.AlterField( + model_name='localizedrecurrence', + name='next_scheduled', + field=models.DateTimeField(default=localized_recurrence.models.default_scheduled), + ), + migrations.AlterField( + model_name='localizedrecurrence', + name='previous_scheduled', + field=models.DateTimeField(default=localized_recurrence.models.default_scheduled), + ), + ]