Browse Source

[ADD] allow developers to have stable times over dst borders

pull/912/head
Holger Brunn 7 years ago
parent
commit
70a5b046aa
No known key found for this signature in database GPG Key ID: 1C9760FECA3AE18
  1. 5
      field_rrule/README.rst
  2. 2
      field_rrule/__openerp__.py
  3. 121
      field_rrule/field_rrule.py
  4. 2
      field_rrule/static/src/js/field_rrule.js
  5. 69
      field_rrule/tests/test_field_rrule.py

5
field_rrule/README.rst

@ -27,6 +27,11 @@ In case you work with defaults and want to dumb down the UI a bit, use ``{'no_ad
Further, as this is a serialized field, a value of `not set` will be represented in the database as ``'null'`` if the value was set and unset afterwards, or a database ``null`` if the value was never set - this is then also what you have to search for when you need records with your field unset. Further, as this is a serialized field, a value of `not set` will be represented in the database as ``'null'`` if the value was set and unset afterwards, or a database ``null`` if the value was never set - this is then also what you have to search for when you need records with your field unset.
Daylight saving time
====================
By default, this field keep intervals stable, as Odoo handles UTC times internally. If you need stable times (think of some repetition of an appointment) in time zones with daylight saving, you need to set ``stable_times=True`` in the field's constructor.
Known issues / Roadmap Known issues / Roadmap
====================== ======================

2
field_rrule/__openerp__.py

@ -3,7 +3,7 @@
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
{ {
"name": "Repetition Rules", "name": "Repetition Rules",
"version": "8.0.1.0.0",
"version": "8.0.1.0.1",
"author": "Therp BV,Odoo Community Association (OCA)", "author": "Therp BV,Odoo Community Association (OCA)",
"license": "AGPL-3", "license": "AGPL-3",
"category": "Hidden/Dependency", "category": "Hidden/Dependency",

121
field_rrule/field_rrule.py

@ -1,36 +1,54 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# © 2016 Therp BV <http://therp.nl> # © 2016 Therp BV <http://therp.nl>
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
import pytz
from dateutil.rrule import rrule, rruleset from dateutil.rrule import rrule, rruleset
from dateutil.tz import gettz
from openerp import fields, models from openerp import fields, models
_DATETIME_FIELDS = ['_until', '_dtstart']
_SCALAR_FIELDS = [
_RRULE_DATETIME_FIELDS = ['_until', '_dtstart']
_RRULE_SCALAR_FIELDS = [
'_wkst', '_cache', '_until', '_dtstart', '_count', '_freq', '_interval', '_wkst', '_cache', '_until', '_dtstart', '_count', '_freq', '_interval',
] ]
_ZERO_IS_NOT_NONE = ['_freq']
_RRULE_ZERO_IS_NOT_NONE = ['_freq']
class SerializableRRuleSet(rruleset, list):
class LocalRRuleSet(rruleset):
"""An rruleset that yields the naive utc representation of a date if the
original date was timezone aware"""
def __iter__(self):
for date in rruleset.__iter__(self):
if not date.tzinfo:
yield date
else:
yield date.astimezone(pytz.utc).replace(tzinfo=None)
class SerializableRRuleSet(list):
"""Getting our rule set json stringified is tricky, because we can't """Getting our rule set json stringified is tricky, because we can't
just inject our own json encoder. Now we pretend our set is a list, just inject our own json encoder. Now we pretend our set is a list,
then json.dumps will try to iterate, which is why we can do our specific then json.dumps will try to iterate, which is why we can do our specific
stuff in __iter__""" stuff in __iter__"""
def __init__(self, *args): def __init__(self, *args):
self._rrule = []
self._rrule = list(args)
self.tz = None
super(SerializableRRuleSet, self).__init__(self) super(SerializableRRuleSet, self).__init__(self)
for arg in args:
self.rrule(arg)
def __iter__(self): def __iter__(self):
for rule in self._rrule: for rule in self._rrule:
yield dict(type='rrule', **{ yield dict(type='rrule', **{
key[1:]: key[1:]:
fields.Datetime.to_string(getattr(rule, key))
if key in _DATETIME_FIELDS
fields.Datetime.to_string(
getattr(rule, key) if
not self.tz or not getattr(rule, key) or
not getattr(rule, key).tzinfo
else getattr(rule, key).astimezone(pytz.utc)
)
if key in _RRULE_DATETIME_FIELDS
else else
[] if getattr(rule, key) is None and key not in _SCALAR_FIELDS
[] if getattr(rule, key) is None and
key not in _RRULE_SCALAR_FIELDS
else else
list(getattr(rule, key)) if key not in _SCALAR_FIELDS
list(getattr(rule, key)) if key not in _RRULE_SCALAR_FIELDS
else getattr(rule, key) else getattr(rule, key)
for key in [ for key in [
'_byhour', '_wkst', '_bysecond', '_bymonthday', '_byhour', '_wkst', '_bysecond', '_bymonthday',
@ -40,6 +58,8 @@ class SerializableRRuleSet(rruleset, list):
'_byeaster', '_byeaster',
] ]
}) })
if self.tz:
yield dict(type='tz', tz=self.tz)
# TODO: implement rdate, exrule, exdate # TODO: implement rdate, exrule, exdate
def __call__(self, default_self=None): def __call__(self, default_self=None):
@ -48,11 +68,8 @@ class SerializableRRuleSet(rruleset, list):
and empty recordset as parameter. In this case, we need self""" and empty recordset as parameter. In this case, we need self"""
if isinstance(default_self, models.BaseModel): if isinstance(default_self, models.BaseModel):
return self return self
result = rruleset()
result = LocalRRuleSet()
result._rrule = self._rrule result._rrule = self._rrule
result._rdate = self._rdate
result._exrule = self._exrule
result._exdate = self._exdate
return result return result
def __nonzero__(self): def __nonzero__(self):
@ -87,31 +104,81 @@ class SerializableRRuleSet(rruleset, list):
class FieldRRule(fields.Serialized): class FieldRRule(fields.Serialized):
_slots = {
'stable_times': None,
}
def convert_to_cache(self, value, record, validate=True): def convert_to_cache(self, value, record, validate=True):
result = SerializableRRuleSet() result = SerializableRRuleSet()
if not value: if not value:
return result return result
if isinstance(value, SerializableRRuleSet): if isinstance(value, SerializableRRuleSet):
if self.stable_times and not value.tz:
self._add_tz(value, record.env.user.tz or 'utc')
return value return value
assert isinstance(value, list), 'An RRULE\'s content must be a list' assert isinstance(value, list), 'An RRULE\'s content must be a list'
tz = None
for data in value: for data in value:
assert isinstance(data, dict), 'The list must contain dictionaries' assert isinstance(data, dict), 'The list must contain dictionaries'
assert 'type' in data, 'The dictionary must contain a type' assert 'type' in data, 'The dictionary must contain a type'
data_type = data['type'] data_type = data['type']
data = {
key: fields.Datetime.from_string(value)
if '_%s' % key in _DATETIME_FIELDS
else map(int, value)
if value and '_%s' % key not in _SCALAR_FIELDS
else int(value) if value
else None if not value and '_%s' % key not in _ZERO_IS_NOT_NONE
else value
for key, value in data.iteritems()
if key != 'type'
}
if hasattr(self, 'convert_to_cache_parse_data_%s' % data_type):
data = getattr(
self, 'convert_to_cache_parse_data_%s' % data_type
)(record, data)
if data_type == 'rrule': if data_type == 'rrule':
result.rrule(rrule(**data))
result._rrule.append(rrule(**data))
elif data_type == 'tz' and self.stable_times:
tz = data['tz']
# TODO: implement rdate, exrule, exdate # TODO: implement rdate, exrule, exdate
else: else:
raise ValueError('Unknown type given') raise ValueError('Unknown type given')
if self.stable_times:
self._add_tz(result, tz or record.env.user.tz or 'utc')
return result
def convert_to_cache_parse_data_rrule(self, record, data):
"""parse a data dictionary from the database"""
return {
key: fields.Datetime.from_string(value)
if '_%s' % key in _RRULE_DATETIME_FIELDS
else map(int, value)
if value and '_%s' % key not in _RRULE_SCALAR_FIELDS
else int(value) if value
else None
if not value and '_%s' % key not in _RRULE_ZERO_IS_NOT_NONE
else value
for key, value in data.iteritems()
if key != 'type'
}
def to_column(self):
"""set our flag on the resulting column"""
result = super(FieldRRule, self).to_column()
result.stable_times = self.stable_times
return result return result
def _add_tz(self, value, tz):
"""set the timezone on an rruleset and adjust dates there"""
value.tz = tz
tz = gettz(tz)
for rule in value._rrule:
for fieldname in _RRULE_DATETIME_FIELDS:
date = getattr(rule, fieldname)
if not date:
continue
setattr(
rule, fieldname,
date.replace(tzinfo=pytz.utc).astimezone(tz),
)
rule._tzinfo = tz
rule._timeset = tuple([
rule._dtstart.replace(
hour=time.hour,
minute=time.minute,
second=time.second,
tzinfo=pytz.utc,
).astimezone(tz).timetz()
if not time.tzinfo else time
for time in rule._timeset
])

2
field_rrule/static/src/js/field_rrule.js

@ -16,7 +16,7 @@ openerp.field_rrule = function(instance)
}, },
set_value: function(val) set_value: function(val)
{ {
var result = this._super(jQuery.extend([], val));
var result = this._super(jQuery.extend(true, [], val));
_.each(this.get('value'), function(rule) _.each(this.get('value'), function(rule)
{ {
rule.__id = _.uniqueId(); rule.__id = _.uniqueId();

69
field_rrule/tests/test_field_rrule.py

@ -1,12 +1,13 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# © 2016 Therp BV <http://therp.nl> # © 2016 Therp BV <http://therp.nl>
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
import datetime
from psycopg2.extensions import AsIs from psycopg2.extensions import AsIs
from dateutil.rrule import MONTHLY, rrule from dateutil.rrule import MONTHLY, rrule
from dateutil.tz import gettz
from openerp import SUPERUSER_ID, fields, models from openerp import SUPERUSER_ID, fields, models
from openerp.tests.common import TransactionCase from openerp.tests.common import TransactionCase
from openerp.addons.field_rrule import FieldRRule
from openerp.addons.field_rrule.field_rrule import SerializableRRuleSet
from ..field_rrule import FieldRRule, SerializableRRuleSet
class RRuleTest(models.TransientModel): class RRuleTest(models.TransientModel):
@ -35,6 +36,8 @@ class RRuleTest(models.TransientModel):
))) )))
# also fiddle with an empty one # also fiddle with an empty one
rrule = FieldRRule() rrule = FieldRRule()
# and the timezone aware version
rrule_with_tz = FieldRRule(stable_times=True)
class TestFieldRrule(TransactionCase): class TestFieldRrule(TransactionCase):
@ -61,3 +64,65 @@ class TestFieldRrule(TransactionCase):
fields.Datetime.from_string('2016-02-01 00:00:00'))) fields.Datetime.from_string('2016-02-01 00:00:00')))
self.assertTrue(record.rrule_with_default.after( self.assertTrue(record.rrule_with_default.after(
fields.Datetime.from_string('2016-02-01 00:00:00'), inc=True)) fields.Datetime.from_string('2016-02-01 00:00:00'), inc=True))
record.env.user.write({'tz': 'Europe/Amsterdam'})
record.rrule_with_tz = SerializableRRuleSet(
rrule(
dtstart=fields.Datetime.from_string('2017-02-02 23:00:00'),
interval=1,
freq=MONTHLY,
count=2,
bymonthday=[1],
)
)
# this rruleset should receive Amsterdam as timezone, and should yield
# different naive utc times depending on dst (NL switches in March)
self.assertTrue(record.rrule_with_tz.tz)
self.assertEqual(
list(record.rrule_with_tz()),
[
datetime.datetime(2017, 2, 28, 23, 0),
datetime.datetime(2017, 3, 31, 22, 0),
]
)
# doing the same with a timezone-aware datetime should work too
record.rrule_with_tz = SerializableRRuleSet(
rrule(
dtstart=fields.Datetime.from_string('2017-02-03 00:00:00')
.replace(tzinfo=gettz('Europe/Amsterdam')),
interval=1,
freq=MONTHLY,
count=2,
bymonthday=[1],
)
)
self.assertTrue(record.rrule_with_tz.tz)
self.assertEqual(
list(record.rrule_with_tz()),
[
datetime.datetime(2017, 2, 28, 23, 0),
datetime.datetime(2017, 3, 31, 22, 0),
]
)
# and the same again with the json representation
record.rrule_with_tz = [
{
'type': 'rrule',
'dtstart': '2017-02-02 23:00:00',
'interval': 1,
'freq': MONTHLY,
'count': 2,
'bymonthday': [1],
},
{
'type': 'tz',
'tz': 'Europe/Amsterdam',
}
]
self.assertTrue(record.rrule_with_tz.tz)
self.assertEqual(
list(record.rrule_with_tz()),
[
datetime.datetime(2017, 2, 28, 23, 0),
datetime.datetime(2017, 3, 31, 22, 0),
]
)
Loading…
Cancel
Save