Browse Source

Merge pull request #189 from oca-sorrento/9.0-mis_builder-sorrento

[9.0] mis_builder Sorrento
pull/197/head
Leonardo Pistone 8 years ago
parent
commit
568fbb6684
  1. 1
      .travis.yml
  2. 12
      account_financial_report_webkit/report/aged_partner_balance.py
  3. 5
      account_financial_report_webkit/report/common_partner_balance_reports.py
  4. 7
      account_financial_report_webkit/report/common_reports.py
  5. 4
      account_financial_report_webkit/report/open_invoices.py
  6. 4
      account_financial_report_webkit/report/partners_ledger.py
  7. 5
      account_financial_report_webkit/report/webkit_parser_header_fix.py
  8. 3
      account_financial_report_webkit_xls/report/general_ledger_xls.py
  9. 34
      account_financial_report_webkit_xls/report/open_invoices_xls.py
  10. 8
      account_financial_report_webkit_xls/report/partners_balance_xls.py
  11. 5
      account_financial_report_webkit_xls/report/trial_balance_xls.py
  12. 18
      account_journal_report_xls/report/nov_account_journal.py
  13. 83
      mis_builder/CHANGES.rst
  14. 19
      mis_builder/README.rst
  15. 26
      mis_builder/__init__.py
  16. 46
      mis_builder/__openerp__.py
  17. 18
      mis_builder/datas/ir_cron.xml
  18. 2
      mis_builder/migrations/8.0.0.2/pre-migration.py
  19. 30
      mis_builder/models/__init__.py
  20. 191
      mis_builder/models/accounting_none.py
  21. 460
      mis_builder/models/aep.py
  22. 26
      mis_builder/models/aggregate.py
  23. 15
      mis_builder/models/data_error.py
  24. 751
      mis_builder/models/mis_builder.py
  25. 1021
      mis_builder/models/mis_report.py
  26. 422
      mis_builder/models/mis_report_instance.py
  27. 276
      mis_builder/models/mis_report_style.py
  28. 33
      mis_builder/models/mis_safe_eval.py
  29. 131
      mis_builder/models/simple_array.py
  30. 34
      mis_builder/report/__init__.py
  31. 138
      mis_builder/report/mis_builder_xls.py
  32. 27
      mis_builder/report/mis_report_instance_qweb.py
  33. 89
      mis_builder/report/mis_report_instance_qweb.xml
  34. 140
      mis_builder/report/mis_report_instance_xlsx.py
  35. 15
      mis_builder/report/mis_report_instance_xlsx.xml
  36. 67
      mis_builder/report/report_mis_report_instance.py
  37. 55
      mis_builder/report/report_mis_report_instance.xml
  38. 6
      mis_builder/security/ir.model.access.csv
  39. 19
      mis_builder/static/src/css/custom.css
  40. 45
      mis_builder/static/src/css/report.css
  41. 183
      mis_builder/static/src/js/mis_builder.js
  42. 108
      mis_builder/static/src/xml/mis_widget.xml
  43. 36
      mis_builder/tests/__init__.py
  44. 4
      mis_builder/tests/mis.report.csv
  45. 4
      mis_builder/tests/mis.report.instance.csv
  46. 4
      mis_builder/tests/mis.report.instance.period.csv
  47. 4
      mis_builder/tests/mis.report.kpi.csv
  48. 2
      mis_builder/tests/mis.report.query.csv
  49. 12
      mis_builder/tests/test_accounting_none.py
  50. 226
      mis_builder/tests/test_aep.py
  51. 12
      mis_builder/tests/test_aggregate.py
  52. 41
      mis_builder/tests/test_fetch_query.py
  53. 82
      mis_builder/tests/test_mis_builder.py
  54. 141
      mis_builder/tests/test_mis_report_instance.py
  55. 27
      mis_builder/tests/test_mis_safe_eval.py
  56. 158
      mis_builder/tests/test_render.py
  57. 12
      mis_builder/tests/test_simple_array.py
  58. 25
      mis_builder/tests/test_utc_midnight.py
  59. 211
      mis_builder/views/mis_builder.xml
  60. 170
      mis_builder/views/mis_report.xml
  61. 204
      mis_builder/views/mis_report_instance.xml
  62. 80
      mis_builder/views/mis_report_style.xml
  63. 26
      mis_builder/wizard/__init__.py
  64. 111
      mis_builder/wizard/mis_builder_dashboard.py
  65. 2
      mis_builder/wizard/mis_builder_dashboard.xml
  66. 2
      mis_builder_demo/__openerp__.py
  67. 2
      oca_dependencies.txt

1
.travis.yml

@ -2,6 +2,7 @@ sudo: false
cache: pip
addons:
postgresql: "9.2" # minimal postgresql version for the daterange method
apt:
sources:
- pov-wkhtmltopdf

12
account_financial_report_webkit/report/aged_partner_balance.py

@ -285,10 +285,14 @@ class AccountAgedTrialBalanceWebkit(PartnersOpenInvoicesWebkit):
:returns: delta in days
"""
sale_lines = [x for x in ledger_lines if x['jtype'] in REC_PAY_TYPE
and line['rec_id'] == x['rec_id']]
refund_lines = [x for x in ledger_lines if x['jtype'] in REFUND_TYPE
and line['rec_id'] == x['rec_id']]
sale_lines = [
x for x in ledger_lines if x['jtype'] in REC_PAY_TYPE and
line['rec_id'] == x['rec_id']
]
refund_lines = [
x for x in ledger_lines if x['jtype'] in REFUND_TYPE and
line['rec_id'] == x['rec_id']
]
if len(sale_lines) == 1:
reference_line = sale_lines[0]
elif len(refund_lines) == 1:

5
account_financial_report_webkit/report/common_partner_balance_reports.py

@ -312,7 +312,10 @@ class CommonPartnerBalanceReportHeaderWebkit(CommonBalanceReportHeaderWebkit,
for partner_id, partner_values in \
values['partners_amounts'].copy().iteritems():
base_partner_balance = partners_amounts_accounts[account.id][partner_id]['balance']\
partners_amounts_account =\
partners_amounts_accounts[account.id]
base_partner_balance =\
partners_amounts_account[partner_id]['balance']\
if partners_amounts_accounts.get(account.id)\
and partners_amounts_accounts.get(account.id)\
.get(partner_id) else 0.0

7
account_financial_report_webkit/report/common_reports.py

@ -130,9 +130,10 @@ class CommonReportHeaderWebkit(common_report_header):
def recursive_sort_by_code(accounts, parent):
sorted_accounts = []
# add all accounts with same parent
level_accounts = [account for account in accounts
if account['parent_id']
and account['parent_id'][0] == parent['id']]
level_accounts = [
account for account in accounts
if account['parent_id'] and
account['parent_id'][0] == parent['id']]
# add consolidation children of parent, as they are logically on
# the same level
if parent.get('child_consol_ids'):

4
account_financial_report_webkit/report/open_invoices.py

@ -164,8 +164,8 @@ class PartnersOpenInvoicesWebkit(report_sxw.rml_parse,
non_null_init_balances = dict([
(ib, amounts) for ib, amounts
in init_balance[account.id].iteritems()
if amounts['init_balance']
or amounts['init_balance_currency']])
if amounts['init_balance'] or
amounts['init_balance_currency']])
init_bal_lines_pids = non_null_init_balances.keys()
partners_order[account.id] = self._order_partners(

4
account_financial_report_webkit/report/partners_ledger.py

@ -165,8 +165,8 @@ class PartnersLedgerWebkit(report_sxw.rml_parse,
non_null_init_balances = dict(
[(ib, amounts) for ib, amounts
in init_balance[account.id].iteritems()
if amounts['init_balance']
or amounts['init_balance_currency']])
if amounts['init_balance'] or
amounts['init_balance_currency']])
init_bal_lines_pids = non_null_init_balances.keys()
else:
init_balance[account.id] = {}

5
account_financial_report_webkit/report/webkit_parser_header_fix.py

@ -27,6 +27,9 @@
# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
#
##############################################################################
from mako.template import Template
from mako.lookup import TemplateLookup
import os
import subprocess
import tempfile
@ -75,8 +78,6 @@ _logger = logging.getLogger('financial.reports.webkit')
# redefine mako_template as this is overriden by jinja since saas-1
# from openerp.addons.report_webkit.webkit_report import mako_template
from mako.template import Template
from mako.lookup import TemplateLookup
def mako_template(text):

3
account_financial_report_webkit_xls/report/general_ledger_xls.py

@ -200,8 +200,7 @@ class general_ledger_xls(report_xls):
display_initial_balance = _p['init_balance'][account.id] and \
(_p['init_balance'][account.id].get(
'debit', 0.0) != 0.0 or
_p['init_balance'][account.id].get('credit', 0.0)
!= 0.0)
_p['init_balance'][account.id].get('credit', 0.0) != 0.0)
display_ledger_lines = _p['ledger_lines'][account.id]
if _p.display_account_raw(data) == 'all' or \

34
account_financial_report_webkit_xls/report/open_invoices_xls.py

@ -260,8 +260,9 @@ class open_invoices_xls(report_xls):
partner_name):
if regroupmode == "regroup":
c_specs = [('acc_title', self.nbr_columns, 0, 'text',
' - '.join([account.code, account.name, partner_name
or _('No partner')])), ]
' - '.join([account.code,
account.name,
partner_name or _('No partner')])), ]
else:
c_specs = [
('acc_title', self.nbr_columns, 0, 'text', ' - '.
@ -332,10 +333,10 @@ class open_invoices_xls(report_xls):
else:
c_specs += [('datedue', 1, 0, 'text', None)]
c_specs += [
('debit', 1, 0, 'number', line.get('debit')
or 0.0, None, style_line_decimal),
('credit', 1, 0, 'number', line.get('credit')
or 0.0, None, style_line_decimal),
('debit', 1, 0, 'number', line.get('debit') or 0.0, None,
style_line_decimal),
('credit', 1, 0, 'number', line.get('credit') or 0.0, None,
style_line_decimal),
]
# determine the formula of the cumulated balance
@ -357,8 +358,9 @@ class open_invoices_xls(report_xls):
if _p.amount_currency(data):
if account.currency_id:
c_specs += [
('curramount', 1, 0, 'number', line.get('amount_currency')
or 0.0, None, style_line_decimal),
('curramount', 1, 0, 'number',
line.get('amount_currency') or 0.0, None,
style_line_decimal),
('currcode', 1, 0, 'text', line[
'currency_code'], None, style_line_right),
]
@ -428,18 +430,18 @@ class open_invoices_xls(report_xls):
else:
c_specs += [('datedue', 1, 0, 'text', None)]
c_specs += [
('debit', 1, 0, 'number', line.get('debit')
or 0.0, None, style_line_decimal),
('credit', 1, 0, 'number', line.get('credit')
or 0.0, None, style_line_decimal),
('debit', 1, 0, 'number', line.get('debit') or 0.0, None,
style_line_decimal),
('credit', 1, 0, 'number', line.get('credit') or 0.0, None,
style_line_decimal),
('cumul', 1, 0, 'number', None, cumul_balance, style_line_decimal),
]
if account.currency_id:
c_specs += [
('curramount', 1, 0, 'number', line.get('amount_currency')
or 0.0, None, style_line_decimal),
('currcode', 1, 0, 'text', line.get('currency_code')
or '', None, style_line_right),
('curramount', 1, 0, 'number',
line.get('amount_currency') or 0.0, None, style_line_decimal),
('currcode', 1, 0, 'text',
line.get('currency_code') or '', None, style_line_right),
]
else:
c_specs += [

8
account_financial_report_webkit_xls/report/partners_balance_xls.py

@ -137,9 +137,11 @@ class partners_balance_xls(report_xls):
('c', 2, 0, 'text', _('Comparison') + str(index + 1) +
' (C' + str(index + 1) + ')')]
if params['comparison_filter'] == 'filter_date':
c_specs += [('f', 2, 0, 'text', _('Dates Filter') + ': ' +
_p.formatLang(params['start'], date=True) + ' - '
+ _p.formatLang(params['stop'], date=True))]
c_specs += [
('f', 2, 0, 'text',
_('Dates Filter') + ': ' +
_p.formatLang(params['start'], date=True) + ' - ' +
_p.formatLang(params['stop'], date=True))]
elif params['comparison_filter'] == 'filter_period':
c_specs += [('f', 2, 0, 'text', _('Periods Filter') +
': ' + params['start'].name + ' - ' +

5
account_financial_report_webkit_xls/report/trial_balance_xls.py

@ -308,8 +308,9 @@ class trial_balance_xls(report_xls):
('diff', 1, 0, 'number', comp_account[
'diff'], None, cell_style_decimal),
('diff_percent', 1, 0, 'number', comp_account[
'percent_diff'] and comp_account['percent_diff']
or 0, None, cell_style_pct),
'percent_diff'] and
comp_account['percent_diff'] or 0, None,
cell_style_pct),
]
c_specs += [('type', 1, 0, 'text',

18
account_journal_report_xls/report/nov_account_journal.py

@ -166,8 +166,8 @@ class nov_journal_print(report_sxw.rml_parse):
"rc.symbol AS currency_symbol, "
"coalesce(ai.internal_number,'-') AS inv_number, "
"coalesce(abs.name,'-') AS st_number, "
"coalesce(av.number,'-') AS voucher_number "
+ select_extra +
"coalesce(av.number,'-') AS voucher_number " +
select_extra +
"FROM account_move_line l "
"INNER JOIN account_move am ON l.move_id = am.id "
"INNER JOIN account_account aa "
@ -192,11 +192,9 @@ class nov_journal_print(report_sxw.rml_parse):
"LEFT OUTER JOIN account_analytic_account ana "
"ON l.analytic_account_id = ana.id "
"LEFT OUTER JOIN res_currency rc "
"ON l.currency_id = rc.id "
+ join_extra +
"ON l.currency_id = rc.id " + join_extra +
"WHERE l.period_id IN %s AND l.journal_id = %s "
"AND am.state IN %s "
+ where_extra +
"AND am.state IN %s " + where_extra +
"ORDER BY " + self.sort_selection +
", move_date, move_id, acc_code",
(tuple(period_ids), journal_id,
@ -206,12 +204,12 @@ class nov_journal_print(report_sxw.rml_parse):
# add reference of corresponding origin document
if journal.type in ('sale', 'sale_refund', 'purchase',
'purchase_refund'):
[x.update({'docname': (_('Invoice') + ': ' + x['inv_number'])
or (_('Voucher') + ': ' + x['voucher_number']) or '-'})
[x.update({'docname': (_('Invoice') + ': ' + x['inv_number']) or
(_('Voucher') + ': ' + x['voucher_number']) or '-'})
for x in lines]
elif journal.type in ('bank', 'cash'):
[x.update({'docname': (_('Statement') + ': ' + x['st_number'])
or (_('Voucher') + ': ' + x['voucher_number']) or '-'})
[x.update({'docname': (_('Statement') + ': ' + x['st_number']) or
(_('Voucher') + ': ' + x['voucher_number']) or '-'})
for x in lines]
else:
code_string = j_obj._report_xls_document_extra(

83
mis_builder/CHANGES.rst

@ -0,0 +1,83 @@
Changelog
---------
.. Future (?)
.. ~~~~~~~~~~
..
.. *
9.0.2.0.0 (2016-??-??)
~~~~~~~~~~~~~~~~~~~~~~
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] hide button box in edit mode on the report instance settings form
* [FIX] Fix sum aggregation of non-stored fields (issue #178)
* [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
because it is inconsistent when working with prefixes; you need to add it
manually in the suffix
* [IMP] AccountingExpressionProcessor now supports 'balu' expressions
to obtain the unallocated profit/loss of previous fiscal years;
get_unallocated_pl is the corresponding convenience method
* [IMP] AccountingExpressionProcessor now has easy methods to obtain
balances by account: get_balances_initial, get_balances_end,
get_balances_variation
* [IMP] there is now an auto-expand feature to automatically display
a detail by account for selected kpis
* [IMP] the kpi and period lists are now manipulated through forms instead
of directly in the tree views
* [IMP] it is now possible to create a report through a wizard, such
reports are deemed temporary and available through a "Last Reports Generated"
menu, they are garbaged collected automatically, unless saved permanently,
which can be done using a Save button
* [IMP] there is now a beginner mode to configure simple reports with
only one period
* [IMP] it is now easier to configure periods with fixed start/end dates
* [IMP] the new sub-kpi mechanism allows the creation of columns
with multiple values, or columns with different values
* [IMP] thanks to the new style model, the Excel export is now styled
* [IMP] a new style model is now used to centralize style configuration
* [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
* [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,
consolidation accounts have been removed
8.0.1.0.0 (2016-04-27)
~~~~~~~~~~~~~~~~~~~~~~
* The copy of a MIS Report Instance now copies period.
https://github.com/OCA/account-financial-reporting/pull/181
* The copy of a MIS Report Template now copies KPIs and queries.
https://github.com/OCA/account-financial-reporting/pull/177
* Usability: the default view for MIS Report instances is now the rendered preview,
and the settings are accessible through a gear icon in the list view and
a button in the preview.
https://github.com/OCA/account-financial-reporting/pull/170
* Display blank cells instead of 0.0 when there is no data.
https://github.com/OCA/account-financial-reporting/pull/169
* Usability: better layout of the MIS Report periods settings on small screens.
https://github.com/OCA/account-financial-reporting/pull/167
* Include the download buttons inside the MIS Builder widget, and refactor
the widget to open the door to analytic filtering in the previews.
https://github.com/OCA/account-financial-reporting/pull/151
* Add KPI rendering prefixes (so you can print $ in front of the value).
https://github.com/OCA/account-financial-reporting/pull/158
* Add hooks for analytic filtering.
https://github.com/OCA/account-financial-reporting/pull/128
https://github.com/OCA/account-financial-reporting/pull/131
8.0.0.2.0
~~~~~~~~~
Pre-history. Or rather, you need to look at the git log.

19
mis_builder/README.rst

@ -63,12 +63,21 @@ analytic accounts.
Known issues / Roadmap
======================
* Add 'Fiscal Year' period type.
* V9 thoughts:
* select accounts by tag (see also select accounts by type below)
* how to handle multi-company consolidation now that consolidation children are gone?
* what replaces root accounts / account charts in v9? nothing it seems, so
we are limited to one chart of accounts per company;
* for multi-company consolidation, must we replace the consolidation chart
of account by a list of companies?
* Allow selecting accounts by type. This is currently possible by expressing
a query such as balp[][('account_id.user_type.code', '=', ...)]. This will work
but would be more efficient if one could write balp[user_type=...], as it would
involve much less queries to the database.
Possible syntax could be balp[code:60%,70%], balp[type:...], balp[tag:...],
with code: being optional and the default.
* More tests should be added. The first part is creating test data, then it will be
easier. At the minimum, We need the following test data:
@ -98,6 +107,14 @@ Contributors
* Stéphane Bidoul <stephane.bidoul@acsone.eu>
* Laetitia Gangloff <laetitia.gangloff@acsone.eu>
* Adrien Peiffer <adrien.peiffer@acsone.eu>
* Alexis de Lattre <alexis.delattre@akretion.com>
* Alexandre Fayolle <alexandre.fayolle@camptocamp.com>
* Jordi Ballester <jordi.ballester@eficent.com>
* Thomas Binsfeld <thomas.binsfeld@gmail.com>
* Giovanni Capalbo <giovanni@therp.nl>
* Marco Calcagni <mcalcagni@dinamicheaziendali.it>
* Sébastien Beau <sebastien.beau@akretion.com>
* Laurent Mignon <laurent.mignon@acsone.eu>
Maintainer
----------

26
mis_builder/__init__.py

@ -1,26 +1,6 @@
# -*- encoding: utf-8 -*-
##############################################################################
#
# mis_builder module for Odoo, Management Information System Builder
# Copyright (C) 2014-2015 ACSONE SA/NV (<http://acsone.eu>)
#
# This file is a part of mis_builder
#
# mis_builder is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License v3 or later
# as published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# mis_builder is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License v3 or later for more details.
#
# You should have received a copy of the GNU Affero General Public License
# v3 or later along with this program.
# If not, see <http://www.gnu.org/licenses/>.
#
##############################################################################
# -*- coding: utf-8 -*-
# © 2014-2015 ACSONE SA/NV (<http://acsone.eu>)
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
from . import models
from . import wizard

46
mis_builder/__openerp__.py

@ -1,30 +1,10 @@
# -*- encoding: utf-8 -*-
##############################################################################
#
# mis_builder module for Odoo, Management Information System Builder
# Copyright (C) 2014-2015 ACSONE SA/NV (<http://acsone.eu>)
#
# This file is a part of mis_builder
#
# mis_builder is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License v3 or later
# as published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# mis_builder is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License v3 or later for more details.
#
# You should have received a copy of the GNU Affero General Public License
# v3 or later along with this program.
# If not, see <http://www.gnu.org/licenses/>.
#
##############################################################################
# -*- coding: utf-8 -*-
# © 2014-2015 ACSONE SA/NV (<http://acsone.eu>)
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
{
'name': 'MIS Builder',
'version': '8.0.0.2.0',
'version': '9.0.2.0.0',
'category': 'Reporting',
'summary': """
Build 'Management Information System' Reports and Dashboards
@ -34,16 +14,21 @@
'website': 'http://acsone.eu',
'depends': [
'account',
'report_xls', # OCA/reporting-engine
'report_xlsx', # OCA/reporting-engine
'date_range', # OCA/server-tools
# TODO uncomment when https://github.com/OCA/web/pull/270 is merged
# 'web_widget_color', # OCA/web
],
'data': [
'wizard/mis_builder_dashboard.xml',
'views/mis_builder.xml',
'views/mis_report.xml',
'views/mis_report_instance.xml',
'views/mis_report_style.xml',
'datas/ir_cron.xml',
'security/ir.model.access.csv',
'security/mis_builder_security.xml',
'report/report_mis_report_instance.xml',
],
'test': [
'report/mis_report_instance_qweb.xml',
'report/mis_report_instance_xlsx.xml',
],
'demo': [
'tests/mis.report.kpi.csv',
@ -55,8 +40,7 @@
'qweb': [
'static/src/xml/*.xml'
],
'installable': False,
'installable': True,
'application': True,
'auto_install': False,
'license': 'AGPL-3',
}

