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.

578 lines
20 KiB

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