Schedule actions using server timezone

issue9436
review423091003
This commit is contained in:
Nicolas Évrard
2022-10-03 00:20:49 +02:00
parent 4dd1c0c222
commit 1793415b7d
11 changed files with 176 additions and 4 deletions
+1
View File
@@ -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
+1
View File
@@ -13,3 +13,4 @@ Tools API reference.
email
singleton
immutabledict
timezone
+25
View File
@@ -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``.
+5
View File
@@ -11,6 +11,11 @@ The planning is managed by ``ir.cron`` records which store the method to call
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``
+1
View File
@@ -161,6 +161,7 @@ setup(name=name,
'werkzeug',
'wrapt',
'passlib >= 1.7.0',
'pytz;python_verrion<"3.9"',
],
extras_require={
'test': tests_require,
+1
View File
@@ -9,6 +9,7 @@ from lxml import etree, objectify
__version__ = "6.5.0"
os.environ.setdefault('TRYTOND_TZ', os.environ.get('TZ', 'UTC'))
os.environ['TZ'] = 'UTC'
if hasattr(time, 'tzset'):
time.tzset()
+7 -2
View File
@@ -14,6 +14,7 @@ from trytond.model import (
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 @@ class Cron(DeactivableMixin, ModelSQL, ModelView):
['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 @@ class Cron(DeactivableMixin, ModelSQL, ModelView):
# 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 @@ class Cron(DeactivableMixin, ModelSQL, ModelView):
]
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 @@ class Cron(DeactivableMixin, ModelSQL, ModelView):
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
+2
View File
@@ -21,6 +21,8 @@ this repository contains the full copyright notices and license terms. -->
<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"/>
+75 -1
View File
@@ -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 dateutil.relativedelta import relativedelta
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 IrTestCase(ModuleTestCase):
})
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
+13 -1
View File
@@ -14,7 +14,7 @@ from trytond.tools import (
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 @@ class ToolsTestCase(unittest.TestCase):
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"
+45
View File
@@ -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'])