Initial commit: OCA Technical packages (595 packages)

This commit is contained in:
Ernad Husremovic 2025-08-29 15:43:03 +02:00
commit 2cc02aac6e
24950 changed files with 2318079 additions and 0 deletions

View file

@ -0,0 +1,6 @@
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from . import base
from . import record_changeset
from . import record_changeset_change
from . import changeset_field_rule

View file

@ -0,0 +1,214 @@
# Copyright 2020 Onestein (<https://www.onestein.eu>)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
import json
from lxml import etree
from odoo import _, api, fields, models
from odoo.tools import config, ormcache
# put this object into context key '__no_changeset' to disable changeset
# functionality
disable_changeset = object()
class Base(models.AbstractModel):
_inherit = "base"
changeset_ids = fields.One2many(
comodel_name="record.changeset",
compute="_compute_changeset_ids",
string="Changesets",
)
changeset_change_ids = fields.One2many(
comodel_name="record.changeset.change",
compute="_compute_changeset_ids",
string="Changeset Changes",
)
count_changesets = fields.Integer(
compute="_compute_count_pending_changesets",
help="The overall number of changesets of this record",
)
count_pending_changesets = fields.Integer(
compute="_compute_count_pending_changesets",
help="The number of pending changesets of this record",
)
count_pending_changeset_changes = fields.Integer(
compute="_compute_count_pending_changesets",
help="The number of pending changes of this record",
)
user_can_see_changeset = fields.Boolean(compute="_compute_user_can_see_changeset")
def _compute_changeset_ids(self):
model_name = self._name
for record in self:
changesets = self.env["record.changeset"].search(
[("model", "=", model_name), ("res_id", "=", record.id)]
)
record.changeset_ids = changesets
record.changeset_change_ids = changesets.mapped("change_ids")
def _compute_count_pending_changesets(self):
model_name = self._name
if model_name in self.models_to_track_changeset():
for rec in self:
rec.count_changesets = len(rec.changeset_ids)
changesets = rec.changeset_ids.filtered(
lambda rev: rev.state == "draft"
and rev.res_id == rec.id
and rev.model == model_name
)
changes = changesets.mapped("change_ids")
changes = changes.filtered(
lambda c: c.state in c.get_pending_changes_states()
)
rec.count_pending_changesets = len(changesets)
rec.count_pending_changeset_changes = len(changes)
else:
for rec in self:
rec.count_changesets = 0
rec.count_pending_changesets = 0
rec.count_pending_changeset_changes = 0
@api.model
@ormcache(skiparg=1)
def models_to_track_changeset(self):
"""Models to be tracked for changes
:args:
:returns: list of models
"""
models = (
self.env["changeset.field.rule"].sudo().search([]).mapped("model_id.model")
)
if config["test_enable"] and self.env.context.get("test_record_changeset"):
if "res.partner" not in models:
models += ["res.partner"] # Used in tests
return models
@api.model_create_multi
def create(self, vals_list):
result = super().create(vals_list)
if self._changeset_disabled():
return result
for this, vals in zip(result, vals_list):
local_vals = self.env["record.changeset"].add_changeset(
this, vals, create=True
)
local_vals = {
key: value for key, value in local_vals.items() if vals[key] != value
}
if local_vals:
this.with_context(
__no_changeset=disable_changeset,
tracking_disable=True,
).write(local_vals)
return result
def write(self, values):
if self._changeset_disabled():
return super().write(values)
for record in self:
local_values = self.env["record.changeset"].add_changeset(record, values)
super(Base, record).write(local_values)
return True
def _changeset_disabled(self):
if self.env.context.get("__no_changeset") == disable_changeset:
return True
# To avoid conflicts with tests of other modules
if config["test_enable"] and not self.env.context.get("test_record_changeset"):
return True
if self._name not in self.models_to_track_changeset():
return True
return False
def action_record_changeset_change_view(self):
self.ensure_one()
res = {
"type": "ir.actions.act_window",
"res_model": "record.changeset.change",
"view_mode": "tree",
"views": [
[
self.env.ref("base_changeset.view_record_changeset_change_tree").id,
"list",
]
],
"context": self.env.context,
"name": _("Record Changes"),
"search_view_id": [
self.env.ref("base_changeset.view_record_changeset_change_search").id,
"search",
],
}
record_id = self.env.context.get("search_default_record_id")
if record_id:
res.update(
{
"domain": [
("model", "=", self._name),
("changeset_id.res_id", "=", record_id),
]
}
)
return res
@api.model
def get_view(self, view_id=None, view_type="form", **options):
"""Insert the pending changes smart button in the form view of tracked models."""
res = super().get_view(view_id=view_id, view_type=view_type, **options)
if (
view_type == "form"
and self._name in self.models_to_track_changeset()
and self._user_can_see_changeset()
):
button_label = _("Changes")
doc = etree.XML(res["arch"])
for node in doc.xpath("//div[@name='button_box']"):
field_count_changesets = etree.Element(
"field",
{
"name": "count_changesets",
"modifiers": json.dumps({"invisible": True}),
},
)
field_count_pending_changeset_changes = etree.Element(
"field",
{
"name": "count_pending_changeset_changes",
"string": button_label,
"widget": "statinfo",
},
)
xml_button = etree.Element(
"button",
{
"type": "object",
"name": "action_record_changeset_change_view",
"icon": "fa-code-fork",
"context": "{'search_default_draft': 1, "
"'search_default_record_id': active_id}",
"modifiers": json.dumps(
{"invisible": [("count_changesets", "=", 0)]}
),
},
)
xml_button.insert(0, field_count_pending_changeset_changes)
xml_button.insert(0, field_count_changesets)
node.insert(0, xml_button)
res["arch"] = etree.tostring(doc, encoding="unicode")
return res
@api.model
def _user_can_see_changeset(self):
"""Return if the current user has changeset access"""
return self.env.is_superuser() or self.user_has_groups(
"base_changeset.group_changeset_user"
)
def _compute_user_can_see_changeset(self):
user_can_see_changeset = self._user_can_see_changeset()
for rec in self:
rec.user_can_see_changeset = user_can_see_changeset

