diff --git a/field_rrule/README.rst b/field_rrule/README.rst index 2374a93f4..f8040c168 100644 --- a/field_rrule/README.rst +++ b/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. +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 ====================== diff --git a/field_rrule/__openerp__.py b/field_rrule/__openerp__.py index 59625aa2b..15a82174d 100644 --- a/field_rrule/__openerp__.py +++ b/field_rrule/__openerp__.py @@ -3,7 +3,7 @@ # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). { "name": "Repetition Rules", - "version": "8.0.1.0.0", + "version": "8.0.1.0.1", "author": "Therp BV,Odoo Community Association (OCA)", "license": "AGPL-3", "category": "Hidden/Dependency", diff --git a/field_rrule/field_rrule.py b/field_rrule/field_rrule.py index 1ec65d653..080720df4 100644 --- a/field_rrule/field_rrule.py +++ b/field_rrule/field_rrule.py @@ -1,36 +1,54 @@ # -*- coding: utf-8 -*- # © 2016 Therp BV # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +import pytz from dateutil.rrule import rrule, rruleset +from dateutil.tz import gettz 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', ] -_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 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 stuff in __iter__""" def __init__(self, *args): - self._rrule = [] + self._rrule = list(args) + self.tz = None super(SerializableRRuleSet, self).__init__(self) - for arg in args: - self.rrule(arg) def __iter__(self): for rule in self._rrule: yield dict(type='rrule', **{ 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 - [] 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 - 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) for key in [ '_byhour', '_wkst', '_bysecond', '_bymonthday', @@ -40,6 +58,8 @@ class SerializableRRuleSet(rruleset, list): '_byeaster', ] }) + if self.tz: + yield dict(type='tz', tz=self.tz) # TODO: implement rdate, exrule, exdate 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""" if isinstance(default_self, models.BaseModel): return self - result = rruleset() + result = LocalRRuleSet() result._rrule = self._rrule - result._rdate = self._rdate - result._exrule = self._exrule - result._exdate = self._exdate return result def __nonzero__(self): @@ -87,31 +104,81 @@ class SerializableRRuleSet(rruleset, list): class FieldRRule(fields.Serialized): + _slots = { + 'stable_times': None, + } + def convert_to_cache(self, value, record, validate=True): result = SerializableRRuleSet() if not value: return result if isinstance(value, SerializableRRuleSet): + if self.stable_times and not value.tz: + self._add_tz(value, record.env.user.tz or 'utc') return value assert isinstance(value, list), 'An RRULE\'s content must be a list' + tz = None for data in value: assert isinstance(data, dict), 'The list must contain dictionaries' assert 'type' in data, 'The dictionary must contain a 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': - 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 else: 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 + + 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 + ]) diff --git a/field_rrule/static/src/js/field_rrule.js b/field_rrule/static/src/js/field_rrule.js index 605d01b9e..636f884f3 100644 --- a/field_rrule/static/src/js/field_rrule.js +++ b/field_rrule/static/src/js/field_rrule.js @@ -16,7 +16,7 @@ openerp.field_rrule = function(instance) }, 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) { rule.__id = _.uniqueId(); diff --git a/field_rrule/tests/test_field_rrule.py b/field_rrule/tests/test_field_rrule.py index 87c97aec9..c87553220 100644 --- a/field_rrule/tests/test_field_rrule.py +++ b/field_rrule/tests/test_field_rrule.py @@ -1,12 +1,13 @@ # -*- coding: utf-8 -*- # © 2016 Therp BV # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +import datetime from psycopg2.extensions import AsIs from dateutil.rrule import MONTHLY, rrule +from dateutil.tz import gettz from openerp import SUPERUSER_ID, fields, models 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): @@ -35,6 +36,8 @@ class RRuleTest(models.TransientModel): ))) # also fiddle with an empty one rrule = FieldRRule() + # and the timezone aware version + rrule_with_tz = FieldRRule(stable_times=True) class TestFieldRrule(TransactionCase): @@ -61,3 +64,65 @@ class TestFieldRrule(TransactionCase): fields.Datetime.from_string('2016-02-01 00:00:00'))) self.assertTrue(record.rrule_with_default.after( 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), + ] + )