18
mis_builder/datas/ir_cron.xml

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<openerp>
<data noupdate="1">
<record id="ir_cron_vacuum_temp_reports" model="ir.cron">
<field name="name">Vacuum temporary reports</field>
<field name="interval_number">4</field>
<field name="interval_type">hours</field>
<field name="numbercall">-1</field>
<field eval="False" name="doall"/>
<field eval="'mis.report.instance'" name="model"/>
<field eval="'_vacuum_report'" name="function"/>
<field eval="'(24,)'" name="args"/>
<field name="active" eval="True" />
</record>
</data>
</openerp>

2
mis_builder/migrations/8.0.0.2/pre-migration.py

@ -1,4 +1,4 @@
# -*- encoding: utf-8 -*-
# -*- coding: utf-8 -*-
##############################################################################
#
# mis_builder module for Odoo, Management Information System Builder

30
mis_builder/models/__init__.py

@ -1,26 +1,8 @@
# -*- encoding: utf-8 -*-
##############################################################################
#
# mis_builder module for Odoo, Management Information System Builder
# Copyright (C) 2014-2015 ACSONE SA/NV (<http://acsone.eu>)
#
# This file is a part of mis_builder
#
# mis_builder is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License v3 or later
# as published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# mis_builder is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License v3 or later for more details.
#
# You should have received a copy of the GNU Affero General Public License
# v3 or later along with this program.
# If not, see <http://www.gnu.org/licenses/>.
#
##############################################################################
# -*- coding: utf-8 -*-
# © 2014-2015 ACSONE SA/NV (<http://acsone.eu>)
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
from . import mis_builder
from . import mis_report
from . import mis_report_instance
from . import mis_report_style
from . import aep

191
mis_builder/models/accounting_none.py

@ -0,0 +1,191 @@
# -*- coding: utf-8 -*-
# © 2016 Thomas Binsfeld
# © 2016 ACSONE SA/NV (<http://acsone.eu>)
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
"""
Provides the AccountingNone singleton.
AccountingNone is a null value that dissolves in basic arithmetic operations,
as illustrated in the examples below. In comparisons, AccountingNone behaves
the same as zero.
>>> 1 + 1
2
>>> 1 + AccountingNone
1
>>> AccountingNone + 1
1
>>> AccountingNone + None
AccountingNone
>>> None + AccountingNone
AccountingNone
>>> +AccountingNone
AccountingNone
>>> -AccountingNone
AccountingNone
>>> -(AccountingNone)
AccountingNone
>>> AccountingNone - 1
-1
>>> 1 - AccountingNone
1
>>> abs(AccountingNone)
AccountingNone
>>> AccountingNone - None
AccountingNone
>>> None - AccountingNone
AccountingNone
>>> AccountingNone / 2
0.0
>>> 2 / AccountingNone
Traceback (most recent call last):
...
ZeroDivisionError
>>> AccountingNone / AccountingNone
AccountingNone
>>> AccountingNone // 2
0.0
>>> 2 // AccountingNone
Traceback (most recent call last):
...
ZeroDivisionError
>>> AccountingNone // AccountingNone
AccountingNone
>>> AccountingNone * 2
0.0
>>> 2 * AccountingNone
0.0
>>> AccountingNone * AccountingNone
AccountingNone
>>> AccountingNone * None
AccountingNone
>>> None * AccountingNone
AccountingNone
>>> str(AccountingNone)
''
>>> bool(AccountingNone)
False
>>> AccountingNone > 0
False
>>> AccountingNone < 0
False
>>> AccountingNone < 1
True
>>> AccountingNone > 1
False
>>> 0 < AccountingNone
False
>>> 0 > AccountingNone
False
>>> 1 < AccountingNone
False
>>> 1 > AccountingNone
True
>>> AccountingNone == 0
True
>>> AccountingNone == 0.0
True
>>> AccountingNone == None
True
"""
__all__ = ['AccountingNone']
class AccountingNoneType(object):
def __add__(self, other):
if other is None:
return AccountingNone
return other
__radd__ = __add__
def __sub__(self, other):
if other is None:
return AccountingNone
return -other
def __rsub__(self, other):
if other is None:
return AccountingNone
return other
def __iadd__(self, other):
if other is None:
return AccountingNone
return other
def __isub__(self, other):
if other is None:
return AccountingNone
return -other
def __abs__(self):
return self
def __pos__(self):
return self
def __neg__(self):
return self
def __div__(self, other):
if other is AccountingNone:
return AccountingNone
return 0.0
def __rdiv__(self, other):
raise ZeroDivisionError
def __floordiv__(self, other):
if other is AccountingNone:
return AccountingNone
return 0.0
def __rfloordiv__(self, other):
raise ZeroDivisionError
def __truediv__(self, other):
if other is AccountingNone:
return AccountingNone
return 0.0
def __rtruediv__(self, other):
raise ZeroDivisionError
def __mul__(self, other):
if other is None or other is AccountingNone:
return AccountingNone
return 0.0
__rmul__ = __mul__
def __repr__(self):
return 'AccountingNone'
def __str__(self):
return ''
def __nonzero__(self):
return False
def __bool__(self):
return False
def __eq__(self, other):
return other == 0 or other is None or other is AccountingNone
def __lt__(self, other):
return 0 < other
def __gt__(self, other):
return 0 > other
AccountingNone = AccountingNoneType()
if __name__ == '__main__':
import doctest
doctest.testmod()

460
mis_builder/models/aep.py

