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.

638 lines
22 KiB

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