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.

641 lines
23 KiB

4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
  1. # Copyright 2015-2020 Onestein (<https://www.onestein.eu>)
  2. # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
  3. import base64
  4. import json
  5. import pydot
  6. from psycopg2.extensions import AsIs
  7. from odoo import _, api, fields, models, tools
  8. from odoo.exceptions import UserError, ValidationError
  9. class BveView(models.Model):
  10. _name = "bve.view"
  11. _description = "BI View Editor"
  12. @api.depends("group_ids", "group_ids.users")
  13. def _compute_users(self):
  14. for bve_view in self.sudo():
  15. if bve_view.group_ids:
  16. bve_view.user_ids = bve_view.group_ids.mapped("users")
  17. else:
  18. bve_view.user_ids = self.env["res.users"].sudo().search([])
  19. @api.depends("name")
  20. def _compute_model_name(self):
  21. for bve_view in self:
  22. name = [x for x in bve_view.name.lower() if x.isalnum()]
  23. model_name = "".join(name).replace("_", ".").replace(" ", ".")
  24. bve_view.model_name = "x_bve." + model_name
  25. def _compute_serialized_data(self):
  26. for bve_view in self:
  27. serialized_data = []
  28. for line in bve_view.line_ids.sorted(key=lambda r: r.sequence):
  29. serialized_data.append(
  30. {
  31. "sequence": line.sequence,
  32. "model_id": line.model_id.id,
  33. "id": line.field_id.id,
  34. "name": line.name,
  35. "model_name": line.model_id.name,
  36. "model": line.model_id.model,
  37. "type": line.ttype,
  38. "table_alias": line.table_alias,
  39. "description": line.description,
  40. "row": line.row,
  41. "column": line.column,
  42. "measure": line.measure,
  43. "list": line.in_list,
  44. "join_node": line.join_node,
  45. "relation": line.relation,
  46. }
  47. )
  48. bve_view.data = json.dumps(serialized_data)
  49. def _inverse_serialized_data(self):
  50. for bve_view in self:
  51. line_ids = self._sync_lines_and_data(bve_view.data)
  52. bve_view.write({"line_ids": line_ids})
  53. name = fields.Char(required=True, copy=False, default="")
  54. model_name = fields.Char(compute="_compute_model_name", store=True)
  55. note = fields.Text(string="Notes")
  56. state = fields.Selection(
  57. [("draft", "Draft"), ("created", "Created")], default="draft", copy=False
  58. )
  59. data = fields.Char(
  60. compute="_compute_serialized_data",
  61. inverse="_inverse_serialized_data",
  62. help="Use the special query builder to define the query "
  63. "to generate your report dataset. "
  64. "NOTE: To be edited, the query should be in 'Draft' status.",
  65. )
  66. line_ids = fields.One2many("bve.view.line", "bve_view_id", string="Lines")
  67. field_ids = fields.One2many(
  68. "bve.view.line",
  69. "bve_view_id",
  70. domain=["|", ("join_node", "=", -1), ("join_node", "=", False)],
  71. string="Fields",
  72. )
  73. relation_ids = fields.One2many(
  74. "bve.view.line",
  75. "bve_view_id",
  76. domain=[("join_node", "!=", -1), ("join_node", "!=", False)],
  77. string="Relations",
  78. )
  79. action_id = fields.Many2one("ir.actions.act_window", string="Action")
  80. view_id = fields.Many2one("ir.ui.view", string="View")
  81. group_ids = fields.Many2many(
  82. "res.groups",
  83. string="Groups",
  84. help="User groups allowed to see the generated report; "
  85. "if NO groups are specified the report will be public "
  86. "for everyone.",
  87. )
  88. user_ids = fields.Many2many(
  89. "res.users", string="Users", compute="_compute_users", store=True
  90. )
  91. query = fields.Text(compute="_compute_sql_query")
  92. over_condition = fields.Text(
  93. states={"draft": [("readonly", False)]},
  94. readonly=True,
  95. help="Condition to be inserted in the OVER part "
  96. "of the ID's row_number function.\n"
  97. "For instance, 'ORDER BY t1.id' would create "
  98. "IDs ordered in the same way as t1's IDs; otherwise "
  99. "IDs are assigned with no specific order.",
  100. )
  101. er_diagram_image = fields.Binary(compute="_compute_er_diagram_image")
  102. _sql_constraints = [
  103. ("name_uniq", "unique(name)", _("Custom BI View names must be unique!")),
  104. ]
  105. @api.depends("line_ids")
  106. def _compute_er_diagram_image(self):
  107. for bve_view in self:
  108. graph = pydot.Dot(graph_type="graph")
  109. table_model_map = {}
  110. for line in bve_view.field_ids:
  111. if line.table_alias not in table_model_map:
  112. table_alias_node = pydot.Node(
  113. line.model_id.name + " " + line.table_alias,
  114. style="filled",
  115. shape="box",
  116. fillcolor="#DDDDDD",
  117. )
  118. table_model_map[line.table_alias] = table_alias_node
  119. graph.add_node(table_model_map[line.table_alias])
  120. field_node = pydot.Node(
  121. line.table_alias + "." + line.field_id.field_description,
  122. label=line.description,
  123. style="filled",
  124. fillcolor="green",
  125. )
  126. graph.add_node(field_node)
  127. graph.add_edge(
  128. pydot.Edge(table_model_map[line.table_alias], field_node)
  129. )
  130. for line in bve_view.relation_ids:
  131. field_description = line.field_id.field_description
  132. table_alias = line.table_alias
  133. diamond_node = pydot.Node(
  134. line.ttype + " " + table_alias + "." + field_description,
  135. label=table_alias + "." + field_description,
  136. style="filled",
  137. shape="diamond",
  138. fillcolor="#D2D2FF",
  139. )
  140. graph.add_node(diamond_node)
  141. graph.add_edge(
  142. pydot.Edge(
  143. table_model_map[table_alias],
  144. diamond_node,
  145. labelfontcolor="#D2D2FF",
  146. color="blue",
  147. )
  148. )
  149. graph.add_edge(
  150. pydot.Edge(
  151. diamond_node,
  152. table_model_map[line.join_node],
  153. labelfontcolor="black",
  154. color="blue",
  155. )
  156. )
  157. try:
  158. png_base64_image = base64.b64encode(graph.create_png())
  159. bve_view.er_diagram_image = png_base64_image
  160. except Exception:
  161. bve_view.er_diagram_image = False
  162. def _create_view_arch(self):
  163. self.ensure_one()
  164. def _get_field_def(line):
  165. field_type = line.view_field_type
  166. return '<field name="{}" type="{}" />'.format(line.name, field_type)
  167. bve_field_lines = self.field_ids.filtered("view_field_type")
  168. return list(map(_get_field_def, bve_field_lines))
  169. def _create_tree_view_arch(self):
  170. self.ensure_one()
  171. def _get_field_attrs(line):
  172. attr = line.list_attr
  173. res = attr and '{}="{}"'.format(attr, line.description) or ""
  174. return '<field name="{}" {} />'.format(line.name, res)
  175. bve_field_lines = self.field_ids.filtered(lambda l: l.in_list)
  176. return list(map(_get_field_attrs, bve_field_lines.sorted("sequence")))
  177. def _create_bve_view(self):
  178. self.ensure_one()
  179. View = self.env["ir.ui.view"].sudo()
  180. # delete old views
  181. View.search([("model", "=", self.model_name)]).unlink()
  182. # create views
  183. View.create(
  184. [
  185. {
  186. "name": "Pivot Analysis",
  187. "type": "pivot",
  188. "model": self.model_name,
  189. "priority": 16,
  190. "arch": """<?xml version="1.0"?>
  191. <pivot string="Pivot Analysis">
  192. {}
  193. </pivot>
  194. """.format(
  195. "".join(self._create_view_arch())
  196. ),
  197. },
  198. {
  199. "name": "Graph Analysis",
  200. "type": "graph",
  201. "model": self.model_name,
  202. "priority": 16,
  203. "arch": """<?xml version="1.0"?>
  204. <graph string="Graph Analysis"
  205. type="bar" stacked="True">
  206. {}
  207. </graph>
  208. """.format(
  209. "".join(self._create_view_arch())
  210. ),
  211. },
  212. {
  213. "name": "Search BI View",
  214. "type": "search",
  215. "model": self.model_name,
  216. "priority": 16,
  217. "arch": """<?xml version="1.0"?>
  218. <search>
  219. {}
  220. </search>
  221. """.format(
  222. "".join(self._create_view_arch())
  223. ),
  224. },
  225. ]
  226. )
  227. # create Tree view
  228. tree_view = View.create(
  229. {
  230. "name": "Tree Analysis",
  231. "type": "tree",
  232. "model": self.model_name,
  233. "priority": 16,
  234. "arch": """<?xml version="1.0"?>
  235. <tree create="false">
  236. {}
  237. </tree>
  238. """.format(
  239. "".join(self._create_tree_view_arch())
  240. ),
  241. }
  242. )
  243. # set the Tree view as the default one
  244. action = (
  245. self.env["ir.actions.act_window"]
  246. .sudo()
  247. .create(
  248. {
  249. "name": self.name,
  250. "res_model": self.model_name,
  251. "type": "ir.actions.act_window",
  252. "view_mode": "tree,graph,pivot",
  253. "view_id": tree_view.id,
  254. "context": "{'service_name': '%s'}" % self.name,
  255. }
  256. )
  257. )
  258. self.write(
  259. {"action_id": action.id, "view_id": tree_view.id, "state": "created"}
  260. )
  261. def _build_access_rules(self, model):
  262. self.ensure_one()
  263. if not self.group_ids:
  264. self.env["ir.model.access"].sudo().create(
  265. {
  266. "name": "read access to " + self.model_name,
  267. "model_id": model.id,
  268. "perm_read": True,
  269. }
  270. )
  271. else:
  272. # read access only to model
  273. access_vals = [
  274. {
  275. "name": "read access to " + self.model_name,
  276. "model_id": model.id,
  277. "group_id": group.id,
  278. "perm_read": True,
  279. }
  280. for group in self.group_ids
  281. ]
  282. self.env["ir.model.access"].sudo().create(access_vals)
  283. def _create_sql_view(self):
  284. self.ensure_one()
  285. view_name = self.model_name.replace(".", "_")
  286. query = self.query and self.query.replace("\n", " ")
  287. # robustness in case something went wrong
  288. self._cr.execute("DROP TABLE IF EXISTS %s", (AsIs(view_name),))
  289. # create postgres view
  290. try:
  291. with self.env.cr.savepoint():
  292. self.env.cr.execute(
  293. "CREATE or REPLACE VIEW %s as (%s)",
  294. (
  295. AsIs(view_name),
  296. AsIs(query),
  297. ),
  298. )
  299. except Exception as e:
  300. raise UserError(
  301. _("Error creating the view '{query}':\n{error}").format(
  302. query=query, error=e
  303. )
  304. )
  305. @api.depends("line_ids", "state", "over_condition")
  306. def _compute_sql_query(self):
  307. for bve_view in self:
  308. tables_map = {}
  309. select_str = "\n CAST(row_number() OVER ({}) as integer) AS id".format(
  310. bve_view.over_condition or ""
  311. )
  312. for line in bve_view.field_ids:
  313. table = line.table_alias
  314. select = line.field_id.name
  315. as_name = line.name
  316. select_str += ",\n {}.{} AS {}".format(table, select, as_name)
  317. if line.table_alias not in tables_map:
  318. table = self.env[line.field_id.model_id.model]._table
  319. tables_map[line.table_alias] = table
  320. seen = set()
  321. from_str = ""
  322. if not bve_view.relation_ids and bve_view.field_ids:
  323. first_line = bve_view.field_ids[0]
  324. table = tables_map[first_line.table_alias]
  325. from_str = "{} AS {}".format(table, first_line.table_alias)
  326. for line in bve_view.relation_ids:
  327. table = tables_map[line.table_alias]
  328. table_format = "{} AS {}".format(table, line.table_alias)
  329. if not from_str:
  330. from_str += table_format
  331. seen.add(line.table_alias)
  332. if line.table_alias not in seen:
  333. seen.add(line.table_alias)
  334. from_str += "\n"
  335. from_str += " LEFT" if line.left_join else ""
  336. from_str += " JOIN {} ON {}.id = {}.{}".format(
  337. table_format,
  338. line.join_node,
  339. line.table_alias,
  340. line.field_id.name,
  341. )
  342. if line.join_node not in seen:
  343. from_str += "\n"
  344. seen.add(line.join_node)
  345. from_str += " LEFT" if line.left_join else ""
  346. from_str += " JOIN {} AS {} ON {}.{} = {}.id".format(
  347. tables_map[line.join_node],
  348. line.join_node,
  349. line.table_alias,
  350. line.field_id.name,
  351. line.join_node,
  352. )
  353. bve_view.query = """SELECT %s\n\nFROM %s
  354. """ % (
  355. AsIs(select_str),
  356. AsIs(from_str),
  357. )
  358. def action_translations(self):
  359. self.ensure_one()
  360. if self.state != "created":
  361. return
  362. self = self.sudo()
  363. model = self.env["ir.model"].search([("model", "=", self.model_name)])
  364. IrTranslation = self.env["ir.translation"]
  365. IrTranslation.translate_fields("ir.model", model.id)
  366. for field in model.field_id:
  367. IrTranslation.translate_fields("ir.model.fields", field.id)
  368. return {
  369. "name": "Translations",
  370. "res_model": "ir.translation",
  371. "type": "ir.actions.act_window",
  372. "view_mode": "tree",
  373. "view_id": self.env.ref("base.view_translation_dialog_tree").id,
  374. "target": "current",
  375. "flags": {"search_view": True, "action_buttons": True},
  376. "domain": [
  377. "|",
  378. "&",
  379. ("res_id", "in", model.field_id.ids),
  380. ("name", "=", "ir.model.fields,field_description"),
  381. "&",
  382. ("res_id", "=", model.id),
  383. ("name", "=", "ir.model,name"),
  384. ],
  385. }
  386. def action_create(self):
  387. self.ensure_one()
  388. # consistency checks
  389. self._check_invalid_lines()
  390. self._check_groups_consistency()
  391. # force removal of dirty views in case something went wrong
  392. self.sudo().action_reset()
  393. # create sql view
  394. self._create_sql_view()
  395. # create model and fields
  396. bve_fields = self.line_ids.filtered(lambda l: not l.join_node)
  397. model = (
  398. self.env["ir.model"]
  399. .sudo()
  400. .with_context(bve=True)
  401. .create(
  402. {
  403. "name": self.name,
  404. "model": self.model_name,
  405. "state": "manual",
  406. "field_id": [(0, 0, f) for f in bve_fields._prepare_field_vals()],
  407. }
  408. )
  409. )
  410. # give access rights
  411. self._build_access_rules(model)
  412. # create tree, graph and pivot views
  413. self._create_bve_view()
  414. def _check_groups_consistency(self):
  415. self.ensure_one()
  416. if not self.group_ids:
  417. return
  418. for line_model in self.line_ids.mapped("model_id"):
  419. res_count = (
  420. self.env["ir.model.access"]
  421. .sudo()
  422. .search(
  423. [
  424. ("model_id", "=", line_model.id),
  425. ("perm_read", "=", True),
  426. "|",
  427. ("group_id", "=", False),
  428. ("group_id", "in", self.group_ids.ids),
  429. ],
  430. limit=1,
  431. )
  432. )
  433. if not res_count:
  434. access_records = (
  435. self.env["ir.model.access"]
  436. .sudo()
  437. .search(
  438. [("model_id", "=", line_model.id), ("perm_read", "=", True)]
  439. )
  440. )
  441. group_list = ""
  442. for group in access_records.mapped("group_id"):
  443. group_list += " * {}\n".format(group.full_name)
  444. msg_title = _(
  445. 'The model "%s" cannot be accessed by users with the '
  446. "selected groups only." % (line_model.name,)
  447. )
  448. msg_details = _("At least one of the following groups must be added:")
  449. raise UserError(
  450. _("{}\n\n{}\n{}".format(msg_title, msg_details, group_list))
  451. )
  452. def _check_invalid_lines(self):
  453. self.ensure_one()
  454. if not self.line_ids:
  455. raise ValidationError(_("No data to process."))
  456. invalid_lines = self.line_ids.filtered(lambda l: not l.model_id)
  457. if invalid_lines:
  458. missing_models = ", ".join(set(invalid_lines.mapped("model_name")))
  459. raise ValidationError(
  460. _(
  461. "Following models are missing: %s.\n"
  462. "Probably some modules were uninstalled." % (missing_models,)
  463. )
  464. )
  465. invalid_lines = self.line_ids.filtered(lambda l: not l.field_id)
  466. if invalid_lines:
  467. missing_fields = ", ".join(set(invalid_lines.mapped("field_name")))
  468. raise ValidationError(
  469. _("Following fields are missing: {}.".format(missing_fields))
  470. )
  471. def open_view(self):
  472. self.ensure_one()
  473. self._check_invalid_lines()
  474. [action] = self.action_id.read()
  475. action["display_name"] = _("BI View")
  476. return action
  477. def copy(self, default=None):
  478. self.ensure_one()
  479. default = dict(default or {}, name=_("%s (copy)") % self.name)
  480. return super().copy(default=default)
  481. def action_reset(self):
  482. self.ensure_one()
  483. has_menus = False
  484. if self.action_id:
  485. action = "ir.actions.act_window,%d" % (self.action_id.id,)
  486. menus = self.env["ir.ui.menu"].search([("action", "=", action)])
  487. has_menus = True if menus else False
  488. menus.unlink()
  489. if self.action_id.view_id:
  490. self.sudo().action_id.view_id.unlink()
  491. self.sudo().action_id.unlink()
  492. self.env["ir.ui.view"].sudo().search([("model", "=", self.model_name)]).unlink()
  493. models_to_delete = (
  494. self.env["ir.model"].sudo().search([("model", "=", self.model_name)])
  495. )
  496. if models_to_delete:
  497. models_to_delete.with_context(_force_unlink=True).unlink()
  498. table_name = self.model_name.replace(".", "_")
  499. tools.drop_view_if_exists(self.env.cr, table_name)
  500. self.state = "draft"
  501. if has_menus:
  502. return {"type": "ir.actions.client", "tag": "reload"}
  503. def unlink(self):
  504. if self.filtered(lambda v: v.state == "created"):
  505. raise UserError(
  506. _("You cannot delete a created view! " "Reset the view to draft first.")
  507. )
  508. return super().unlink()
  509. @api.model
  510. def _sync_lines_and_data(self, data):
  511. line_ids = [(5, 0, 0)]
  512. fields_info = []
  513. if data:
  514. fields_info = json.loads(data)
  515. table_model_map = {}
  516. for item in fields_info:
  517. if item.get("join_node", -1) == -1:
  518. table_model_map[item["table_alias"]] = item["model_id"]
  519. for sequence, field_info in enumerate(fields_info, start=1):
  520. join_model_id = False
  521. join_node = field_info.get("join_node", -1)
  522. if join_node != -1 and table_model_map.get(join_node):
  523. join_model_id = int(table_model_map[join_node])
  524. line_ids += [
  525. (
  526. 0,
  527. False,
  528. {
  529. "sequence": sequence,
  530. "model_id": field_info["model_id"],
  531. "table_alias": field_info["table_alias"],
  532. "description": field_info["description"],
  533. "field_id": field_info["id"],
  534. "ttype": field_info["type"],
  535. "row": field_info["row"],
  536. "column": field_info["column"],
  537. "measure": field_info["measure"],
  538. "in_list": field_info["list"],
  539. "relation": field_info.get("relation"),
  540. "join_node": field_info.get("join_node"),
  541. "join_model_id": join_model_id,
  542. },
  543. )
  544. ]
  545. return line_ids
  546. @api.constrains("line_ids")
  547. def _constraint_line_ids(self):
  548. models_with_tables = self.env.registry.models.keys()
  549. for view in self:
  550. nodes = view.line_ids.filtered(lambda n: n.join_node)
  551. nodes_models = nodes.mapped("table_alias")
  552. nodes_models += nodes.mapped("join_node")
  553. not_nodes = view.line_ids.filtered(lambda n: not n.join_node)
  554. not_nodes_models = not_nodes.mapped("table_alias")
  555. err_msg = _("Inconsistent lines.")
  556. if set(nodes_models) - set(not_nodes_models):
  557. raise ValidationError(err_msg)
  558. if len(set(not_nodes_models) - set(nodes_models)) > 1:
  559. raise ValidationError(err_msg)
  560. models = view.line_ids.mapped("model_id")
  561. if models.filtered(lambda m: m.model not in models_with_tables):
  562. raise ValidationError(_("Abstract models not supported."))
  563. @api.model
  564. def get_clean_list(self, data_dict):
  565. serialized_data = data_dict
  566. if type(data_dict) == str:
  567. serialized_data = json.loads(data_dict)
  568. table_alias_list = set()
  569. for item in serialized_data:
  570. if item.get("join_node", -1) in [-1, False]:
  571. table_alias_list.add(item["table_alias"])
  572. for item in serialized_data:
  573. if item.get("join_node", -1) not in [-1, False]:
  574. if item["table_alias"] not in table_alias_list:
  575. serialized_data.remove(item)
  576. elif item["join_node"] not in table_alias_list:
  577. serialized_data.remove(item)
  578. return json.dumps(serialized_data)