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 @@
from . import base
from . import ir_model_fields
from . import queue_job
from . import queue_job_channel
from . import queue_job_function
from . import queue_job_lock

View file

@ -0,0 +1,266 @@
# Copyright 2016 Camptocamp
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html)
import functools
from odoo import api, models
from ..delay import Delayable, DelayableRecordset
from ..utils import must_run_without_delay
class Base(models.AbstractModel):
"""The base model, which is implicitly inherited by all models.
A new :meth:`~with_delay` method is added on all Odoo Models, allowing to
postpone the execution of a job method in an asynchronous process.
"""
_inherit = "base"
def with_delay(
self,
priority=None,
eta=None,
max_retries=None,
description=None,
channel=None,
identity_key=None,
):
"""Return a ``DelayableRecordset``
It is a shortcut for the longer form as shown below::
self.with_delay(priority=20).action_done()
# is equivalent to:
self.delayable().set(priority=20).action_done().delay()
``with_delay()`` accepts job properties which specify how the job will
be executed.
Usage with job properties::
env['a.model'].with_delay(priority=30, eta=60*60*5).action_done()
delayable.export_one_thing(the_thing_to_export)
# => the job will be executed with a low priority and not before a
# delay of 5 hours from now
When using :meth:``with_delay``, the final ``delay()`` is implicit.
See the documentation of :meth:``delayable`` for more details.
:return: instance of a DelayableRecordset
:rtype: :class:`odoo.addons.queue_job.job.DelayableRecordset`
"""
return DelayableRecordset(
self,
priority=priority,
eta=eta,
max_retries=max_retries,
description=description,
channel=channel,
identity_key=identity_key,
)
def delayable(
self,
priority=None,
eta=None,
max_retries=None,
description=None,
channel=None,
identity_key=None,
):
"""Return a ``Delayable``
The returned instance allows to enqueue any method of the recordset's
Model.
Usage::
delayable = self.env["res.users"].browse(10).delayable(priority=20)
delayable.do_work(name="test"}).delay()
In this example, the ``do_work`` method will not be executed directly.
It will be executed in an asynchronous job.
Method calls on a Delayable generally return themselves, so calls can
be chained together::
delayable.set(priority=15).do_work(name="test"}).delay()
The order of the calls that build the job is not relevant, beside
the call to ``delay()`` that must happen at the very end. This is
equivalent to the example above::
delayable.do_work(name="test"}).set(priority=15).delay()
Very importantly, ``delay()`` must be called on the top-most parent
of a chain of jobs, so if you have this::
job1 = record1.delayable().do_work()
job2 = record2.delayable().do_work()
job1.on_done(job2)
The ``delay()`` call must be made on ``job1``, otherwise ``job2`` will
be delayed, but ``job1`` will never be. When done on ``job1``, the
``delay()`` call will traverse the graph of jobs and delay all of
them::
job1.delay()
For more details on the graph dependencies, read the documentation of
:module:`~odoo.addons.queue_job.delay`.
:param priority: Priority of the job, 0 being the higher priority.
Default is 10.
:param eta: Estimated Time of Arrival of the job. It will not be
executed before this date/time.
:param max_retries: maximum number of retries before giving up and set
the job state to 'failed'. A value of 0 means
infinite retries. Default is 5.
:param description: human description of the job. If None, description
is computed from the function doc or name
:param channel: the complete name of the channel to use to process
the function. If specified it overrides the one
defined on the function
:param identity_key: key uniquely identifying the job, if specified
and a job with the same key has not yet been run,
the new job will not be added. It is either a
string, either a function that takes the job as
argument (see :py:func:`..job.identity_exact`).
the new job will not be added.
:return: instance of a Delayable
:rtype: :class:`odoo.addons.queue_job.job.Delayable`
"""
return Delayable(
self,
priority=priority,
eta=eta,
max_retries=max_retries,
description=description,
channel=channel,
identity_key=identity_key,
)
def _patch_job_auto_delay(self, method_name, context_key=None):
"""Patch a method to be automatically delayed as job method when called
This patch method has to be called in ``_register_hook`` (example
below).
When a method is patched, any call to the method will not directly
execute the method's body, but will instead enqueue a job.
When a ``context_key`` is set when calling ``_patch_job_auto_delay``,
the patched method is automatically delayed only when this key is
``True`` in the caller's context. It is advised to patch the method
with a ``context_key``, because making the automatic delay *in any
case* can produce nasty and unexpected side effects (e.g. another
module calls the method and expects it to be computed before doing
something else, expecting a result, ...).
A typical use case is when a method in a module we don't control is
called synchronously in the middle of another method, and we'd like all
the calls to this method become asynchronous.
The options of the job usually passed to ``with_delay()`` (priority,
description, identity_key, ...) can be returned in a dictionary by a
method named after the name of the method suffixed by ``_job_options``
which takes the same parameters as the initial method.
It is still possible to force synchronous execution of the method by
setting a key ``_job_force_sync`` to True in the environment context.
Example patching the "foo" method to be automatically delayed as job
(the job options method is optional):
.. code-block:: python
# original method:
def foo(self, arg1):
print("hello", arg1)
def large_method(self):
# doing a lot of things
self.foo("world)
# doing a lot of other things
def button_x(self):
self.with_context(auto_delay_foo=True).large_method()
# auto delay patch:
def foo_job_options(self, arg1):
return {
"priority": 100,
"description": "Saying hello to {}".format(arg1)
}
def _register_hook(self):
self._patch_method(
"foo",
self._patch_job_auto_delay("foo", context_key="auto_delay_foo")
)
return super()._register_hook()
The result when ``button_x`` is called, is that a new job for ``foo``
is delayed.
"""
def auto_delay_wrapper(self, *args, **kwargs):
# when no context_key is set, we delay in any case (warning, can be
# dangerous)
context_delay = self.env.context.get(context_key) if context_key else True
if (
self.env.context.get("job_uuid")
or not context_delay
or must_run_without_delay(self.env)
):
# we are in the job execution
return auto_delay_wrapper.origin(self, *args, **kwargs)
else:
# replace the synchronous call by a job on itself
method_name = auto_delay_wrapper.origin.__name__
job_options_method = getattr(
self, "{}_job_options".format(method_name), None
)
job_options = {}
if job_options_method:
job_options.update(job_options_method(*args, **kwargs))
delayed = self.with_delay(**job_options)
return getattr(delayed, method_name)(*args, **kwargs)
origin = getattr(self, method_name)
return functools.update_wrapper(auto_delay_wrapper, origin)
@api.model
def _job_store_values(self, job):
"""Hook for manipulating job stored values.
You can define a more specific hook for a job function
by defining a method name with this pattern:
`_queue_job_store_values_${func_name}`
NOTE: values will be stored only if they match stored fields on `queue.job`.
:param job: current queue_job.job.Job instance.
:return: dictionary for setting job values.
"""
return {}
@api.model
def _job_prepare_context_before_enqueue_keys(self):
"""Keys to keep in context of stored jobs
Empty by default for backward compatibility.
"""
return ("tz", "lang", "allowed_company_ids", "force_company", "active_test")
def _job_prepare_context_before_enqueue(self):
"""Return the context to store in the jobs
Can be used to keep only safe keys.
"""
return {
key: value
for key, value in self.env.context.items()
if key in self._job_prepare_context_before_enqueue_keys()
}

