From e8aa6dd2c89e21f3b43bc1463a140dd28af98385 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Bidoul?= Date: Sun, 15 May 2016 15:30:50 +0200 Subject: [PATCH] [IMP] mis_builder: number format are now part of styles Plus a default style at the report level. Plus correct number rendering for comparisons in Excel export. --- mis_builder/CHANGES.rst | 9 +- mis_builder/models/mis_report.py | 170 ++++++++--------- mis_builder/models/mis_report_style.py | 173 ++++++++++++++---- .../report/mis_report_instance_qweb.xml | 22 +-- .../report/mis_report_instance_xlsx.py | 37 +--- mis_builder/tests/mis.report.kpi.csv | 4 +- mis_builder/tests/test_fetch_query.py | 1 + mis_builder/tests/test_render.py | 167 +++++++++-------- mis_builder/views/mis_builder_style.xml | 58 ------ mis_builder/views/mis_report.xml | 22 +-- mis_builder/views/mis_report_style.xml | 80 ++++++++ 11 files changed, 415 insertions(+), 328 deletions(-) delete mode 100644 mis_builder/views/mis_builder_style.xml create mode 100644 mis_builder/views/mis_report_style.xml diff --git a/mis_builder/CHANGES.rst b/mis_builder/CHANGES.rst index 677cc68d..78796ca5 100644 --- a/mis_builder/CHANGES.rst +++ b/mis_builder/CHANGES.rst @@ -3,8 +3,8 @@ Changelog .. Future (?) .. ~~~~~~~~~~ -.. -.. * +.. +.. * 9.0.1.0.0 (2016-??-??) ~~~~~~~~~~~~~~~~~~~~~~ @@ -13,6 +13,9 @@ Part of the work for this release has been done at the Sorrento sprint April 26-29, 2016. The rest (ie a major refactoring) has been done in the weeks after. +* [IMP] There is now a default style at the report level +* [CHG] Number display properties (rounding, prefix, suffix, factor) are + now defined in styles * [CHG] Percentage difference are rounded to 1 digit instead of the kpi's rounding, as the KPI rounding does not make sense in this case * [CHG] The divider suffix (k, M, etc) is not inserted automatically anymore @@ -42,7 +45,7 @@ the weeks after. * [FIX] use =like instead of like to search for accounts, because the % are added by the user in the expressions * [FIX] Correctly compute the initial balance of income and expense account - based on the start of the fiscal year + based on the start of the fiscal year * [IMP] Support date ranges (from OCA/server-tools/date_range) as a more flexible alternative to fiscal periods * v9 migration: fiscal periods are removed, account charts are removed, diff --git a/mis_builder/models/mis_report.py b/mis_builder/models/mis_report.py index 0e7b7951..1d5d1f33 100644 --- a/mis_builder/models/mis_report.py +++ b/mis_builder/models/mis_report.py @@ -40,6 +40,14 @@ class KpiMatrixRow(object): self.account_id = account_id self.comment = '' self.parent_row = parent_row + if not self.account_id: + self.style_props = self._matrix._style_model.merge([ + self.kpi.report_id.style_id, + self.kpi.style_id]) + else: + self.style_props = self._matrix._style_model.merge([ + self.kpi.report_id.style_id, + self.kpi.auto_expand_accounts_style_id]) @property def description(self): @@ -48,13 +56,6 @@ class KpiMatrixRow(object): else: return self._matrix.get_account_name(self.account_id) - @property - def style(self): - if not self.account_id: - return self.kpi.style - else: - return self.kpi.auto_expand_accounts_style - @property def row_id(self): if not self.account_id: @@ -134,13 +135,14 @@ class KpiMatrixCell(object): def __init__(self, row, subcol, val, val_rendered, val_comment, - style=None, drilldown_arg=None): + style_props, + drilldown_arg): self.row = row self.subcol = subcol self.val = val self.val_rendered = val_rendered self.val_comment = val_comment - self.style = style + self.style_props = style_props self.drilldown_arg = drilldown_arg @@ -151,6 +153,7 @@ class KpiMatrix(object): lang_model = env['res.lang'] lang_id = lang_model._lang_get(env.user.lang) self.lang = lang_model.browse(lang_id) + self._style_model = env['mis.report.style'] self._account_model = env['account.account'] # data structures # { kpi: KpiMatrixRow } @@ -224,7 +227,7 @@ class KpiMatrix(object): val_rendered = val.name val_comment = val.msg else: - val_rendered = kpi.render(self.lang, val) + val_rendered = kpi.render(self.lang, row.style_props, val) if subcol.subkpi: val_comment = u'{}.{} = {}'.format( row.kpi.name, @@ -234,9 +237,9 @@ class KpiMatrix(object): val_comment = u'{} = {}'.format( row.kpi.name, row.kpi.expression) - # TODO style + # TODO FIXME style expression cell = KpiMatrixCell(row, subcol, val, val_rendered, val_comment, - None, drilldown_arg) + row.style_props, drilldown_arg) cell_tuple.append(cell) col._set_cell_tuple(row, cell_tuple) @@ -279,10 +282,11 @@ class KpiMatrix(object): base_vals, comparison_col.iter_subcols()): # TODO FIXME average factors - delta, delta_r = row.kpi.compare_and_render( - self.lang, val, base_val, 1, 1) + delta, delta_r, style_r = row.kpi.compare_and_render( + self.lang, row.style_props, val, base_val, 1, 1) comparison_cell_tuple.append(KpiMatrixCell( - row, comparison_subcol, delta, delta_r, None)) + row, comparison_subcol, delta, delta_r, None, + style_r, None)) comparison_col._set_cell_tuple(row, comparison_cell_tuple) self._comparison_cols[pos_period_key].append(comparison_col) @@ -355,11 +359,13 @@ class KpiMatrix(object): row.parent_row.row_id or None), 'description': row.description, 'comment': row.comment, - 'style': row.style and row.style.to_css_style() or None, + 'style': self._style_model.to_css_style( + row.style_props), 'cols': [] } for cell in row.iter_cells(): if cell is None: + # TODO use subcol style here row_data['cols'].append({}) else: col_data = { @@ -367,7 +373,8 @@ class KpiMatrix(object): if cell.val is not AccountingNone else None), 'val_r': cell.val_rendered, 'val_c': cell.val_comment, - # TODO FIXME style + 'style': self._style_model.to_css_style( + cell.style_props), } if cell.drilldown_arg: col_data['drilldown_arg'] = cell.drilldown_arg @@ -380,13 +387,6 @@ class KpiMatrix(object): } -def _get_selection_label(selection, value): - for v, l in selection: - if v == value: - return l - return '' - - def _utc_midnight(d, tz_name, add_day=0): d = fields.Datetime.from_string(d) + datetime.timedelta(days=add_day) utc_tz = pytz.timezone('UTC') @@ -427,13 +427,13 @@ class MisReportKpi(models.Model): inverse='_inverse_expression') expression_ids = fields.One2many('mis.report.kpi.expression', 'kpi_id') auto_expand_accounts = fields.Boolean(string='Display details by account') - auto_expand_accounts_style = fields.Many2one( + auto_expand_accounts_style_id = fields.Many2one( string="Style for account detail rows", comodel_name="mis.report.style", required=False ) - style = fields.Many2one( - string="Row style", + style_id = fields.Many2one( + string="Style", comodel_name="mis.report.style", required=False ) @@ -445,18 +445,8 @@ class MisReportKpi(models.Model): ('pct', _('Percentage')), ('str', _('String'))], required=True, - string='Type', + string='Value type', default='num') - divider = fields.Selection([('1e-6', _('µ')), - ('1e-3', _('m')), - ('1', _('1')), - ('1e3', _('k')), - ('1e6', _('M'))], - string='Factor', - default='1') - dp = fields.Integer(string='Rounding', default=0) - prefix = fields.Char(size=16, string='Prefix') - suffix = fields.Char(size=16, string='Suffix') compare_method = fields.Selection([('diff', _('Difference')), ('pct', _('Percentage')), ('none', _('None'))], @@ -544,57 +534,57 @@ class MisReportKpi(models.Model): def _onchange_type(self): if self.type == 'num': self.compare_method = 'pct' - self.divider = '1' - self.dp = 0 elif self.type == 'pct': self.compare_method = 'diff' - self.divider = '1' - self.dp = 0 elif self.type == 'str': self.compare_method = 'none' - self.divider = '' - self.dp = 0 def get_expression_for_subkpi(self, subkpi): for expression in self.expression_ids: if expression.subkpi_id == subkpi: return expression.name - def render(self, lang, value): + @api.multi + def render(self, lang, style_props, value): """ render a KPI value as a unicode string, ready for display """ - assert len(self) == 1 - if value is None or value is AccountingNone: - return '' - elif self.type == 'num': - return self._render_num(lang, value, self.divider, - self.dp, self.prefix, self.suffix) + self.ensure_one() + style_obj = self.env['mis.report.style'] + if self.type == 'num': + return style_obj.render_num(lang, value, style_props.divider, + style_props.dp, + style_props.prefix, style_props.suffix) elif self.type == 'pct': - return self._render_num(lang, value, 0.01, - self.dp, '', '%') + return style_obj.render_pct(lang, value, style_props.dp) else: - return unicode(value) + return style_obj.render_str(lang, value) - def compare_and_render(self, lang, value, base_value, + @api.multi + def compare_and_render(self, lang, style_props, value, base_value, average_value=1, average_base_value=1): """ render the comparison of two KPI values, ready for display - Returns a tuple, with the numeric comparison and its string rendering. + Returns a triple, with + * the numeric comparison + * its string rendering + * the update style properties If the difference is 0, an empty string is returned. """ - assert len(self) == 1 + self.ensure_one() + style_obj = self.env['mis.report.style'] + delta = AccountingNone + style_r = style_props.copy() if value is None: value = AccountingNone if base_value is None: base_value = AccountingNone if self.type == 'pct': delta = value - base_value - if delta and round(delta, self.dp + 2) != 0: - return delta, self._render_num( - lang, - delta, - 0.01, self.dp, '', _('pp'), - sign='+') + if delta and round(delta, (style_props.dp or 0) + 2) != 0: + style_r.update(dict( + divider=0.01, prefix='', suffix=_('pp'))) + else: + delta = AccountingNone elif self.type == 'num': if value and average_value: value = value / float(average_value) @@ -602,43 +592,27 @@ class MisReportKpi(models.Model): base_value = base_value / float(average_base_value) if self.compare_method == 'diff': delta = value - base_value - if delta and round(delta, self.dp) != 0: - return delta, self._render_num( - lang, - delta, - self.divider, self.dp, self.prefix, self.suffix, - sign='+') + if delta and round(delta, style_props.dp or 0) != 0: + pass + else: + delta = AccountingNone elif self.compare_method == 'pct': - if base_value and round(base_value, self.dp) != 0: + if base_value and round(base_value, style_props.dp or 0) != 0: delta = (value - base_value) / abs(base_value) - if delta and round(delta, self.dp) != 0: - return delta, self._render_num( - lang, - delta, - 0.01, 1, '', '%', - sign='+') - else: - return AccountingNone, '' - return 0, '' - - def _render_num(self, lang, value, divider, - dp, prefix, suffix, sign='-'): - # format number following user language - value = round(value / float(divider or 1), dp) or 0 - value = lang.format( - '%%%s.%df' % (sign, dp), - value, - grouping=True) - value = value.replace('-', u'\N{NON-BREAKING HYPHEN}') - if prefix: - prefix = prefix + u'\N{NO-BREAK SPACE}' - else: - prefix = '' - if suffix: - suffix = u'\N{NO-BREAK SPACE}' + suffix + if delta and round(delta, 1) != 0: + style_r.update(dict( + divider=0.01, dp=1, prefix='', suffix='%')) + else: + delta = AccountingNone + if delta is not AccountingNone: + delta_r = style_obj.render_num( + lang, delta, + style_r.divider, style_r.dp, + style_r.prefix, style_r.suffix, + sign='+') + return delta, delta_r, style_r else: - suffix = '' - return prefix + value + suffix + return AccountingNone, '', style_r class MisReportSubkpi(models.Model): @@ -773,6 +747,8 @@ class MisReport(models.Model): string='Name', translate=True) description = fields.Char(required=False, string='Description', translate=True) + style_id = fields.Many2one(string="Style", + comodel_name="mis.report.style") query_ids = fields.One2many('mis.report.query', 'report_id', string='Queries', copy=True) diff --git a/mis_builder/models/mis_report_style.py b/mis_builder/models/mis_report_style.py index f478ba8b..14a3e7af 100644 --- a/mis_builder/models/mis_report_style.py +++ b/mis_builder/models/mis_report_style.py @@ -3,16 +3,45 @@ # © 2016 ACSONE SA/NV () # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). -from openerp import api, fields, models +from openerp import api, fields, models, _ +from openerp.exceptions import UserError + +from .accounting_none import AccountingNone + + +class PropertyDict(dict): + + def __getattr__(self, name): + return self.get(name) + + def copy(self): + return PropertyDict(self) + + +PROPS = [ + 'color', + 'background_color', + 'font_style', + 'font_weight', + 'font_size', + 'indent_level', + 'prefix', + 'suffix', + 'dp', + 'divider', +] class MisReportKpiStyle(models.Model): _name = 'mis.report.style' - @api.depends('indent_level') + @api.one + @api.constrains('indent_level') def check_positive_val(self): - return self.indent_level > 0 + if self.indent_level < 0: + raise UserError(_('Indent level must be greater than ' + 'or equal to 0')) _font_style_selection = [ ('normal', 'Normal'), @@ -25,7 +54,7 @@ class MisReportKpiStyle(models.Model): ] _font_size_selection = [ - ('medium', ''), + ('medium', 'medium'), ('xx-small', 'xx-small'), ('x-small', 'x-small'), ('small', 'small'), @@ -34,7 +63,7 @@ class MisReportKpiStyle(models.Model): ('xx-large', 'xx-large'), ] - _font_size_to_size = { + _font_size_to_xlsx_size = { 'medium': 11, 'xx-small': 5, 'x-small': 7, @@ -44,57 +73,129 @@ class MisReportKpiStyle(models.Model): 'xx-large': 17 } + # style name + # TODO enforce uniqueness name = fields.Char(string='Style name', required=True) + # color + color_inherit = fields.Boolean(default=True) color = fields.Char( string='Text color', help='Text color in valid RGB code (from #000000 to #FFFFFF)', ) + background_color_inherit = fields.Boolean(default=True) background_color = fields.Char( help='Background color in valid RGB code (from #000000 to #FFFFFF)' ) + # font + font_style_inherit = fields.Boolean(default=True) font_style = fields.Selection( selection=_font_style_selection, ) + font_weight_inherit = fields.Boolean(default=True) font_weight = fields.Selection( selection=_font_weight_selection ) + font_size_inherit = fields.Boolean(default=True) font_size = fields.Selection( selection=_font_size_selection ) + # indent + indent_level_inherit = fields.Boolean(default=True) indent_level = fields.Integer() + # number format + prefix_inherit = fields.Boolean(default=True) + prefix = fields.Char(size=16, string='Prefix') + suffix_inherit = fields.Boolean(default=True) + suffix = fields.Char(size=16, string='Suffix') + dp_inherit = fields.Boolean(default=True) + dp = fields.Integer(string='Rounding', default=0) + divider_inherit = fields.Boolean(default=True) + divider = fields.Selection([('1e-6', _('µ')), + ('1e-3', _('m')), + ('1', _('1')), + ('1e3', _('k')), + ('1e6', _('M'))], + string='Factor', + default='1') - @api.multi - def font_size_to_size(self): - self.ensure_one() - return self._font_size_to_size.get(self.font_size, 11) - - @api.multi - def to_xlsx_format_properties(self): - self.ensure_one() - props = { - 'italic': self.font_style == 'italic', - 'bold': self.font_weight == 'bold', - 'font_color': self.color, - 'bg_color': self.background_color, - 'indent': self.indent_level, - 'size': self.font_size_to_size() - } - return props - - @api.multi - def to_css_style(self): - self.ensure_one() - css_attributes = [ - ('font-style', self.font_style), - ('font-weight', self.font_weight), - ('font-size', self.font_size), - ('color', self.color), - ('background-color', self.background_color), - ('indent-level', self.indent_level) + @api.model + def merge(self, styles): + r = PropertyDict() + for style in styles: + if not style: + continue + if isinstance(style, dict): + r.update(style) + else: + for prop in PROPS: + inherit = getattr(style, prop + '_inherit', None) + if inherit is None: + value = getattr(style, prop) + if value: + r[prop] = value + elif not inherit: + value = getattr(style, prop) + r[prop] = value + return r + + @api.model + def render_num(self, lang, value, + divider=1.0, dp=0, prefix=None, suffix=None, sign='-'): + # format number following user language + if value is None or value is AccountingNone: + return u'' + value = round(value / float(divider or 1), dp or 0) or 0 + r = lang.format('%%%s.%df' % (sign, dp or 0), value, grouping=True) + r = r.replace('-', u'\N{NON-BREAKING HYPHEN}') + if prefix: + r = prefix + u'\N{NO-BREAK SPACE}' + r + if suffix: + r = r + u'\N{NO-BREAK SPACE}' + suffix + return r + + @api.model + def render_pct(self, lang, value, dp=1, sign='-'): + return self.render_num(lang, value, divider=0.01, + dp=dp, suffix='%', sign=sign) + + @api.model + def render_str(self, lang, value): + if value is None or value is AccountingNone: + return u'' + return unicode(value) + + @api.model + def to_xlsx_style(self, props): + num_format = '0.' + if props.dp: + num_format += '0' * props.dp + if props.prefix: + num_format = u'"{} "{}'.format(props.prefix, num_format) + if props.suffix: + num_format = u'{}" {}"'.format(num_format, props.suffix) + + xlsx_attributes = [ + ('italic', props.font_style == 'italic'), + ('bold', props.font_weight == 'bold'), + ('size', self._font_size_to_xlsx_size.get(props.font_size, 11)), + ('font_color', props.color), + ('bg_color', props.background_color), + ('indent', props.indent_level), + ('num_format', num_format), ] + return dict([a for a in xlsx_attributes + if a[1] is not None]) - css_list = [ - '%s:%s' % x for x in css_attributes if x[1] + @api.model + def to_css_style(self, props): + css_attributes = [ + ('font-style', props.font_style), + ('font-weight', props.font_weight), + ('font-size', props.font_size), + ('color', props.color), + ('background-color', props.background_color), + ('indent-level', props.indent_level) ] - return ';'.join(css_item for css_item in css_list) + return '; '.join(['%s: %s' % a for a in css_attributes + if a[1] is not None]) or None diff --git a/mis_builder/report/mis_report_instance_qweb.xml b/mis_builder/report/mis_report_instance_qweb.xml index a33ad383..5bcd02d4 100644 --- a/mis_builder/report/mis_report_instance_qweb.xml +++ b/mis_builder/report/mis_report_instance_qweb.xml @@ -17,11 +17,18 @@ + +