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.

731 lines
24 KiB

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