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.

598 lines
20 KiB

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