diff --git a/mis_builder/README.rst b/mis_builder/README.rst new file mode 100644 index 00000000..8bf1d332 --- /dev/null +++ b/mis_builder/README.rst @@ -0,0 +1,92 @@ +.. image:: https://img.shields.io/badge/licence-AGPL--3-blue.svg + :alt: License: AGPL-3 + +MIS Builder +=========== + +This module allows you to build Management Information Systems dashboards. +Such style of reports presents KPI in rows and time periods in columns. +Reports mainly fetch data from account moves, but can also combine data coming +from arbitrary Odoo models. Reports can be exported to PDF, Excel and they +can be added to Odoo dashboards. + +Installation +============ + +There is no specific installation procedure for this module. + +Configuration and Usage +======================= + +To configure this module, you need to: + +* Go to Accounting > Configuration > Financial Reports > MIS Report Templates where + you can create report templates by defining KPI's. KPI's constitute the rows of your + reports. Such report templates are time independent. + +.. figure:: static/description/ex_report_template.png + :scale: 80 % + :alt: Sample report template + +* Then in Accounting > Reporting > MIS Reports you can create report instance by + binding the templates to time period, hence defining the columns of your reports. + +.. figure:: static/description/ex_report.png + :alt: Sample report configuration + +* From the MIS Report view, you can preview the report, add it to and Odoo dashboard, + and export it to PDF or Excel. + +.. figure:: static/description/ex_dashboard.png + :alt: Sample dashboard view + +For further information, please visit: + +* https://www.odoo.com/forum/help-1 + +Known issues / Roadmap +====================== + +* 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: + + * one account charts with a few normal accounts and view accounts, + * two fiscal years, + * an opening entry in the second fiscal year, + * to test multi-company consolidation, we need a second company with it's own + account chart and two fiscal years, but without opening entry; we also need + a third company which is the parent of the other two and has a consolidation + chart of account. + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us smashing it by providing a detailed and welcomed feedback +`here `_. + +Credits +======= + +Contributors +------------ + +* Stéphane Bidoul +* Laetitia Gangloff +* Adrien Peiffer + +Maintainer +---------- + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +This module is maintained by the OCA. + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +To contribute to this module, please visit http://odoo-community.org. diff --git a/mis_builder/__init__.py b/mis_builder/__init__.py new file mode 100644 index 00000000..386e1d1d --- /dev/null +++ b/mis_builder/__init__.py @@ -0,0 +1,27 @@ +# -*- 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 . +# +############################################################################## + +from . import models +from . import wizard +from . import report diff --git a/mis_builder/__openerp__.py b/mis_builder/__openerp__.py new file mode 100644 index 00000000..713bedde --- /dev/null +++ b/mis_builder/__openerp__.py @@ -0,0 +1,62 @@ +# -*- 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 . +# +############################################################################## + +{ + 'name': 'MIS Builder', + 'version': '0.2', + 'category': 'Reporting', + 'summary': """ + Build 'Management Information System' Reports and Dashboards + """, + 'author': 'ACSONE SA/NV,' + 'Odoo Community Association (OCA)', + 'website': 'http://acsone.eu', + 'depends': [ + 'account', + 'report_xls', # OCA/reporting-engine + ], + 'data': [ + 'wizard/mis_builder_dashboard.xml', + 'views/mis_builder.xml', + 'security/ir.model.access.csv', + 'security/mis_builder_security.xml', + 'report/report_mis_report_instance.xml', + ], + 'test': [ + ], + 'demo': [ + 'tests/mis.report.kpi.csv', + 'tests/mis.report.query.csv', + 'tests/mis.report.csv', + 'tests/mis.report.instance.period.csv', + 'tests/mis.report.instance.csv', + ], + 'qweb': [ + 'static/src/xml/*.xml' + ], + 'installable': True, + 'application': True, + 'auto_install': False, + 'license': 'AGPL-3', +} diff --git a/mis_builder/migrations/8.0.0.2/pre-migration.py b/mis_builder/migrations/8.0.0.2/pre-migration.py new file mode 100644 index 00000000..6530bda0 --- /dev/null +++ b/mis_builder/migrations/8.0.0.2/pre-migration.py @@ -0,0 +1,42 @@ +# -*- 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 . +# +############################################################################## + + +def migrate(cr, version): + if not version: + return + + cr.execute(""" + ALTER TABLE mis_report_instance + ADD COLUMN root_account INTEGER + """) + cr.execute(""" + UPDATE mis_report_instance + SET root_account = ( + SELECT id FROM account_account + WHERE parent_id IS NULL + AND company_id = mis_report_instance.company_id + LIMIT 1 + ) + """) diff --git a/mis_builder/models/__init__.py b/mis_builder/models/__init__.py new file mode 100644 index 00000000..e99af9da --- /dev/null +++ b/mis_builder/models/__init__.py @@ -0,0 +1,26 @@ +# -*- 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 . +# +############################################################################## + +from . import mis_builder +from . import aep diff --git a/mis_builder/models/aep.py b/mis_builder/models/aep.py new file mode 100644 index 00000000..b8d3664e --- /dev/null +++ b/mis_builder/models/aep.py @@ -0,0 +1,382 @@ +# -*- 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 re +from collections import defaultdict + +from openerp.exceptions import Warning +from openerp.osv import expression +from openerp.tools.safe_eval import safe_eval +from openerp.tools.translate import _ + +MODE_VARIATION = 'p' +MODE_INITIAL = 'i' +MODE_END = 'e' + + +class AccountingExpressionProcessor(object): + """ Processor for accounting expressions. + + Expressions of the form [accounts][optional move line domain] + are supported, where: + * field is bal, crd, deb + * mode is i (initial balance), e (ending balance), + p (moves over period) + * accounts is a list of accounts, possibly containing % wildcards + * an optional domain on move lines allowing filters on eg analytic + accounts or journal + + Examples: + * bal[70]: variation of the balance of moves on account 70 + over the period (it is the same as balp[70]); + * bali[70,60]: balance of accounts 70 and 60 at the start of period; + * bale[1%]: balance of accounts starting with 1 at end of period. + + How to use: + * repeatedly invoke parse_expr() for each expression containing + accounting variables as described above; this lets the processor + group domains and modes and accounts; + * when all expressions have been parsed, invoke done_parsing() + to notify the processor that it can prepare to query (mainly + search all accounts - children, consolidation - that will need to + be queried; + * for each period, call do_queries(), then call replace_expr() for each + expression to replace accounting variables with their resulting value + for the given period. + + How it works: + * by accumulating the expressions before hand, it ensures to do the + strict minimum number of queries to the database (for each period, + one query per domain and mode); + * it queries using the orm read_group which reduces to a query with + sum on debit and credit and group by on account_id (note: it seems + the orm then does one query per account to fetch the account + name...); + * 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 + # before done_parsing: {(domain, mode): set(account_codes)} + # after done_parsing: {(domain, mode): list(account_ids)} + self._map_account_ids = defaultdict(set) + 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') + 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) + elif '%' in account_code: + like_codes.add(account_code) + else: + 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) + + def _parse_match_object(self, mo): + """Split a match object corresponding to an accounting variable + + Returns field, mode, [account codes], (domain expression). + """ + field, mode, account_codes, domain = mo.groups() + if not mode: + mode = MODE_VARIATION + elif mode == 's': + mode = MODE_END + if account_codes.startswith('_'): + account_codes = account_codes[1:] + else: + account_codes = account_codes[1:-1] + if account_codes.strip(): + account_codes = [a.strip() for a in account_codes.split(',')] + else: + account_codes = [None] + domain = domain or '[]' + domain = tuple(safe_eval(domain)) + return field, mode, account_codes, domain + + def parse_expr(self, expr): + """Parse an expression, extracting accounting variables. + + Domains and accounts are extracted and stored in the map + 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): + _, mode, account_codes, domain = self._parse_match_object(mo) + key = (domain, mode) + self._map_account_ids[key].update(account_codes) + + def done_parsing(self, root_account): + """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) + account_ids = set() + for account_code in account_codes: + account_ids.update(self._account_ids_by_code[account_code]) + self._map_account_ids[key] = list(account_ids) + + @classmethod + def has_account_var(cls, expr): + """Test if an string contains an accounting variable.""" + 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): + """ Get a domain on account.move.line for an expression. + + Prerequisite: done_parsing() must have been invoked. + + Returns a domain that can be used to search on account.move.line. + """ + aml_domains = [] + date_domain_by_mode = {} + 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 field == 'crd': + aml_domain.append(('credit', '>', 0)) + elif field == 'deb': + aml_domain.append(('debit', '>', 0)) + aml_domains.append(expression.normalize_domain(aml_domain)) + 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) + 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 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): + """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'] + # {(domain, mode): {account_id: (debit, credit)}} + self._data = defaultdict(dict) + domain_by_mode = {} + for key in self._map_account_ids: + domain, mode = key + 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])) + # fetch sum of debit/credit, grouped by account_id + accs = aml_model.read_group(domain, + ['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) + + def replace_expr(self, expr): + """Replace accounting variables in an expression by their amount. + + Returns a new expression string. + + 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] + v = 0.0 + 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)) + if field == 'bal': + v += debit - credit + elif field == 'deb': + v += debit + elif field == 'crd': + v += credit + return '(' + repr(v) + ')' + return self.ACC_RE.sub(f, expr) diff --git a/mis_builder/models/mis_builder.py b/mis_builder/models/mis_builder.py new file mode 100644 index 00000000..109b9fdc --- /dev/null +++ b/mis_builder/models/mis_builder.py @@ -0,0 +1,741 @@ +# -*- 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, _ +from openerp.tools.safe_eval import safe_eval + +from .aep import AccountingExpressionProcessor as AEP + +_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) + + +def _sum(l): + if not l: + return None + return sum(l) + + +def _avg(l): + if not l: + return None + return sum(l) / float(len(l)) + + +def _min(l): + if not l: + return None + return min(l) + + +def _max(l): + if not l: + return None + return max(l) + + +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): + return _is_valid_python_var(self.name) + + @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): + return _is_valid_python_var(self.name) + + +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 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) + 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 [] + 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) + + 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/report/__init__.py b/mis_builder/report/__init__.py new file mode 100644 index 00000000..c5280449 --- /dev/null +++ b/mis_builder/report/__init__.py @@ -0,0 +1,26 @@ +# -*- 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 . +# +############################################################################## + +from . import mis_builder_xls +from . import report_mis_report_instance diff --git a/mis_builder/report/mis_builder_xls.py b/mis_builder/report/mis_builder_xls.py new file mode 100644 index 00000000..88dcd1cf --- /dev/null +++ b/mis_builder/report/mis_builder_xls.py @@ -0,0 +1,138 @@ +# -*- 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/report_mis_report_instance.py b/mis_builder/report/report_mis_report_instance.py new file mode 100644 index 00000000..9305081c --- /dev/null +++ b/mis_builder/report/report_mis_report_instance.py @@ -0,0 +1,67 @@ +# -*- 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 new file mode 100644 index 00000000..3d8de2be --- /dev/null +++ b/mis_builder/report/report_mis_report_instance.xml @@ -0,0 +1,55 @@ + + + + + + + + diff --git a/mis_builder/security/ir.model.access.csv b/mis_builder/security/ir.model.access.csv new file mode 100644 index 00000000..9f552f1f --- /dev/null +++ b/mis_builder/security/ir.model.access.csv @@ -0,0 +1,11 @@ +"id","name","model_id:id","group_id:id","perm_read","perm_write","perm_create","perm_unlink" +manage_mis_report_kpi,manage_mis_report_kpi,model_mis_report_kpi,account.group_account_manager,1,1,1,1 +access_mis_report_kpi,access_mis_report_kpi,model_mis_report_kpi,base.group_user,1,0,0,0 +manage_mis_report_query,manage_mis_report_query,model_mis_report_query,account.group_account_manager,1,1,1,1 +access_mis_report_query,access_mis_report_query,model_mis_report_query,base.group_user,1,0,0,0 +manage_mis_report,manage_mis_report,model_mis_report,account.group_account_manager,1,1,1,1 +access_mis_report,access_mis_report,model_mis_report,base.group_user,1,0,0,0 +manage_mis_report_instance_period,manage_mis_report_instance_period,model_mis_report_instance_period,account.group_account_manager,1,1,1,1 +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 diff --git a/mis_builder/security/mis_builder_security.xml b/mis_builder/security/mis_builder_security.xml new file mode 100644 index 00000000..d4562df0 --- /dev/null +++ b/mis_builder/security/mis_builder_security.xml @@ -0,0 +1,13 @@ + + + + + + Mis Builder multi company + + + ['|',('company_id','=',False),('company_id','child_of',[user.company_id.id])] + + + + diff --git a/mis_builder/static/description/ex_dashboard.png b/mis_builder/static/description/ex_dashboard.png new file mode 100644 index 00000000..6c2d5b4f Binary files /dev/null and b/mis_builder/static/description/ex_dashboard.png differ diff --git a/mis_builder/static/description/ex_report.png b/mis_builder/static/description/ex_report.png new file mode 100644 index 00000000..03b882a8 Binary files /dev/null and b/mis_builder/static/description/ex_report.png differ diff --git a/mis_builder/static/description/ex_report_template.png b/mis_builder/static/description/ex_report_template.png new file mode 100644 index 00000000..f6070b87 Binary files /dev/null and b/mis_builder/static/description/ex_report_template.png differ diff --git a/mis_builder/static/description/icon.png b/mis_builder/static/description/icon.png new file mode 100644 index 00000000..3a0328b5 Binary files /dev/null and b/mis_builder/static/description/icon.png differ diff --git a/mis_builder/static/description/icon.svg b/mis_builder/static/description/icon.svg new file mode 100644 index 00000000..a7a26d09 --- /dev/null +++ b/mis_builder/static/description/icon.svg @@ -0,0 +1,79 @@ + + + + + + + + image/svg+xml + + + + + + + + + + + diff --git a/mis_builder/static/src/css/custom.css b/mis_builder/static/src/css/custom.css new file mode 100644 index 00000000..44be83a4 --- /dev/null +++ b/mis_builder/static/src/css/custom.css @@ -0,0 +1,13 @@ +.openerp .mis_builder_ralign { + text-align: right; +} + +.openerp .mis_builder a { + /* we don't want the link color, to respect user styles */ + color: inherit; +} + +.openerp .mis_builder a:hover { + /* underline links on hover to give a visual cue */ + text-decoration: underline; +} diff --git a/mis_builder/static/src/img/icon.png b/mis_builder/static/src/img/icon.png new file mode 100644 index 00000000..833ce921 Binary files /dev/null and b/mis_builder/static/src/img/icon.png differ diff --git a/mis_builder/static/src/js/mis_builder.js b/mis_builder/static/src/js/mis_builder.js new file mode 100644 index 00000000..df369e06 --- /dev/null +++ b/mis_builder/static/src/js/mis_builder.js @@ -0,0 +1,48 @@ +openerp.mis_builder = function(instance) { + + instance.mis_builder.MisReport = instance.web.form.FormWidget.extend({ + template: "mis_builder.MisReport", + + init: function() { + this._super.apply(this, arguments); + this.mis_report_data = null; + }, + + 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(); + }); + }, + + events: { + "click a.mis_builder_drilldown": "drilldown", + }, + + 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); + } + }); + } + }, + }); + + instance.web.form.custom_widgets.add('mis_report', 'instance.mis_builder.MisReport'); +} diff --git a/mis_builder/static/src/xml/mis_widget.xml b/mis_builder/static/src/xml/mis_widget.xml new file mode 100644 index 00000000..5cfd9994 --- /dev/null +++ b/mis_builder/static/src/xml/mis_widget.xml @@ -0,0 +1,62 @@ + diff --git a/mis_builder/tests/__init__.py b/mis_builder/tests/__init__.py new file mode 100644 index 00000000..42df0b2e --- /dev/null +++ b/mis_builder/tests/__init__.py @@ -0,0 +1,25 @@ +# -*- 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 . +# +############################################################################## + +from . import test_mis_builder diff --git a/mis_builder/tests/mis.report.csv b/mis_builder/tests/mis.report.csv new file mode 100644 index 00000000..51b55125 --- /dev/null +++ b/mis_builder/tests/mis.report.csv @@ -0,0 +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 diff --git a/mis_builder/tests/mis.report.instance.csv b/mis_builder/tests/mis.report.instance.csv new file mode 100644 index 00000000..f53ed012 --- /dev/null +++ b/mis_builder/tests/mis.report.instance.csv @@ -0,0 +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 diff --git a/mis_builder/tests/mis.report.instance.period.csv b/mis_builder/tests/mis.report.instance.period.csv new file mode 100644 index 00000000..72250953 --- /dev/null +++ b/mis_builder/tests/mis.report.instance.period.csv @@ -0,0 +1,2 @@ +"id","duration","name","offset","type","sequence" +"mis_report_instance_period_test","1","today","","Day","" \ No newline at end of file diff --git a/mis_builder/tests/mis.report.kpi.csv b/mis_builder/tests/mis.report.kpi.csv new file mode 100644 index 00000000..1c53b999 --- /dev/null +++ b/mis_builder/tests/mis.report.kpi.csv @@ -0,0 +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 diff --git a/mis_builder/tests/mis.report.query.csv b/mis_builder/tests/mis.report.query.csv new file mode 100644 index 00000000..060a82f0 --- /dev/null +++ b/mis_builder/tests/mis.report.query.csv @@ -0,0 +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 diff --git a/mis_builder/tests/test_mis_builder.py b/mis_builder/tests/test_mis_builder.py new file mode 100644 index 00000000..4533f003 --- /dev/null +++ b/mis_builder/tests/test_mis_builder.py @@ -0,0 +1,82 @@ +# -*- 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/views/mis_builder.xml b/mis_builder/views/mis_builder.xml new file mode 100644 index 00000000..31551f10 --- /dev/null +++ b/mis_builder/views/mis_builder.xml @@ -0,0 +1,211 @@ + + + + + + + + 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 + + +
+ +