Files
reporting-engine/bi_sql_editor/models/bi_sql_view.py
GuillemCForgeFlow fdec58782b [IMP]bi_sql_editor: archive Scheduled Action to not recreate when resetting the SQL View
Based on the agreed solution: https://github.com/OCA/reporting-engine/pull/903#pullrequestreview-2273835493. It is best to archive the Scheduled Action every time you set back to the draft state. Then, when creating the SQL view, if it is a materialized view, unarchive the Scheduled Action if already exists, otherwise create it from scratch.
Then, if the SQL View is deleted, also make sure to remove the Scheduled Action if there is one assigned as it has not been done in the `button_set_draft` method
2024-09-02 12:56:55 +02:00

712 lines
24 KiB
Python

# 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 SUPERUSER_ID, _, api, fields, models
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_text(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"
_description = "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
)
# UI related fields
# 1. Editable fields, which can be set by the user (optional) before
# creating the UI elements
@api.model
def _default_parent_menu_id(self):
return self.env.ref("bi_sql_editor.menu_bi_sql_editor")
parent_menu_id = fields.Many2one(
string="Parent Odoo Menu",
comodel_name="ir.ui.menu",
required=True,
readonly=True,
default=lambda self: self._default_parent_menu_id(),
states={
"draft": [("readonly", False)],
"sql_valid": [("readonly", False)],
"model_valid": [("readonly", False)],
},
help="By assigning a value to this field before manually creating the "
"UI, you're overwriting the parent menu on which the menu related to "
"the SQL report will be created.",
)
# 2. Readonly fields, non editable by the user
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")
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")
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")
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")
def _compute_view_name(self):
for sql_view in self:
sql_view.view_name = "{}{}".format(
sql_view._sql_prefix, sql_view.technical_name,
)
@api.depends("technical_name")
def _compute_model_name(self):
for sql_view in self:
sql_view.model_name = "{}{}".format(
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
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
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."
)
)
# Remove Scheduled Actions, as they were not removed in `button_set_draft`
self.mapped("cron_id").unlink()
return super(BiSQLView, self).unlink()
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
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:
# Create Cron only if it has not been created yet, otherwise unarchive it
if sql_view.cron_id and not sql_view.cron_id.active:
sql_view.cron_id.write({"active": True})
else:
sql_view.cron_id = (
self.env["ir.cron"].create(sql_view._prepare_cron()).id
)
sql_view.state = "model_valid"
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.write({"active": False})
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})
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"})
def button_update_model_access(self):
self._drop_model_access()
self._create_model_access()
self.write({"has_group_changed": False})
def button_refresh_materialized_view(self):
self._refresh_materialized_view()
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
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,
}
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
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,
}
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,
}
def _prepare_tree_view(self):
self.ensure_one()
return {
"name": self.name,
"type": "tree",
"model": self.model_id.model,
"arch": """<?xml version="1.0"?>"""
"""<tree string="Analysis">{}"""
"""</tree>""".format(
"".join([x._prepare_tree_field() for x in self.bi_sql_view_field_ids])
),
}
def _prepare_graph_view(self):
self.ensure_one()
return {
"name": self.name,
"type": "graph",
"model": self.model_id.model,
"arch": """<?xml version="1.0"?>"""
"""<graph string="Analysis" type="bar" stacked="True">{}"""
"""</graph>""".format(
"".join([x._prepare_graph_field() for x in self.bi_sql_view_field_ids])
),
}
def _prepare_pivot_view(self):
self.ensure_one()
return {
"name": self.name,
"type": "pivot",
"model": self.model_id.model,
"arch": """<?xml version="1.0"?>"""
"""<pivot string="Analysis" stacked="True">{}"""
"""</pivot>""".format(
"".join([x._prepare_pivot_field() for x in self.bi_sql_view_field_ids])
),
}
def _prepare_search_view(self):
self.ensure_one()
return {
"name": self.name,
"type": "search",
"model": self.model_id.model,
"arch": """<?xml version="1.0"?>"""
"""<search string="Analysis">{}"""
"""<group expand="1" string="Group By">{}</group>"""
"""</search>""".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
]
),
),
}
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,
}
def _prepare_action_name(self):
self.ensure_one()
if not self.is_materialized:
return self.name
return "{} ({})".format(
self.name, datetime.utcnow().strftime(_("%m/%d/%Y %H:%M:%S UTC")),
)
def _prepare_menu(self):
self.ensure_one()
return {
"name": self.name,
"parent_id": self.parent_menu_id.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)
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
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, str(e))
)
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)
)
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)
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)
def _drop_model_access(self):
for sql_view in self:
self.env["ir.model.access"].search(
[("model_id", "=", sql_view.model_name)]
).unlink()
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()
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()
def _prepare_request_check_execution(self):
self.ensure_one()
return "CREATE VIEW {} AS ({});".format(self.view_name, self.query)
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 {} VIEW {} AS ({});".format(
self.materialized_text, self.view_name, query,
)
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()
def _refresh_materialized_view(self):
for sql_view in self.filtered(lambda x: x.is_materialized):
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:
# Alter name of the action, to display last refresh
# datetime of the materialized view
sql_view.action_id.name = sql_view._prepare_action_name()
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]
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])))