Browse Source

[ADD] field_rrule

pull/480/head
Holger Brunn 8 years ago
parent
commit
12a127cd12
No known key found for this signature in database GPG Key ID: 1C9760FECA3AE18
  1. 67
      field_rrule/README.rst
  2. 5
      field_rrule/__init__.py
  3. 20
      field_rrule/__openerp__.py
  4. 26
      field_rrule/demo/res_partner.py
  5. 15
      field_rrule/demo/res_partner.xml
  6. 102
      field_rrule/field_rrule.py
  7. BIN
      field_rrule/static/description/icon.png
  8. 22
      field_rrule/static/src/css/field_rrule.css
  9. 261
      field_rrule/static/src/js/field_rrule.js
  10. 105
      field_rrule/static/src/xml/field_rrule.xml
  11. 4
      field_rrule/tests/__init__.py
  12. 9
      field_rrule/tests/test_field_rrule.py
  13. 11
      field_rrule/views/templates.xml

67
field_rrule/README.rst

@ -0,0 +1,67 @@
.. image:: https://img.shields.io/badge/licence-AGPL--3-blue.svg
:target: http://www.gnu.org/licenses/agpl-3.0-standalone.html
:alt: License: AGPL-3
================
Repetition rules
================
This module was written to offer a field type that holds rrules according to RFC 2445.
Usage
=====
To use this module, you need to:
* depend on it
* say ``from openerp.addons.field_rrule.field_rrule import FieldRRule``
* use ``FieldRRule`` like any other field
* on forms, use ``widget="rrule"``
* have a look at `demo/res_partner.*`
Technically, this is a wrapper around serialized fields. The value always will be a subclass of dateutil's ``rruleset``. For technical reasons, this class overrides ``__iter__``, so if you need a proper ``rruleset``, call the value: ``my_browse_record.my_field_of_type_rrule()`` - this gives you a vanilla ``rruleset``.
Known issues / Roadmap
======================
* support the unimplemented features of rrules
* support rdates, exdates, exrules
* consider multiple widgets with different feature sets
Bug Tracker
===========
Bugs are tracked on `GitHub Issues
<https://github.com/OCA/server-tools/issues>`_. In case of trouble, please
check there if your issue has already been reported. If you spotted it first,
help us smashing it by providing a detailed and welcomed feedback.
Credits
=======
Images
------
* Odoo Community Association: `Icon <https://github.com/OCA/maintainer-tools/blob/master/template/module/static/description/icon.svg>`_.
Contributors
------------
* Holger Brunn <hbrunn@therp.nl>
Do not contact contributors directly about help with questions or problems concerning this addon, but use the `community mailing list <mailto:community@mail.odoo.com>`_ or the `appropriate specialized mailinglist <https://odoo-community.org/groups>`_ for help, and the bug tracker linked in `Bug Tracker`_ above for technical issues.
Maintainer
----------
.. image:: https://odoo-community.org/logo.png
:alt: Odoo Community Association
:target: https://odoo-community.org
This module is maintained by the OCA.
OCA, or the Odoo Community Association, is a nonprofit organization whose
mission is to support the collaborative development of Odoo features and
promote its widespread use.
To contribute to this module, please visit https://odoo-community.org.

5
field_rrule/__init__.py

@ -0,0 +1,5 @@
# -*- coding: utf-8 -*-
# © 2016 Therp BV <http://therp.nl>
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
from .field_rrule import FieldRRule
from . import res_partner

20
field_rrule/__openerp__.py

@ -0,0 +1,20 @@
# -*- coding: utf-8 -*-
# © 2016 Therp BV <http://therp.nl>
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
{
"name": "Repetition Rules",
"version": "8.0.1.0.0",
"author": "Therp BV,Odoo Community Association (OCA)",
"license": "AGPL-3",
"category": "Hidden/Dependency",
"summary": "Provides a field and widget for RRules according to RFC 2445",
"depends": [
'web',
],
"data": [
'views/templates.xml',
],
"qweb": [
'static/src/xml/field_rrule.xml',
],
}

26
field_rrule/demo/res_partner.py