View file

@ -0,0 +1,181 @@
# Copyright 2015-2017 Camptocamp SA
# Copyright 2020 Onestein (<https://www.onestein.eu>)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import api, fields, models, tools
from odoo.tools.cache import ormcache
class ChangesetFieldRule(models.Model):
_name = "changeset.field.rule"
_description = "Changeset Field Rules"
_rec_name = "field_id"
model_id = fields.Many2one(related="field_id.model_id", store=True)
field_id = fields.Many2one(
comodel_name="ir.model.fields", ondelete="cascade", required=True
)
action = fields.Selection(
selection="_selection_action",
required=True,
help="Auto: always apply a change.\n"
"Validate: manually applied by an administrator.\n"
"Never: change never applied.",
)
source_model_id = fields.Many2one(
comodel_name="ir.model",
string="Source Model",
ondelete="cascade",
domain=lambda self: [("id", "in", self._domain_source_models().ids)],
help="If a source model is defined, the rule will be applied only "
"when the change is made from this origin. "
"Rules without source model are global and applies to all "
"backends.\n"
"Rules with a source model have precedence over global rules, "
"but if a field has no rule with a source model, the global rule "
"is used.",
)
company_id = fields.Many2one("res.company", default=lambda self: self.env.company)
active = fields.Boolean(default=True)
prevent_self_validation = fields.Boolean(default=False)
expression = fields.Text(
help="Use this rule only on records where this is true. "
"Available variables: object, user",
)
validator_group_ids = fields.Many2many(
"res.groups",
"changeset_field_rule_validator_group_rel",
string="Validator Groups",
default=lambda self: self.env.ref(
"base_changeset.group_changeset_user",
raise_if_not_found=False,
)
or self.env["res.groups"],
)
def init(self):
"""Ensure there is at most one rule with source_model_id NULL."""
self.env.cr.execute(
"""
CREATE UNIQUE INDEX IF NOT EXISTS source_model_null_field_uniq
ON %s (field_id)
WHERE source_model_id IS NULL
"""
% self._table
)
_sql_constraints = [
(
"model_field_uniq",
"unique (source_model_id, field_id)",
"A rule already exists for this field.",
)
]
@api.model
def _domain_source_models(self):
"""Returns the models for which we can define rules.
Example for submodules (replace by the xmlid of the model):
::
models = super()._domain_source_models()
return models | self.env.ref('base.model_res_users')
Rules without model are global and apply for all models.
"""
return self.env.ref("base.model_res_users")
@api.model
def _selection_action(self):
return [("auto", "Auto"), ("validate", "Validate"), ("never", "Never")]
@api.constrains("expression")
def _check_expression(self):
for this in self:
this._evaluate_expression(self.env[this.model_id.model].new({}))
@ormcache(skiparg=1)
@api.model
def _get_rules(self, source_model_name, record_model_name):
"""Cache rules
Keep only the id of the rules, because if we keep the recordsets
in the ormcache, we won't be able to browse them once their
cursor is closed.
The public method ``get_rules`` return the rules with the recordsets
when called.
"""
domain = self._get_rules_search_domain(record_model_name, source_model_name)
model_rules = self.sudo().search(
domain,
# using 'ASC' means that 'NULLS LAST' is the default
order="source_model_id ASC",
)
# model's rules have precedence over global ones so we iterate
# over rules which have a source model first, then we complete
# them with the global rules
result = {}
for rule in model_rules:
# we already have a model's rule
if result.get(rule.field_id.name):
continue
result[rule.field_id.name] = rule.id
return result
def _get_rules_search_domain(self, record_model_name, source_model_name):
return [
("model_id.model", "=", record_model_name),
"|",
("source_model_id.model", "=", source_model_name),
("source_model_id", "=", False),
]
@api.model
def get_rules(self, source_model_name, record_model_name):
"""Return the rules for a model
When a model is specified, it will return the rules for this
model. Fields that have no rule for this model will use the
global rules (those without source).
The source model is the model which ask for a change, it will be
for instance ``res.users``, ``lefac.backend`` or
``magellan.backend``.
The second argument (``source_model_name``) is optional but
cannot be an optional keyword argument otherwise it would not be
in the key for the cache. The callers have to pass ``None`` if
they want only global rules.
"""
rules = {}
cached_rules = self._get_rules(source_model_name, record_model_name)
for field, rule_id in cached_rules.items():
rules[field] = self.browse(rule_id)
return rules
def _evaluate_expression(self, record):
"""Evaluate expression if set"""
self.ensure_one()
return not self.expression or tools.safe_eval.safe_eval(
self.expression, {"object": record, "user": self.env.user}
)
@api.model_create_multi
def create(self, vals_list):
record = super().create(vals_list)
self.clear_caches()
return record
def write(self, vals):
result = super().write(vals)
self.clear_caches()
return result
def unlink(self):
result = super().unlink()
self.clear_caches()
return result

