You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

1491 lines
56 KiB

9 years ago
9 years ago
9 years ago
11 years ago
9 years ago
11 years ago
  1. # -*- coding: utf-8 -*-
  2. # © 2014-2016 ACSONE SA/NV (<http://acsone.eu>)
  3. # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
  4. from collections import OrderedDict
  5. import datetime
  6. import dateutil
  7. from itertools import izip
  8. import logging
  9. import re
  10. import time
  11. import pytz
  12. from openerp import api, exceptions, fields, models, _
  13. from openerp.tools.safe_eval import safe_eval
  14. from .aep import AccountingExpressionProcessor as AEP
  15. from .aggregate import _sum, _avg, _min, _max
  16. from .accounting_none import AccountingNone
  17. from openerp.exceptions import UserError
  18. from .simple_array import SimpleArray
  19. from .mis_safe_eval import mis_safe_eval, DataError
  20. _logger = logging.getLogger(__name__)
  21. class AutoStruct(object):
  22. def __init__(self, **kwargs):
  23. for k, v in kwargs.items():
  24. setattr(self, k, v)
  25. class KpiMatrixRow(object):
  26. def __init__(self, kpi, account_id=None, parent_row=None):
  27. self.kpi = kpi
  28. self.account_id = account_id
  29. self.description = kpi.description
  30. self.comment = ''
  31. self.parent_row = parent_row
  32. @property
  33. def style(self):
  34. return self.kpi.style
  35. def iter_cell_tuples(self, cols):
  36. for col in cols:
  37. yield col.get_cell_tuple_for_row(self)
  38. def iter_cells(self, subcols):
  39. for subcol in subcols:
  40. yield subcol.get_cell_for_row(self)
  41. class KpiMatrixCol(object):
  42. def __init__(self, description, comment, locals_dict, subkpis):
  43. self.description = description
  44. self.comment = comment
  45. self.locals_dict = locals_dict
  46. self.colspan = subkpis and len(subkpis) or 1
  47. self._subcols = []
  48. if not subkpis:
  49. subcol = KpiMatrixSubCol(self, '', '', 0)
  50. self._subcols.append(subcol)
  51. else:
  52. for i, subkpi in enumerate(subkpis):
  53. subcol = KpiMatrixSubCol(self, subkpi.description, '', i)
  54. self._subcols.append(subcol)
  55. self._cell_tuples_by_row = {} # {row: (cells tuple)}
  56. def _set_cell_tuple(self, row, cell_tuple):
  57. self._cell_tuples_by_row[row] = cell_tuple
  58. def iter_subcols(self):
  59. return self._subcols
  60. def iter_cell_tuples(self):
  61. return self._cells_by_row.values()
  62. def get_cell_tuple_for_row(self, row):
  63. return self._cell_tuples_by_row.get(row)
  64. class KpiMatrixSubCol(object):
  65. def __init__(self, col, description, comment, index=0):
  66. self.col = col
  67. self.description = description
  68. self.comment = comment
  69. self.index = index
  70. def iter_cells(self):
  71. for cells in self.col.iter_cell_tuples():
  72. yield cells[self.index]
  73. def get_cell_for_row(self, row):
  74. cell_tuple = self.col.get_cell_tuple_for_row(row)
  75. return cell_tuple[self.index]
  76. class KpiMatrixCell(object):
  77. def __init__(self, row, subcol,
  78. val, val_rendered, val_comment,
  79. style=None, drilldown_key=None):
  80. self.row = row
  81. self.subcol = subcol
  82. self.val = val
  83. self.val_rendered = val_rendered
  84. self.val_comment = val_comment
  85. self.drilldown_key = None
  86. class KpiMatrix(object):
  87. def __init__(self, env):
  88. # cache language id for faster rendering
  89. lang = env.user.lang or 'en_US'
  90. self.lang = env['res.lang'].search([('code', '=', lang)])
  91. # data structures
  92. self._kpi_rows = OrderedDict() # { kpi: KpiMatrixRow }
  93. self._detail_rows = {} # { kpi: {account_id: KpiMatrixRow} }
  94. self._cols = OrderedDict() # { period_key: KpiMatrixCol }
  95. def declare_kpi(self, kpi):
  96. self._kpi_rows[kpi] = KpiMatrixRow(kpi)
  97. self._detail_rows[kpi] = {}
  98. def declare_period(self, period_key, description, comment,
  99. locals_dict, subkpis):
  100. self._cols[period_key] = KpiMatrixCol(description, comment,
  101. locals_dict, subkpis)
  102. def set_values(self, kpi, period_key, vals):
  103. self.set_values_detail_account(kpi, period_key, None, vals)
  104. def set_values_detail_account(self, kpi, period_key, account_id, vals):
  105. if not account_id:
  106. row = self._kpi_rows[kpi]
  107. else:
  108. kpi_row = self._kpi_rows[kpi]
  109. row = KpiMatrixRow(kpi, account_id, parent_row=kpi_row)
  110. self._detail_rows[kpi][account_id] = row
  111. col = self._cols[period_key]
  112. cell_tuple = []
  113. assert len(vals) == col.colspan
  114. for val, subcol in izip(vals, col.iter_subcols()):
  115. if isinstance(val, DataError):
  116. val_rendered = val.name
  117. val_comment = val.msg
  118. else:
  119. val_rendered = kpi.render(self.lang, val)
  120. val_comment = '' # TODO FIXME get subkpi expression
  121. # TODO style
  122. # TODO drilldown_key
  123. cell = KpiMatrixCell(row, subcol, val, val_rendered, val_comment)
  124. cell_tuple.append(cell)
  125. col._set_cell_tuple(row, cell_tuple)
  126. def iter_rows(self):
  127. for kpi_row in self._kpi_rows.values():
  128. yield kpi_row
  129. # TODO FIXME sort detail rows
  130. for detail_row in self._detail_rows[kpi_row.kpi].values():
  131. yield detail_row
  132. def iter_cols(self):
  133. return self._cols.values()
  134. def iter_subcols(self):
  135. for col in self.iter_cols():
  136. for subcol in col.iter_subcols():
  137. yield subcol
  138. class old_KpiMatrix(object):
  139. def __iter_kpis(self):
  140. """ Iterate kpis, including auto-expanded details by accounts
  141. It yields, in display order:
  142. * kpi technical name
  143. * kpi display name
  144. * kpi object
  145. """
  146. for kpi, account_ids in self._kpis.iteritems():
  147. yield kpi.name, kpi.description, kpi
  148. for account_id in sorted(account_ids, key=self.get_account_name):
  149. yield "%s:%s" % (kpi.name, account_id), \
  150. self.get_account_name(account_id), kpi
  151. def __get_exploded_account_ids(self):
  152. """ Get the list of auto-expanded account ids
  153. It returns the complete list, across all periods and kpis.
  154. This method must be called after setting all kpi values
  155. using set_kpi_vals and set_exploded_kpi_vals.
  156. """
  157. res = set()
  158. for kpi, account_ids in self._kpis.iteritems():
  159. res.update(account_ids)
  160. return list(res)
  161. def __load_account_names(self, account_obj):
  162. """ Load account names for all exploded account ids
  163. This method must be called after setting all kpi values
  164. using set_kpi_vals and set_exploded_kpi_vals, and before
  165. calling get_account_name().
  166. """
  167. account_data = account_obj.browse(self.get_exploded_account_ids())
  168. self._account_names_by_id = {a.id: u"{} {}".format(a.code, a.name)
  169. for a in account_data}
  170. def __get_account_name(self, account_id):
  171. """ Get account display name from it's id
  172. This method must be called after loading account names with
  173. load_account_names().
  174. """
  175. return self._account_names_by_id.get(account_id, account_id)
  176. def _get_selection_label(selection, value):
  177. for v, l in selection:
  178. if v == value:
  179. return l
  180. return ''
  181. def _utc_midnight(d, tz_name, add_day=0):
  182. d = fields.Datetime.from_string(d) + datetime.timedelta(days=add_day)
  183. utc_tz = pytz.timezone('UTC')
  184. context_tz = pytz.timezone(tz_name)
  185. local_timestamp = context_tz.localize(d, is_dst=False)
  186. return fields.Datetime.to_string(local_timestamp.astimezone(utc_tz))
  187. def _python_var(var_str):
  188. return re.sub(r'\W|^(?=\d)', '_', var_str).lower()
  189. def _is_valid_python_var(name):
  190. return re.match("[_A-Za-z][_a-zA-Z0-9]*$", name)
  191. class MisReportKpi(models.Model):
  192. """ A KPI is an element (ie a line) of a MIS report.
  193. In addition to a name and description, it has an expression
  194. to compute it based on queries defined in the MIS report.
  195. It also has various informations defining how to render it
  196. (numeric or percentage or a string, a prefix, a suffix, divider) and
  197. how to render comparison of two values of the KPI.
  198. KPI's have a sequence and are ordered inside the MIS report.
  199. """
  200. _name = 'mis.report.kpi'
  201. name = fields.Char(size=32, required=True,
  202. string='Name')
  203. description = fields.Char(required=True,
  204. string='Description',
  205. translate=True)
  206. multi = fields.Boolean()
  207. expression = fields.Char(
  208. compute='_compute_expression',
  209. inverse='_inverse_expression')
  210. expression_ids = fields.One2many('mis.report.kpi.expression', 'kpi_id')
  211. auto_expand_accounts = fields.Boolean(string='Display details by account')
  212. style = fields.Many2one(
  213. string="Default style for KPI",
  214. comodel_name="mis.report.kpi.style",
  215. required=False
  216. )
  217. style_expression = fields.Char(
  218. string='Style expression',
  219. help='An expression that returns a style name for the kpi style')
  220. type = fields.Selection([('num', _('Numeric')),
  221. ('pct', _('Percentage')),
  222. ('str', _('String'))],
  223. required=True,
  224. string='Type',
  225. default='num')
  226. divider = fields.Selection([('1e-6', _('µ')),
  227. ('1e-3', _('m')),
  228. ('1', _('1')),
  229. ('1e3', _('k')),
  230. ('1e6', _('M'))],
  231. string='Factor',
  232. default='1')
  233. dp = fields.Integer(string='Rounding', default=0)
  234. prefix = fields.Char(size=16, string='Prefix')
  235. suffix = fields.Char(size=16, string='Suffix')
  236. compare_method = fields.Selection([('diff', _('Difference')),
  237. ('pct', _('Percentage')),
  238. ('none', _('None'))],
  239. required=True,
  240. string='Comparison Method',
  241. default='pct')
  242. sequence = fields.Integer(string='Sequence', default=100)
  243. report_id = fields.Many2one('mis.report',
  244. string='Report',
  245. ondelete='cascade')
  246. _order = 'sequence, id'
  247. @api.one
  248. @api.constrains('name')
  249. def _check_name(self):
  250. if not _is_valid_python_var(self.name):
  251. raise exceptions.Warning(_('The name must be a valid '
  252. 'python identifier'))
  253. @api.onchange('name')
  254. def _onchange_name(self):
  255. if self.name and not _is_valid_python_var(self.name):
  256. return {
  257. 'warning': {
  258. 'title': 'Invalid name %s' % self.name,
  259. 'message': 'The name must be a valid python identifier'
  260. }
  261. }
  262. @api.multi
  263. def _compute_expression(self):
  264. for kpi in self:
  265. l = []
  266. for expression in kpi.expression_ids:
  267. if expression.subkpi_id:
  268. l.append('{}={}'.format(
  269. expression.subkpi_id.name, expression.name))
  270. else:
  271. l.append(
  272. expression.name or 'AccountingNone')
  273. kpi.expression = ',\n'.join(l)
  274. @api.multi
  275. def _inverse_expression(self):
  276. for kpi in self:
  277. if kpi.multi:
  278. raise UserError('Can not update a multi kpi from the kpi line')
  279. if kpi.expression_ids:
  280. kpi.expression_ids[0].write({
  281. 'name': kpi.expression,
  282. 'subkpi_id': None})
  283. for expression in kpi.expression_ids[1:]:
  284. expression.unlink()
  285. else:
  286. kpi.write({
  287. 'expression_ids': [(0, 0, {
  288. 'name': kpi.expression
  289. })]
  290. })
  291. @api.onchange('multi')
  292. def _onchange_multi(self):
  293. for kpi in self:
  294. if not kpi.multi:
  295. if kpi.expression_ids:
  296. kpi.expression = kpi.expression_ids[0].name
  297. else:
  298. kpi.expression = None
  299. else:
  300. expressions = []
  301. for subkpi in kpi.report_id.subkpi_ids:
  302. expressions.append((0, 0, {
  303. 'name': kpi.expression,
  304. 'subkpi_id': subkpi.id,
  305. }))
  306. kpi.expression_ids = expressions
  307. @api.onchange('description')
  308. def _onchange_description(self):
  309. """ construct name from description """
  310. if self.description and not self.name:
  311. self.name = _python_var(self.description)
  312. @api.onchange('type')
  313. def _onchange_type(self):
  314. if self.type == 'num':
  315. self.compare_method = 'pct'
  316. self.divider = '1'
  317. self.dp = 0
  318. elif self.type == 'pct':
  319. self.compare_method = 'diff'
  320. self.divider = '1'
  321. self.dp = 0
  322. elif self.type == 'str':
  323. self.compare_method = 'none'
  324. self.divider = ''
  325. self.dp = 0
  326. def render(self, lang, value):
  327. """ render a KPI value as a unicode string, ready for display """
  328. assert len(self) == 1
  329. if value is None or value is AccountingNone:
  330. return ''
  331. elif self.type == 'num':
  332. return self._render_num(lang, value, self.divider,
  333. self.dp, self.prefix, self.suffix)
  334. elif self.type == 'pct':
  335. return self._render_num(lang, value, 0.01,
  336. self.dp, '', '%')
  337. else:
  338. return unicode(value) # noqa - silence python3 error
  339. def render_comparison(self, lang, value, base_value,
  340. average_value, average_base_value):
  341. """ render the comparison of two KPI values, ready for display
  342. If the difference is 0, an empty string is returned.
  343. """
  344. assert len(self) == 1
  345. if value is None:
  346. value = AccountingNone
  347. if base_value is None:
  348. base_value = AccountingNone
  349. if self.type == 'pct':
  350. delta = value - base_value
  351. if delta and round(delta, self.dp) != 0:
  352. return self._render_num(
  353. lang,
  354. delta,
  355. 0.01, self.dp, '', _('pp'),
  356. sign='+')
  357. elif self.type == 'num':
  358. if value and average_value:
  359. value = value / float(average_value)
  360. if base_value and average_base_value:
  361. base_value = base_value / float(average_base_value)
  362. if self.compare_method == 'diff':
  363. delta = value - base_value
  364. if delta and round(delta, self.dp) != 0:
  365. return self._render_num(
  366. lang,
  367. delta,
  368. self.divider, self.dp, self.prefix, self.suffix,
  369. sign='+')
  370. elif self.compare_method == 'pct':
  371. if base_value and round(base_value, self.dp) != 0:
  372. delta = (value - base_value) / abs(base_value)
  373. if delta and round(delta, self.dp) != 0:
  374. return self._render_num(
  375. lang,
  376. delta,
  377. 0.01, self.dp, '', '%',
  378. sign='+')
  379. return ''
  380. def _render_num(self, lang, value, divider,
  381. dp, prefix, suffix, sign='-'):
  382. divider_label = _get_selection_label(
  383. self._columns['divider'].selection, divider)
  384. if divider_label == '1':
  385. divider_label = ''
  386. # format number following user language
  387. value = round(value / float(divider or 1), dp) or 0
  388. value = lang.format(
  389. '%%%s.%df' % (sign, dp),
  390. value,
  391. grouping=True)
  392. value = u'%s\N{NO-BREAK SPACE}%s\N{NO-BREAK SPACE}%s%s' % \
  393. (prefix or '', value, divider_label, suffix or '')
  394. value = value.replace('-', u'\N{NON-BREAKING HYPHEN}')
  395. return value
  396. class MisReportSubkpi(models.Model):
  397. _name = 'mis.report.subkpi'
  398. _order = 'sequence'
  399. sequence = fields.Integer()
  400. report_id = fields.Many2one('mis.report')
  401. name = fields.Char(size=32, required=True,
  402. string='Name')
  403. description = fields.Char(required=True,
  404. string='Description',
  405. translate=True)
  406. expression_ids = fields.One2many('mis.report.kpi.expression', 'subkpi_id')
  407. @api.one
  408. @api.constrains('name')
  409. def _check_name(self):
  410. if not _is_valid_python_var(self.name):
  411. raise exceptions.Warning(_('The name must be a valid '
  412. 'python identifier'))
  413. @api.onchange('name')
  414. def _onchange_name(self):
  415. if self.name and not _is_valid_python_var(self.name):
  416. return {
  417. 'warning': {
  418. 'title': 'Invalid name %s' % self.name,
  419. 'message': 'The name must be a valid python identifier'
  420. }
  421. }
  422. @api.onchange('description')
  423. def _onchange_description(self):
  424. """ construct name from description """
  425. if self.description and not self.name:
  426. self.name = _python_var(self.description)
  427. @api.multi
  428. def unlink(self):
  429. for subkpi in self:
  430. subkpi.expression_ids.unlink()
  431. return super(MisReportSubkpi, self).unlink()
  432. class MisReportKpiExpression(models.Model):
  433. """ A KPI Expression is an expression of a line of a MIS report Kpi.
  434. It's used to compute the kpi value.
  435. """
  436. _name = 'mis.report.kpi.expression'
  437. _order = 'sequence, name'
  438. sequence = fields.Integer(
  439. related='subkpi_id.sequence',
  440. store=True,
  441. readonly=True)
  442. name = fields.Char(string='Expression')
  443. kpi_id = fields.Many2one('mis.report.kpi')
  444. # TODO FIXME set readonly=True when onchange('subkpi_ids') below works
  445. subkpi_id = fields.Many2one(
  446. 'mis.report.subkpi',
  447. readonly=False)
  448. _sql_constraints = [
  449. ('subkpi_kpi_unique', 'unique(subkpi_id, kpi_id)',
  450. 'Sub KPI must be used once and only once for each KPI'),
  451. ]
  452. class MisReportQuery(models.Model):
  453. """ A query to fetch arbitrary data for a MIS report.
  454. A query works on a model and has a domain and list of fields to fetch.
  455. At runtime, the domain is expanded with a "and" on the date/datetime field.
  456. """
  457. _name = 'mis.report.query'
  458. @api.one
  459. @api.depends('field_ids')
  460. def _compute_field_names(self):
  461. field_names = [field.name for field in self.field_ids]
  462. self.field_names = ', '.join(field_names)
  463. name = fields.Char(size=32, required=True,
  464. string='Name')
  465. model_id = fields.Many2one('ir.model', required=True,
  466. string='Model')
  467. field_ids = fields.Many2many('ir.model.fields', required=True,
  468. string='Fields to fetch')
  469. field_names = fields.Char(compute='_compute_field_names',
  470. string='Fetched fields name')
  471. aggregate = fields.Selection([('sum', _('Sum')),
  472. ('avg', _('Average')),
  473. ('min', _('Min')),
  474. ('max', _('Max'))],
  475. string='Aggregate')
  476. date_field = fields.Many2one('ir.model.fields', required=True,
  477. string='Date field',
  478. domain=[('ttype', 'in',
  479. ('date', 'datetime'))])
  480. domain = fields.Char(string='Domain')
  481. report_id = fields.Many2one('mis.report', string='Report',
  482. ondelete='cascade')
  483. _order = 'name'
  484. @api.one
  485. @api.constrains('name')
  486. def _check_name(self):
  487. if not _is_valid_python_var(self.name):
  488. raise exceptions.Warning(_('The name must be a valid '
  489. 'python identifier'))
  490. class MisReport(models.Model):
  491. """ A MIS report template (without period information)
  492. The MIS report holds:
  493. * a list of explicit queries; the result of each query is
  494. stored in a variable with same name as a query, containing as list
  495. of data structures populated with attributes for each fields to fetch;
  496. when queries have an aggregate method and no fields to group, it returns
  497. a data structure with the aggregated fields
  498. * a list of KPI to be evaluated based on the variables resulting
  499. from the accounting data and queries (KPI expressions can references
  500. queries and accounting expression - see AccoutingExpressionProcessor)
  501. """
  502. _name = 'mis.report'
  503. name = fields.Char(required=True,
  504. string='Name', translate=True)
  505. description = fields.Char(required=False,
  506. string='Description', translate=True)
  507. query_ids = fields.One2many('mis.report.query', 'report_id',
  508. string='Queries',
  509. copy=True)
  510. kpi_ids = fields.One2many('mis.report.kpi', 'report_id',
  511. string='KPI\'s',
  512. copy=True)
  513. subkpi_ids = fields.One2many('mis.report.subkpi', 'report_id',
  514. string="Sub KPI",
  515. copy=True)
  516. @api.onchange('subkpi_ids')
  517. def _on_change_subkpi_ids(self):
  518. """ Update kpi expressions when subkpis change on the report,
  519. so the list of kpi expressions is always up-to-date """
  520. for kpi in self.kpi_ids:
  521. if not kpi.multi:
  522. continue
  523. new_subkpis = set([subkpi for subkpi in self.subkpi_ids])
  524. expressions = []
  525. for expression in kpi.expression_ids:
  526. assert expression.subkpi_id # must be true if kpi is multi
  527. if expression.subkpi_id not in self.subkpi_ids:
  528. expressions.append((2, expression.id, None)) # remove
  529. else:
  530. new_subkpis.remove(expression.subkpi_id) # no change
  531. for subkpi in new_subkpis:
  532. # TODO FIXME this does not work, while the remove above works
  533. expressions.append((0, None, {
  534. 'name': False,
  535. 'subkpi_id': subkpi.id,
  536. })) # add empty expressions for new subkpis
  537. if expressions:
  538. kpi.expressions_ids = expressions
  539. @api.multi
  540. def get_wizard_report_action(self):
  541. action = self.env.ref('mis_builder.mis_report_instance_view_action')
  542. res = action.read()[0]
  543. view = self.env.ref('mis_builder.wizard_mis_report_instance_view_form')
  544. res.update({
  545. 'view_id': view.id,
  546. 'views': [(view.id, 'form')],
  547. 'target': 'new',
  548. 'context': {
  549. 'default_report_id': self.id,
  550. 'default_name': self.name,
  551. 'default_temporary': True,
  552. }
  553. })
  554. return res
  555. @api.one
  556. def copy(self, default=None):
  557. default = dict(default or {})
  558. default['name'] = _('%s (copy)') % self.name
  559. return super(MisReport, self).copy(default)
  560. # TODO: kpi name cannot be start with query name
  561. @api.multi
  562. def _prepare_kpi_matrix(self):
  563. self.ensure_one()
  564. kpi_matrix = KpiMatrix(self.env)
  565. for kpi in self.kpi_ids:
  566. kpi_matrix.declare_kpi(kpi)
  567. return kpi_matrix
  568. @api.multi
  569. def _prepare_aep(self, company):
  570. self.ensure_one()
  571. aep = AEP(self.env)
  572. for kpi in self.kpi_ids:
  573. for expression in kpi.expression_ids:
  574. aep.parse_expr(expression.name)
  575. aep.done_parsing(company)
  576. return aep
  577. @api.multi
  578. def _fetch_queries(self, date_from, date_to,
  579. get_additional_query_filter=None):
  580. self.ensure_one()
  581. res = {}
  582. for query in self.query_ids:
  583. model = self.env[query.model_id.model]
  584. eval_context = {
  585. 'env': self.env,
  586. 'time': time,
  587. 'datetime': datetime,
  588. 'dateutil': dateutil,
  589. # deprecated
  590. 'uid': self.env.uid,
  591. 'context': self.env.context,
  592. }
  593. domain = query.domain and \
  594. safe_eval(query.domain, eval_context) or []
  595. if get_additional_query_filter:
  596. domain.extend(get_additional_query_filter(query))
  597. if query.date_field.ttype == 'date':
  598. domain.extend([(query.date_field.name, '>=', date_from),
  599. (query.date_field.name, '<=', date_to)])
  600. else:
  601. datetime_from = _utc_midnight(
  602. date_from, self._context.get('tz', 'UTC'))
  603. datetime_to = _utc_midnight(
  604. date_to, self._context.get('tz', 'UTC'), add_day=1)
  605. domain.extend([(query.date_field.name, '>=', datetime_from),
  606. (query.date_field.name, '<', datetime_to)])
  607. field_names = [f.name for f in query.field_ids]
  608. if not query.aggregate:
  609. data = model.search_read(domain, field_names)
  610. res[query.name] = [AutoStruct(**d) for d in data]
  611. elif query.aggregate == 'sum':
  612. data = model.read_group(
  613. domain, field_names, [])
  614. s = AutoStruct(count=data[0]['__count'])
  615. for field_name in field_names:
  616. v = data[0][field_name]
  617. setattr(s, field_name, v)
  618. res[query.name] = s
  619. else:
  620. data = model.search_read(domain, field_names)
  621. s = AutoStruct(count=len(data))
  622. if query.aggregate == 'min':
  623. agg = _min
  624. elif query.aggregate == 'max':
  625. agg = _max
  626. elif query.aggregate == 'avg':
  627. agg = _avg
  628. for field_name in field_names:
  629. setattr(s, field_name,
  630. agg([d[field_name] for d in data]))
  631. res[query.name] = s
  632. return res
  633. @api.multi
  634. def _compute_period(self, kpi_matrix,
  635. period_key, period_description, period_comment,
  636. aep,
  637. date_from, date_to,
  638. target_move,
  639. company,
  640. subkpis_filter=None,
  641. get_additional_move_line_filter=None,
  642. get_additional_query_filter=None):
  643. """ Evaluate a report for a given period, populating a KpiMatrix.
  644. :param kpi_matrix: the KpiMatrix object to be populated
  645. :param period_key: the period key to use when populating the KpiMatrix
  646. :param aep: an AccountingExpressionProcessor instance created
  647. using _prepare_aep()
  648. :param date_from, date_to: the starting and ending date
  649. :param target_move: all|posted
  650. :param company:
  651. :param get_additional_move_line_filter: a bound method that takes
  652. no arguments and returns
  653. a domain compatible with
  654. account.move.line
  655. :param get_additional_query_filter: a bound method that takes a single
  656. query argument and returns a
  657. domain compatible with the query
  658. underlying model
  659. """
  660. self.ensure_one()
  661. locals_dict = {
  662. 'sum': _sum,
  663. 'min': _min,
  664. 'max': _max,
  665. 'len': len,
  666. 'avg': _avg,
  667. 'AccountingNone': AccountingNone,
  668. 'SimpleArray': SimpleArray,
  669. }
  670. # fetch non-accounting queries
  671. locals_dict.update(self._fetch_queries(
  672. date_from, date_to, get_additional_query_filter))
  673. # use AEP to do the accounting queries
  674. additional_move_line_filter = None
  675. if get_additional_move_line_filter:
  676. additional_move_line_filter = get_additional_move_line_filter()
  677. aep.do_queries(company,
  678. date_from, date_to,
  679. target_move,
  680. additional_move_line_filter)
  681. if subkpis_filter:
  682. subkpis = [subkpi for subkpi in self.subkpi_ids
  683. if subkpi in subkpis_filter]
  684. else:
  685. subkpis = self.subkpi_ids
  686. kpi_matrix.declare_period(period_key,
  687. period_description, period_comment,
  688. locals_dict, subkpis)
  689. compute_queue = self.kpi_ids
  690. recompute_queue = []
  691. while True:
  692. for kpi in compute_queue:
  693. # build the list of expressions for this kpi
  694. expressions = []
  695. for expression in kpi.expression_ids:
  696. if expression.subkpi_id and \
  697. subkpis_filter and \
  698. expression.subkpi_id not in subkpis_filter:
  699. continue
  700. expressions.append(expression.name)
  701. vals = []
  702. try:
  703. for expression in expressions:
  704. replaced_expr = aep.replace_expr(expression)
  705. vals.append(
  706. mis_safe_eval(replaced_expr, locals_dict))
  707. except NameError:
  708. recompute_queue.append(kpi)
  709. break
  710. else:
  711. # no error, set it in locals_dict so it can be used
  712. # in computing other kpis
  713. if len(expressions) == 1:
  714. locals_dict[kpi.name] = vals[0]
  715. else:
  716. locals_dict[kpi.name] = SimpleArray(vals)
  717. kpi_matrix.set_values(kpi, period_key, vals)
  718. if not kpi.auto_expand_accounts:
  719. continue
  720. for account_id, replaced_exprs in \
  721. aep.replace_exprs_by_account_id(expressions):
  722. account_id_vals = []
  723. for replaced_expr in replaced_exprs:
  724. account_id_vals.append(
  725. mis_safe_eval(replaced_expr, locals_dict))
  726. kpi_matrix.set_values_detail_account(
  727. kpi, period_key, account_id, account_id_vals)
  728. if len(recompute_queue) == 0:
  729. # nothing to recompute, we are done
  730. break
  731. if len(recompute_queue) == len(compute_queue):
  732. # could not compute anything in this iteration
  733. # (ie real Name errors or cyclic dependency)
  734. # so we stop trying
  735. break
  736. # try again
  737. compute_queue = recompute_queue
  738. recompute_queue = []
  739. class MisReportInstancePeriod(models.Model):
  740. """ A MIS report instance has the logic to compute
  741. a report template for a given date period.
  742. Periods have a duration (day, week, fiscal period) and
  743. are defined as an offset relative to a pivot date.
  744. """
  745. @api.one
  746. @api.depends('report_instance_id.pivot_date', 'type', 'offset',
  747. 'duration', 'report_instance_id.comparison_mode')
  748. def _compute_dates(self):
  749. self.date_from = False
  750. self.date_to = False
  751. self.valid = False
  752. report = self.report_instance_id
  753. d = fields.Date.from_string(report.pivot_date)
  754. if not report.comparison_mode:
  755. self.date_from = report.date_from
  756. self.date_to = report.date_to
  757. self.valid = True
  758. elif self.mode == 'fix':
  759. self.date_from = self.manual_date_from
  760. self.date_to = self.manual_date_to
  761. self.valid = True
  762. elif self.type == 'd':
  763. date_from = d + datetime.timedelta(days=self.offset)
  764. date_to = date_from + \
  765. datetime.timedelta(days=self.duration - 1)
  766. self.date_from = fields.Date.to_string(date_from)
  767. self.date_to = fields.Date.to_string(date_to)
  768. self.valid = True
  769. elif self.type == 'w':
  770. date_from = d - datetime.timedelta(d.weekday())
  771. date_from = date_from + datetime.timedelta(days=self.offset * 7)
  772. date_to = date_from + \
  773. datetime.timedelta(days=(7 * self.duration) - 1)
  774. self.date_from = fields.Date.to_string(date_from)
  775. self.date_to = fields.Date.to_string(date_to)
  776. self.valid = True
  777. elif self.type == 'date_range':
  778. date_range_obj = self.env['date.range']
  779. current_periods = date_range_obj.search(
  780. [('type_id', '=', self.date_range_type_id.id),
  781. ('date_start', '<=', d),
  782. ('date_end', '>=', d),
  783. ('company_id', '=', self.report_instance_id.company_id.id)])
  784. if current_periods:
  785. all_periods = date_range_obj.search(
  786. [('type_id', '=', self.date_range_type_id.id),
  787. ('company_id', '=',
  788. self.report_instance_id.company_id.id)],
  789. order='date_start')
  790. all_period_ids = [p.id for p in all_periods]
  791. p = all_period_ids.index(current_periods[0].id) + self.offset
  792. if p >= 0 and p + self.duration <= len(all_period_ids):
  793. periods = all_periods[p:p + self.duration]
  794. self.date_from = periods[0].date_start
  795. self.date_to = periods[-1].date_end
  796. self.valid = True
  797. _name = 'mis.report.instance.period'
  798. name = fields.Char(size=32, required=True,
  799. string='Description', translate=True)
  800. mode = fields.Selection([('fix', 'Fix'),
  801. ('relative', 'Relative'),
  802. ], required=True,
  803. default='fix')
  804. type = fields.Selection([('d', _('Day')),
  805. ('w', _('Week')),
  806. ('date_range', _('Date Range'))
  807. ],
  808. string='Period type')
  809. date_range_type_id = fields.Many2one(
  810. comodel_name='date.range.type', string='Date Range Type')
  811. offset = fields.Integer(string='Offset',
  812. help='Offset from current period',
  813. default=-1)
  814. duration = fields.Integer(string='Duration',
  815. help='Number of periods',
  816. default=1)
  817. date_from = fields.Date(compute='_compute_dates', string="From")
  818. date_to = fields.Date(compute='_compute_dates', string="To")
  819. manual_date_from = fields.Date(string="From")
  820. manual_date_to = fields.Date(string="To")
  821. date_range_id = fields.Many2one(
  822. comodel_name='date.range',
  823. string='Date Range')
  824. valid = fields.Boolean(compute='_compute_dates',
  825. type='boolean',
  826. string='Valid')
  827. sequence = fields.Integer(string='Sequence', default=100)
  828. report_instance_id = fields.Many2one('mis.report.instance',
  829. string='Report Instance',
  830. ondelete='cascade')
  831. comparison_column_ids = fields.Many2many(
  832. comodel_name='mis.report.instance.period',
  833. relation='mis_report_instance_period_rel',
  834. column1='period_id',
  835. column2='compare_period_id',
  836. string='Compare with')
  837. normalize_factor = fields.Integer(
  838. string='Factor',
  839. help='Factor to use to normalize the period (used in comparison',
  840. default=1)
  841. subkpi_ids = fields.Many2many(
  842. 'mis.report.subkpi',
  843. string="Sub KPI Filter")
  844. _order = 'sequence, id'
  845. _sql_constraints = [
  846. ('duration', 'CHECK (duration>0)',
  847. 'Wrong duration, it must be positive!'),
  848. ('normalize_factor', 'CHECK (normalize_factor>0)',
  849. 'Wrong normalize factor, it must be positive!'),
  850. ('name_unique', 'unique(name, report_instance_id)',
  851. 'Period name should be unique by report'),
  852. ]
  853. @api.onchange('date_range_id')
  854. def onchange_date_range(self):
  855. for record in self:
  856. record.manual_date_from = record.date_range_id.date_start
  857. record.manual_date_to = record.date_range_id.date_end
  858. record.name = record.date_range_id.name
  859. @api.multi
  860. def _get_additional_move_line_filter(self):
  861. """ Prepare a filter to apply on all move lines
  862. This filter is applied with a AND operator on all
  863. accounting expression domains. This hook is intended
  864. to be inherited, and is useful to implement filtering
  865. on analytic dimensions or operational units.
  866. Returns an Odoo domain expression (a python list)
  867. compatible with account.move.line."""
  868. self.ensure_one()
  869. return []
  870. @api.multi
  871. def _get_additional_query_filter(self, query):
  872. """ Prepare an additional filter to apply on the query
  873. This filter is combined to the query domain with a AND
  874. operator. This hook is intended
  875. to be inherited, and is useful to implement filtering
  876. on analytic dimensions or operational units.
  877. Returns an Odoo domain expression (a python list)
  878. compatible with the model of the query."""
  879. self.ensure_one()
  880. return []
  881. @api.multi
  882. def drilldown(self, expr):
  883. self.ensure_one()
  884. # TODO FIXME: drilldown by account
  885. if AEP.has_account_var(expr):
  886. aep = AEP(self.env)
  887. aep.parse_expr(expr)
  888. aep.done_parsing(self.report_instance_id.company_id)
  889. domain = aep.get_aml_domain_for_expr(
  890. expr,
  891. self.date_from, self.date_to,
  892. self.report_instance_id.target_move,
  893. self.report_instance_id.company_id)
  894. domain.extend(self._get_additional_move_line_filter())
  895. return {
  896. 'name': expr + ' - ' + self.name,
  897. 'domain': domain,
  898. 'type': 'ir.actions.act_window',
  899. 'res_model': 'account.move.line',
  900. 'views': [[False, 'list'], [False, 'form']],
  901. 'view_type': 'list',
  902. 'view_mode': 'list',
  903. 'target': 'current',
  904. }
  905. else:
  906. return False
  907. @api.multi
  908. def _render_period(self, kpi_matrix, lang_id, aep):
  909. """ Compute and render a mis report instance period
  910. It returns a dictionary keyed on kpi.name with a list of dictionaries
  911. with the following values (one item in the list for each subkpi):
  912. * val: the evaluated kpi, or None if there is no data or an error
  913. * val_r: the rendered kpi as a string, or #ERR, #DIV
  914. * val_c: a comment (explaining the error, typically)
  915. * style: the css style of the kpi
  916. (may change in the future!)
  917. * prefix: a prefix to display in front of the rendered value
  918. * suffix: a prefix to display after rendered value
  919. * dp: the decimal precision of the kpi
  920. * is_percentage: true if the kpi is of percentage type
  921. (may change in the future!)
  922. * expr: the kpi expression
  923. * drilldown: true if the drilldown method of
  924. mis.report.instance.period is going to do something
  925. useful in this kpi
  926. """
  927. # TODO FIXME remove this method
  928. self.ensure_one()
  929. # first invoke the compute method on the mis report template
  930. # passing it all the information regarding period and filters
  931. self.report_instance_id.report_id._compute_period(
  932. kpi_matrix, self,
  933. aep,
  934. self.date_from, self.date_to,
  935. self.report_instance_id.target_move,
  936. self.report_instance_id.company_id,
  937. self.subkpi_ids,
  938. self._get_additional_move_line_filter,
  939. self._get_additional_query_filter,
  940. )
  941. # second, render it to something that can be used by the widget
  942. res = {}
  943. mis_report_kpi_style = self.env['mis.report.kpi.style']
  944. for kpi_name, kpi, vals in kpi_matrix.iter_kpi_vals(self):
  945. res[kpi_name] = []
  946. try:
  947. # TODO FIXME check style_expression evaluation wrt subkpis
  948. kpi_style = None
  949. if kpi.style_expression:
  950. style_name = safe_eval(kpi.style_expression,
  951. kpi_matrix.get_locals_dict(self))
  952. styles = mis_report_kpi_style.search(
  953. [('name', '=', style_name)])
  954. kpi_style = styles and styles[0]
  955. except:
  956. _logger.warning("error evaluating css stype expression %s",
  957. kpi.style, exc_info=True)
  958. default_vals = {
  959. 'prefix': kpi.prefix,
  960. 'suffix': kpi.suffix,
  961. 'dp': kpi.dp,
  962. 'is_percentage': kpi.type == 'pct',
  963. 'period_id': self.id,
  964. 'style': '',
  965. 'xlsx_style': {},
  966. }
  967. if kpi_style:
  968. default_vals.update({
  969. 'style': kpi_style.to_css_style(),
  970. 'xlsx_style': kpi_style.to_xlsx_forma_properties(),
  971. })
  972. for idx, subkpi_val in enumerate(vals):
  973. vals = default_vals.copy()
  974. if isinstance(subkpi_val, DataError):
  975. vals.update({
  976. 'val': subkpi_val.name,
  977. 'val_r': subkpi_val.name,
  978. 'val_c': subkpi_val.msg,
  979. 'drilldown': False,
  980. })
  981. else:
  982. if kpi.multi:
  983. expression = kpi.expression_ids[idx].name
  984. comment = '{}.{} = {}'.format(
  985. kpi.name,
  986. kpi.expression_ids[idx].subkpi_id.name,
  987. expression)
  988. else:
  989. expression = kpi.expression
  990. comment = '{} = {}'.format(
  991. kpi.name,
  992. expression)
  993. drilldown = (subkpi_val is not AccountingNone and
  994. AEP.has_account_var(expression))
  995. vals.update({
  996. 'val': (None
  997. if subkpi_val is AccountingNone
  998. else subkpi_val),
  999. 'val_r': kpi.render(lang_id, subkpi_val),
  1000. 'val_c': comment,
  1001. 'expr': expression,
  1002. 'drilldown': drilldown,
  1003. })
  1004. res[kpi_name].append(vals)
  1005. return res
  1006. class MisReportInstance(models.Model):
  1007. """The MIS report instance combines everything to compute
  1008. a MIS report template for a set of periods."""
  1009. @api.one
  1010. @api.depends('date')
  1011. def _compute_pivot_date(self):
  1012. if self.date:
  1013. self.pivot_date = self.date
  1014. else:
  1015. self.pivot_date = fields.Date.context_today(self)
  1016. @api.model
  1017. def _default_company(self):
  1018. return self.env['res.company'].\
  1019. _company_default_get('mis.report.instance')
  1020. _name = 'mis.report.instance'
  1021. name = fields.Char(required=True,
  1022. string='Name', translate=True)
  1023. description = fields.Char(related='report_id.description',
  1024. readonly=True)
  1025. date = fields.Date(string='Base date',
  1026. help='Report base date '
  1027. '(leave empty to use current date)')
  1028. pivot_date = fields.Date(compute='_compute_pivot_date',
  1029. string="Pivot date")
  1030. report_id = fields.Many2one('mis.report',
  1031. required=True,
  1032. string='Report')
  1033. period_ids = fields.One2many('mis.report.instance.period',
  1034. 'report_instance_id',
  1035. required=True,
  1036. string='Periods',
  1037. copy=True)
  1038. target_move = fields.Selection([('posted', 'All Posted Entries'),
  1039. ('all', 'All Entries')],
  1040. string='Target Moves',
  1041. required=True,
  1042. default='posted')
  1043. company_id = fields.Many2one(comodel_name='res.company',
  1044. string='Company',
  1045. default=_default_company,
  1046. required=True)
  1047. landscape_pdf = fields.Boolean(string='Landscape PDF')
  1048. comparison_mode = fields.Boolean(
  1049. compute="_compute_comparison_mode",
  1050. inverse="_inverse_comparison_mode")
  1051. date_range_id = fields.Many2one(
  1052. comodel_name='date.range',
  1053. string='Date Range')
  1054. date_from = fields.Date(string="From")
  1055. date_to = fields.Date(string="To")
  1056. temporary = fields.Boolean(default=False)
  1057. @api.multi
  1058. def save_report(self):
  1059. self.ensure_one()
  1060. self.write({'temporary': False})
  1061. action = self.env.ref('mis_builder.mis_report_instance_view_action')
  1062. res = action.read()[0]
  1063. view = self.env.ref('mis_builder.mis_report_instance_view_form')
  1064. res.update({
  1065. 'views': [(view.id, 'form')],
  1066. 'res_id': self.id,
  1067. })
  1068. return res
  1069. @api.model
  1070. def _vacuum_report(self, hours=24):
  1071. clear_date = fields.Datetime.to_string(
  1072. datetime.datetime.now() - datetime.timedelta(hours=hours))
  1073. reports = self.search([
  1074. ('write_date', '<', clear_date),
  1075. ('temporary', '=', True),
  1076. ])
  1077. _logger.debug('Vacuum %s Temporary MIS Builder Report', len(reports))
  1078. return reports.unlink()
  1079. @api.one
  1080. def copy(self, default=None):
  1081. default = dict(default or {})
  1082. default['name'] = _('%s (copy)') % self.name
  1083. return super(MisReportInstance, self).copy(default)
  1084. def _format_date(self, lang_id, date):
  1085. # format date following user language
  1086. date_format = self.env['res.lang'].browse(lang_id).date_format
  1087. return datetime.datetime.strftime(
  1088. fields.Date.from_string(date), date_format)
  1089. @api.multi
  1090. @api.depends('date_from')
  1091. def _compute_comparison_mode(self):
  1092. for instance in self:
  1093. instance.comparison_mode = bool(instance.period_ids) and\
  1094. not bool(instance.date_from)
  1095. @api.multi
  1096. def _inverse_comparison_mode(self):
  1097. for record in self:
  1098. if not record.comparison_mode:
  1099. if not record.date_from:
  1100. record.date_from = datetime.now()
  1101. if not record.date_to:
  1102. record.date_to = datetime.now()
  1103. record.period_ids.unlink()
  1104. record.write({'period_ids': [
  1105. (0, 0, {
  1106. 'name': 'Default',
  1107. 'type': 'd',
  1108. })
  1109. ]})
  1110. else:
  1111. record.date_from = None
  1112. record.date_to = None
  1113. @api.onchange('date_range_id')
  1114. def onchange_date_range(self):
  1115. for record in self:
  1116. record.date_from = record.date_range_id.date_start
  1117. record.date_to = record.date_range_id.date_end
  1118. @api.multi
  1119. def preview(self):
  1120. assert len(self) == 1
  1121. view_id = self.env.ref('mis_builder.'
  1122. 'mis_report_instance_result_view_form')
  1123. return {
  1124. 'type': 'ir.actions.act_window',
  1125. 'res_model': 'mis.report.instance',
  1126. 'res_id': self.id,
  1127. 'view_mode': 'form',
  1128. 'view_type': 'form',
  1129. 'view_id': view_id.id,
  1130. 'target': 'current',
  1131. }
  1132. @api.multi
  1133. def print_pdf(self):
  1134. self.ensure_one()
  1135. return {
  1136. 'name': 'MIS report instance QWEB PDF report',
  1137. 'model': 'mis.report.instance',
  1138. 'type': 'ir.actions.report.xml',
  1139. 'report_name': 'mis_builder.report_mis_report_instance',
  1140. 'report_type': 'qweb-pdf',
  1141. 'context': self.env.context,
  1142. }
  1143. @api.multi
  1144. def export_xls(self):
  1145. self.ensure_one()
  1146. return {
  1147. 'name': 'MIS report instance XLSX report',
  1148. 'model': 'mis.report.instance',
  1149. 'type': 'ir.actions.report.xml',
  1150. 'report_name': 'mis.report.instance.xlsx',
  1151. 'report_type': 'xlsx',
  1152. 'context': self.env.context,
  1153. }
  1154. @api.multi
  1155. def display_settings(self):
  1156. assert len(self.ids) <= 1
  1157. view_id = self.env.ref('mis_builder.mis_report_instance_view_form')
  1158. return {
  1159. 'type': 'ir.actions.act_window',
  1160. 'res_model': 'mis.report.instance',
  1161. 'res_id': self.id if self.id else False,
  1162. 'view_mode': 'form',
  1163. 'view_type': 'form',
  1164. 'views': [(view_id.id, 'form')],
  1165. 'view_id': view_id.id,
  1166. 'target': 'current',
  1167. }
  1168. @api.multi
  1169. def compute(self):
  1170. self.ensure_one()
  1171. aep = self.report_id._prepare_aep(self.company_id)
  1172. kpi_matrix = self.report_id._prepare_kpi_matrix()
  1173. for period in self.period_ids:
  1174. self.report_id._compute_period(
  1175. kpi_matrix,
  1176. period.id,
  1177. 'period name', # TODO FIXME
  1178. 'period comment', # TODO FIXME
  1179. aep,
  1180. period.date_from,
  1181. period.date_to,
  1182. self.target_move,
  1183. self.company_id,
  1184. period.subkpi_ids,
  1185. period._get_additional_move_line_filter,
  1186. period._get_additional_query_filter)
  1187. header = [{'cols': []}, {'cols': []}]
  1188. for col in kpi_matrix.iter_cols():
  1189. header[0]['cols'].append({
  1190. 'description': col.description,
  1191. 'comment': col.comment,
  1192. 'colspan': col.colspan,
  1193. })
  1194. for subcol in col.iter_subcols():
  1195. header[1]['cols'].append({
  1196. 'description': subcol.description,
  1197. 'comment': subcol.comment,
  1198. 'colspan': 1,
  1199. })
  1200. content = []
  1201. for row in kpi_matrix.iter_rows():
  1202. row_data = {
  1203. 'row_id': id(row),
  1204. 'parent_row_id': row.parent_row and id(row.parent_row) or None,
  1205. 'description': row.description,
  1206. 'comment': row.comment,
  1207. 'style': row.style and row.style.to_css_style() or '',
  1208. 'cols': []
  1209. }
  1210. for cell in row.iter_cells(kpi_matrix.iter_subcols()):
  1211. row_data['cols'].append({
  1212. 'val': (cell.val
  1213. if cell.val is not AccountingNone else None),
  1214. 'val_r': cell.val_rendered,
  1215. 'val_c': cell.val_comment,
  1216. # TODO FIXME style
  1217. # TODO FIXME drilldown
  1218. })
  1219. content.append(row_data)
  1220. return {
  1221. 'header': header,
  1222. 'content': content,
  1223. }
  1224. @api.multi
  1225. def old_compute(self):
  1226. self.ensure_one()
  1227. aep = self.report_id._prepare_aep(self.company_id)
  1228. # fetch user language only once
  1229. # TODO: is this necessary?
  1230. lang = self.env.user.lang
  1231. if not lang:
  1232. lang = 'en_US'
  1233. lang_id = self.env['res.lang'].search([('code', '=', lang)]).id
  1234. # compute kpi values for each period
  1235. kpi_values_by_period_ids = {}
  1236. kpi_matrix = KpiMatrix(lang_id)
  1237. for period in self.period_ids:
  1238. if not period.valid:
  1239. continue
  1240. kpi_values = period._render_period(kpi_matrix, lang_id, aep)
  1241. kpi_values_by_period_ids[period.id] = kpi_values
  1242. kpi_matrix.load_account_names(self.env['account.account'])
  1243. # prepare header and content
  1244. header = [{
  1245. 'kpi_name': '',
  1246. 'cols': []
  1247. }, {
  1248. 'kpi_name': '',
  1249. 'cols': []
  1250. }]
  1251. content = []
  1252. rows_by_kpi_name = {}
  1253. for kpi_name, kpi_description, kpi in kpi_matrix.iter_kpis():
  1254. props = {
  1255. 'kpi_name': kpi_description,
  1256. 'cols': [],
  1257. 'default_style': '',
  1258. 'default_xlsx_style': {},
  1259. }
  1260. rows_by_kpi_name[kpi_name] = props
  1261. if kpi.style:
  1262. props.update({
  1263. 'default_style': kpi.style.to_css_style(),
  1264. 'default_xlsx_style': kpi.style.to_xlsx_format_properties()
  1265. })
  1266. content.append(rows_by_kpi_name[kpi_name])
  1267. # populate header and content
  1268. for period in self.period_ids:
  1269. if not period.valid:
  1270. continue
  1271. # add the column header
  1272. if period.duration > 1 or period.type in ('w', 'date_range'):
  1273. # from, to
  1274. date_from = self._format_date(lang_id, period.date_from)
  1275. date_to = self._format_date(lang_id, period.date_to)
  1276. header_date = _('from %s to %s') % (date_from, date_to)
  1277. else:
  1278. header_date = self._format_date(lang_id, period.date_from)
  1279. subkpis = period.subkpi_ids or \
  1280. period.report_instance_id.report_id.subkpi_ids
  1281. header[0]['cols'].append(dict(
  1282. name=period.name,
  1283. date=header_date,
  1284. colspan=len(subkpis) or 1,
  1285. ))
  1286. if subkpis:
  1287. for subkpi in subkpis:
  1288. header[1]['cols'].append(dict(
  1289. name=subkpi.description,
  1290. colspan=1,
  1291. ))
  1292. else:
  1293. header[1]['cols'].append(dict(
  1294. name="",
  1295. colspan=1,
  1296. ))
  1297. # add kpi values
  1298. kpi_values = kpi_values_by_period_ids[period.id]
  1299. for kpi_name in kpi_values:
  1300. rows_by_kpi_name[kpi_name]['cols'] += kpi_values[kpi_name]
  1301. # add comparison columns
  1302. for compare_col in period.comparison_column_ids:
  1303. compare_kpi_values = \
  1304. kpi_values_by_period_ids.get(compare_col.id)
  1305. if compare_kpi_values:
  1306. # add the comparison column header
  1307. header[0]['cols'].append(
  1308. dict(name=_('%s vs %s') % (period.name,
  1309. compare_col.name),
  1310. date=''))
  1311. # add comparison values
  1312. for kpi in self.report_id.kpi_ids:
  1313. rows_by_kpi_name[kpi.name]['cols'].append({
  1314. 'val_r': kpi.render_comparison(
  1315. lang_id,
  1316. kpi_values[kpi.name]['val'],
  1317. compare_kpi_values[kpi.name]['val'],
  1318. period.normalize_factor,
  1319. compare_col.normalize_factor)
  1320. })
  1321. return {
  1322. 'report_name': self.name,
  1323. 'header': header,
  1324. 'content': content,
  1325. }