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.

362 lines
13 KiB

7 years ago
7 years ago
  1. # Copyright 2011 Raphaël Valyi, Renato Lima, Guewen Baconnier, Sodexis
  2. # Copyright 2017 Akretion (http://www.akretion.com)
  3. # Mourad EL HADJ MIMOUNE <mourad.elhadj.mimoune@akretion.com>
  4. # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
  5. import time
  6. from functools import wraps
  7. from odoo import api, fields, models, _
  8. from odoo.exceptions import UserError, ValidationError
  9. from odoo.tools.safe_eval import safe_eval
  10. def implemented_by_base_exception(func):
  11. """Call a prefixed function based on 'namespace'."""
  12. @wraps(func)
  13. def wrapper(cls, *args, **kwargs):
  14. fun_name = func.__name__
  15. fun = '_%s%s' % (cls.rule_group, fun_name)
  16. if not hasattr(cls, fun):
  17. fun = '_default%s' % (fun_name)
  18. return getattr(cls, fun)(*args, **kwargs)
  19. return wrapper
  20. class ExceptionRule(models.Model):
  21. _name = 'exception.rule'
  22. _description = 'Exception Rule'
  23. _order = 'active desc, sequence asc'
  24. name = fields.Char('Exception Name', required=True, translate=True)
  25. description = fields.Text('Description', translate=True)
  26. sequence = fields.Integer(
  27. string='Sequence',
  28. help="Gives the sequence order when applying the test",
  29. )
  30. rule_group = fields.Selection(
  31. selection=[],
  32. help="Rule group is used to group the rules that must validated "
  33. "at same time for a target object. Ex: "
  34. "validate sale.order.line rules with sale order rules.",
  35. required=True,
  36. )
  37. model = fields.Selection(selection=[], string='Apply on', required=True)
  38. exception_type = fields.Selection(
  39. selection=[('by_domain', 'By domain'),
  40. ('by_py_code', 'By python code')],
  41. string='Exception Type', required=True, default='by_py_code',
  42. help="By python code: allow to define any arbitrary check\n"
  43. "By domain: limited to a selection by an odoo domain:\n"
  44. " performance can be better when exceptions "
  45. " are evaluated with several records")
  46. domain = fields.Char('Domain')
  47. active = fields.Boolean('Active')
  48. next_state = fields.Char(
  49. 'Next state',
  50. help="If we detect exception we set the state of object (ex purchase) "
  51. "to the next_state (ex 'to approve'). If there are more than one "
  52. "exception detected and all have a value for next_state, we use"
  53. "the exception having the smallest sequence value",
  54. )
  55. code = fields.Text(
  56. 'Python Code',
  57. help="Python code executed to check if the exception apply or "
  58. "not. Use failed = True to block the exception",
  59. )
  60. @api.constrains('next_state')
  61. def _check_next_state_value(self):
  62. """ Ensure that the next_state value is in the state values of
  63. destination model """
  64. for rule in self:
  65. if rule.next_state:
  66. select_vals = self.env[
  67. rule.model].fields_get()[
  68. 'state']['selection']
  69. select_vals_code = [s[0] for s in select_vals]
  70. if rule.next_state not in select_vals_code:
  71. raise ValidationError(_(
  72. 'The value "%s" you choose for the "next state" '
  73. 'field state of "%s" is wrong.'
  74. ' Value must be in this list %s'
  75. ) % (
  76. rule.next_state,
  77. rule.model,
  78. select_vals
  79. ))
  80. @api.multi
  81. def _get_domain(self):
  82. """ override me to customize domains according exceptions cases """
  83. self.ensure_one()
  84. return safe_eval(self.domain)
  85. class BaseException(models.AbstractModel):
  86. _name = 'base.exception'
  87. _order = 'main_exception_id asc'
  88. _description = 'Exception'
  89. main_exception_id = fields.Many2one(
  90. 'exception.rule',
  91. compute='_compute_main_error',
  92. string='Main Exception',
  93. store=True,
  94. )
  95. rule_group = fields.Selection([], readonly=True)
  96. exception_ids = fields.Many2many('exception.rule', string='Exceptions')
  97. ignore_exception = fields.Boolean('Ignore Exceptions', copy=False)
  98. @api.depends('exception_ids', 'ignore_exception')
  99. def _compute_main_error(self):
  100. for rec in self:
  101. if not rec.ignore_exception and rec.exception_ids:
  102. rec.main_exception_id = rec.exception_ids[0]
  103. else:
  104. rec.main_exception_id = False
  105. @api.multi
  106. def _popup_exceptions(self):
  107. action = self._get_popup_action().read()[0]
  108. action.update({
  109. 'context': {
  110. 'active_id': self.ids[0],
  111. 'active_ids': self.ids,
  112. 'active_model': self._name,
  113. }
  114. })
  115. return action
  116. @api.model
  117. def _get_popup_action(self):
  118. return self.env.ref('base_exception.action_exception_rule_confirm')
  119. @api.multi
  120. def _check_exception(self):
  121. """
  122. This method must be used in a constraint that must be created in the
  123. object that inherits for base.exception.
  124. for sale :
  125. @api.constrains('ignore_exception',)
  126. def sale_check_exception(self):
  127. ...
  128. ...
  129. self._check_exception
  130. """
  131. exception_ids = self.detect_exceptions()
  132. if exception_ids:
  133. exceptions = self.env['exception.rule'].browse(exception_ids)
  134. raise ValidationError('\n'.join(exceptions.mapped('name')))
  135. @api.multi
  136. def test_exceptions(self):
  137. """
  138. Condition method for the workflow from draft to confirm
  139. """
  140. if self.detect_exceptions():
  141. return False
  142. return True
  143. @api.multi
  144. def _reverse_field(self):
  145. """Name of the many2many field from exception rule to self.
  146. In order to take advantage of domain optimisation, exception rule
  147. model should have a many2many field to inherited object.
  148. The opposit relation already exists in the name of exception_ids
  149. Example:
  150. class ExceptionRule(models.Model):
  151. _inherit = 'exception.rule'
  152. model = fields.Selection(
  153. selection_add=[
  154. ('sale.order', 'Sale order'),
  155. [...]
  156. ])
  157. sale_ids = fields.Many2many(
  158. 'sale.order',
  159. string='Sales')
  160. [...]
  161. """
  162. exception_obj = self.env['exception.rule']
  163. reverse_fields = self.env['ir.model.fields'].search([
  164. ['model', '=', 'exception.rule'],
  165. ['ttype', '=', 'many2many'],
  166. ['relation', '=', self[0]._name],
  167. ])
  168. # ir.model.fields may contain old variable name
  169. # so we check if the field exists on exception rule
  170. return ([
  171. field.name for field in reverse_fields
  172. if hasattr(exception_obj, field.name)
  173. ] or [None])[0]
  174. @api.multi
  175. def _rule_domain(self):
  176. """Filter exception.rules.
  177. By default, only the rules with the correct rule group
  178. will be used.
  179. """
  180. return [('rule_group', 'in', self.mapped('rule_group'))]
  181. @api.multi
  182. def detect_exceptions(self):
  183. """List all exception_ids applied on self
  184. Exception ids are also written on records
  185. """
  186. if not self:
  187. return []
  188. exception_obj = self.env['exception.rule']
  189. all_exceptions = exception_obj.sudo().search(
  190. self._rule_domain())
  191. model_exceptions = all_exceptions.filtered(
  192. lambda ex: ex.model == self._name)
  193. sub_exceptions = all_exceptions.filtered(
  194. lambda ex: ex.model != self._name)
  195. reverse_field = self._reverse_field()
  196. if reverse_field:
  197. optimize = True
  198. else:
  199. optimize = False
  200. exception_by_rec, exception_by_rule = self._detect_exceptions(
  201. model_exceptions, sub_exceptions, optimize)
  202. all_exception_ids = []
  203. for obj, exception_ids in exception_by_rec.items():
  204. obj.exception_ids = [(6, 0, exception_ids)]
  205. all_exception_ids += exception_ids
  206. for rule, exception_ids in exception_by_rule.items():
  207. rule[reverse_field] = [(6, 0, exception_ids.ids)]
  208. if exception_ids:
  209. all_exception_ids += [rule.id]
  210. return list(set(all_exception_ids))
  211. @api.model
  212. def _exception_rule_eval_context(self, obj_name, rec):
  213. return {
  214. 'time': time,
  215. 'self': rec,
  216. # obj_name, object, obj: deprecated.
  217. # should be removed in future migrations
  218. obj_name: rec,
  219. 'object': rec,
  220. 'obj': rec,
  221. # copy context to prevent side-effects of eval
  222. # should be deprecated too, accesible through self.
  223. 'context': self.env.context.copy()
  224. }
  225. @api.model
  226. def _rule_eval(self, rule, obj_name, rec):
  227. eval_ctx = self._exception_rule_eval_context(obj_name, rec)
  228. try:
  229. safe_eval(rule.code, eval_ctx, mode='exec', nocopy=True)
  230. except Exception as e:
  231. raise UserError(_(
  232. 'Error when evaluating the exception.rule: '
  233. '%s\n(%s)') % (rule.name, e))
  234. return eval_ctx.get('failed', False)
  235. @api.multi
  236. def _detect_exceptions(
  237. self, model_exceptions, sub_exceptions,
  238. optimize=False,
  239. ):
  240. """Find exceptions found on self.
  241. @returns
  242. exception_by_rec: {record_id: exception_ids}
  243. exception_by_rule: {rule_id: record_ids}
  244. """
  245. exception_by_rec = {}
  246. exception_by_rule = {}
  247. exception_set = set()
  248. python_rules = []
  249. dom_rules = []
  250. optim_rules = []
  251. for rule in model_exceptions:
  252. if rule.exception_type == 'by_py_code':
  253. python_rules.append(rule)
  254. elif rule.exception_type == 'by_domain' and rule.domain:
  255. if optimize:
  256. optim_rules.append(rule)
  257. else:
  258. dom_rules.append(rule)
  259. for rule in optim_rules:
  260. domain = rule._get_domain()
  261. domain.append(['ignore_exception', '=', False])
  262. domain.append(['id', 'in', self.ids])
  263. records_with_exception = self.search(domain)
  264. exception_by_rule[rule] = records_with_exception
  265. if records_with_exception:
  266. exception_set.add(rule.id)
  267. if len(python_rules) or len(dom_rules) or sub_exceptions:
  268. for rec in self:
  269. for rule in python_rules:
  270. if (
  271. not rec.ignore_exception and
  272. self._rule_eval(rule, rec.rule_group, rec)
  273. ):
  274. exception_by_rec.setdefault(rec, []).append(rule.id)
  275. exception_set.add(rule.id)
  276. for rule in dom_rules:
  277. # there is no reverse many2many, so this rule
  278. # can't be optimized, see _reverse_field
  279. domain = rule._get_domain()
  280. domain.append(['ignore_exception', '=', False])
  281. domain.append(['id', '=', rec.id])
  282. if self.search_count(domain):
  283. exception_by_rec.setdefault(
  284. rec, []).append(rule.id)
  285. exception_set.add(rule.id)
  286. if sub_exceptions:
  287. group_line = rec.rule_group + '_line'
  288. for obj_line in rec._get_lines():
  289. for rule in sub_exceptions:
  290. if rule.id in exception_set:
  291. # we do not matter if the exception as
  292. # already been
  293. # found for an line of this object
  294. # (ex sale order line if obj is sale order)
  295. continue
  296. if rule.exception_type == 'by_py_code':
  297. if self._rule_eval(
  298. rule, group_line, obj_line
  299. ):
  300. exception_by_rec.setdefault(
  301. rec, []).append(rule.id)
  302. elif (
  303. rule.exception_type == 'by_domain' and
  304. rule.domain
  305. ):
  306. # sub_exception are currently not optimizable
  307. domain = rule._get_domain()
  308. domain.append(('id', '=', obj_line.id))
  309. if obj_line.search_count(domain):
  310. exception_by_rec.setdefault(
  311. rec, []).append(rule.id)
  312. # set object to next state
  313. # find exception that raised error and has next_state
  314. next_state_exception_ids = model_exceptions.filtered(
  315. lambda r: r.id in exception_set and r.next_state)
  316. if next_state_exception_ids:
  317. self.state = next_state_exception_ids[0].next_state
  318. return exception_by_rec, exception_by_rule
  319. @implemented_by_base_exception
  320. def _get_lines(self):
  321. pass
  322. def _default_get_lines(self):
  323. return []