changeset b040256012ac in trytond:default details: https://hg.tryton.org/trytond?cmd=changeset&node=b040256012ac description: Schedule actions using server timezone
issue9436 review423091003 diffstat: CHANGELOG | 1 + doc/ref/tools/index.rst | 1 + doc/ref/tools/timezone.rst | 25 ++++++++++++++ doc/topics/cron.rst | 5 ++ setup.py | 1 + trytond/__init__.py | 1 + trytond/ir/cron.py | 9 +++- trytond/ir/view/cron_form.xml | 2 + trytond/tests/test_ir.py | 76 ++++++++++++++++++++++++++++++++++++++++++- trytond/tests/test_tools.py | 14 +++++++- trytond/tools/timezone.py | 45 +++++++++++++++++++++++++ 11 files changed, 176 insertions(+), 4 deletions(-) diffs (321 lines): diff -r f4c77eb02b5b -r b040256012ac CHANGELOG --- a/CHANGELOG Mon Oct 03 00:15:12 2022 +0200 +++ b/CHANGELOG Mon Oct 03 00:20:49 2022 +0200 @@ -1,3 +1,4 @@ +* Schedule actions using server timezone * Reset the user sessions less often * Add straight values to wizard state view * Add strip option to Char fields diff -r f4c77eb02b5b -r b040256012ac doc/ref/tools/index.rst --- a/doc/ref/tools/index.rst Mon Oct 03 00:15:12 2022 +0200 +++ b/doc/ref/tools/index.rst Mon Oct 03 00:20:49 2022 +0200 @@ -13,3 +13,4 @@ email singleton immutabledict + timezone diff -r f4c77eb02b5b -r b040256012ac doc/ref/tools/timezone.rst --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/doc/ref/tools/timezone.rst Mon Oct 03 00:20:49 2022 +0200 @@ -0,0 +1,25 @@ +.. _ref-tools-timezone: +.. module:: trytond.tools.timezone + +timezone +======== + +.. function:: get_tzinfo(zoneid) + + Get a class representing a IANA time zone specified by the string ``zoneid``. + +.. function:: available_timezones() + + Get a list of all the valid IANA keys available. + +.. attribute:: UTC + + The UTC :py:class:`datetime.tzinfo` instance. + +.. attribute:: SERVER + + The server timezone :py:class:`datetime.tzinfo` instance. + + Tryton tests the environment variables ``TRYTOND_TZ`` and ``TZ`` in this + order to select to IANA key to use. + If they are both empty, it defaults to ``UTC``. diff -r f4c77eb02b5b -r b040256012ac doc/topics/cron.rst --- a/doc/topics/cron.rst Mon Oct 03 00:15:12 2022 +0200 +++ b/doc/topics/cron.rst Mon Oct 03 00:20:49 2022 +0200 @@ -11,6 +11,11 @@ and the interval of time between calls. The method must be a class method of a :class:`~trytond.model.Model` which can be called without any parameters. +.. note:: + + The timezone used to schedule the action is + :attr:`timezone.SERVER <trytond.tools.timezone.SERVER>`. + To register a new method with the scheduler, you must extend the ``ir.cron`` model and append the new method to the :attr:`~trytond.model.fields.Selection.selection` attribute of the ``method`` diff -r f4c77eb02b5b -r b040256012ac setup.py --- a/setup.py Mon Oct 03 00:15:12 2022 +0200 +++ b/setup.py Mon Oct 03 00:20:49 2022 +0200 @@ -161,6 +161,7 @@ 'werkzeug', 'wrapt', 'passlib >= 1.7.0', + 'pytz;python_verrion<"3.9"', ], extras_require={ 'test': tests_require, diff -r f4c77eb02b5b -r b040256012ac trytond/__init__.py --- a/trytond/__init__.py Mon Oct 03 00:15:12 2022 +0200 +++ b/trytond/__init__.py Mon Oct 03 00:20:49 2022 +0200 @@ -9,6 +9,7 @@ __version__ = "6.5.0" +os.environ.setdefault('TRYTOND_TZ', os.environ.get('TZ', 'UTC')) os.environ['TZ'] = 'UTC' if hasattr(time, 'tzset'): time.tzset() diff -r f4c77eb02b5b -r b040256012ac trytond/ir/cron.py --- a/trytond/ir/cron.py Mon Oct 03 00:15:12 2022 +0200 +++ b/trytond/ir/cron.py Mon Oct 03 00:20:49 2022 +0200 @@ -14,6 +14,7 @@ from trytond.pool import Pool from trytond.pyson import Eval from trytond.status import processing +from trytond.tools import timezone as tz from trytond.transaction import Transaction from trytond.worker import run_task @@ -66,6 +67,7 @@ ['minutes', 'hours', 'days', 'weeks']), }, depends=['interval_type']) + timezone = fields.Function(fields.Char("Timezone"), 'get_timezone') next_call = fields.DateTime("Next Call", select=True) method = fields.Selection([ @@ -97,6 +99,9 @@ # Migration from 5.0: remove required on next_call table_h.not_null_action('next_call', 'remove') + def get_timezone(self, name): + return tz.SERVER.key + @staticmethod def check_xml_record(crons, values): return True @@ -110,7 +115,7 @@ ] def compute_next_call(self, now): - return (now + return (now.replace(tzinfo=tz.UTC).astimezone(tz.SERVER) + relativedelta(**{self.interval_type: self.interval_number}) + relativedelta( microsecond=0, @@ -132,7 +137,7 @@ int(self.weekday.index) if self.weekday and self.interval_type not in {'minutes', 'hours', 'days'} - else None))) + else None))).astimezone(tz.UTC).replace(tzinfo=None) @dualmethod @ModelView.button diff -r f4c77eb02b5b -r b040256012ac trytond/ir/view/cron_form.xml --- a/trytond/ir/view/cron_form.xml Mon Oct 03 00:15:12 2022 +0200 +++ b/trytond/ir/view/cron_form.xml Mon Oct 03 00:20:49 2022 +0200 @@ -21,6 +21,8 @@ <field name="hour" xexpand="0"/> <label name="minute"/> <field name="minute" xexpand="0"/> + <label name="timezone"/> + <field name="timezone"/> </group> <label name="next_call"/> <field name="next_call"/> diff -r f4c77eb02b5b -r b040256012ac trytond/tests/test_ir.py --- a/trytond/tests/test_ir.py Mon Oct 03 00:15:12 2022 +0200 +++ b/trytond/tests/test_ir.py Mon Oct 03 00:20:49 2022 +0200 @@ -1,6 +1,7 @@ # This file is part of Tryton. The COPYRIGHT file at the top level of # this repository contains the full copyright notices and license terms. import datetime +import unittest from decimal import Decimal from unittest.mock import ANY, Mock, patch @@ -9,9 +10,11 @@ from trytond.config import config from trytond.pool import Pool from trytond.pyson import Eval, If, PYSONEncoder +from trytond.tools import timezone from trytond.transaction import Transaction -from .test_tryton import ModuleTestCase, with_transaction +from .test_tryton import ( + ModuleTestCase, activate_module, drop_db, with_transaction) class IrTestCase(ModuleTestCase): @@ -383,4 +386,75 @@ }) +class IrCronTestCase(unittest.TestCase): + "Test ir.cron features" + + @classmethod + def setUpClass(cls): + drop_db() + activate_module(['ir']) + super().setUpClass() + + @classmethod + def tearDownClass(cls): + super().tearDownClass() + drop_db() + + def setUp(self): + server_tz = timezone.SERVER + timezone.SERVER = timezone.ZoneInfo('Canada/Eastern') + self.addCleanup(setattr, timezone, 'SERVER', server_tz) + + def _get_cron(self): + pool = Pool() + Cron = pool.get('ir.cron') + + cron = Cron() + for attribute in [ + 'interval_number', 'interval_type', 'minute', 'hour', + 'weekday', 'day']: + setattr(cron, attribute, None) + return cron + + @with_transaction() + def test_scheduling_non_utc(self): + "Test scheduling with a non UTC timezone" + cron = self._get_cron() + cron.interval_number = 1 + cron.interval_type = 'days' + cron.hour = 1 + + # Quebec is UTC-5 + self.assertEqual( + cron.compute_next_call(datetime.datetime(2021, 12, 31, 5, 0)), + datetime.datetime(2022, 1, 1, 6, 0)) + + @with_transaction() + def test_scheduling_on_dst_change(self): + "Test scheduling while the DST change occurs" + cron = self._get_cron() + cron.interval_number = 1 + cron.interval_type = 'days' + cron.hour = 2 + + # 2022-03-13 is the day of DST switch + # Quebec is UTC-4 + self.assertEqual( + cron.compute_next_call(datetime.datetime(2022, 3, 12, 6, 30)), + datetime.datetime(2022, 3, 13, 7, 30)) + + @with_transaction() + def test_scheduling_on_standard_time(self): + "Test scheduling while the calendar returns to the standard time" + cron = self._get_cron() + cron.interval_number = 1 + cron.interval_type = 'hours' + + # 2022-11-06 is the day of DST switch + # Quebec is UTC-5 + self.assertEqual( + cron.compute_next_call(datetime.datetime(2022, 11, 6, 7, 30)), + datetime.datetime(2022, 11, 6, 8, 30)) + + del ModuleTestCase diff -r f4c77eb02b5b -r b040256012ac trytond/tests/test_tools.py --- a/trytond/tests/test_tools.py Mon Oct 03 00:15:12 2022 +0200 +++ b/trytond/tests/test_tools.py Mon Oct 03 00:20:49 2022 +0200 @@ -14,7 +14,7 @@ decimal_, escape_wildcard, file_open, firstline, grouped_slice, is_full_text, is_instance_method, lstrip_wildcard, reduce_domain, reduce_ids, remove_forbidden_chars, rstrip_wildcard, slugify, - sortable_values, strip_wildcard, unescape_wildcard) + sortable_values, strip_wildcard, timezone, unescape_wildcard) from trytond.tools.domain_inversion import ( concat, domain_inversion, eval_domain, extract_reference_models, localize_domain, merge, parse, prepare_reference_domain, simplify, @@ -291,6 +291,18 @@ with self.subTest(string=string): self.assertEqual(remove_forbidden_chars(string), result) + def test_get_tzinfo_valid(self): + "Test get_tzinfo with an valid timezone" + zi = timezone.get_tzinfo('Europe/Brussels') + now = dt.datetime(2022, 5, 17, tzinfo=zi) + self.assertEqual(str(now), "2022-05-17 00:00:00+02:00") + + def test_get_tzinfo_invalid(self): + "Test get_tzinfo with an invalid timezone" + zi = timezone.get_tzinfo('foo') + now = dt.datetime(2022, 5, 17, tzinfo=zi) + self.assertEqual(str(now), "2022-05-17 00:00:00+00:00") + class StringPartitionedTestCase(unittest.TestCase): "Test StringPartitioned" diff -r f4c77eb02b5b -r b040256012ac trytond/tools/timezone.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/trytond/tools/timezone.py Mon Oct 03 00:20:49 2022 +0200 @@ -0,0 +1,45 @@ +# This file is part of Tryton. The COPYRIGHT file at the top level of +# this repository contains the full copyright notices and license terms. +import logging +import os + +try: + import zoneinfo + ZoneInfo = zoneinfo.ZoneInfo + ZoneInfoNotFoundError = zoneinfo.ZoneInfoNotFoundError +except ImportError: + import pytz + from dateutil.tz import gettz as ZoneInfo + + class ZoneInfoNotFoundError(KeyError): + pass + +__all__ = ['SERVER', 'UTC', 'get_tzinfo', 'available_timezones'] +logger = logging.getLogger(__name__) +_ALL_ZONES = None + + +def available_timezones(): + global _ALL_ZONES + + if not _ALL_ZONES: + if zoneinfo: + _ALL_ZONES = zoneinfo.available_timezones() + else: + _ALL_ZONES = pytz.all_timezones + return _ALL_ZONES[:] + + +def get_tzinfo(zoneid): + try: + zi = ZoneInfo(zoneid) + if not zi: + raise ZoneInfoNotFoundError + except ZoneInfoNotFoundError: + logger.warning("Timezone %s not found falling back to UTC", zoneid) + zi = UTC + return zi + + +UTC = ZoneInfo('UTC') +SERVER = get_tzinfo(os.environ['TRYTOND_TZ'])