View file

@ -0,0 +1,13 @@
# Copyright 2020 Camptocamp
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html)
from odoo import fields, models
class IrModelFields(models.Model):
_inherit = "ir.model.fields"
ttype = fields.Selection(
selection_add=[("job_serialized", "Job Serialized")],
ondelete={"job_serialized": "cascade"},
)

View file

@ -0,0 +1,457 @@
# Copyright 2013-2020 Camptocamp SA
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html)
import logging
import random
from datetime import datetime, timedelta
from odoo import _, api, exceptions, fields, models
from odoo.tools import config, html_escape
from odoo.addons.base_sparse_field.models.fields import Serialized
from ..delay import Graph
from ..exception import JobError
from ..fields import JobSerialized
from ..job import (
CANCELLED,
DONE,
FAILED,
PENDING,
STARTED,
STATES,
WAIT_DEPENDENCIES,
Job,
)
_logger = logging.getLogger(__name__)
class QueueJob(models.Model):
"""Model storing the jobs to be executed."""
_name = "queue.job"
_description = "Queue Job"
_inherit = ["mail.thread", "mail.activity.mixin"]
_log_access = False
_order = "date_created DESC, date_done DESC"
_removal_interval = 30 # days
_default_related_action = "related_action_open_record"
# This must be passed in a context key "_job_edit_sentinel" to write on
# protected fields. It protects against crafting "queue.job" records from
# RPC (e.g. on internal methods). When ``with_delay`` is used, the sentinel
# is set.
EDIT_SENTINEL = object()
_protected_fields = (
"uuid",
"name",
"date_created",
"model_name",
"method_name",
"func_string",
"channel_method_name",
"job_function_id",
"records",
"args",
"kwargs",
)
uuid = fields.Char(string="UUID", readonly=True, index=True, required=True)
graph_uuid = fields.Char(
string="Graph UUID",
readonly=True,
index=True,
help="Single shared identifier of a Graph. Empty for a single job.",
)
user_id = fields.Many2one(comodel_name="res.users", string="User ID")
company_id = fields.Many2one(
comodel_name="res.company", string="Company", index=True
)
name = fields.Char(string="Description", readonly=True)
model_name = fields.Char(string="Model", readonly=True)
method_name = fields.Char(readonly=True)
# record_ids field is only for backward compatibility (e.g. used in related
# actions), can be removed (replaced by "records") in 14.0
record_ids = JobSerialized(compute="_compute_record_ids", base_type=list)
records = JobSerialized(
string="Record(s)",
readonly=True,
base_type=models.BaseModel,
)
dependencies = Serialized(readonly=True)
# dependency graph as expected by the field widget
dependency_graph = Serialized(compute="_compute_dependency_graph")
graph_jobs_count = fields.Integer(compute="_compute_graph_jobs_count")
args = JobSerialized(readonly=True, base_type=tuple)
kwargs = JobSerialized(readonly=True, base_type=dict)
func_string = fields.Char(string="Task", readonly=True)
state = fields.Selection(STATES, readonly=True, required=True, index=True)
priority = fields.Integer()
exc_name = fields.Char(string="Exception", readonly=True)
exc_message = fields.Char(string="Exception Message", readonly=True, tracking=True)
exc_info = fields.Text(string="Exception Info", readonly=True)
result = fields.Text(readonly=True)
date_created = fields.Datetime(string="Created Date", readonly=True)
date_started = fields.Datetime(string="Start Date", readonly=True)
date_enqueued = fields.Datetime(string="Enqueue Time", readonly=True)
date_done = fields.Datetime(readonly=True)
exec_time = fields.Float(
string="Execution Time (avg)",
group_operator="avg",
help="Time required to execute this job in seconds. Average when grouped.",
)
date_cancelled = fields.Datetime(readonly=True)
eta = fields.Datetime(string="Execute only after")
retry = fields.Integer(string="Current try")
max_retries = fields.Integer(
string="Max. retries",
help="The job will fail if the number of tries reach the "
"max. retries.\n"
"Retries are infinite when empty.",
)
# FIXME the name of this field is very confusing
channel_method_name = fields.Char(string="Complete Method Name", readonly=True)
job_function_id = fields.Many2one(
comodel_name="queue.job.function",
string="Job Function",
readonly=True,
)
channel = fields.Char(index=True)
identity_key = fields.Char(readonly=True)
worker_pid = fields.Integer(readonly=True)
def init(self):
self._cr.execute(
"SELECT indexname FROM pg_indexes WHERE indexname = %s ",
("queue_job_identity_key_state_partial_index",),
)
if not self._cr.fetchone():
self._cr.execute(
"CREATE INDEX queue_job_identity_key_state_partial_index "
"ON queue_job (identity_key) WHERE state in ('pending', "
"'enqueued', 'wait_dependencies') AND identity_key IS NOT NULL;"
)
@api.depends("records")
def _compute_record_ids(self):
for record in self:
record.record_ids = record.records.ids
@api.depends("dependencies")
def _compute_dependency_graph(self):
jobs_groups = self.env["queue.job"].read_group(
[
(
"graph_uuid",
"in",
[uuid for uuid in self.mapped("graph_uuid") if uuid],
)
],
["graph_uuid", "ids:array_agg(id)"],
["graph_uuid"],
)
ids_per_graph_uuid = {
group["graph_uuid"]: group["ids"] for group in jobs_groups
}
for record in self:
if not record.graph_uuid:
record.dependency_graph = {}
continue
graph_jobs = self.browse(ids_per_graph_uuid.get(record.graph_uuid) or [])
if not graph_jobs:
record.dependency_graph = {}
continue
graph_ids = {graph_job.uuid: graph_job.id for graph_job in graph_jobs}
graph_jobs_by_ids = {graph_job.id: graph_job for graph_job in graph_jobs}
graph = Graph()
for graph_job in graph_jobs:
graph.add_vertex(graph_job.id)
for parent_uuid in graph_job.dependencies["depends_on"]:
parent_id = graph_ids.get(parent_uuid)
if not parent_id:
continue
graph.add_edge(parent_id, graph_job.id)
for child_uuid in graph_job.dependencies["reverse_depends_on"]:
child_id = graph_ids.get(child_uuid)
if not child_id:
continue
graph.add_edge(graph_job.id, child_id)
record.dependency_graph = {
# list of ids
"nodes": [
graph_jobs_by_ids[graph_id]._dependency_graph_vis_node()
for graph_id in graph.vertices()
],
# list of tuples (from, to)
"edges": graph.edges(),
}
def _dependency_graph_vis_node(self):
"""Return the node as expected by the JobDirectedGraph widget"""
default = ("#D2E5FF", "#2B7CE9")
colors = {
DONE: ("#C2FABC", "#4AD63A"),
FAILED: ("#FB7E81", "#FA0A10"),
STARTED: ("#FFFF00", "#FFA500"),
}
return {
"id": self.id,
"title": "<strong>%s</strong><br/>%s"
% (
html_escape(self.display_name),
html_escape(self.func_string),
),
"color": colors.get(self.state, default)[0],
"border": colors.get(self.state, default)[1],
"shadow": True,
}
def _compute_graph_jobs_count(self):
jobs_groups = self.env["queue.job"].read_group(
[
(
"graph_uuid",
"in",
[uuid for uuid in self.mapped("graph_uuid") if uuid],
)
],
["graph_uuid"],
["graph_uuid"],
)
count_per_graph_uuid = {
group["graph_uuid"]: group["graph_uuid_count"] for group in jobs_groups
}
for record in self:
record.graph_jobs_count = count_per_graph_uuid.get(record.graph_uuid) or 0
@api.model_create_multi
def create(self, vals_list):
if self.env.context.get("_job_edit_sentinel") is not self.EDIT_SENTINEL:
# Prevent to create a queue.job record "raw" from RPC.
# ``with_delay()`` must be used.
raise exceptions.AccessError(
_("Queue jobs must be created by calling 'with_delay()'.")
)
return super(
QueueJob,
self.with_context(mail_create_nolog=True, mail_create_nosubscribe=True),
).create(vals_list)
def write(self, vals):
if self.env.context.get("_job_edit_sentinel") is not self.EDIT_SENTINEL:
write_on_protected_fields = [
fieldname for fieldname in vals if fieldname in self._protected_fields
]
if write_on_protected_fields:
raise exceptions.AccessError(
_("Not allowed to change field(s): {}").format(
write_on_protected_fields
)
)
different_user_jobs = self.browse()
if vals.get("user_id"):
different_user_jobs = self.filtered(
lambda records: records.env.user.id != vals["user_id"]
)
if vals.get("state") == "failed":
self._message_post_on_failure()
result = super().write(vals)
for record in different_user_jobs:
# the user is stored in the env of the record, but we still want to
# have a stored user_id field to be able to search/groupby, so
# synchronize the env of records with user_id
super(QueueJob, record).write(
{"records": record.records.with_user(vals["user_id"])}
)
return result
def open_related_action(self):
"""Open the related action associated to the job"""
self.ensure_one()
job = Job.load(self.env, self.uuid)
action = job.related_action()
if action is None:
raise exceptions.UserError(_("No action available for this job"))
return action
def open_graph_jobs(self):
"""Return action that opens all jobs of the same graph"""
self.ensure_one()
jobs = self.env["queue.job"].search([("graph_uuid", "=", self.graph_uuid)])
action = self.env["ir.actions.act_window"]._for_xml_id(
"queue_job.action_queue_job"
)
action.update(
{
"name": _("Jobs for graph %s") % (self.graph_uuid),
"context": {},
"domain": [("id", "in", jobs.ids)],
}
)
return action
def _change_job_state(self, state, result=None):
"""Change the state of the `Job` object
Changing the state of the Job will automatically change some fields
(date, result, ...).
"""
for record in self:
job_ = Job.load(record.env, record.uuid)
if state == DONE:
job_.set_done(result=result)
job_.store()
record.env["queue.job"].flush_model()
job_.enqueue_waiting()
elif state == PENDING:
job_.set_pending(result=result)
job_.store()
elif state == CANCELLED:
job_.set_cancelled(result=result)
job_.store()
record.env["queue.job"].flush_model()
job_.cancel_dependent_jobs()
else:
raise ValueError("State not supported: %s" % state)
def button_done(self):
result = _("Manually set to done by %s") % self.env.user.name
self._change_job_state(DONE, result=result)
return True
def button_cancelled(self):
result = _("Cancelled by %s") % self.env.user.name
self._change_job_state(CANCELLED, result=result)
return True
def requeue(self):
jobs_to_requeue = self.filtered(lambda job_: job_.state != WAIT_DEPENDENCIES)
jobs_to_requeue._change_job_state(PENDING)
return jobs_to_requeue
def _message_post_on_failure(self):
# subscribe the users now to avoid to subscribe them
# at every job creation
domain = self._subscribe_users_domain()
base_users = self.env["res.users"].search(domain)
for record in self:
users = base_users | record.user_id
record.message_subscribe(partner_ids=users.mapped("partner_id").ids)
msg = record._message_failed_job()
if msg:
record.message_post(body=msg, subtype_xmlid="queue_job.mt_job_failed")
def _subscribe_users_domain(self):
"""Subscribe all users having the 'Queue Job Manager' group"""
group = self.env.ref("queue_job.group_queue_job_manager")
if not group:
return None
companies = self.mapped("company_id")
domain = [("groups_id", "=", group.id)]
if companies:
domain.append(("company_id", "in", companies.ids))
return domain
def _message_failed_job(self):
"""Return a message which will be posted on the job when it is failed.
It can be inherited to allow more precise messages based on the
exception informations.
If nothing is returned, no message will be posted.
"""
self.ensure_one()
return _(
"Something bad happened during the execution of job %s. "
"More details in the 'Exception Information' section.",
self.uuid,
)
def _needaction_domain_get(self):
"""Returns the domain to filter records that require an action
:return: domain or False is no action
"""
return [("state", "=", "failed")]
def autovacuum(self):
"""Delete all jobs done based on the removal interval defined on the
channel
Called from a cron.
"""
for channel in self.env["queue.job.channel"].search([]):
deadline = datetime.now() - timedelta(days=int(channel.removal_interval))
while True:
jobs = self.search(
[
"|",
("date_done", "<=", deadline),
("date_cancelled", "<=", deadline),
("channel", "=", channel.complete_name),
],
limit=1000,
)
if jobs:
jobs.unlink()
if not config["test_enable"]:
self.env.cr.commit() # pylint: disable=E8102
else:
break
return True
def related_action_open_record(self):
"""Open a form view with the record(s) of the job.
For instance, for a job on a ``product.product``, it will open a
``product.product`` form view with the product record(s) concerned by
the job. If the job concerns more than one record, it opens them in a
list.
This is the default related action.
"""
self.ensure_one()
records = self.records.exists()
if not records:
return None
action = {
"name": _("Related Record"),
"type": "ir.actions.act_window",
"view_mode": "form",
"res_model": records._name,
}
if len(records) == 1:
action["res_id"] = records.id
else:
action.update(
{
"name": _("Related Records"),
"view_mode": "tree,form",
"domain": [("id", "in", records.ids)],
}
)
return action
def _test_job(self, failure_rate=0):
_logger.info("Running test job.")
if random.random() <= failure_rate:
raise JobError("Job failed")