View file

@ -0,0 +1,184 @@
# Copyright 2015-2017 Camptocamp SA
# Copyright 2020 Onestein (<https://www.onestein.eu>)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import api, fields, models
class RecordChangeset(models.Model):
_name = "record.changeset"
_description = "Record Changeset"
_order = "date desc"
_rec_name = "date"
model = fields.Char(index=True, required=True, readonly=True)
res_id = fields.Many2oneReference(
string="Record ID",
index=True,
required=True,
readonly=True,
model_field="model",
)
change_ids = fields.One2many(
comodel_name="record.changeset.change",
inverse_name="changeset_id",
string="Changes",
readonly=True,
)
date = fields.Datetime(
string="Modified on", default=fields.Datetime.now(), index=True, readonly=True
)
modified_by_id = fields.Many2one(
"res.users", default=lambda self: self.env.user, readonly=True
)
state = fields.Selection(
compute="_compute_state",
selection=[("draft", "Pending"), ("done", "Done")],
store=True,
)
note = fields.Text()
source = fields.Reference(
string="Source of the change", selection="_reference_models", readonly=True
)
company_id = fields.Many2one("res.company")
record_id = fields.Reference(
selection="_reference_models", compute="_compute_resource_record", readonly=True
)
@api.depends("model", "res_id")
def _compute_resource_record(self):
for changeset in self:
changeset.record_id = "{},{}".format(changeset.model, changeset.res_id or 0)
@api.model
def _reference_models(self):
models = self.env["ir.model"].sudo().search([])
return [(model.model, model.name) for model in models]
@api.depends("change_ids", "change_ids.state")
def _compute_state(self):
for rec in self:
changes = rec.mapped("change_ids")
if all(change.state in ("done", "cancel") for change in changes):
rec.state = "done"
else:
rec.state = "draft"
def name_get(self):
result = []
for changeset in self:
name = "{} ({})".format(changeset.date, changeset.record_id.display_name)
result.append((changeset.id, name))
return result
def apply(self):
self.with_context(skip_pending_status_check=True).mapped("change_ids").apply()
def cancel(self):
self.with_context(skip_pending_status_check=True).mapped("change_ids").cancel()
@api.model
def add_changeset(self, record, values, create=False):
"""Add a changeset on a record
By default, when a record is modified by a user or by the
system, the the changeset will follow the rules configured for
the global rules.
A caller should pass the following keys in the context:
* ``__changeset_rules_source_model``: name of the model which
asks for the change
* ``__changeset_rules_source_id``: id of the record which asks
for the change
When the source model and id are not defined, the current user
is considered as the origin of the change.
Should be called before the execution of ``write`` on the record
so we can keep track of the existing value and also because the
returned values should be used for ``write`` as some of the
values may have been removed.
:param values: the values being written on the record
:type values: dict
:param create: in create mode, no check is made to see if the field
value consitutes a change.
:type creatie: boolean
:returns: dict of values that should be wrote on the record
(fields with a 'Validate' or 'Never' rule are excluded)
"""
record.ensure_one()
source_model = self.env.context.get("__changeset_rules_source_model")
source_id = self.env.context.get("__changeset_rules_source_id")
if not source_model:
# if the changes source is not defined, log the user who
# made the change
source_model = "res.users"
if not source_id:
source_id = self.env.uid
if source_model and source_id:
source = "{},{}".format(source_model, source_id)
else:
source = False
change_model = self.env["record.changeset.change"]
write_values = values.copy()
changes = []
rules = self.env["changeset.field.rule"].get_rules(
source_model_name=source_model, record_model_name=record._name
)
for field in values:
rule = rules.get(field)
if (
not rule
or not rule._evaluate_expression(record)
or (create and not values[field])
):
continue
if field in values:
if not create and not change_model._has_field_changed(
record, field, values[field]
):
continue
change, pop_value = change_model._prepare_changeset_change(
record,
rule,
field,
values[field],
create=create,
)
if pop_value:
write_values.pop(field)
if create:
# overwrite with null value for new records
write_values[field] = (
# but create some default for required text fields
record._fields[field].required
and record._fields[field].type in ("char", "text")
and "/"
or record._fields[field].null(record)
)
changes.append(change)
if changes:
changeset_vals = self._prepare_changeset_vals(changes, record, source)
self.env["record.changeset"].create(changeset_vals)
return write_values
@api.model
def _prepare_changeset_vals(self, changes, record, source):
has_company = "company_id" in self.env[record._name]._fields
has_company = has_company and record.company_id
company = record.company_id if has_company else self.env.company
return {
# newly created records are passed as newid records with the id in ref
"res_id": record.id or record.id.ref,
"model": record._name,
"company_id": company.id,
"change_ids": [(0, 0, vals) for vals in changes],
"date": fields.Datetime.now(),
"source": source,
}

