diff --git a/.travis.yml b/.travis.yml index a47cc6ad..48740fc9 100644 --- a/.travis.yml +++ b/.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 diff --git a/account_financial_report_webkit/report/aged_partner_balance.py b/account_financial_report_webkit/report/aged_partner_balance.py index fd9cb550..9275af2f 100644 --- a/account_financial_report_webkit/report/aged_partner_balance.py +++ b/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: diff --git a/account_financial_report_webkit/report/common_partner_balance_reports.py b/account_financial_report_webkit/report/common_partner_balance_reports.py index b291a8bf..87755388 100644 --- a/account_financial_report_webkit/report/common_partner_balance_reports.py +++ b/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 diff --git a/account_financial_report_webkit/report/common_reports.py b/account_financial_report_webkit/report/common_reports.py index 11c4203d..cd7af7eb 100644 --- a/account_financial_report_webkit/report/common_reports.py +++ b/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'): diff --git a/account_financial_report_webkit/report/open_invoices.py b/account_financial_report_webkit/report/open_invoices.py index 3afdd099..158acf85 100644 --- a/account_financial_report_webkit/report/open_invoices.py +++ b/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( diff --git a/account_financial_report_webkit/report/partners_ledger.py b/account_financial_report_webkit/report/partners_ledger.py index df66efac..dc0eeed6 100644 --- a/account_financial_report_webkit/report/partners_ledger.py +++ b/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] = {} diff --git a/account_financial_report_webkit/report/webkit_parser_header_fix.py b/account_financial_report_webkit/report/webkit_parser_header_fix.py index a92c90fa..9484aaf0 100644 --- a/account_financial_report_webkit/report/webkit_parser_header_fix.py +++ b/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): diff --git a/account_financial_report_webkit_xls/report/general_ledger_xls.py b/account_financial_report_webkit_xls/report/general_ledger_xls.py index 401ba3b7..52a3d759 100644 --- a/account_financial_report_webkit_xls/report/general_ledger_xls.py +++ b/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 \ diff --git a/account_financial_report_webkit_xls/report/open_invoices_xls.py b/account_financial_report_webkit_xls/report/open_invoices_xls.py index 5180c658..08d2135e 100644 --- a/account_financial_report_webkit_xls/report/open_invoices_xls.py +++ b/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 += [ diff --git a/account_financial_report_webkit_xls/report/partners_balance_xls.py b/account_financial_report_webkit_xls/report/partners_balance_xls.py index 7c16af9c..9255b567 100644 --- a/account_financial_report_webkit_xls/report/partners_balance_xls.py +++ b/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 + ' - ' + diff --git a/account_financial_report_webkit_xls/report/trial_balance_xls.py b/account_financial_report_webkit_xls/report/trial_balance_xls.py index b82fba80..476375a0 100644 --- a/account_financial_report_webkit_xls/report/trial_balance_xls.py +++ b/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', diff --git a/account_journal_report_xls/report/nov_account_journal.py b/account_journal_report_xls/report/nov_account_journal.py index 2b0278c3..f400d14e 100644 --- a/account_journal_report_xls/report/nov_account_journal.py +++ b/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( diff --git a/mis_builder/CHANGES.rst b/mis_builder/CHANGES.rst new file mode 100644 index 00000000..02d9d002 --- /dev/null +++ b/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. diff --git a/mis_builder/README.rst b/mis_builder/README.rst index a9cbf083..4ea2e57d 100644 --- a/mis_builder/README.rst +++ b/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 * Laetitia Gangloff * Adrien Peiffer +* Alexis de Lattre +* Alexandre Fayolle +* Jordi Ballester +* Thomas Binsfeld +* Giovanni Capalbo +* Marco Calcagni +* Sébastien Beau +* Laurent Mignon Maintainer ---------- diff --git a/mis_builder/__init__.py b/mis_builder/__init__.py index 386e1d1d..409475d8 100644 --- a/mis_builder/__init__.py +++ b/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 () -# -# 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 . -# -############################################################################## +# -*- coding: utf-8 -*- +# © 2014-2015 ACSONE SA/NV () +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). from . import models from . import wizard diff --git a/mis_builder/__openerp__.py b/mis_builder/__openerp__.py index d84f07b6..3d8f0d8e 100644 --- a/mis_builder/__openerp__.py +++ b/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 () -# -# 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 . -# -############################################################################## +# -*- coding: utf-8 -*- +# © 2014-2015 ACSONE SA/NV () +# 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', } diff --git a/mis_builder/datas/ir_cron.xml b/mis_builder/datas/ir_cron.xml new file mode 100644 index 00000000..199770c8 --- /dev/null +++ b/mis_builder/datas/ir_cron.xml @@ -0,0 +1,18 @@ + + + + + + Vacuum temporary reports + 4 + hours + -1 + + + + + + + + + diff --git a/mis_builder/migrations/8.0.0.2/pre-migration.py b/mis_builder/migrations/8.0.0.2/pre-migration.py index 6530bda0..8b08c619 100644 --- a/mis_builder/migrations/8.0.0.2/pre-migration.py +++ b/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 diff --git a/mis_builder/models/__init__.py b/mis_builder/models/__init__.py index e99af9da..e632fa44 100644 --- a/mis_builder/models/__init__.py +++ b/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 () -# -# 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 . -# -############################################################################## +# -*- coding: utf-8 -*- +# © 2014-2015 ACSONE SA/NV () +# 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 diff --git a/mis_builder/models/accounting_none.py b/mis_builder/models/accounting_none.py new file mode 100644 index 00000000..de65ac1f --- /dev/null +++ b/mis_builder/models/accounting_none.py @@ -0,0 +1,191 @@ +# -*- coding: utf-8 -*- +# © 2016 Thomas Binsfeld +# © 2016 ACSONE SA/NV () +# 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() diff --git a/mis_builder/models/aep.py b/mis_builder/models/aep.py index 740a58ec..25f8970d 100644 --- a/mis_builder/models/aep.py +++ b/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 () -# -# 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 . -# -############################################################################## +# -*- coding: utf-8 -*- +# © 2014-2015 ACSONE SA/NV () +# 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\bbal|\bcrd|\bdeb)" - r"(?P[pise])?" - r"(?P_[a-zA-Z0-9]+|\[.*?\])" - r"(?P\[.*?\])?") - - 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\bbal|\bcrd|\bdeb)" + r"(?P[piseu])?" + r"(?P_[a-zA-Z0-9]+|\[.*?\])" + r"(?P\[.*?\])?") + + 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()))) diff --git a/mis_builder/models/aggregate.py b/mis_builder/models/aggregate.py index 4788c4c2..bd2aa3c3 100644 --- a/mis_builder/models/aggregate.py +++ b/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 () -# -# 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 . -# -############################################################################## +# -*- coding: utf-8 -*- +# © 2014-2015 ACSONE SA/NV () +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). def _sum(l): diff --git a/mis_builder/models/data_error.py b/mis_builder/models/data_error.py new file mode 100644 index 00000000..e2869c5a --- /dev/null +++ b/mis_builder/models/data_error.py @@ -0,0 +1,15 @@ +# -*- coding: utf-8 -*- +# © 2016 ACSONE SA/NV () +# © 2016 Akretion () +# 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 diff --git a/mis_builder/models/mis_builder.py b/mis_builder/models/mis_builder.py deleted file mode 100644 index 423fb94d..00000000 --- a/mis_builder/models/mis_builder.py +++ /dev/null @@ -1,751 +0,0 @@ -# -*- encoding: utf-8 -*- -############################################################################## -# -# mis_builder module for Odoo, Management Information System Builder -# Copyright (C) 2014-2015 ACSONE SA/NV () -# -# 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 . -# -############################################################################## - -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} diff --git a/mis_builder/models/mis_report.py b/mis_builder/models/mis_report.py new file mode 100644 index 00000000..ed8f8215 --- /dev/null +++ b/mis_builder/models/mis_report.py @@ -0,0 +1,1021 @@ +# -*- coding: utf-8 -*- +# © 2014-2016 ACSONE SA/NV () +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from collections import defaultdict, OrderedDict +import datetime +import dateutil +from itertools import izip +import logging +import re +import time + +import pytz + +from openerp import api, fields, models, _ +from openerp.exceptions import UserError +from openerp.tools.safe_eval import safe_eval + +from .aep import AccountingExpressionProcessor as AEP +from .aggregate import _sum, _avg, _min, _max +from .accounting_none import AccountingNone +from .simple_array import SimpleArray +from .mis_safe_eval import mis_safe_eval, DataError, NameDataError +from .mis_report_style import ( + TYPE_NUM, TYPE_PCT, TYPE_STR, CMP_DIFF, CMP_PCT, CMP_NONE +) + +_logger = logging.getLogger(__name__) + + +class AutoStruct(object): + + def __init__(self, **kwargs): + for k, v in kwargs.items(): + setattr(self, k, v) + + +class KpiMatrixRow(object): + + # TODO: ultimately, the kpi matrix will become ignorant of KPI's and + # accounts and know about rows, columns, sub columns and styles only. + # It is already ignorant of period and only knowns about columns. + # This will require a correct abstraction for expanding row details. + + def __init__(self, matrix, kpi, account_id=None, parent_row=None): + self._matrix = matrix + self.kpi = kpi + self.account_id = account_id + self.description = '' + self.parent_row = parent_row + if not self.account_id: + self.style_props = self._matrix._style_model.merge([ + self.kpi.report_id.style_id, + self.kpi.style_id]) + else: + self.style_props = self._matrix._style_model.merge([ + self.kpi.report_id.style_id, + self.kpi.auto_expand_accounts_style_id]) + + @property + def label(self): + if not self.account_id: + return self.kpi.description + else: + return self._matrix.get_account_name(self.account_id) + + @property + def row_id(self): + if not self.account_id: + return self.kpi.name + else: + return '{}:{}'.format(self.kpi.name, self.account_id) + + def iter_cell_tuples(self, cols=None): + if cols is None: + cols = self._matrix.iter_cols() + for col in cols: + yield col.get_cell_tuple_for_row(self) + + def iter_cells(self, subcols=None): + if subcols is None: + subcols = self._matrix.iter_subcols() + for subcol in subcols: + yield subcol.get_cell_for_row(self) + + +class KpiMatrixCol(object): + + def __init__(self, label, description, locals_dict, subkpis): + self.label = label + self.description = description + self.locals_dict = locals_dict + self.colspan = subkpis and len(subkpis) or 1 + self._subcols = [] + self.subkpis = subkpis + if not subkpis: + subcol = KpiMatrixSubCol(self, '', '', 0) + self._subcols.append(subcol) + else: + for i, subkpi in enumerate(subkpis): + subcol = KpiMatrixSubCol(self, subkpi.description, '', i) + self._subcols.append(subcol) + self._cell_tuples_by_row = {} # {row: (cells tuple)} + + def _set_cell_tuple(self, row, cell_tuple): + self._cell_tuples_by_row[row] = cell_tuple + + def iter_subcols(self): + return self._subcols + + def iter_cell_tuples(self): + return self._cells_by_row.values() + + def get_cell_tuple_for_row(self, row): + return self._cell_tuples_by_row.get(row) + + +class KpiMatrixSubCol(object): + + def __init__(self, col, label, description, index=0): + self.col = col + self.label = label + self.description = description + self.index = index + + @property + def subkpi(self): + if self.col.subkpis: + return self.col.subkpis[self.index] + + def iter_cells(self): + for cell_tuple in self.col.iter_cell_tuples(): + yield cell_tuple[self.index] + + def get_cell_for_row(self, row): + cell_tuple = self.col.get_cell_tuple_for_row(row) + if cell_tuple is None: + return None + return cell_tuple[self.index] + + +class KpiMatrixCell(object): + + def __init__(self, row, subcol, + val, val_rendered, val_comment, + style_props, + drilldown_arg): + self.row = row + self.subcol = subcol + self.val = val + self.val_rendered = val_rendered + self.val_comment = val_comment + self.style_props = style_props + self.drilldown_arg = drilldown_arg + + +class KpiMatrix(object): + + def __init__(self, env): + # cache language id for faster rendering + lang_model = env['res.lang'] + lang_id = lang_model._lang_get(env.user.lang) + self.lang = lang_model.browse(lang_id) + self._style_model = env['mis.report.style'] + self._account_model = env['account.account'] + # data structures + # { kpi: KpiMatrixRow } + self._kpi_rows = OrderedDict() + # { kpi: {account_id: KpiMatrixRow} } + self._detail_rows = {} + # { col_key: KpiMatrixCol } + self._cols = OrderedDict() + # { col_key (left of comparison): [(col_key, base_col_key)] } + self._comparison_todo = defaultdict(list) + self._comparison_cols = defaultdict(list) + # { account_id: account_name } + self._account_names = {} + + def declare_kpi(self, kpi): + """ Declare a new kpi (row) in the matrix. + + Invoke this first for all kpi, in display order. + """ + self._kpi_rows[kpi] = KpiMatrixRow(self, kpi) + self._detail_rows[kpi] = {} + + def declare_col(self, col_key, label, description, + locals_dict, subkpis): + """ Declare a new column, giving it an identifier (key). + + Invoke this and declare_comparison in display order. + """ + col = KpiMatrixCol(label, description, locals_dict, subkpis) + self._cols[col_key] = col + return col + + def declare_comparison(self, col_key, base_col_key): + """ Declare a new comparison column. + + Invoke this and declare_col in display order. + """ + last_col_key = list(self._cols.keys())[-1] + self._comparison_todo[last_col_key].append( + (col_key, base_col_key)) + + def set_values(self, kpi, col_key, vals, + drilldown_args): + """ Set values for a kpi and a colum. + + Invoke this after declaring the kpi and the column. + """ + self.set_values_detail_account(kpi, col_key, None, vals, + drilldown_args) + + def set_values_detail_account(self, kpi, col_key, account_id, vals, + drilldown_args): + """ Set values for a kpi and a column and a detail account. + + Invoke this after declaring the kpi and the column. + """ + if not account_id: + row = self._kpi_rows[kpi] + else: + kpi_row = self._kpi_rows[kpi] + if account_id in self._detail_rows[kpi]: + row = self._detail_rows[kpi][account_id] + else: + row = KpiMatrixRow(self, kpi, account_id, parent_row=kpi_row) + self._detail_rows[kpi][account_id] = row + col = self._cols[col_key] + cell_tuple = [] + assert len(vals) == col.colspan + assert len(drilldown_args) == col.colspan + for val, drilldown_arg, subcol in \ + izip(vals, drilldown_args, col.iter_subcols()): + if isinstance(val, DataError): + val_rendered = val.name + val_comment = val.msg + else: + val_rendered = self._style_model.render( + self.lang, row.style_props, kpi.type, val) + if subcol.subkpi: + val_comment = u'{}.{} = {}'.format( + row.kpi.name, + subcol.subkpi.name, + row.kpi.get_expression_for_subkpi(subcol.subkpi)) + else: + val_comment = u'{} = {}'.format( + row.kpi.name, + row.kpi.expression) + cell_style_props = row.style_props + if row.kpi.style_expression: + # evaluate style expression + try: + style_name = mis_safe_eval(row.kpi.style_expression, + col.locals_dict) + except: + _logger.error("Error evaluating style expression <%s>", + row.kpi.style_expression, exc_info=True) + if style_name: + style = self._style_model.search( + [('name', '=', style_name)]) + if style: + cell_style_props = self._style_model.merge( + [row.style_props, style[0]]) + else: + _logger.error("Style '%s' not found.", style_name) + cell = KpiMatrixCell(row, subcol, val, val_rendered, val_comment, + cell_style_props, drilldown_arg) + cell_tuple.append(cell) + assert len(cell_tuple) == col.colspan + col._set_cell_tuple(row, cell_tuple) + + def compute_comparisons(self): + """ Compute comparisons. + + Invoke this after setting all values. + """ + for pos_col_key, comparisons in self._comparison_todo.items(): + for col_key, base_col_key in comparisons: + col = self._cols[col_key] + base_col = self._cols[base_col_key] + common_subkpis = set(col.subkpis) & set(base_col.subkpis) + if not common_subkpis: + raise UserError('Columns {} and {} are not comparable'. + format(col.description, + base_col.description)) + label = u'{} vs {}'.\ + format(col.label, base_col.label) + comparison_col = KpiMatrixCol(label, None, {}, + sorted(common_subkpis, + key=lambda s: s.sequence)) + for row in self.iter_rows(): + cell_tuple = col.get_cell_tuple_for_row(row) + base_cell_tuple = base_col.get_cell_tuple_for_row(row) + if cell_tuple is None and base_cell_tuple is None: + continue + if cell_tuple is None: + vals = [AccountingNone] * len(common_subkpis) + else: + vals = [cell.val for cell in cell_tuple + if cell.subcol.subkpi in common_subkpis] + if base_cell_tuple is None: + base_vals = [AccountingNone] * len(common_subkpis) + else: + base_vals = [cell.val for cell in base_cell_tuple + if cell.subcol.subkpi in common_subkpis] + comparison_cell_tuple = [] + for val, base_val, comparison_subcol in \ + izip(vals, + base_vals, + comparison_col.iter_subcols()): + # TODO FIXME average factors + delta, delta_r, style_r = \ + self._style_model.compare_and_render( + self.lang, row.style_props, + row.kpi.type, row.kpi.compare_method, + val, base_val, 1, 1) + comparison_cell_tuple.append(KpiMatrixCell( + row, comparison_subcol, delta, delta_r, None, + style_r, None)) + comparison_col._set_cell_tuple(row, comparison_cell_tuple) + self._comparison_cols[pos_col_key].append(comparison_col) + + def iter_rows(self): + """ Iterate rows in display order. + + yields KpiMatrixRow. + """ + for kpi_row in self._kpi_rows.values(): + yield kpi_row + detail_rows = self._detail_rows[kpi_row.kpi].values() + detail_rows = sorted(detail_rows, key=lambda r: r.description) + for detail_row in detail_rows: + yield detail_row + + def iter_cols(self): + """ Iterate columns in display order. + + yields KpiMatrixCol: one for each column or comparison. + """ + for col_key, col in self._cols.items(): + yield col + for comparison_col in self._comparison_cols[col_key]: + yield comparison_col + + def iter_subcols(self): + """ Iterate sub columns in display order. + + yields KpiMatrixSubCol: one for each subkpi in each column + and comparison. + """ + for col in self.iter_cols(): + for subcol in col.iter_subcols(): + yield subcol + + def _load_account_names(self): + account_ids = set() + for detail_rows in self._detail_rows.values(): + account_ids.update(detail_rows.keys()) + account_ids = list(account_ids) + accounts = self._account_model.search([('id', 'in', account_ids)]) + self._account_names = {a.id: u'{} {}'.format(a.code, a.name) + for a in accounts} + + def get_account_name(self, account_id): + if account_id not in self._account_names: + self._load_account_names() + return self._account_names[account_id] + + def as_dict(self): + header = [{'cols': []}, {'cols': []}] + for col in self.iter_cols(): + header[0]['cols'].append({ + 'label': col.label, + 'description': col.description, + 'colspan': col.colspan, + }) + for subcol in col.iter_subcols(): + header[1]['cols'].append({ + 'label': subcol.label, + 'description': subcol.description, + 'colspan': 1, + }) + + body = [] + for row in self.iter_rows(): + row_data = { + 'row_id': row.row_id, + 'parent_row_id': (row.parent_row and + row.parent_row.row_id or None), + 'label': row.label, + 'description': row.description, + 'style': self._style_model.to_css_style( + row.style_props), + 'cells': [] + } + for cell in row.iter_cells(): + if cell is None: + # TODO use subcol style here + row_data['cells'].append({}) + else: + if cell.val is AccountingNone or \ + isinstance(cell.val, DataError): + val = None + else: + val = cell.val + col_data = { + 'val': val, + 'val_r': cell.val_rendered, + 'val_c': cell.val_comment, + 'style': self._style_model.to_css_style( + cell.style_props), + } + if cell.drilldown_arg: + col_data['drilldown_arg'] = cell.drilldown_arg + row_data['cells'].append(col_data) + body.append(row_data) + + return { + 'header': header, + 'body': body, + } + + +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 prefix, 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) + multi = fields.Boolean() + expression = fields.Char( + compute='_compute_expression', + inverse='_inverse_expression') + expression_ids = fields.One2many('mis.report.kpi.expression', 'kpi_id') + auto_expand_accounts = fields.Boolean(string='Display details by account') + auto_expand_accounts_style_id = fields.Many2one( + string="Style for account detail rows", + comodel_name="mis.report.style", + required=False + ) + style_id = fields.Many2one( + string="Style", + comodel_name="mis.report.style", + required=False + ) + style_expression = fields.Char( + string='Style expression', + help='An expression that returns a style depending on the KPI value. ' + 'Such style is applied on top of the row style.') + type = fields.Selection([(TYPE_NUM, _('Numeric')), + (TYPE_PCT, _('Percentage')), + (TYPE_STR, _('String'))], + required=True, + string='Value type', + default=TYPE_NUM) + compare_method = fields.Selection([(CMP_DIFF, _('Difference')), + (CMP_PCT, _('Percentage')), + (CMP_NONE, _('None'))], + required=True, + string='Comparison Method', + default=CMP_PCT) + sequence = fields.Integer(string='Sequence', default=100) + report_id = fields.Many2one('mis.report', + string='Report', + ondelete='cascade') + + _order = 'sequence, id' + + @api.constrains('name') + def _check_name(self): + for record in self: + if not _is_valid_python_var(record.name): + raise UserError(_('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.multi + def _compute_expression(self): + for kpi in self: + l = [] + for expression in kpi.expression_ids: + if expression.subkpi_id: + l.append(u'{}\xa0=\xa0{}'.format( + expression.subkpi_id.name, expression.name)) + else: + l.append( + expression.name or 'AccountingNone') + kpi.expression = ',\n'.join(l) + + @api.multi + def _inverse_expression(self): + for kpi in self: + if kpi.multi: + raise UserError(_('Can not update a multi kpi from ' + 'the kpi line')) + if kpi.expression_ids: + kpi.expression_ids[0].write({ + 'name': kpi.expression, + 'subkpi_id': None}) + for expression in kpi.expression_ids[1:]: + expression.unlink() + else: + kpi.write({ + 'expression_ids': [(0, 0, { + 'name': kpi.expression + })] + }) + + @api.onchange('multi') + def _onchange_multi(self): + for kpi in self: + if not kpi.multi: + if kpi.expression_ids: + kpi.expression = kpi.expression_ids[0].name + else: + kpi.expression = None + else: + expressions = [] + for subkpi in kpi.report_id.subkpi_ids: + expressions.append((0, 0, { + 'name': kpi.expression, + 'subkpi_id': subkpi.id, + })) + kpi.expression_ids = expressions + + @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 == TYPE_NUM: + self.compare_method = CMP_PCT + elif self.type == TYPE_PCT: + self.compare_method = CMP_DIFF + elif self.type == TYPE_STR: + self.compare_method = CMP_NONE + + def get_expression_for_subkpi(self, subkpi): + for expression in self.expression_ids: + if expression.subkpi_id == subkpi: + return expression.name + + +class MisReportSubkpi(models.Model): + _name = 'mis.report.subkpi' + _order = 'sequence' + + sequence = fields.Integer() + report_id = fields.Many2one('mis.report') + name = fields.Char(size=32, required=True, + string='Name') + description = fields.Char(required=True, + string='Description', + translate=True) + expression_ids = fields.One2many('mis.report.kpi.expression', 'subkpi_id') + + @api.constrains('name') + def _check_name(self): + for record in self: + if not _is_valid_python_var(record.name): + raise UserError(_('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.multi + def unlink(self): + for subkpi in self: + subkpi.expression_ids.unlink() + return super(MisReportSubkpi, self).unlink() + + +class MisReportKpiExpression(models.Model): + """ A KPI Expression is an expression of a line of a MIS report Kpi. + It's used to compute the kpi value. + """ + + _name = 'mis.report.kpi.expression' + _order = 'sequence, name' + + sequence = fields.Integer( + related='subkpi_id.sequence', + store=True, + readonly=True) + name = fields.Char(string='Expression') + kpi_id = fields.Many2one('mis.report.kpi') + # TODO FIXME set readonly=True when onchange('subkpi_ids') below works + subkpi_id = fields.Many2one( + 'mis.report.subkpi', + readonly=False) + + _sql_constraints = [ + ('subkpi_kpi_unique', 'unique(subkpi_id, kpi_id)', + 'Sub KPI must be used once and only once for each KPI'), + ] + + +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.depends('field_ids') + def _compute_field_names(self): + for record in self: + field_names = [field.name for field in record.field_ids] + record.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.constrains('name') + def _check_name(self): + for record in self: + if not _is_valid_python_var(record.name): + raise UserError(_('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) + style_id = fields.Many2one(string="Style", + comodel_name="mis.report.style") + query_ids = fields.One2many('mis.report.query', 'report_id', + string='Queries', + copy=True) + kpi_ids = fields.One2many('mis.report.kpi', 'report_id', + string='KPI\'s', + copy=True) + subkpi_ids = fields.One2many('mis.report.subkpi', 'report_id', + string="Sub KPI", + copy=True) + + @api.onchange('subkpi_ids') + def _on_change_subkpi_ids(self): + """ Update kpi expressions when subkpis change on the report, + so the list of kpi expressions is always up-to-date """ + for kpi in self.kpi_ids: + if not kpi.multi: + continue + new_subkpis = set([subkpi for subkpi in self.subkpi_ids]) + expressions = [] + for expression in kpi.expression_ids: + assert expression.subkpi_id # must be true if kpi is multi + if expression.subkpi_id not in self.subkpi_ids: + expressions.append((2, expression.id, None)) # remove + else: + new_subkpis.remove(expression.subkpi_id) # no change + for subkpi in new_subkpis: + # TODO FIXME this does not work, while the remove above works + expressions.append((0, None, { + 'name': False, + 'subkpi_id': subkpi.id, + })) # add empty expressions for new subkpis + if expressions: + kpi.expressions_ids = expressions + + @api.multi + def get_wizard_report_action(self): + action = self.env.ref('mis_builder.mis_report_instance_view_action') + res = action.read()[0] + view = self.env.ref('mis_builder.wizard_mis_report_instance_view_form') + res.update({ + 'view_id': view.id, + 'views': [(view.id, 'form')], + 'target': 'new', + 'context': { + 'default_report_id': self.id, + 'default_name': self.name, + 'default_temporary': True, + } + }) + return res + + @api.multi + def copy(self, default=None): + self.ensure_one() + default = dict(default or {}) + default['name'] = _('%s (copy)') % self.name + return super(MisReport, self).copy(default) + + # TODO: kpi name cannot be start with query name + + @api.multi + def prepare_kpi_matrix(self): + self.ensure_one() + kpi_matrix = KpiMatrix(self.env) + for kpi in self.kpi_ids: + kpi_matrix.declare_kpi(kpi) + return kpi_matrix + + @api.multi + def prepare_aep(self, company): + self.ensure_one() + aep = AEP(company) + for kpi in self.kpi_ids: + for expression in kpi.expression_ids: + aep.parse_expr(expression.name) + aep.done_parsing() + return aep + + @api.multi + def _fetch_queries(self, date_from, date_to, + get_additional_query_filter=None): + self.ensure_one() + res = {} + for query in self.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 [] + if get_additional_query_filter: + domain.extend(get_additional_query_filter(query)) + if query.date_field.ttype == 'date': + domain.extend([(query.date_field.name, '>=', date_from), + (query.date_field.name, '<=', date_to)]) + else: + datetime_from = _utc_midnight( + date_from, self._context.get('tz', 'UTC')) + datetime_to = _utc_midnight( + 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] + all_stored = all([model._fields[f].store for f in field_names]) + if not query.aggregate: + data = model.search_read(domain, field_names) + res[query.name] = [AutoStruct(**d) for d in data] + elif query.aggregate == 'sum' and all_stored: + # use read_group to sum stored fields + data = model.read_group( + domain, field_names, []) + s = AutoStruct(count=data[0]['__count']) + for field_name in field_names: + try: + v = data[0][field_name] + except KeyError: + _logger.error('field %s not found in read_group ' + 'for %s; not summable?', + field_name, model._name) + v = AccountingNone + 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 + elif query.aggregate == 'sum': + agg = _sum + for field_name in field_names: + setattr(s, field_name, + agg([d[field_name] for d in data])) + res[query.name] = s + return res + + @api.multi + def declare_and_compute_period(self, kpi_matrix, + col_key, + col_label, + col_description, + aep, + date_from, date_to, + target_move, + company, + subkpis_filter=None, + get_additional_move_line_filter=None, + get_additional_query_filter=None): + """ Evaluate a report for a given period, populating a KpiMatrix. + + :param kpi_matrix: the KpiMatrix object to be populated created + with prepare_kpi_matrix() + :param col_key: the period key to use when populating the KpiMatrix + :param aep: an AccountingExpressionProcessor instance created + using _prepare_aep() + :param date_from, date_to: the starting and ending date + :param target_move: all|posted + :param company: + :param get_additional_move_line_filter: a bound method that takes + no arguments and returns + a domain compatible with + account.move.line + :param get_additional_query_filter: a bound method that takes a single + query argument and returns a + domain compatible with the query + underlying model + """ + self.ensure_one() + + locals_dict = { + 'sum': _sum, + 'min': _min, + 'max': _max, + 'len': len, + 'avg': _avg, + 'AccountingNone': AccountingNone, + 'SimpleArray': SimpleArray, + } + + # fetch non-accounting queries + locals_dict.update(self._fetch_queries( + date_from, date_to, get_additional_query_filter)) + + # use AEP to do the accounting queries + additional_move_line_filter = None + if get_additional_move_line_filter: + additional_move_line_filter = get_additional_move_line_filter() + aep.do_queries(date_from, date_to, + target_move, + additional_move_line_filter) + + if subkpis_filter: + subkpis = [subkpi for subkpi in self.subkpi_ids + if subkpi in subkpis_filter] + else: + subkpis = self.subkpi_ids + col = kpi_matrix.declare_col(col_key, + col_label, col_description, + locals_dict, subkpis) + + compute_queue = self.kpi_ids + recompute_queue = [] + while True: + for kpi in compute_queue: + # build the list of expressions for this kpi + expressions = [] + for expression in kpi.expression_ids: + if expression.subkpi_id and \ + subkpis_filter and \ + expression.subkpi_id not in subkpis_filter: + continue + expressions.append(expression.name) + + vals = [] + drilldown_args = [] + name_error = False + for expression in expressions: + replaced_expr = aep.replace_expr(expression) + vals.append( + mis_safe_eval(replaced_expr, locals_dict)) + if isinstance(vals[-1], NameDataError): + name_error = True + if replaced_expr != expression: + drilldown_args.append({ + 'period_id': col_key, + 'expr': expression, + }) + else: + drilldown_args.append(None) + if name_error: + recompute_queue.append(kpi) + else: + # no error, set it in locals_dict so it can be used + # in computing other kpis + if len(expressions) == 1: + locals_dict[kpi.name] = vals[0] + else: + locals_dict[kpi.name] = SimpleArray(vals) + + # even in case of name error we set the result in the matrix + # so the name error will be displayed if it cannot be + # resolved by recomputing later + if len(expressions) == 1 and col.colspan > 1: + if isinstance(vals[0], tuple): + vals = vals[0] + assert len(vals) == col.colspan + elif isinstance(vals[0], DataError): + vals = (vals[0],) * col.colspan + else: + raise UserError(_("Probably not your fault... but I'm " + "really curious to know how you " + "managed to raise this error so " + "I can handle one more corner " + "case!")) + if len(drilldown_args) != col.colspan: + drilldown_args = [None] * col.colspan + kpi_matrix.set_values( + kpi, col_key, vals, drilldown_args) + + if not kpi.auto_expand_accounts or name_error: + continue + + for account_id, replaced_exprs in \ + aep.replace_exprs_by_account_id(expressions): + vals = [] + drilldown_args = [] + for expression, replaced_expr in \ + izip(expressions, replaced_exprs): + vals.append(mis_safe_eval(replaced_expr, locals_dict)) + if replaced_expr != expression: + drilldown_args.append({ + 'period_id': col_key, + 'expr': expression, + 'account_id': account_id + }) + else: + drilldown_args.append(None) + kpi_matrix.set_values_detail_account( + kpi, col_key, account_id, vals, drilldown_args) + + 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 Name errors or cyclic dependency) + # so we stop trying + break + # try again + compute_queue = recompute_queue + recompute_queue = [] diff --git a/mis_builder/models/mis_report_instance.py b/mis_builder/models/mis_report_instance.py new file mode 100644 index 00000000..8d436343 --- /dev/null +++ b/mis_builder/models/mis_report_instance.py @@ -0,0 +1,422 @@ +# -*- coding: utf-8 -*- +# © 2014-2016 ACSONE SA/NV () +# 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 diff --git a/mis_builder/models/mis_report_style.py b/mis_builder/models/mis_report_style.py new file mode 100644 index 00000000..20fb236f --- /dev/null +++ b/mis_builder/models/mis_report_style.py @@ -0,0 +1,276 @@ +# -*- coding: utf-8 -*- +# © 2016 Therp BV () +# © 2016 ACSONE SA/NV () +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from openerp import api, fields, models, _ +from openerp.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 diff --git a/mis_builder/models/mis_safe_eval.py b/mis_builder/models/mis_safe_eval.py new file mode 100644 index 00000000..995ff76c --- /dev/null +++ b/mis_builder/models/mis_safe_eval.py @@ -0,0 +1,33 @@ +# -*- coding: utf-8 -*- +# © 2016 ACSONE SA/NV () +# 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 diff --git a/mis_builder/models/simple_array.py b/mis_builder/models/simple_array.py new file mode 100644 index 00000000..b9b10b86 --- /dev/null +++ b/mis_builder/models/simple_array.py @@ -0,0 +1,131 @@ +# -*- coding: utf-8 -*- +# © 2014-2015 ACSONE SA/NV () +# 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() diff --git a/mis_builder/report/__init__.py b/mis_builder/report/__init__.py index 671c3f7b..592a2ccf 100644 --- a/mis_builder/report/__init__.py +++ b/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 () -# -# 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 . -# -############################################################################## +# -*- coding: utf-8 -*- +# © 2014-2015 ACSONE SA/NV () +# 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 diff --git a/mis_builder/report/mis_builder_xls.py b/mis_builder/report/mis_builder_xls.py deleted file mode 100644 index 88dcd1cf..00000000 --- a/mis_builder/report/mis_builder_xls.py +++ /dev/null @@ -1,138 +0,0 @@ -# -*- encoding: utf-8 -*- -############################################################################## -# -# mis_builder module for Odoo, Management Information System Builder -# Copyright (C) 2014-2015 ACSONE SA/NV () -# -# 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 . -# -############################################################################## - -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) diff --git a/mis_builder/report/mis_report_instance_qweb.py b/mis_builder/report/mis_report_instance_qweb.py new file mode 100644 index 00000000..162b146a --- /dev/null +++ b/mis_builder/report/mis_report_instance_qweb.py @@ -0,0 +1,27 @@ +# -*- coding: utf-8 -*- +# © 2014-2015 ACSONE SA/NV () +# 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) diff --git a/mis_builder/report/mis_report_instance_qweb.xml b/mis_builder/report/mis_report_instance_qweb.xml new file mode 100644 index 00000000..769034bc --- /dev/null +++ b/mis_builder/report/mis_report_instance_qweb.xml @@ -0,0 +1,89 @@ + + + + + + MIS report instance QWEB PDF report + mis.report.instance + ir.actions.report.xml + mis_builder.report_mis_report_instance + qweb-pdf + + + + + + + + + + + diff --git a/mis_builder/report/mis_report_instance_xlsx.py b/mis_builder/report/mis_report_instance_xlsx.py new file mode 100644 index 00000000..68cc1a91 --- /dev/null +++ b/mis_builder/report/mis_report_instance_xlsx.py @@ -0,0 +1,140 @@ +# -*- coding: utf-8 -*- +# © 2014-2016 ACSONE SA/NV () +# 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) diff --git a/mis_builder/report/mis_report_instance_xlsx.xml b/mis_builder/report/mis_report_instance_xlsx.xml new file mode 100644 index 00000000..e3695648 --- /dev/null +++ b/mis_builder/report/mis_report_instance_xlsx.xml @@ -0,0 +1,15 @@ + + + + + + MIS report instance XLS report + mis.report.instance + ir.actions.report.xml + mis.report.instance.xlsx + xlsx + + + + + diff --git a/mis_builder/report/report_mis_report_instance.py b/mis_builder/report/report_mis_report_instance.py deleted file mode 100644 index 9305081c..00000000 --- a/mis_builder/report/report_mis_report_instance.py +++ /dev/null @@ -1,67 +0,0 @@ -# -*- encoding: utf-8 -*- -############################################################################## -# -# mis_builder module for Odoo, Management Information System Builder -# Copyright (C) 2014-2015 ACSONE SA/NV () -# -# 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 . -# -############################################################################## - -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) diff --git a/mis_builder/report/report_mis_report_instance.xml b/mis_builder/report/report_mis_report_instance.xml deleted file mode 100644 index 3d8de2be..00000000 --- a/mis_builder/report/report_mis_report_instance.xml +++ /dev/null @@ -1,55 +0,0 @@ - - - - - - - - diff --git a/mis_builder/security/ir.model.access.csv b/mis_builder/security/ir.model.access.csv index 9f552f1f..f6ee127c 100644 --- a/mis_builder/security/ir.model.access.csv +++ b/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 diff --git a/mis_builder/static/src/css/custom.css b/mis_builder/static/src/css/custom.css index 44be83a4..063eb2d6 100644 --- a/mis_builder/static/src/css/custom.css +++ b/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; +} diff --git a/mis_builder/static/src/css/report.css b/mis_builder/static/src/css/report.css new file mode 100644 index 00000000..581a8560 --- /dev/null +++ b/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; +} diff --git a/mis_builder/static/src/js/mis_builder.js b/mis_builder/static/src/js/mis_builder.js index df369e06..977786cf 100644 --- a/mis_builder/static/src/js/mis_builder.js +++ b/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); +}); diff --git a/mis_builder/static/src/xml/mis_widget.xml b/mis_builder/static/src/xml/mis_widget.xml index 5cfd9994..e720722d 100644 --- a/mis_builder/static/src/xml/mis_widget.xml +++ b/mis_builder/static/src/xml/mis_widget.xml @@ -1,62 +1,56 @@ diff --git a/mis_builder/tests/__init__.py b/mis_builder/tests/__init__.py index 42df0b2e..354d62fd 100644 --- a/mis_builder/tests/__init__.py +++ b/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 () -# -# 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 . -# -############################################################################## +# -*- coding: utf-8 -*- +# © 2014-2015 ACSONE SA/NV () +# 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 diff --git a/mis_builder/tests/mis.report.csv b/mis_builder/tests/mis.report.csv index 51b55125..761c37a3 100644 --- a/mis_builder/tests/mis.report.csv +++ b/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" \ No newline at end of file +"id","description","kpi_ids/id","name","query_ids/id" +"mis_report_test","","mis_report_kpi_test","Test report","mis_report_query_test" diff --git a/mis_builder/tests/mis.report.instance.csv b/mis_builder/tests/mis.report.instance.csv index f53ed012..73776865 100644 --- a/mis_builder/tests/mis.report.instance.csv +++ b/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" \ No newline at end of file +"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" diff --git a/mis_builder/tests/mis.report.instance.period.csv b/mis_builder/tests/mis.report.instance.period.csv index 72250953..73bd7580 100644 --- a/mis_builder/tests/mis.report.instance.period.csv +++ b/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","" \ No newline at end of file +"id","duration","name","offset","type","sequence","mode" +"mis_report_instance_period_test","1","today","","Day","","relative" diff --git a/mis_builder/tests/mis.report.kpi.csv b/mis_builder/tests/mis.report.kpi.csv index 1c53b999..16a834d2 100644 --- a/mis_builder/tests/mis.report.kpi.csv +++ b/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","" \ No newline at end of file +"id","description","expression","name" +"mis_report_kpi_test","total test","len(test)","total_test" diff --git a/mis_builder/tests/mis.report.query.csv b/mis_builder/tests/mis.report.query.csv index 060a82f0..6ef1ac21 100644 --- a/mis_builder/tests/mis.report.query.csv +++ b/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" \ No newline at end of file +"mis_report_query_test","analytic.field_account_analytic_line_date","","analytic.field_account_analytic_line_amount","analytic.model_account_analytic_line","test" diff --git a/mis_builder/tests/test_accounting_none.py b/mis_builder/tests/test_accounting_none.py new file mode 100644 index 00000000..6a410228 --- /dev/null +++ b/mis_builder/tests/test_accounting_none.py @@ -0,0 +1,12 @@ +# -*- coding: utf-8 -*- +# © 2014-2015 ACSONE SA/NV () +# 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 diff --git a/mis_builder/tests/test_aep.py b/mis_builder/tests/test_aep.py new file mode 100644 index 00000000..5b1f349a --- /dev/null +++ b/mis_builder/tests/test_aep.py @@ -0,0 +1,226 @@ +# -*- coding: utf-8 -*- +# © 2014-2015 ACSONE SA/NV () +# 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)) diff --git a/mis_builder/tests/test_aggregate.py b/mis_builder/tests/test_aggregate.py new file mode 100644 index 00000000..ca92a4d1 --- /dev/null +++ b/mis_builder/tests/test_aggregate.py @@ -0,0 +1,12 @@ +# -*- coding: utf-8 -*- +# © 2014-2015 ACSONE SA/NV () +# 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 diff --git a/mis_builder/tests/test_fetch_query.py b/mis_builder/tests/test_fetch_query.py new file mode 100644 index 00000000..f701e735 --- /dev/null +++ b/mis_builder/tests/test_fetch_query.py @@ -0,0 +1,41 @@ +# -*- coding: utf-8 -*- +# © 2014-2016 ACSONE SA/NV () +# 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) diff --git a/mis_builder/tests/test_mis_builder.py b/mis_builder/tests/test_mis_builder.py deleted file mode 100644 index 4533f003..00000000 --- a/mis_builder/tests/test_mis_builder.py +++ /dev/null @@ -1,82 +0,0 @@ -# -*- encoding: utf-8 -*- -############################################################################## -# -# mis_builder module for Odoo, Management Information System Builder -# Copyright (C) 2014-2015 ACSONE SA/NV () -# -# 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 . -# -############################################################################## - -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) diff --git a/mis_builder/tests/test_mis_report_instance.py b/mis_builder/tests/test_mis_report_instance.py new file mode 100644 index 00000000..052b8601 --- /dev/null +++ b/mis_builder/tests/test_mis_report_instance.py @@ -0,0 +1,141 @@ +# -*- coding: utf-8 -*- +# © 2016 ACSONE SA/NV () +# 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') diff --git a/mis_builder/tests/test_mis_safe_eval.py b/mis_builder/tests/test_mis_safe_eval.py new file mode 100644 index 00000000..f2cf9b06 --- /dev/null +++ b/mis_builder/tests/test_mis_safe_eval.py @@ -0,0 +1,27 @@ +# -*- coding: utf-8 -*- +# © 2016 ACSONE SA/NV () +# 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') diff --git a/mis_builder/tests/test_render.py b/mis_builder/tests/test_render.py new file mode 100644 index 00000000..4a08a6ab --- /dev/null +++ b/mis_builder/tests/test_render.py @@ -0,0 +1,158 @@ +# -*- coding: utf-8 -*- +# © 2016 ACSONE SA/NV () +# 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)) diff --git a/mis_builder/tests/test_simple_array.py b/mis_builder/tests/test_simple_array.py new file mode 100644 index 00000000..ab59eed0 --- /dev/null +++ b/mis_builder/tests/test_simple_array.py @@ -0,0 +1,12 @@ +# -*- coding: utf-8 -*- +# © 2014-2015 ACSONE SA/NV () +# 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 diff --git a/mis_builder/tests/test_utc_midnight.py b/mis_builder/tests/test_utc_midnight.py new file mode 100644 index 00000000..4e5d3df2 --- /dev/null +++ b/mis_builder/tests/test_utc_midnight.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +# © 2014-2015 ACSONE SA/NV () +# 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') diff --git a/mis_builder/views/mis_builder.xml b/mis_builder/views/mis_builder.xml deleted file mode 100644 index 31551f10..00000000 --- a/mis_builder/views/mis_builder.xml +++ /dev/null @@ -1,211 +0,0 @@ - - - - - - - - mis.report.view.tree - mis.report - - - - - - - - - - mis.report.view.form - mis.report - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-
-
- - - MIS Report Templates - - mis.report - form - tree,form - - - - - - MIS report instance XLS report - mis.report.instance - ir.actions.report.xml - mis.report.instance.xls - xls - - - - - MIS report instance QWEB PDF report - mis.report.instance - ir.actions.report.xml - mis_builder.report_mis_report_instance - qweb-pdf - - - - - mis.report.instance.result.view.form - mis.report.instance - - -
- -