View file

@ -0,0 +1,94 @@
# Copyright 2013-2020 Camptocamp SA
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html)
from odoo import _, api, exceptions, fields, models
class QueueJobChannel(models.Model):
_name = "queue.job.channel"
_description = "Job Channels"
name = fields.Char()
complete_name = fields.Char(
compute="_compute_complete_name", store=True, readonly=True, recursive=True
)
parent_id = fields.Many2one(
comodel_name="queue.job.channel", string="Parent Channel", ondelete="restrict"
)
job_function_ids = fields.One2many(
comodel_name="queue.job.function",
inverse_name="channel_id",
string="Job Functions",
)
removal_interval = fields.Integer(
default=lambda self: self.env["queue.job"]._removal_interval, required=True
)
_sql_constraints = [
("name_uniq", "unique(complete_name)", "Channel complete name must be unique")
]
@api.depends("name", "parent_id.complete_name")
def _compute_complete_name(self):
for record in self:
if not record.name:
complete_name = "" # new record
elif record.parent_id:
complete_name = ".".join([record.parent_id.complete_name, record.name])
else:
complete_name = record.name
record.complete_name = complete_name
@api.constrains("parent_id", "name")
def parent_required(self):
for record in self:
if record.name != "root" and not record.parent_id:
raise exceptions.ValidationError(_("Parent channel required."))
@api.model_create_multi
def create(self, vals_list):
records = self.browse()
if self.env.context.get("install_mode"):
# installing a module that creates a channel: rebinds the channel
# to an existing one (likely we already had the channel created by
# the @job decorator previously)
new_vals_list = []
for vals in vals_list:
name = vals.get("name")
parent_id = vals.get("parent_id")
if name and parent_id:
existing = self.search(
[("name", "=", name), ("parent_id", "=", parent_id)]
)
if existing:
if not existing.get_metadata()[0].get("noupdate"):
existing.write(vals)
records |= existing
continue
new_vals_list.append(vals)
vals_list = new_vals_list
records |= super().create(vals_list)
return records
def write(self, values):
for channel in self:
if (
not self.env.context.get("install_mode")
and channel.name == "root"
and ("name" in values or "parent_id" in values)
):
raise exceptions.UserError(_("Cannot change the root channel"))
return super().write(values)
def unlink(self):
for channel in self:
if channel.name == "root":
raise exceptions.UserError(_("Cannot remove the root channel"))
return super().unlink()
def name_get(self):
result = []
for record in self:
result.append((record.id, record.complete_name))
return result