@ -0,0 +1,26 @@
# -*- coding: utf-8 -*-
# © 2016 Therp BV <http://therp.nl>
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
from dateutil.rrule import YEARLY, rrule
from openerp import api, fields, models
from openerp.addons.field_rrule.field_rrule import FieldRRule,\
SerializableRRuleSet
class ResPartner(models.Model):
_inherit = 'res.partner'
@api.depends('rrule')
def _compute_rrule_representation(self):
for this in self:
if not this.rrule:
this.rrule_representation = 'You did not fill in rules yet'
continue
this.rrule_representation = 'First 5 dates: %s\n%s' % (
', '.join(map(str, this.rrule[:5])),
this.rrule,
)
rrule = FieldRRule('RRule', default=SerializableRRuleSet(rrule(YEARLY)))
rrule_representation = fields.Text(
string='RRule representation', compute=_compute_rrule_representation)

15
field_rrule/demo/res_partner.xml

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<openerp>
<data>
<record id="d" model="ir.ui.view">
<field name="model">res.partner</field>
<field name="inherit_id" ref="base.view_partner_form" />
<field name="arch" type="xml">
<field name="website" position="after">
<field name="rrule" widget="rrule" />
<field name="rrule_representation" />
</field>
</field>
</record>
</data>
</openerp>

102
field_rrule/field_rrule.py

@ -0,0 +1,102 @@
# -*- coding: utf-8 -*-
# © 2016 Therp BV <http://therp.nl>
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
from dateutil.rrule import rrule, rruleset, YEARLY
from openerp import fields, models
_DATETIME_FIELDS = ['_until', '_dtstart']
_SCALAR_FIELDS = [
'_wkst', '_cache', '_until', '_dtstart', '_count', '_freq', '_interval',
]
_ZERO_IS_NOT_NONE = ['_freq']
class SerializableRRuleSet(rruleset, 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 = []
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
else
[] if getattr(rule, key) is None and key not in _SCALAR_FIELDS
else
list(getattr(rule, key)) if key not in _SCALAR_FIELDS
else getattr(rule, key)
for key in [
'_byhour', '_wkst', '_bysecond', '_bymonthday',
'_byweekno', '_bysetpos', '_cache', '_bymonth',
'_byyearday', '_byweekday', '_byminute',
'_until', '_dtstart', '_count', '_freq', '_interval',
'_byeaster',
]
})
# TODO: implement rdate, exrule, exdate
def __call__(self, default_self=None):
"""convert self to a proper rruleset for iteration.
If we use defaults on our field, this will be called too with
and empty recordset as parameter. In this case, we need self"""
if isinstance(default_self, models.Model):
return self
result = rruleset()
result._rrule = self._rrule
result._rdate = self._rdate
result._exrule = self._exrule
result._exdate = self._exdate
return result
def __exit__(self):
pass
def __nonzero__(self):
return bool(self._rrule)
def __repr__(self):
return ', '.join(str(a) for a in self)
def __getitem__(self, key):
return rruleset.__getitem__(self(), key)
def __getslice__(self, i, j):
return rruleset.__getitem__(self(), slice(i, j))
class FieldRRule(fields.Serialized):
def convert_to_cache(self, value, record, validate=True):
result = SerializableRRuleSet()
if not value:
return result
if isinstance(value, SerializableRRuleSet):
return value
assert isinstance(value, list), 'An RRULE\'s content must be a list'
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 data_type == 'rrule':
result.rrule(rrule(**data))
# TODO: implement rdate, exrule, exdate
else:
raise ValueError('Unknown type given')
return result

BIN
field_rrule/static/description/icon.png

After

Width: 128  |  Height: 128  |  Size: 9.2 KiB

22
field_rrule/static/src/css/field_rrule.css

@ -0,0 +1,22 @@
.oe_form_field_rrule .rule_item label
{
white-space: nowrap;
}
.oe_form_field_rrule .rule_header
{
text-align: right;
padding-right: 8px;
}
.oe_form_field_rrule th
{
vertical-align: top;
padding-right: 8px;
}
.oe_form_editable .oe_form_field_rrule form.rule_item:hover .rule_header
{
background: #7c7bad;
}
.oe_form_editable .oe_form_field_rrule form.rule_item:hover .rule_header .oe_link
{
color: white;
}

261
field_rrule/static/src/js/field_rrule.js

