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.

526 lines
18 KiB

  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 openerp import _, api, fields, models, SUPERUSER_ID
  9. from openerp.exceptions import Warning as 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', 'Graph, 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_'. Should have correct"
  26. "syntax. For more information, see 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. query = fields.Text(
  45. help="SQL Request that will be inserted as the view. Take care to :\n"
  46. " * set a name for all your selected fields, specially if you use"
  47. " SQL function (like EXTRACT, ...);\n"
  48. " * Do not use 'SELECT *' or 'SELECT table.*';\n"
  49. " * prefix the name of the selectable columns by 'x_';",
  50. default="SELECT\n"
  51. " my_field as x_my_field\n"
  52. "FROM my_table")
  53. domain_force = fields.Text(
  54. string='Extra Rule Definition', default="[]", help="Define here"
  55. " access restriction to data.\n"
  56. " Take care to use field name prefixed by 'x_'."
  57. " A global 'ir.rule' will be created."
  58. " A typical Multi Company rule is for exemple \n"
  59. " ['|', ('x_company_id','child_of', [user.company_id.id]),"
  60. "('x_company_id','=',False)].")
  61. has_group_changed = fields.Boolean(copy=False)
  62. bi_sql_view_field_ids = fields.One2many(
  63. string='SQL Fields', comodel_name='bi.sql.view.field',
  64. inverse_name='bi_sql_view_id')
  65. model_id = fields.Many2one(
  66. string='Odoo Model', comodel_name='ir.model', readonly=True)
  67. tree_view_id = fields.Many2one(
  68. string='Odoo Tree View', comodel_name='ir.ui.view', readonly=True)
  69. graph_view_id = fields.Many2one(
  70. string='Odoo Graph View', comodel_name='ir.ui.view', readonly=True)
  71. search_view_id = fields.Many2one(
  72. string='Odoo Search View', comodel_name='ir.ui.view', readonly=True)
  73. action_id = fields.Many2one(
  74. string='Odoo Action', comodel_name='ir.actions.act_window',
  75. readonly=True)
  76. menu_id = fields.Many2one(
  77. string='Odoo Menu', comodel_name='ir.ui.menu', readonly=True)
  78. cron_id = fields.Many2one(
  79. string='Odoo Cron', comodel_name='ir.cron', readonly=True,
  80. help="Cron Task that will refresh the materialized view")
  81. rule_id = fields.Many2one(
  82. string='Odoo Rule', comodel_name='ir.rule', readonly=True)
  83. # Compute Section
  84. @api.depends('is_materialized')
  85. @api.multi
  86. def _compute_materialized_text(self):
  87. for sql_view in self:
  88. sql_view.materialized_text =\
  89. sql_view.is_materialized and 'MATERIALIZED' or ''
  90. @api.depends('technical_name')
  91. @api.multi
  92. def _compute_view_name(self):
  93. for sql_view in self:
  94. sql_view.view_name = '%s%s' % (
  95. sql_view._sql_prefix, sql_view.technical_name)
  96. @api.depends('technical_name')
  97. @api.multi
  98. def _compute_model_name(self):
  99. for sql_view in self:
  100. sql_view.model_name = '%s%s' % (
  101. sql_view._model_prefix, sql_view.technical_name)
  102. @api.onchange('group_ids')
  103. def onchange_group_ids(self):
  104. if self.state not in ('draft', 'sql_valid'):
  105. self.has_group_changed = True
  106. # Overload Section
  107. @api.multi
  108. def unlink(self):
  109. non_draft_views = self.search([
  110. ('id', 'in', self.ids),
  111. ('state', 'not in', ('draft', 'sql_valid'))])
  112. if non_draft_views:
  113. raise UserError(_("You can only unlink draft views"))
  114. return super(BiSQLView, self).unlink()
  115. @api.multi
  116. def copy(self, default=None):
  117. self.ensure_one()
  118. default = dict(default or {})
  119. default.update({
  120. 'name': _('%s (Copy)') % (self.name),
  121. 'technical_name': '%s_copy' % (self.technical_name),
  122. })
  123. return super(BiSQLView, self).copy(default=default)
  124. # Action Section
  125. @api.multi
  126. def button_create_sql_view_and_model(self):
  127. for sql_view in self:
  128. if sql_view.state != 'sql_valid':
  129. raise UserError(_(
  130. "You can only process this action on SQL Valid items"))
  131. # Create ORM and acess
  132. sql_view._create_model_and_fields()
  133. sql_view._create_model_access()
  134. # Create SQL View and indexes
  135. sql_view._create_view()
  136. sql_view._create_index()
  137. if sql_view.is_materialized:
  138. sql_view.cron_id = self.env['ir.cron'].create(
  139. sql_view._prepare_cron()).id
  140. sql_view.state = 'model_valid'
  141. @api.multi
  142. def button_set_draft(self):
  143. for sql_view in self:
  144. sql_view.rule_id.unlink()
  145. if sql_view.state in ('model_valid', 'ui_valid'):
  146. # Drop SQL View (and indexes by cascade)
  147. sql_view._drop_view()
  148. # Drop ORM
  149. sql_view._drop_model_and_fields()
  150. sql_view.tree_view_id.unlink()
  151. sql_view.graph_view_id.unlink()
  152. sql_view.search_view_id.unlink()
  153. sql_view.action_id.unlink()
  154. sql_view.menu_id.unlink()
  155. if sql_view.cron_id:
  156. sql_view.cron_id.unlink()
  157. sql_view.write({'state': 'draft', 'has_group_changed': False})
  158. @api.multi
  159. def button_create_ui(self):
  160. self.tree_view_id = self.env['ir.ui.view'].create(
  161. self._prepare_tree_view()).id
  162. self.graph_view_id = self.env['ir.ui.view'].create(
  163. self._prepare_graph_view()).id
  164. self.search_view_id = self.env['ir.ui.view'].create(
  165. self._prepare_search_view()).id
  166. self.action_id = self.env['ir.actions.act_window'].create(
  167. self._prepare_action()).id
  168. self.menu_id = self.env['ir.ui.menu'].create(
  169. self._prepare_menu()).id
  170. self.write({'state': 'ui_valid'})
  171. @api.multi
  172. def button_update_model_access(self):
  173. self._drop_model_access()
  174. self._create_model_access()
  175. self.write({'has_group_changed': False})
  176. @api.multi
  177. def button_refresh_materialized_view(self):
  178. self._refresh_materialized_view()
  179. @api.multi
  180. def button_open_view(self):
  181. return {
  182. 'type': 'ir.actions.act_window',
  183. 'res_model': self.model_id.model,
  184. 'search_view_id': self.search_view_id.id,
  185. 'view_type': 'form',
  186. 'view_mode': 'graph,tree',
  187. }
  188. # Prepare Function
  189. @api.multi
  190. def _prepare_model(self):
  191. self.ensure_one()
  192. field_id = []
  193. for field in self.bi_sql_view_field_ids.filtered(
  194. lambda x: x.field_description is not False):
  195. field_id.append([0, False, field._prepare_model_field()])
  196. return {
  197. 'name': self.name,
  198. 'model': self.model_name,
  199. 'access_ids': [],
  200. 'field_id': field_id,
  201. }
  202. @api.multi
  203. def _prepare_model_access(self):
  204. self.ensure_one()
  205. res = []
  206. for group in self.group_ids:
  207. res.append({
  208. 'name': _('%s Access %s') % (
  209. self.model_name, group.full_name),
  210. 'model_id': self.model_id.id,
  211. 'group_id': group.id,
  212. 'perm_read': True,
  213. 'perm_create': False,
  214. 'perm_write': False,
  215. 'perm_unlink': False,
  216. })
  217. return res
  218. @api.multi
  219. def _prepare_cron(self):
  220. self.ensure_one()
  221. return {
  222. 'name': _('Refresh Materialized View %s') % (self.view_name),
  223. 'user_id': SUPERUSER_ID,
  224. 'model': 'bi.sql.view',
  225. 'function': 'button_refresh_materialized_view',
  226. 'args': repr(([self.id],))
  227. }
  228. @api.multi
  229. def _prepare_rule(self):
  230. self.ensure_one()
  231. return {
  232. 'name': _('Access %s') % (self.name),
  233. 'model_id': self.model_id.id,
  234. 'domain_force': self.domain_force,
  235. 'global': True,
  236. }
  237. @api.multi
  238. def _prepare_tree_view(self):
  239. self.ensure_one()
  240. return {
  241. 'name': self.name,
  242. 'type': 'tree',
  243. 'model': self.model_id.model,
  244. 'arch':
  245. """<?xml version="1.0"?>"""
  246. """<tree string="Analysis">{}"""
  247. """</tree>""".format("".join(
  248. [x._prepare_tree_field()
  249. for x in self.bi_sql_view_field_ids]))
  250. }
  251. @api.multi
  252. def _prepare_graph_view(self):
  253. self.ensure_one()
  254. return {
  255. 'name': self.name,
  256. 'type': 'graph',
  257. 'model': self.model_id.model,
  258. 'arch':
  259. """<?xml version="1.0"?>"""
  260. """<graph string="Analysis" type="pivot" stacked="True">{}"""
  261. """</graph>""".format("".join(
  262. [x._prepare_graph_field()
  263. for x in self.bi_sql_view_field_ids]))
  264. }
  265. @api.multi
  266. def _prepare_search_view(self):
  267. self.ensure_one()
  268. return {
  269. 'name': self.name,
  270. 'type': 'search',
  271. 'model': self.model_id.model,
  272. 'arch':
  273. """<?xml version="1.0"?>"""
  274. """<search string="Analysis">{}"""
  275. """<group expand="1" string="Group By">{}</group>"""
  276. """</search>""".format(
  277. "".join(
  278. [x._prepare_search_field()
  279. for x in self.bi_sql_view_field_ids]),
  280. "".join(
  281. [x._prepare_search_filter_field()
  282. for x in self.bi_sql_view_field_ids]))
  283. }
  284. @api.multi
  285. def _prepare_action(self):
  286. self.ensure_one()
  287. return {
  288. 'name': self.name,
  289. 'res_model': self.model_id.model,
  290. 'type': 'ir.actions.act_window',
  291. 'view_type': 'form',
  292. 'view_mode': 'graph,tree',
  293. 'view_id': self.graph_view_id.id,
  294. 'search_view_id': self.search_view_id.id,
  295. }
  296. @api.multi
  297. def _prepare_menu(self):
  298. self.ensure_one()
  299. return {
  300. 'name': self.name,
  301. 'parent_id': self.env.ref('bi_sql_editor.menu_bi_sql_editor').id,
  302. 'action': 'ir.actions.act_window,%s' % (self.action_id.id),
  303. }
  304. # Custom Section
  305. def _log_execute(self, req):
  306. _logger.info("Executing SQL Request %s ..." % (req))
  307. self.env.cr.execute(req)
  308. @api.multi
  309. def _drop_view(self):
  310. for sql_view in self:
  311. self._log_execute(
  312. "DROP %s VIEW IF EXISTS %s" % (
  313. sql_view.materialized_text, sql_view.view_name))
  314. sql_view.size = False
  315. @api.multi
  316. def _create_view(self):
  317. for sql_view in self:
  318. sql_view._drop_view()
  319. try:
  320. self._log_execute(sql_view._prepare_request_for_execution())
  321. sql_view._refresh_size()
  322. except ProgrammingError as e:
  323. raise UserError(_(
  324. "SQL Error while creating %s VIEW %s :\n %s") % (
  325. sql_view.materialized_text, sql_view.view_name,
  326. e.message))
  327. @api.multi
  328. def _create_index(self):
  329. for sql_view in self:
  330. for sql_field in sql_view.bi_sql_view_field_ids.filtered(
  331. lambda x: x.is_index is True):
  332. self._log_execute(
  333. "CREATE INDEX %s ON %s (%s);" % (
  334. sql_field.index_name, sql_view.view_name,
  335. sql_field.name))
  336. @api.multi
  337. def _create_model_and_fields(self):
  338. for sql_view in self:
  339. # Create model
  340. sql_view.model_id = self.env['ir.model'].create(
  341. self._prepare_model()).id
  342. sql_view.rule_id = self.env['ir.rule'].create(
  343. self._prepare_rule()).id
  344. # Drop table, created by the ORM
  345. req = "DROP TABLE %s" % (sql_view.view_name)\
  346. # pylint: disable=sql-injection
  347. self.env.cr.execute(req)
  348. @api.multi
  349. def _create_model_access(self):
  350. for sql_view in self:
  351. for item in sql_view._prepare_model_access():
  352. self.env['ir.model.access'].create(item)
  353. @api.multi
  354. def _drop_model_access(self):
  355. for sql_view in self:
  356. self.env['ir.model.access'].search(
  357. [('model_id', '=', sql_view.model_name)]).unlink()
  358. @api.multi
  359. def _drop_model_and_fields(self):
  360. for sql_view in self:
  361. sql_view.model_id.unlink()
  362. @api.multi
  363. def _hook_executed_request(self):
  364. self.ensure_one()
  365. req = """
  366. SELECT attnum,
  367. attname AS column,
  368. format_type(atttypid, atttypmod) AS type
  369. FROM pg_attribute
  370. WHERE attrelid = '%s'::regclass
  371. AND NOT attisdropped
  372. AND attnum > 0
  373. ORDER BY attnum;""" % (
  374. self.view_name) # pylint: disable=sql-injection
  375. self.env.cr.execute(req)
  376. return self.env.cr.fetchall()
  377. @api.multi
  378. def _prepare_request_check_execution(self):
  379. self.ensure_one()
  380. return "CREATE VIEW %s AS (%s);" % (self.view_name, self.query)
  381. @api.multi
  382. def _prepare_request_for_execution(self):
  383. self.ensure_one()
  384. query = """
  385. SELECT
  386. CAST(row_number() OVER () as integer) AS id,
  387. CAST(Null as timestamp without time zone) as create_date,
  388. CAST(Null as integer) as create_uid,
  389. CAST(Null as timestamp without time zone) as write_date,
  390. CAST(Null as integer) as write_uid,
  391. my_query.*
  392. FROM
  393. (%s) as my_query
  394. """ % (self.query)
  395. return "CREATE %s VIEW %s AS (%s);" % (
  396. self.materialized_text, self.view_name, query)
  397. @api.multi
  398. def _check_execution(self):
  399. """Ensure that the query is valid, trying to execute it.
  400. a non materialized view is created for this check.
  401. A rollback is done at the end.
  402. After the execution, and before the rollback, an analysis of
  403. the database structure is done, to know fields type."""
  404. self.ensure_one()
  405. sql_view_field_obj = self.env['bi.sql.view.field']
  406. columns = super(BiSQLView, self)._check_execution()
  407. field_ids = []
  408. for column in columns:
  409. existing_field = self.bi_sql_view_field_ids.filtered(
  410. lambda x: x.name == column[1])
  411. if existing_field:
  412. # Update existing field
  413. field_ids.append(existing_field.id)
  414. existing_field.write({
  415. 'sequence': column[0],
  416. 'sql_type': column[2],
  417. })
  418. else:
  419. # Create a new one if name is prefixed by x_
  420. if column[1][:2] == 'x_':
  421. field_ids.append(sql_view_field_obj.create({
  422. 'sequence': column[0],
  423. 'name': column[1],
  424. 'sql_type': column[2],
  425. 'bi_sql_view_id': self.id,
  426. }).id)
  427. # Drop obsolete view field
  428. self.bi_sql_view_field_ids.filtered(
  429. lambda x: x.id not in field_ids).unlink()
  430. if not self.bi_sql_view_field_ids:
  431. raise UserError(_(
  432. "No Column was found.\n"
  433. "Columns name should be prefixed by 'x_'."))
  434. return columns
  435. @api.multi
  436. def _refresh_materialized_view(self):
  437. for sql_view in self:
  438. req = "REFRESH %s VIEW %s" % (
  439. sql_view.materialized_text, sql_view.view_name)
  440. self._log_execute(req)
  441. sql_view._refresh_size()
  442. if sql_view.action_id:
  443. # Alter name of the action, to display last refresh datetime
  444. # of the materialized view
  445. sql_view.action_id.name = "%s (%s)" % (
  446. self.name,
  447. datetime.utcnow().strftime(_("%m/%d/%Y %H:%M:%S UTC")))
  448. @api.multi
  449. def _refresh_size(self):
  450. for sql_view in self:
  451. req = "SELECT pg_size_pretty(pg_total_relation_size('%s'));" % (
  452. sql_view.view_name) # pylint: disable=sql-injection
  453. self.env.cr.execute(req)
  454. sql_view.size = self.env.cr.fetchone()[0]