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.

668 lines
23 KiB

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