@ -0,0 +1,261 @@
//-*- coding: utf-8 -*-
//© 2016 Therp BV <http://therp.nl>
//License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
openerp.field_rrule = function(instance)
{
instance.field_rrule.FieldRRule = instance.web.form.AbstractField
.extend(instance.web.form.ReinitializeFieldMixin, {
template: 'FieldRRule',
events: {
'click a.add_rule': 'add_rule',
'click a.rule_remove': 'remove_rule',
'change select': 'frequency_changed',
'change input:not(.rule_ignore_input)': 'input_changed',
'change input[name="recurrence_type"]': 'toggle_recurrence_type',
},
set_value(val)
{
var result = this._super(jQuery.extend([], val));
_.each(this.get('value'), function(rule)
{
rule.__id = _.uniqueId();
});
this.reinitialize();
return result;
},
get_value()
{
var result = jQuery.extend(
true, [], this._super.apply(this, arguments));
_.each(result, function(rule)
{
delete rule['__id'];
});
return result;
},
initialize_content: function()
{
var self = this;
this.$('select[name="freq"]').trigger('change', true)
this.$('input[name="recurrence_type"]:checked')
.trigger('change', true);
this.$('input[type="datetime"]').each(function()
{
var input = jQuery(this),
current_value = input.val();
input.datetimepicker({
onSelect: self.proxy('on_timepicker_select'),
onClose: self.proxy('on_timepicker_select'),
closeOnDateSelect: true,
value: current_value ? instance.web.str_to_datetime(
instance.web.parse_value(
input.val(), {type: 'datetime'})) : new Date(),
});
});
},
frequency_changed: function(e, noreset)
{
var frequency = jQuery(e.currentTarget),
current_item = frequency
.parentsUntil('form', 'table.rule_item');
current_item.find('[data-visible-freq]').each(function()
{
var node = jQuery(this);
node.toggle(
String(node.data('visible-freq')).split(',')
.indexOf(frequency.val()) >= 0
);
});
this.input_changed(e, noreset);
},
input_changed: function(e, noreset)
{
var input = jQuery(e.currentTarget),
current_item = input
.parentsUntil('form', 'table.rule_item'),
all_values = this.get('value') || [],
old_values = jQuery.extend(true, [], all_values);
value = _.findWhere(all_values, {
__id: String(current_item.data('id')),
});
if(jQuery.isArray(value[input.attr('name')]))
{
var input_value = parseInt(input.val());
value[input.attr('name')] = _.filter(
value[input.attr('name')], function(x) {
return x != input_value
});
if(input.is(':checked'))
{
value[input.attr('name')].push(input_value);
}
}
else if(input.attr('type') == 'datetime')
{
value[input.attr('name')] = instance.web.parse_value(
input.val(), {type: 'datetime'});
}
else
{
value[input.attr('name')] = input.val() || undefined;
}
if(!noreset)
{
this.reset_fields(value, current_item, input.attr('name'));
}
this.trigger("change:value", this, {
oldValue: old_values,
newValue: all_values,
});
},
toggle_recurrence_type: function(e)
{
var type = jQuery(e.currentTarget),
current_item = type.parentsUntil('tr', 'td');
current_item.find('input:not(.rule_ignore_input)').each(function()
{
var input = jQuery(this),
visible = input.attr('name') == type.attr('value');
input.toggle(visible);
if(visible)
{
input.trigger('change');
}
});
},
reset_fields: function(rule, current_item, field)
{
// for some fields, we should reset some other fields when they
// were changed
if(field == 'freq')
{
rule.byweekday = [];
rule.bymonthday = [];
rule.byyearday = [];
current_item.find(
'[name="byweekday"], [name="bymonthday"], ' +
'[name="byyearday"]'
).prop('checked', false);
}
if(field == 'dtstart')
{
rule.byhour = [];
rule.byminute = [];
rule.bysecond = [];
current_item.find(
'[name="byhour"], [name="bysecond"], [name="byminute"]'
).prop('checked', false);
}
if(field == 'until')
{
rule.count = undefined;
current_item.find('[name="count"]').val('0');
}
if(field == 'count')
{
rule.until = undefined;
current_item.find('[name="until"]').val('');
}
},
add_rule: function(e)
{
var value = this.get('value') || [];
value.push(this.get_default_rrule());
this.set('value', value);
this.reinitialize();
},
remove_rule: function(e)
{
var value = this.get('value') || [],
old_value = jQuery.extend(true, [], value);
current_item = jQuery(e.currentTarget)
.parentsUntil('form', 'table.rule_item'),
current_id = String(current_item.data('id'));
for(var i = 0; i < value.length; i++)
{
if(value[i]['__id'] == current_id)
{
value.splice(i, 1);
i--;
}
}
this.trigger("change:value", this, {
oldValue: old_value,
newValue: value,
});
this.reinitialize();
},
get_default_rrule: function()
{
return {
__id: _.uniqueId(),
type: 'rrule',
freq: 1,
count: undefined,
interval: 1,
dtstart: instance.datetime_to_str(new Date()),
byweekday: [],
bymonthday: [],
byyearday: [],
};
},
on_timepicker_select: function(datestring, input)
{
return this.format_timepicker_value(input.input || input.$input);
},
on_timepicker_month_year: function(year, month, input)
{
return this.format_timepicker_value(input.input || input.$input);
},
format_timepicker_value: function(input)
{
input.val(instance.web.format_value(
input.datetimepicker('getDate'), {type: 'datetime'}));
},
format_field_weekday: function(weekday)
{
switch(parseInt(weekday))
{
case 0: return instance.web._t('Monday');
case 1: return instance.web._t('Tuesday');
case 2: return instance.web._t('Wednesday');
case 3: return instance.web._t('Thursday');
case 4: return instance.web._t('Friday');
case 5: return instance.web._t('Saturday');
case 6: return instance.web._t('Sunday');
}
},
format_field_freq: function(frequency)
{
switch(parseInt(frequency))
{
case 0: return instance.web._t('Yearly');
case 1: return instance.web._t('Monthly');
case 2: return instance.web._t('Weekly');
case 3: return instance.web._t('Daily');
case 4: return instance.web._t('Hourly');
case 5: return instance.web._t('Minutely');
case 6: return instance.web._t('Secondly');
}
},
format_field_dtstart: function(dtstart)
{
return instance.web.format_value(dtstart, {type: 'datetime'});
},
format_field_until: function(until)
{
return instance.web.format_value(until, {type: 'datetime'});
},
format_field_count: function(count)
{
if(!count)
{
return instance.web._t('Indefinitely');
}
return _.str.sprintf(instance.web._t('%s occurrences'), count);
},
});
instance.web.form.widgets.add('rrule', 'instance.field_rrule.FieldRRule');
}

