# Copyright (C) 2017 - Today: GRAP (http://www.grap.coop) # @author: Sylvain LE GAL (https://twitter.com/legalsylvain) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). import logging from datetime import datetime from psycopg2 import ProgrammingError from odoo import _, api, fields, models, SUPERUSER_ID from odoo.exceptions import UserError from odoo.tools import pycompat, sql from odoo.addons.base.models.ir_model import IrModel _logger = logging.getLogger(__name__) @api.model def _instanciate(self, 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'] _module = False _custom = True _transient = bool(model_data['transient']) __doc__ = model_data['info'] # START OF patch if model_data['model'].startswith(BiSQLView._model_prefix): CustomModel._auto = False CustomModel._abstract = True # END of patch return CustomModel IrModel._instanciate = _instanciate class BiSQLView(models.Model): _name = 'bi.sql.view' _order = 'sequence' _inherit = ['sql.request.mixin'] _sql_prefix = 'x_bi_sql_view_' _model_prefix = 'x_bi_sql_view.' _sql_request_groups_relation = 'bi_sql_view_groups_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'), ] technical_name = fields.Char( 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") view_name = fields.Char( 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.") is_materialized = fields.Boolean( 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) size = fields.Char( 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"') query = fields.Text( help="SQL Request that will be inserted as the view. Take care to :\n" " * set a name for all your selected fields, specially if you use" " 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") domain_force = fields.Text( 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)], }) action_context = fields.Text( 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)], }) 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') model_id = fields.Many2one( 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) graph_view_id = fields.Many2one( 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) search_view_id = fields.Many2one( 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) menu_id = fields.Many2one( 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") 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)], }) sequence = fields.Integer(string='sequence') # Constrains Section @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')) @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')) # Compute Section @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 '' @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) @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) @api.onchange('group_ids') def onchange_group_ids(self): 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): 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): raise UserError( _("You can only unlink draft views." "If you want to delete them, first set them to draft.")) return super(BiSQLView, self).unlink() @api.multi 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, }) return super(BiSQLView, self).copy(default=default) # Action Section @api.multi def button_create_sql_view_and_model(self): for sql_view in self: if sql_view.state != 'sql_valid': raise UserError(_( "You can only process this action on SQL Valid items")) # Create ORM and access sql_view._create_model_and_fields() sql_view._create_model_access() # Create SQL View and indexes sql_view._create_view() sql_view._create_index() if sql_view.is_materialized: sql_view.cron_id = self.env['ir.cron'].create( sql_view._prepare_cron()).id sql_view.state = 'model_valid' return True @api.multi def button_set_draft(self): for sql_view in self: sql_view.menu_id.unlink() sql_view.action_id.unlink() sql_view.tree_view_id.unlink() sql_view.graph_view_id.unlink() sql_view.pivot_view_id.unlink() sql_view.search_view_id.unlink() if sql_view.cron_id: sql_view.cron_id.unlink() 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() # Drop ORM sql_view._drop_model_and_fields() sql_view.write({'state': 'draft', 'has_group_changed': False}) @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'}) return True @api.multi def button_update_model_access(self): self._drop_model_access() self._create_model_access() self.write({'has_group_changed': False}) @api.multi def button_refresh_materialized_view(self): self._refresh_materialized_view() @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, } # Prepare Function @api.multi def _prepare_model(self): self.ensure_one() field_id = [] for field in self.bi_sql_view_field_ids.filtered( 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, } @api.multi def _prepare_model_access(self): 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, }) return res @api.multi def _prepare_cron(self): self.ensure_one() 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, } @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, } @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])) } @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])) } @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])) } @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])) } @api.multi def _prepare_action(self): self.ensure_one() view_mode = self.view_order first_view = view_mode.split(',')[0] if first_view == 'tree': view_id = self.tree_view_id.id elif first_view == 'pivot': view_id = self.pivot_view_id.id else: view_id = self.graph_view_id.id 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': self.action_context, } @api.multi def _prepare_action_name(self): self.ensure_one() if not self.is_materialized: return self.name return "%s (%s)" % ( self.name, 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, } # Custom Section def _log_execute(self, req): _logger.info("Executing SQL Request %s ..." % req) self.env.cr.execute(req) @api.multi 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)) sql_view.size = False @api.multi def _create_view(self): for sql_view in self: sql_view._drop_view() try: 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)) @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): self._log_execute( "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 # Drop table, created by the ORM if sql.table_exists(self._cr, sql_view.view_name): req = "DROP TABLE %s" % sql_view.view_name self._log_execute(req) @api.multi 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) @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() @api.multi def _drop_model_and_fields(self): for sql_view in self: if sql_view.rule_id: sql_view.rule_id.unlink() if sql_view.model_id: sql_view.model_id.with_context(_force_unlink=True).unlink() @api.multi def _hook_executed_request(self): self.ensure_one() req = """ SELECT attnum, attname AS column, format_type(atttypid, atttypmod) AS type FROM pg_attribute WHERE attrelid = '%s'::regclass AND NOT attisdropped AND attnum > 0 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) @api.multi def _prepare_request_for_execution(self): self.ensure_one() query = """ SELECT CAST(row_number() OVER () as integer) AS id, CAST(Null as timestamp without time zone) as create_date, CAST(Null as integer) as create_uid, CAST(Null as timestamp without time zone) as write_date, CAST(Null as integer) as write_uid, my_query.* FROM (%s) as my_query """ % self.query return "CREATE %s VIEW %s AS (%s);" % ( self.materialized_text, self.view_name, query) @api.multi def _check_execution(self): """Ensure that the query is valid, trying to execute it. a non materialized view is created for this check. A rollback is done at the end. 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'] 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]) if existing_field: # Update existing field field_ids.append(existing_field.id) 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) # Drop obsolete view field 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_'.")) 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), ]) 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) self._log_execute(req) sql_view._refresh_size() if sql_view.action_id: # Alter name of the action, to display last refresh # datetime of the materialized view sql_view.action_id.name = sql_view._prepare_action_name() @api.multi def _refresh_size(self): for sql_view in self: req = "SELECT pg_size_pretty(pg_total_relation_size('%s'));" % ( sql_view.view_name) self._log_execute(req) sql_view.size = self.env.cr.fetchone()[0] @api.multi 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])))