View file

@ -0,0 +1,436 @@
# Copyright 2015-2017 Camptocamp SA
# Copyright 2020 Onestein (<https://www.onestein.eu>)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from itertools import groupby
from operator import attrgetter
from odoo import _, api, fields, models
from odoo.exceptions import UserError
from .base import disable_changeset
# sentinel object to be sure that no empty value was passed to
# RecordChangesetChange._value_for_changeset
_NO_VALUE = object()
class RecordChangesetChange(models.Model):
"""Store the change of one field for one changeset on one record
This model is composed of 3 sets of fields:
* 'origin'
* 'old'
* 'new'
The 'new' fields contain the value that needs to be validated.
The 'old' field copies the actual value of the record when the
change is either applied either canceled. This field is used as a storage
place but never shown by itself.
The 'origin' fields is a related field towards the actual values of
the record until the change is either applied either canceled, past
that it shows the 'old' value.
The reason behind this is that the values may change on a record between
the moment when the changeset is created and when it is applied.
On the views, we show the origin fields which represent the actual
record values or the old values and we show the new fields.
The 'origin' and 'new_value_display' are displayed on
the tree view where we need a unique of field, the other fields are
displayed on the form view so we benefit from their widgets.
"""
_name = "record.changeset.change"
_description = "Record Changeset Change"
_rec_name = "field_id"
changeset_id = fields.Many2one(
comodel_name="record.changeset",
required=True,
ondelete="cascade",
readonly=True,
)
field_id = fields.Many2one(
comodel_name="ir.model.fields",
required=True,
readonly=True,
ondelete="cascade",
)
field_name = fields.Char(related="field_id.name", readonly=True)
field_type = fields.Selection(related="field_id.ttype", readonly=True)
model = fields.Char(related="field_id.model", readonly=True, store=True)
origin_value_display = fields.Char(
string="Previous", compute="_compute_value_display"
)
new_value_display = fields.Char(string="New", compute="_compute_value_display")
# Fields showing the origin record's value or the 'old' value if
# the change is applied or canceled.
origin_value_char = fields.Char(compute="_compute_origin_values", readonly=True)
origin_value_date = fields.Date(compute="_compute_origin_values", readonly=True)
origin_value_datetime = fields.Datetime(
compute="_compute_origin_values", readonly=True
)
origin_value_float = fields.Float(compute="_compute_origin_values", readonly=True)
origin_value_monetary = fields.Float(
compute="_compute_origin_values", readonly=True
)
origin_value_integer = fields.Integer(
compute="_compute_origin_values", readonly=True
)
origin_value_text = fields.Text(compute="_compute_origin_values", readonly=True)
origin_value_boolean = fields.Boolean(
compute="_compute_origin_values", readonly=True
)
origin_value_reference = fields.Reference(
compute="_compute_origin_values", selection="_reference_models", readonly=True
)
# Fields storing the previous record's values (saved when the
# changeset is applied)
old_value_char = fields.Char(readonly=True)
old_value_date = fields.Date(readonly=True)
old_value_datetime = fields.Datetime(readonly=True)
old_value_float = fields.Float(readonly=True)
old_value_monetary = fields.Float(readonly=True)
old_value_integer = fields.Integer(readonly=True)
old_value_text = fields.Text(readonly=True)
old_value_boolean = fields.Boolean(readonly=True)
old_value_reference = fields.Reference(selection="_reference_models", readonly=True)
# Fields storing the value applied on the record
new_value_char = fields.Char(readonly=True)
new_value_date = fields.Date(readonly=True)
new_value_datetime = fields.Datetime(readonly=True)
new_value_float = fields.Float(readonly=True)
new_value_monetary = fields.Float(readonly=True)
new_value_integer = fields.Integer(readonly=True)
new_value_text = fields.Text(readonly=True)
new_value_boolean = fields.Boolean(readonly=True)
new_value_reference = fields.Reference(selection="_reference_models", readonly=True)
state = fields.Selection(
selection=[("draft", "Pending"), ("done", "Approved"), ("cancel", "Rejected")],
required=True,
default="draft",
readonly=True,
)
record_id = fields.Reference(related="changeset_id.record_id")
rule_id = fields.Many2one("changeset.field.rule", readonly=True)
user_can_validate_changeset = fields.Boolean(
compute="_compute_user_can_validate_changeset"
)
date = fields.Datetime(related="changeset_id.date")
modified_by_id = fields.Many2one(related="changeset_id.modified_by_id")
verified_on_date = fields.Datetime(string="Verified on", readonly=True)
verified_by_id = fields.Many2one("res.users", readonly=True)
@api.model
def _reference_models(self):
"""Get all model names from ir.model.
Requires sudo, as ir.model is only readable for ERP managers.
"""
models = self.sudo().env["ir.model"].search([])
return [(model.model, model.name) for model in models]
_suffix_to_types = {
"char": ("char", "selection"),
"date": ("date",),
"datetime": ("datetime",),
"float": ("float",),
"monetary": ("monetary",),
"integer": ("integer",),
"text": ("text", "html"),
"boolean": ("boolean",),
"reference": ("many2one",),
}
_type_to_suffix = {
ftype: suffix for suffix, ftypes in _suffix_to_types.items() for ftype in ftypes
}
_origin_value_fields = ["origin_value_%s" % suffix for suffix in _suffix_to_types]
_old_value_fields = ["old_value_%s" % suffix for suffix in _suffix_to_types]
_new_value_fields = ["new_value_%s" % suffix for suffix in _suffix_to_types]
_value_fields = _origin_value_fields + _old_value_fields + _new_value_fields
@api.depends("changeset_id.res_id", "changeset_id.model")
def _compute_origin_values(self):
states = self.get_pending_changes_states()
field_names = [
field_name
for field_name in self._fields.keys()
if field_name.startswith("origin_value_")
and field_name != "origin_value_display"
]
for rec in self:
field_name = rec.get_field_for_type(rec.field_id, "origin")
for fname in field_names:
if fname == field_name:
if rec.state in states:
value = rec.record_id[rec.field_id.name]
else:
old_field = rec.get_field_for_type(rec.field_id, "old")
value = rec[old_field]
setattr(rec, fname, value)
else:
setattr(rec, fname, False)
@api.depends(lambda self: self._value_fields)
def _compute_value_display(self):
for rec in self:
for prefix in ("origin", "new"):
value = getattr(rec, "get_%s_value" % prefix)()
if rec.field_id.ttype == "many2one" and value:
value = value.display_name
setattr(rec, "%s_value_display" % prefix, value)
@api.model
def get_field_for_type(self, field, prefix):
assert prefix in ("origin", "old", "new")
field_type = self._type_to_suffix.get(field.sudo().ttype)
if not field_type:
raise NotImplementedError("field type %s is not supported" % field_type)
return "{}_value_{}".format(prefix, field_type)
def get_origin_value(self):
self.ensure_one()
field_name = self.get_field_for_type(self.field_id, "origin")
return self[field_name]
def get_new_value(self):
self.ensure_one()
field_name = self.get_field_for_type(self.field_id, "new")
return self[field_name]
def set_old_value(self):
"""Copy the value of the record to the 'old' field"""
for change in self:
# copy the existing record's value for the history
old_value_for_write = self._value_for_changeset(
change.record_id, change.field_id.name
)
old_field_name = self.get_field_for_type(change.field_id, "old")
change.write({old_field_name: old_value_for_write})
def apply(self):
"""Apply the change on the changeset's record
It is optimized thus that it makes only one write on the record
per changeset if many changes are applied at once.
"""
for change in self:
if not change.user_can_validate_changeset:
raise UserError(_("You don't have the rights to apply the changes."))
changes_ok = self.browse()
key = attrgetter("changeset_id")
for changeset, changes in groupby(
self.with_context(__no_changeset=disable_changeset).sorted(key=key), key=key
):
values = {}
for change in changes:
if change.state in ("cancel", "done"):
continue
field = change.field_id
new_value = change.get_new_value()
value_for_write = change._convert_value_for_write(new_value)
values[field.name] = value_for_write
change.set_old_value()
changes_ok |= change
if not values:
continue
self._check_previous_changesets(changeset)
changeset.record_id.with_context(__no_changeset=disable_changeset).write(
values
)
changes_ok._finalize_change_approval()
def _check_previous_changesets(self, changeset):
if self.env.context.get("require_previous_changesets_done"):
states = self.get_pending_changes_states()
previous_changesets = self.env["record.changeset"].search(
[
("date", "<", changeset.date),
("state", "in", states),
("model", "=", changeset.model),
("res_id", "=", changeset.res_id),
],
limit=1,
)
if previous_changesets:
raise UserError(
_(
"This change cannot be applied because a previous "
"changeset for the same record is pending.\n"
"Apply all the anterior changesets before applying "
"this one."
)
)
def cancel(self):
"""Reject the change"""
for change in self:
if not change.user_can_validate_changeset:
raise UserError(_("You don't have the rights to reject the changes."))
if any(change.state == "done" for change in self):
raise UserError(_("This change has already be applied."))
self.set_old_value()
self._finalize_change_rejection()
def _finalize_change_approval(self):
self.write(
{
"state": "done",
"verified_by_id": self.env.user.id,
"verified_on_date": fields.Datetime.now(),
}
)
def _finalize_change_rejection(self):
self.write(
{
"state": "cancel",
"verified_by_id": self.env.user.id,
"verified_on_date": fields.Datetime.now(),
}
)
@api.model
def _has_field_changed(self, record, field, value):
field_def = record._fields[field]
current_value = field_def.convert_to_write(record[field], record)
if not (current_value or value):
return False
return current_value != value
def _convert_value_for_write(self, value):
if not value:
return value
model = self.env[self.field_id.model_id.model]
model_field_def = model._fields[self.field_id.name]
return model_field_def.convert_to_write(value, self.record_id)
@api.model
def _value_for_changeset(self, record, field_name, value=_NO_VALUE):
"""Return a value from the record ready to write in a changeset field
:param record: modified record
:param field_name: name of the modified field
:param value: if no value is given, it is read from the record
"""
field_def = record._fields[field_name]
if value is _NO_VALUE:
# when the value is read from the record, we need to prepare
# it for the write (e.g. extract .id from a many2one record)
value = field_def.convert_to_write(record[field_name], record)
if field_def.type == "many2one":
# store as 'reference'
comodel = field_def.comodel_name
return "{},{}".format(comodel, value) if value else False
else:
return value
@api.model
def _prepare_changeset_change(self, record, rule, field_name, value, create=False):
"""Prepare data for a changeset change
It returns a dict of the values to write on the changeset change
and a boolean that indicates if the value should be popped out
of the values to write on the model.
:returns: dict of values, boolean
"""
field = rule.sudo().field_id
new_field_name = self.get_field_for_type(field, "new")
new_value = self._value_for_changeset(record, field_name, value=value)
change = {
new_field_name: new_value,
"field_id": field.id,
"rule_id": rule.id,
}
if rule.action == "auto":
change["state"] = "done"
pop_value = False
elif rule.action == "validate":
change["state"] = "draft"
pop_value = True # change to apply manually
elif rule.action == "never":
change["state"] = "cancel"
pop_value = True # change never applied
if create or change["state"] in ("cancel", "done"):
# Normally the 'old' value is set when we use the 'apply'
# button, but since we short circuit the 'apply', we
# directly set the 'old' value here
old_field_name = self.get_field_for_type(field, "old")
# get values ready to write as expected by the changeset
# (for instance, a many2one is written in a reference
# field)
origin_value = self._value_for_changeset(
record, field_name, value=False if create else _NO_VALUE
)
change[old_field_name] = origin_value
return change, pop_value
@api.model
def get_changeset_changes_by_field(self, model, res_id):
"""Return changes grouped by field.
:returns: dictionary with field names as keys and lists of dictionaries
describing changes as keys.
:rtype: dict
"""
fields = [
"new_value_display",
"origin_value_display",
"field_name",
"user_can_validate_changeset",
]
states = self.get_pending_changes_states()
domain = [
("changeset_id.model", "=", model),
("changeset_id.res_id", "=", res_id),
("state", "in", states),
]
return {
field_name: list(changes)
for (field_name, changes) in groupby(
self.search_read(domain, fields), lambda vals: vals["field_name"]
)
}
@api.depends_context("user")
def _compute_user_can_validate_changeset(self):
is_superuser = self.env.is_superuser()
has_group = self.user_has_groups("base_changeset.group_changeset_user")
user_groups = self.env.user.groups_id
for rec in self:
can_validate = rec._is_change_pending() and (
is_superuser
or rec.rule_id.validator_group_ids & user_groups
or has_group
)
if rec.rule_id.prevent_self_validation:
can_validate = can_validate and rec.modified_by_id != self.env.user
rec.user_can_validate_changeset = can_validate
@api.model
def get_pending_changes_states(self):
return ["draft"]
def _is_change_pending(self):
self.ensure_one()
skip_status_check = self.env.context.get("skip_pending_status_check")
return skip_status_check or self.state in self.get_pending_changes_states()