105
field_rrule/static/src/xml/field_rrule.xml

@ -0,0 +1,105 @@
<template>
<div t-name="FieldRRule" class="oe_form_field_rrule">
<div t-if="!widget.get('effective_readonly')">
<a class="oe_link add_rule">Add instance</a>
</div>
<form t-foreach="widget.get('value')" t-as="rule" class="rule_item">
<table t-if="rule.type == 'rrule'" class="rule_item" t-att-data-id="rule.__id">
<tr t-if="!widget.get('effective_readonly')">
<td colspan="2" class="rule_header">
<a class="oe_link rule_remove" title="Remove this rule"><i class="fa fa-times" /></a>
</td>
</tr>
<tr>
<th>Begin</th>
<td t-if="widget.get('effective_readonly')">
<t t-esc="widget.format_field_dtstart(rule.dtstart)" />
</td>
<td t-if="!widget.get('effective_readonly')" class="oe_form_required">
<input name="dtstart" type="datetime" t-att-value="widget.format_field_dtstart(rule.dtstart)" required="required" />
</td>
</tr>
<tr>
<th>End</th>
<td t-if="widget.get('effective_readonly')">
<t t-esc="widget.format_field_dtstart(rule.until) or widget.format_field_count(rule.count)" />
</td>
<td t-if="!widget.get('effective_readonly')" class="oe_form_required">
<div>
<label>
<input type="radio" name="recurrence_type" value="until" class="rule_ignore_input" t-att-checked="rule.until and 'checked' or None" />At a date
</label>
<label>
<input type="radio" name="recurrence_type" value="count" class="rule_ignore_input" t-att-checked="rule.until and None or 'checked'" />After an amount of occurrences
</label>
</div>
<input name="until" type="datetime" t-att-value="widget.format_field_until(rule.until)" required="required" />
<input name="count" type="number" step="1" t-att-value="rule.count" required="required" placeholder="Leave empty for no limit" />
</td>
</tr>
<tr>
<th>Recurrence</th>
<td t-if="widget.get('effective_readonly')">
<t t-esc="widget.format_field_freq(rule.freq)" />
</td>
<td t-if="!widget.get('effective_readonly')" class="oe_form_required">
<select name="freq" type="select" required="required">
<option value="2" t-att-selected="rule.freq == 2 and 'selected' or None">Weekly</option>
<option value="1" t-att-selected="rule.freq == 1 and 'selected' or None">Monthly</option>
<option value="0" t-att-selected="rule.freq == 0 and 'selected' or None">Yearly</option>
</select>
</td>
</tr>
<tr>
<th>Interval</th>
<td t-if="widget.get('effective_readonly')">
<t t-esc="rule.interval" />
</td>
<td t-if="!widget.get('effective_readonly')" class="oe_form_required">
<input type="number" step="1" name="interval" t-att-value="rule.interval" />
</td>
</tr>
<tr data-visible-freq="0" t-if="!widget.get('effective_readonly') or rule.freq == 0">
<th>Day of year</th>
<td t-if="!widget.get('effective_readonly')">
<t t-foreach="_.range(1, 366)" t-as="day_of_year">
<label><input name="byyearday" type="checkbox" t-att-value="day_of_year" t-att-checked="rule.byyearday.indexOf(day_of_year) >= 0 and 'checked' or None" /><t t-esc="day_of_year" /></label>
</t>
</td>
<td t-if="widget.get('effective_readonly')">
<t t-foreach="rule.byyearday" t-as="day_of_year">
<t t-esc="day_of_year" /><t t-if="!day_of_year_last">, </t>
</t>
</td>
</tr>
<tr data-visible-freq="1" t-if="!widget.get('effective_readonly') or rule.freq == 1">
<th>Day of month</th>
<td t-if="!widget.get('effective_readonly')">
<t t-foreach="_.range(1, 32)" t-as="day_of_month">
<label><input name="bymonthday" type="checkbox" t-att-value="day_of_month" t-att-checked="rule.bymonthday.indexOf(day_of_month) >= 0 and 'checked' or None" /><t t-esc="day_of_month" /></label>
</t>
</td>
<td t-if="widget.get('effective_readonly')">
<t t-foreach="rule.bymonthday" t-as="day_of_month">
<t t-esc="day_of_month" /><t t-if="!day_of_month_last">, </t>
</t>
</td>
</tr>
<tr data-visible-freq="2" t-if="!widget.get('effective_readonly') or rule.freq == 2">
<th>Weekday</th>
<td t-if="!widget.get('effective_readonly')">
<t t-foreach="_.range(0, 7)" t-as="weekday">
<label><input name="byweekday" type="checkbox" t-att-value="weekday" t-att-checked="rule.byweekday.indexOf(weekday) >= 0 and 'checked' or None" /><t t-esc="widget.format_field_weekday(weekday)" /></label>
</t>
</td>
<td t-if="widget.get('effective_readonly')">
<t t-foreach="rule.byweekday" t-as="weekday">
<t t-esc="widget.format_field_weekday(weekday)" /><t t-if="!weekday_last">, </t>
</t>
</td>
</tr>
</table>
<hr t-if="!rule_last" />
</form>
</div>
</template>

4
field_rrule/tests/__init__.py

@ -0,0 +1,4 @@
# -*- coding: utf-8 -*-
# © 2016 Therp BV <http://therp.nl>
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
from . import test_field_rrule

9
field_rrule/tests/test_field_rrule.py

@ -0,0 +1,9 @@
# -*- coding: utf-8 -*-
# © 2016 Therp BV <http://therp.nl>
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
from openerp.tests.common import TransactionCase
class TestFieldRrule(TransactionCase):
def test_field_rrule(self):
pass

11
field_rrule/views/templates.xml

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<openerp>
<data>
<template id="assets_backend" name="field_rrule assets" inherit_id="web.assets_backend">
<xpath expr="." position="inside">
<script type="text/javascript" src="/field_rrule/static/src/js/field_rrule.js"></script>
<link rel="stylesheet" href="/field_rrule/static/src/css/field_rrule.css"/>
</xpath>
</template>
</data>
</openerp>
Loading…
Cancel
Save