View file

@ -0,0 +1,273 @@
# Copyright 2013-2020 Camptocamp SA
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html)
import ast
import logging
import re
from collections import namedtuple
from odoo import _, api, exceptions, fields, models, tools
from ..fields import JobSerialized
_logger = logging.getLogger(__name__)
regex_job_function_name = re.compile(r"^<([0-9a-z_\.]+)>\.([0-9a-zA-Z_]+)$")
class QueueJobFunction(models.Model):
_name = "queue.job.function"
_description = "Job Functions"
_log_access = False
JobConfig = namedtuple(
"JobConfig",
"channel "
"retry_pattern "
"related_action_enable "
"related_action_func_name "
"related_action_kwargs "
"job_function_id ",
)
def _default_channel(self):
return self.env.ref("queue_job.channel_root")
name = fields.Char(
compute="_compute_name",
inverse="_inverse_name",
index=True,
store=True,
)
# model and method should be required, but the required flag doesn't
# let a chance to _inverse_name to be executed
model_id = fields.Many2one(
comodel_name="ir.model", string="Model", ondelete="cascade"
)
method = fields.Char()
channel_id = fields.Many2one(
comodel_name="queue.job.channel",
string="Channel",
required=True,
default=lambda r: r._default_channel(),
)
channel = fields.Char(related="channel_id.complete_name", store=True, readonly=True)
retry_pattern = JobSerialized(string="Retry Pattern (serialized)", base_type=dict)
edit_retry_pattern = fields.Text(
string="Retry Pattern",
compute="_compute_edit_retry_pattern",
inverse="_inverse_edit_retry_pattern",
help="Pattern expressing from the count of retries on retryable errors,"
" the number of of seconds to postpone the next execution. Setting the "
"number of seconds to a 2-element tuple or list will randomize the "
"retry interval between the 2 values.\n"
"Example: {1: 10, 5: 20, 10: 30, 15: 300}.\n"
"Example: {1: (1, 10), 5: (11, 20), 10: (21, 30), 15: (100, 300)}.\n"
"See the module description for details.",
)
related_action = JobSerialized(string="Related Action (serialized)", base_type=dict)
edit_related_action = fields.Text(
string="Related Action",
compute="_compute_edit_related_action",
inverse="_inverse_edit_related_action",
help="The action when the button *Related Action* is used on a job. "
"The default action is to open the view of the record related "
"to the job. Configured as a dictionary with optional keys: "
"enable, func_name, kwargs.\n"
"See the module description for details.",
)
@api.depends("model_id.model", "method")
def _compute_name(self):
for record in self:
if not (record.model_id and record.method):
record.name = ""
continue
record.name = self.job_function_name(record.model_id.model, record.method)
def _inverse_name(self):
groups = regex_job_function_name.match(self.name)
if not groups:
raise exceptions.UserError(_("Invalid job function: {}").format(self.name))
model_name = groups[1]
method = groups[2]
model = (
self.env["ir.model"].sudo().search([("model", "=", model_name)], limit=1)
)
if not model:
raise exceptions.UserError(_("Model {} not found").format(model_name))
self.model_id = model.id
self.method = method
@api.depends("retry_pattern")
def _compute_edit_retry_pattern(self):
for record in self:
retry_pattern = record._parse_retry_pattern()
record.edit_retry_pattern = str(retry_pattern)
def _inverse_edit_retry_pattern(self):
try:
edited = (self.edit_retry_pattern or "").strip()
if edited:
self.retry_pattern = ast.literal_eval(edited)
else:
self.retry_pattern = {}
except (ValueError, TypeError, SyntaxError) as ex:
raise exceptions.UserError(
self._retry_pattern_format_error_message()
) from ex
@api.depends("related_action")
def _compute_edit_related_action(self):
for record in self:
record.edit_related_action = str(record.related_action)
def _inverse_edit_related_action(self):
try:
edited = (self.edit_related_action or "").strip()
if edited:
self.related_action = ast.literal_eval(edited)
else:
self.related_action = {}
except (ValueError, TypeError, SyntaxError) as ex:
raise exceptions.UserError(
self._related_action_format_error_message()
) from ex
@staticmethod
def job_function_name(model_name, method_name):
return "<{}>.{}".format(model_name, method_name)
def job_default_config(self):
return self.JobConfig(
channel="root",
retry_pattern={},
related_action_enable=True,
related_action_func_name=None,
related_action_kwargs={},
job_function_id=None,
)
def _parse_retry_pattern(self):
try:
# as json can't have integers as keys and the field is stored
# as json, convert back to int
retry_pattern = {}
for try_count, postpone_value in self.retry_pattern.items():
if isinstance(postpone_value, int):
retry_pattern[int(try_count)] = postpone_value
else:
retry_pattern[int(try_count)] = tuple(postpone_value)
except ValueError:
_logger.error(
"Invalid retry pattern for job function %s,"
" keys could not be parsed as integers, fallback"
" to the default retry pattern.",
self.name,
)
retry_pattern = {}
return retry_pattern
@tools.ormcache("name")
def job_config(self, name):
config = self.search([("name", "=", name)], limit=1)
if not config:
return self.job_default_config()
retry_pattern = config._parse_retry_pattern()
return self.JobConfig(
channel=config.channel,
retry_pattern=retry_pattern,
related_action_enable=config.related_action.get("enable", True),
related_action_func_name=config.related_action.get("func_name"),
related_action_kwargs=config.related_action.get("kwargs", {}),
job_function_id=config.id,
)
def _retry_pattern_format_error_message(self):
return _(
"Unexpected format of Retry Pattern for {}.\n"
"Example of valid formats:\n"
"{{1: 300, 5: 600, 10: 1200, 15: 3000}}\n"
"{{1: (1, 10), 5: (11, 20), 10: (21, 30), 15: (100, 300)}}"
).format(self.name)
@api.constrains("retry_pattern")
def _check_retry_pattern(self):
for record in self:
retry_pattern = record.retry_pattern
if not retry_pattern:
continue
all_values = list(retry_pattern) + list(retry_pattern.values())
for value in all_values:
try:
self._retry_value_type_check(value)
except ValueError as ex:
raise exceptions.UserError(
record._retry_pattern_format_error_message()
) from ex
def _retry_value_type_check(self, value):
if isinstance(value, (tuple, list)):
if len(value) != 2:
raise ValueError
[self._retry_value_type_check(element) for element in value]
return
int(value)
def _related_action_format_error_message(self):
return _(
"Unexpected format of Related Action for {}.\n"
"Example of valid format:\n"
'{{"enable": True, "func_name": "related_action_foo",'
' "kwargs" {{"limit": 10}}}}'
).format(self.name)
@api.constrains("related_action")
def _check_related_action(self):
valid_keys = ("enable", "func_name", "kwargs")
for record in self:
related_action = record.related_action
if not related_action:
continue
if any(key not in valid_keys for key in related_action):
raise exceptions.UserError(
record._related_action_format_error_message()
)
@api.model_create_multi
def create(self, vals_list):
records = self.browse()
if self.env.context.get("install_mode"):
# installing a module that creates a job function: rebinds the record
# to an existing one (likely we already had the job function created by
# the @job decorator previously)
new_vals_list = []
for vals in vals_list:
name = vals.get("name")
if name:
existing = self.search([("name", "=", name)], limit=1)
if existing:
if not existing.get_metadata()[0].get("noupdate"):
existing.write(vals)
records |= existing
continue
new_vals_list.append(vals)
vals_list = new_vals_list
records |= super().create(vals_list)
self.clear_caches()
return records
def write(self, values):
res = super().write(values)
self.clear_caches()
return res
def unlink(self):
res = super().unlink()
self.clear_caches()
return res

View file

@ -0,0 +1,16 @@
# Copyright 2025 ACSONE SA/NV
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
from odoo import fields, models
class QueueJobLock(models.Model):
_name = "queue.job.lock"
_description = "Queue Job Lock"
queue_job_id = fields.Many2one(
comodel_name="queue.job",
required=True,
ondelete="cascade",
index=True,
)