diff --git a/bi_sql_editor/__manifest__.py b/bi_sql_editor/__manifest__.py index 1ce60559..16b9dfa3 100644 --- a/bi_sql_editor/__manifest__.py +++ b/bi_sql_editor/__manifest__.py @@ -3,27 +3,21 @@ # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). { - 'name': 'BI SQL Editor', - 'summary': 'BI Views builder, based on Materialized or Normal SQL Views', - 'version': '12.0.1.2.0', - 'license': 'AGPL-3', - 'category': 'Reporting', - 'author': 'GRAP,Odoo Community Association (OCA)', - 'website': 'https://github.com/OCA/reporting-engine', - 'depends': [ - 'base', - 'sql_request_abstract', + "name": "BI SQL Editor", + "summary": "BI Views builder, based on Materialized or Normal SQL Views", + "version": "12.0.1.2.0", + "license": "AGPL-3", + "category": "Reporting", + "author": "GRAP,Odoo Community Association (OCA)", + "website": "https://github.com/OCA/reporting-engine", + "depends": ["base", "sql_request_abstract"], + "data": [ + "security/ir.model.access.csv", + "views/view_bi_sql_view.xml", + "views/action.xml", + "views/menu.xml", ], - 'data': [ - 'security/ir.model.access.csv', - 'views/view_bi_sql_view.xml', - 'views/action.xml', - 'views/menu.xml', - ], - 'demo': [ - 'demo/res_groups_demo.xml', - 'demo/bi_sql_view_demo.xml', - ], - 'installable': True, - 'uninstall_hook': 'uninstall_hook' + "demo": ["demo/res_groups_demo.xml", "demo/bi_sql_view_demo.xml"], + "installable": True, + "uninstall_hook": "uninstall_hook", } diff --git a/bi_sql_editor/demo/bi_sql_view_demo.xml b/bi_sql_editor/demo/bi_sql_view_demo.xml index 13c87d02..6e7e9711 100644 --- a/bi_sql_editor/demo/bi_sql_view_demo.xml +++ b/bi_sql_editor/demo/bi_sql_view_demo.xml @@ -1,27 +1,28 @@ - + - - - - Draft Incorrect SQL View - incorrect_view - + Draft Incorrect SQL View + incorrect_view + - - - - Partners View - partners_view - + + Partners View + partners_view + - - - - Modules by Authors - modules_view - - + + Modules by Authors + modules_view + + - - - - - - - - + + + + diff --git a/bi_sql_editor/demo/res_groups_demo.xml b/bi_sql_editor/demo/res_groups_demo.xml index f9377b73..454e815d 100644 --- a/bi_sql_editor/demo/res_groups_demo.xml +++ b/bi_sql_editor/demo/res_groups_demo.xml @@ -1,15 +1,13 @@ - + - - diff --git a/bi_sql_editor/hooks.py b/bi_sql_editor/hooks.py index ec2ecb6a..25fe633e 100644 --- a/bi_sql_editor/hooks.py +++ b/bi_sql_editor/hooks.py @@ -6,6 +6,6 @@ from odoo.api import Environment def uninstall_hook(cr, registry): env = Environment(cr, SUPERUSER_ID, {}) - recs = env['bi.sql.view'].search([]) + recs = env["bi.sql.view"].search([]) for rec in recs: rec.button_set_draft() diff --git a/bi_sql_editor/models/bi_sql_view.py b/bi_sql_editor/models/bi_sql_view.py index e7b6f963..0e7f77d6 100644 --- a/bi_sql_editor/models/bi_sql_view.py +++ b/bi_sql_editor/models/bi_sql_view.py @@ -4,11 +4,13 @@ import logging from datetime import datetime + from psycopg2 import ProgrammingError -from odoo import _, api, fields, models, SUPERUSER_ID +from odoo import SUPERUSER_ID, _, api, fields, models from odoo.exceptions import UserError from odoo.tools import pycompat, safe_eval, sql + from odoo.addons.base.models.ir_model import IrModel _logger = logging.getLogger(__name__) @@ -16,20 +18,20 @@ _logger = logging.getLogger(__name__) @api.model def _instanciate(self, model_data): - """ Return a class for the custom model given by - parameters ``model_data``. """ + """Return a class for the custom model given by + parameters ``model_data``.""" # This monkey patch is meant to avoid create/search tables for those # materialized views. Doing "super" doesn't work. class CustomModel(models.Model): - _name = pycompat.to_native(model_data['model']) - _description = model_data['name'] + _name = pycompat.to_native(model_data["model"]) + _description = model_data["name"] _module = False _custom = True - _transient = bool(model_data['transient']) - __doc__ = model_data['info'] + _transient = bool(model_data["transient"]) + __doc__ = model_data["info"] # START OF patch - if model_data['model'].startswith(BiSQLView._model_prefix): + if model_data["model"].startswith(BiSQLView._model_prefix): CustomModel._auto = False CustomModel._abstract = True # END of patch @@ -40,62 +42,73 @@ IrModel._instanciate = _instanciate class BiSQLView(models.Model): - _name = 'bi.sql.view' - _order = 'sequence' - _inherit = ['sql.request.mixin'] + _name = "bi.sql.view" + _order = "sequence" + _inherit = ["sql.request.mixin"] - _sql_prefix = 'x_bi_sql_view_' + _sql_prefix = "x_bi_sql_view_" - _model_prefix = 'x_bi_sql_view.' + _model_prefix = "x_bi_sql_view." - _sql_request_groups_relation = 'bi_sql_view_groups_rel' + _sql_request_groups_relation = "bi_sql_view_groups_rel" - _sql_request_users_relation = 'bi_sql_view_users_rel' + _sql_request_users_relation = "bi_sql_view_users_rel" _STATE_SQL_EDITOR = [ - ('model_valid', 'SQL View and Model Created'), - ('ui_valid', 'Views, Action and Menu Created'), + ("model_valid", "SQL View and Model Created"), + ("ui_valid", "Views, Action and Menu Created"), ] technical_name = fields.Char( - string='Technical Name', required=True, + string="Technical Name", + required=True, help="Suffix of the SQL view. SQL full name will be computed and" " prefixed by 'x_bi_sql_view_'. Syntax should follow: " "https://www.postgresql.org/" - "docs/current/static/sql-syntax-lexical.html#SQL-SYNTAX-IDENTIFIERS") + "docs/current/static/sql-syntax-lexical.html#SQL-SYNTAX-IDENTIFIERS", + ) view_name = fields.Char( - string='View Name', compute='_compute_view_name', readonly=True, - store=True, help="Full name of the SQL view") + string="View Name", + compute="_compute_view_name", + readonly=True, + store=True, + help="Full name of the SQL view", + ) model_name = fields.Char( - string='Model Name', compute='_compute_model_name', readonly=True, - store=True, help="Full Qualified Name of the transient model that will" - " be created.") + string="Model Name", + compute="_compute_model_name", + readonly=True, + store=True, + help="Full Qualified Name of the transient model that will" " be created.", + ) is_materialized = fields.Boolean( - string='Is Materialized View', default=True, readonly=True, - states={ - 'draft': [('readonly', False)], - 'sql_valid': [('readonly', False)], - }) + string="Is Materialized View", + default=True, + readonly=True, + states={"draft": [("readonly", False)], "sql_valid": [("readonly", False)]}, + ) - materialized_text = fields.Char( - compute='_compute_materialized_text', store=True) + materialized_text = fields.Char(compute="_compute_materialized_text", store=True) size = fields.Char( - string='Database Size', readonly=True, - help="Size of the materialized view and its indexes") + string="Database Size", + readonly=True, + help="Size of the materialized view and its indexes", + ) state = fields.Selection(selection_add=_STATE_SQL_EDITOR) - view_order = fields.Char(string='View Order', - required=True, - readonly=False, - states={'ui_valid': [('readonly', True)]}, - default="pivot,graph,tree", - help='Comma-separated text. Possible values:' - ' "graph", "pivot" or "tree"') + view_order = fields.Char( + string="View Order", + required=True, + readonly=False, + states={"ui_valid": [("readonly", True)]}, + default="pivot,graph,tree", + help="Comma-separated text. Possible values:" ' "graph", "pivot" or "tree"', + ) query = fields.Text( help="SQL Request that will be inserted as the view. Take care to :\n" @@ -103,98 +116,112 @@ class BiSQLView(models.Model): " SQL function (like EXTRACT, ...);\n" " * Do not use 'SELECT *' or 'SELECT table.*';\n" " * prefix the name of the selectable columns by 'x_';", - default="SELECT\n" - " my_field as x_my_field\n" - "FROM my_table") + default="SELECT\n" " my_field as x_my_field\n" "FROM my_table", + ) domain_force = fields.Text( - string='Extra Rule Definition', default="[]", readonly=True, + string="Extra Rule Definition", + default="[]", + readonly=True, help="Define here access restriction to data.\n" " Take care to use field name prefixed by 'x_'." " A global 'ir.rule' will be created." " A typical Multi Company rule is for exemple \n" " ['|', ('x_company_id','child_of', [user.company_id.id])," "('x_company_id','=',False)].", - states={ - 'draft': [('readonly', False)], - 'sql_valid': [('readonly', False)], - }) + states={"draft": [("readonly", False)], "sql_valid": [("readonly", False)]}, + ) computed_action_context = fields.Text( - compute="_compute_computed_action_context", - string="Computed Action Context") + compute="_compute_computed_action_context", string="Computed Action Context" + ) action_context = fields.Text( - string="Action Context", default="{}", readonly=True, + string="Action Context", + default="{}", + readonly=True, help="Define here a context that will be used" " by default, when creating the action.", states={ - 'draft': [('readonly', False)], - 'sql_valid': [('readonly', False)], - 'model_valid': [('readonly', False)], - }) + "draft": [("readonly", False)], + "sql_valid": [("readonly", False)], + "model_valid": [("readonly", False)], + }, + ) has_group_changed = fields.Boolean(copy=False) bi_sql_view_field_ids = fields.One2many( - string='SQL Fields', comodel_name='bi.sql.view.field', - inverse_name='bi_sql_view_id') + string="SQL Fields", + comodel_name="bi.sql.view.field", + inverse_name="bi_sql_view_id", + ) model_id = fields.Many2one( - string='Odoo Model', comodel_name='ir.model', readonly=True) + string="Odoo Model", comodel_name="ir.model", readonly=True + ) tree_view_id = fields.Many2one( - string='Odoo Tree View', comodel_name='ir.ui.view', readonly=True) + string="Odoo Tree View", comodel_name="ir.ui.view", readonly=True + ) graph_view_id = fields.Many2one( - string='Odoo Graph View', comodel_name='ir.ui.view', readonly=True) + string="Odoo Graph View", comodel_name="ir.ui.view", readonly=True + ) pivot_view_id = fields.Many2one( - string='Odoo Pivot View', comodel_name='ir.ui.view', readonly=True) + string="Odoo Pivot View", comodel_name="ir.ui.view", readonly=True + ) search_view_id = fields.Many2one( - string='Odoo Search View', comodel_name='ir.ui.view', readonly=True) + string="Odoo Search View", comodel_name="ir.ui.view", readonly=True + ) action_id = fields.Many2one( - string='Odoo Action', comodel_name='ir.actions.act_window', - readonly=True) + string="Odoo Action", comodel_name="ir.actions.act_window", readonly=True + ) menu_id = fields.Many2one( - string='Odoo Menu', comodel_name='ir.ui.menu', readonly=True) + string="Odoo Menu", comodel_name="ir.ui.menu", readonly=True + ) cron_id = fields.Many2one( - string='Odoo Cron', comodel_name='ir.cron', readonly=True, - help="Cron Task that will refresh the materialized view") + string="Odoo Cron", + comodel_name="ir.cron", + readonly=True, + help="Cron Task that will refresh the materialized view", + ) - rule_id = fields.Many2one( - string='Odoo Rule', comodel_name='ir.rule', readonly=True) + rule_id = fields.Many2one(string="Odoo Rule", comodel_name="ir.rule", readonly=True) group_ids = fields.Many2many( - comodel_name='res.groups', readonly=True, states={ - 'draft': [('readonly', False)], - 'sql_valid': [('readonly', False)], - }) + comodel_name="res.groups", + readonly=True, + states={"draft": [("readonly", False)], "sql_valid": [("readonly", False)]}, + ) - sequence = fields.Integer(string='sequence') + sequence = fields.Integer(string="sequence") # Constrains Section - @api.constrains('is_materialized') + @api.constrains("is_materialized") @api.multi def _check_index_materialized(self): for rec in self.filtered(lambda x: not x.is_materialized): if rec.bi_sql_view_field_ids.filtered(lambda x: x.is_index): - raise UserError(_( - 'You can not create indexes on non materialized views')) + raise UserError( + _("You can not create indexes on non materialized views") + ) - @api.constrains('view_order') + @api.constrains("view_order") @api.multi def _check_view_order(self): for rec in self: if rec.view_order: - for vtype in rec.view_order.split(','): - if vtype not in ('graph', 'pivot', 'tree'): - raise UserError(_( - 'Only graph, pivot or tree views are supported')) + for vtype in rec.view_order.split(","): + if vtype not in ("graph", "pivot", "tree"): + raise UserError( + _("Only graph, pivot or tree views are supported") + ) # Compute Section @api.depends("bi_sql_view_field_ids.graph_type") @@ -207,60 +234,71 @@ class BiSQLView(models.Model): "pivot_column_groupby": [], } for field in rec.bi_sql_view_field_ids.filtered( - lambda x: x.graph_type == "measure"): + lambda x: x.graph_type == "measure" + ): action["pivot_measures"].append(field.name) for field in rec.bi_sql_view_field_ids.filtered( - lambda x: x.graph_type == "row"): + lambda x: x.graph_type == "row" + ): action["pivot_row_groupby"].append(field.name) for field in rec.bi_sql_view_field_ids.filtered( - lambda x: x.graph_type == "col"): + lambda x: x.graph_type == "col" + ): action["pivot_column_groupby"].append(field.name) rec.computed_action_context = str(action) - @api.depends('is_materialized') + @api.depends("is_materialized") @api.multi def _compute_materialized_text(self): for sql_view in self: - sql_view.materialized_text =\ - sql_view.is_materialized and 'MATERIALIZED' or '' + sql_view.materialized_text = ( + sql_view.is_materialized and "MATERIALIZED" or "" + ) - @api.depends('technical_name') + @api.depends("technical_name") @api.multi def _compute_view_name(self): for sql_view in self: - sql_view.view_name = '%s%s' % ( - sql_view._sql_prefix, sql_view.technical_name) + sql_view.view_name = "{}{}".format( + sql_view._sql_prefix, + sql_view.technical_name, + ) - @api.depends('technical_name') + @api.depends("technical_name") @api.multi def _compute_model_name(self): for sql_view in self: - sql_view.model_name = '%s%s' % ( - sql_view._model_prefix, sql_view.technical_name) + sql_view.model_name = "{}{}".format( + sql_view._model_prefix, + sql_view.technical_name, + ) - @api.onchange('group_ids') + @api.onchange("group_ids") def onchange_group_ids(self): - if self.state not in ('draft', 'sql_valid'): + if self.state not in ("draft", "sql_valid"): self.has_group_changed = True # Overload Section @api.multi def write(self, vals): res = super(BiSQLView, self).write(vals) - if vals.get('sequence', False): + if vals.get("sequence", False): for rec in self.filtered(lambda x: x.menu_id): rec.menu_id.sequence = rec.sequence return res @api.multi def unlink(self): - if any(view.state not in ('draft', 'sql_valid') for view in self): + if any(view.state not in ("draft", "sql_valid") for view in self): raise UserError( - _("You can only unlink draft views." - "If you want to delete them, first set them to draft.")) + _( + "You can only unlink draft views." + "If you want to delete them, first set them to draft." + ) + ) self.cron_id.unlink() return super(BiSQLView, self).unlink() @@ -268,10 +306,12 @@ class BiSQLView(models.Model): def copy(self, default=None): self.ensure_one() default = dict(default or {}) - default.update({ - 'name': _('%s (Copy)') % self.name, - 'technical_name': '%s_copy' % self.technical_name, - }) + default.update( + { + "name": _("%s (Copy)") % self.name, + "technical_name": "%s_copy" % self.technical_name, + } + ) return super(BiSQLView, self).copy(default=default) # Action Section @@ -288,11 +328,12 @@ class BiSQLView(models.Model): if sql_view.is_materialized: if not sql_view.cron_id: - sql_view.cron_id = self.env['ir.cron'].create( - sql_view._prepare_cron()).id + sql_view.cron_id = ( + self.env["ir.cron"].create(sql_view._prepare_cron()).id + ) else: sql_view.cron_id.active = True - sql_view.state = 'model_valid' + sql_view.state = "model_valid" @api.multi def button_set_draft(self): @@ -304,7 +345,7 @@ class BiSQLView(models.Model): sql_view.pivot_view_id.unlink() sql_view.search_view_id.unlink() - if sql_view.state in ('model_valid', 'ui_valid'): + if sql_view.state in ("model_valid", "ui_valid"): # Drop SQL View (and indexes by cascade) if sql_view.is_materialized: sql_view._drop_view() @@ -321,25 +362,27 @@ class BiSQLView(models.Model): @api.multi def button_create_ui(self): - self.tree_view_id = self.env['ir.ui.view'].create( - self._prepare_tree_view()).id - self.graph_view_id = self.env['ir.ui.view'].create( - self._prepare_graph_view()).id - self.pivot_view_id = self.env['ir.ui.view'].create( - self._prepare_pivot_view()).id - self.search_view_id = self.env['ir.ui.view'].create( - self._prepare_search_view()).id - self.action_id = self.env['ir.actions.act_window'].create( - self._prepare_action()).id - self.menu_id = self.env['ir.ui.menu'].create( - self._prepare_menu()).id - self.write({'state': 'ui_valid'}) + self.tree_view_id = self.env["ir.ui.view"].create(self._prepare_tree_view()).id + self.graph_view_id = ( + self.env["ir.ui.view"].create(self._prepare_graph_view()).id + ) + self.pivot_view_id = ( + self.env["ir.ui.view"].create(self._prepare_pivot_view()).id + ) + self.search_view_id = ( + self.env["ir.ui.view"].create(self._prepare_search_view()).id + ) + self.action_id = ( + self.env["ir.actions.act_window"].create(self._prepare_action()).id + ) + self.menu_id = self.env["ir.ui.menu"].create(self._prepare_menu()).id + self.write({"state": "ui_valid"}) @api.multi def button_update_model_access(self): self._drop_model_access() self._create_model_access() - self.write({'has_group_changed': False}) + self.write({"has_group_changed": False}) @api.multi def button_refresh_materialized_view(self): @@ -348,10 +391,10 @@ class BiSQLView(models.Model): @api.multi def button_open_view(self): return { - 'type': 'ir.actions.act_window', - 'res_model': self.model_id.model, - 'search_view_id': self.search_view_id.id, - 'view_mode': self.action_id.view_mode, + "type": "ir.actions.act_window", + "res_model": self.model_id.model, + "search_view_id": self.search_view_id.id, + "view_mode": self.action_id.view_mode, } # Prepare Function @@ -360,13 +403,14 @@ class BiSQLView(models.Model): self.ensure_one() field_id = [] for field in self.bi_sql_view_field_ids.filtered( - lambda x: x.field_description is not False): + lambda x: x.field_description is not False + ): field_id.append([0, False, field._prepare_model_field()]) return { - 'name': self.name, - 'model': self.model_name, - 'access_ids': [], - 'field_id': field_id, + "name": self.name, + "model": self.model_name, + "access_ids": [], + "field_id": field_id, } @api.multi @@ -374,118 +418,120 @@ class BiSQLView(models.Model): self.ensure_one() res = [] for group in self.group_ids: - res.append({ - 'name': _('%s Access %s') % ( - self.model_name, group.full_name), - 'model_id': self.model_id.id, - 'group_id': group.id, - 'perm_read': True, - 'perm_create': False, - 'perm_write': False, - 'perm_unlink': False, - }) + res.append( + { + "name": _("%s Access %s") % (self.model_name, group.full_name), + "model_id": self.model_id.id, + "group_id": group.id, + "perm_read": True, + "perm_create": False, + "perm_write": False, + "perm_unlink": False, + } + ) return res @api.multi def _prepare_cron(self): now = datetime.now() return { - 'name': _('Refresh Materialized View %s') % self.view_name, - 'user_id': SUPERUSER_ID, - 'model_id': self.env['ir.model'].search([ - ('model', '=', self._name)], limit=1).id, - 'state': 'code', - 'code': 'model._refresh_materialized_view_cron(%s)' % self.ids, - 'numbercall': -1, - 'interval_number': 1, - 'interval_type': 'days', - 'nextcall': datetime(now.year, now.month, now.day+1), - 'active': True, + "name": _("Refresh Materialized View %s") % self.view_name, + "user_id": SUPERUSER_ID, + "model_id": self.env["ir.model"] + .search([("model", "=", self._name)], limit=1) + .id, + "state": "code", + "code": "model._refresh_materialized_view_cron(%s)" % self.ids, + "numbercall": -1, + "interval_number": 1, + "interval_type": "days", + "nextcall": datetime(now.year, now.month, now.day + 1), + "active": True, } @api.multi def _prepare_rule(self): self.ensure_one() return { - 'name': _('Access %s') % self.name, - 'model_id': self.model_id.id, - 'domain_force': self.domain_force, - 'global': True, + "name": _("Access %s") % self.name, + "model_id": self.model_id.id, + "domain_force": self.domain_force, + "global": True, } @api.multi def _prepare_tree_view(self): self.ensure_one() return { - 'name': self.name, - 'type': 'tree', - 'model': self.model_id.model, - 'arch': - """""" - """{}""" - """""".format("".join( - [x._prepare_tree_field() - for x in self.bi_sql_view_field_ids])) + "name": self.name, + "type": "tree", + "model": self.model_id.model, + "arch": """""" + """{}""" + """""".format( + "".join([x._prepare_tree_field() for x in self.bi_sql_view_field_ids]) + ), } @api.multi def _prepare_graph_view(self): self.ensure_one() return { - 'name': self.name, - 'type': 'graph', - 'model': self.model_id.model, - 'arch': - """""" - """{}""" - """""".format("".join( - [x._prepare_graph_field() - for x in self.bi_sql_view_field_ids])) + "name": self.name, + "type": "graph", + "model": self.model_id.model, + "arch": """""" + """{}""" + """""".format( + "".join([x._prepare_graph_field() for x in self.bi_sql_view_field_ids]) + ), } @api.multi def _prepare_pivot_view(self): self.ensure_one() return { - 'name': self.name, - 'type': 'pivot', - 'model': self.model_id.model, - 'arch': - """""" - """{}""" - """""".format("".join( - [x._prepare_pivot_field() - for x in self.bi_sql_view_field_ids])) + "name": self.name, + "type": "pivot", + "model": self.model_id.model, + "arch": """""" + """{}""" + """""".format( + "".join([x._prepare_pivot_field() for x in self.bi_sql_view_field_ids]) + ), } @api.multi def _prepare_search_view(self): self.ensure_one() return { - 'name': self.name, - 'type': 'search', - 'model': self.model_id.model, - 'arch': - """""" - """{}""" - """{}""" - """""".format( - "".join( - [x._prepare_search_field() - for x in self.bi_sql_view_field_ids]), - "".join( - [x._prepare_search_filter_field() - for x in self.bi_sql_view_field_ids])) + "name": self.name, + "type": "search", + "model": self.model_id.model, + "arch": """""" + """{}""" + """{}""" + """""".format( + "".join( + [x._prepare_search_field() for x in self.bi_sql_view_field_ids] + ), + "".join( + [ + x._prepare_search_filter_field() + for x in self.bi_sql_view_field_ids + ] + ), + ), } @api.multi def _prepare_action(self): self.ensure_one() view_mode = self.view_order - first_view = view_mode.split(',')[0] - if first_view == 'tree': + first_view = view_mode.split(",")[0] + if first_view == "tree": view_id = self.tree_view_id.id - elif first_view == 'pivot': + elif first_view == "pivot": view_id = self.pivot_view_id.id else: view_id = self.graph_view_id.id @@ -493,13 +539,13 @@ class BiSQLView(models.Model): for k, v in safe_eval(self.action_context).items(): action[k] = v return { - 'name': self._prepare_action_name(), - 'res_model': self.model_id.model, - 'type': 'ir.actions.act_window', - 'view_mode': view_mode, - 'view_id': view_id, - 'search_view_id': self.search_view_id.id, - 'context': str(action), + "name": self._prepare_action_name(), + "res_model": self.model_id.model, + "type": "ir.actions.act_window", + "view_mode": view_mode, + "view_id": view_id, + "search_view_id": self.search_view_id.id, + "context": str(action), } @api.multi @@ -507,18 +553,19 @@ class BiSQLView(models.Model): self.ensure_one() if not self.is_materialized: return self.name - return "%s (%s)" % ( + return "{} ({})".format( self.name, - datetime.utcnow().strftime(_("%m/%d/%Y %H:%M:%S UTC"))) + datetime.utcnow().strftime(_("%m/%d/%Y %H:%M:%S UTC")), + ) @api.multi def _prepare_menu(self): self.ensure_one() return { - 'name': self.name, - 'parent_id': self.env.ref('bi_sql_editor.menu_bi_sql_editor').id, - 'action': 'ir.actions.act_window,%s' % self.action_id.id, - 'sequence': self.sequence, + "name": self.name, + "parent_id": self.env.ref("bi_sql_editor.menu_bi_sql_editor").id, + "action": "ir.actions.act_window,%s" % self.action_id.id, + "sequence": self.sequence, } # Custom Section @@ -530,8 +577,9 @@ class BiSQLView(models.Model): def _drop_view(self): for sql_view in self: self._log_execute( - "DROP %s VIEW IF EXISTS %s" % ( - sql_view.materialized_text, sql_view.view_name)) + "DROP %s VIEW IF EXISTS %s" + % (sql_view.materialized_text, sql_view.view_name) + ) sql_view.size = False @api.multi @@ -542,29 +590,28 @@ class BiSQLView(models.Model): self._log_execute(sql_view._prepare_request_for_execution()) sql_view._refresh_size() except ProgrammingError as e: - raise UserError(_( - "SQL Error while creating %s VIEW %s :\n %s") % ( - sql_view.materialized_text, sql_view.view_name, - e.message)) + raise UserError( + _("SQL Error while creating %s VIEW %s :\n %s") + % (sql_view.materialized_text, sql_view.view_name, str(e)) + ) @api.multi def _create_index(self): for sql_view in self: for sql_field in sql_view.bi_sql_view_field_ids.filtered( - lambda x: x.is_index is True): + lambda x: x.is_index is True + ): self._log_execute( - "CREATE INDEX %s ON %s (%s);" % ( - sql_field.index_name, sql_view.view_name, - sql_field.name)) + "CREATE INDEX %s ON %s (%s);" + % (sql_field.index_name, sql_view.view_name, sql_field.name) + ) @api.multi def _create_model_and_fields(self): for sql_view in self: # Create model - sql_view.model_id = self.env['ir.model'].create( - self._prepare_model()).id - sql_view.rule_id = self.env['ir.rule'].create( - self._prepare_rule()).id + sql_view.model_id = self.env["ir.model"].create(self._prepare_model()).id + sql_view.rule_id = self.env["ir.rule"].create(self._prepare_rule()).id # Drop table, created by the ORM if sql.table_exists(self._cr, sql_view.view_name): req = "DROP TABLE %s" % sql_view.view_name @@ -574,13 +621,14 @@ class BiSQLView(models.Model): def _create_model_access(self): for sql_view in self: for item in sql_view._prepare_model_access(): - self.env['ir.model.access'].create(item) + self.env["ir.model.access"].create(item) @api.multi def _drop_model_access(self): for sql_view in self: - self.env['ir.model.access'].search( - [('model_id', '=', sql_view.model_name)]).unlink() + self.env["ir.model.access"].search( + [("model_id", "=", sql_view.model_name)] + ).unlink() @api.multi def _drop_model_and_fields(self): @@ -593,7 +641,8 @@ class BiSQLView(models.Model): @api.multi def _hook_executed_request(self): self.ensure_one() - req = """ + req = ( + """ SELECT attnum, attname AS column, format_type(atttypid, atttypmod) AS type @@ -601,19 +650,22 @@ class BiSQLView(models.Model): WHERE attrelid = '%s'::regclass AND NOT attisdropped AND attnum > 0 - ORDER BY attnum;""" % self.view_name + ORDER BY attnum;""" + % self.view_name + ) self._log_execute(req) return self.env.cr.fetchall() @api.multi def _prepare_request_check_execution(self): self.ensure_one() - return "CREATE VIEW %s AS (%s);" % (self.view_name, self.query) + return "CREATE VIEW {} AS ({});".format(self.view_name, self.query) @api.multi def _prepare_request_for_execution(self): self.ensure_one() - query = """ + query = ( + """ SELECT CAST(row_number() OVER () as integer) AS id, CAST(Null as timestamp without time zone) as create_date, @@ -623,9 +675,14 @@ class BiSQLView(models.Model): my_query.* FROM (%s) as my_query - """ % self.query - return "CREATE %s VIEW %s AS (%s);" % ( - self.materialized_text, self.view_name, query) + """ + % self.query + ) + return "CREATE {} VIEW {} AS ({});".format( + self.materialized_text, + self.view_name, + query, + ) @api.multi def _check_execution(self): @@ -635,54 +692,59 @@ class BiSQLView(models.Model): After the execution, and before the rollback, an analysis of the database structure is done, to know fields type.""" self.ensure_one() - sql_view_field_obj = self.env['bi.sql.view.field'] + sql_view_field_obj = self.env["bi.sql.view.field"] columns = super(BiSQLView, self)._check_execution() field_ids = [] for column in columns: existing_field = self.bi_sql_view_field_ids.filtered( - lambda x: x.name == column[1]) + lambda x: x.name == column[1] + ) if existing_field: # Update existing field field_ids.append(existing_field.id) - existing_field.write({ - 'sequence': column[0], - 'sql_type': column[2], - }) + existing_field.write({"sequence": column[0], "sql_type": column[2]}) else: # Create a new one if name is prefixed by x_ - if column[1][:2] == 'x_': - field_ids.append(sql_view_field_obj.create({ - 'sequence': column[0], - 'name': column[1], - 'sql_type': column[2], - 'bi_sql_view_id': self.id, - }).id) + if column[1][:2] == "x_": + field_ids.append( + sql_view_field_obj.create( + { + "sequence": column[0], + "name": column[1], + "sql_type": column[2], + "bi_sql_view_id": self.id, + } + ).id + ) # Drop obsolete view field - self.bi_sql_view_field_ids.filtered( - lambda x: x.id not in field_ids).unlink() + self.bi_sql_view_field_ids.filtered(lambda x: x.id not in field_ids).unlink() if not self.bi_sql_view_field_ids: - raise UserError(_( - "No Column was found.\n" - "Columns name should be prefixed by 'x_'.")) + raise UserError( + _("No Column was found.\n" "Columns name should be prefixed by 'x_'.") + ) return columns @api.model def _refresh_materialized_view_cron(self, view_ids): - sql_views = self.search([ - ('is_materialized', '=', True), - ('state', 'in', ['model_valid', 'ui_valid']), - ('id', 'in', view_ids), - ]) + sql_views = self.search( + [ + ("is_materialized", "=", True), + ("state", "in", ["model_valid", "ui_valid"]), + ("id", "in", view_ids), + ] + ) return sql_views._refresh_materialized_view() @api.multi def _refresh_materialized_view(self): for sql_view in self.filtered(lambda x: x.is_materialized): - req = "REFRESH %s VIEW %s" % ( - sql_view.materialized_text, sql_view.view_name) + req = "REFRESH {} VIEW {}".format( + sql_view.materialized_text, + sql_view.view_name, + ) self._log_execute(req) sql_view._refresh_size() if sql_view.action_id: @@ -696,7 +758,8 @@ class BiSQLView(models.Model): def _refresh_size(self): for sql_view in self: req = "SELECT pg_size_pretty(pg_total_relation_size('%s'));" % ( - sql_view.view_name) + sql_view.view_name + ) self._log_execute(req) sql_view.size = self.env.cr.fetchone()[0] @@ -704,4 +767,4 @@ class BiSQLView(models.Model): def button_preview_sql_expression(self): self.button_validate_sql_expression() res = self._execute_sql_request() - raise UserError('\n'.join(map(lambda x: str(x), res[:100]))) + raise UserError("\n".join(map(lambda x: str(x), res[:100]))) diff --git a/bi_sql_editor/models/bi_sql_view_field.py b/bi_sql_editor/models/bi_sql_view_field.py index c8c94300..be44ffd4 100644 --- a/bi_sql_editor/models/bi_sql_view_field.py +++ b/bi_sql_editor/models/bi_sql_view_field.py @@ -9,145 +9,165 @@ from odoo.exceptions import UserError class BiSQLViewField(models.Model): - _name = 'bi.sql.view.field' - _description = 'Bi SQL View Field' - _order = 'sequence' + _name = "bi.sql.view.field" + _description = "Bi SQL View Field" + _order = "sequence" _TTYPE_SELECTION = [ - ('boolean', 'boolean'), - ('char', 'char'), - ('date', 'date'), - ('datetime', 'datetime'), - ('float', 'float'), - ('integer', 'integer'), - ('many2one', 'many2one'), - ('selection', 'selection'), + ("boolean", "boolean"), + ("char", "char"), + ("date", "date"), + ("datetime", "datetime"), + ("float", "float"), + ("integer", "integer"), + ("many2one", "many2one"), + ("selection", "selection"), ] _GRAPH_TYPE_SELECTION = [ - ('col', 'Column'), - ('row', 'Row'), - ('measure', 'Measure'), + ("col", "Column"), + ("row", "Row"), + ("measure", "Measure"), ] _TREE_VISIBILITY_SELECTION = [ - ('unavailable', 'Unavailable'), - ('hidden', 'Hidden'), - ('available', 'Available'), + ("unavailable", "Unavailable"), + ("hidden", "Hidden"), + ("available", "Available"), ] # Mapping to guess Odoo field type, from SQL column type _SQL_MAPPING = { - 'boolean': 'boolean', - 'bigint': 'integer', - 'integer': 'integer', - 'double precision': 'float', - 'numeric': 'float', - 'text': 'char', - 'character varying': 'char', - 'date': 'date', - 'timestamp without time zone': 'datetime', + "boolean": "boolean", + "bigint": "integer", + "integer": "integer", + "double precision": "float", + "numeric": "float", + "text": "char", + "character varying": "char", + "date": "date", + "timestamp without time zone": "datetime", } - name = fields.Char(string='Name', required=True, readonly=True) + name = fields.Char(string="Name", required=True, readonly=True) sql_type = fields.Char( - string='SQL Type', required=True, readonly=True, - help="SQL Type in the database") + string="SQL Type", required=True, readonly=True, help="SQL Type in the database" + ) - sequence = fields.Integer(string='sequence', required=True, readonly=True) + sequence = fields.Integer(string="sequence", required=True, readonly=True) bi_sql_view_id = fields.Many2one( - string='SQL View', comodel_name='bi.sql.view', ondelete='cascade') + string="SQL View", comodel_name="bi.sql.view", ondelete="cascade" + ) is_index = fields.Boolean( - string='Is Index', help="Check this box if you want to create" + string="Is Index", + help="Check this box if you want to create" " an index on that field. This is recommended for searchable and" - " groupable fields, to reduce duration") + " groupable fields, to reduce duration", + ) is_group_by = fields.Boolean( - string='Is Group by', help="Check this box if you want to create" - " a 'group by' option in the search view") + string="Is Group by", + help="Check this box if you want to create" + " a 'group by' option in the search view", + ) - index_name = fields.Char( - string='Index Name', compute='_compute_index_name') + index_name = fields.Char(string="Index Name", compute="_compute_index_name") - graph_type = fields.Selection( - string='Graph Type', selection=_GRAPH_TYPE_SELECTION) + graph_type = fields.Selection(string="Graph Type", selection=_GRAPH_TYPE_SELECTION) tree_visibility = fields.Selection( - string='Tree Visibility', selection=_TREE_VISIBILITY_SELECTION, - default='available', required=True) + string="Tree Visibility", + selection=_TREE_VISIBILITY_SELECTION, + default="available", + required=True, + ) field_description = fields.Char( - string='Field Description', help="This will be used as the name" - " of the Odoo field, displayed for users") + string="Field Description", + help="This will be used as the name" " of the Odoo field, displayed for users", + ) ttype = fields.Selection( - string='Field Type', selection=_TTYPE_SELECTION, help="Type of the" + string="Field Type", + selection=_TTYPE_SELECTION, + help="Type of the" " Odoo field that will be created. Keep empty if you don't want to" " create a new field. If empty, this field will not be displayed" - " neither available for search or group by function") + " neither available for search or group by function", + ) selection = fields.Text( - string='Selection Options', default='[]', + string="Selection Options", + default="[]", help="For 'Selection' Odoo field.\n" " List of options, specified as a Python expression defining a list of" " (key, label) pairs. For example:" - " [('blue','Blue'), ('yellow','Yellow')]") + " [('blue','Blue'), ('yellow','Yellow')]", + ) many2one_model_id = fields.Many2one( - comodel_name='ir.model', string='Model', - help="For 'Many2one' Odoo field.\n" - " Comodel of the field.") + comodel_name="ir.model", + string="Model", + help="For 'Many2one' Odoo field.\n" " Comodel of the field.", + ) # Constrains Section - @api.constrains('is_index') + @api.constrains("is_index") @api.multi def _check_index_materialized(self): for rec in self.filtered(lambda x: x.is_index): if not rec.bi_sql_view_id.is_materialized: - raise UserError(_( - 'You can not create indexes on non materialized views')) + raise UserError( + _("You can not create indexes on non materialized views") + ) # Compute Section @api.multi def _compute_index_name(self): for sql_field in self: - sql_field.index_name = '%s_%s' % ( - sql_field.bi_sql_view_id.view_name, sql_field.name) + sql_field.index_name = "{}_{}".format( + sql_field.bi_sql_view_id.view_name, + sql_field.name, + ) # Overload Section @api.model def create(self, vals): - field_without_prefix = vals['name'][2:] + field_without_prefix = vals["name"][2:] # guess field description field_description = re.sub( - r'\w+', lambda m: m.group(0).capitalize(), - field_without_prefix.replace('_id', '').replace('_', ' ')) + r"\w+", + lambda m: m.group(0).capitalize(), + field_without_prefix.replace("_id", "").replace("_", " "), + ) # Guess ttype # Don't execute as simple .get() in the dict to manage # correctly the type 'character varying(x)' ttype = False for k, v in self._SQL_MAPPING.items(): - if k in vals['sql_type']: + if k in vals["sql_type"]: ttype = v # Guess many2one_model_id many2one_model_id = False - if vals['sql_type'] == 'integer' and( - vals['name'][-3:] == '_id'): - ttype = 'many2one' - model_name = self._model_mapping().get(field_without_prefix, '') - many2one_model_id = self.env['ir.model'].search( - [('model', '=', model_name)]).id - - vals.update({ - 'ttype': ttype, - 'field_description': field_description, - 'many2one_model_id': many2one_model_id, - }) + if vals["sql_type"] == "integer" and (vals["name"][-3:] == "_id"): + ttype = "many2one" + model_name = self._model_mapping().get(field_without_prefix, "") + many2one_model_id = ( + self.env["ir.model"].search([("model", "=", model_name)]).id + ) + + vals.update( + { + "ttype": ttype, + "field_description": field_description, + "many2one_model_id": many2one_model_id, + } + ) return super(BiSQLViewField, self).create(vals) # Custom Section @@ -157,8 +177,9 @@ class BiSQLViewField(models.Model): field name. Sample : {'account_id': 'account.account'; 'product_id': 'product.product'} """ - relation_fields = self.env['ir.model.fields'].search([ - ('ttype', '=', 'many2one')]) + relation_fields = self.env["ir.model.fields"].search( + [("ttype", "=", "many2one")] + ) res = {} keys_to_pop = [] for field in relation_fields: @@ -177,49 +198,49 @@ class BiSQLViewField(models.Model): def _prepare_model_field(self): self.ensure_one() return { - 'name': self.name, - 'field_description': self.field_description, - 'model_id': self.bi_sql_view_id.model_id.id, - 'ttype': self.ttype, - 'selection': self.ttype == 'selection' and self.selection or False, - 'relation': self.ttype == 'many2one' and - self.many2one_model_id.model or False, + "name": self.name, + "field_description": self.field_description, + "model_id": self.bi_sql_view_id.model_id.id, + "ttype": self.ttype, + "selection": self.ttype == "selection" and self.selection or False, + "relation": self.ttype == "many2one" + and self.many2one_model_id.model + or False, } @api.multi def _prepare_tree_field(self): self.ensure_one() - res = '' - if self.field_description and self.tree_visibility != 'unavailable': + res = "" + if self.field_description and self.tree_visibility != "unavailable": res = """""".format( - self.name, - self.tree_visibility == 'hidden' and 'invisible="1"' or '') + self.name, self.tree_visibility == "hidden" and 'invisible="1"' or "" + ) return res @api.multi def _prepare_graph_field(self): self.ensure_one() - res = '' + res = "" if self.graph_type and self.field_description: res = """\n""".format( - self.name, self.graph_type) + self.name, self.graph_type + ) return res @api.multi def _prepare_pivot_field(self): self.ensure_one() - res = '' + res = "" if self.field_description: - graph_type_text =\ - self.graph_type and "type=\"%s\"" % (self.graph_type) or "" - res = """\n""".format( - self.name, graph_type_text) + graph_type_text = self.graph_type and 'type="%s"' % (self.graph_type) or "" + res = """\n""".format(self.name, graph_type_text) return res @api.multi def _prepare_search_field(self): self.ensure_one() - res = '' + res = "" if self.field_description: res = """\n""".format(self.name) return res @@ -227,10 +248,12 @@ class BiSQLViewField(models.Model): @api.multi def _prepare_search_filter_field(self): self.ensure_one() - res = '' + res = "" if self.field_description and self.is_group_by: res = """\n""" % ( - self.name, self.field_description, self.name - ) + self.name, + self.field_description, + self.name, + ) return res diff --git a/bi_sql_editor/tests/test_bi_sql_view.py b/bi_sql_editor/tests/test_bi_sql_view.py index bfc4c2b7..806b090e 100644 --- a/bi_sql_editor/tests/test_bi_sql_view.py +++ b/bi_sql_editor/tests/test_bi_sql_view.py @@ -1,91 +1,94 @@ # Copyright 2017 Onestein () # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). -from odoo.tests.common import SingleTransactionCase, at_install, post_install from odoo.exceptions import AccessError, UserError +from odoo.tests.common import SingleTransactionCase, at_install, post_install @at_install(False) @post_install(True) class TestBiSqlViewEditor(SingleTransactionCase): - @classmethod def setUpClass(cls): super(TestBiSqlViewEditor, cls).setUpClass() - cls.res_partner = cls.env['res.partner'] - cls.res_users = cls.env['res.users'] - cls.bi_sql_view = cls.env['bi.sql.view'] + cls.res_partner = cls.env["res.partner"] + cls.res_users = cls.env["res.users"] + cls.bi_sql_view = cls.env["bi.sql.view"] cls.group_bi_user = cls.env.ref( - 'sql_request_abstract.group_sql_request_manager') - cls.group_user = cls.env.ref( - 'base.group_user') - cls.view = cls.bi_sql_view.create({ - 'name': 'Partners View 2', - 'is_materialized': True, - 'technical_name': 'partners_view_2', - 'query': "SELECT name as x_name, street as x_street," - "company_id as x_company_id FROM res_partner " - "ORDER BY name" - }) - cls.company = cls.env.ref('base.main_company') + "sql_request_abstract.group_sql_request_manager" + ) + cls.group_user = cls.env.ref("base.group_user") + cls.view = cls.bi_sql_view.create( + { + "name": "Partners View 2", + "is_materialized": True, + "technical_name": "partners_view_2", + "query": "SELECT name as x_name, street as x_street," + "company_id as x_company_id FROM res_partner " + "ORDER BY name", + } + ) + cls.company = cls.env.ref("base.main_company") # Create bi user - cls.bi_user = cls._create_user('bi_user', cls.group_bi_user, - cls.company) - cls.no_bi_user = cls._create_user('no_bi_user', cls.group_user, - cls.company) + cls.bi_user = cls._create_user("bi_user", cls.group_bi_user, cls.company) + cls.no_bi_user = cls._create_user("no_bi_user", cls.group_user, cls.company) @classmethod def _create_user(cls, login, groups, company): """Create a user.""" - user = cls.res_users.create({ - 'name': login, - 'login': login, - 'password': 'demo', - 'email': 'example@yourcompany.com', - 'company_id': company.id, - 'groups_id': [(6, 0, groups.ids)] - }) + user = cls.res_users.create( + { + "name": login, + "login": login, + "password": "demo", + "email": "example@yourcompany.com", + "company_id": company.id, + "groups_id": [(6, 0, groups.ids)], + } + ) return user def test_process_view(self): view = self.view - self.assertEqual(view.state, 'draft', 'state not draft') + self.assertEqual(view.state, "draft", "state not draft") view.button_validate_sql_expression() - self.assertEqual(view.state, 'sql_valid', 'state not sql_valid') + self.assertEqual(view.state, "sql_valid", "state not sql_valid") view.button_create_sql_view_and_model() - self.assertEqual(view.state, 'model_valid', 'state not model_valid') + self.assertEqual(view.state, "model_valid", "state not model_valid") view.button_create_ui() - self.assertEqual(view.state, 'ui_valid', 'state not ui_valid') + self.assertEqual(view.state, "ui_valid", "state not ui_valid") view.button_update_model_access() - self.assertEqual(view.has_group_changed, False, - 'has_group_changed not False') + self.assertEqual(view.has_group_changed, False, "has_group_changed not False") cron_res = view.cron_id.method_direct_trigger() - self.assertEqual(cron_res, True, 'something went wrong with the cron') + self.assertEqual(cron_res, True, "something went wrong with the cron") def test_copy(self): copy_view = self.view.copy() - self.assertEqual( - copy_view.name, 'Partners View 2 (Copy)', 'Wrong name') + self.assertEqual(copy_view.name, "Partners View 2 (Copy)", "Wrong name") def test_security(self): with self.assertRaises(AccessError): self.bi_sql_view.sudo(self.no_bi_user.id).search( - [('name', '=', 'Partners View 2')]) + [("name", "=", "Partners View 2")] + ) bi = self.bi_sql_view.sudo(self.bi_user.id).search( - [('name', '=', 'Partners View 2')]) - self.assertEqual(len(bi), 1, 'Bi user should not have access to ' - 'bi %s' % self.view.name) + [("name", "=", "Partners View 2")] + ) + self.assertEqual( + len(bi), 1, "Bi user should not have access to " "bi %s" % self.view.name + ) def test_unlink(self): - self.assertEqual(self.view.state, 'ui_valid', 'state not ui_valid') + self.assertEqual(self.view.state, "ui_valid", "state not ui_valid") with self.assertRaises(UserError): self.view.unlink() self.view.button_set_draft() self.assertNotEqual( - self.view.cron_id, False, 'Set to draft materialized view should' - ' not unlink cron' + self.view.cron_id, + False, + "Set to draft materialized view should" " not unlink cron", ) self.view.unlink() - res = self.bi_sql_view.search([('name', '=', 'Partners View 2')]) - self.assertEqual(len(res), 0, 'View not deleted') + res = self.bi_sql_view.search([("name", "=", "Partners View 2")]) + self.assertEqual(len(res), 0, "View not deleted") diff --git a/bi_sql_editor/views/action.xml b/bi_sql_editor/views/action.xml index b984cbff..2e7e9ce2 100644 --- a/bi_sql_editor/views/action.xml +++ b/bi_sql_editor/views/action.xml @@ -1,12 +1,10 @@ - + - - SQL Views ir.actions.act_window @@ -14,5 +12,4 @@ License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). form tree,form - diff --git a/bi_sql_editor/views/menu.xml b/bi_sql_editor/views/menu.xml index 46087d5c..00c540f0 100644 --- a/bi_sql_editor/views/menu.xml +++ b/bi_sql_editor/views/menu.xml @@ -1,22 +1,22 @@ - + - - - - - - + + diff --git a/bi_sql_editor/views/view_bi_sql_view.xml b/bi_sql_editor/views/view_bi_sql_view.xml index 91013803..bbbd1a0b 100644 --- a/bi_sql_editor/views/view_bi_sql_view.xml +++ b/bi_sql_editor/views/view_bi_sql_view.xml @@ -1,132 +1,231 @@ - + - - bi.sql.view - - - - - - + + + + + + - bi.sql.view
-

- +

- - - - - - + + + + + + - + - - - - - - - - - + + + + + + + + - + - - - - + ('ttype', '=', 'selection')]}" + /> + + + + - + - - + + - + - + - + - - - - - - + + + + + + @@ -135,5 +234,4 @@ License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
-
diff --git a/setup/bi_sql_editor/odoo/addons/bi_sql_editor b/setup/bi_sql_editor/odoo/addons/bi_sql_editor new file mode 120000 index 00000000..f07a4a62 --- /dev/null +++ b/setup/bi_sql_editor/odoo/addons/bi_sql_editor @@ -0,0 +1 @@ +../../../../bi_sql_editor \ No newline at end of file diff --git a/setup/bi_sql_editor/setup.py b/setup/bi_sql_editor/setup.py new file mode 100644 index 00000000..28c57bb6 --- /dev/null +++ b/setup/bi_sql_editor/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +)