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.

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