1332 lines
63 KiB
Python
1332 lines
63 KiB
Python
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
|
|
|
from ast import literal_eval
|
|
import logging
|
|
|
|
from odoo import api, models, fields, _
|
|
from odoo.exceptions import ValidationError, UserError
|
|
from odoo.fields import Command, Domain
|
|
from collections import defaultdict
|
|
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
|
|
class StudioApprovalRuleApprover(models.Model):
|
|
_name = 'studio.approval.rule.approver'
|
|
_description = "Approval Rule Approvers Enriched"
|
|
_order = "id desc"
|
|
_log_access = True # explicit: we need create_uid and create_date
|
|
|
|
user_id = fields.Many2one("res.users", required=True, ondelete="cascade")
|
|
rule_id = fields.Many2one("studio.approval.rule", required=True, index=True, ondelete="cascade")
|
|
date_to = fields.Date()
|
|
is_delegation = fields.Boolean(default=True)
|
|
|
|
@api.depends("create_uid", "user_id", "date_to")
|
|
def _compute_display_name(self):
|
|
for rec in self:
|
|
name = []
|
|
if rec.create_uid.name:
|
|
name.extend([rec.create_uid.name, _("delegates to %s.", rec.user_id.name)])
|
|
else:
|
|
name.append(_("to %s.", rec.user_id.name))
|
|
|
|
if rec.date_to:
|
|
val = rec._fields["date_to"].convert_to_display_name(rec.date_to, rec)
|
|
name.append(_("Until %s", val))
|
|
rec.display_name = " ".join(name)
|
|
|
|
def _is_valid(self):
|
|
return not self.date_to or self.date_to >= fields.Date.context_today(self)
|
|
|
|
def _filtered_valid(self):
|
|
return self.filtered(lambda rec: rec._is_valid())
|
|
|
|
|
|
class StudioApprovalRule(models.Model):
|
|
_name = 'studio.approval.rule'
|
|
_description = "Studio Approval Rule"
|
|
_inherit = ["studio.mixin", 'mail.thread']
|
|
|
|
@api.model
|
|
def _parse_action_from_button(self, str_action):
|
|
if not str_action:
|
|
return False
|
|
try:
|
|
return int(str_action)
|
|
except ValueError:
|
|
action = self.env.ref(str_action, raise_if_not_found=False)
|
|
return action and action.id
|
|
|
|
@api.model
|
|
def _cast_domain(self, domain):
|
|
if isinstance(domain, str):
|
|
domain = literal_eval(domain)
|
|
if domain is False or domain is None:
|
|
return Domain(True)
|
|
return Domain(domain)
|
|
|
|
def _group_expand_notification_order(self, options, domain):
|
|
return sorted(set(options).union({'1', '2', '3', '4'}))
|
|
|
|
active = fields.Boolean(default=True)
|
|
model_id = fields.Many2one("ir.model", string="Model", ondelete="cascade", required=True)
|
|
method = fields.Char(string="Method")
|
|
action_id = fields.Many2one("ir.actions.actions", string="Action", ondelete="cascade")
|
|
action_xmlid = fields.Char(related="action_id.xml_id", search="_search_action_xmlid")
|
|
name = fields.Char()
|
|
message = fields.Char(translate=True, string="Description", help="The step description will be displayed on the button on which an approval is requested.")
|
|
approver_ids = fields.Many2many(
|
|
comodel_name='res.users',
|
|
compute='_compute_approver_ids',
|
|
inverse="_inverse_approver_ids",
|
|
string='Approvers',
|
|
help="These users are able to approve or reject the step and will be assigned to an activity when their approval is requested.",
|
|
domain="[('id', 'not in', [1]), ('share', '=', False)]",
|
|
tracking=True
|
|
)
|
|
approver_log_ids = fields.One2many("studio.approval.rule.approver", inverse_name="rule_id", tracking=True)
|
|
approval_group_id = fields.Many2one(comodel_name='res.groups', string='Approval Group', help="The users in this group are able to approve or reject the step.", tracking=True)
|
|
users_to_notify = fields.Many2many(
|
|
comodel_name='res.users',
|
|
relation='approval_rule_users_to_notify_rel',
|
|
string='Users to Notify',
|
|
domain="[('id', 'not in', [1]), ('share', '=', False)]",
|
|
help="These users will receive a notification via internal note when the step is approved or rejected"
|
|
)
|
|
notification_order = fields.Selection(
|
|
selection=[
|
|
('1', 'Step 1'),
|
|
('2', 'Step 2'),
|
|
('3', 'Step 3'),
|
|
('4', 'Step 4'),
|
|
('5', 'Step 5'),
|
|
('6', 'Step 6'),
|
|
('7', 'Step 7'),
|
|
('8', 'Step 8'),
|
|
('9', 'Step 9')
|
|
],
|
|
string='Step',
|
|
default='1',
|
|
group_expand=_group_expand_notification_order,
|
|
help="Defines the sequential order in which the approvals are requested."
|
|
)
|
|
exclusive_user = fields.Boolean(string="Exclusive Approval",
|
|
help="If set, the user who approves this rule will not "
|
|
"be able to approve other rules for the same "
|
|
"record")
|
|
# store these for performance reasons, reading should be fast while writing can be slower
|
|
model_name = fields.Char(string="Model Name", related="model_id.model", store=True, index=True)
|
|
domain = fields.Char(help="If set, the rule will only apply on records that match the domain.")
|
|
conditional = fields.Boolean(compute="_compute_conditional", string="Conditional Rule")
|
|
can_validate = fields.Boolean(string="Can be approved",
|
|
help="Whether the rule can be approved by the current user",
|
|
compute="_compute_can_validate")
|
|
kanban_color = fields.Integer(compute="_compute_kanban_color")
|
|
entry_ids = fields.One2many('studio.approval.entry', 'rule_id', string='Entries')
|
|
entries_count = fields.Integer('Number of Entries', compute='_compute_entries_count')
|
|
|
|
_method_or_action_together = models.Constraint(
|
|
'CHECK(method IS NULL OR action_id IS NULL)',
|
|
"A rule must apply to an action or a method (but not both).",
|
|
)
|
|
_method_or_action_not_null = models.Constraint(
|
|
'CHECK(method IS NOT NULL OR action_id IS NOT NULL)',
|
|
"A rule must apply to an action or a method.",
|
|
)
|
|
|
|
@api.depends("notification_order")
|
|
def _compute_display_name(self):
|
|
string_notification_order = dict(self._fields['notification_order']._description_selection(self.env))
|
|
for rec in self:
|
|
rec.display_name = string_notification_order[rec.notification_order]
|
|
|
|
@api.depends("approver_log_ids")
|
|
def _compute_approver_ids(self):
|
|
for rec in self.sudo():
|
|
rec.approver_ids = rec.approver_log_ids._filtered_valid().user_id
|
|
|
|
def _inverse_approver_ids(self):
|
|
for rec in self:
|
|
track_message = _("%(user_name)s has set approval rights from %(previous_approvers)s to %(next_approvers)s",
|
|
user_name=self.env.user.name,
|
|
previous_approvers=", ".join(self.approver_log_ids.filtered(lambda log: not log.is_delegation).user_id.mapped("name")) or _("no one"),
|
|
next_approvers=", ".join(rec.approver_ids.mapped("name")) or _("no one")
|
|
)
|
|
rec._track_set_log_message(track_message)
|
|
commands = rec._get_approver_log_changes({"approver_ids": rec.approver_ids})
|
|
rec.approver_log_ids = commands
|
|
|
|
def _get_approver_log_changes(self, data):
|
|
approvers = data.get("approver_ids")
|
|
if approvers is not None and not data.get("approver_log_ids"):
|
|
approver_ids = self._fields["approver_ids"].convert_to_cache(approvers, self)
|
|
commands = [Command.clear()]
|
|
commands.extend([Command.create({"user_id": _id, "is_delegation": False}) for _id in approver_ids])
|
|
return commands
|
|
return None
|
|
|
|
def _delegate_to(self, user_ids, date_to):
|
|
self.ensure_one()
|
|
commands = []
|
|
revoked_users = []
|
|
for log in self.approver_log_ids:
|
|
if not log._is_valid():
|
|
commands.append(Command.delete(log.id))
|
|
elif log.create_uid.id == self.env.uid and log.is_delegation:
|
|
commands.append(Command.delete(log.id))
|
|
revoked_users.append(log.user_id.id)
|
|
|
|
for approver_id in user_ids:
|
|
commands.append(Command.create({
|
|
"user_id": approver_id.id,
|
|
"date_to": date_to,
|
|
"is_delegation": True
|
|
}))
|
|
|
|
if user_ids:
|
|
message = _("%(user_name)s delegated approval rights to %(delegate_to)s",
|
|
user_name=self.env.user.name,
|
|
delegate_to=", ".join(user_ids.mapped("name")))
|
|
if date_to:
|
|
approver_model = self.env["studio.approval.rule.approver"]
|
|
date_field = approver_model._fields["date_to"]
|
|
val = date_field.convert_to_display_name(date_to, approver_model)
|
|
message += _(" until %s", val)
|
|
self._track_set_log_message(message)
|
|
elif revoked_users:
|
|
revoked_users = self.env["res.users"].browse(revoked_users)
|
|
message = _("%(user_name)s revoked their delegation to %(revoked_users)s",
|
|
user_name=self.env.user.name,
|
|
revoked_users=", ".join(revoked_users.mapped("name")))
|
|
self._track_set_log_message(message)
|
|
self.write({"approver_log_ids": commands})
|
|
|
|
@api.constrains("model_id", "method")
|
|
def _check_model_method(self):
|
|
for rule in self:
|
|
if rule.model_id and rule.method:
|
|
if rule.model_id.model == self._name:
|
|
raise ValidationError(_("You just like to break things, don't you?"))
|
|
if rule.method.startswith("_") or '__' in rule.method:
|
|
raise ValidationError(_("Private methods cannot be restricted (since they "
|
|
"cannot be called remotely, this would be useless)."))
|
|
model = rule.model_id and self.env[rule.model_id.model]
|
|
if not hasattr(model, rule.method) or not callable(getattr(model, rule.method)):
|
|
raise ValidationError(
|
|
_("There is no method %(method)s on the model %(model_name)s (%(model_id)s)",
|
|
method=rule.method, model_name=rule.model_id.name, model_id=rule.model_id.model)
|
|
)
|
|
if rule.method in ["create", "write", "unlink"]:
|
|
# base_automation and studio_approval executes delattr command in their
|
|
# unregister_hook before re-patching in their register_hook.
|
|
# However base_automation will not re-patch approvals and vice versa.
|
|
|
|
raise ValidationError(_("For compatibility purpose with base_automation,"
|
|
"approvals on 'create', 'write' and 'unlink' methods "
|
|
"are forbidden."))
|
|
|
|
def _search_action_xmlid(self, operator, value):
|
|
if operator not in ('in', 'not in'):
|
|
return NotImplemented
|
|
action_ids = [self._parse_action_from_button(v) for v in value]
|
|
return [("action_id", operator, action_ids)]
|
|
|
|
@api.model
|
|
def default_get(self, fields):
|
|
vals = super().default_get(fields)
|
|
default_model_name = self.env.context.get("default_model_name")
|
|
if default_model_name and "model_id" in fields and not vals.get("model_id"):
|
|
vals["model_id"] = self.env["ir.model"]._get(default_model_name)
|
|
default_action_xmlid = self.env.context.get("default_action_xmlid")
|
|
if default_action_xmlid and "action_id" in fields and not vals.get("action_id"):
|
|
action_id = self._parse_action_from_button(default_action_xmlid)
|
|
if action_id:
|
|
vals["action_id"] = self._parse_action_from_button(default_action_xmlid)
|
|
return vals
|
|
|
|
def write(self, vals):
|
|
if "approver_log_ids" in vals:
|
|
vals.pop("approver_ids", None)
|
|
write_readonly_fields = bool(set(vals.keys()) & {'model_id', 'method', 'action_id'})
|
|
if write_readonly_fields and any(rule.entry_ids for rule in self):
|
|
raise UserError(_(
|
|
"Rules with existing entries cannot be modified since it would break existing "
|
|
"approval entries. You should archive the rule and create a new one instead."))
|
|
res = super().write(vals)
|
|
if "active" in vals:
|
|
self._post_create_delete(operation="create" if vals["active"] else "unlink")
|
|
self._update_registry()
|
|
return res
|
|
|
|
def _base_automation_data_for_model(self, model_id):
|
|
# Careful here, this method implements things specific to actual models
|
|
# So, be aware that if you put specific code for a model that is not present
|
|
# in the registry, chances are there will be a crash
|
|
model_name = model_id.model
|
|
if model_name == "sale.order":
|
|
state_field = self.env["ir.model.fields"]._get("sale.order", "state")
|
|
return {
|
|
'trigger': 'on_state_set',
|
|
'trg_selection_field_id': state_field.selection_ids.filtered(lambda s: s.value == "draft").id,
|
|
'filter_pre_domain': "[('state', '!=', 'draft')]",
|
|
}
|
|
if model_name == "account.move":
|
|
state_field = self.env["ir.model.fields"]._get("account.move", "state")
|
|
return {
|
|
'trigger': 'on_state_set',
|
|
'trg_selection_field_id': state_field.selection_ids.filtered(lambda s: s.value == "draft").id,
|
|
'filter_pre_domain': "[('state', '!=', 'draft')]",
|
|
}
|
|
if model_name == "purchase.order":
|
|
state_field = self.env["ir.model.fields"]._get("purchase.order", "state")
|
|
return {
|
|
'trigger': 'on_state_set',
|
|
'trg_selection_field_id': state_field.selection_ids.filtered(lambda s: s.value == "draft").id,
|
|
'filter_pre_domain': "[('state', '!=', 'draft')]",
|
|
}
|
|
return None
|
|
|
|
def _make_automated_actions(self):
|
|
def process_xml_id_params(model_name, module_prefix=None):
|
|
if module_prefix is None:
|
|
module_prefix = "web_studio"
|
|
return model_name.replace(".", "_"), module_prefix + "." if module_prefix else ""
|
|
|
|
def get_base_automation_xml_id(model_name, module_prefix=None):
|
|
model_name, module_prefix = process_xml_id_params(model_name, module_prefix)
|
|
return f"{module_prefix}remove_approval_entries__{model_name}__automation"
|
|
|
|
def get_base_action_server_xml_id(model_name, module_prefix=None):
|
|
model_name, module_prefix = process_xml_id_params(model_name, module_prefix)
|
|
return f"{module_prefix}remove_approval_entries__{model_name}__action_server"
|
|
|
|
self_sudo = self.sudo()
|
|
new_automations = []
|
|
for model_id in self_sudo.mapped("model_id"):
|
|
model = model_id.model
|
|
base_auto_data = self_sudo._base_automation_data_for_model(model_id)
|
|
if base_auto_data is None:
|
|
continue
|
|
|
|
specific_ref = get_base_automation_xml_id(model)
|
|
if self_sudo.env.ref(specific_ref, raise_if_not_found=False):
|
|
continue
|
|
automation = {
|
|
"name": f"Reset approval entries for ({model_id.name})",
|
|
"model_id": model_id.id,
|
|
**base_auto_data,
|
|
}
|
|
new_automations.append(automation)
|
|
|
|
automations = self_sudo.env["base.automation"].create(new_automations)
|
|
|
|
actions_server_data = []
|
|
xml_ids_data = []
|
|
for base_auto in automations:
|
|
model_id = base_auto.model_id
|
|
model = model_id.model
|
|
xml_ids_data.append({
|
|
"name": get_base_automation_xml_id(model, module_prefix=""),
|
|
"model": "base.automation",
|
|
"res_id": base_auto.id,
|
|
"module": "web_studio",
|
|
"noupdate": True,
|
|
})
|
|
server_action = self_sudo.env.ref(get_base_action_server_xml_id(model), raise_if_not_found=False)
|
|
if server_action:
|
|
server_action.write({
|
|
"base_automation_id": base_auto.id,
|
|
"model_id": model_id.id
|
|
})
|
|
else:
|
|
actions_server_data.append({
|
|
"name": f"Reset approval entries for ({model_id.name})",
|
|
"base_automation_id": base_auto.id,
|
|
'state': 'code',
|
|
'code': "records.env['studio.approval.entry']._delete_entries(records)",
|
|
"model_id": model_id.id,
|
|
})
|
|
|
|
for server_action in self_sudo.env["ir.actions.server"].create(actions_server_data):
|
|
model_data_name = get_base_action_server_xml_id(server_action.model_id.model, module_prefix="")
|
|
xml_ids_data.extend([{
|
|
"name": model_data_name,
|
|
"model": "ir.actions.server",
|
|
"res_id": server_action.id,
|
|
"module": "web_studio",
|
|
"noupdate": True,
|
|
}, {
|
|
"name": model_data_name,
|
|
"model": "ir.actions.server",
|
|
"res_id": server_action.id,
|
|
"module": "__cloc_exclude__",
|
|
}
|
|
])
|
|
|
|
self_sudo.env["ir.model.data"].create(xml_ids_data)
|
|
|
|
@api.model_create_multi
|
|
def create(self, vals_list):
|
|
for vals in vals_list:
|
|
if "approver_log_ids" in vals:
|
|
vals.pop("approver_ids", None)
|
|
rules = super().create(vals_list)
|
|
rules._make_automated_actions()
|
|
rules._post_create_delete(operation="create")
|
|
self._update_registry()
|
|
return rules
|
|
|
|
@api.ondelete(at_uninstall=True)
|
|
def _on_delete_model_side_effects(self):
|
|
self._post_create_delete(operation="unlink")
|
|
|
|
def _update_registry(self):
|
|
""" Update the registry after a modification on approval rules. """
|
|
if self.env.registry.ready:
|
|
# re-install the model patches, and notify other workers
|
|
self._unregister_hook()
|
|
self._register_hook()
|
|
self.env.registry.registry_invalidated = True
|
|
|
|
def _register_hook(self):
|
|
""" Patch methods that should verify the approval rules """
|
|
|
|
def _patch(model, method_name, function):
|
|
""" Patch method `name` on `model`, unless it has been patched already. """
|
|
if method_name.startswith("_") or '__' in method_name:
|
|
raise ValidationError(_("Can't patch private methods."))
|
|
if method_name in ["create", "write", "unlink"]:
|
|
raise ValidationError(_("Can't patch 'create', 'write' and 'unlink'."))
|
|
if model not in patched_models[method_name]:
|
|
patched_models[method_name].add(model)
|
|
ModelClass = model.env.registry[model._name]
|
|
method = getattr(ModelClass, method_name, None)
|
|
if method:
|
|
function.studio_approval_rule_origin = method
|
|
setattr(ModelClass, method_name, function)
|
|
|
|
def _make_approval_method(method_name, model_name):
|
|
""" Instanciate a method that verify the approval rule """
|
|
def method(self, *args, **kwargs):
|
|
if self.env.su:
|
|
# in a sudoed environment, approvals are skipped
|
|
# otherwise we risk breaking some important flows
|
|
# (e.g. ecommerce order confirmations, invoice posting because
|
|
# online payment succeeeded, etc.)
|
|
_logger.info("Skipping approval checks in a sudoed environment: method call %s ALLOWED on records %s", method_name, self)
|
|
return method.studio_approval_rule_origin(self, *args, **kwargs)
|
|
approved, rules, entries = [], [], []
|
|
approved_records = self.env[self._name]
|
|
for record in self:
|
|
result = self.env['studio.approval.rule'].check_approval(model_name, record.id, method_name, None)
|
|
approved.append(result['approved'])
|
|
rules.append(result['rules'])
|
|
entries.append(result['entries'])
|
|
if result['approved']:
|
|
approved_records |= record
|
|
|
|
if all(approved):
|
|
return method.studio_approval_rule_origin(self, *args, **kwargs)
|
|
else:
|
|
unapproved_records = self - approved_records
|
|
msg, log_args = "Approval checks failed: method call %s REJECTED on records %s", (method_name, unapproved_records)
|
|
if approved_records:
|
|
msg += " (some records were ALLOWED for the same call: %s)"
|
|
log_args = (*log_args, approved_records)
|
|
_logger.info(msg, *log_args)
|
|
if len(approved_records) > 0:
|
|
method.studio_approval_rule_origin(approved_records, *args, **kwargs)
|
|
message = ''
|
|
title = ''
|
|
if len(self) > 1:
|
|
title = _('Approvals missing')
|
|
message = _('Some records were skipped because approvals were missing to\
|
|
proceed with your request: ')
|
|
message += ', '.join(unapproved_records.mapped('display_name'))
|
|
else:
|
|
missing_approvals = self.env['studio.approval.rule'].get_missing_approvals(rules[0], entries[0])
|
|
message += _('Some approvals are missing') if len(missing_approvals) > 1 else _('An approval is missing')
|
|
|
|
return {
|
|
'type': 'ir.actions.client',
|
|
'tag': "display_notification",
|
|
'params' : {
|
|
'title': title,
|
|
'message': message,
|
|
'sticky': False,
|
|
'type': 'warning',
|
|
'next': {
|
|
'type': 'ir.actions.act_window_close',
|
|
}
|
|
}
|
|
}
|
|
|
|
return method
|
|
|
|
patched_models = defaultdict(set)
|
|
# retrieve all approvals, and patch their corresponding model
|
|
for approval in self.search([]):
|
|
Model = self.env.get(approval.model_name)
|
|
if approval.method and Model is not None:
|
|
approval_method = _make_approval_method(approval.method, approval.model_name)
|
|
_patch(Model, approval.method, approval_method)
|
|
|
|
def _unregister_hook(self):
|
|
""" Remove the patches installed by _register_hook() """
|
|
|
|
# prepare a dictionary with the model name as a key and methods list as value
|
|
model_methods_dict = {}
|
|
# Also take inactive rule into account in case of a write
|
|
for rule in self.with_context(active_test=False).search([('method', '!=', False)]):
|
|
if rule.model_name in model_methods_dict:
|
|
model_methods_dict[rule.model_name].append(rule.method)
|
|
else:
|
|
model_methods_dict[rule.model_name] = [rule.method]
|
|
|
|
# for each model, remove studio_approval patches
|
|
for Model in self.env.registry.values():
|
|
if Model._name in model_methods_dict:
|
|
for method_name in model_methods_dict[Model._name]:
|
|
method = getattr(Model, method_name, None)
|
|
if method and callable(method) and hasattr(method, 'studio_approval_rule_origin'):
|
|
delattr(Model, method_name)
|
|
|
|
def get_missing_approvals(self, rules, entries):
|
|
missing_approvals = []
|
|
done_approvals = [entry['rule_id'][0] for entry in \
|
|
filter(lambda entry: bool(entry['approved']), entries)]
|
|
for rule in rules:
|
|
if (rule['id'] not in done_approvals):
|
|
missing_approvals.append(rule)
|
|
|
|
return missing_approvals
|
|
|
|
@api.ondelete(at_uninstall=False)
|
|
def _unlink_except_existing_entries(self):
|
|
if any(rule.entry_ids for rule in self):
|
|
raise UserError(_(
|
|
"Rules with existing entries cannot be deleted since it would delete existing "
|
|
"approval entries. You should archive the rule instead."))
|
|
|
|
@api.depends('approval_group_id', 'approver_ids')
|
|
@api.depends_context("uid")
|
|
def _compute_can_validate(self):
|
|
user = self.env.user
|
|
for rule in self:
|
|
rule.can_validate = user in rule.approver_ids or (rule.approval_group_id and user.has_group(rule.approval_group_id.id))
|
|
|
|
@api.depends('can_validate')
|
|
def _compute_kanban_color(self):
|
|
for rule in self:
|
|
rule.kanban_color = 7 if rule.can_validate else 0
|
|
|
|
@api.depends("domain")
|
|
def _compute_conditional(self):
|
|
for rule in self:
|
|
rule.conditional = bool(rule.domain)
|
|
|
|
@api.depends('entry_ids')
|
|
def _compute_entries_count(self):
|
|
for rule in self:
|
|
rule.entries_count = len(rule.entry_ids)
|
|
|
|
@api.model
|
|
def create_rule(self, model, method, action_id, notification_order=None):
|
|
model = self.env['ir.model']._get(model)
|
|
values = {
|
|
'model_id': model.id,
|
|
'method': method,
|
|
'action_id': self._parse_action_from_button(action_id),
|
|
}
|
|
if notification_order is not None:
|
|
values["notification_order"] = notification_order
|
|
return self.create(values)
|
|
|
|
def set_approval(self, res_id, approved):
|
|
"""Set an approval entry for the current rule and specified record.
|
|
|
|
Check _set_approval for implementation details.
|
|
|
|
:param record self: a recordset of a *single* rule (ensure_one)
|
|
:param int res_id: ID of the record on which the approval will be set
|
|
(the model comes from the rule itself)
|
|
:param bool approved: whether the rule is approved or rejected
|
|
:return: True if the rule was approved, False if it was rejected
|
|
:rtype: boolean
|
|
:raise: odoo.exceptions.AccessError when the user does not have write
|
|
access to the underlying record
|
|
:raise: odoo.exceptions.UserError when any of the other checks failed
|
|
"""
|
|
self.ensure_one()
|
|
entry = self._set_approval(res_id, approved)
|
|
return entry and entry.approved
|
|
|
|
def delete_approval(self, res_id):
|
|
"""Delete an approval entry for the current rule and specified record.
|
|
|
|
:param record self: a recordset of a *single* rule (ensure_one)
|
|
:param int res_id: ID of the record on which the approval will be set
|
|
(the model comes from the rule itself)
|
|
:return: True
|
|
:rtype: boolean
|
|
:raise: odoo.exceptions.AccessError when the user does not have write
|
|
access to the underlying record
|
|
:raise: odoo.exceptions.UserError when any there is no existing entry
|
|
to cancel or when the user is trying to cancel an entry that
|
|
they didn't create themselves
|
|
"""
|
|
self.ensure_one()
|
|
record = self.env[self.sudo().model_name].browse(res_id)
|
|
record.check_access('write')
|
|
ruleSudo = self.sudo()
|
|
|
|
existing_entry = self.env['studio.approval.entry'].sudo().search([
|
|
('model', '=', ruleSudo.model_name),
|
|
('method', '=', ruleSudo.method), ('action_id', '=', ruleSudo.action_id.id),
|
|
('res_id', '=', res_id), ('rule_id', '=', self.id)])
|
|
if existing_entry and existing_entry.user_id != self.env.user:
|
|
rules_above = self.env["studio.approval.rule"].sudo().search_read([
|
|
('model_name', '=', ruleSudo.model_name),
|
|
('method', '=', ruleSudo.method), ('action_id', '=', ruleSudo.action_id.id),
|
|
('notification_order', ">", ruleSudo.notification_order)
|
|
], ["domain", "can_validate"], order="notification_order DESC")
|
|
|
|
can_revoke = False
|
|
for rule in rules_above:
|
|
domain = self._cast_domain(rule["domain"])
|
|
if not record.sudo().filtered_domain(domain):
|
|
continue
|
|
if rule["can_validate"]:
|
|
can_revoke = True
|
|
break
|
|
|
|
if not can_revoke:
|
|
# this should normally not happen because of ir.rules, but let's be careful
|
|
# when dealing with security
|
|
raise UserError(_("You cannot cancel an approval you didn't set yourself or you don't belong to an higher level rule's approvers."))
|
|
if not existing_entry:
|
|
raise UserError(_("No approval found for this rule, record and user combination."))
|
|
return existing_entry.unlink()
|
|
|
|
def _set_approval(self, res_id, approved):
|
|
"""Create an entry for an approval rule after checking if it is allowed.
|
|
|
|
To know if the entry can be created, checks are done in that order:
|
|
- user has write access on the underlying record
|
|
- user has the group required by the rule
|
|
- there is no existing entry for that rule and record
|
|
- if this rule has 'exclusive_user' enabled: no other
|
|
rule has been approved/rejected for the same record
|
|
- if this rule has 'exclusive_user' disabled: no
|
|
rule with 'exclusive_user' enabled/disabled has been
|
|
approved/rejected for the same record
|
|
|
|
If all these checks pass, create an entry for the current rule with
|
|
`approve` as its value.
|
|
|
|
:param record self: a recordset of a *single* rule (ensure_one)
|
|
:param int res_id: ID of the record on which the approval will be set
|
|
(the model comes from the rule itself)
|
|
:param bool approved: whether the rule is approved or rejected
|
|
:return: a new approval entry
|
|
:rtype: :class:`~odoo.addons.web_studio.models.StudioApprovalEntry`
|
|
:raise: odoo.exceptions.AccessError when the user does not have write
|
|
access to the underlying record
|
|
:raise: odoo.exceptions.UserError when any of the other checks failed
|
|
"""
|
|
self.ensure_one()
|
|
self = self._clean_context()
|
|
# acquire a lock on similar rules to prevent race conditions that could bypass
|
|
# the 'force different users' field; will be released at the end of the transaction
|
|
ruleSudo = self.sudo()
|
|
domain = self._get_rule_domain(ruleSudo.model_name, ruleSudo.method, ruleSudo.action_id)
|
|
all_rule_ids = tuple(ruleSudo.search(domain).ids)
|
|
if not all_rule_ids:
|
|
return None
|
|
self.env.cr.execute('SELECT id FROM studio_approval_rule WHERE id IN %s FOR UPDATE NOWAIT', (all_rule_ids,))
|
|
# NOTE: despite the 'NOWAIT' modifier, the query will actually be retried by
|
|
# Odoo itself (not PG); the NOWAIT ensures that no deadlock will happen
|
|
# check if the user has write access to the record
|
|
record = self.env[self.sudo().model_name].browse(res_id)
|
|
record.check_access('write')
|
|
# check if the user has the necessary group
|
|
if not ruleSudo.can_validate:
|
|
raise UserError(
|
|
_('You can not approve this rule.')
|
|
)
|
|
|
|
# check if there's an entry for this rule already
|
|
# done in sudo since entries by other users are not visible otherwise
|
|
existing_entry = ruleSudo.env['studio.approval.entry'].search([
|
|
('rule_id', '=', self.id), ('res_id', '=', res_id)
|
|
])
|
|
if existing_entry:
|
|
raise UserError(_('This rule has already been approved/rejected.'))
|
|
# if exclusive_user on: check if another rule for the same record
|
|
# has been approved/reject by the same user
|
|
rule_limitation_msg = _("This approval or the one you already submitted limits you "
|
|
"to a single approval on this action.\nAnother user is required "
|
|
"to further approve this action.")
|
|
if ruleSudo.exclusive_user:
|
|
existing_entry = ruleSudo.env['studio.approval.entry'].search([
|
|
('model', '=', ruleSudo.model_name), ('res_id', '=', res_id),
|
|
('method', '=', ruleSudo.method), ('action_id', '=', ruleSudo.action_id.id),
|
|
('user_id', '=', self.env.user.id),
|
|
('rule_id.active', '=', True), # archived rules should have no impact
|
|
])
|
|
if existing_entry:
|
|
raise UserError(rule_limitation_msg)
|
|
# if exclusive_user off: check if another rule with that flag on has already been
|
|
# approved/rejected by the same user
|
|
if not ruleSudo.exclusive_user:
|
|
existing_entry = ruleSudo.env['studio.approval.entry'].search([
|
|
('model', '=', ruleSudo.model_name), ('res_id', '=', res_id),
|
|
('method', '=', ruleSudo.method), ('action_id', '=', ruleSudo.action_id.id),
|
|
('user_id', '=', self.env.user.id), ('rule_id.exclusive_user', '=', True),
|
|
('rule_id.active', '=', True), # archived rules should have no impact
|
|
])
|
|
if existing_entry:
|
|
raise UserError(rule_limitation_msg)
|
|
# all checks passed: create the entry
|
|
result = ruleSudo.env['studio.approval.entry'].create({
|
|
'user_id': self.env.uid,
|
|
'rule_id': ruleSudo.id,
|
|
'res_id': res_id,
|
|
'approved': approved,
|
|
})
|
|
if not self.env.context.get('prevent_approval_request_unlink'):
|
|
ruleSudo._unlink_request(res_id)
|
|
|
|
if approved and ruleSudo.notification_order != '9':
|
|
same_level_rules = []
|
|
higher_level_rules = []
|
|
# approval rules for higher levels can be requested if no rules with the current level are set
|
|
for rule in ruleSudo.search_read([
|
|
('notification_order', '>=', ruleSudo.notification_order),
|
|
('active', '=', True),
|
|
("model_name", "=", ruleSudo.model_name),
|
|
('method', '=', ruleSudo.method),
|
|
('action_id', '=', ruleSudo.action_id.id)
|
|
], ["domain", "notification_order"]):
|
|
if rule["id"] == ruleSudo.id:
|
|
continue
|
|
rule_domain = self._cast_domain(rule["domain"])
|
|
if not record.sudo().filtered_domain(rule_domain):
|
|
continue
|
|
if rule["notification_order"] == ruleSudo.notification_order:
|
|
same_level_rules.append(rule["id"])
|
|
else:
|
|
higher_level_rules.append(rule["id"])
|
|
|
|
should_notify_higher = not same_level_rules
|
|
if same_level_rules:
|
|
approved_entries = ruleSudo.env["studio.approval.entry"].search_read([
|
|
("rule_id", "in", same_level_rules), ("res_id", "=", record.id), ("approved", "=", True)
|
|
], ["rule_id"])
|
|
if approved_entries:
|
|
entry_rule_ids = {entry["rule_id"][0] for entry in approved_entries}
|
|
should_notify_higher = all(same_level_id in entry_rule_ids for same_level_id in same_level_rules)
|
|
else:
|
|
should_notify_higher = False
|
|
if should_notify_higher:
|
|
for rule in ruleSudo.browse(higher_level_rules):
|
|
rule._create_request(res_id)
|
|
return result
|
|
|
|
def _get_rule_domain(self, model, method, action_id):
|
|
# just in case someone didn't cast it properly client side, would be
|
|
# a shame to be able to skip this 'security' because of a missing parseInt 😜
|
|
action_id = self._parse_action_from_button(action_id)
|
|
domain = Domain('model_name', '=', model)
|
|
if method:
|
|
domain &= Domain('method', '=', method)
|
|
if action_id:
|
|
domain &= Domain('action_id', '=', action_id)
|
|
return domain
|
|
|
|
def _clean_context(self):
|
|
"""Remove `active_test` from the context, if present."""
|
|
# we *never* want archived rules to be applied, ensure a clean context
|
|
if 'active_test' in self.env.context:
|
|
new_ctx = self.env.context.copy()
|
|
new_ctx.pop('active_test')
|
|
self = self.with_context(new_ctx)
|
|
return self
|
|
|
|
@api.model
|
|
def _get_approval_spec(self, model, spec):
|
|
"""
|
|
Gets the approval specification for a given model according
|
|
to specifications. Those are a directory of what approvals and
|
|
entries we should fetch, based on method, action_id and res_id
|
|
|
|
Arguments:
|
|
:param model: string
|
|
the name of the model of which we want the approvals
|
|
:param spec: dict
|
|
An object containing the specifications of the approvals we want.
|
|
{
|
|
(method, action_id): set<int>,
|
|
...
|
|
}
|
|
Note that method and action_id are mutually exclusive. The tuple allows to both be used as
|
|
a dict key and to know where to find the relevant information.
|
|
|
|
Returns:
|
|
:returns tuple: (model, map_rules, results)
|
|
model: the model's name
|
|
map_rules: dict: {
|
|
int: { **record.read() },
|
|
...
|
|
}
|
|
results: dict: {
|
|
(res_id, method, action_id): { "rules": int[], "entries": dict[] },
|
|
...
|
|
}
|
|
"""
|
|
records = self.env[model]
|
|
records.check_access('read')
|
|
|
|
def m2o_to_id(t_uple):
|
|
return t_uple and t_uple[0]
|
|
|
|
# Harvest all res_ids, method names, and action_ids
|
|
# to be able to pass one batching search query
|
|
all_res_ids = set()
|
|
all_methods = set()
|
|
all_action_ids = set()
|
|
for (method, action_id), ids in spec.items():
|
|
all_res_ids |= ids
|
|
if method:
|
|
all_methods.add(method)
|
|
if action_id:
|
|
all_action_ids.add(action_id)
|
|
res_ids = [_id for _id in all_res_ids if _id]
|
|
if res_ids:
|
|
records = records.browse(res_ids).exists()
|
|
# we check that the user has read access on the underlying record before returning anything
|
|
records.check_access('read')
|
|
|
|
# Search every rule matching all methods and actions: we'll map those results afterwards
|
|
rules_domain = Domain('model_name', '=', model) & (
|
|
Domain("method", "in", list(all_methods))
|
|
| Domain("action_id", "in", list(all_action_ids))
|
|
)
|
|
rules_data = self.sudo().search_read(
|
|
domain=rules_domain,
|
|
fields=['name', 'message', 'exclusive_user', 'can_validate', 'action_id', 'method', "approver_ids", "users_to_notify", "approval_group_id", "notification_order", "domain"],
|
|
order='notification_order asc, exclusive_user desc, id asc')
|
|
|
|
# Process rules by remapping to the specs
|
|
# Harvest data to be able to search for entries afterwards
|
|
# Start building the result: each res_id in the spec matches
|
|
# a key (res_id, method, action_id)
|
|
results = defaultdict(dict)
|
|
res_ids_for_entries = set()
|
|
rule_ids_for_entries = set()
|
|
map_rules = {}
|
|
for rule in rules_data:
|
|
map_rules[rule["id"]] = rule
|
|
|
|
res_ids_for_rule = spec[(rule["method"], m2o_to_id(rule["action_id"]))]
|
|
records_for_rule = records.browse([_id for _id in res_ids_for_rule if _id]).with_prefetch(records.ids or None)
|
|
# in JS, an empty array will be truthy and I don't want to start using JSON parsing
|
|
# instead, empty domains are replace by False here
|
|
# done for stupid UI reasons that would take much more code to be fixed client-side
|
|
rule_domain = rule.get('domain') and literal_eval(rule['domain'])
|
|
rule['domain'] = rule_domain or False
|
|
|
|
record_ids_for_result = list()
|
|
if records_for_rule:
|
|
record_ids_for_result = records_for_rule.sudo().filtered_domain(self._cast_domain(rule_domain)).ids
|
|
|
|
# Push rule here to search for entries
|
|
# we won't fetch entries for the False res_id
|
|
if record_ids_for_result:
|
|
rule_ids_for_entries.add(rule["id"])
|
|
|
|
if False in res_ids_for_rule:
|
|
record_ids_for_result.append(False)
|
|
|
|
for res_id in record_ids_for_result:
|
|
if res_id: # Don't push "False": we don't want to pass a useless search query
|
|
res_ids_for_entries.add(res_id)
|
|
res_key = (res_id, rule["method"], m2o_to_id(rule["action_id"]))
|
|
results[res_key] = results.get(res_key, {"rules": [], "entries": []})
|
|
results[res_key]["rules"].append(rule["id"])
|
|
|
|
if rule_ids_for_entries:
|
|
# Search for entries according to res_ids and rule_ids: we'll re-group them after
|
|
# done in sudo as users can only see their own entries through ir.rules
|
|
entries_data = self.env['studio.approval.entry'].sudo().search_read(
|
|
domain=[('model', '=', model), ('res_id', 'in', list(res_ids_for_entries)), ('rule_id', 'in', list(rule_ids_for_entries))],
|
|
fields=['approved', 'user_id', 'write_date', 'rule_id', 'model', 'res_id'])
|
|
|
|
# Process entries
|
|
# fillup each returned approval_spec according to the key
|
|
# (res_id, method, action_id) with the matching entries.
|
|
for entry in entries_data:
|
|
rule = map_rules[entry["rule_id"][0]]
|
|
key = (entry["res_id"], rule["method"], m2o_to_id(rule["action_id"]))
|
|
results[key]["entries"].append(entry)
|
|
|
|
return model, map_rules, results
|
|
|
|
@api.model
|
|
def get_approval_spec(self, args_list):
|
|
"""Get the approval spec for a list of buttons and records.
|
|
|
|
An approval spec is a dict containing information regarding approval rules
|
|
and approval entries for the action described with the model/method/action_id
|
|
arguments (method and action_id cannot be truthy at the same time).
|
|
|
|
The `rules` entry of the returned dict contains a description of the approval rules
|
|
for the current record: the group required for its approval, the message describing
|
|
the reason for the rule to exist, whether it can be approved if other rules for the
|
|
same record have been approved by the same user, a domain (if the rule is conditional)
|
|
and a computed 'can_validate' field which specifies whether the current user is in the
|
|
required group to approve the rule. This entry contains a read_group result on the
|
|
rule model for the fields 'group_id?', 'message', 'exclusive_user', 'domain' and
|
|
'can_validate'.
|
|
|
|
The `entries` entry of the returned dict contains a description of the existing approval
|
|
entries for the current record. It is the result of a read_group on the approval entry model
|
|
for the rules found for the current record for the fields 'approved', 'user_id', 'write_date',
|
|
'rule_id', 'model' and 'res_id'.
|
|
|
|
If res_id is provided, domain on rules are checked against the specified record and are only
|
|
included in the result if the record matches the domain. If no res_id is provided, domains
|
|
are not checked and the full set of rules is returned; this is useful when editing the rules
|
|
through Studio as you always want a full description of the rules regardless of the record
|
|
visible in the view while you edit them.
|
|
|
|
:param list args_list: A list of dictionaries containing the following keys:
|
|
|
|
- model (str): Technical name of the model for the requested spec.
|
|
- method (str): Method for the spec.
|
|
- action_id (int): Database ID of the ir.actions.action record for the spec.
|
|
- res_id (int): Database ID of the record for which the spec must be checked.
|
|
Defaults to False.
|
|
|
|
:return: a dict containing all rules that are used by by records.
|
|
records are contained under their model's key, under the form of a list of tuples
|
|
So doing dict(get_approval_spec[model_name]) gives all the records keyed by (res_id, method, action_id)::
|
|
|
|
{
|
|
"all_rules": { [id]: rule },
|
|
[model_name]: [((res_id, method, action_id), { rules: [], entries: [] }), ... ]
|
|
}
|
|
|
|
:rtype: dict
|
|
:raise: UserError if action_id and method are both truthy (rules can only apply to a method
|
|
or an action, not both)
|
|
|
|
:raise: AccessError if the user does not have read access to the underlying model (and record
|
|
if res_id is specified)
|
|
"""
|
|
self = self._clean_context()
|
|
# First, group all arguments to get a dictionary of the form
|
|
# {
|
|
# [model_name]: {
|
|
# (method, action_id): set<int>, ...
|
|
# },
|
|
# ...
|
|
# }
|
|
grouped_model = dict()
|
|
actions_map = dict()
|
|
for args in args_list:
|
|
method = args.get("method") or False
|
|
action_id_origin = args.get("action_id") or False
|
|
action_id = self._parse_action_from_button(action_id_origin)
|
|
actions_map[action_id] = action_id_origin
|
|
model = args["model"]
|
|
model_group = grouped_model[model] = grouped_model.get(model, defaultdict(set))
|
|
if method and action_id:
|
|
raise UserError(_('Approvals can only be done on a method or an action, not both.'))
|
|
res_id = args.get("res_id") or False
|
|
if res_id:
|
|
res_id = int(res_id)
|
|
model_group[(method, action_id)].add(res_id)
|
|
|
|
# Actually get approval specs for each model
|
|
# Then, return a dict containing all_rules used by approval specs
|
|
# and the specs themselves.
|
|
# we return a list of tuples for each model to be compatible with
|
|
# a JSON serialization.
|
|
result = {"all_rules": {}}
|
|
|
|
for model, spec in grouped_model.items():
|
|
model, rules, results = self._get_approval_spec(model, spec)
|
|
result["all_rules"].update(rules)
|
|
tuple_list = []
|
|
for (res_id, method, action_id), val in results.items():
|
|
tuple_list.append(((res_id, method, actions_map.get(action_id, action_id)), val))
|
|
result[model] = tuple_list
|
|
|
|
return result
|
|
|
|
@api.model
|
|
def check_approval(self, model, res_id, method, action_id):
|
|
"""Check if the current user can proceed with an action.
|
|
|
|
Check existing rules for the requested action and provided record; during this
|
|
check, any rule which the user can approve will be approved automatically.
|
|
|
|
Returns a dict indicating whether the action can proceed (`approved` key)
|
|
(when *all* applicable rules have an entry that mark approval), as well as the
|
|
rules and entries that are part of the approval flow for the specified action.
|
|
|
|
:param str model: technical name of the model on which the action takes place
|
|
:param int res_id: database ID of the record for which the action must be approved
|
|
:param str method: method of the action that the user wants to run
|
|
:param int action_id: database ID of the ir.actions.action that the user wants to run
|
|
:return: a dict describing the result of the approval flow
|
|
:rtype: dict
|
|
:raise: UserError if action_id and method are both truthy (rules can only apply to a method
|
|
or an action, not both)
|
|
:raise: AccessError if the user does not have write access to the underlying record
|
|
"""
|
|
self = self._clean_context()
|
|
if method and action_id:
|
|
raise UserError(_('Approvals can only be done on a method or an action, not both.'))
|
|
record = self.env[model].browse(res_id)
|
|
# we check that the user has write access on the underlying record before doing anything
|
|
# if another type of access is necessary to perform the action, it will be checked
|
|
# there anyway
|
|
record.check_access('write')
|
|
ruleSudo = self.sudo()
|
|
domain = self._get_rule_domain(model, method, action_id)
|
|
# order by 'exclusive_user' so that restrictive rules are approved first
|
|
rules_data = ruleSudo.search_read(
|
|
domain=domain,
|
|
fields=['message', 'name', 'domain'],
|
|
order='notification_order asc, exclusive_user desc, id asc'
|
|
)
|
|
applicable_rule_ids = list()
|
|
for rule in rules_data:
|
|
rule_domain = self._cast_domain(rule.get('domain'))
|
|
if record.sudo().filtered_domain(rule_domain):
|
|
# the record matches the domain of the rule
|
|
# or the rule has no domain set on it
|
|
applicable_rule_ids.append(rule['id'])
|
|
rules_data = list(filter(lambda r: r['id'] in applicable_rule_ids, rules_data))
|
|
if not rules_data:
|
|
# no rule matching our operation: return early, the user can proceed
|
|
return {'approved': True, 'rules': [], 'entries': []}
|
|
# need sudo, we need to check entries from other people and through record rules
|
|
# users can only see their own entries by default
|
|
entries_data = self.env['studio.approval.entry'].sudo().search_read(
|
|
domain=[('model', '=', model), ('res_id', '=', res_id), ('rule_id', 'in', applicable_rule_ids)],
|
|
fields=['approved', 'rule_id', 'user_id'])
|
|
entries_by_rule = dict.fromkeys(applicable_rule_ids, False)
|
|
for rule_id in entries_by_rule:
|
|
candidate_entry = list(filter(lambda e: e['rule_id'][0] == rule_id, entries_data))
|
|
candidate_entry = candidate_entry and candidate_entry[0]
|
|
if not candidate_entry:
|
|
# there is a rule that has no entry yet, try to approve it
|
|
try:
|
|
new_entry = self.browse(rule_id)._set_approval(res_id, True)
|
|
entries_data.append({
|
|
'id': new_entry.id,
|
|
'approved': True,
|
|
'rule_id': [rule_id, False],
|
|
'user_id': (self.env.user.id, self.env.user.display_name),
|
|
})
|
|
entries_by_rule[rule_id] = True
|
|
except UserError:
|
|
# either the user doesn't have the required group, or they already
|
|
# validated another rule for a 'exclusive_user' approval
|
|
# if the rule has a responsible, create a request for them
|
|
self.browse(rule_id)._create_request(res_id)
|
|
pass
|
|
else:
|
|
entries_by_rule[rule_id] = candidate_entry['approved']
|
|
return {
|
|
'approved': all(entries_by_rule.values()),
|
|
'rules': rules_data,
|
|
'entries': entries_data,
|
|
}
|
|
|
|
def _create_request(self, res_id):
|
|
self.ensure_one()
|
|
ruleSudo = self.sudo()
|
|
if not self.model_id.sudo().is_mail_activity:
|
|
return False
|
|
|
|
users = ruleSudo.approver_ids
|
|
if not users:
|
|
return False
|
|
|
|
requests = self.env['studio.approval.request'].sudo().search([('rule_id', '=', self.id), ('res_id', '=', res_id)])
|
|
if requests:
|
|
# already requested, let's not create a shitload of activities for the same users
|
|
return False
|
|
if self.notification_order != '1':
|
|
# search and read entries as sudo. Otherwise we won't see entries create/approved by other users
|
|
entry_sudo = self.env["studio.approval.entry"].sudo()
|
|
record = self.env[ruleSudo.model_name].browse(res_id)
|
|
# avoid asking for an approval if all request from a lower level have not yet been validated
|
|
for approval_rule in ruleSudo.search([
|
|
('notification_order', '<', self.notification_order),
|
|
('active', '=', True),
|
|
("model_name", "=", ruleSudo.model_name),
|
|
('method', '=', ruleSudo.method),
|
|
('action_id', '=', ruleSudo.action_id.id)
|
|
]):
|
|
rule_domain = self._cast_domain(approval_rule.domain)
|
|
if not record.sudo().filtered_domain(rule_domain):
|
|
continue
|
|
existing_entry = entry_sudo.search([
|
|
('model', '=', ruleSudo.model_name),
|
|
('method', '=', ruleSudo.method),
|
|
('action_id', '=', ruleSudo.action_id.id),
|
|
('res_id', '=', res_id),
|
|
('rule_id', '=', approval_rule.id)
|
|
])
|
|
if not existing_entry or not existing_entry.approved:
|
|
# if rules from lower levels are not yet approved, don't create a request
|
|
return False
|
|
|
|
record = self.env[self.model_name].browse(res_id)
|
|
activity_type_id = self.env.ref("mail.mail_activity_data_todo").id
|
|
activity_ids = []
|
|
for user in users:
|
|
# Free floating context for translation
|
|
# ruff: noqa: F841
|
|
context = {"lang": user.lang}
|
|
summary = _("Grant Approval")
|
|
activity = record.activity_schedule(activity_type_id=activity_type_id, user_id=user.id, summary=summary)
|
|
activity_ids.append(activity.id)
|
|
|
|
self.env['studio.approval.request'].sudo().create([
|
|
{
|
|
'rule_id': self.id,
|
|
'mail_activity_id': activity_id,
|
|
'res_id': res_id,
|
|
}
|
|
for activity_id in activity_ids
|
|
])
|
|
|
|
return True
|
|
|
|
def _unlink_request(self, res_id):
|
|
self.ensure_one()
|
|
sar = self.env['studio.approval.request'].search([('rule_id', '=', self.id), ('res_id', '=', res_id)])
|
|
sar.unlink()
|
|
return True
|
|
|
|
def open_delegate_action(self):
|
|
self.ensure_one()
|
|
return {
|
|
"type": "ir.actions.act_window",
|
|
"name": _("Delegate to"),
|
|
"target": "new",
|
|
"res_model": "studio.approval.rule.delegate",
|
|
"views": [[False, "form"]],
|
|
"context": {
|
|
"active_test": True,
|
|
"default_approval_rule_id": self.id,
|
|
}
|
|
}
|
|
|
|
def open_kanban_rules(self, model, method=False, action=False):
|
|
model_id = self.env["ir.model"]._get(model)
|
|
context = {
|
|
"default_model_id": model_id.id,
|
|
"search_view_ref": "web_studio.studio_approval_rule_button_configuration_search_view",
|
|
"search_default_notification_order": True,
|
|
}
|
|
domain = [("model_id", "=", model_id.id)]
|
|
if method:
|
|
context["default_method"] = method
|
|
domain.append(("method", "=", method))
|
|
else:
|
|
action_id = self._parse_action_from_button(action)
|
|
context["default_action_id"] = action_id
|
|
domain.append(("action_id", "=", action_id))
|
|
|
|
return {
|
|
"name": _("Approvals %(model_name)s", model_name=model_id.name),
|
|
"res_model": "studio.approval.rule",
|
|
"type": "ir.actions.act_window",
|
|
"views": [
|
|
[False, "kanban"],
|
|
[False, "list"],
|
|
[False, "form"],
|
|
],
|
|
"domain": domain,
|
|
"context": context,
|
|
}
|
|
|
|
def _get_remaining_rules_domain(self, model_name, method, action_id):
|
|
return Domain.AND([
|
|
Domain("model_name", "=", model_name),
|
|
Domain("method", "=", method),
|
|
Domain("action_id", "=", action_id),
|
|
Domain("active", "=", True),
|
|
Domain("id", "not in", self.ids)
|
|
])
|
|
|
|
def _post_create_delete(self, operation=None):
|
|
for model_name, method, action_id in {(r.model_name, r.method, r.action_id) for r in self}:
|
|
if model_name == "account.move" and method == "action_post":
|
|
if action := self.env.ref("account.action_validate_account_moves", raise_if_not_found=False):
|
|
if operation == "create":
|
|
action.binding_model_id = False
|
|
if operation == "unlink" and not self.search_count(self._get_remaining_rules_domain(model_name, method, action_id)):
|
|
action.binding_model_id = self.env["ir.model"]._get("account.move").id
|
|
|
|
# Tracking Values
|
|
def _track_filter_for_display(self, tracking_values):
|
|
approver_log_field = self.env["ir.model.fields"]._get(self._name, "approver_log_ids")
|
|
approver_ids_field = self.env["ir.model.fields"]._get(self._name, "approver_ids")
|
|
return tracking_values.filtered(lambda tv: tv.field_id not in [approver_log_field, approver_ids_field])
|
|
|
|
def _mail_track(self, tracked_fields, initial_values):
|
|
# The one2many may have some deleted records
|
|
# pop the whole thing to always have the custom message
|
|
# and avoid a MissingError
|
|
initial_values.pop("approver_log_ids", None)
|
|
return super()._mail_track(tracked_fields, initial_values)
|
|
|
|
|
|
class StudioApprovalEntry(models.Model):
|
|
_name = 'studio.approval.entry'
|
|
_description = 'Studio Approval Entry'
|
|
# entries don't have the studio mixin since they depend on the data of the
|
|
# db - they cannot be included into the Studio Customizations module
|
|
|
|
@api.model
|
|
def _default_user_id(self):
|
|
return self.env.user
|
|
|
|
name = fields.Char(compute='_compute_name', store=True)
|
|
user_id = fields.Many2one('res.users', string='Approved/rejected by', ondelete='restrict',
|
|
required=True, default=lambda s: s._default_user_id(), index=True)
|
|
# cascade deletion from the rule should only happen when the model itself is deleted
|
|
rule_id = fields.Many2one('studio.approval.rule', string='Approval Rule', ondelete='cascade',
|
|
required=True, index=True)
|
|
# store these for performance reasons, reading should be fast while writing can be slower
|
|
model = fields.Char(string='Model Name', related="rule_id.model_name", store=True)
|
|
method = fields.Char(string='Method', related="rule_id.method", store=True)
|
|
action_id = fields.Many2one('ir.actions.actions', related="rule_id.action_id", store=True)
|
|
res_id = fields.Many2oneReference(string='Record ID', model_field='model', required=True)
|
|
reference = fields.Char(string='Reference', compute='_compute_reference')
|
|
approved = fields.Boolean(string='Approved')
|
|
|
|
_uniq_combination = models.Constraint(
|
|
'unique(rule_id,model,res_id)',
|
|
"A rule can only be approved/rejected once per record.",
|
|
)
|
|
_model_res_id_idx = models.Index("(model, res_id)")
|
|
|
|
@api.depends('user_id', 'model', 'res_id')
|
|
def _compute_name(self):
|
|
for entry in self:
|
|
if not entry.id:
|
|
entry.name = _('New Approval Entry')
|
|
entry.name = '%s - %s(%s)' % (entry.user_id.name, entry.model, entry.res_id)
|
|
|
|
@api.depends('model', 'res_id')
|
|
def _compute_reference(self):
|
|
for entry in self:
|
|
entry.reference = "%s,%s" % (entry.model, entry.res_id)
|
|
|
|
@api.model_create_multi
|
|
def create(self, vals_list):
|
|
entries = super().create(vals_list)
|
|
entries._notify_approval()
|
|
return entries
|
|
|
|
def _delete_entries(self, records):
|
|
model_name = records._name
|
|
existing_entries = self.sudo().env['studio.approval.entry'].search([
|
|
('model', '=', model_name),
|
|
('res_id', 'in', records.ids)
|
|
])
|
|
existing_entries.unlink()
|
|
|
|
def write(self, vals):
|
|
res = super().write(vals)
|
|
self._notify_approval()
|
|
return res
|
|
|
|
def _notify_approval(self):
|
|
"""Post a generic note on the record if it inherits mail.thread."""
|
|
for entry in self:
|
|
if not entry.rule_id.model_id.is_mail_thread:
|
|
continue
|
|
record = self.env[entry.model].browse(entry.res_id)
|
|
partner_ids = entry.rule_id.users_to_notify.partner_id
|
|
rule = entry.rule_id
|
|
target_name = ""
|
|
if rule.name:
|
|
target_name = rule.name
|
|
elif rule.method:
|
|
target_name = _("Method: %s", rule.method)
|
|
elif rule.action_id.name:
|
|
target_name = _("Action: %s", rule.action_id.name)
|
|
|
|
record.message_post_with_source(
|
|
'web_studio.notify_approval',
|
|
author_id=entry.user_id.partner_id.id,
|
|
partner_ids=partner_ids.ids,
|
|
render_values={
|
|
'partner_ids': partner_ids,
|
|
'target_name': target_name,
|
|
'user_name': entry.user_id.display_name,
|
|
'approved': entry.approved,
|
|
},
|
|
subtype_xmlid='mail.mt_note',
|
|
)
|
|
|
|
|
|
class StudioApprovalRequest(models.Model):
|
|
_name = 'studio.approval.request'
|
|
_description = 'Studio Approval Request'
|
|
|
|
mail_activity_id = fields.Many2one('mail.activity', string='Linked Activity', ondelete='cascade',
|
|
required=True, index=True, inverse="_inverse_mail_activity_id")
|
|
rule_id = fields.Many2one('studio.approval.rule', string='Approval Rule', ondelete='cascade',
|
|
required=True, index=True)
|
|
res_id = fields.Many2oneReference(string='Record ID', model_field='model', required=True)
|
|
|
|
def _inverse_mail_activity_id(self):
|
|
for rec in self:
|
|
rec.mail_activity_id.studio_approval_request_id = rec.id
|
|
|
|
|
|
class StudioApprovalRuleDelegate(models.TransientModel):
|
|
_name = 'studio.approval.rule.delegate'
|
|
_description = "Approval Rule Delegate"
|
|
|
|
approval_rule_id = fields.Many2one("studio.approval.rule", required=True)
|
|
approver_ids = fields.Many2many("res.users", string="Approvers",
|
|
domain="['&', ('id', 'not in', [uid, 1]), ('share', '=', False)]")
|
|
users_to_notify = fields.Many2many("res.users",
|
|
relation="res_user_approval_rule_notify_rel",
|
|
domain="[('id', 'not in', [1]), ('share', '=', False)]",
|
|
string="Notify to")
|
|
date_to = fields.Date(string="Until")
|
|
|
|
@api.model_create_multi
|
|
def create(self, vals_list):
|
|
records = super().create(vals_list)
|
|
for rec in records:
|
|
rule = rec.approval_rule_id.sudo()
|
|
rule._delegate_to(rec.approver_ids, rec.date_to)
|
|
rule.write({"users_to_notify": rec.users_to_notify})
|
|
return records
|
|
|
|
@api.model
|
|
def default_get(self, fields):
|
|
vals = super().default_get(fields)
|
|
default_rule_id = self.env.context.get("default_approval_rule_id")
|
|
if default_rule_id and "approver_ids" in fields and "users_to_notify" in fields:
|
|
rule = self.env["studio.approval.rule"].browse(default_rule_id).sudo()
|
|
vals["approver_ids"] = [Command.link(log.user_id.id) for log in rule.approver_log_ids if log.is_delegation and log.create_uid.id == self.env.uid]
|
|
vals["users_to_notify"] = [Command.link(uid) for uid in rule.users_to_notify.ids]
|
|
return vals
|