@ -1,38 +1,16 @@
# -*- encoding: utf-8 -*-
##############################################################################
#
# mis_builder module for Odoo, Management Information System Builder
# Copyright (C) 2014-2015 ACSONE SA/NV (<http://acsone.eu>)
#
# This file is a part of mis_builder
#
# mis_builder is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License v3 or later
# as published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# mis_builder is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License v3 or later for more details.
#
# You should have received a copy of the GNU Affero General Public License
# v3 or later along with this program.
# If not, see <http://www.gnu.org/licenses/>.
#
##############################################################################
# -*- coding: utf-8 -*-
# © 2014-2015 ACSONE SA/NV (<http://acsone.eu>)
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
import re
from collections import defaultdict
from itertools import izip
from openerp.exceptions import Warning
from openerp.osv import expression
from openerp import fields
from openerp.models import expression
from openerp.tools.safe_eval import safe_eval
from openerp.tools.translate import _
MODE_VARIATION = 'p'
MODE_INITIAL = 'i'
MODE_END = 'e'
from openerp.tools.float_utils import float_is_zero
from .accounting_none import AccountingNone
class AccountingExpressionProcessor(object):
@ -43,6 +21,9 @@ class AccountingExpressionProcessor(object):
* field is bal, crd, deb
* mode is i (initial balance), e (ending balance),
p (moves over period)
* there is also a special u mode (unallocated P&L) which computes
the sum from the beginning until the beginning of the fiscal year
of the period; it is only meaningful for P&L accounts
* accounts is a list of accounts, possibly containing % wildcards
* an optional domain on move lines allowing filters on eg analytic
accounts or journal
@ -76,66 +57,56 @@ class AccountingExpressionProcessor(object):
* additionally, one query per view/consolidation account is done to
discover the children accounts.
"""
ACC_RE = re.compile(r"(?P<field>\bbal|\bcrd|\bdeb)"
r"(?P<mode>[pise])?"
r"(?P<accounts>_[a-zA-Z0-9]+|\[.*?\])"
r"(?P<domain>\[.*?\])?")
def __init__(self, env):
self.env = env
MODE_VARIATION = 'p'
MODE_INITIAL = 'i'
MODE_END = 'e'
MODE_UNALLOCATED = 'u'
_ACC_RE = re.compile(r"(?P<field>\bbal|\bcrd|\bdeb)"
r"(?P<mode>[piseu])?"
r"(?P<accounts>_[a-zA-Z0-9]+|\[.*?\])"
r"(?P<domain>\[.*?\])?")
def __init__(self, company):
self.company = company
self.dp = company.currency_id.decimal_places
# before done_parsing: {(domain, mode): set(account_codes)}
# after done_parsing: {(domain, mode): list(account_ids)}
self._map_account_ids = defaultdict(set)
# {account_code: account_id} where account_code can be
# - None for all accounts
# - NNN% for a like
# - NNN for a code with an exact match
self._account_ids_by_code = defaultdict(set)
def _load_account_codes(self, account_codes, root_account):
account_model = self.env['account.account']
# TODO: account_obj is necessary because _get_children_and_consol
# does not work in new API?
account_obj = self.env.registry('account.account')
# smart ending balance (returns AccountingNone if there
# are no moves in period and 0 initial balance), implies
# a first query to get the initial balance and another
# to get the variation, so it's a bit slower
self.smart_end = True
def _load_account_codes(self, account_codes):
account_model = self.company.env['account.account']
exact_codes = set()
like_codes = set()
for account_code in account_codes:
if account_code in self._account_ids_by_code:
continue
if account_code is None:
# by convention the root account is keyed as
# None in _account_ids_by_code, so it is consistent
# with what _parse_match_object returns for an
# empty list of account codes, ie [None]
exact_codes.add(root_account.code)
# None means we want all accounts
account_ids = account_model.\
search([('company_id', '=', self.company.id)]).ids
self._account_ids_by_code[account_code].update(account_ids)
elif '%' in account_code:
like_codes.add(account_code)
account_ids = account_model.\
search([('code', '=like', account_code),
('company_id', '=', self.company.id)]).ids
self._account_ids_by_code[account_code].update(account_ids)
else:
# search exact codes after the loop to do less queries
exact_codes.add(account_code)
for account in account_model.\
search([('code', 'in', list(exact_codes)),
('parent_id', 'child_of', root_account.id)]):
if account.code == root_account.code:
code = None
else:
code = account.code
if account.type in ('view', 'consolidation'):
self._account_ids_by_code[code].update(
account_obj._get_children_and_consol(
self.env.cr, self.env.uid,
[account.id],
self.env.context))
else:
self._account_ids_by_code[code].add(account.id)
for like_code in like_codes:
for account in account_model.\
search([('code', 'like', like_code),
('parent_id', 'child_of', root_account.id)]):
if account.type in ('view', 'consolidation'):
self._account_ids_by_code[like_code].update(
account_obj._get_children_and_consol(
self.env.cr, self.env.uid,
[account.id],
self.env.context))
else:
self._account_ids_by_code[like_code].add(account.id)
('company_id', '=', self.company.id)]):
self._account_ids_by_code[account.code].add(account.id)
def _parse_match_object(self, mo):
"""Split a match object corresponding to an accounting variable
@ -144,9 +115,9 @@ class AccountingExpressionProcessor(object):
"""
field, mode, account_codes, domain = mo.groups()
if not mode:
mode = MODE_VARIATION
mode = self.MODE_VARIATION
elif mode == 's':
mode = MODE_END
mode = self.MODE_END
if account_codes.startswith('_'):
account_codes = account_codes[1:]
else:
@ -154,7 +125,7 @@ class AccountingExpressionProcessor(object):
if account_codes.strip():
account_codes = [a.strip() for a in account_codes.split(',')]
else:
account_codes = [None]
account_codes = [None] # None means we want all accounts
domain = domain or '[]'
domain = tuple(safe_eval(domain))
return field, mode, account_codes, domain
@ -166,16 +137,23 @@ class AccountingExpressionProcessor(object):
so when all expressions have been parsed, we know which
account codes to query for each domain and mode.
"""
for mo in self.ACC_RE.finditer(expr):
for mo in self._ACC_RE.finditer(expr):
_, mode, account_codes, domain = self._parse_match_object(mo)
key = (domain, mode)
self._map_account_ids[key].update(account_codes)
if mode == self.MODE_END and self.smart_end:
modes = (self.MODE_INITIAL, self.MODE_VARIATION, self.MODE_END)
else:
modes = (mode, )
for mode in modes:
key = (domain, mode)
self._map_account_ids[key].update(account_codes)
def done_parsing(self, root_account):
def done_parsing(self):
"""Load account codes and replace account codes by
account ids in map."""
for key, account_codes in self._map_account_ids.items():
self._load_account_codes(account_codes, root_account)
# TODO _load_account_codes could be done
# for all account_codes at once (also in v8)
self._load_account_codes(account_codes)
account_ids = set()
for account_code in account_codes:
account_ids.update(self._account_ids_by_code[account_code])
@ -184,12 +162,12 @@ class AccountingExpressionProcessor(object):
@classmethod
def has_account_var(cls, expr):
"""Test if an string contains an accounting variable."""
return bool(cls.ACC_RE.search(expr))
return bool(cls._ACC_RE.search(expr))
def get_aml_domain_for_expr(self, expr,
date_from, date_to,
period_from, period_to,
target_move):
target_move,
account_id=None):
""" Get a domain on account.move.line for an expression.
Prerequisite: done_parsing() must have been invoked.
@ -198,13 +176,20 @@ class AccountingExpressionProcessor(object):
"""
aml_domains = []
date_domain_by_mode = {}
for mo in self.ACC_RE.finditer(expr):
for mo in self._ACC_RE.finditer(expr):
field, mode, account_codes, domain = self._parse_match_object(mo)
aml_domain = list(domain)
account_ids = set()
for account_code in account_codes:
account_ids.update(self._account_ids_by_code[account_code])
aml_domain.append(('account_id', 'in', tuple(account_ids)))
if not account_id:
aml_domain.append(('account_id', 'in', tuple(account_ids)))
else:
# filter on account_id
if account_id in account_ids:
aml_domain.append(('account_id', '=', account_id))
else:
continue
if field == 'crd':
aml_domain.append(('credit', '>', 0))
elif field == 'deb':
@ -213,137 +198,63 @@ class AccountingExpressionProcessor(object):
if mode not in date_domain_by_mode:
date_domain_by_mode[mode] = \
self.get_aml_domain_for_dates(date_from, date_to,
period_from, period_to,
mode, target_move)
assert aml_domains
return expression.OR(aml_domains) + \
expression.OR(date_domain_by_mode.values())
def _period_has_moves(self, period):
move_model = self.env['account.move']
return bool(move_model.search([('period_id', '=', period.id)],
limit=1))
def _get_previous_opening_period(self, period, company_id):
period_model = self.env['account.period']
periods = period_model.search(
[('date_start', '<=', period.date_start),
('special', '=', True),
('company_id', '=', company_id)],
order="date_start desc",
limit=1)
return periods and periods[0]
def _get_previous_normal_period(self, period, company_id):
period_model = self.env['account.period']
periods = period_model.search(
[('date_start', '<', period.date_start),
('special', '=', False),
('company_id', '=', company_id)],
order="date_start desc",
limit=1)
return periods and periods[0]
def _get_first_normal_period(self, company_id):
period_model = self.env['account.period']
periods = period_model.search(
[('special', '=', False),
('company_id', '=', company_id)],
order="date_start asc",
limit=1)
return periods and periods[0]
def _get_period_ids_between(self, period_from, period_to, company_id):
period_model = self.env['account.period']
periods = period_model.search(
[('date_start', '>=', period_from.date_start),
('date_stop', '<=', period_to.date_stop),
('special', '=', False),
('company_id', '=', company_id)])
period_ids = [p.id for p in periods]
if period_from.special:
period_ids.append(period_from.id)
return period_ids
def _get_period_company_ids(self, period_from, period_to):
period_model = self.env['account.period']
periods = period_model.search(
[('date_start', '>=', period_from.date_start),
('date_stop', '<=', period_to.date_stop),
('special', '=', False)])
return set([p.company_id.id for p in periods])
def _get_period_ids_for_mode(self, period_from, period_to, mode):
assert not period_from.special
assert not period_to.special
assert period_from.company_id == period_to.company_id
assert period_from.date_start <= period_to.date_start
period_ids = []
for company_id in self._get_period_company_ids(period_from, period_to):
if mode == MODE_VARIATION:
period_ids.extend(self._get_period_ids_between(
period_from, period_to, company_id))
else:
if mode == MODE_INITIAL:
period_to = self._get_previous_normal_period(
period_from, company_id)
# look for opening period with moves
opening_period = self._get_previous_opening_period(
period_from, company_id)
if opening_period and \
self._period_has_moves(opening_period[0]):
# found opening period with moves
if opening_period.date_start == period_from.date_start and\
mode == MODE_INITIAL:
# if the opening period has the same start date as
# period_from, then we'll find the initial balance
# in the initial period and that's it
period_ids.append(opening_period[0].id)
continue
period_from = opening_period[0]
else:
# no opening period with moves,
# use very first normal period
period_from = self._get_first_normal_period(company_id)
if period_to:
period_ids.extend(self._get_period_ids_between(
period_from, period_to, company_id))
return period_ids
def get_aml_domain_for_dates(self, date_from, date_to,
period_from, period_to,
mode,
target_move):
if period_from and period_to:
period_ids = self._get_period_ids_for_mode(
period_from, period_to, mode)
domain = [('period_id', 'in', period_ids)]
else:
if mode == MODE_VARIATION:
domain = [('date', '>=', date_from), ('date', '<=', date_to)]
else:
raise Warning(_("Modes i and e are only applicable for "
"fiscal periods"))
if mode == self.MODE_VARIATION:
domain = [('date', '>=', date_from), ('date', '<=', date_to)]
elif mode in (self.MODE_INITIAL, self.MODE_END):
# for income and expense account, sum from the beginning
# of the current fiscal year only, for balance sheet accounts
# sum from the beginning of time
date_from_date = fields.Date.from_string(date_from)
fy_date_from = \
self.company.\
compute_fiscalyear_dates(date_from_date)['date_from']
domain = ['|',
('date', '>=', fields.Date.to_string(fy_date_from)),
('user_type_id.include_initial_balance', '=', True)]
if mode == self.MODE_INITIAL:
domain.append(('date', '<', date_from))
elif mode == self.MODE_END:
domain.append(('date', '<=', date_to))
elif mode == self.MODE_UNALLOCATED:
date_from_date = fields.Date.from_string(date_from)
fy_date_from = \
self.company.\
compute_fiscalyear_dates(date_from_date)['date_from']
domain = [('date', '<', fields.Date.to_string(fy_date_from)),
('user_type_id.include_initial_balance', '=', False)]
if target_move == 'posted':
domain.append(('move_id.state', '=', 'posted'))
return expression.normalize_domain(domain)
def do_queries(self, date_from, date_to, period_from, period_to,
target_move, additional_move_line_filter=None):
def do_queries(self, date_from, date_to,
target_move='posted', additional_move_line_filter=None):
"""Query sums of debit and credit for all accounts and domains
used in expressions.
This method must be executed after done_parsing().
"""
aml_model = self.env['account.move.line']
aml_model = self.company.env['account.move.line']
# {(domain, mode): {account_id: (debit, credit)}}
self._data = defaultdict(dict)
domain_by_mode = {}
ends = []
for key in self._map_account_ids:
domain, mode = key
if mode == self.MODE_END and self.smart_end:
# postpone computation of ending balance
ends.append((domain, mode))
continue
if mode not in domain_by_mode:
domain_by_mode[mode] = \
self.get_aml_domain_for_dates(date_from, date_to,
period_from, period_to,
mode, target_move)
domain = list(domain) + domain_by_mode[mode]
domain.append(('account_id', 'in', self._map_account_ids[key]))
@ -354,8 +265,26 @@ class AccountingExpressionProcessor(object):
['debit', 'credit', 'account_id'],
['account_id'])
for acc in accs:
self._data[key][acc['account_id'][0]] = \
(acc['debit'] or 0.0, acc['credit'] or 0.0)
debit = acc['debit'] or 0.0
credit = acc['credit'] or 0.0
if mode in (self.MODE_INITIAL, self.MODE_UNALLOCATED) and \
float_is_zero(debit-credit,
precision_rounding=self.dp):
# in initial mode, ignore accounts with 0 balance
continue
self._data[key][acc['account_id'][0]] = (debit, credit)
# compute ending balances by summing initial and variation
for key in ends:
domain, mode = key
initial_data = self._data[(domain, self.MODE_INITIAL)]
variation_data = self._data[(domain, self.MODE_VARIATION)]
account_ids = set(initial_data.keys()) | set(variation_data.keys())
for account_id in account_ids:
di, ci = initial_data.get(account_id,
(AccountingNone, AccountingNone))
dv, cv = variation_data.get(account_id,
(AccountingNone, AccountingNone))
self._data[key][account_id] = (di + dv, ci + cv)
def replace_expr(self, expr):
"""Replace accounting variables in an expression by their amount.
@ -368,17 +297,146 @@ class AccountingExpressionProcessor(object):
field, mode, account_codes, domain = self._parse_match_object(mo)
key = (domain, mode)
account_ids_data = self._data[key]
v = 0.0
v = AccountingNone
for account_code in account_codes:
account_ids = self._account_ids_by_code[account_code]
for account_id in account_ids:
debit, credit = \
account_ids_data.get(account_id, (0.0, 0.0))
account_ids_data.get(account_id,
(AccountingNone, AccountingNone))
if field == 'bal':
v += debit - credit
elif field == 'deb':
v += debit
elif field == 'crd':
v += credit
# in initial balance mode, assume 0 is None
# as it does not make sense to distinguish 0 from "no data"
if v is not AccountingNone and \
mode in (self.MODE_INITIAL, self.MODE_UNALLOCATED) and \
float_is_zero(v, precision_rounding=self.dp):
v = AccountingNone
return '(' + repr(v) + ')'
return self.ACC_RE.sub(f, expr)
return self._ACC_RE.sub(f, expr)
def replace_exprs_by_account_id(self, exprs):
"""Replace accounting variables in a list of expression
by their amount, iterating by accounts involved in the expression.
yields account_id, replaced_expr
This method must be executed after do_queries().
"""
def f(mo):
field, mode, account_codes, domain = self._parse_match_object(mo)
key = (domain, mode)
account_ids_data = self._data[key]
debit, credit = \
account_ids_data.get(account_id,
(AccountingNone, AccountingNone))
if field == 'bal':
v = debit - credit
elif field == 'deb':
v = debit
elif field == 'crd':
v = credit
# in initial balance mode, assume 0 is None
# as it does not make sense to distinguish 0 from "no data"
if v is not AccountingNone and \
mode in (self.MODE_INITIAL, self.MODE_UNALLOCATED) and \
float_is_zero(v, precision_rounding=self.dp):
v = AccountingNone
return '(' + repr(v) + ')'
account_ids = set()
for expr in exprs:
for mo in self._ACC_RE.finditer(expr):
field, mode, account_codes, domain = \
self._parse_match_object(mo)
key = (domain, mode)
account_ids_data = self._data[key]
for account_code in account_codes:
for account_id in self._account_ids_by_code[account_code]:
if account_id in account_ids_data:
account_ids.add(account_id)
for account_id in account_ids:
yield account_id, [self._ACC_RE.sub(f, expr) for expr in exprs]
@classmethod
def _get_balances(cls, mode, company, date_from, date_to,
target_move='posted'):
expr = 'deb{mode}[], crd{mode}[]'.format(mode=mode)
aep = AccountingExpressionProcessor(company)
# disable smart_end to have the data at once, instead
# of initial + variation
aep.smart_end = False
aep.parse_expr(expr)
aep.done_parsing()
aep.do_queries(date_from, date_to, target_move)
return aep._data[((), mode)]
@classmethod
def get_balances_initial(cls, company, date, target_move='posted'):
""" A convenience method to obtain the initial balances of all accounts
at a given date.
It is the same as get_balances_end(date-1).
:param company:
:param date:
:param target_move: if 'posted', consider only posted moves
Returns a dictionary: {account_id, (debit, credit)}
"""
return cls._get_balances(cls.MODE_INITIAL, company,
date, date, target_move)
@classmethod
def get_balances_end(cls, company, date, target_move='posted'):
""" A convenience method to obtain the ending balances of all accounts
at a given date.
It is the same as get_balances_initial(date+1).
:param company:
:param date:
:param target_move: if 'posted', consider only posted moves
Returns a dictionary: {account_id, (debit, credit)}
"""
return cls._get_balances(cls.MODE_END, company,
date, date, target_move)
@classmethod
def get_balances_variation(cls, company, date_from, date_to,
target_move='posted'):
""" A convenience method to obtain the variation of the
balances of all accounts over a period.
:param company:
:param date:
:param target_move: if 'posted', consider only posted moves
Returns a dictionary: {account_id, (debit, credit)}
"""
return cls._get_balances(cls.MODE_VARIATION, company,
date_from, date_to, target_move)
@classmethod
def get_unallocated_pl(cls, company, date, target_move='posted'):
""" A convenience method to obtain the unallocated profit/loss
of the previous fiscal years at a given date.
:param company:
:param date:
:param target_move: if 'posted', consider only posted moves
Returns a tuple (debit, credit)
"""
# TODO shoud we include here the accounts of type "unaffected"
# or leave that to the caller?
bals = cls._get_balances(cls.MODE_UNALLOCATED, company,
date, date, target_move)
return tuple(map(sum, izip(*bals.values())))

26
mis_builder/models/aggregate.py

@ -1,26 +1,6 @@
# -*- encoding: utf-8 -*-
##############################################################################
#
# mis_builder module for Odoo, Management Information System Builder
# Copyright (C) 2014-2015 ACSONE SA/NV (<http://acsone.eu>)
#
# This file is a part of mis_builder
#
# mis_builder is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License v3 or later
# as published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# mis_builder is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License v3 or later for more details.
#
# You should have received a copy of the GNU Affero General Public License
# v3 or later along with this program.
# If not, see <http://www.gnu.org/licenses/>.
#
##############################################################################
# -*- coding: utf-8 -*-
# © 2014-2015 ACSONE SA/NV (<http://acsone.eu>)
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
def _sum(l):

15
mis_builder/models/data_error.py

@ -0,0 +1,15 @@
# -*- coding: utf-8 -*-
# © 2016 ACSONE SA/NV (<http://acsone.eu>)
# © 2016 Akretion (<http://akretion.com>)
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
class DataError(Exception):
def __init__(self, name, msg):
self.name = name
self.msg = msg
class NameDataError(DataError):
pass

751
mis_builder/models/mis_builder.py

@ -1,751 +0,0 @@
# -*- encoding: utf-8 -*-
##############################################################################
#
# mis_builder module for Odoo, Management Information System Builder
# Copyright (C) 2014-2015 ACSONE SA/NV (<http://acsone.eu>)
#
# This file is a part of mis_builder
#
# mis_builder is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License v3 or later
# as published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# mis_builder is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License v3 or later for more details.
#
# You should have received a copy of the GNU Affero General Public License
# v3 or later along with this program.
# If not, see <http://www.gnu.org/licenses/>.
#
##############################################################################
import datetime
import dateutil
import logging
import re
import time
import traceback
import pytz
from openerp import api, fields, models, _, exceptions
from openerp.tools.safe_eval import safe_eval
from .aep import AccountingExpressionProcessor as AEP
from .aggregate import _sum, _avg, _min, _max
_logger = logging.getLogger(__name__)
class AutoStruct(object):
def __init__(self, **kwargs):
for k, v in kwargs.items():
setattr(self, k, v)
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')
context_tz = pytz.timezone(tz_name)
local_timestamp = context_tz.localize(d, is_dst=False)
return fields.Datetime.to_string(local_timestamp.astimezone(utc_tz))
def _python_var(var_str):
return re.sub(r'\W|^(?=\d)', '_', var_str).lower()
def _is_valid_python_var(name):
return re.match("[_A-Za-z][_a-zA-Z0-9]*$", name)
class MisReportKpi(models.Model):
""" A KPI is an element (ie a line) of a MIS report.
In addition to a name and description, it has an expression
to compute it based on queries defined in the MIS report.
It also has various informations defining how to render it
(numeric or percentage or a string, a suffix, divider) and
how to render comparison of two values of the KPI.
KPI's have a sequence and are ordered inside the MIS report.
"""
_name = 'mis.report.kpi'
name = fields.Char(size=32, required=True,
string='Name')
description = fields.Char(required=True,
string='Description',
translate=True)
expression = fields.Char(required=True,
string='Expression')
default_css_style = fields.Char(string='Default CSS style')
css_style = fields.Char(string='CSS style expression')
type = fields.Selection([('num', _('Numeric')),
('pct', _('Percentage')),
('str', _('String'))],
required=True,
string='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)
suffix = fields.Char(size=16, string='Suffix')
compare_method = fields.Selection([('diff', _('Difference')),
('pct', _('Percentage')),
('none', _('None'))],
required=True,
string='Comparison Method',
default='pct')
sequence = fields.Integer(string='Sequence', default=100)
report_id = fields.Many2one('mis.report',
string='Report',
ondelete='cascade')
_order = 'sequence, id'
@api.one
@api.constrains('name')
def _check_name(self):
if not _is_valid_python_var(self.name):
raise exception.Warning(_('The name must be a valid python identifier'))
@api.onchange('name')
def _onchange_name(self):
if self.name and not _is_valid_python_var(self.name):
return {
'warning': {
'title': 'Invalid name %s' % self.name,
'message': 'The name must be a valid python identifier'
}
}
@api.onchange('description')
def _onchange_description(self):
""" construct name from description """
if self.description and not self.name:
self.name = _python_var(self.description)
@api.onchange('type')
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 render(self, lang_id, value):
""" render a KPI value as a unicode string, ready for display """
assert len(self) == 1
if value is None:
return '#N/A'
elif self.type == 'num':
return self._render_num(lang_id, value, self.divider,
self.dp, self.suffix)
elif self.type == 'pct':
return self._render_num(lang_id, value, 0.01,
self.dp, '%')
else:
return unicode(value)
def render_comparison(self, lang_id, value, base_value,
average_value, average_base_value):
""" render the comparison of two KPI values, ready for display """
assert len(self) == 1
if value is None or base_value is None:
return ''
if self.type == 'pct':
return self._render_num(
lang_id,
value - base_value,
0.01, self.dp, _('pp'), sign='+')
elif self.type == 'num':
if average_value:
value = value / float(average_value)
if average_base_value:
base_value = base_value / float(average_base_value)
if self.compare_method == 'diff':
return self._render_num(
lang_id,
value - base_value,
self.divider, self.dp, self.suffix, sign='+')
elif self.compare_method == 'pct':
if round(base_value, self.dp) != 0:
return self._render_num(
lang_id,
(value - base_value) / abs(base_value),
0.01, self.dp, '%', sign='+')
return ''
def _render_num(self, lang_id, value, divider,
dp, suffix, sign='-'):
divider_label = _get_selection_label(
self._columns['divider'].selection, divider)
if divider_label == '1':
divider_label = ''
# format number following user language
value = round(value / float(divider or 1), dp) or 0
value = self.env['res.lang'].browse(lang_id).format(
'%%%s.%df' % (sign, dp),
value,
grouping=True)
value = u'%s\N{NO-BREAK SPACE}%s%s' % \
(value, divider_label, suffix or '')
value = value.replace('-', u'\N{NON-BREAKING HYPHEN}')
return value
class MisReportQuery(models.Model):
""" A query to fetch arbitrary data for a MIS report.
A query works on a model and has a domain and list of fields to fetch.
At runtime, the domain is expanded with a "and" on the date/datetime field.
"""
_name = 'mis.report.query'
@api.one
@api.depends('field_ids')
def _compute_field_names(self):
field_names = [field.name for field in self.field_ids]
self.field_names = ', '.join(field_names)
name = fields.Char(size=32, required=True,
string='Name')
model_id = fields.Many2one('ir.model', required=True,
string='Model')
field_ids = fields.Many2many('ir.model.fields', required=True,
string='Fields to fetch')
field_names = fields.Char(compute='_compute_field_names',
string='Fetched fields name')
aggregate = fields.Selection([('sum', _('Sum')),
('avg', _('Average')),
('min', _('Min')),
('max', _('Max'))],
string='Aggregate')
date_field = fields.Many2one('ir.model.fields', required=True,
string='Date field',
domain=[('ttype', 'in',
('date', 'datetime'))])
domain = fields.Char(string='Domain')
report_id = fields.Many2one('mis.report', string='Report',
ondelete='cascade')
_order = 'name'
@api.one
@api.constrains('name')
def _check_name(self):
if not _is_valid_python_var(self.name):
raise exception.Warning(_('The name must be a valid python identifier'))
class MisReport(models.Model):
""" A MIS report template (without period information)
The MIS report holds:
* a list of explicit queries; the result of each query is
stored in a variable with same name as a query, containing as list
of data structures populated with attributes for each fields to fetch;
when queries have an aggregate method and no fields to group, it returns
a data structure with the aggregated fields
* a list of KPI to be evaluated based on the variables resulting
from the accounting data and queries (KPI expressions can references
queries and accounting expression - see AccoutingExpressionProcessor)
"""
_name = 'mis.report'
name = fields.Char(required=True,
string='Name', translate=True)
description = fields.Char(required=False,
string='Description', translate=True)
query_ids = fields.One2many('mis.report.query', 'report_id',
string='Queries')
kpi_ids = fields.One2many('mis.report.kpi', 'report_id',
string='KPI\'s')
# TODO: kpi name cannot be start with query name
class MisReportInstancePeriod(models.Model):
""" A MIS report instance has the logic to compute
a report template for a given date period.
Periods have a duration (day, week, fiscal period) and
are defined as an offset relative to a pivot date.
"""
@api.one
@api.depends('report_instance_id.pivot_date', 'type', 'offset', 'duration')
def _compute_dates(self):
self.date_from = False
self.date_to = False
self.period_from = False
self.period_to = False
self.valid = False
d = fields.Date.from_string(self.report_instance_id.pivot_date)
if self.type == 'd':
date_from = d + datetime.timedelta(days=self.offset)
date_to = date_from + \
datetime.timedelta(days=self.duration - 1)
self.date_from = fields.Date.to_string(date_from)
self.date_to = fields.Date.to_string(date_to)
self.valid = True
elif self.type == 'w':
date_from = d - datetime.timedelta(d.weekday())
date_from = date_from + datetime.timedelta(days=self.offset * 7)
date_to = date_from + \
datetime.timedelta(days=(7 * self.duration) - 1)
self.date_from = fields.Date.to_string(date_from)
self.date_to = fields.Date.to_string(date_to)
self.valid = True
elif self.type == 'fp':
current_periods = self.env['account.period'].search(
[('special', '=', False),
('date_start', '<=', d),
('date_stop', '>=', d),
('company_id', '=',
self.report_instance_id.company_id.id)])
if current_periods:
all_periods = self.env['account.period'].search(
[('special', '=', False),
('company_id', '=',
self.report_instance_id.company_id.id)],
order='date_start')
all_period_ids = [p.id for p in all_periods]
p = all_period_ids.index(current_periods[0].id) + self.offset
if p >= 0 and p + self.duration <= len(all_period_ids):
periods = all_periods[p:p + self.duration]
self.date_from = periods[0].date_start
self.date_to = periods[-1].date_stop
self.period_from = periods[0]
self.period_to = periods[-1]
self.valid = True
_name = 'mis.report.instance.period'
name = fields.Char(size=32, required=True,
string='Description', translate=True)
type = fields.Selection([('d', _('Day')),
('w', _('Week')),
('fp', _('Fiscal Period')),
# ('fy', _('Fiscal Year'))
],
required=True,
string='Period type')
offset = fields.Integer(string='Offset',
help='Offset from current period',
default=-1)
duration = fields.Integer(string='Duration',
help='Number of periods',
default=1)
date_from = fields.Date(compute='_compute_dates', string="From")
date_to = fields.Date(compute='_compute_dates', string="To")
period_from = fields.Many2one(compute='_compute_dates',
comodel_name='account.period',
string="From period")
period_to = fields.Many2one(compute='_compute_dates',
comodel_name='account.period',
string="To period")
valid = fields.Boolean(compute='_compute_dates',
type='boolean',
string='Valid')
sequence = fields.Integer(string='Sequence', default=100)
report_instance_id = fields.Many2one('mis.report.instance',
string='Report Instance',
ondelete='cascade')
comparison_column_ids = fields.Many2many(
comodel_name='mis.report.instance.period',
relation='mis_report_instance_period_rel',
column1='period_id',
column2='compare_period_id',
string='Compare with')
normalize_factor = fields.Integer(
string='Factor',
help='Factor to use to normalize the period (used in comparison',
default=1)
_order = 'sequence, id'
_sql_constraints = [
('duration', 'CHECK (duration>0)',
'Wrong duration, it must be positive!'),
('normalize_factor', 'CHECK (normalize_factor>0)',
'Wrong normalize factor, it must be positive!'),
('name_unique', 'unique(name, report_instance_id)',
'Period name should be unique by report'),
]
@api.multi
def _get_additional_move_line_filter(self):
""" Prepare a filter to apply on all move lines
This filter is applied with a AND operator on all
accounting expression domains. This hook is intended
to be inherited, and is useful to implement filtering
on analytic dimensions or operational units.
Returns an Odoo domain expression (a python list)
compatible with account.move.line."""
self.ensure_one()
return []
@api.multi
def _get_additional_query_filter(self, query):
""" Prepare an additional filter to apply on the query
This filter is combined to the query domain with a AND
operator. This hook is intended
to be inherited, and is useful to implement filtering
on analytic dimensions or operational units.
Returns an Odoo domain expression (a python list)
compatible with the model of the query."""
self.ensure_one()
return []
@api.multi
def drilldown(self, expr):
assert len(self) == 1
if AEP.has_account_var(expr):
aep = AEP(self.env)
aep.parse_expr(expr)
aep.done_parsing(self.report_instance_id.root_account)
domain = aep.get_aml_domain_for_expr(
expr,
self.date_from, self.date_to,
self.period_from, self.period_to,
self.report_instance_id.target_move)
domain.extend(self._get_additional_move_line_filter())
return {
'name': expr + ' - ' + self.name,
'domain': domain,
'type': 'ir.actions.act_window',
'res_model': 'account.move.line',
'views': [[False, 'list'], [False, 'form']],
'view_type': 'list',
'view_mode': 'list',
'target': 'current',
}
else:
return False
def _fetch_queries(self):
assert len(self) == 1
res = {}
for query in self.report_instance_id.report_id.query_ids:
model = self.env[query.model_id.model]
eval_context = {
'env': self.env,
'time': time,
'datetime': datetime,
'dateutil': dateutil,
# deprecated
'uid': self.env.uid,
'context': self.env.context,
}
domain = query.domain and \
safe_eval(query.domain, eval_context) or []
domain.extend(self._get_additional_query_filter(query))
if query.date_field.ttype == 'date':
domain.extend([(query.date_field.name, '>=', self.date_from),
(query.date_field.name, '<=', self.date_to)])
else:
datetime_from = _utc_midnight(
self.date_from, self._context.get('tz', 'UTC'))
datetime_to = _utc_midnight(
self.date_to, self._context.get('tz', 'UTC'), add_day=1)
domain.extend([(query.date_field.name, '>=', datetime_from),
(query.date_field.name, '<', datetime_to)])
field_names = [f.name for f in query.field_ids]
if not query.aggregate:
data = model.search_read(domain, field_names)
res[query.name] = [AutoStruct(**d) for d in data]
elif query.aggregate == 'sum':
data = model.read_group(
domain, field_names, [])
s = AutoStruct(count=data[0]['__count'])
for field_name in field_names:
v = data[0][field_name]
setattr(s, field_name, v)
res[query.name] = s
else:
data = model.search_read(domain, field_names)
s = AutoStruct(count=len(data))
if query.aggregate == 'min':
agg = _min
elif query.aggregate == 'max':
agg = _max
elif query.aggregate == 'avg':
agg = _avg
for field_name in field_names:
setattr(s, field_name,
agg([d[field_name] for d in data]))
res[query.name] = s
return res
def _compute(self, lang_id, aep):
res = {}
localdict = {
'registry': self.pool,
'sum': _sum,
'min': _min,
'max': _max,
'len': len,
'avg': _avg,
}
localdict.update(self._fetch_queries())
aep.do_queries(self.date_from, self.date_to,
self.period_from, self.period_to,
self.report_instance_id.target_move,
self._get_additional_move_line_filter())
compute_queue = self.report_instance_id.report_id.kpi_ids
recompute_queue = []
while True:
for kpi in compute_queue:
try:
kpi_val_comment = kpi.name + " = " + kpi.expression
kpi_eval_expression = aep.replace_expr(kpi.expression)
kpi_val = safe_eval(kpi_eval_expression, localdict)
except ZeroDivisionError:
kpi_val = None
kpi_val_rendered = '#DIV/0'
kpi_val_comment += '\n\n%s' % (traceback.format_exc(),)
except (NameError, ValueError):
recompute_queue.append(kpi)
kpi_val = None
kpi_val_rendered = '#ERR'
kpi_val_comment += '\n\n%s' % (traceback.format_exc(),)
except:
kpi_val = None
kpi_val_rendered = '#ERR'
kpi_val_comment += '\n\n%s' % (traceback.format_exc(),)
else:
kpi_val_rendered = kpi.render(lang_id, kpi_val)
localdict[kpi.name] = kpi_val
try:
kpi_style = None
if kpi.css_style:
kpi_style = safe_eval(kpi.css_style, localdict)
except:
_logger.warning("error evaluating css stype expression %s",
kpi.css_style, exc_info=True)
kpi_style = None
drilldown = (kpi_val is not None and
AEP.has_account_var(kpi.expression))
res[kpi.name] = {
'val': kpi_val,
'val_r': kpi_val_rendered,
'val_c': kpi_val_comment,
'style': kpi_style,
'suffix': kpi.suffix,
'dp': kpi.dp,
'is_percentage': kpi.type == 'pct',
'period_id': self.id,
'expr': kpi.expression,
'drilldown': drilldown,
}
if len(recompute_queue) == 0:
# nothing to recompute, we are done
break
if len(recompute_queue) == len(compute_queue):
# could not compute anything in this iteration
# (ie real Value errors or cyclic dependency)
# so we stop trying
break
# try again
compute_queue = recompute_queue
recompute_queue = []
return res
class MisReportInstance(models.Model):
"""The MIS report instance combines everything to compute
a MIS report template for a set of periods."""
@api.one
@api.depends('date')
def _compute_pivot_date(self):
if self.date:
self.pivot_date = self.date
else:
self.pivot_date = fields.Date.context_today(self)
_name = 'mis.report.instance'
name = fields.Char(required=True,
string='Name', translate=True)
description = fields.Char(required=False,
string='Description', translate=True)
date = fields.Date(string='Base date',
help='Report base date '
'(leave empty to use current date)')
pivot_date = fields.Date(compute='_compute_pivot_date',
string="Pivot date")
report_id = fields.Many2one('mis.report',
required=True,
string='Report')
period_ids = fields.One2many('mis.report.instance.period',
'report_instance_id',
required=True,
string='Periods')
target_move = fields.Selection([('posted', 'All Posted Entries'),
('all', 'All Entries')],
string='Target Moves',
required=True,
default='posted')
company_id = fields.Many2one(comodel_name='res.company',
string='Company',
readonly=True,
related='root_account.company_id',
store=True)
root_account = fields.Many2one(comodel_name='account.account',
domain='[("parent_id", "=", False)]',
string="Account chart",
required=True)
landscape_pdf = fields.Boolean(string='Landscape PDF')
def _format_date(self, lang_id, date):
# format date following user language
date_format = self.env['res.lang'].browse(lang_id).date_format
return datetime.datetime.strftime(
fields.Date.from_string(date), date_format)
@api.multi
def preview(self):
assert len(self) == 1
view_id = self.env.ref('mis_builder.'
'mis_report_instance_result_view_form')
return {
'type': 'ir.actions.act_window',
'res_model': 'mis.report.instance',
'res_id': self.id,
'view_mode': 'form',
'view_type': 'form',
'view_id': view_id.id,
'target': 'new',
}
@api.multi
def compute(self):
assert len(self) == 1
# prepare AccountingExpressionProcessor
aep = AEP(self.env)
for kpi in self.report_id.kpi_ids:
aep.parse_expr(kpi.expression)
aep.done_parsing(self.root_account)
# fetch user language only once
# TODO: is this necessary?
lang = self.env.user.lang
if not lang:
lang = 'en_US'
lang_id = self.env['res.lang'].search([('code', '=', lang)]).id
# compute kpi values for each period
kpi_values_by_period_ids = {}
for period in self.period_ids:
if not period.valid:
continue
kpi_values = period._compute(lang_id, aep)
kpi_values_by_period_ids[period.id] = kpi_values
# prepare header and content
header = []
header.append({
'kpi_name': '',
'cols': []
})
content = []
rows_by_kpi_name = {}
for kpi in self.report_id.kpi_ids:
rows_by_kpi_name[kpi.name] = {
'kpi_name': kpi.description,
'cols': [],
'default_style': kpi.default_css_style
}
content.append(rows_by_kpi_name[kpi.name])
# populate header and content
for period in self.period_ids:
if not period.valid:
continue
# add the column header
if period.duration > 1 or period.type == 'w':
# from, to
if period.period_from and period.period_to:
date_from = period.period_from.name
date_to = period.period_to.name
else:
date_from = self._format_date(lang_id, period.date_from)
date_to = self._format_date(lang_id, period.date_to)
header_date = _('from %s to %s') % (date_from, date_to)
else:
# one period or one day
if period.period_from and period.period_to:
header_date = period.period_from.name
else:
header_date = self._format_date(lang_id, period.date_from)
header[0]['cols'].append(dict(name=period.name, date=header_date))
# add kpi values
kpi_values = kpi_values_by_period_ids[period.id]
for kpi_name in kpi_values:
rows_by_kpi_name[kpi_name]['cols'].append(kpi_values[kpi_name])
# add comparison columns
for compare_col in period.comparison_column_ids:
compare_kpi_values = \
kpi_values_by_period_ids.get(compare_col.id)
if compare_kpi_values:
# add the comparison column header
header[0]['cols'].append(
dict(name=_('%s vs %s') % (period.name,
compare_col.name),
date=''))
# add comparison values
for kpi in self.report_id.kpi_ids:
rows_by_kpi_name[kpi.name]['cols'].append({
'val_r': kpi.render_comparison(
lang_id,
kpi_values[kpi.name]['val'],
compare_kpi_values[kpi.name]['val'],
period.normalize_factor,
compare_col.normalize_factor)
})
return {'header': header,
'content': content}

1021
mis_builder/models/mis_report.py
File diff suppressed because it is too large
View File

422
mis_builder/models/mis_report_instance.py

@ -0,0 +1,422 @@
# -*- coding: utf-8 -*-
# © 2014-2016 ACSONE SA/NV (<http://acsone.eu>)
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
from openerp import api, fields, models, _
import datetime
import logging
from .aep import AccountingExpressionProcessor as AEP
_logger = logging.getLogger(__name__)
class MisReportInstancePeriod(models.Model):
""" A MIS report instance has the logic to compute
a report template for a given date period.
Periods have a duration (day, week, fiscal period) and
are defined as an offset relative to a pivot date.
"""
@api.multi
@api.depends('report_instance_id.pivot_date',
'report_instance_id.comparison_mode',
'type', 'offset', 'duration', 'mode')
def _compute_dates(self):
for record in self:
record.date_from = False
record.date_to = False
record.valid = False
report = record.report_instance_id
d = fields.Date.from_string(report.pivot_date)
if not report.comparison_mode:
record.date_from = report.date_from
record.date_to = report.date_to
record.valid = True
elif record.mode == 'fix':
record.date_from = record.manual_date_from
record.date_to = record.manual_date_to
record.valid = True
elif record.type == 'd':
date_from = d + datetime.timedelta(days=record.offset)
date_to = date_from + \
datetime.timedelta(days=record.duration - 1)
record.date_from = fields.Date.to_string(date_from)
record.date_to = fields.Date.to_string(date_to)
record.valid = True
elif record.type == 'w':
date_from = d - datetime.timedelta(d.weekday())
date_from = date_from + \
datetime.timedelta(days=record.offset * 7)
date_to = date_from + \
datetime.timedelta(days=(7 * record.duration) - 1)
record.date_from = fields.Date.to_string(date_from)
record.date_to = fields.Date.to_string(date_to)
record.valid = True
elif record.type == 'date_range':
date_range_obj = record.env['date.range']
current_periods = date_range_obj.search(
[('type_id', '=', record.date_range_type_id.id),
('date_start', '<=', d),
('date_end', '>=', d),
('company_id', '=',
record.report_instance_id.company_id.id)])
if current_periods:
all_periods = date_range_obj.search(
[('type_id', '=', record.date_range_type_id.id),
('company_id', '=',
record.report_instance_id.company_id.id)],
order='date_start')
all_period_ids = [p.id for p in all_periods]
p = all_period_ids.index(current_periods[0].id) + \
record.offset
if p >= 0 and p + record.duration <= len(all_period_ids):
periods = all_periods[p:p + record.duration]
record.date_from = periods[0].date_start
record.date_to = periods[-1].date_end
record.valid = True
_name = 'mis.report.instance.period'
name = fields.Char(size=32, required=True,
string='Description', translate=True)
mode = fields.Selection([('fix', 'Fix'),
('relative', 'Relative'),
], required=True,
default='fix')
type = fields.Selection([('d', _('Day')),
('w', _('Week')),
('date_range', _('Date Range'))
],
string='Period type')
date_range_type_id = fields.Many2one(
comodel_name='date.range.type', string='Date Range Type')
offset = fields.Integer(string='Offset',
help='Offset from current period',
default=-1)
duration = fields.Integer(string='Duration',
help='Number of periods',
default=1)
date_from = fields.Date(compute='_compute_dates', string="From")
date_to = fields.Date(compute='_compute_dates', string="To")
manual_date_from = fields.Date(string="From")
manual_date_to = fields.Date(string="To")
date_range_id = fields.Many2one(
comodel_name='date.range',
string='Date Range')
valid = fields.Boolean(compute='_compute_dates',
type='boolean',
string='Valid')
sequence = fields.Integer(string='Sequence', default=100)
report_instance_id = fields.Many2one('mis.report.instance',
string='Report Instance',
ondelete='cascade')
comparison_column_ids = fields.Many2many(
comodel_name='mis.report.instance.period',
relation='mis_report_instance_period_rel',
column1='period_id',
column2='compare_period_id',
string='Compare with')
normalize_factor = fields.Integer(
string='Factor',
help='Factor to use to normalize the period (used in comparison',
default=1)
subkpi_ids = fields.Many2many(
'mis.report.subkpi',
string="Sub KPI Filter")
_order = 'sequence, id'
_sql_constraints = [
('duration', 'CHECK (duration>0)',
'Wrong duration, it must be positive!'),
('normalize_factor', 'CHECK (normalize_factor>0)',
'Wrong normalize factor, it must be positive!'),
('name_unique', 'unique(name, report_instance_id)',
'Period name should be unique by report'),
]
@api.onchange('date_range_id')
def onchange_date_range(self):
for record in self:
record.manual_date_from = record.date_range_id.date_start
record.manual_date_to = record.date_range_id.date_end
record.name = record.date_range_id.name
@api.multi
def _get_additional_move_line_filter(self):
""" Prepare a filter to apply on all move lines
This filter is applied with a AND operator on all
accounting expression domains. This hook is intended
to be inherited, and is useful to implement filtering
on analytic dimensions or operational units.
Returns an Odoo domain expression (a python list)
compatible with account.move.line."""
self.ensure_one()
return []
@api.multi
def _get_additional_query_filter(self, query):
""" Prepare an additional filter to apply on the query
This filter is combined to the query domain with a AND
operator. This hook is intended
to be inherited, and is useful to implement filtering
on analytic dimensions or operational units.
Returns an Odoo domain expression (a python list)
compatible with the model of the query."""
self.ensure_one()
return []
class MisReportInstance(models.Model):
"""The MIS report instance combines everything to compute
a MIS report template for a set of periods."""
@api.depends('date')
def _compute_pivot_date(self):
for record in self:
if record.date:
record.pivot_date = record.date
else:
record.pivot_date = fields.Date.context_today(record)
@api.model
def _default_company(self):
return self.env['res.company'].\
_company_default_get('mis.report.instance')
_name = 'mis.report.instance'
name = fields.Char(required=True,
string='Name', translate=True)
description = fields.Char(related='report_id.description',
readonly=True)
date = fields.Date(string='Base date',
help='Report base date '
'(leave empty to use current date)')
pivot_date = fields.Date(compute='_compute_pivot_date',
string="Pivot date")
report_id = fields.Many2one('mis.report',
required=True,
string='Report')
period_ids = fields.One2many('mis.report.instance.period',
'report_instance_id',
required=True,
string='Periods',
copy=True)
target_move = fields.Selection([('posted', 'All Posted Entries'),
('all', 'All Entries')],
string='Target Moves',
required=True,
default='posted')
company_id = fields.Many2one(comodel_name='res.company',
string='Company',
default=_default_company,
required=True)
landscape_pdf = fields.Boolean(string='Landscape PDF')
comparison_mode = fields.Boolean(
compute="_compute_comparison_mode",
inverse="_inverse_comparison_mode")
date_range_id = fields.Many2one(
comodel_name='date.range',
string='Date Range')
date_from = fields.Date(string="From")
date_to = fields.Date(string="To")
temporary = fields.Boolean(default=False)
@api.multi
def save_report(self):
self.ensure_one()
self.write({'temporary': False})
action = self.env.ref('mis_builder.mis_report_instance_view_action')
res = action.read()[0]
view = self.env.ref('mis_builder.mis_report_instance_view_form')
res.update({
'views': [(view.id, 'form')],
'res_id': self.id,
})
return res
@api.model
def _vacuum_report(self, hours=24):
clear_date = fields.Datetime.to_string(
datetime.datetime.now() - datetime.timedelta(hours=hours))
reports = self.search([
('write_date', '<', clear_date),
('temporary', '=', True),
])
_logger.debug('Vacuum %s Temporary MIS Builder Report', len(reports))
return reports.unlink()
@api.multi
def copy(self, default=None):
self.ensure_one()
default = dict(default or {})
default['name'] = _('%s (copy)') % self.name
return super(MisReportInstance, self).copy(default)
def _format_date(self, date):
# format date following user language
lang_model = self.env['res.lang']
lang_id = lang_model._lang_get(self.env.user.lang)
date_format = lang_model.browse(lang_id).date_format
return datetime.datetime.strftime(
fields.Date.from_string(date), date_format)
@api.multi
@api.depends('date_from')
def _compute_comparison_mode(self):
for instance in self:
instance.comparison_mode = bool(instance.period_ids) and\
not bool(instance.date_from)
@api.multi
def _inverse_comparison_mode(self):
for record in self:
if not record.comparison_mode:
if not record.date_from:
record.date_from = datetime.now()
if not record.date_to:
record.date_to = datetime.now()
record.period_ids.unlink()
record.write({'period_ids': [
(0, 0, {
'name': 'Default',
'type': 'd',
})
]})
else:
record.date_from = None
record.date_to = None
@api.onchange('date_range_id')
def onchange_date_range(self):
for record in self:
record.date_from = record.date_range_id.date_start
record.date_to = record.date_range_id.date_end
@api.multi
def preview(self):
self.ensure_one()
view_id = self.env.ref('mis_builder.'
'mis_report_instance_result_view_form')
return {
'type': 'ir.actions.act_window',
'res_model': 'mis.report.instance',
'res_id': self.id,
'view_mode': 'form',
'view_type': 'form',
'view_id': view_id.id,
'target': 'current',
}
@api.multi
def print_pdf(self):
self.ensure_one()
return {
'name': 'MIS report instance QWEB PDF report',
'model': 'mis.report.instance',
'type': 'ir.actions.report.xml',
'report_name': 'mis_builder.report_mis_report_instance',
'report_type': 'qweb-pdf',
'context': self.env.context,
}
@api.multi
def export_xls(self):
self.ensure_one()
return {
'name': 'MIS report instance XLSX report',
'model': 'mis.report.instance',
'type': 'ir.actions.report.xml',
'report_name': 'mis.report.instance.xlsx',
'report_type': 'xlsx',
'context': self.env.context,
}
@api.multi
def display_settings(self):
assert len(self.ids) <= 1
view_id = self.env.ref('mis_builder.mis_report_instance_view_form')
return {
'type': 'ir.actions.act_window',
'res_model': 'mis.report.instance',
'res_id': self.id if self.id else False,
'view_mode': 'form',
'view_type': 'form',
'views': [(view_id.id, 'form')],
'view_id': view_id.id,
'target': 'current',
}
@api.multi
def _compute_matrix(self):
self.ensure_one()
aep = self.report_id.prepare_aep(self.company_id)
kpi_matrix = self.report_id.prepare_kpi_matrix()
for period in self.period_ids:
if period.date_from == period.date_to:
comment = self._format_date(period.date_from)
else:
date_from = self._format_date(period.date_from)
date_to = self._format_date(period.date_to)
comment = _('from %s to %s') % (date_from, date_to)
self.report_id.declare_and_compute_period(
kpi_matrix,
period.id,
period.name,
comment,
aep,
period.date_from,
period.date_to,
self.target_move,
self.company_id,
period.subkpi_ids,
period._get_additional_move_line_filter,
period._get_additional_query_filter)
for comparison_column in period.comparison_column_ids:
kpi_matrix.declare_comparison(period.id, comparison_column.id)
kpi_matrix.compute_comparisons()
return kpi_matrix
@api.multi
def compute(self):
self.ensure_one()
kpi_matrix = self._compute_matrix()
return kpi_matrix.as_dict()
@api.multi
def drilldown(self, arg):
self.ensure_one()
period_id = arg.get('period_id')
expr = arg.get('expr')
account_id = arg.get('account_id')
if period_id and expr and AEP.has_account_var(expr):
period = self.env['mis.report.instance.period'].browse(period_id)
aep = AEP(self.company_id)
aep.parse_expr(expr)
aep.done_parsing()
domain = aep.get_aml_domain_for_expr(
expr,
period.date_from, period.date_to,
self.target_move,
account_id)
domain.extend(period._get_additional_move_line_filter())
return {
'name': u'{} - {}'.format(expr, period.name),
'domain': domain,
'type': 'ir.actions.act_window',
'res_model': 'account.move.line',
'views': [[False, 'list'], [False, 'form']],
'view_type': 'list',
'view_mode': 'list',
'target': 'current',
}
else:
return False

276
mis_builder/models/mis_report_style.py

@ -0,0 +1,276 @@
# -*- coding: utf-8 -*-
# © 2016 Therp BV (<http://therp.nl>)
# © 2016 ACSONE SA/NV (<http://acsone.eu>)
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
from openerp import api, fields, models, _
from openerp.exceptions import UserError
from .accounting_none import AccountingNone
from .data_error import DataError
class PropertyDict(dict):
def __getattr__(self, name):
return self.get(name)
def copy(self): # pylint: disable=copy-wo-api-one,method-required-super
return PropertyDict(self)
PROPS = [
'color',
'background_color',
'font_style',
'font_weight',
'font_size',
'indent_level',
'prefix',
'suffix',
'dp',
'divider',
]
TYPE_NUM = 'num'
TYPE_PCT = 'pct'
TYPE_STR = 'str'
CMP_DIFF = 'diff'
CMP_PCT = 'pct'
CMP_NONE = 'none'
class MisReportKpiStyle(models.Model):
_name = 'mis.report.style'
@api.constrains('indent_level')
def check_positive_val(self):
for record in self:
if record.indent_level < 0:
raise UserError(_('Indent level must be greater than '
'or equal to 0'))
_font_style_selection = [
('normal', 'Normal'),
('italic', 'Italic'),
]
_font_weight_selection = [
('nornal', 'Normal'),
('bold', 'Bold'),
]
_font_size_selection = [
('medium', 'medium'),
('xx-small', 'xx-small'),
('x-small', 'x-small'),
('small', 'small'),
('large', 'large'),
('x-large', 'x-large'),
('xx-large', 'xx-large'),
]
_font_size_to_xlsx_size = {
'medium': 11,
'xx-small': 5,
'x-small': 7,
'small': 9,
'large': 13,
'x-large': 15,
'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)',
default='#000000',
)
background_color_inherit = fields.Boolean(default=True)
background_color = fields.Char(
help='Background color in valid RGB code (from #000000 to #FFFFFF)',
default='#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.model
def merge(self, styles):
""" Merge several styles, giving priority to the last.
Returns a PropertyDict of style properties.
"""
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(self, lang, style_props, type, value):
if type == 'num':
return self.render_num(lang, value, style_props.divider,
style_props.dp,
style_props.prefix, style_props.suffix)
elif type == 'pct':
return self.render_pct(lang, value, style_props.dp)
else:
return self.render_str(lang, value)
@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 compare_and_render(self, lang, style_props, type, compare_method,
value, base_value,
average_value=1, average_base_value=1):
delta = AccountingNone
style_r = style_props.copy()
if isinstance(value, DataError) or isinstance(base_value, DataError):
return AccountingNone, '', style_r
if value is None:
value = AccountingNone
if base_value is None:
base_value = AccountingNone
if type == TYPE_PCT:
delta = value - base_value
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 type == TYPE_NUM:
if value and average_value:
value = value / float(average_value)
if base_value and average_base_value:
base_value = base_value / float(average_base_value)
if compare_method == CMP_DIFF:
delta = value - base_value
if delta and round(delta, style_props.dp or 0) != 0:
pass
else:
delta = AccountingNone
elif compare_method == CMP_PCT:
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, 1) != 0:
style_r.update(dict(
divider=0.01, dp=1, prefix='', suffix='%'))
else:
delta = AccountingNone
if delta is not AccountingNone:
delta_r = self.render_num(
lang, delta,
style_r.divider, style_r.dp,
style_r.prefix, style_r.suffix,
sign='+')
return delta, delta_r, style_r
else:
return AccountingNone, '', style_r
@api.model
def to_xlsx_style(self, props):
num_format = '0'
if props.dp:
num_format += '.'
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])
@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(['%s: %s' % a for a in css_attributes
if a[1] is not None]) or None

33
mis_builder/models/mis_safe_eval.py

@ -0,0 +1,33 @@
# -*- coding: utf-8 -*-
# © 2016 ACSONE SA/NV (<http://acsone.eu>)
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
import traceback
from openerp.tools.safe_eval import test_expr, _SAFE_OPCODES, _BUILTINS
from .data_error import DataError, NameDataError
__all__ = ['mis_safe_eval']
def mis_safe_eval(expr, locals_dict):
""" Evaluate an expression using safe_eval
Returns the evaluated value or DataError.
Raises NameError if the evaluation depends on a variable that is not
present in local_dict.
"""
try:
c = test_expr(expr, _SAFE_OPCODES, mode='eval')
globals_dict = {'__builtins__': _BUILTINS}
val = eval(c, globals_dict, locals_dict) # pylint: disable=eval-used
except NameError:
val = NameDataError('#NAME', traceback.format_exc())
except ZeroDivisionError:
val = DataError('#DIV/0', traceback.format_exc())
except:
val = DataError('#ERR', traceback.format_exc())
return val

131
mis_builder/models/simple_array.py

@ -0,0 +1,131 @@
# -*- coding: utf-8 -*-
# © 2014-2015 ACSONE SA/NV (<http://acsone.eu>)
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
""" A trivial immutable array that supports basic arithmetic operations.
>>> a = SimpleArray((1.0, 2.0, 3.0))
>>> b = SimpleArray((4.0, 5.0, 6.0))
>>> t = (4.0, 5.0, 6.0)
>>> +a
SimpleArray((1.0, 2.0, 3.0))
>>> -a
SimpleArray((-1.0, -2.0, -3.0))
>>> a + b
SimpleArray((5.0, 7.0, 9.0))
>>> b + a
SimpleArray((5.0, 7.0, 9.0))
>>> a + t
SimpleArray((5.0, 7.0, 9.0))
>>> t + a
SimpleArray((5.0, 7.0, 9.0))
>>> a - b
SimpleArray((-3.0, -3.0, -3.0))
>>> a - t
SimpleArray((-3.0, -3.0, -3.0))
>>> t - a
SimpleArray((3.0, 3.0, 3.0))
>>> a * b
SimpleArray((4.0, 10.0, 18.0))
>>> b * a
SimpleArray((4.0, 10.0, 18.0))
>>> a * t
SimpleArray((4.0, 10.0, 18.0))
>>> t * a
SimpleArray((4.0, 10.0, 18.0))
>>> a / b
SimpleArray((0.25, 0.4, 0.5))
>>> b / a
SimpleArray((4.0, 2.5, 2.0))
>>> a / t
SimpleArray((0.25, 0.4, 0.5))
>>> t / a
SimpleArray((4.0, 2.5, 2.0))
>>> b / 2
SimpleArray((2.0, 2.5, 3.0))
>>> 2 * b
SimpleArray((8.0, 10.0, 12.0))
>>> b += 2 ; b
SimpleArray((6.0, 7.0, 8.0))
>>> a / ((1.0, 0.0, 1.0))
SimpleArray((1.0, DataError(), 3.0))
>>> a / 0.0
SimpleArray((DataError(), DataError(), DataError()))
"""
import operator
import traceback
from .data_error import DataError
__all__ = ['SimpleArray']
# TODO named tuple-like behaviour, so expressions can work on subkpis
class SimpleArray(tuple):
def _op(self, op, other):
def _o2(x, y):
try:
return op(x, y)
except ZeroDivisionError:
return DataError('#DIV/0', traceback.format_exc())
except:
return DataError('#ERR', traceback.format_exc())
if isinstance(other, tuple):
if len(other) != len(self):
raise TypeError("tuples must have same length for %s" % op)
return SimpleArray(map(_o2, self, other))
else:
return SimpleArray(map(lambda z: _o2(z, other), self))
def __add__(self, other):
return self._op(operator.add, other)
__radd__ = __add__
def __pos__(self):
return SimpleArray(map(operator.pos, self))
def __neg__(self):
return SimpleArray(map(operator.neg, self))
def __sub__(self, other):
return self._op(operator.sub, other)
def __rsub__(self, other):
return SimpleArray(other)._op(operator.sub, self)
def __mul__(self, other):
return self._op(operator.mul, other)
__rmul__ = __mul__
def __div__(self, other):
return self._op(operator.div, other)
def __floordiv__(self, other):
return self._op(operator.floordiv, other)
def __truediv__(self, other):
return self._op(operator.truediv, other)
def __rdiv__(self, other):
return SimpleArray(other)._op(operator.div, self)
def __rfloordiv__(self, other):
return SimpleArray(other)._op(operator.floordiv, self)
def __rtruediv__(self, other):
return SimpleArray(other)._op(operator.truediv, self)
def __repr__(self):
return "SimpleArray(%s)" % tuple.__repr__(self)
if __name__ == '__main__':
import doctest
doctest.testmod()

34
mis_builder/report/__init__.py

@ -1,30 +1,6 @@
# -*- encoding: utf-8 -*-
##############################################################################
#
# mis_builder module for Odoo, Management Information System Builder
# Copyright (C) 2014-2015 ACSONE SA/NV (<http://acsone.eu>)
#
# This file is a part of mis_builder
#
# mis_builder is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License v3 or later
# as published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# mis_builder is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License v3 or later for more details.
#
# You should have received a copy of the GNU Affero General Public License
# v3 or later along with this program.
# If not, see <http://www.gnu.org/licenses/>.
#
##############################################################################
# -*- coding: utf-8 -*-
# © 2014-2015 ACSONE SA/NV (<http://acsone.eu>)
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
try:
from . import mis_builder_xls
except ImportError:
pass # this module is not installed
from . import report_mis_report_instance
from . import mis_report_instance_qweb
from . import mis_report_instance_xlsx

138
mis_builder/report/mis_builder_xls.py

@ -1,138 +0,0 @@
# -*- encoding: utf-8 -*-
##############################################################################
#
# mis_builder module for Odoo, Management Information System Builder
# Copyright (C) 2014-2015 ACSONE SA/NV (<http://acsone.eu>)
#
# This file is a part of mis_builder
#
# mis_builder is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License v3 or later
# as published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# mis_builder is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License v3 or later for more details.
#
# You should have received a copy of the GNU Affero General Public License
# v3 or later along with this program.
# If not, see <http://www.gnu.org/licenses/>.
#
##############################################################################
import xlwt
from openerp.report import report_sxw
from openerp.addons.report_xls.report_xls import report_xls
import logging
_logger = logging.getLogger(__name__)
class mis_builder_xls_parser(report_sxw.rml_parse):
def __init__(self, cr, uid, name, context):
super(mis_builder_xls_parser, self).__init__(
cr, uid, name, context=context)
self.context = context
class mis_builder_xls(report_xls):
def __init__(self, name, table, rml=False, parser=False, header=True,
store=False):
super(mis_builder_xls, self).__init__(
name, table, rml, parser, header, store)
# Cell Styles
_xs = self.xls_styles
# header
rh_cell_format = _xs['bold'] + _xs['fill'] + \
_xs['borders_all'] + _xs['right']
self.rh_cell_style = xlwt.easyxf(rh_cell_format)
self.rh_cell_style_date = xlwt.easyxf(
rh_cell_format, num_format_str=report_xls.date_format)
# lines
self.mis_rh_cell_style = xlwt.easyxf(
_xs['borders_all'] + _xs['bold'] + _xs['fill'])
def generate_xls_report(self, _p, _xs, data, objects, wb):
report_name = objects[0].name
ws = wb.add_sheet(report_name[:31])
ws.panes_frozen = True
ws.remove_splits = True
ws.portrait = 0 # Landscape
ws.fit_width_to_pages = 1
row_pos = 0
# set print header/footer
ws.header_str = self.xls_headers['standard']
ws.footer_str = self.xls_footers['standard']
# Title
c_specs = [
('report_name', 1, 0, 'text', report_name),
]
row_data = self.xls_row_template(c_specs, ['report_name'])
row_pos = self.xls_write_row(
ws, row_pos, row_data, row_style=xlwt.easyxf(_xs['xls_title']))
row_pos += 1
# get the computed result of the report
data = self.pool.get('mis.report.instance').compute(
self.cr, self.uid, objects[0].id)
# Column headers
header_name_list = ['']
col_specs_template = {'': {'header': [1, 30, 'text', ''],
'header_date': [1, 1, 'text', '']}}
for col in data['header'][0]['cols']:
col_specs_template[col['name']] = {'header': [1, 30, 'text',
col['name']],
'header_date': [1, 1, 'text',
col['date']]}
header_name_list.append(col['name'])
c_specs = map(
lambda x: self.render(x, col_specs_template, 'header'),
header_name_list)
row_data = self.xls_row_template(c_specs, [x[0] for x in c_specs])
row_pos = self.xls_write_row(
ws, row_pos, row_data, row_style=self.rh_cell_style,
set_column_size=True)
c_specs = map(lambda x: self.render(
x, col_specs_template, 'header_date'), header_name_list)
row_data = self.xls_row_template(c_specs, [x[0] for x in c_specs])
row_pos = self.xls_write_row(
ws, row_pos, row_data, row_style=self.rh_cell_style_date)
ws.set_horz_split_pos(row_pos)
ws.set_vert_split_pos(1)
for line in data['content']:
col = 0
ws.write(row_pos, col, line['kpi_name'], self.mis_rh_cell_style)
for value in line['cols']:
col += 1
num_format_str = '#'
if value.get('dp'):
num_format_str += '.'
num_format_str += '0' * int(value['dp'])
if value.get('suffix'):
num_format_str = num_format_str + ' "%s"' % value['suffix']
kpi_cell_style = xlwt.easyxf(
_xs['borders_all'] + _xs['right'],
num_format_str=num_format_str)
if value.get('val'):
val = value['val']
if value.get('is_percentage'):
val = val / 0.01
ws.write(row_pos, col, val, kpi_cell_style)
else:
ws.write(row_pos, col, value['val_r'], kpi_cell_style)
row_pos += 1
mis_builder_xls('report.mis.report.instance.xls',
'mis.report.instance',
parser=mis_builder_xls_parser)

27
mis_builder/report/mis_report_instance_qweb.py

@ -0,0 +1,27 @@
# -*- coding: utf-8 -*-
# © 2014-2015 ACSONE SA/NV (<http://acsone.eu>)
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
import logging
from openerp import api, models
_logger = logging.getLogger(__name__)
class Report(models.Model):
_inherit = "report"
@api.v7
def get_pdf(self, cr, uid, ids, report_name, html=None, data=None,
context=None):
if ids:
report = self._get_report_from_name(cr, uid, report_name)
obj = self.pool[report.model].browse(cr, uid, ids,
context=context)[0]
context = context.copy()
if hasattr(obj, 'landscape_pdf') and obj.landscape_pdf:
context.update({'landscape': True})
return super(Report, self).get_pdf(cr, uid, ids, report_name,
html=html, data=data,
context=context)

89
mis_builder/report/mis_report_instance_qweb.xml

@ -0,0 +1,89 @@
<?xml version="1.0" encoding="UTF-8"?>
<openerp>
<data>
<record id="qweb_pdf_export" model="ir.actions.report.xml">
<field name="name">MIS report instance QWEB PDF report</field>
<field name="model">mis.report.instance</field>
<field name="type">ir.actions.report.xml</field>
<field name="report_name">mis_builder.report_mis_report_instance</field>
<field name="report_type">qweb-pdf</field>
<field name="auto" eval="False"/>
</record>
<template id="assets_report" inherit_id="report.assets_common">
<xpath expr="." position="inside">
<link href="/mis_builder/static/src/css/report.css" rel="stylesheet"/>
</xpath>
</template>
<!--
TODO we use divs with css table layout, but this has drawbacks:
(bad layout of first column, no colspan for first header row),
consider getting back to a plain HTML table.
-->
<template id="report_mis_report_instance">
<t t-call="report.html_container">
<t t-foreach="docs" t-as="o">
<t t-call="report.internal_layout">
<t t-set="matrix" t-value="o._compute_matrix()"/>
<t t-set="style_obj" t-value="o.env['mis.report.style']"/>
<div class="page">
<h2><span t-field="o.name" /> - <span t-field="o.company_id.name" /></h2>
<div class="mis_table">
<div class="mis_thead">
<div class="mis_row">
<div class="mis_cell mis_collabel"></div>
<t t-foreach="matrix.iter_cols()" t-as="col">
<div class="mis_cell mis_collabel">
<t t-esc="col.label"/>
<t t-if="col.description">
<br/>
<t t-esc="col.description"/>
</t>
</div>
<!-- add empty cells because we have no colspan with css tables -->
<t t-foreach="list(col.iter_subcols())[1:]" t-as="subcol">
<div class="mis_cell mis_collabel"></div>
</t>
</t>
</div>
<div class="mis_row">
<div class="mis_cell mis_collabel"></div>
<t t-foreach="matrix.iter_subcols()" t-as="subcol">
<div class="mis_cell mis_collabel">
<t t-esc="subcol.label"/>
<t t-if="subcol.description">
<br/>
<t t-esc="subcol.description"/>
</t>
</div>
</t>
</div>
</div>
<div class="mis_tbody">
<div t-foreach="matrix.iter_rows()" t-as="row" class="mis_row">
<div t-att-style="style_obj.to_css_style(row.style_props)" class="mis_cell mis_rowlabel">
<t t-esc="row.label"/>
<t t-if="row.description">
<br/>
<t t-esc="row.description"/>
</t>
</div>
<t t-foreach="row.iter_cells()" t-as="cell">
<div t-att-style="cell and style_obj.to_css_style(cell.style_props) or ''" class="mis_cell mis_amount">
<t t-esc="cell and cell.val_rendered or ''"/>
</div>
</t>
</div>
</div>
</div>
</div>
</t>
</t>
</t>
</template>
</data>
</openerp>

140
mis_builder/report/mis_report_instance_xlsx.py

@ -0,0 +1,140 @@
# -*- coding: utf-8 -*-
# © 2014-2016 ACSONE SA/NV (<http://acsone.eu>)
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
from collections import defaultdict
import logging
from openerp.report import report_sxw
from ..models.accounting_none import AccountingNone
from ..models.data_error import DataError
_logger = logging.getLogger(__name__)
try:
from openerp.addons.report_xlsx.report.report_xlsx import ReportXlsx
except ImportError:
_logger.debug("report_xlsx not installed, Excel export non functional")
class ReportXslx:
pass
ROW_HEIGHT = 15 # xlsxwriter units
COL_WIDTH = 0.9 # xlsxwriter units
MIN_COL_WIDTH = 10 # characters
MAX_COL_WIDTH = 50 # characters
class MisBuilderXslx(ReportXlsx):
def __init__(self, name, table, rml=False, parser=False, header=True,
store=False):
super(MisBuilderXslx, self).__init__(
name, table, rml, parser, header, store)
def generate_xlsx_report(self, workbook, data, objects):
# get the computed result of the report
matrix = objects._compute_matrix()
style_obj = self.env['mis.report.style']
# create worksheet
report_name = '{} - {}'.format(
objects[0].name, objects[0].company_id.name)
sheet = workbook.add_worksheet(report_name[:31])
row_pos = 0
col_pos = 0
# width of the labels column
label_col_width = MIN_COL_WIDTH
# {col_pos: max width in characters}
col_width = defaultdict(lambda: MIN_COL_WIDTH)
# document title
bold = workbook.add_format({'bold': True})
header_format = workbook.add_format({
'bold': True, 'align': 'center', 'bg_color': '#F0EEEE'})
sheet.write(row_pos, 0, report_name, bold)
row_pos += 2
# column headers
sheet.write(row_pos, 0, '', header_format)
col_pos = 1
for col in matrix.iter_cols():
label = col.label
if col.description:
label += '\n' + col.description
sheet.set_row(row_pos, ROW_HEIGHT * 2)
if col.colspan > 1:
sheet.merge_range(
row_pos, col_pos, row_pos,
col_pos + col.colspan-1,
label, header_format)
else:
sheet.write(row_pos, col_pos, label, header_format)
col_width[col_pos] = max(col_width[col_pos],
len(col.label or ''),
len(col.description or ''))
col_pos += col.colspan
row_pos += 1
# sub column headers
sheet.write(row_pos, 0, '', header_format)
col_pos = 1
for subcol in matrix.iter_subcols():
label = subcol.label
if subcol.description:
label += '\n' + subcol.description
sheet.set_row(row_pos, ROW_HEIGHT * 2)
sheet.write(row_pos, col_pos, label, header_format)
col_width[col_pos] = max(col_width[col_pos],
len(subcol.label or ''),
len(subcol.description or ''))
col_pos += 1
row_pos += 1
# rows
for row in matrix.iter_rows():
row_xlsx_style = style_obj.to_xlsx_style(row.style_props)
row_format = workbook.add_format(row_xlsx_style)
col_pos = 0
label = row.label
if row.description:
label += '\n' + row.description
sheet.set_row(row_pos, ROW_HEIGHT * 2)
sheet.write(row_pos, col_pos, label, row_format)
label_col_width = max(label_col_width,
len(row.label or ''),
len(row.description or ''))
for cell in row.iter_cells():
col_pos += 1
if not cell or cell.val is AccountingNone:
# TODO col/subcol format
sheet.write(row_pos, col_pos, '', row_format)
continue
cell_xlsx_style = style_obj.to_xlsx_style(cell.style_props)
cell_xlsx_style['align'] = 'right'
cell_format = workbook.add_format(cell_xlsx_style)
if isinstance(cell.val, DataError):
val = cell.val.name
# TODO display cell.val.msg as Excel comment?
elif cell.val is None or cell.val is AccountingNone:
val = ''
else:
val = cell.val / float(cell.style_props.get('divider', 1))
sheet.write(row_pos, col_pos, val, cell_format)
col_width[col_pos] = max(col_width[col_pos],
len(cell.val_rendered or ''))
row_pos += 1
# adjust col widths
sheet.set_column(0, 0, min(label_col_width, MAX_COL_WIDTH) * COL_WIDTH)
data_col_width = min(MAX_COL_WIDTH, max(*col_width.values()))
min_col_pos = min(*col_width.keys())
max_col_pos = max(*col_width.keys())
sheet.set_column(min_col_pos, max_col_pos, data_col_width * COL_WIDTH)
MisBuilderXslx('report.mis.report.instance.xlsx',
'mis.report.instance', parser=report_sxw.rml_parse)

15
mis_builder/report/mis_report_instance_xlsx.xml

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<openerp>
<data>
<record id="xls_export" model="ir.actions.report.xml">
<field name="name">MIS report instance XLS report</field>
<field name="model">mis.report.instance</field>
<field name="type">ir.actions.report.xml</field>
<field name="report_name">mis.report.instance.xlsx</field>
<field name="report_type">xlsx</field>
<field name="auto" eval="False"/>
</record>
</data>
</openerp>

67
mis_builder/report/report_mis_report_instance.py

@ -1,67 +0,0 @@
# -*- encoding: utf-8 -*-
##############################################################################
#
# mis_builder module for Odoo, Management Information System Builder
# Copyright (C) 2014-2015 ACSONE SA/NV (<http://acsone.eu>)
#
# This file is a part of mis_builder
#
# mis_builder is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License v3 or later
# as published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# mis_builder is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License v3 or later for more details.
#
# You should have received a copy of the GNU Affero General Public License
# v3 or later along with this program.
# If not, see <http://www.gnu.org/licenses/>.
#
##############################################################################
import logging
from openerp import api, models
_logger = logging.getLogger(__name__)
class ReportMisReportInstance(models.AbstractModel):
_name = 'report.mis_builder.report_mis_report_instance'
@api.multi
def render_html(self, data=None):
docs = self.env['mis.report.instance'].browse(self._ids)
docs_computed = {}
for doc in docs:
docs_computed[doc.id] = doc.compute()
docargs = {
'doc_ids': self._ids,
'doc_model': 'mis.report.instance',
'docs': docs,
'docs_computed': docs_computed,
}
return self.env['report'].\
render('mis_builder.report_mis_report_instance', docargs)
class Report(models.Model):
_inherit = "report"
@api.v7
def get_pdf(self, cr, uid, ids, report_name, html=None, data=None,
context=None):
if ids:
report = self._get_report_from_name(cr, uid, report_name)
obj = self.pool[report.model].browse(cr, uid, ids,
context=context)[0]
context = context.copy()
if hasattr(obj, 'landscape_pdf') and obj.landscape_pdf:
context.update({'landscape': True})
return super(Report, self).get_pdf(cr, uid, ids, report_name,
html=html, data=data,
context=context)

55
mis_builder/report/report_mis_report_instance.xml

@ -1,55 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<openerp>
<data>
<template id="report_mis_report_instance">
<t t-call="report.html_container">
<t t-foreach="docs" t-as="o">
<t t-call="report.internal_layout">
<div class="page">
<h2 t-field="o.name"></h2>
<table class="table table-condensed">
<thead>
<tr>
<t t-foreach="docs_computed[o.id]['header']" t-as="h">
<th>
<div>
<t t-esc="h_value['kpi_name']"/>
</div>
</th>
<th t-foreach="h_value['cols']" t-as="col" class="text-center">
<div>
<t t-esc="col['name']"/>
</div>
<div>
<t t-esc="col['date']"/>
</div>
</th>
</t>
</tr>
</thead>
<tbody>
<tr t-foreach="docs_computed[o.id]['content']" t-as="c">
<td t-att-style="c_value['default_style']">
<div class="text-left">
<t t-esc="c_value['kpi_name']"/>
</div>
</td>
<t t-foreach="c_value['cols']" t-as="value">
<td t-att-style="c_value['default_style']">
<div t-att-style="value_value.get('style')" class="text-right">
<t t-esc="value_value['val_r']"/>
</div>
</td>
</t>
</tr>
</tbody>
</table>
</div>
</t>
</t>
</t>
</template>
</data>
</openerp>

6
mis_builder/security/ir.model.access.csv

@ -9,3 +9,9 @@ manage_mis_report_instance_period,manage_mis_report_instance_period,model_mis_re
access_mis_report_instance_period,access_mis_report_instance_period,model_mis_report_instance_period,base.group_user,1,0,0,0
manage_mis_report_instance,manage_mis_report_instance,model_mis_report_instance,account.group_account_manager,1,1,1,1
access_mis_report_instance,access_mis_report_instance,model_mis_report_instance,base.group_user,1,0,0,0
manage_mis_report_subkpi,access_mis_report_subkpi,model_mis_report_subkpi,account.group_account_manager,1,1,1,1
access_mis_report_subkpi,access_mis_report_subkpi,model_mis_report_subkpi,base.group_user,1,0,0,0
manage_mis_report_kpi_expression,access_mis_report_kpi_expression,model_mis_report_kpi_expression,account.group_account_manager,1,1,1,1
access_mis_report_kpi_expression,access_mis_report_kpi_expression,model_mis_report_kpi_expression,base.group_user,1,0,0,0
manage_mis_report_style,access_mis_report_style,model_mis_report_style,account.group_account_manager,1,0,0,0
access_mis_report_style,access_mis_report_style,model_mis_report_style,base.group_user,1,0,0,0

19
mis_builder/static/src/css/custom.css

@ -1,7 +1,15 @@
.openerp .mis_builder_ralign {
.openerp .mis_builder_amount {
text-align: right;
}
.openerp .mis_builder_collabel {
text-align: center;
}
.openerp .mis_builder_rowlabel {
text-align: left;
}
.openerp .mis_builder a {
/* we don't want the link color, to respect user styles */
color: inherit;
@ -11,3 +19,12 @@
/* underline links on hover to give a visual cue */
text-decoration: underline;
}
.openerp .oe_mis_builder_buttons {
padding-bottom: 10px;
padding-top: 10px;
}
.oe_mis_builder_content {
padding: 20px;
}

45
mis_builder/static/src/css/report.css

@ -0,0 +1,45 @@
.mis_table {
display: table;
width: 100%;
table-layout: fixed;
}
.mis_row {
display: table-row;
page-break-inside: avoid;
}
.mis_cell {
display: table-cell;
page-break-inside: avoid;
}
.mis_thead {
display: table-header-group;
}
.mis_tbody {
display: table-row-group;
}
.mis_table, .mis_table .mis_row {
border-left: 0px;
border-right: 0px;
text-align: left;
padding-right: 3px;
padding-left: 3px;
padding-top: 2px;
padding-bottom: 2px;
border-collapse: collapse;
}
.mis_table .mis_row {
border-color: grey;
border-bottom: 1px solid lightGrey;
}
.mis_table .mis_cell.mis_collabel {
font-weight: bold;
background-color: #F0F0F0;
text-align: center;
}
.mis_table .mis_cell.mis_rowlabel {
text-align: left;
/*white-space: nowrap;*/
}
.mis_table .mis_cell.mis_amount {
text-align: right;
}

183
mis_builder/static/src/js/mis_builder.js

@ -1,48 +1,147 @@
openerp.mis_builder = function(instance) {
odoo.define('mis.builder', function (require) {
"use strict";
instance.mis_builder.MisReport = instance.web.form.FormWidget.extend({
template: "mis_builder.MisReport",
var core = require('web.core');
var form_common = require('web.form_common');
var Model = require('web.DataModel');
var data = require('web.data');
var ActionManager = require('web.ActionManager');
init: function() {
this._super.apply(this, arguments);
this.mis_report_data = null;
},
var MisReport = form_common.FormWidget.extend({
/**
* @constructs instance.mis_builder.MisReport
* @extends instance.web.form.FormWidget
*
*/
template: "mis_builder.MisReport",
init: function() {
this._super.apply(this, arguments);
this.mis_report_data = null;
this.mis_report_instance_id = false;
this.field_manager.on("view_content_has_changed", this, this.reload_widget);
},
reload_widget: function() {
var self = this;
self.mis_report_instance_id = self.getParent().datarecord.id;
if (self.mis_report_instance_id) {
self.generate_content();
}
},
start: function() {
this._super.apply(this, arguments);
var self = this;
new instance.web.Model("mis.report.instance").call(
"compute",
[self.getParent().dataset.context.active_id],
{'context': new instance.web.CompoundContext()}
).then(function(result){
self.mis_report_data = result;
self.renderElement();
});
},
start: function() {
this._super.apply(this, arguments);
var self = this;
self.mis_report_instance_id = self.getParent().datarecord.id;
if (self.mis_report_instance_id) {
self.getParent().dataset.context.no_destroy = true;
}
},
get_context: function() {
var self = this;
var context = {};
if (this.mis_report_instance_id){
context.active_ids = [this.mis_report_instance_id];
}
return context;
},
print: function() {
var self = this;
var context = new data.CompoundContext(self.build_context(), self.get_context()|| {});
new Model("mis.report.instance").call(
"print_pdf",
[self.mis_report_instance_id],
{'context': context}
).then(function(result){
self.do_action(result);
});
},
export_pdf: function() {
var self = this;
var context = new data.CompoundContext(self.build_context(), self.get_context()|| {});
new Model("mis.report.instance").call(
"export_xls",
[self.mis_report_instance_id],
{'context': context}
).then(function(result){
self.do_action(result);
});
},
display_settings: function() {
var self = this;
var context = new data.CompoundContext(self.build_context(), self.get_context()|| {});
new Model("mis.report.instance").call(
"display_settings",
[self.mis_report_instance_id],
{'context': context}
).then(function(result){
self.do_action(result);
});
},
generate_content: function() {
var self = this;
var context = new data.CompoundContext(self.build_context(), self.get_context()|| {});
new Model("mis.report.instance").call(
"compute",
[self.mis_report_instance_id],
{'context': context}
).then(function(result){
self.mis_report_data = result;
self.renderElement();
});
},
renderElement: function() {
this._super();
var self = this;
self.$(".oe_mis_builder_print").click(_.bind(this.print, this));
self.$(".oe_mis_builder_export").click(_.bind(this.export_pdf, this));
self.$(".oe_mis_builder_settings").click(_.bind(this.display_settings, this));
var Users = new Model('res.users');
Users.call('has_group', ['account.group_account_user']).done(function (res) {
if (res) {
self.$(".oe_mis_builder_settings").show();
}
});
},
events: {
"click a.mis_builder_drilldown": "drilldown",
},
events: {
"click a.mis_builder_drilldown": "drilldown",
},
drilldown: function(event) {
var self = this;
var drilldown = $(event.target).data("drilldown");
if (drilldown) {
new Model("mis.report.instance").call(
"drilldown",
[self.mis_report_instance_id, drilldown]
).then(function(result) {
if (result) {
self.do_action(result);
}
});
}
},
});
drilldown: function(event) {
var self = this;
var drilldown = JSON.parse($(event.target).data("drilldown"));
if (drilldown) {
var period_id = JSON.parse($(event.target).data("period-id"));
var val_c = JSON.parse($(event.target).data("expr"));
new instance.web.Model("mis.report.instance.period").call(
"drilldown",
[period_id, val_c],
{'context': new instance.web.CompoundContext()}
).then(function(result) {
if (result) {
self.do_action(result);
}
});
ActionManager.include({
/*
* In the case where we would be open in modal view, this is
* necessary to avoid to close the popup on click on button like print,
* export, ...
*/
dialog_stop: function (reason) {
var self = this;
if (self.dialog_widget && self.dialog_widget.dataset && self.dialog_widget.dataset.context) {
var context = self.dialog_widget.dataset.context;
if (!context.no_destroy) {
this._super.apply(this, arguments);
}
},
});
instance.web.form.custom_widgets.add('mis_report', 'instance.mis_builder.MisReport');
}
} else {
this._super.apply(this, arguments);
}
}
});
core.form_custom_registry.add('mis_report', MisReport);
});

108
mis_builder/static/src/xml/mis_widget.xml

@ -1,62 +1,56 @@
<template>
<t t-name="mis_builder.MisReport">
<p> </p>
<table t-if="widget.mis_report_data" class="oe_list_content mis_builder">
<thead>
<tr class="oe_list_header_columns">
<t t-foreach="widget.mis_report_data.header" t-as="h">
<th class="oe_list_header_char">
<div>
<t t-esc="h_value.kpi_name"/>
</div>
</th>
<th t-foreach="h_value.cols" t-as="col" class="oe_list_header_char mis_builder_ralign">
<div>
<t t-esc="col.name"/>
</div>
<div>
<t t-esc="col.date"/>
</div>
</th>
</t>
</tr>
</thead>
<tbody>
<tr t-foreach="widget.mis_report_data.content" t-as="c">
<td t-att="{'style': c_value.default_style}">
<div>
<t t-esc="c_value.kpi_name"/>
</div>
</td>
<t t-foreach="c_value.cols" t-as="value">
<td t-att="{'style': c_value.default_style}" class="mis_builder_ralign">
<div t-att="{'style': value_value.style, 'title': value_value.val_c}">
<t t-if="value_value.drilldown">
<a href="javascript:void(0)"
class="mis_builder_drilldown"
t-att-data-drilldown="JSON.stringify(value_value.drilldown)"
t-att-data-period-id="JSON.stringify(value_value.period_id)"
t-att-data-expr="JSON.stringify(value_value.expr)"
>
<t t-esc="value_value.val_r"/>
</a>
<div class="oe_mis_builder_content">
<t t-if="widget.mis_report_data">
<h2><t t-esc="widget.mis_report_data.report_name" /></h2>
<div class="oe_mis_builder_buttons oe_right oe_button_box">
<button class="oe_mis_builder_print btn btn-sm oe_button"><img src="/web/static/src/img/icons/gtk-print.png"/> Print</button>
<button class="oe_mis_builder_export btn btn-sm oe_button"><img src="/web/static/src/img/icons/gtk-go-down.png"/>Export</button>
<button style="display: none;" class="oe_mis_builder_settings btn btn-sm oe_button"><img src="/web/static/src/img/icons/gtk-execute.png"/> Settings</button>
</div>
<table class="oe_list_content mis_builder">
<thead>
<tr t-foreach="widget.mis_report_data.header" t-as="row" class="oe_list_header_columns">
<th class="oe_list_header_char">
</th>
<th t-foreach="row.cols" t-as="col" class="oe_list_header_char mis_builder_collabel" t-att-colspan="col.colspan">
<t t-esc="col.label"/>
<t t-if="col.description">
<br/>
<t t-esc="col.description"/>
</t>
</th>
</tr>
</thead>
<tbody>
<tr t-foreach="widget.mis_report_data.body" t-as="row">
<td t-att="{'style': row.style}">
<t t-esc="row.label"/>
<t t-if="row.description">
<br/>
<t t-esc="row.description"/>
</t>
<t t-if="!value_value.drilldown">
<t t-esc="value_value.val_r"/>
</t>
</div>
</td>
</t>
</tr>
</tbody>
<tfoot>
<tr>
<td class="oe_list_footer" />
<t t-foreach="widget.mis_report_data.header" t-as="f">
<td t-foreach="f_value.cols" class="oe_list_footer" />
</t>
</tr>
</tfoot>
</table>
</td>
<td t-foreach="row.cells" t-as="cell" t-att="{'style': cell.style, 'title': cell.val_c}" class="mis_builder_amount">
<t t-if="cell.drilldown_arg">
<a href="javascript:void(0)"
class="mis_builder_drilldown"
t-att-data-drilldown="JSON.stringify(cell.drilldown_arg)"
>
<t t-esc="cell.val_r"/>
</a>
</t>
<t t-if="!cell.drilldown_arg">
<t t-esc="cell.val_r"/>
</t>
</td>
</tr>
</tbody>
<tfoot>
<tr></tr>
</tfoot>
</table>
</t>
</div>
</t>
</template>

36
mis_builder/tests/__init__.py

@ -1,25 +1,13 @@
# -*- encoding: utf-8 -*-
##############################################################################
#
# mis_builder module for Odoo, Management Information System Builder
# Copyright (C) 2014-2015 ACSONE SA/NV (<http://acsone.eu>)
#
# This file is a part of mis_builder
#
# mis_builder is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License v3 or later
# as published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# mis_builder is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License v3 or later for more details.
#
# You should have received a copy of the GNU Affero General Public License
# v3 or later along with this program.
# If not, see <http://www.gnu.org/licenses/>.
#
##############################################################################
# -*- coding: utf-8 -*-
# © 2014-2015 ACSONE SA/NV (<http://acsone.eu>)
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
from . import test_mis_builder
from . import test_accounting_none
from . import test_aep
from . import test_aggregate
from . import test_fetch_query
from . import test_mis_report_instance
from . import test_mis_safe_eval
from . import test_render
from . import test_simple_array
from . import test_utc_midnight

4
mis_builder/tests/mis.report.csv

@ -1,2 +1,2 @@
"id","description","kpi_ids/id","name","query_ids/id"
"mis_report_test","","mis_report_kpi_test","Test report","mis_report_query_test"
"id","description","kpi_ids/id","name","query_ids/id"
"mis_report_test","","mis_report_kpi_test","Test report","mis_report_query_test"

4
mis_builder/tests/mis.report.instance.csv

@ -1,2 +1,2 @@
"id","date","description","name","period_ids/id","report_id/id","root_account/id"
"mis_report_instance_test","2014-07-31","","Test-report-instance without company","mis_report_instance_period_test","mis_report_test","account.chart0"
"id","date","description","name","period_ids/id","report_id/id"
"mis_report_instance_test","2014-07-31","","Test-report-instance without company","mis_report_instance_period_test","mis_report_test"

4
mis_builder/tests/mis.report.instance.period.csv

@ -1,2 +1,2 @@
"id","duration","name","offset","type","sequence"
"mis_report_instance_period_test","1","today","","Day",""
"id","duration","name","offset","type","sequence","mode"
"mis_report_instance_period_test","1","today","","Day","","relative"

4
mis_builder/tests/mis.report.kpi.csv

@ -1,2 +1,2 @@
"id","compare_method","description","expression","divider","name","dp","sequence","type","suffix"
"mis_report_kpi_test","Percentage","total test","len(test)","","total_test","","1","Numeric",""
"id","description","expression","name"
"mis_report_kpi_test","total test","len(test)","total_test"

2
mis_builder/tests/mis.report.query.csv

@ -1,2 +1,2 @@
"id","date_field/id","domain","field_ids/id","model_id/id","name"
"mis_report_query_test","account.field_account_analytic_balance_date1","","account.field_account_analytic_balance_empty_acc","account.model_account_analytic_balance","test"
"mis_report_query_test","analytic.field_account_analytic_line_date","","analytic.field_account_analytic_line_amount","analytic.model_account_analytic_line","test"

12
mis_builder/tests/test_accounting_none.py

@ -0,0 +1,12 @@
# -*- coding: utf-8 -*-
# © 2014-2015 ACSONE SA/NV (<http://acsone.eu>)
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
import doctest
from openerp.addons.mis_builder.models import accounting_none
def load_tests(loader, tests, ignore):
tests.addTests(doctest.DocTestSuite(accounting_none))
return tests

226
mis_builder/tests/test_aep.py

@ -0,0 +1,226 @@
# -*- coding: utf-8 -*-
# © 2014-2015 ACSONE SA/NV (<http://acsone.eu>)
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
import datetime
import time
from openerp import fields
import openerp.tests.common as common
from openerp.tools.safe_eval import safe_eval
from ..models.aep import AccountingExpressionProcessor as AEP
from ..models.accounting_none import AccountingNone
class TestAEP(common.TransactionCase):
def setUp(self):
super(TestAEP, self).setUp()
self.res_company = self.env['res.company']
self.account_model = self.env['account.account']
self.move_model = self.env['account.move']
self.journal_model = self.env['account.journal']
self.curr_year = datetime.date.today().year
self.prev_year = self.curr_year - 1
# create company
self.company = self.res_company.create({
'name': 'AEP Company'})
# create receivable bs account
type_ar = self.browse_ref('account.data_account_type_receivable')
self.account_ar = self.account_model.create({
'company_id': self.company.id,
'code': '400AR',
'name': 'Receivable',
'user_type_id': type_ar.id,
'reconcile': True})
# create income pl account
type_in = self.browse_ref('account.data_account_type_revenue')
self.account_in = self.account_model.create({
'company_id': self.company.id,
'code': '700IN',
'name': 'Income',
'user_type_id': type_in.id})
# create journal
self.journal = self.journal_model.create({
'company_id': self.company.id,
'name': 'Sale journal',
'code': 'VEN',
'type': 'sale'})
# create move in december last year
self._create_move(
date=datetime.date(self.prev_year, 12, 1),
amount=100,
debit_acc=self.account_ar,
credit_acc=self.account_in)
# create move in january this year
self._create_move(
date=datetime.date(self.curr_year, 1, 1),
amount=300,
debit_acc=self.account_ar,
credit_acc=self.account_in)
# create move in february this year
self._create_move(
date=datetime.date(self.curr_year, 3, 1),
amount=500,
debit_acc=self.account_ar,
credit_acc=self.account_in)
# create the AEP, and prepare the expressions we'll need
self.aep = AEP(self.company)
self.aep.parse_expr("bali[]")
self.aep.parse_expr("bale[]")
self.aep.parse_expr("balp[]")
self.aep.parse_expr("balu[]")
self.aep.parse_expr("bali[700IN]")
self.aep.parse_expr("bale[700IN]")
self.aep.parse_expr("balp[700IN]")
self.aep.parse_expr("bali[400AR]")
self.aep.parse_expr("bale[400AR]")
self.aep.parse_expr("balp[400AR]")
self.aep.parse_expr("debp[400A%]")
self.aep.parse_expr("crdp[700I%]")
self.aep.parse_expr("bal_700IN") # deprecated
self.aep.parse_expr("bals[700IN]") # deprecated
self.aep.done_parsing()
def _create_move(self, date, amount, debit_acc, credit_acc):
move = self.move_model.create({
'journal_id': self.journal.id,
'date': fields.Date.to_string(date),
'line_ids': [(0, 0, {
'name': '/',
'debit': amount,
'account_id': debit_acc.id,
}), (0, 0, {
'name': '/',
'credit': amount,
'account_id': credit_acc.id,
})]})
move.post()
return move
def _do_queries(self, date_from, date_to):
self.aep.do_queries(
date_from=fields.Date.to_string(date_from),
date_to=fields.Date.to_string(date_to),
target_move='posted')
def _eval(self, expr):
eval_dict = {'AccountingNone': AccountingNone}
return safe_eval(self.aep.replace_expr(expr), eval_dict)
def _eval_by_account_id(self, expr):
res = {}
eval_dict = {'AccountingNone': AccountingNone}
for account_id, replaced_exprs in \
self.aep.replace_exprs_by_account_id([expr]):
res[account_id] = safe_eval(replaced_exprs[0], eval_dict)
return res
def test_sanity_check(self):
self.assertEquals(self.company.fiscalyear_last_day, 31)
self.assertEquals(self.company.fiscalyear_last_month, 12)
def test_aep_basic(self):
# let's query for december
self._do_queries(
datetime.date(self.prev_year, 12, 1),
datetime.date(self.prev_year, 12, 31))
# initial balance must be None
self.assertIs(self._eval('bali[400AR]'), AccountingNone)
self.assertIs(self._eval('bali[700IN]'), AccountingNone)
# check variation
self.assertEquals(self._eval('balp[400AR]'), 100)
self.assertEquals(self._eval('balp[700IN]'), -100)
# check ending balance
self.assertEquals(self._eval('bale[400AR]'), 100)
self.assertEquals(self._eval('bale[700IN]'), -100)
# let's query for January
self._do_queries(
datetime.date(self.curr_year, 1, 1),
datetime.date(self.curr_year, 1, 31))
# initial balance is None for income account (it's not carried over)
self.assertEquals(self._eval('bali[400AR]'), 100)
self.assertIs(self._eval('bali[700IN]'), AccountingNone)
# check variation
self.assertEquals(self._eval('balp[400AR]'), 300)
self.assertEquals(self._eval('balp[700IN]'), -300)
# check ending balance
self.assertEquals(self._eval('bale[400AR]'), 400)
self.assertEquals(self._eval('bale[700IN]'), -300)
# let's query for March
self._do_queries(
datetime.date(self.curr_year, 3, 1),
datetime.date(self.curr_year, 3, 31))
# initial balance is the ending balance fo January
self.assertEquals(self._eval('bali[400AR]'), 400)
self.assertEquals(self._eval('bali[700IN]'), -300)
# check variation
self.assertEquals(self._eval('balp[400AR]'), 500)
self.assertEquals(self._eval('balp[700IN]'), -500)
# check ending balance
self.assertEquals(self._eval('bale[400AR]'), 900)
self.assertEquals(self._eval('bale[700IN]'), -800)
# check some variant expressions, for coverage
self.assertEquals(self._eval('crdp[700I%]'), 500)
self.assertEquals(self._eval('debp[400A%]'), 500)
self.assertEquals(self._eval('bal_700IN'), -500)
self.assertEquals(self._eval('bals[700IN]'), -800)
# unallocated p&l from previous year
self.assertEquals(self._eval('balu[]'), -100)
# TODO allocate profits, and then...
def test_aep_by_account(self):
self._do_queries(
datetime.date(self.curr_year, 3, 1),
datetime.date(self.curr_year, 3, 31))
variation = self._eval_by_account_id('balp[]')
self.assertEquals(variation, {
self.account_ar.id: 500,
self.account_in.id: -500,
})
variation = self._eval_by_account_id('balp[700IN]')
self.assertEquals(variation, {
self.account_in.id: -500,
})
end = self._eval_by_account_id('bale[]')
self.assertEquals(end, {
self.account_ar.id: 900,
self.account_in.id: -800,
})
def test_aep_convenience_methods(self):
initial = AEP.get_balances_initial(
self.company,
time.strftime('%Y') + '-03-01',
'posted')
self.assertEquals(initial, {
self.account_ar.id: (400, 0),
self.account_in.id: (0, 300),
})
variation = AEP.get_balances_variation(
self.company,
time.strftime('%Y') + '-03-01',
time.strftime('%Y') + '-03-31',
'posted')
self.assertEquals(variation, {
self.account_ar.id: (500, 0),
self.account_in.id: (0, 500),
})
end = AEP.get_balances_end(
self.company,
time.strftime('%Y') + '-03-31',
'posted')
self.assertEquals(end, {
self.account_ar.id: (900, 0),
self.account_in.id: (0, 800),
})
unallocated = AEP.get_unallocated_pl(
self.company,
time.strftime('%Y') + '-03-15',
'posted')
self.assertEquals(unallocated, (0, 100))

12
mis_builder/tests/test_aggregate.py

@ -0,0 +1,12 @@
# -*- coding: utf-8 -*-
# © 2014-2015 ACSONE SA/NV (<http://acsone.eu>)
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
import doctest
from openerp.addons.mis_builder.models import aggregate
def load_tests(loader, tests, ignore):
tests.addTests(doctest.DocTestSuite(aggregate))
return tests

41
mis_builder/tests/test_fetch_query.py

@ -0,0 +1,41 @@
# -*- coding: utf-8 -*-
# © 2014-2016 ACSONE SA/NV (<http://acsone.eu>)
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
import openerp.tests.common as common
class TestFetchQuery(common.TransactionCase):
def test_fetch_query(self):
# create a report on account.analytic.line
data = self.registry('mis.report.instance').compute(
self.cr, self.uid,
self.ref('mis_builder.mis_report_instance_test'))
self.maxDiff = None
self.assertEquals(
{'body':
[{'label': u'total test',
'description': '',
'style': None,
'parent_row_id': None,
'row_id': u'total_test',
'cells': [{'val': 0,
'val_r': u'0',
'val_c': u'total_test = len(test)',
'style': None,
}]
}],
'header':
[{'cols': [{'description': '07/31/2014',
'label': u'today',
'colspan': 1,
}],
},
{'cols': [{'label': '',
'description': '',
'colspan': 1,
}],
},
],
}, data)

82
mis_builder/tests/test_mis_builder.py

@ -1,82 +0,0 @@
# -*- encoding: utf-8 -*-
##############################################################################
#
# mis_builder module for Odoo, Management Information System Builder
# Copyright (C) 2014-2015 ACSONE SA/NV (<http://acsone.eu>)
#
# This file is a part of mis_builder
#
# mis_builder is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License v3 or later
# as published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# mis_builder is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License v3 or later for more details.
#
# You should have received a copy of the GNU Affero General Public License
# v3 or later along with this program.
# If not, see <http://www.gnu.org/licenses/>.
#
##############################################################################
import openerp.tests.common as common
from ..models import mis_builder
class test_mis_builder(common.TransactionCase):
def setUp(self):
super(test_mis_builder, self).setUp()
def test_datetime_conversion(self):
date_to_convert = '2014-07-05'
date_time_convert = mis_builder._utc_midnight(
date_to_convert, 'Europe/Brussels')
self.assertEqual(date_time_convert, '2014-07-04 22:00:00',
'The converted date time convert must contains hour')
date_time_convert = mis_builder._utc_midnight(
date_to_convert, 'Europe/Brussels', add_day=1)
self.assertEqual(date_time_convert, '2014-07-05 22:00:00',
'The converted date time convert must contains hour')
date_time_convert = mis_builder._utc_midnight(
date_to_convert, 'US/Pacific')
self.assertEqual(date_time_convert, '2014-07-05 07:00:00',
'The converted date time convert must contains hour')
date_time_convert = mis_builder._utc_midnight(
date_to_convert, 'US/Pacific', add_day=1)
self.assertEqual(date_time_convert, '2014-07-06 07:00:00',
'The converted date time convert must contains hour')
def test_fetch_query(self):
# create a report on a model without company_id field :
# account.analytic.balance
data = self.registry('mis.report.instance').compute(
self.cr, self.uid,
self.ref('mis_builder.mis_report_instance_test'))
self.assertDictContainsSubset(
{'content':
[{'kpi_name': u'total test',
'default_style': False,
'cols': [{'period_id': self.ref('mis_builder.'
'mis_report_instance_'
'period_test'),
'style': None,
'suffix': False,
'expr': 'len(test)',
'val_c': 'total_test = len(test)',
'val': 0,
'val_r': u'0\xa0',
'is_percentage': False,
'dp': 0,
'drilldown': False}]
}],
'header':
[{'kpi_name': '',
'cols': [{'date': '07/31/2014',
'name': u'today'}]
}],
}, data)

141
mis_builder/tests/test_mis_report_instance.py

@ -0,0 +1,141 @@
# -*- coding: utf-8 -*-
# © 2016 ACSONE SA/NV (<http://acsone.eu>)
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
import openerp.tests.common as common
from openerp.tools import test_reports
class TestMisReportInstance(common.TransactionCase):
""" Basic integration test to exercise mis.report.instance.
We don't check the actual results here too much as computation correctness
should be covered by lower level unit tests.
"""
def setUp(self):
super(TestMisReportInstance, self).setUp()
partner_model_id = \
self.env.ref('base.model_res_partner').id
partner_create_date_field_id = \
self.env.ref('base.field_res_partner_create_date').id
partner_debit_field_id = \
self.env.ref('account.field_res_partner_debit').id
# create a report with 2 subkpis and one query
self.report = self.env['mis.report'].create(dict(
name='test report',
subkpi_ids=[(0, 0, dict(
name='sk1',
description='subkpi 1',
sequence=1,
)), (0, 0, dict(
name='sk2',
description='subkpi 2',
sequence=2,
))],
query_ids=[(0, 0, dict(
name='partner',
model_id=partner_model_id,
field_ids=[(4, partner_debit_field_id, None)],
date_field=partner_create_date_field_id,
aggregate='sum',
))],
))
# kpi with accounting formulas
self.env['mis.report.kpi'].create(dict(
report_id=self.report.id,
description='kpi 1',
name='k1',
multi=True,
expression_ids=[(0, 0, dict(
name='bale[200%]',
subkpi_id=self.report.subkpi_ids[0].id,
)), (0, 0, dict(
name='balp[200%]',
subkpi_id=self.report.subkpi_ids[1].id,
))],
))
# kpi with accounting formula and query
self.env['mis.report.kpi'].create(dict(
report_id=self.report.id,
description='kpi 2',
name='k2',
multi=True,
expression_ids=[(0, 0, dict(
name='balp[200%]',
subkpi_id=self.report.subkpi_ids[0].id,
)), (0, 0, dict(
name='partner.debit',
subkpi_id=self.report.subkpi_ids[1].id,
))],
))
# kpi with a simple expression summing other multi-valued kpis
self.env['mis.report.kpi'].create(dict(
report_id=self.report.id,
description='kpi 4',
name='k4',
multi=False,
expression='k1 + k2 + k3',
))
# kpi with 2 constants
self.env['mis.report.kpi'].create(dict(
report_id=self.report.id,
description='kpi 3',
name='k3',
multi=True,
expression_ids=[(0, 0, dict(
name='AccountingNone',
subkpi_id=self.report.subkpi_ids[0].id,
)), (0, 0, dict(
name='1.0',
subkpi_id=self.report.subkpi_ids[1].id,
))],
))
# kpi with a NameError (x not defined)
self.env['mis.report.kpi'].create(dict(
report_id=self.report.id,
description='kpi 5',
name='k5',
multi=True,
expression_ids=[(0, 0, dict(
name='x',
subkpi_id=self.report.subkpi_ids[0].id,
)), (0, 0, dict(
name='1.0',
subkpi_id=self.report.subkpi_ids[1].id,
))],
))
# create a report instance
self.report_instance = self.env['mis.report.instance'].create(dict(
name='test instance',
report_id=self.report.id,
company_id=self.env.ref('base.main_company').id,
period_ids=[(0, 0, dict(
name='p1',
mode='relative',
type='d',
subkpi_ids=[(4, self.report.subkpi_ids[0].id, None)],
)), (0, 0, dict(
name='p2',
mode='fix',
manual_date_from='2014-01-01',
manual_date_to='2014-12-31',
))],
))
self.report_instance.period_ids[1].comparison_column_ids = \
[(4, self.report_instance.period_ids[0].id, None)]
def test_json(self):
self.report_instance.compute()
def test_qweb(self):
test_reports.try_report(self.env.cr, self.env.uid,
'mis_builder.report_mis_report_instance',
[self.report_instance.id],
report_type='qweb-pdf')
def test_xlsx(self):
test_reports.try_report(self.env.cr, self.env.uid,
'mis.report.instance.xlsx',
[self.report_instance.id],
report_type='xlsx')

27
mis_builder/tests/test_mis_safe_eval.py

@ -0,0 +1,27 @@
# -*- coding: utf-8 -*-
# © 2016 ACSONE SA/NV (<http://acsone.eu>)
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
import openerp.tests.common as common
from ..models.mis_safe_eval import mis_safe_eval, DataError, NameDataError
class TestMisSafeEval(common.TransactionCase):
def test_nominal(self):
val = mis_safe_eval('a + 1', {'a': 1})
self.assertEqual(val, 2)
def test_exceptions(self):
val = mis_safe_eval('1/0', {}) # division by zero
self.assertTrue(isinstance(val, DataError))
self.assertEqual(val.name, '#DIV/0')
val = mis_safe_eval('1a', {}) # syntax error
self.assertTrue(isinstance(val, DataError))
self.assertEqual(val.name, '#ERR')
def test_name_error(self):
val = mis_safe_eval('a + 1', {})
self.assertTrue(isinstance(val, NameDataError))
self.assertEqual(val.name, '#NAME')

158
mis_builder/tests/test_render.py

@ -0,0 +1,158 @@
# -*- coding: utf-8 -*-
# © 2016 ACSONE SA/NV (<http://acsone.eu>)
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
import openerp.tests.common as common
from ..models.accounting_none import AccountingNone
from ..models.mis_report_style import (
TYPE_NUM, TYPE_PCT, TYPE_STR, CMP_DIFF, CMP_PCT
)
class TestRendering(common.TransactionCase):
def setUp(self):
super(TestRendering, self).setUp()
self.style_obj = self.env['mis.report.style']
self.kpi_obj = self.env['mis.report.kpi']
self.style = self.style_obj.create(dict(
name='teststyle',
))
self.lang = self.env['res.lang'].search([('code', '=', 'en_US')])[0]
def _render(self, value, type=TYPE_NUM):
style_props = self.style_obj.merge([self.style])
return self.style_obj.render(self.lang, style_props, type, value)
def _compare_and_render(self, value, base_value,
type=TYPE_NUM, compare_method=CMP_PCT):
style_props = self.style_obj.merge([self.style])
return self.style_obj.compare_and_render(self.lang, style_props,
type, compare_method,
value, base_value)[:2]
def test_render(self):
self.assertEquals(u'1', self._render(1))
self.assertEquals(u'1', self._render(1.1))
self.assertEquals(u'2', self._render(1.6))
self.style.dp_inherit = False
self.style.dp = 2
self.assertEquals(u'1.00', self._render(1))
self.assertEquals(u'1.10', self._render(1.1))
self.assertEquals(u'1.60', self._render(1.6))
self.assertEquals(u'1.61', self._render(1.606))
self.assertEquals(u'12,345.67', self._render(12345.67))
def test_render_negative(self):
# non breaking hyphen
self.assertEquals(u'\u20111', self._render(-1))
def test_render_zero(self):
self.assertEquals(u'0', self._render(0))
self.assertEquals(u'', self._render(None))
self.assertEquals(u'', self._render(AccountingNone))
def test_render_suffix(self):
self.style.suffix_inherit = False
self.style.suffix = u''
self.assertEquals(u'1\xa0', self._render(1))
self.style.suffix = u'k€'
self.style.divider_inherit = False
self.style.divider = '1e3'
self.assertEquals(u'1\xa0k€', self._render(1000))
def test_render_prefix(self):
self.style.prefix_inherit = False
self.style.prefix = u'$'
self.assertEquals(u'$\xa01', self._render(1))
self.style.prefix = u'k$'
self.style.divider_inherit = False
self.style.divider = '1e3'
self.assertEquals(u'k$\xa01', self._render(1000))
def test_render_divider(self):
self.style.divider_inherit = False
self.style.divider = '1e3'
self.style.dp_inherit = False
self.style.dp = 0
self.assertEquals(u'1', self._render(1000))
self.style.divider = '1e6'
self.style.dp = 3
self.assertEquals(u'0.001', self._render(1000))
self.style.divider = '1e-3'
self.style.dp = 0
self.assertEquals(u'1,000', self._render(1))
self.style.divider = '1e-6'
self.style.dp = 0
self.assertEquals(u'1,000,000', self._render(1))
def test_render_pct(self):
self.assertEquals(u'100\xa0%', self._render(1, TYPE_PCT))
self.assertEquals(u'50\xa0%', self._render(0.5, TYPE_PCT))
self.style.dp_inherit = False
self.style.dp = 2
self.assertEquals(u'51.23\xa0%', self._render(0.5123, TYPE_PCT))
def test_render_string(self):
self.assertEquals(u'', self._render('', TYPE_STR))
self.assertEquals(u'', self._render(None, TYPE_STR))
self.assertEquals(u'abcdé', self._render(u'abcdé', TYPE_STR))
def test_compare_num_pct(self):
self.assertEquals((1.0, u'+100.0\xa0%'),
self._compare_and_render(100, 50))
self.assertEquals((0.5, u'+50.0\xa0%'),
self._compare_and_render(75, 50))
self.assertEquals((0.5, u'+50.0\xa0%'),
self._compare_and_render(-25, -50))
self.assertEquals((1.0, u'+100.0\xa0%'),
self._compare_and_render(0, -50))
self.assertEquals((2.0, u'+200.0\xa0%'),
self._compare_and_render(50, -50))
self.assertEquals((-0.5, u'\u201150.0\xa0%'),
self._compare_and_render(25, 50))
self.assertEquals((-1.0, u'\u2011100.0\xa0%'),
self._compare_and_render(0, 50))
self.assertEquals((-2.0, u'\u2011200.0\xa0%'),
self._compare_and_render(-50, 50))
self.assertEquals((-0.5, u'\u201150.0\xa0%'),
self._compare_and_render(-75, -50))
self.assertEquals((AccountingNone, u''),
self._compare_and_render(50, AccountingNone))
self.assertEquals((AccountingNone, u''),
self._compare_and_render(50, None))
self.assertEquals((-1.0, u'\u2011100.0\xa0%'),
self._compare_and_render(AccountingNone, 50))
self.assertEquals((-1.0, u'\u2011100.0\xa0%'),
self._compare_and_render(None, 50))
def test_compare_num_diff(self):
self.assertEquals((25, u'+25'),
self._compare_and_render(75, 50,
TYPE_NUM, CMP_DIFF))
self.assertEquals((-25, u'\u201125'),
self._compare_and_render(25, 50,
TYPE_NUM, CMP_DIFF))
self.style.suffix_inherit = False
self.style.suffix = u''
self.assertEquals((-25, u'\u201125\xa0'),
self._compare_and_render(25, 50,
TYPE_NUM, CMP_DIFF))
self.style.suffix = u''
self.assertEquals((50.0, u'+50'),
self._compare_and_render(50, AccountingNone,
TYPE_NUM, CMP_DIFF))
self.assertEquals((50.0, u'+50'),
self._compare_and_render(50, None,
TYPE_NUM, CMP_DIFF))
self.assertEquals((-50.0, u'\u201150'),
self._compare_and_render(AccountingNone, 50,
TYPE_NUM, CMP_DIFF))
self.assertEquals((-50.0, u'\u201150'),
self._compare_and_render(None, 50,
TYPE_NUM, CMP_DIFF))
def test_compare_pct(self):
self.assertEquals((0.25, u'+25\xa0pp'),
self._compare_and_render(0.75, 0.50, TYPE_PCT))

12
mis_builder/tests/test_simple_array.py

@ -0,0 +1,12 @@
# -*- coding: utf-8 -*-
# © 2014-2015 ACSONE SA/NV (<http://acsone.eu>)
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
import doctest
from openerp.addons.mis_builder.models import simple_array
def load_tests(loader, tests, ignore):
tests.addTests(doctest.DocTestSuite(simple_array))
return tests

25
mis_builder/tests/test_utc_midnight.py

@ -0,0 +1,25 @@
# -*- coding: utf-8 -*-
# © 2014-2015 ACSONE SA/NV (<http://acsone.eu>)
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
import openerp.tests.common as common
from ..models.mis_report import _utc_midnight
class TestUtcMidnight(common.TransactionCase):
def test_utc_midnight(self):
date_to_convert = '2014-07-05'
date_time_convert = _utc_midnight(
date_to_convert, 'Europe/Brussels')
self.assertEqual(date_time_convert, '2014-07-04 22:00:00')
date_time_convert = _utc_midnight(
date_to_convert, 'Europe/Brussels', add_day=1)
self.assertEqual(date_time_convert, '2014-07-05 22:00:00')
date_time_convert = _utc_midnight(
date_to_convert, 'US/Pacific')
self.assertEqual(date_time_convert, '2014-07-05 07:00:00')
date_time_convert = _utc_midnight(
date_to_convert, 'US/Pacific', add_day=1)
self.assertEqual(date_time_convert, '2014-07-06 07:00:00')

211
mis_builder/views/mis_builder.xml

@ -1,211 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<openerp>
<data>
<template id="assets_backend" name="mis_builder" inherit_id="web.assets_backend">
<xpath expr="." position="inside">
<link rel="stylesheet" href="/mis_builder/static/src/css/custom.css"/>
<script type="text/javascript" src="/mis_builder/static/src/js/mis_builder.js"></script>
</xpath>
</template>
<record model="ir.ui.view" id="mis_report_view_tree">
<field name="name">mis.report.view.tree</field>
<field name="model">mis.report</field>
<field name="arch" type="xml">
<tree string="MIS Reports">
<field name="name"/>
<field name="description"/>
</tree>
</field>
</record>
<record model="ir.ui.view" id="mis_report_view_form">
<field name="name">mis.report.view.form</field>
<field name="model">mis.report</field>
<field name="arch" type="xml">
<form string="MIS Report" version="7.0">
<sheet>
<group col="2">
<field name="name"/>
<field name="description"/>
<field name="query_ids">
<tree string="Queries" editable="bottom">
<field name="name"/>
<field name="model_id"/>
<field name="field_ids" domain="[('model_id', '=', model_id)]" widget="many2many_tags"/>
<field name="field_names"/>
<field name="aggregate"/>
<field name="date_field" domain="[('model_id', '=', model_id), ('ttype', 'in', ('date', 'datetime'))]"/>
<field name="domain"/>
</tree>
</field>
<field name="kpi_ids">
<tree string="KPI's" editable="bottom">
<field name="sequence" widget="handle"/>
<field name="description"/>
<field name="name"/>
<field name="expression"/>
<field name="type"/>
<field name="dp" attrs="{'invisible': [('type', '=', 'str')]}"/>
<field name="divider" attrs="{'invisible': [('type', '=', 'str')]}"/>
<field name="suffix"/>
<field name="compare_method" attrs="{'invisible': [('type', '=', 'str')]}"/>
<field name="default_css_style"/>
<field name="css_style"/>
</tree>
</field>
</group>
<group col="2" string="Legend (for expression)">
<group>
<label colspan="2" string="Expressions are of the form &lt;field&gt;&lt;mode&gt;[accounts][domain]"/>
<label colspan="2" string="Possible values for 'field' can be:"/>
<group>
<label colspan="2" string="* bal for balance (debit - credit)"/>
<label colspan="2" string="* crd for credit"/>
<label colspan="2" string="* deb for debit"/>
</group>
<label colspan="2" string="Possible values for 'mode' are:"/>
<group>
<label colspan="2" string="* nothing or p: variation over the period"/>
<label colspan="2" string="* i: at the beginning of the period"/>
<label colspan="2" string="* e: at the end of the period"/>
</group>
<label colspan="2" string="'accounts' is a comma-separated list of account codes, possibly containing %% wildcards"/>
<label colspan="2" string="'domain' is an optional filter on move lines (eg to filter on analytic accounts or journal)"/>
</group>
<group>
<label colspan="2" string="Examples"/>
<group>
<label colspan="2" string="* bal[70]: variation of the balance of account 70 over the period (it is the same as balp[70]);"/>
<label colspan="2" string="* bali[70,60]: initial balance of accounts 70 and 60;"/>
<label colspan="2" string="* bale[1%%]: balance of accounts starting with 1 at end of period."/>
</group>
</group>
</group>
</sheet>
</form>
</field>
</record>
<record model="ir.actions.act_window" id="mis_report_view_action">
<field name="name">MIS Report Templates</field>
<field name="view_id" ref="mis_report_view_tree"/>
<field name="res_model">mis.report</field>
<field name="view_type">form</field>
<field name="view_mode">tree,form</field>
</record>
<menuitem id="mis_report_view_menu" parent="account.menu_account_reports" name="MIS Report Templates" action="mis_report_view_action" sequence="21"/>
<record id="xls_export" model="ir.actions.report.xml">
<field name="name">MIS report instance XLS report</field>
<field name="model">mis.report.instance</field>
<field name="type">ir.actions.report.xml</field>
<field name="report_name">mis.report.instance.xls</field>
<field name="report_type">xls</field>
<field name="auto" eval="False"/>
</record>
<record id="qweb_pdf_export" model="ir.actions.report.xml">
<field name="name">MIS report instance QWEB PDF report</field>
<field name="model">mis.report.instance</field>
<field name="type">ir.actions.report.xml</field>
<field name="report_name">mis_builder.report_mis_report_instance</field>
<field name="report_type">qweb-pdf</field>
<field name="auto" eval="False"/>
</record>
<record model="ir.ui.view" id="mis_report_instance_result_view_form">
<field name="name">mis.report.instance.result.view.form</field>
<field name="model">mis.report.instance</field>
<field name="priority" eval="17"/>
<field name="arch" type="xml">
<form string="MIS Report Result" version="7.0">
<widget type="mis_report"></widget>
<button icon="gtk-print" name="%(qweb_pdf_export)d" string="Print" type="action" colspan="2"/>
<button icon="gtk-execute" name="%(xls_export)d" string="Export" type="action" colspan="2"/>
</form>
</field>
</record>
<record model="ir.ui.view" id="mis_report_instance_view_tree">
<field name="name">mis.report.instance.view.tree</field>
<field name="model">mis.report.instance</field>
<field name="arch" type="xml">
<tree string="MIS Report Instances">
<field name="name"/>
<field name="description"/>
<field name="report_id"/>
<field name="target_move"/>
<field name="pivot_date"/>
<field name="company_id"/>
</tree>
</field>
</record>
<record model="ir.ui.view" id="mis_report_instance_view_form">
<field name="name">mis.report.instance.view.form</field>
<field name="model">mis.report.instance</field>
<field name="priority" eval="16"/>
<field name="arch" type="xml">
<form string="MIS Report Instance" version="7.0">
<sheet>
<div class="oe_title">
<div class="oe_edit_only">
<label for="name"/>
</div>
<h1>
<field name="name" placeholder="Name"/>
</h1>
</div>
<div class="oe_right oe_button_box" name="buttons">
<button type="object" name="preview" string="Preview" icon="gtk-print-preview" />
<button type="action" name="%(qweb_pdf_export)d" string="Print" icon="gtk-print" />
<button type="action" name="%(xls_export)d" string="Export" icon="gtk-execute" />
<button type="action" name="%(mis_report_instance_add_to_dashboard_action)d" string="Add to dashboard" icon="gtk-add" />
</div>
<group col="4">
<field name="report_id" colspan="4"/>
<field name="description"/>
<field name="landscape_pdf" />
<field name="root_account"/>
<field name="company_id" groups="base.group_multi_company"/>
<field name="date"/>
<field name="target_move"/>
<field name="period_ids" colspan="4">
<tree string="KPI's" editable="bottom" colors="red:valid==False">
<field name="sequence" widget="handle"/>
<field name="name"/>
<field name="type"/>
<field name="offset"/>
<field name="duration"/>
<field name="normalize_factor"/>
<field name="date_from"/>
<field name="date_to"/>
<field name="period_from"/>
<field name="period_to"/>
<field name="valid" invisible="1"/>
<field name="report_instance_id" invisible="1"/>
<field name="id" invisible="1"/>
<field name="comparison_column_ids" domain="[('report_instance_id', '=', report_instance_id), ('id', '!=', id)]" widget="many2many_tags"/>
</tree>
</field>
</group>
</sheet>
</form>
</field>
</record>
<record model="ir.actions.act_window" id="mis_report_instance_view_action">
<field name="name">MIS Reports</field>
<field name="view_id" ref="mis_report_instance_view_tree"/>
<field name="res_model">mis.report.instance</field>
<field name="view_type">form</field>
<field name="view_mode">tree,form</field>
</record>
<menuitem id="mis_report_instance_view_menu" parent="account.menu_finance_reports" name="MIS Reports" action="mis_report_instance_view_action" sequence="101"/>
</data>
</openerp>

170
mis_builder/views/mis_report.xml

@ -0,0 +1,170 @@
<?xml version="1.0" encoding="UTF-8"?>
<openerp>
<data>
<record model="ir.ui.view" id="mis_report_view_tree">
<field name="name">mis.report.view.tree</field>
<field name="model">mis.report</field>
<field name="arch" type="xml">
<tree string="MIS Reports">
<field name="name"/>
<field name="description"/>
</tree>
</field>
</record>
<record model="ir.ui.view" id="mis_report_view_form">
<field name="name">mis.report.view.form</field>
<field name="model">mis.report</field>
<field name="arch" type="xml">
<form string="MIS Report" version="7.0">
<sheet>
<group col="2">
<field name="name"/>
<field name="description"/>
<field name="style_id"/>
</group>
<group string="Sub KPI's">
<field name="subkpi_ids" nolabel="1" colspan="2">
<tree string="Sub KPI's" editable="bottom">
<field name="sequence" widget="handle"/>
<field name="description"/>
<field name="name"/>
</tree>
</field>
</group>
<group string="Queries">
<field name="query_ids" nolabel="1" colspan="2">
<tree string="Queries" editable="bottom">
<field name="name"/>
<field name="model_id"/>
<field name="field_ids" domain="[('model_id', '=', model_id)]" widget="many2many_tags"/>
<field name="field_names"/>
<field name="aggregate"/>
<field name="date_field" domain="[('model_id', '=', model_id), ('ttype', 'in', ('date', 'datetime'))]"/>
<field name="domain"/>
</tree>
</field>
</group>
<group string="KPI's">
<field name="kpi_ids" nolabel="1" colspan="2">
<tree string="KPI's">
<field name="sequence" widget="handle"/>
<field name="description"/>
<field name="name"/>
<field name="type"/>
<field name="compare_method" attrs="{'invisible': [('type', '=', 'str')]}"/>
<field name="multi"/>
<field name="expression"/>
</tree>
</field>
</group>
<group col="2" string="Legend (for kpi expressions)">
<group>
<label colspan="2" string="Expressions are of the form &lt;field&gt;&lt;mode&gt;[accounts][domain]"/>
<label colspan="2" string="Possible values for 'field' can be:"/>
<group>
<label colspan="2" string="* bal for balance (debit - credit)"/>
<label colspan="2" string="* crd for credit"/>
<label colspan="2" string="* deb for debit"/>
</group>
<label colspan="2" string="Possible values for 'mode' are:"/>
<group>
<label colspan="2" string="* nothing or p: variation over the period"/>
<label colspan="2" string="* i: at the beginning of the period"/>
<label colspan="2" string="* e: at the end of the period"/>
</group>
<label colspan="2" string="'accounts' is a comma-separated list of account codes, possibly containing %% wildcards"/>
<label colspan="2" string="'domain' is an optional filter on move lines (eg to filter on analytic accounts or journal)"/>
</group>
<group>
<label colspan="2" string="Examples"/>
<group>
<label colspan="2" string="* bal[70]: variation of the balance of account 70 over the period (it is the same as balp[70]);"/>
<label colspan="2" string="* bali[70,60]: initial balance of accounts 70 and 60;"/>
<label colspan="2" string="* bale[1%%]: balance of accounts starting with 1 at end of period."/>
</group>
</group>
</group>
</sheet>
</form>
</field>
</record>
<record id="mis_report_view_kpi_form" model="ir.ui.view">
<field name="name">mis.report.view.kpi.form</field>
<field name="model">mis.report.kpi</field>
<field name="arch" type="xml">
<form string="MIS Report KPI" version="7.0">
<group col="4">
<field name="description"/>
<field name="name"/>
<field name="type"/>
<field name="compare_method"/>
<field name="style_id"/>
<field name="style_expression"/>
<!--<field name="sequence" />-->
</group>
<group string="Expression">
<field name="multi"/>
<field name="expression_ids" colspan="4" nolabel="1"
delete="0" create="0"
attrs="{'invisible': [('multi', '=', False)]}">
<tree editable="bottom">
<field name="subkpi_id"/>
<field name="name"/>
</tree>
</field>
<field name="expression" colspan="4" nolabel="1"
attrs="{'invisible': [('multi', '=', True)],
'readonly': [('multi', '=', True)]}"/>
</group>
<group col="4" string="Auto expand">
<field name="auto_expand_accounts"/>
<field name="auto_expand_accounts_style_id"
attrs="{'invisible': [('auto_expand_accounts', '!=', True)]}"/>
</group>
<group col="2" string="Legend (for kpi expressions)">
<group>
<label colspan="2" string="Expressions are of the form &lt;field&gt;&lt;mode&gt;[accounts][domain]"/>
<label colspan="2" string="Possible values for 'field' can be:"/>
<group>
<label colspan="2" string="* bal for balance (debit - credit)"/>
<label colspan="2" string="* crd for credit"/>
<label colspan="2" string="* deb for debit"/>
</group>
<label colspan="2" string="Possible values for 'mode' are:"/>
<group>
<label colspan="2" string="* nothing or p: variation over the period"/>
<label colspan="2" string="* i: at the beginning of the period"/>
<label colspan="2" string="* e: at the end of the period"/>
</group>
<label colspan="2" string="'accounts' is a comma-separated list of account codes, possibly containing %% wildcards"/>
<label colspan="2" string="'domain' is an optional filter on move lines (eg to filter on analytic accounts or journal)"/>
</group>
<group>
<label colspan="2" string="Examples"/>
<group>
<label colspan="2" string="* bal[70]: variation of the balance of account 70 over the period (it is the same as balp[70]);"/>
<label colspan="2" string="* bali[70,60]: initial balance of accounts 70 and 60;"/>
<label colspan="2" string="* bale[1%%]: balance of accounts starting with 1 at end of period."/>
</group>
</group>
</group>
</form>
</field>
</record>
<record model="ir.actions.act_window" id="mis_report_view_action">
<field name="name">MIS Report Templates</field>
<field name="view_id" ref="mis_report_view_tree"/>
<field name="res_model">mis.report</field>
<field name="view_type">form</field>
<field name="view_mode">tree,form</field>
</record>
<menuitem id="mis_report_view_menu" parent="account.menu_account_reports" name="MIS Report Templates" action="mis_report_view_action" sequence="21"/>
</data>
</openerp>

204
mis_builder/views/mis_report_instance.xml

@ -0,0 +1,204 @@
<?xml version="1.0" encoding="UTF-8"?>
<openerp>
<data>
<template id="assets_backend" name="mis_builder" inherit_id="web.assets_backend">
<xpath expr="." position="inside">
<link rel="stylesheet" href="/mis_builder/static/src/css/custom.css"/>
<script type="text/javascript" src="/mis_builder/static/src/js/mis_builder.js"></script>
</xpath>
</template>
<record model="ir.ui.view" id="mis_report_instance_result_view_form">
<field name="name">mis.report.instance.result.view.form</field>
<field name="model">mis.report.instance</field>
<field name="priority" eval="20 "/>
<field name="arch" type="xml">
<form string="MIS Report Result" version="7.0" edit="false" create="false" delete="false" >
<widget type="mis_report"></widget>
</form>
</field>
</record>
<record model="ir.ui.view" id="mis_report_instance_view_tree">
<field name="name">mis.report.instance.view.tree</field>
<field name="model">mis.report.instance</field>
<field name="arch" type="xml">
<tree string="MIS Report Instances">
<button name="preview" type="object" icon="gtk-print-preview" />
<button type="object" name="print_pdf" string="Print" icon="gtk-print" />
<button type="object" name="export_xls" string="Export" icon="gtk-go-down" />
<field name="name"/>
<field name="report_id" string="Template"/>
<field name="company_id" groups="base.group_multi_company"/>
<field name="target_move"/>
<field name="pivot_date"/>
</tree>
</field>
</record>
<record model="ir.ui.view" id="mis_report_instance_view_form">
<field name="name">mis.report.instance.view.form</field>
<field name="model">mis.report.instance</field>
<field name="priority" eval="15"/>
<field name="arch" type="xml">
<form string="MIS Report Instance" version="7.0">
<sheet>
<field name="temporary" invisible="1"/>
<div class="oe_read_only oe_right oe_button_box" name="buttons">
<button type="object" name="preview" string="Preview" icon="gtk-print-preview" />
<button type="object" name="print_pdf" string="Print" icon="gtk-print" />
<button type="object" name="export_xls" string="Export" icon="gtk-go-down" />
<button type="action" name="%(mis_report_instance_add_to_dashboard_action)d" string="Add to dashboard" icon="gtk-add" attrs="{'invisible': [('temporary', '=', True)]}"/>
<button type="object" name="save_report" string="Save" icon="gtk-floppy" attrs="{'invisible': [('temporary', '=', False)]}"/>
</div>
<div class="oe_title">
<div class="oe_edit_only">
<label for="name"/>
</div>
<h1>
<field name="name" placeholder="Name"/>
</h1>
<field name="description"/>
</div>
<group>
<group>
<field name="report_id" string="Template"/>
<field name="company_id" groups="base.group_multi_company"/>
<field name="target_move" widget="radio"/>
<field name="landscape_pdf"/>
<field name="comparison_mode"/>
</group>
<group>
<group name="simple_mode"
attrs="{'invisible': [('comparison_mode', '=', True)]}" colspan="4">
<field name="date_range_id"/>
<field name="date_from" attrs="{'required': [('comparison_mode', '=', False)]}"/>
<field name="date_to" attrs="{'required': [('comparison_mode', '=', False)]}"/>
</group>
</group>
</group>
<group name="comparison_mode" string="Comparison"
attrs="{'invisible': [('comparison_mode', '=', False)]}" colspan="4">
<field name="period_ids" colspan="4" nolabel="1" attrs="{'required': [('comparison_mode', '=', True)]}">
<tree string="KPI's" colors="red:valid==False">
<field name="sequence" widget="handle"/>
<field name="name"/>
<field name="date_from"/>
<field name="date_to"/>
<field name="valid" invisible="1"/>
<field name="report_instance_id" invisible="1"/>
<field name="id" invisible="1"/>
<field name="subkpi_ids"
domain="[('report_id', '=', parent.report_id)]"
widget="many2many_tags"/>
<field name="comparison_column_ids" domain="[('report_instance_id', '=', report_instance_id), ('id', '!=', id)]" widget="many2many_tags"/>
</tree>
</field>
<field name="date"/>
</group>
</sheet>
</form>
</field>
</record>
<record model="ir.actions.act_window" id="mis_report_instance_view_action">
<field name="name">MIS Reports</field>
<field name="view_id" ref="mis_report_instance_view_tree"/>
<field name="res_model">mis.report.instance</field>
<field name="view_type">form</field>
<field name="view_mode">tree,form</field>
<field name="domain">[('temporary', '=', False)]</field>
</record>
<menuitem id="mis_report_instance_view_menu" parent="account.menu_finance_reports" name="MIS Reports" action="mis_report_instance_view_action" sequence="101"/>
<record id="wizard_mis_report_instance_view_form" model="ir.ui.view">
<field name="model">mis.report.instance</field>
<field name="inherit_id" ref="mis_builder.mis_report_instance_view_form"/>
<field name="mode">primary</field>
<field name="arch" type="xml">
<field name="name" position="attributes">
<attribute name="readonly">1</attribute>
</field>
<label for="name" position="replace"/>
<field name="report_id" position="attributes">
<attribute name="invisible">1</attribute>
</field>
<div name="buttons" position="attributes">
<attribute name="invisible">1</attribute>
</div>
<sheet position="after">
<footer>
<button type="object" name="save_report" string="Save" icon="gtk-floppy"/>
<button type="object" name="preview" string="Preview" icon="gtk-print-preview" />
<button type="object" name="print_pdf" string="Print" icon="gtk-print" />
<button type="object" name="export_xls" string="Export" icon="gtk-go-down" />
or <button string="Cancel" class="oe_link" special="cancel" />
</footer>
</sheet>
</field>
</record>
<record model="ir.actions.act_window" id="last_mis_report_instance_view_action">
<field name="name">Last Reports Generated</field>
<field name="view_id" ref="mis_report_instance_view_tree"/>
<field name="res_model">mis.report.instance</field>
<field name="view_type">form</field>
<field name="view_mode">tree,form</field>
<field name="domain">[('temporary', '=', True)]</field>
</record>
<menuitem id="last_wizard_mis_report_instance_view_menu"
parent="account.menu_finance_reports"
name="Last Reports Generated"
action="last_mis_report_instance_view_action"
sequence="102"/>
<record model="ir.ui.view" id="mis_report_instance_period_view_form">
<field name="model">mis.report.instance.period</field>
<field name="priority" eval="16"/>
<field name="arch" type="xml">
<form string="KPI's">
<sheet>
<group>
<field name="mode" widget="radio"/>
<field name="valid" invisible="1"/>
<group name="relative" attrs="{'invisible': [('mode', '!=', 'relative')]}" colspan="4">
<group>
<field name="type" attrs="{'required': [('mode', '=', 'relative')]}"/>
<field name="date_range_type_id"
attrs="{'invisible': [('type', '!=', 'date_range')], 'required': [('type', '=', 'date_range')]}"/>
<field name="offset"/>
<field name="duration"/>
</group>
<group>
<field name="date_from"/>
<field name="date_to"/>
</group>
</group>
<group name="fix" attrs="{'invisible': [('mode', '!=', 'fix')]}" colspan="4">
<field name="date_range_id"/>
<field name="manual_date_from"
attrs="{'required': [('mode', '=', 'fix')]}"/>
<field name="manual_date_to"
attrs="{'required': [('mode', '=', 'fix')]}"/>
</group>
<field name="name" placeholder="Name"/>
<field name="normalize_factor"/>
<field name="report_instance_id" invisible="1"/>
<field name="id" invisible="1"/>
<field name="subkpi_ids"
domain="[('report_id', '=', parent.report_id)]"
widget="many2many_tags"/>
<field name="comparison_column_ids"
domain="[('report_instance_id', '=', report_instance_id), ('id', '!=', id)]"
widget="many2many_tags"/>
</group>
</sheet>
</form>
</field>
</record>
</data>
</openerp>

80
mis_builder/views/mis_report_style.xml

@ -0,0 +1,80 @@
<?xml version="1.0" encoding="UTF-8"?>
<openerp>
<data>
<record model="ir.ui.view" id="mis_report_style_view_tree">
<field name="name">mis.report.style.view.tree</field>
<field name="model">mis.report.style</field>
<field name="arch" type="xml">
<tree string="MIS Report Styles">
<field name="name"/>
</tree>
</field>
</record>
<record id="mis_report_style_view_form" model="ir.ui.view">
<field name="name">mis.report.style.view.form</field>
<field name="model">mis.report.style</field>
<field name="arch" type="xml">
<form string="MIS Report Style" version="7.0">
<sheet>
<group string="Style" col="2">
<field name="name" />
</group>
<group string="Number" col="4">
<field name="dp_inherit" string="Rounding inherit"/>
<field name="dp"
attrs="{'invisible': [('dp_inherit', '=', True)]}"/>
<field name="divider_inherit" string="Factor inherit"/>
<field name="divider"
attrs="{'invisible': [('divider_inherit', '=', True)]}"/>
<field name="prefix_inherit"/>
<field name="prefix"
attrs="{'invisible': [('prefix_inherit', '=', True)]}"/>
<field name="suffix_inherit"/>
<field name="suffix"
attrs="{'invisible': [('suffix_inherit', '=', True)]}"/>
</group>
<group string="Color" col="4">
<field name="color_inherit" />
<field name="color"
attrs="{'invisible': [('color_inherit', '=', True)]}"
widget="color" />
<field name="background_color_inherit" />
<field name="background_color"
attrs="{'invisible': [('background_color_inherit', '=', True)]}"
widget="color" />
</group>
<group string="Font" col="4">
<field name="font_style_inherit" />
<field name="font_style"
attrs="{'invisible': [('font_style_inherit', '=', True)]}" />
<field name="font_weight_inherit" />
<field name="font_weight"
attrs="{'invisible': [('font_weight_inherit', '=', True)]}" />
<field name="font_size_inherit" />
<field name="font_size"
attrs="{'invisible': [('font_size_inherit', '=', True)]}" />
</group>
<group string="Indent" col="4">
<field name="indent_level_inherit" />
<field name="indent_level"
attrs="{'invisible': [('indent_level_inherit', '=', True)]}" />
</group>
</sheet>
</form>
</field>
</record>
<record model="ir.actions.act_window" id="mis_report_style_view_action">
<field name="name">MIS Report Styles</field>
<field name="view_id" ref="mis_report_style_view_tree"/>
<field name="res_model">mis.report.style</field>
<field name="view_type">form</field>
<field name="view_mode">tree,form</field>
</record>
<menuitem id="mis_report_style_view_menu" parent="account.menu_account_reports" name="MIS Report Styles" action="mis_report_style_view_action" sequence="22"/>
</data>
</openerp>

26
mis_builder/wizard/__init__.py

@ -1,25 +1,5 @@
# -*- encoding: utf-8 -*-
##############################################################################
#
# mis_builder module for Odoo, Management Information System Builder
# Copyright (C) 2014-2015 ACSONE SA/NV (<http://acsone.eu>)
#
# This file is a part of mis_builder
#
# mis_builder is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License v3 or later
# as published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# mis_builder is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License v3 or later for more details.
#
# You should have received a copy of the GNU Affero General Public License
# v3 or later along with this program.
# If not, see <http://www.gnu.org/licenses/>.
#
##############################################################################
# -*- coding: utf-8 -*-
# © 2014-2015 ACSONE SA/NV (<http://acsone.eu>)
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
from . import mis_builder_dashboard

111
mis_builder/wizard/mis_builder_dashboard.py

@ -1,87 +1,66 @@
# -*- encoding: utf-8 -*-
##############################################################################
#
# mis_builder module for Odoo, Management Information System Builder
# Copyright (C) 2014-2015 ACSONE SA/NV (<http://acsone.eu>)
#
# This file is a part of mis_builder
#
# mis_builder is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License v3 or later
# as published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# mis_builder is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License v3 or later for more details.
#
# You should have received a copy of the GNU Affero General Public License
# v3 or later along with this program.
# If not, see <http://www.gnu.org/licenses/>.
#
##############################################################################
# -*- coding: utf-8 -*-
# © 2014-2015 ACSONE SA/NV (<http://acsone.eu>)
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
from openerp.osv import orm, fields
from openerp import api, fields, models
from lxml import etree
class add_mis_report_instance_dashboard(orm.TransientModel):
class AddMisReportInstanceDashboard(models.TransientModel):
_name = "add.mis.report.instance.dashboard.wizard"
_columns = {'name': fields.char('Name', size=32, required=True),
'dashboard_id': fields.many2one(
'ir.actions.act_window',
string="Dashboard", required=True,
domain="[('res_model', '=', 'board.board')]"),
}
name = fields.Char('Name', size=32, required=True)
def default_get(self, cr, uid, fields, context=None):
if context is None:
context = {}
if context.get('active_id'):
res = super(add_mis_report_instance_dashboard, self).default_get(
cr, uid, fields, context=context)
dashboard_id = fields.Many2one('ir.actions.act_window',
string="Dashboard", required=True,
domain="[('res_model', '=', "
"'board.board')]")
@api.model
def default_get(self, fields):
res = {}
if self.env.context.get('active_id', False):
res = super(AddMisReportInstanceDashboard, self).default_get(
fields)
# get report instance name
res['name'] = self.pool['mis.report.instance'].read(
cr, uid, context['active_id'], ['name'])['name']
res['name'] = self.env['mis.report.instance'].browse(
self.env.context['active_id']).name
return res
def action_add_to_dashboard(self, cr, uid, ids, context=None):
if context is None:
context = {}
assert 'active_id' in context, "active_id missing in context"
wizard_data = self.browse(cr, uid, ids, context=context)[0]
@api.multi
def action_add_to_dashboard(self):
assert self.env.context.get('active_id', False), \
"active_id missing in context"
# create the act_window corresponding to this report
view_id = self.pool['ir.model.data'].get_object_reference(
cr, uid, 'mis_builder', 'mis_report_instance_result_view_form')[1]
report_result = self.pool['ir.actions.act_window'].create(
cr, uid,
self.env.ref('mis_builder.mis_report_instance_result_view_form')
view = self.env.ref(
'mis_builder.mis_report_instance_result_view_form')
report_result = self.env['ir.actions.act_window'].create(
{'name': 'mis.report.instance.result.view.action.%d'
% context['active_id'],
% self.env.context['active_id'],
'res_model': 'mis.report.instance',
'res_id': context['active_id'],
'res_id': self.env.context['active_id'],
'target': 'current',
'view_mode': 'form',
'view_id': view_id})
'view_id': view.id})
# add this result in the selected dashboard
last_customization = self.pool['ir.ui.view.custom'].search(
cr, uid,
[('user_id', '=', uid),
('ref_id', '=', wizard_data.dashboard_id.view_id.id)], limit=1)
arch = wizard_data.dashboard_id.view_id.arch
last_customization = self.env['ir.ui.view.custom'].search(
[('user_id', '=', self.env.uid),
('ref_id', '=', self.dashboard_id.view_id.id)], limit=1)
arch = self.dashboard_id.view_id.arch
if last_customization:
arch = self.pool['ir.ui.view.custom'].read(
cr, uid, last_customization[0], ['arch'])['arch']
arch = self.env['ir.ui.view.custom'].browse(
last_customization[0].id).arch
new_arch = etree.fromstring(arch)
column = new_arch.xpath("//column")[0]
column.append(etree.Element('action', {'context': str(context),
'name': str(report_result),
'string': wizard_data.name,
'view_mode': 'form'}))
self.pool['ir.ui.view.custom'].create(
cr, uid, {'user_id': uid,
'ref_id': wizard_data.dashboard_id.view_id.id,
'arch': etree.tostring(new_arch, pretty_print=True)})
column.append(etree.Element('action', {'context': str(
self.env.context),
'name': str(report_result.id),
'string': self.name,
'view_mode': 'form'}))
self.env['ir.ui.view.custom'].create(
{'user_id': self.env.uid,
'ref_id': self.dashboard_id.view_id.id,
'arch': etree.tostring(new_arch, pretty_print=True)})
return {'type': 'ir.actions.act_window_close', }

2
mis_builder/wizard/mis_builder_dashboard.xml

@ -30,4 +30,4 @@
</record>
</data>
</openerp>
</openerp>

2
mis_builder_demo/__openerp__.py

@ -24,7 +24,7 @@
{
'name': 'MIS Builder demo data',
'version': '8.0.0.1.0',
'version': '9.0.1.0.0',
'category': 'Reporting',
'summary': """
Demo data for the mis_builder module

2
oca_dependencies.txt

@ -1,3 +1,5 @@
# list the OCA project dependencies, one per line
# add a github url if you need a forked version
reporting-engine
server-tools
web
Loading…
Cancel
Save