OCA reporting engine fork for dev and update.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

732 lines
24 KiB

7 years ago
7 years ago
7 years ago
7 years ago
  1. # Copyright (C) 2017 - Today: GRAP (http://www.grap.coop)
  2. # @author: Sylvain LE GAL (https://twitter.com/legalsylvain)
  3. # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
  4. import logging
  5. from datetime import datetime
  6. from psycopg2 import ProgrammingError
  7. from odoo import SUPERUSER_ID, _, api, fields, models
  8. from odoo.exceptions import UserError
  9. from odoo.tools import pycompat, sql
  10. from odoo.tools.safe_eval import safe_eval
  11. from odoo.addons.base.models.ir_model import IrModel
  12. _logger = logging.getLogger(__name__)
  13. @api.model
  14. def _instanciate(self, model_data):
  15. """Return a class for the custom model given by
  16. parameters ``model_data``."""
  17. # This monkey patch is meant to avoid create/search tables for those
  18. # materialized views. Doing "super" doesn't work.
  19. class CustomModel(models.Model):
  20. _name = pycompat.to_text(model_data["model"])
  21. _description = model_data["name"]
  22. _module = False
  23. _custom = True
  24. _transient = bool(model_data["transient"])
  25. __doc__ = model_data["info"]
  26. # START OF patch
  27. if model_data["model"].startswith(BiSQLView._model_prefix):
  28. CustomModel._auto = False
  29. CustomModel._abstract = True
  30. # END of patch
  31. return CustomModel
  32. IrModel._instanciate = _instanciate
  33. class BiSQLView(models.Model):
  34. _name = "bi.sql.view"
  35. _description = "BI SQL View"
  36. _order = "sequence"
  37. _inherit = ["sql.request.mixin"]
  38. _sql_prefix = "x_bi_sql_view_"
  39. _model_prefix = "x_bi_sql_view."
  40. _sql_request_groups_relation = "bi_sql_view_groups_rel"
  41. _sql_request_users_relation = "bi_sql_view_users_rel"
  42. _STATE_SQL_EDITOR = [
  43. ("model_valid", "SQL View and Model Created"),
  44. ("ui_valid", "Views, Action and Menu Created"),
  45. ]
  46. technical_name = fields.Char(
  47. string="Technical Name",
  48. required=True,
  49. help="Suffix of the SQL view. SQL full name will be computed and"
  50. " prefixed by 'x_bi_sql_view_'. Syntax should follow: "
  51. "https://www.postgresql.org/"
  52. "docs/current/static/sql-syntax-lexical.html#SQL-SYNTAX-IDENTIFIERS",
  53. )
  54. view_name = fields.Char(
  55. string="View Name",
  56. compute="_compute_view_name",
  57. readonly=True,
  58. store=True,
  59. help="Full name of the SQL view",
  60. )
  61. model_name = fields.Char(
  62. string="Model Name",
  63. compute="_compute_model_name",
  64. readonly=True,
  65. store=True,
  66. help="Full Qualified Name of the transient model that will" " be created.",
  67. )
  68. is_materialized = fields.Boolean(
  69. string="Is Materialized View",
  70. default=True,
  71. readonly=True,
  72. states={"draft": [("readonly", False)], "sql_valid": [("readonly", False)]},
  73. )
  74. materialized_text = fields.Char(compute="_compute_materialized_text", store=True)
  75. size = fields.Char(
  76. string="Database Size",
  77. readonly=True,
  78. help="Size of the materialized view and its indexes",
  79. )
  80. state = fields.Selection(selection_add=_STATE_SQL_EDITOR)
  81. view_order = fields.Char(
  82. string="View Order",
  83. required=True,
  84. readonly=False,
  85. states={"ui_valid": [("readonly", True)]},
  86. default="pivot,graph,tree",
  87. help="Comma-separated text. Possible values:" ' "graph", "pivot" or "tree"',
  88. )
  89. query = fields.Text(
  90. help="SQL Request that will be inserted as the view. Take care to :\n"
  91. " * set a name for all your selected fields, specially if you use"
  92. " SQL function (like EXTRACT, ...);\n"
  93. " * Do not use 'SELECT *' or 'SELECT table.*';\n"
  94. " * prefix the name of the selectable columns by 'x_';",
  95. default="SELECT\n" " my_field as x_my_field\n" "FROM my_table",
  96. )
  97. domain_force = fields.Text(
  98. string="Extra Rule Definition",
  99. default="[]",
  100. readonly=True,
  101. help="Define here access restriction to data.\n"
  102. " Take care to use field name prefixed by 'x_'."
  103. " A global 'ir.rule' will be created."
  104. " A typical Multi Company rule is for exemple \n"
  105. " ['|', ('x_company_id','child_of', [user.company_id.id]),"
  106. "('x_company_id','=',False)].",
  107. states={"draft": [("readonly", False)], "sql_valid": [("readonly", False)]},
  108. )
  109. computed_action_context = fields.Text(
  110. compute="_compute_computed_action_context", string="Computed Action Context"
  111. )
  112. action_context = fields.Text(
  113. string="Action Context",
  114. default="{}",
  115. readonly=True,
  116. help="Define here a context that will be used"
  117. " by default, when creating the action.",
  118. states={
  119. "draft": [("readonly", False)],
  120. "sql_valid": [("readonly", False)],
  121. "model_valid": [("readonly", False)],
  122. },
  123. )
  124. has_group_changed = fields.Boolean(copy=False)
  125. bi_sql_view_field_ids = fields.One2many(
  126. string="SQL Fields",
  127. comodel_name="bi.sql.view.field",
  128. inverse_name="bi_sql_view_id",
  129. )
  130. model_id = fields.Many2one(
  131. string="Odoo Model", comodel_name="ir.model", readonly=True
  132. )
  133. tree_view_id = fields.Many2one(
  134. string="Odoo Tree View", comodel_name="ir.ui.view", readonly=True
  135. )
  136. graph_view_id = fields.Many2one(
  137. string="Odoo Graph View", comodel_name="ir.ui.view", readonly=True
  138. )
  139. pivot_view_id = fields.Many2one(
  140. string="Odoo Pivot View", comodel_name="ir.ui.view", readonly=True
  141. )
  142. search_view_id = fields.Many2one(
  143. string="Odoo Search View", comodel_name="ir.ui.view", readonly=True
  144. )
  145. action_id = fields.Many2one(
  146. string="Odoo Action", comodel_name="ir.actions.act_window", readonly=True
  147. )
  148. menu_id = fields.Many2one(
  149. string="Odoo Menu", comodel_name="ir.ui.menu", readonly=True
  150. )
  151. cron_id = fields.Many2one(
  152. string="Odoo Cron",
  153. comodel_name="ir.cron",
  154. readonly=True,
  155. help="Cron Task that will refresh the materialized view",
  156. )
  157. rule_id = fields.Many2one(string="Odoo Rule", comodel_name="ir.rule", readonly=True)
  158. group_ids = fields.Many2many(
  159. comodel_name="res.groups",
  160. readonly=True,
  161. states={"draft": [("readonly", False)], "sql_valid": [("readonly", False)]},
  162. )
  163. sequence = fields.Integer(string="sequence")
  164. # Constrains Section
  165. @api.constrains("is_materialized")
  166. def _check_index_materialized(self):
  167. for rec in self.filtered(lambda x: not x.is_materialized):
  168. if rec.bi_sql_view_field_ids.filtered(lambda x: x.is_index):
  169. raise UserError(
  170. _("You can not create indexes on non materialized views")
  171. )
  172. @api.constrains("view_order")
  173. def _check_view_order(self):
  174. for rec in self:
  175. if rec.view_order:
  176. for vtype in rec.view_order.split(","):
  177. if vtype not in ("graph", "pivot", "tree"):
  178. raise UserError(
  179. _("Only graph, pivot or tree views are supported")
  180. )
  181. # Compute Section
  182. @api.depends("bi_sql_view_field_ids.graph_type")
  183. def _compute_computed_action_context(self):
  184. for rec in self:
  185. action = {
  186. "pivot_measures": [],
  187. "pivot_row_groupby": [],
  188. "pivot_column_groupby": [],
  189. }
  190. for field in rec.bi_sql_view_field_ids.filtered(
  191. lambda x: x.graph_type == "measure"
  192. ):
  193. action["pivot_measures"].append(field.name)
  194. for field in rec.bi_sql_view_field_ids.filtered(
  195. lambda x: x.graph_type == "row"
  196. ):
  197. action["pivot_row_groupby"].append(field.name)
  198. for field in rec.bi_sql_view_field_ids.filtered(
  199. lambda x: x.graph_type == "col"
  200. ):
  201. action["pivot_column_groupby"].append(field.name)
  202. rec.computed_action_context = str(action)
  203. @api.depends("is_materialized")
  204. def _compute_materialized_text(self):
  205. for sql_view in self:
  206. sql_view.materialized_text = (
  207. sql_view.is_materialized and "MATERIALIZED" or ""
  208. )
  209. @api.depends("technical_name")
  210. def _compute_view_name(self):
  211. for sql_view in self:
  212. sql_view.view_name = "{}{}".format(
  213. sql_view._sql_prefix,
  214. sql_view.technical_name,
  215. )
  216. @api.depends("technical_name")
  217. def _compute_model_name(self):
  218. for sql_view in self:
  219. sql_view.model_name = "{}{}".format(
  220. sql_view._model_prefix,
  221. sql_view.technical_name,
  222. )
  223. @api.onchange("group_ids")
  224. def onchange_group_ids(self):
  225. if self.state not in ("draft", "sql_valid"):
  226. self.has_group_changed = True
  227. # Overload Section
  228. def write(self, vals):
  229. res = super(BiSQLView, self).write(vals)
  230. if vals.get("sequence", False):
  231. for rec in self.filtered(lambda x: x.menu_id):
  232. rec.menu_id.sequence = rec.sequence
  233. return res
  234. def unlink(self):
  235. if any(view.state not in ("draft", "sql_valid") for view in self):
  236. raise UserError(
  237. _(
  238. "You can only unlink draft views."
  239. "If you want to delete them, first set them to draft."
  240. )
  241. )
  242. self.cron_id.unlink()
  243. return super(BiSQLView, self).unlink()
  244. def copy(self, default=None):
  245. self.ensure_one()
  246. default = dict(default or {})
  247. default.update(
  248. {
  249. "name": _("%s (Copy)") % self.name,
  250. "technical_name": "%s_copy" % self.technical_name,
  251. }
  252. )
  253. return super(BiSQLView, self).copy(default=default)
  254. # Action Section
  255. def button_create_sql_view_and_model(self):
  256. for sql_view in self.filtered(lambda x: x.state == "sql_valid"):
  257. # Create ORM and access
  258. sql_view._create_model_and_fields()
  259. sql_view._create_model_access()
  260. # Create SQL View and indexes
  261. sql_view._create_view()
  262. sql_view._create_index()
  263. if sql_view.is_materialized:
  264. if not sql_view.cron_id:
  265. sql_view.cron_id = (
  266. self.env["ir.cron"].create(sql_view._prepare_cron()).id
  267. )
  268. else:
  269. sql_view.cron_id.active = True
  270. sql_view.state = "model_valid"
  271. def button_set_draft(self):
  272. for sql_view in self.filtered(lambda x: x.state != "draft"):
  273. sql_view.menu_id.unlink()
  274. sql_view.action_id.unlink()
  275. sql_view.tree_view_id.unlink()
  276. sql_view.graph_view_id.unlink()
  277. sql_view.pivot_view_id.unlink()
  278. sql_view.search_view_id.unlink()
  279. if sql_view.state in ("model_valid", "ui_valid"):
  280. # Drop SQL View (and indexes by cascade)
  281. if sql_view.is_materialized:
  282. sql_view._drop_view()
  283. if sql_view.cron_id:
  284. sql_view.cron_id.active = False
  285. # Drop ORM
  286. sql_view._drop_model_and_fields()
  287. sql_view.has_group_changed = False
  288. super(BiSQLView, sql_view).button_set_draft()
  289. return True
  290. def button_create_ui(self):
  291. self.tree_view_id = self.env["ir.ui.view"].create(self._prepare_tree_view()).id
  292. self.graph_view_id = (
  293. self.env["ir.ui.view"].create(self._prepare_graph_view()).id
  294. )
  295. self.pivot_view_id = (
  296. self.env["ir.ui.view"].create(self._prepare_pivot_view()).id
  297. )
  298. self.search_view_id = (
  299. self.env["ir.ui.view"].create(self._prepare_search_view()).id
  300. )
  301. self.action_id = (
  302. self.env["ir.actions.act_window"].create(self._prepare_action()).id
  303. )
  304. self.menu_id = self.env["ir.ui.menu"].create(self._prepare_menu()).id
  305. self.write({"state": "ui_valid"})
  306. def button_update_model_access(self):
  307. self._drop_model_access()
  308. self._create_model_access()
  309. self.write({"has_group_changed": False})
  310. def button_refresh_materialized_view(self):
  311. self._refresh_materialized_view()
  312. def button_open_view(self):
  313. return {
  314. "type": "ir.actions.act_window",
  315. "res_model": self.model_id.model,
  316. "search_view_id": self.search_view_id.id,
  317. "view_mode": self.action_id.view_mode,
  318. }
  319. # Prepare Function
  320. def _prepare_model(self):
  321. self.ensure_one()
  322. field_id = []
  323. for field in self.bi_sql_view_field_ids.filtered(
  324. lambda x: x.field_description is not False
  325. ):
  326. field_id.append([0, False, field._prepare_model_field()])
  327. return {
  328. "name": self.name,
  329. "model": self.model_name,
  330. "access_ids": [],
  331. "field_id": field_id,
  332. }
  333. def _prepare_model_access(self):
  334. self.ensure_one()
  335. res = []
  336. for group in self.group_ids:
  337. res.append(
  338. {
  339. "name": _("%s Access %s") % (self.model_name, group.full_name),
  340. "model_id": self.model_id.id,
  341. "group_id": group.id,
  342. "perm_read": True,
  343. "perm_create": False,
  344. "perm_write": False,
  345. "perm_unlink": False,
  346. }
  347. )
  348. return res
  349. def _prepare_cron(self):
  350. now = datetime.now()
  351. return {
  352. "name": _("Refresh Materialized View %s") % self.view_name,
  353. "user_id": SUPERUSER_ID,
  354. "model_id": self.env["ir.model"]
  355. .search([("model", "=", self._name)], limit=1)
  356. .id,
  357. "state": "code",
  358. "code": "model._refresh_materialized_view_cron(%s)" % self.ids,
  359. "numbercall": -1,
  360. "interval_number": 1,
  361. "interval_type": "days",
  362. "nextcall": datetime(now.year, now.month, now.day + 1),
  363. "active": True,
  364. }
  365. def _prepare_rule(self):
  366. self.ensure_one()
  367. return {
  368. "name": _("Access %s") % self.name,
  369. "model_id": self.model_id.id,
  370. "domain_force": self.domain_force,
  371. "global": True,
  372. }
  373. def _prepare_tree_view(self):
  374. self.ensure_one()
  375. return {
  376. "name": self.name,
  377. "type": "tree",
  378. "model": self.model_id.model,
  379. "arch": """<?xml version="1.0"?>"""
  380. """<tree string="Analysis">{}"""
  381. """</tree>""".format(
  382. "".join([x._prepare_tree_field() for x in self.bi_sql_view_field_ids])
  383. ),
  384. }
  385. def _prepare_graph_view(self):
  386. self.ensure_one()
  387. return {
  388. "name": self.name,
  389. "type": "graph",
  390. "model": self.model_id.model,
  391. "arch": """<?xml version="1.0"?>"""
  392. """<graph string="Analysis" type="bar" stacked="True">{}"""
  393. """</graph>""".format(
  394. "".join([x._prepare_graph_field() for x in self.bi_sql_view_field_ids])
  395. ),
  396. }
  397. def _prepare_pivot_view(self):
  398. self.ensure_one()
  399. return {
  400. "name": self.name,
  401. "type": "pivot",
  402. "model": self.model_id.model,
  403. "arch": """<?xml version="1.0"?>"""
  404. """<pivot string="Analysis" stacked="True">{}"""
  405. """</pivot>""".format(
  406. "".join([x._prepare_pivot_field() for x in self.bi_sql_view_field_ids])
  407. ),
  408. }
  409. def _prepare_search_view(self):
  410. self.ensure_one()
  411. return {
  412. "name": self.name,
  413. "type": "search",
  414. "model": self.model_id.model,
  415. "arch": """<?xml version="1.0"?>"""
  416. """<search string="Analysis">{}"""
  417. """<group expand="1" string="Group By">{}</group>"""
  418. """</search>""".format(
  419. "".join(
  420. [x._prepare_search_field() for x in self.bi_sql_view_field_ids]
  421. ),
  422. "".join(
  423. [
  424. x._prepare_search_filter_field()
  425. for x in self.bi_sql_view_field_ids
  426. ]
  427. ),
  428. ),
  429. }
  430. def _prepare_action(self):
  431. self.ensure_one()
  432. view_mode = self.view_order
  433. first_view = view_mode.split(",")[0]
  434. if first_view == "tree":
  435. view_id = self.tree_view_id.id
  436. elif first_view == "pivot":
  437. view_id = self.pivot_view_id.id
  438. else:
  439. view_id = self.graph_view_id.id
  440. action = safe_eval(self.computed_action_context)
  441. for k, v in safe_eval(self.action_context).items():
  442. action[k] = v
  443. return {
  444. "name": self._prepare_action_name(),
  445. "res_model": self.model_id.model,
  446. "type": "ir.actions.act_window",
  447. "view_mode": view_mode,
  448. "view_id": view_id,
  449. "search_view_id": self.search_view_id.id,
  450. "context": str(action),
  451. }
  452. def _prepare_action_name(self):
  453. self.ensure_one()
  454. if not self.is_materialized:
  455. return self.name
  456. return "{} ({})".format(
  457. self.name,
  458. datetime.utcnow().strftime(_("%m/%d/%Y %H:%M:%S UTC")),
  459. )
  460. def _prepare_menu(self):
  461. self.ensure_one()
  462. return {
  463. "name": self.name,
  464. "parent_id": self.env.ref("bi_sql_editor.menu_bi_sql_editor").id,
  465. "action": "ir.actions.act_window,%s" % self.action_id.id,
  466. "sequence": self.sequence,
  467. }
  468. # Custom Section
  469. def _log_execute(self, req):
  470. _logger.info("Executing SQL Request %s ..." % req)
  471. self.env.cr.execute(req)
  472. def _drop_view(self):
  473. for sql_view in self:
  474. self._log_execute(
  475. "DROP %s VIEW IF EXISTS %s"
  476. % (sql_view.materialized_text, sql_view.view_name)
  477. )
  478. sql_view.size = False
  479. def _create_view(self):
  480. for sql_view in self:
  481. sql_view._drop_view()
  482. try:
  483. self._log_execute(sql_view._prepare_request_for_execution())
  484. sql_view._refresh_size()
  485. except ProgrammingError as e:
  486. raise UserError(
  487. _("SQL Error while creating %s VIEW %s :\n %s")
  488. % (sql_view.materialized_text, sql_view.view_name, str(e))
  489. )
  490. def _create_index(self):
  491. for sql_view in self:
  492. for sql_field in sql_view.bi_sql_view_field_ids.filtered(
  493. lambda x: x.is_index is True
  494. ):
  495. self._log_execute(
  496. "CREATE INDEX %s ON %s (%s);"
  497. % (sql_field.index_name, sql_view.view_name, sql_field.name)
  498. )
  499. def _create_model_and_fields(self):
  500. for sql_view in self:
  501. # Create model
  502. sql_view.model_id = self.env["ir.model"].create(self._prepare_model()).id
  503. sql_view.rule_id = self.env["ir.rule"].create(self._prepare_rule()).id
  504. # Drop table, created by the ORM
  505. if sql.table_exists(self._cr, sql_view.view_name):
  506. req = "DROP TABLE %s" % sql_view.view_name
  507. self._log_execute(req)
  508. def _create_model_access(self):
  509. for sql_view in self:
  510. for item in sql_view._prepare_model_access():
  511. self.env["ir.model.access"].create(item)
  512. def _drop_model_access(self):
  513. for sql_view in self:
  514. self.env["ir.model.access"].search(
  515. [("model_id", "=", sql_view.model_name)]
  516. ).unlink()
  517. def _drop_model_and_fields(self):
  518. for sql_view in self:
  519. if sql_view.rule_id:
  520. sql_view.rule_id.unlink()
  521. if sql_view.model_id:
  522. sql_view.model_id.with_context(_force_unlink=True).unlink()
  523. def _hook_executed_request(self):
  524. self.ensure_one()
  525. req = (
  526. """
  527. SELECT attnum,
  528. attname AS column,
  529. format_type(atttypid, atttypmod) AS type
  530. FROM pg_attribute
  531. WHERE attrelid = '%s'::regclass
  532. AND NOT attisdropped
  533. AND attnum > 0
  534. ORDER BY attnum;"""
  535. % self.view_name
  536. )
  537. self._log_execute(req)
  538. return self.env.cr.fetchall()
  539. def _prepare_request_check_execution(self):
  540. self.ensure_one()
  541. return "CREATE VIEW {} AS ({});".format(self.view_name, self.query)
  542. def _prepare_request_for_execution(self):
  543. self.ensure_one()
  544. query = (
  545. """
  546. SELECT
  547. CAST(row_number() OVER () as integer) AS id,
  548. CAST(Null as timestamp without time zone) as create_date,
  549. CAST(Null as integer) as create_uid,
  550. CAST(Null as timestamp without time zone) as write_date,
  551. CAST(Null as integer) as write_uid,
  552. my_query.*
  553. FROM
  554. (%s) as my_query
  555. """
  556. % self.query
  557. )
  558. return "CREATE {} VIEW {} AS ({});".format(
  559. self.materialized_text,
  560. self.view_name,
  561. query,
  562. )
  563. def _check_execution(self):
  564. """Ensure that the query is valid, trying to execute it.
  565. a non materialized view is created for this check.
  566. A rollback is done at the end.
  567. After the execution, and before the rollback, an analysis of
  568. the database structure is done, to know fields type."""
  569. self.ensure_one()
  570. sql_view_field_obj = self.env["bi.sql.view.field"]
  571. columns = super(BiSQLView, self)._check_execution()
  572. field_ids = []
  573. for column in columns:
  574. existing_field = self.bi_sql_view_field_ids.filtered(
  575. lambda x: x.name == column[1]
  576. )
  577. if existing_field:
  578. # Update existing field
  579. field_ids.append(existing_field.id)
  580. existing_field.write({"sequence": column[0], "sql_type": column[2]})
  581. else:
  582. # Create a new one if name is prefixed by x_
  583. if column[1][:2] == "x_":
  584. field_ids.append(
  585. sql_view_field_obj.create(
  586. {
  587. "sequence": column[0],
  588. "name": column[1],
  589. "sql_type": column[2],
  590. "bi_sql_view_id": self.id,
  591. }
  592. ).id
  593. )
  594. # Drop obsolete view field
  595. self.bi_sql_view_field_ids.filtered(lambda x: x.id not in field_ids).unlink()
  596. if not self.bi_sql_view_field_ids:
  597. raise UserError(
  598. _("No Column was found.\n" "Columns name should be prefixed by 'x_'.")
  599. )
  600. return columns
  601. @api.model
  602. def _refresh_materialized_view_cron(self, view_ids):
  603. sql_views = self.search(
  604. [
  605. ("is_materialized", "=", True),
  606. ("state", "in", ["model_valid", "ui_valid"]),
  607. ("id", "in", view_ids),
  608. ]
  609. )
  610. return sql_views._refresh_materialized_view()
  611. def _refresh_materialized_view(self):
  612. for sql_view in self.filtered(lambda x: x.is_materialized):
  613. req = "REFRESH {} VIEW {}".format(
  614. sql_view.materialized_text,
  615. sql_view.view_name,
  616. )
  617. self._log_execute(req)
  618. sql_view._refresh_size()
  619. if sql_view.action_id:
  620. # Alter name of the action, to display last refresh
  621. # datetime of the materialized view
  622. sql_view.action_id.with_context(
  623. lang=self.env.user.lang
  624. ).name = sql_view._prepare_action_name()
  625. def _refresh_size(self):
  626. for sql_view in self:
  627. req = "SELECT pg_size_pretty(pg_total_relation_size('%s'));" % (
  628. sql_view.view_name
  629. )
  630. self._log_execute(req)
  631. sql_view.size = self.env.cr.fetchone()[0]
  632. def button_preview_sql_expression(self):
  633. self.button_validate_sql_expression()
  634. res = self._execute_sql_request()
  635. raise UserError("\n".join(map(lambda x: str(x), res[:100])))