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
  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. self._auto = False
  16. return super(BaseModel, self)._auto_init()
  17. @api.model_cr_context
  18. def _auto_end(self):
  19. if self._name.startswith(BiSQLView._model_prefix):
  20. self._foreign_keys = set()
  21. return super(BaseModel, self)._auto_end()
  22. class BiSQLView(models.Model):
  23. _name = 'bi.sql.view'
  24. _order = 'sequence'
  25. _inherit = ['sql.request.mixin']
  26. _sql_prefix = 'x_bi_sql_view_'
  27. _model_prefix = 'x_bi_sql_view.'
  28. _sql_request_groups_relation = 'bi_sql_view_groups_rel'
  29. _sql_request_users_relation = 'bi_sql_view_users_rel'
  30. _STATE_SQL_EDITOR = [
  31. ('model_valid', 'SQL View and Model Created'),
  32. ('ui_valid', 'Views, Action and Menu Created'),
  33. ]
  34. technical_name = fields.Char(
  35. string='Technical Name', required=True,
  36. help="Suffix of the SQL view. SQL full name will be computed and"
  37. " prefixed by 'x_bi_sql_view_'. Syntax should follow: "
  38. "https://www.postgresql.org/"
  39. "docs/current/static/sql-syntax-lexical.html#SQL-SYNTAX-IDENTIFIERS")
  40. view_name = fields.Char(
  41. string='View Name', compute='_compute_view_name', readonly=True,
  42. store=True, help="Full name of the SQL view")
  43. model_name = fields.Char(
  44. string='Model Name', compute='_compute_model_name', readonly=True,
  45. store=True, help="Full Qualified Name of the transient model that will"
  46. " be created.")
  47. is_materialized = fields.Boolean(
  48. string='Is Materialized View', default=True, readonly=True,
  49. states={
  50. 'draft': [('readonly', False)],
  51. 'sql_valid': [('readonly', False)],
  52. })
  53. materialized_text = fields.Char(
  54. compute='_compute_materialized_text', store=True)
  55. size = fields.Char(
  56. string='Database Size', readonly=True,
  57. help="Size of the materialized view and its indexes")
  58. state = fields.Selection(selection_add=_STATE_SQL_EDITOR)
  59. view_order = fields.Char(string='View Order',
  60. required=True,
  61. readonly=False,
  62. states={'ui_valid': [('readonly', True)]},
  63. default="pivot,graph,tree",
  64. help='Comma-separated text. Possible values:'
  65. ' "graph", "pivot" or "tree"')
  66. query = fields.Text(
  67. help="SQL Request that will be inserted as the view. Take care to :\n"
  68. " * set a name for all your selected fields, specially if you use"
  69. " SQL function (like EXTRACT, ...);\n"
  70. " * Do not use 'SELECT *' or 'SELECT table.*';\n"
  71. " * prefix the name of the selectable columns by 'x_';",
  72. default="SELECT\n"
  73. " my_field as x_my_field\n"
  74. "FROM my_table")
  75. domain_force = fields.Text(
  76. string='Extra Rule Definition', default="[]", readonly=True,
  77. help="Define here access restriction to data.\n"
  78. " Take care to use field name prefixed by 'x_'."
  79. " A global 'ir.rule' will be created."
  80. " A typical Multi Company rule is for exemple \n"
  81. " ['|', ('x_company_id','child_of', [user.company_id.id]),"
  82. "('x_company_id','=',False)].",
  83. states={
  84. 'draft': [('readonly', False)],
  85. 'sql_valid': [('readonly', False)],
  86. })
  87. has_group_changed = fields.Boolean(copy=False)
  88. bi_sql_view_field_ids = fields.One2many(
  89. string='SQL Fields', comodel_name='bi.sql.view.field',
  90. inverse_name='bi_sql_view_id')
  91. model_id = fields.Many2one(
  92. string='Odoo Model', comodel_name='ir.model', readonly=True)
  93. tree_view_id = fields.Many2one(
  94. string='Odoo Tree View', comodel_name='ir.ui.view', readonly=True)
  95. graph_view_id = fields.Many2one(
  96. string='Odoo Graph View', comodel_name='ir.ui.view', readonly=True)
  97. pivot_view_id = fields.Many2one(
  98. string='Odoo Pivot View', comodel_name='ir.ui.view', readonly=True)
  99. search_view_id = fields.Many2one(
  100. string='Odoo Search View', comodel_name='ir.ui.view', readonly=True)
  101. action_id = fields.Many2one(
  102. string='Odoo Action', comodel_name='ir.actions.act_window',
  103. readonly=True)
  104. menu_id = fields.Many2one(
  105. string='Odoo Menu', comodel_name='ir.ui.menu', readonly=True)
  106. cron_id = fields.Many2one(
  107. string='Odoo Cron', comodel_name='ir.cron', readonly=True,
  108. help="Cron Task that will refresh the materialized view")
  109. rule_id = fields.Many2one(
  110. string='Odoo Rule', comodel_name='ir.rule', readonly=True)
  111. group_ids = fields.Many2many(
  112. comodel_name='res.groups', readonly=True, states={
  113. 'draft': [('readonly', False)],
  114. 'sql_valid': [('readonly', False)],
  115. })
  116. sequence = fields.Integer(string='sequence')
  117. # Constrains Section
  118. @api.constrains('is_materialized')
  119. @api.multi
  120. def _check_index_materialized(self):
  121. for rec in self.filtered(lambda x: not x.is_materialized):
  122. if rec.bi_sql_view_field_ids.filtered(lambda x: x.is_index):
  123. raise UserError(_(
  124. 'You can not create indexes on non materialized views'))
  125. @api.constrains('view_order')
  126. @api.multi
  127. def _check_view_order(self):
  128. for rec in self:
  129. if rec.view_order:
  130. for vtype in rec.view_order.split(','):
  131. if vtype not in ('graph', 'pivot', 'tree'):
  132. raise UserError(_(
  133. 'Only graph, pivot or tree views are supported'))
  134. # Compute Section
  135. @api.depends('is_materialized')
  136. @api.multi
  137. def _compute_materialized_text(self):
  138. for sql_view in self:
  139. sql_view.materialized_text =\
  140. sql_view.is_materialized and 'MATERIALIZED' or ''
  141. @api.depends('technical_name')
  142. @api.multi
  143. def _compute_view_name(self):
  144. for sql_view in self:
  145. sql_view.view_name = '%s%s' % (
  146. sql_view._sql_prefix, sql_view.technical_name)
  147. @api.depends('technical_name')
  148. @api.multi
  149. def _compute_model_name(self):
  150. for sql_view in self:
  151. sql_view.model_name = '%s%s' % (
  152. sql_view._model_prefix, sql_view.technical_name)
  153. @api.onchange('group_ids')
  154. def onchange_group_ids(self):
  155. if self.state not in ('draft', 'sql_valid'):
  156. self.has_group_changed = True
  157. # Overload Section
  158. @api.multi
  159. def write(self, vals):
  160. res = super(BiSQLView, self).write(vals)
  161. if vals.get('sequence', False):
  162. for rec in self.filtered(lambda x: x.menu_id):
  163. rec.menu_id.sequence = rec.sequence
  164. return res
  165. @api.multi
  166. def unlink(self):
  167. if any(view.state not in ('draft', 'sql_valid') for view in self):
  168. raise UserError(_("You can only unlink draft views"))
  169. return super(BiSQLView, self).unlink()
  170. @api.multi
  171. def copy(self, default=None):
  172. self.ensure_one()
  173. default = dict(default or {})
  174. default.update({
  175. 'name': _('%s (Copy)') % (self.name),
  176. 'technical_name': '%s_copy' % (self.technical_name),
  177. })
  178. return super(BiSQLView, self).copy(default=default)
  179. # Action Section
  180. @api.multi
  181. def button_create_sql_view_and_model(self):
  182. for sql_view in self:
  183. if sql_view.state != 'sql_valid':
  184. raise UserError(_(
  185. "You can only process this action on SQL Valid items"))
  186. # Create ORM and acess
  187. sql_view._create_model_and_fields()
  188. sql_view._create_model_access()
  189. # Create SQL View and indexes
  190. sql_view._create_view()
  191. sql_view._create_index()
  192. if sql_view.is_materialized:
  193. sql_view.cron_id = self.env['ir.cron'].create(
  194. sql_view._prepare_cron()).id
  195. sql_view.state = 'model_valid'
  196. @api.multi
  197. def button_set_draft(self):
  198. for sql_view in self:
  199. sql_view.menu_id.unlink()
  200. sql_view.action_id.unlink()
  201. sql_view.tree_view_id.unlink()
  202. sql_view.graph_view_id.unlink()
  203. sql_view.pivot_view_id.unlink()
  204. sql_view.search_view_id.unlink()
  205. if sql_view.cron_id:
  206. sql_view.cron_id.unlink()
  207. if sql_view.state in ('model_valid', 'ui_valid'):
  208. # Drop SQL View (and indexes by cascade)
  209. if sql_view.is_materialized:
  210. sql_view._drop_view()
  211. # Drop ORM
  212. sql_view._drop_model_and_fields()
  213. sql_view.write({'state': 'draft', 'has_group_changed': False})
  214. @api.multi
  215. def button_create_ui(self):
  216. self.tree_view_id = self.env['ir.ui.view'].create(
  217. self._prepare_tree_view()).id
  218. self.graph_view_id = self.env['ir.ui.view'].create(
  219. self._prepare_graph_view()).id
  220. self.pivot_view_id = self.env['ir.ui.view'].create(
  221. self._prepare_pivot_view()).id
  222. self.search_view_id = self.env['ir.ui.view'].create(
  223. self._prepare_search_view()).id
  224. self.action_id = self.env['ir.actions.act_window'].create(
  225. self._prepare_action()).id
  226. self.menu_id = self.env['ir.ui.menu'].create(
  227. self._prepare_menu()).id
  228. self.write({'state': 'ui_valid'})
  229. @api.multi
  230. def button_update_model_access(self):
  231. self._drop_model_access()
  232. self._create_model_access()
  233. self.write({'has_group_changed': False})
  234. @api.multi
  235. def button_refresh_materialized_view(self):
  236. self._refresh_materialized_view()
  237. @api.multi
  238. def button_open_view(self):
  239. return {
  240. 'type': 'ir.actions.act_window',
  241. 'res_model': self.model_id.model,
  242. 'search_view_id': self.search_view_id.id,
  243. 'view_mode': self.action_id.view_mode,
  244. }
  245. # Prepare Function
  246. @api.multi
  247. def _prepare_model(self):
  248. self.ensure_one()
  249. field_id = []
  250. for field in self.bi_sql_view_field_ids.filtered(
  251. lambda x: x.field_description is not False):
  252. field_id.append([0, False, field._prepare_model_field()])
  253. return {
  254. 'name': self.name,
  255. 'model': self.model_name,
  256. 'access_ids': [],
  257. 'field_id': field_id,
  258. }
  259. @api.multi
  260. def _prepare_model_access(self):
  261. self.ensure_one()
  262. res = []
  263. for group in self.group_ids:
  264. res.append({
  265. 'name': _('%s Access %s') % (
  266. self.model_name, group.full_name),
  267. 'model_id': self.model_id.id,
  268. 'group_id': group.id,
  269. 'perm_read': True,
  270. 'perm_create': False,
  271. 'perm_write': False,
  272. 'perm_unlink': False,
  273. })
  274. return res
  275. @api.multi
  276. def _prepare_cron(self):
  277. self.ensure_one()
  278. return {
  279. 'name': _('Refresh Materialized View %s') % (self.view_name),
  280. 'user_id': SUPERUSER_ID,
  281. 'model_id': self.env['ir.model'].search([
  282. ('model', '=', self._name)], limit=1).id,
  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="bar" 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])))