mirror of
https://github.com/bringout/oca-technical.git
synced 2026-04-18 09:12:03 +02:00
Initial commit: OCA Technical packages (595 packages)
This commit is contained in:
commit
2cc02aac6e
24950 changed files with 2318079 additions and 0 deletions
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue