[ADD] base modules

This commit is contained in:
Muhammad
2024-04-07 12:43:39 +05:00
parent 311598a929
commit fa3d921e2d
276 changed files with 51186 additions and 0 deletions

384
queue_job/README.rst Executable file
View File

@@ -0,0 +1,384 @@
=========
Job Queue
=========
.. !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! This file is generated by oca-gen-addon-readme !!
!! changes will be overwritten. !!
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
.. |badge1| image:: https://img.shields.io/badge/maturity-Mature-brightgreen.png
:target: https://odoo-community.org/page/development-status
:alt: Mature
.. |badge2| image:: https://img.shields.io/badge/licence-LGPL--3-blue.png
:target: http://www.gnu.org/licenses/lgpl-3.0-standalone.html
:alt: License: LGPL-3
.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fqueue-lightgray.png?logo=github
:target: https://github.com/OCA/queue/tree/14.0/queue_job
:alt: OCA/queue
.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png
:target: https://translation.odoo-community.org/projects/queue-14-0/queue-14-0-queue_job
:alt: Translate me on Weblate
.. |badge5| image:: https://img.shields.io/badge/runbot-Try%20me-875A7B.png
:target: https://runbot.odoo-community.org/runbot/230/14.0
:alt: Try me on Runbot
|badge1| |badge2| |badge3| |badge4| |badge5|
This addon adds an integrated Job Queue to Odoo.
It allows to postpone method calls executed asynchronously.
Jobs are executed in the background by a ``Jobrunner``, in their own transaction.
Example:
.. code-block:: python
from odoo import models, fields, api
class MyModel(models.Model):
_name = 'my.model'
def my_method(self, a, k=None):
_logger.info('executed with a: %s and k: %s', a, k)
class MyOtherModel(models.Model):
_name = 'my.other.model'
def button_do_stuff(self):
self.env['my.model'].with_delay().my_method('a', k=2)
In the snippet of code above, when we call ``button_do_stuff``, a job **capturing
the method and arguments** will be postponed. It will be executed as soon as the
Jobrunner has a free bucket, which can be instantaneous if no other job is
running.
Features:
* Views for jobs, jobs are stored in PostgreSQL
* Jobrunner: execute the jobs, highly efficient thanks to PostgreSQL's NOTIFY
* Channels: give a capacity for the root channel and its sub-channels and
segregate jobs in them. Allow for instance to restrict heavy jobs to be
executed one at a time while little ones are executed 4 at a times.
* Retries: Ability to retry jobs by raising a type of exception
* Retry Pattern: the 3 first tries, retry after 10 seconds, the 5 next tries,
retry after 1 minutes, ...
* Job properties: priorities, estimated time of arrival (ETA), custom
description, number of retries
* Related Actions: link an action on the job view, such as open the record
concerned by the job
**Table of contents**
.. contents::
:local:
Installation
============
Be sure to have the ``requests`` library.
Configuration
=============
* Using environment variables and command line:
* Adjust environment variables (optional):
- ``ODOO_QUEUE_JOB_CHANNELS=root:4`` or any other channels configuration.
The default is ``root:1``
- if ``xmlrpc_port`` is not set: ``ODOO_QUEUE_JOB_PORT=8069``
* Start Odoo with ``--load=web,queue_job``
and ``--workers`` greater than 1. [1]_
* Using the Odoo configuration file:
.. code-block:: ini
[options]
(...)
workers = 6
server_wide_modules = web,queue_job
(...)
[queue_job]
channels = root:2
* Confirm the runner is starting correctly by checking the odoo log file:
.. code-block::
...INFO...queue_job.jobrunner.runner: starting
...INFO...queue_job.jobrunner.runner: initializing database connections
...INFO...queue_job.jobrunner.runner: queue job runner ready for db <dbname>
...INFO...queue_job.jobrunner.runner: database connections ready
* Create jobs (eg using ``base_import_async``) and observe they
start immediately and in parallel.
* Tip: to enable debug logging for the queue job, use
``--log-handler=odoo.addons.queue_job:DEBUG``
.. [1] It works with the threaded Odoo server too, although this way
of running Odoo is obviously not for production purposes.
Usage
=====
To use this module, you need to:
#. Go to ``Job Queue`` menu
Developers
~~~~~~~~~~
**Configure default options for jobs**
In earlier versions, jobs could be configured using the ``@job`` decorator.
This is now obsolete, they can be configured using optional ``queue.job.function``
and ``queue.job.channel`` XML records.
Example of channel:
.. code-block:: XML
<record id="channel_sale" model="queue.job.channel">
<field name="name">sale</field>
<field name="parent_id" ref="queue_job.channel_root" />
</record>
Example of job function:
.. code-block:: XML
<record id="job_function_sale_order_action_done" model="queue.job.function">
<field name="model_id" ref="sale.model_sale_order"</field>
<field name="method">action_done</field>
<field name="channel_id" ref="channel_sale" />
<field name="related_action" eval='{"func_name": "custom_related_action"}' />
<field name="retry_pattern" eval="{1: 60, 2: 180, 3: 10, 5: 300}" />
</record>
The general form for the ``name`` is: ``<model.name>.method``.
The channel, related action and retry pattern options are optional, they are
documented below.
When writing modules, if 2+ modules add a job function or channel with the same
name (and parent for channels), they'll be merged in the same record, even if
they have different xmlids. On uninstall, the merged record is deleted when all
the modules using it are uninstalled.
**Job function: channel**
The channel where the job will be delayed. The default channel is ``root``.
**Job function: related action**
The *Related Action* appears as a button on the Job's view.
The button will execute the defined action.
The default one is to open the view of the record related to the job (form view
when there is a single record, list view for several records).
In many cases, the default related action is enough and doesn't need
customization, but it can be customized by providing a dictionary on the job
function:
.. code-block:: python
{
"enable": False,
"func_name": "related_action_partner",
"kwargs": {"name": "Partner"},
}
* ``enable``: when ``False``, the button has no effect (default: ``True``)
* ``func_name``: name of the method on ``queue.job`` that returns an action
* ``kwargs``: extra arguments to pass to the related action method
Example of related action code:
.. code-block:: python
class QueueJob(models.Model):
_inherit = 'queue.job'
def related_action_partner(self, name):
self.ensure_one()
model = self.model_name
partner = self.records
action = {
'name': name,
'type': 'ir.actions.act_window',
'res_model': model,
'view_type': 'form',
'view_mode': 'form',
'res_id': partner.id,
}
return action
**Job function: retry pattern**
When a job fails with a retryable error type, it is automatically
retried later. By default, the retry is always 10 minutes later.
A retry pattern can be configured on the job function. What a pattern represents
is "from X tries, postpone to Y seconds". It is expressed as a dictionary where
keys are tries and values are seconds to postpone as integers:
.. code-block:: python
{
1: 10,
5: 20,
10: 30,
15: 300,
}
Based on this configuration, we can tell that:
* 5 first retries are postponed 10 seconds later
* retries 5 to 10 postponed 20 seconds later
* retries 10 to 15 postponed 30 seconds later
* all subsequent retries postponed 5 minutes later
**Bypass jobs on running Odoo**
When you are developing (ie: connector modules) you might want
to bypass the queue job and run your code immediately.
To do so you can set `TEST_QUEUE_JOB_NO_DELAY=1` in your enviroment.
**Bypass jobs in tests**
When writing tests on job-related methods is always tricky to deal with
delayed recordsets. To make your testing life easier
you can set `test_queue_job_no_delay=True` in the context.
Tip: you can do this at test case level like this
.. code-block:: python
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.env = cls.env(context=dict(
cls.env.context,
test_queue_job_no_delay=True, # no jobs thanks
))
Then all your tests execute the job methods synchronously
without delaying any jobs.
Known issues / Roadmap
======================
* After creating a new database or installing ``queue_job`` on an
existing database, Odoo must be restarted for the runner to detect it.
* When Odoo shuts down normally, it waits for running jobs to finish.
However, when the Odoo server crashes or is otherwise force-stopped,
running jobs are interrupted while the runner has no chance to know
they have been aborted. In such situations, jobs may remain in
``started`` or ``enqueued`` state after the Odoo server is halted.
Since the runner has no way to know if they are actually running or
not, and does not know for sure if it is safe to restart the jobs,
it does not attempt to restart them automatically. Such stale jobs
therefore fill the running queue and prevent other jobs to start.
You must therefore requeue them manually, either from the Jobs view,
or by running the following SQL statement *before starting Odoo*:
.. code-block:: sql
update queue_job set state='pending' where state in ('started', 'enqueued')
Changelog
=========
.. [ The change log. The goal of this file is to help readers
understand changes between version. The primary audience is
end users and integrators. Purely technical changes such as
code refactoring must not be mentioned here.
This file may contain ONE level of section titles, underlined
with the ~ (tilde) character. Other section markers are
forbidden and will likely break the structure of the README.rst
or other documents where this fragment is included. ]
Next
~~~~
* [ADD] Run jobrunner as a worker process instead of a thread in the main
process (when running with --workers > 0)
* [REF] ``@job`` and ``@related_action`` deprecated, any method can be delayed,
and configured using ``queue.job.function`` records
* [MIGRATION] from 13.0 branched at rev. e24ff4b
Bug Tracker
===========
Bugs are tracked on `GitHub Issues <https://github.com/OCA/queue/issues>`_.
In case of trouble, please check there if your issue has already been reported.
If you spotted it first, help us smashing it by providing a detailed and welcomed
`feedback <https://github.com/OCA/queue/issues/new?body=module:%20queue_job%0Aversion:%2014.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**>`_.
Do not contact contributors directly about support or help with technical issues.
Credits
=======
Authors
~~~~~~~
* Camptocamp
* ACSONE SA/NV
Contributors
~~~~~~~~~~~~
* Guewen Baconnier <guewen.baconnier@camptocamp.com>
* Stéphane Bidoul <stephane.bidoul@acsone.eu>
* Matthieu Dietrich <matthieu.dietrich@camptocamp.com>
* Jos De Graeve <Jos.DeGraeve@apertoso.be>
* David Lefever <dl@taktik.be>
* Laurent Mignon <laurent.mignon@acsone.eu>
* Laetitia Gangloff <laetitia.gangloff@acsone.eu>
* Cédric Pigeon <cedric.pigeon@acsone.eu>
* Tatiana Deribina <tatiana.deribina@avoin.systems>
* Souheil Bejaoui <souheil.bejaoui@acsone.eu>
* Eric Antones <eantones@nuobit.com>
Maintainers
~~~~~~~~~~~
This module is maintained by the OCA.
.. image:: https://odoo-community.org/logo.png
:alt: Odoo Community Association
:target: https://odoo-community.org
OCA, or the Odoo Community Association, is a nonprofit organization whose
mission is to support the collaborative development of Odoo features and
promote its widespread use.
.. |maintainer-guewen| image:: https://github.com/guewen.png?size=40px
:target: https://github.com/guewen
:alt: guewen
Current `maintainer <https://odoo-community.org/page/maintainer-role>`__:
|maintainer-guewen|
This module is part of the `OCA/queue <https://github.com/OCA/queue/tree/14.0/queue_job>`_ project on GitHub.
You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

9
queue_job/__init__.py Executable file
View File

@@ -0,0 +1,9 @@
from . import controllers
from . import fields
from . import models
from . import wizards
from . import jobrunner
from .post_init_hook import post_init_hook
# shortcuts
from .job import identity_exact

29
queue_job/__manifest__.py Executable file
View File

@@ -0,0 +1,29 @@
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html)
{
"name": "Job Queue",
"version": "14.0.1.3.1",
"author": "Camptocamp,ACSONE SA/NV,Odoo Community Association (OCA)",
"website": "https://github.com/OCA/queue",
"license": "LGPL-3",
"category": "Generic Modules",
"depends": ["mail"],
"external_dependencies": {"python": ["requests"]},
"data": [
"security/security.xml",
"security/ir.model.access.csv",
"views/queue_job_views.xml",
"views/queue_job_channel_views.xml",
"views/queue_job_function_views.xml",
"wizards/queue_jobs_to_done_views.xml",
"wizards/queue_requeue_job_views.xml",
"views/queue_job_menus.xml",
"data/queue_data.xml",
"data/queue_job_function_data.xml",
],
"installable": True,
"development_status": "Mature",
"maintainers": ["guewen"],
"post_init_hook": "post_init_hook",
}

View File

@@ -0,0 +1 @@
from . import main

144
queue_job/controllers/main.py Executable file
View File

@@ -0,0 +1,144 @@
# Copyright (c) 2015-2016 ACSONE SA/NV (<http://acsone.eu>)
# Copyright 2013-2016 Camptocamp SA
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html)
import logging
import traceback
from io import StringIO
from psycopg2 import OperationalError
from werkzeug.exceptions import Forbidden
import odoo
from odoo import _, http, tools
from odoo.service.model import PG_CONCURRENCY_ERRORS_TO_RETRY
from ..exception import FailedJobError, NothingToDoJob, RetryableJobError
from ..job import ENQUEUED, Job
_logger = logging.getLogger(__name__)
PG_RETRY = 5 # seconds
class RunJobController(http.Controller):
def _try_perform_job(self, env, job):
"""Try to perform the job."""
job.set_started()
job.store()
env.cr.commit()
_logger.debug("%s started", job)
job.perform()
job.set_done()
job.store()
env["base"].flush()
env.cr.commit()
_logger.debug("%s done", job)
@http.route("/queue_job/runjob", type="http", auth="none", save_session=False)
def runjob(self, db, job_uuid, **kw):
http.request.session.db = db
env = http.request.env(user=odoo.SUPERUSER_ID)
def retry_postpone(job, message, seconds=None):
job.env.clear()
with odoo.api.Environment.manage():
with odoo.registry(job.env.cr.dbname).cursor() as new_cr:
job.env = job.env(cr=new_cr)
job.postpone(result=message, seconds=seconds)
job.set_pending(reset_retry=False)
job.store()
new_cr.commit()
# ensure the job to run is in the correct state and lock the record
env.cr.execute(
"SELECT state FROM queue_job WHERE uuid=%s AND state=%s FOR UPDATE",
(job_uuid, ENQUEUED),
)
if not env.cr.fetchone():
_logger.warning(
"was requested to run job %s, but it does not exist, "
"or is not in state %s",
job_uuid,
ENQUEUED,
)
return ""
job = Job.load(env, job_uuid)
assert job and job.state == ENQUEUED
try:
try:
self._try_perform_job(env, job)
except OperationalError as err:
# Automatically retry the typical transaction serialization
# errors
if err.pgcode not in PG_CONCURRENCY_ERRORS_TO_RETRY:
raise
retry_postpone(
job, tools.ustr(err.pgerror, errors="replace"), seconds=PG_RETRY
)
_logger.debug("%s OperationalError, postponed", job)
except NothingToDoJob as err:
if str(err):
msg = str(err)
else:
msg = _("Job interrupted and set to Done: nothing to do.")
job.set_done(msg)
job.store()
env.cr.commit()
except RetryableJobError as err:
# delay the job later, requeue
retry_postpone(job, str(err), seconds=err.seconds)
_logger.debug("%s postponed", job)
except (FailedJobError, Exception):
buff = StringIO()
traceback.print_exc(file=buff)
_logger.error(buff.getvalue())
job.env.clear()
with odoo.api.Environment.manage():
with odoo.registry(job.env.cr.dbname).cursor() as new_cr:
job.env = job.env(cr=new_cr)
job.set_failed(exc_info=buff.getvalue())
job.store()
new_cr.commit()
raise
return ""
@http.route("/queue_job/create_test_job", type="http", auth="user")
def create_test_job(
self, priority=None, max_retries=None, channel="root", description="Test job"
):
if not http.request.env.user.has_group("base.group_erp_manager"):
raise Forbidden(_("Access Denied"))
if priority is not None:
try:
priority = int(priority)
except ValueError:
priority = None
if max_retries is not None:
try:
max_retries = int(max_retries)
except ValueError:
max_retries = None
delayed = (
http.request.env["queue.job"]
.with_delay(
priority=priority,
max_retries=max_retries,
channel=channel,
description=description,
)
._test_job()
)
return delayed.db_record().uuid

37
queue_job/data/queue_data.xml Executable file
View File

@@ -0,0 +1,37 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<data noupdate="1">
<record id="ir_cron_queue_job_garbage_collector" model="ir.cron">
<field name="name">Jobs Garbage Collector</field>
<field name="interval_number">5</field>
<field name="interval_type">minutes</field>
<field name="numbercall">-1</field>
<field ref="model_queue_job" name="model_id" />
<field name="state">code</field>
<field name="code">model.requeue_stuck_jobs()</field>
</record>
<!-- Queue-job-related subtypes for messaging / Chatter -->
<record id="mt_job_failed" model="mail.message.subtype">
<field name="name">Job failed</field>
<field name="res_model">queue.job</field>
<field name="default" eval="True" />
</record>
<record id="ir_cron_autovacuum_queue_jobs" model="ir.cron">
<field name="name">AutoVacuum Job Queue</field>
<field ref="model_queue_job" name="model_id" />
<field eval="True" name="active" />
<field name="user_id" ref="base.user_root" />
<field name="interval_number">1</field>
<field name="interval_type">days</field>
<field name="numbercall">-1</field>
<field eval="False" name="doall" />
<field name="state">code</field>
<field name="code">model.autovacuum()</field>
</record>
</data>
<data noupdate="0">
<record model="queue.job.channel" id="channel_root">
<field name="name">root</field>
</record>
</data>
</odoo>

View File

@@ -0,0 +1,6 @@
<odoo noupdate="1">
<record id="job_function_queue_job__test_job" model="queue.job.function">
<field name="model_id" ref="queue_job.model_queue_job" />
<field name="method">_test_job</field>
</record>
</odoo>

43
queue_job/exception.py Executable file
View File

@@ -0,0 +1,43 @@
# Copyright 2012-2016 Camptocamp
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html)
class BaseQueueJobError(Exception):
"""Base queue job error"""
class JobError(BaseQueueJobError):
"""A job had an error"""
class NoSuchJobError(JobError):
"""The job does not exist."""
class FailedJobError(JobError):
"""A job had an error having to be resolved."""
class RetryableJobError(JobError):
"""A job had an error but can be retried.
The job will be retried after the given number of seconds. If seconds is
empty, it will be retried according to the ``retry_pattern`` of the job or
by :const:`odoo.addons.queue_job.job.RETRY_INTERVAL` if nothing is defined.
If ``ignore_retry`` is True, the retry counter will not be increased.
"""
def __init__(self, msg, seconds=None, ignore_retry=False):
super().__init__(msg)
self.seconds = seconds
self.ignore_retry = ignore_retry
# TODO: remove support of NothingToDo: too dangerous
class NothingToDoJob(JobError):
"""The Job has nothing to do."""
class ChannelNotFound(BaseQueueJobError):
"""A channel could not be found"""

118
queue_job/fields.py Executable file
View File

@@ -0,0 +1,118 @@
# copyright 2016 Camptocamp
# license lgpl-3.0 or later (http://www.gnu.org/licenses/lgpl.html)
import json
from datetime import date, datetime
import dateutil
import lxml
from odoo import fields, models
from odoo.tools.func import lazy
class JobSerialized(fields.Field):
"""Provide the storage for job fields stored as json
A base_type must be set, it must be dict, list or tuple.
When the field is not set, the json will be the corresponding
json string ("{}" or "[]").
Support for some custom types has been added to the json decoder/encoder
(see JobEncoder and JobDecoder).
"""
type = "job_serialized"
column_type = ("text", "text")
_base_type = None
# these are the default values when we convert an empty value
_default_json_mapping = {
dict: "{}",
list: "[]",
tuple: "[]",
models.BaseModel: lambda env: json.dumps(
{"_type": "odoo_recordset", "model": "base", "ids": [], "uid": env.uid}
),
}
def __init__(self, string=fields.Default, base_type=fields.Default, **kwargs):
super().__init__(string=string, _base_type=base_type, **kwargs)
def _setup_attrs(self, model, name):
super()._setup_attrs(model, name)
if self._base_type not in self._default_json_mapping:
raise ValueError("%s is not a supported base type" % (self._base_type))
def _base_type_default_json(self, env):
default_json = self._default_json_mapping.get(self._base_type)
if not isinstance(default_json, str):
default_json = default_json(env)
return default_json
def convert_to_column(self, value, record, values=None, validate=True):
return self.convert_to_cache(value, record, validate=validate)
def convert_to_cache(self, value, record, validate=True):
# cache format: json.dumps(value) or None
if isinstance(value, self._base_type):
return json.dumps(value, cls=JobEncoder)
else:
return value or None
def convert_to_record(self, value, record):
default = self._base_type_default_json(record.env)
return json.loads(value or default, cls=JobDecoder, env=record.env)
class JobEncoder(json.JSONEncoder):
"""Encode Odoo recordsets so that we can later recompose them"""
def default(self, obj):
if isinstance(obj, models.BaseModel):
return {
"_type": "odoo_recordset",
"model": obj._name,
"ids": obj.ids,
"uid": obj.env.uid,
"su": obj.env.su,
}
elif isinstance(obj, datetime):
return {"_type": "datetime_isoformat", "value": obj.isoformat()}
elif isinstance(obj, date):
return {"_type": "date_isoformat", "value": obj.isoformat()}
elif isinstance(obj, lxml.etree._Element):
return {
"_type": "etree_element",
"value": lxml.etree.tostring(obj, encoding=str),
}
elif isinstance(obj, lazy):
return obj._value
return json.JSONEncoder.default(self, obj)
class JobDecoder(json.JSONDecoder):
"""Decode json, recomposing recordsets"""
def __init__(self, *args, **kwargs):
env = kwargs.pop("env")
super().__init__(object_hook=self.object_hook, *args, **kwargs)
assert env
self.env = env
def object_hook(self, obj):
if "_type" not in obj:
return obj
type_ = obj["_type"]
if type_ == "odoo_recordset":
model = self.env(user=obj.get("uid"), su=obj.get("su"))[obj["model"]]
return model.browse(obj["ids"])
elif type_ == "datetime_isoformat":
return dateutil.parser.parse(obj["value"])
elif type_ == "date_isoformat":
return dateutil.parser.parse(obj["value"]).date()
elif type_ == "etree_element":
return lxml.etree.fromstring(obj["value"])
return obj

826
queue_job/i18n/de.po Executable file
View File

@@ -0,0 +1,826 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * queue_job
#
msgid ""
msgstr ""
"Project-Id-Version: Odoo Server 12.0\n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2020-07-22 12:20+0000\n"
"Last-Translator: c2cdidier <didier.donze@camptocamp.com>\n"
"Language-Team: none\n"
"Language: de\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: \n"
"Plural-Forms: nplurals=2; plural=n != 1;\n"
"X-Generator: Weblate 3.10\n"
#. module: queue_job
#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_form
msgid ""
"<span class=\"oe_grey oe_inline\"> If the max. retries is 0, the number of "
"retries is infinite.</span>"
msgstr ""
"<span class=\"oe_grey oe_inline\">Wenn die maximale Anzahl der Wiederholung "
"auf 0 gesetzt ist, wird dies als unendlich interpretiert.</span>"
#. module: queue_job
#: code:addons/queue_job/controllers/main.py:0
#, python-format
msgid "Access Denied"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__message_needaction
msgid "Action Needed"
msgstr "Aktion notwendig"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__activity_ids
msgid "Activities"
msgstr "Aktivitäten"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__activity_exception_decoration
#, fuzzy
msgid "Activity Exception Decoration"
msgstr "Exception-Information"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__activity_state
msgid "Activity State"
msgstr "Aktivitätsstatus"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__activity_type_icon
msgid "Activity Type Icon"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__args
msgid "Args"
msgstr "Argumente"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__message_attachment_count
msgid "Attachment Count"
msgstr "Anzahl der Anhänge"
#. module: queue_job
#: model:ir.actions.server,name:queue_job.ir_cron_autovacuum_queue_jobs_ir_actions_server
#: model:ir.cron,cron_name:queue_job.ir_cron_autovacuum_queue_jobs
#: model:ir.cron,name:queue_job.ir_cron_autovacuum_queue_jobs
msgid "AutoVacuum Job Queue"
msgstr "AutoVacuum für Job-Warteschlange"
#. module: queue_job
#: model:ir.model,name:queue_job.model_base
msgid "Base"
msgstr "Basis"
#. module: queue_job
#: model_terms:ir.ui.view,arch_db:queue_job.view_requeue_job
#: model_terms:ir.ui.view,arch_db:queue_job.view_set_jobs_done
msgid "Cancel"
msgstr "Abbrechen"
#. module: queue_job
#: code:addons/queue_job/models/queue_job_channel.py:0
#, python-format
msgid "Cannot change the root channel"
msgstr "Der Root-Kanal kann nicht geändert werden"
#. module: queue_job
#: code:addons/queue_job/models/queue_job_channel.py:0
#, python-format
msgid "Cannot remove the root channel"
msgstr "Der Root-Kanal kann nicht entfernt werden"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__channel
#: model:ir.model.fields,field_description:queue_job.field_queue_job_function__channel_id
#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_function_search
#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_search
msgid "Channel"
msgstr "Kanal"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__channel_method_name
msgid "Channel Method Name"
msgstr "Kanal-Methodenname"
#. module: queue_job
#: model:ir.model.constraint,message:queue_job.constraint_queue_job_channel_name_uniq
msgid "Channel complete name must be unique"
msgstr "Der vollständige Name des Kanals muss eindeutig sein"
#. module: queue_job
#: model:ir.actions.act_window,name:queue_job.action_queue_job_channel
#: model:ir.ui.menu,name:queue_job.menu_queue_job_channel
#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_channel_form
#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_channel_search
msgid "Channels"
msgstr "Kanäle"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__company_id
msgid "Company"
msgstr "Unternehmen"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job_channel__complete_name
#: model:ir.model.fields,field_description:queue_job.field_queue_job_function__channel
msgid "Complete Name"
msgstr "Vollständiger Name"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__date_created
msgid "Created Date"
msgstr "Erstellt am"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job_channel__create_uid
#: model:ir.model.fields,field_description:queue_job.field_queue_jobs_to_done__create_uid
#: model:ir.model.fields,field_description:queue_job.field_queue_requeue_job__create_uid
msgid "Created by"
msgstr "Erstellt von"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job_channel__create_date
#: model:ir.model.fields,field_description:queue_job.field_queue_jobs_to_done__create_date
#: model:ir.model.fields,field_description:queue_job.field_queue_requeue_job__create_date
msgid "Created on"
msgstr "Erstellt am"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__retry
msgid "Current try"
msgstr "Aktueller Versuch"
#. module: queue_job
#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_form
msgid "Current try / max. retries"
msgstr "Aktueller Versuch / max. Anzahl der Wiederholung"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__date_done
msgid "Date Done"
msgstr "Erledigt am"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__name
msgid "Description"
msgstr "Beschreibung"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_ir_model_fields__display_name
#: model:ir.model.fields,field_description:queue_job.field_queue_job__display_name
#: model:ir.model.fields,field_description:queue_job.field_queue_job_channel__display_name
#: model:ir.model.fields,field_description:queue_job.field_queue_job_function__display_name
#: model:ir.model.fields,field_description:queue_job.field_queue_jobs_to_done__display_name
#: model:ir.model.fields,field_description:queue_job.field_queue_requeue_job__display_name
msgid "Display Name"
msgstr "Anzeigename"
#. module: queue_job
#: model:ir.model.fields.selection,name:queue_job.selection__queue_job__state__done
#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_search
msgid "Done"
msgstr "Erledigt"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__date_enqueued
msgid "Enqueue Time"
msgstr "Zeit der Einreihung"
#. module: queue_job
#: model:ir.model.fields.selection,name:queue_job.selection__queue_job__state__enqueued
#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_search
msgid "Enqueued"
msgstr "Eingereiht"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__exc_info
msgid "Exception Info"
msgstr "Exception-Info"
#. module: queue_job
#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_form
msgid "Exception Information"
msgstr "Exception-Information"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__eta
msgid "Execute only after"
msgstr "Erst ausführen nach"
#. module: queue_job
#: model:ir.model.fields.selection,name:queue_job.selection__queue_job__state__failed
#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_search
msgid "Failed"
msgstr "Fehlgeschlagen"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_ir_model_fields__ttype
msgid "Field Type"
msgstr ""
#. module: queue_job
#: model:ir.model,name:queue_job.model_ir_model_fields
msgid "Fields"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__message_follower_ids
msgid "Followers"
msgstr "Abonnenten"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__message_channel_ids
msgid "Followers (Channels)"
msgstr "Abonnenten (Kanäle)"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__message_partner_ids
msgid "Followers (Partners)"
msgstr "Abonnenten (Partner)"
#. module: queue_job
#: model:ir.model.fields,help:queue_job.field_queue_job__activity_type_icon
msgid "Font awesome icon e.g. fa-tasks"
msgstr ""
#. module: queue_job
#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_function_search
#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_search
msgid "Group By"
msgstr "Gruppieren nach"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_ir_model_fields__id
#: model:ir.model.fields,field_description:queue_job.field_queue_job__id
#: model:ir.model.fields,field_description:queue_job.field_queue_job_channel__id
#: model:ir.model.fields,field_description:queue_job.field_queue_job_function__id
#: model:ir.model.fields,field_description:queue_job.field_queue_jobs_to_done__id
#: model:ir.model.fields,field_description:queue_job.field_queue_requeue_job__id
msgid "ID"
msgstr "ID"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__activity_exception_icon
msgid "Icon"
msgstr "Symbol"
#. module: queue_job
#: model:ir.model.fields,help:queue_job.field_queue_job__activity_exception_icon
msgid "Icon to indicate an exception activity."
msgstr "Symbol zur Kennzeichnung einer Ausnahmeaktivität."
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__identity_key
msgid "Identity Key"
msgstr "Identitätsschlüssel"
#. module: queue_job
#: code:addons/queue_job/models/queue_job.py:0
#, fuzzy, python-format
msgid "If both parameters are 0, ALL jobs will be requeued!"
msgstr "Die ausgewählten Jobs werden erneut eingereiht."
#. module: queue_job
#: model:ir.model.fields,help:queue_job.field_queue_job__message_needaction
#: model:ir.model.fields,help:queue_job.field_queue_job__message_unread
msgid "If checked, new messages require your attention."
msgstr "Wenn es gesetzt ist, erfordern neue Nachrichten Ihre Aufmerksamkeit."
#. module: queue_job
#: model:ir.model.fields,help:queue_job.field_queue_job__message_has_error
#: model:ir.model.fields,help:queue_job.field_queue_job__message_has_sms_error
msgid "If checked, some messages have a delivery error."
msgstr ""
"Wenn es gesetzt ist, gibt es einige Nachrichten mit einem Übertragungsfehler."
#. module: queue_job
#: code:addons/queue_job/models/queue_job_function.py:0
#, python-format
msgid "Invalid job function: {}"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__message_is_follower
msgid "Is Follower"
msgstr "Ist Abonnent"
#. module: queue_job
#: model:ir.model,name:queue_job.model_queue_job_channel
msgid "Job Channels"
msgstr "Job-Kanäle"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__job_function_id
#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_search
msgid "Job Function"
msgstr "Job-Funktion"
#. module: queue_job
#: model:ir.actions.act_window,name:queue_job.action_queue_job_function
#: model:ir.model,name:queue_job.model_queue_job_function
#: model:ir.model.fields,field_description:queue_job.field_queue_job_channel__job_function_ids
#: model:ir.ui.menu,name:queue_job.menu_queue_job_function
#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_function_form
#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_function_search
msgid "Job Functions"
msgstr "Job-Funktionen"
#. module: queue_job
#: model:ir.module.category,name:queue_job.module_category_queue_job
#: model:ir.ui.menu,name:queue_job.menu_queue_job_root
msgid "Job Queue"
msgstr "Job-Warteschlange"
#. module: queue_job
#: model:res.groups,name:queue_job.group_queue_job_manager
msgid "Job Queue Manager"
msgstr "Job-Warteschlangenverwalter"
#. module: queue_job
#: model:ir.model.fields.selection,name:queue_job.selection__ir_model_fields__ttype__job_serialized
#, fuzzy
msgid "Job Serialized"
msgstr "Job ist fehlgeschlagen"
#. module: queue_job
#: model:mail.message.subtype,name:queue_job.mt_job_failed
msgid "Job failed"
msgstr "Job ist fehlgeschlagen"
#. module: queue_job
#: code:addons/queue_job/controllers/main.py:0
#, python-format
msgid "Job interrupted and set to Done: nothing to do."
msgstr "Job unterbrochen und als Erledigt markiert: Es ist nicht zu tun."
#. module: queue_job
#: model:ir.actions.act_window,name:queue_job.action_queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_jobs_to_done__job_ids
#: model:ir.model.fields,field_description:queue_job.field_queue_requeue_job__job_ids
#: model:ir.ui.menu,name:queue_job.menu_queue_job
#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_form
#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_search
msgid "Jobs"
msgstr "Jobs"
#. module: queue_job
#: model:ir.actions.server,name:queue_job.ir_cron_queue_job_garbage_collector_ir_actions_server
#: model:ir.cron,cron_name:queue_job.ir_cron_queue_job_garbage_collector
#: model:ir.cron,name:queue_job.ir_cron_queue_job_garbage_collector
msgid "Jobs Garbage Collector"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__kwargs
msgid "Kwargs"
msgstr "Kwargs"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_ir_model_fields____last_update
#: model:ir.model.fields,field_description:queue_job.field_queue_job____last_update
#: model:ir.model.fields,field_description:queue_job.field_queue_job_channel____last_update
#: model:ir.model.fields,field_description:queue_job.field_queue_job_function____last_update
#: model:ir.model.fields,field_description:queue_job.field_queue_jobs_to_done____last_update
#: model:ir.model.fields,field_description:queue_job.field_queue_requeue_job____last_update
msgid "Last Modified on"
msgstr "Zuletzt geändert am"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job_channel__write_uid
#: model:ir.model.fields,field_description:queue_job.field_queue_jobs_to_done__write_uid
#: model:ir.model.fields,field_description:queue_job.field_queue_requeue_job__write_uid
msgid "Last Updated by"
msgstr "Zuletzt aktualisiert von"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job_channel__write_date
#: model:ir.model.fields,field_description:queue_job.field_queue_jobs_to_done__write_date
#: model:ir.model.fields,field_description:queue_job.field_queue_requeue_job__write_date
msgid "Last Updated on"
msgstr "Zuletzt aktualisiert am"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__message_main_attachment_id
msgid "Main Attachment"
msgstr "Haupt-Anhang"
#. module: queue_job
#: code:addons/queue_job/models/queue_job.py:0
#, python-format
msgid "Manually set to done by %s"
msgstr "Manuell als Erledigt markiert von: %s"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__max_retries
msgid "Max. retries"
msgstr "max. Anzahl von Wiederholungen"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__message_has_error
msgid "Message Delivery error"
msgstr "Fehler bei Nachrichtenübermittlung"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__message_ids
msgid "Messages"
msgstr "Nachrichten"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job_function__method
#, fuzzy
msgid "Method"
msgstr "Methodenname"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__method_name
msgid "Method Name"
msgstr "Methodenname"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__model_name
#: model:ir.model.fields,field_description:queue_job.field_queue_job_function__model_id
msgid "Model"
msgstr "Modell"
#. module: queue_job
#: code:addons/queue_job/models/queue_job_function.py:0
#, python-format
msgid "Model {} not found"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job_channel__name
#: model:ir.model.fields,field_description:queue_job.field_queue_job_function__name
msgid "Name"
msgstr "Bezeichnung"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__activity_date_deadline
msgid "Next Activity Deadline"
msgstr "Fälligkeit der nächsten Aktivität"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__activity_summary
msgid "Next Activity Summary"
msgstr "Zusammenfassung der nächsten Aktivität"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__activity_type_id
msgid "Next Activity Type"
msgstr "Typ der nächsten Aktivität"
#. module: queue_job
#: code:addons/queue_job/models/queue_job.py:0
#, python-format
msgid "No action available for this job"
msgstr "Für diesen Job ist keine Aktion verfügbar"
#. module: queue_job
#: code:addons/queue_job/models/queue_job.py:0
#, python-format
msgid "Not allowed to change field(s): {}"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__message_needaction_counter
msgid "Number of Actions"
msgstr "Anzahl der Aktionen"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__message_has_error_counter
msgid "Number of errors"
msgstr "Anzahl der Fehler"
#. module: queue_job
#: model:ir.model.fields,help:queue_job.field_queue_job__message_needaction_counter
msgid "Number of messages which requires an action"
msgstr "Das ist die Anzahl von Nachrichten, die eine Aktion benötigen"
#. module: queue_job
#: model:ir.model.fields,help:queue_job.field_queue_job__message_has_error_counter
msgid "Number of messages with delivery error"
msgstr "Das ist die Anzahl von Nachrichten mit Übermittlungsfehler"
#. module: queue_job
#: model:ir.model.fields,help:queue_job.field_queue_job__message_unread_counter
msgid "Number of unread messages"
msgstr "Das ist die Anzahl von ungelesenen Nachrichten"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__override_channel
msgid "Override Channel"
msgstr "Kanal überschreiben"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job_channel__parent_id
msgid "Parent Channel"
msgstr "Übergeordneter Kanal"
#. module: queue_job
#: code:addons/queue_job/models/queue_job_channel.py:0
#, python-format
msgid "Parent channel required."
msgstr "Es ist ein übergeordneter Kanal notwendig."
#. module: queue_job
#: model:ir.model.fields,help:queue_job.field_queue_job_function__edit_retry_pattern
msgid ""
"Pattern expressing from the count of retries on retryable errors, the number "
"of of seconds to postpone the next execution.\n"
"Example: {1: 10, 5: 20, 10: 30, 15: 300}.\n"
"See the module description for details."
msgstr ""
#. module: queue_job
#: model:ir.model.fields.selection,name:queue_job.selection__queue_job__state__pending
#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_search
msgid "Pending"
msgstr "Ausstehend"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__priority
msgid "Priority"
msgstr "Priorität"
#. module: queue_job
#: model:ir.ui.menu,name:queue_job.menu_queue
msgid "Queue"
msgstr "Warteschlange"
#. module: queue_job
#: model:ir.model,name:queue_job.model_queue_job
msgid "Queue Job"
msgstr "Job einreihen"
#. module: queue_job
#: code:addons/queue_job/models/queue_job.py:0
#, python-format
msgid "Queue jobs must created by calling 'with_delay()'."
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__record_ids
msgid "Record"
msgstr "Datensatz"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__records
#, fuzzy
msgid "Record(s)"
msgstr "Datensatz"
#. module: queue_job
#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_form
msgid "Related"
msgstr "Zugehörige Aktion anzeigen"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job_function__edit_related_action
#, fuzzy
msgid "Related Action"
msgstr "Zugehöriger Datensatz"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job_function__related_action
msgid "Related Action (serialized)"
msgstr ""
#. module: queue_job
#: code:addons/queue_job/models/queue_job.py:0
#, python-format
msgid "Related Record"
msgstr "Zugehöriger Datensatz"
#. module: queue_job
#: code:addons/queue_job/models/queue_job.py:0
#, python-format
msgid "Related Records"
msgstr "Zugehörige Datensätze"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job_channel__removal_interval
msgid "Removal Interval"
msgstr "Entfernungsintervall"
#. module: queue_job
#: model_terms:ir.ui.view,arch_db:queue_job.view_requeue_job
msgid "Requeue"
msgstr "Erneut einreihen"
#. module: queue_job
#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_form
msgid "Requeue Job"
msgstr "Job erneut einreihen"
#. module: queue_job
#: model:ir.actions.act_window,name:queue_job.action_requeue_job
#: model_terms:ir.ui.view,arch_db:queue_job.view_requeue_job
msgid "Requeue Jobs"
msgstr "Jobs erneut einreihen"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__activity_user_id
msgid "Responsible User"
msgstr "Verantwortlicher Benutzer"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__result
#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_form
msgid "Result"
msgstr "Ergebnis"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job_function__edit_retry_pattern
msgid "Retry Pattern"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job_function__retry_pattern
msgid "Retry Pattern (serialized)"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__message_has_sms_error
msgid "SMS Delivery error"
msgstr "Fehler bei der SMS Nachrichtenübermittlung"
#. module: queue_job
#: model:ir.model,name:queue_job.model_queue_jobs_to_done
msgid "Set all selected jobs to done"
msgstr "Alle ausgewählten Jobs als Erledigt markieren"
#. module: queue_job
#: model_terms:ir.ui.view,arch_db:queue_job.view_set_jobs_done
msgid "Set jobs done"
msgstr "Jobs als Erledigt markieren"
#. module: queue_job
#: model:ir.actions.act_window,name:queue_job.action_set_jobs_done
msgid "Set jobs to done"
msgstr "Jobs als Erledigt markieren"
#. module: queue_job
#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_form
msgid "Set to 'Done'"
msgstr "Als Erledigt markieren"
#. module: queue_job
#: model_terms:ir.ui.view,arch_db:queue_job.view_set_jobs_done
msgid "Set to done"
msgstr "Als Erledigt markieren"
#. module: queue_job
#: code:addons/queue_job/models/queue_job.py:0
#, python-format
msgid ""
"Something bad happened during the execution of the job. More details in the "
"'Exception Information' section."
msgstr ""
"Bei der Ausführung des Jobs ist etwas Ungewöhnliches passiert. Beachten Sie "
"die Details im Abschnitt \"Exception-Information\"."
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__date_started
msgid "Start Date"
msgstr "Gestartet am"
#. module: queue_job
#: model:ir.model.fields.selection,name:queue_job.selection__queue_job__state__started
#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_search
msgid "Started"
msgstr "Gestartet"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__state
#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_search
msgid "State"
msgstr "Status"
#. module: queue_job
#: model:ir.model.fields,help:queue_job.field_queue_job__activity_state
msgid ""
"Status based on activities\n"
"Overdue: Due date is already passed\n"
"Today: Activity date is today\n"
"Planned: Future activities."
msgstr ""
"Der Status hängt von den Aktivitäten ab.\n"
"Überfällig: Das Fälligkeitsdatum der Aktivität ist überschritten.\n"
"Heute: Die Aktivität findet heute statt.\n"
"Geplant: Die Aktivitäten findet in der Zukunft statt."
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__func_string
msgid "Task"
msgstr "Aufgabe"
#. module: queue_job
#: model:ir.model.fields,help:queue_job.field_queue_job_function__edit_related_action
msgid ""
"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."
msgstr ""
#. module: queue_job
#: model:ir.model.fields,help:queue_job.field_queue_job__max_retries
msgid ""
"The job will fail if the number of tries reach the max. retries.\n"
"Retries are infinite when empty."
msgstr ""
"Der Job wird fehlschlagen, wenn die Anzahl der Versuche gleich der maximalen "
"Anzahl der Wiederholungen ist.\n"
"Wenn Letzteres nicht gesetzt ist, werden unendlich viele Versuche "
"unternommen."
#. module: queue_job
#: model_terms:ir.ui.view,arch_db:queue_job.view_requeue_job
msgid "The selected jobs will be requeued."
msgstr "Die ausgewählten Jobs werden erneut eingereiht."
#. module: queue_job
#: model_terms:ir.ui.view,arch_db:queue_job.view_set_jobs_done
msgid "The selected jobs will be set to done."
msgstr "Die ausgewählten Jobs werden als Erledigt markiert."
#. module: queue_job
#: model:ir.model.fields,help:queue_job.field_queue_job__activity_exception_decoration
msgid "Type of the exception activity on record."
msgstr "Typ der Ausnahmeaktivität im Datensatz."
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__uuid
msgid "UUID"
msgstr "UUID"
#. module: queue_job
#: code:addons/queue_job/models/queue_job_function.py:0
#, python-format
msgid ""
"Unexpected format of Related Action for {}.\n"
"Example of valid format:\n"
"{{\"enable\": True, \"func_name\": \"related_action_foo\", \"kwargs"
"\" {{\"limit\": 10}}}}"
msgstr ""
#. module: queue_job
#: code:addons/queue_job/models/queue_job_function.py:0
#, python-format
msgid ""
"Unexpected format of Retry Pattern for {}.\n"
"Example of valid format:\n"
"{{1: 300, 5: 600, 10: 1200, 15: 3000}}"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__message_unread
msgid "Unread Messages"
msgstr "Ungelesene Nachrichten"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__message_unread_counter
msgid "Unread Messages Counter"
msgstr "Zähler für ungelesene Nachrichten"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__user_id
msgid "User ID"
msgstr "Benutzer"
#. module: queue_job
#: model:ir.model,name:queue_job.model_queue_requeue_job
msgid "Wizard to requeue a selection of jobs"
msgstr "Assistent zur erneuten Einreihung einer Job-Auswahl"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__worker_pid
msgid "Worker Pid"
msgstr ""
#~ msgid "Website Messages"
#~ msgstr "Website Nachrichten"
#~ msgid "Website communication history"
#~ msgstr "Historie der Website-Kommunikation"
#~ msgid "If checked new messages require your attention."
#~ msgstr ""
#~ "Wenn es gesetzt ist, erfordern neue Nachrichten Ihre Aufmerksamkeit."
#~ msgid "Overdue"
#~ msgstr "Überfällig"
#~ msgid "Planned"
#~ msgstr "Geplant"
#~ msgid "Today"
#~ msgstr "Heute"

782
queue_job/i18n/queue_job.pot Executable file
View File

@@ -0,0 +1,782 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * queue_job
#
msgid ""
msgstr ""
"Project-Id-Version: Odoo Server 14.0\n"
"Report-Msgid-Bugs-To: \n"
"Last-Translator: \n"
"Language-Team: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: \n"
"Plural-Forms: \n"
#. module: queue_job
#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_form
msgid ""
"<span class=\"oe_grey oe_inline\"> If the max. retries is 0, the number of "
"retries is infinite.</span>"
msgstr ""
#. module: queue_job
#: code:addons/queue_job/controllers/main.py:0
#, python-format
msgid "Access Denied"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__message_needaction
msgid "Action Needed"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__activity_ids
msgid "Activities"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__activity_exception_decoration
msgid "Activity Exception Decoration"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__activity_state
msgid "Activity State"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__activity_type_icon
msgid "Activity Type Icon"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__args
msgid "Args"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__message_attachment_count
msgid "Attachment Count"
msgstr ""
#. module: queue_job
#: model:ir.actions.server,name:queue_job.ir_cron_autovacuum_queue_jobs_ir_actions_server
#: model:ir.cron,cron_name:queue_job.ir_cron_autovacuum_queue_jobs
#: model:ir.cron,name:queue_job.ir_cron_autovacuum_queue_jobs
msgid "AutoVacuum Job Queue"
msgstr ""
#. module: queue_job
#: model:ir.model,name:queue_job.model_base
msgid "Base"
msgstr ""
#. module: queue_job
#: model_terms:ir.ui.view,arch_db:queue_job.view_requeue_job
#: model_terms:ir.ui.view,arch_db:queue_job.view_set_jobs_done
msgid "Cancel"
msgstr ""
#. module: queue_job
#: code:addons/queue_job/models/queue_job_channel.py:0
#, python-format
msgid "Cannot change the root channel"
msgstr ""
#. module: queue_job
#: code:addons/queue_job/models/queue_job_channel.py:0
#, python-format
msgid "Cannot remove the root channel"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__channel
#: model:ir.model.fields,field_description:queue_job.field_queue_job_function__channel_id
#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_function_search
#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_search
msgid "Channel"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__channel_method_name
msgid "Channel Method Name"
msgstr ""
#. module: queue_job
#: model:ir.model.constraint,message:queue_job.constraint_queue_job_channel_name_uniq
msgid "Channel complete name must be unique"
msgstr ""
#. module: queue_job
#: model:ir.actions.act_window,name:queue_job.action_queue_job_channel
#: model:ir.ui.menu,name:queue_job.menu_queue_job_channel
#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_channel_form
#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_channel_search
msgid "Channels"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__company_id
msgid "Company"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job_channel__complete_name
#: model:ir.model.fields,field_description:queue_job.field_queue_job_function__channel
msgid "Complete Name"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__date_created
msgid "Created Date"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job_channel__create_uid
#: model:ir.model.fields,field_description:queue_job.field_queue_jobs_to_done__create_uid
#: model:ir.model.fields,field_description:queue_job.field_queue_requeue_job__create_uid
msgid "Created by"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job_channel__create_date
#: model:ir.model.fields,field_description:queue_job.field_queue_jobs_to_done__create_date
#: model:ir.model.fields,field_description:queue_job.field_queue_requeue_job__create_date
msgid "Created on"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__retry
msgid "Current try"
msgstr ""
#. module: queue_job
#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_form
msgid "Current try / max. retries"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__date_done
msgid "Date Done"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__name
msgid "Description"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_ir_model_fields__display_name
#: model:ir.model.fields,field_description:queue_job.field_queue_job__display_name
#: model:ir.model.fields,field_description:queue_job.field_queue_job_channel__display_name
#: model:ir.model.fields,field_description:queue_job.field_queue_job_function__display_name
#: model:ir.model.fields,field_description:queue_job.field_queue_jobs_to_done__display_name
#: model:ir.model.fields,field_description:queue_job.field_queue_requeue_job__display_name
msgid "Display Name"
msgstr ""
#. module: queue_job
#: model:ir.model.fields.selection,name:queue_job.selection__queue_job__state__done
#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_search
msgid "Done"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__date_enqueued
msgid "Enqueue Time"
msgstr ""
#. module: queue_job
#: model:ir.model.fields.selection,name:queue_job.selection__queue_job__state__enqueued
#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_search
msgid "Enqueued"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__exc_info
msgid "Exception Info"
msgstr ""
#. module: queue_job
#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_form
msgid "Exception Information"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__eta
msgid "Execute only after"
msgstr ""
#. module: queue_job
#: model:ir.model.fields.selection,name:queue_job.selection__queue_job__state__failed
#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_search
msgid "Failed"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_ir_model_fields__ttype
msgid "Field Type"
msgstr ""
#. module: queue_job
#: model:ir.model,name:queue_job.model_ir_model_fields
msgid "Fields"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__message_follower_ids
msgid "Followers"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__message_channel_ids
msgid "Followers (Channels)"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__message_partner_ids
msgid "Followers (Partners)"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,help:queue_job.field_queue_job__activity_type_icon
msgid "Font awesome icon e.g. fa-tasks"
msgstr ""
#. module: queue_job
#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_function_search
#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_search
msgid "Group By"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_ir_model_fields__id
#: model:ir.model.fields,field_description:queue_job.field_queue_job__id
#: model:ir.model.fields,field_description:queue_job.field_queue_job_channel__id
#: model:ir.model.fields,field_description:queue_job.field_queue_job_function__id
#: model:ir.model.fields,field_description:queue_job.field_queue_jobs_to_done__id
#: model:ir.model.fields,field_description:queue_job.field_queue_requeue_job__id
msgid "ID"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__activity_exception_icon
msgid "Icon"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,help:queue_job.field_queue_job__activity_exception_icon
msgid "Icon to indicate an exception activity."
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__identity_key
msgid "Identity Key"
msgstr ""
#. module: queue_job
#: code:addons/queue_job/models/queue_job.py:0
#, python-format
msgid "If both parameters are 0, ALL jobs will be requeued!"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,help:queue_job.field_queue_job__message_needaction
#: model:ir.model.fields,help:queue_job.field_queue_job__message_unread
msgid "If checked, new messages require your attention."
msgstr ""
#. module: queue_job
#: model:ir.model.fields,help:queue_job.field_queue_job__message_has_error
#: model:ir.model.fields,help:queue_job.field_queue_job__message_has_sms_error
msgid "If checked, some messages have a delivery error."
msgstr ""
#. module: queue_job
#: code:addons/queue_job/models/queue_job_function.py:0
#, python-format
msgid "Invalid job function: {}"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__message_is_follower
msgid "Is Follower"
msgstr ""
#. module: queue_job
#: model:ir.model,name:queue_job.model_queue_job_channel
msgid "Job Channels"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__job_function_id
#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_search
msgid "Job Function"
msgstr ""
#. module: queue_job
#: model:ir.actions.act_window,name:queue_job.action_queue_job_function
#: model:ir.model,name:queue_job.model_queue_job_function
#: model:ir.model.fields,field_description:queue_job.field_queue_job_channel__job_function_ids
#: model:ir.ui.menu,name:queue_job.menu_queue_job_function
#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_function_form
#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_function_search
msgid "Job Functions"
msgstr ""
#. module: queue_job
#: model:ir.module.category,name:queue_job.module_category_queue_job
#: model:ir.ui.menu,name:queue_job.menu_queue_job_root
msgid "Job Queue"
msgstr ""
#. module: queue_job
#: model:res.groups,name:queue_job.group_queue_job_manager
msgid "Job Queue Manager"
msgstr ""
#. module: queue_job
#: model:ir.model.fields.selection,name:queue_job.selection__ir_model_fields__ttype__job_serialized
msgid "Job Serialized"
msgstr ""
#. module: queue_job
#: model:mail.message.subtype,name:queue_job.mt_job_failed
msgid "Job failed"
msgstr ""
#. module: queue_job
#: code:addons/queue_job/controllers/main.py:0
#, python-format
msgid "Job interrupted and set to Done: nothing to do."
msgstr ""
#. module: queue_job
#: model:ir.actions.act_window,name:queue_job.action_queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_jobs_to_done__job_ids
#: model:ir.model.fields,field_description:queue_job.field_queue_requeue_job__job_ids
#: model:ir.ui.menu,name:queue_job.menu_queue_job
#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_form
#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_search
msgid "Jobs"
msgstr ""
#. module: queue_job
#: model:ir.actions.server,name:queue_job.ir_cron_queue_job_garbage_collector_ir_actions_server
#: model:ir.cron,cron_name:queue_job.ir_cron_queue_job_garbage_collector
#: model:ir.cron,name:queue_job.ir_cron_queue_job_garbage_collector
msgid "Jobs Garbage Collector"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__kwargs
msgid "Kwargs"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_ir_model_fields____last_update
#: model:ir.model.fields,field_description:queue_job.field_queue_job____last_update
#: model:ir.model.fields,field_description:queue_job.field_queue_job_channel____last_update
#: model:ir.model.fields,field_description:queue_job.field_queue_job_function____last_update
#: model:ir.model.fields,field_description:queue_job.field_queue_jobs_to_done____last_update
#: model:ir.model.fields,field_description:queue_job.field_queue_requeue_job____last_update
msgid "Last Modified on"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job_channel__write_uid
#: model:ir.model.fields,field_description:queue_job.field_queue_jobs_to_done__write_uid
#: model:ir.model.fields,field_description:queue_job.field_queue_requeue_job__write_uid
msgid "Last Updated by"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job_channel__write_date
#: model:ir.model.fields,field_description:queue_job.field_queue_jobs_to_done__write_date
#: model:ir.model.fields,field_description:queue_job.field_queue_requeue_job__write_date
msgid "Last Updated on"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__message_main_attachment_id
msgid "Main Attachment"
msgstr ""
#. module: queue_job
#: code:addons/queue_job/models/queue_job.py:0
#, python-format
msgid "Manually set to done by %s"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__max_retries
msgid "Max. retries"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__message_has_error
msgid "Message Delivery error"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__message_ids
msgid "Messages"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job_function__method
msgid "Method"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__method_name
msgid "Method Name"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__model_name
#: model:ir.model.fields,field_description:queue_job.field_queue_job_function__model_id
msgid "Model"
msgstr ""
#. module: queue_job
#: code:addons/queue_job/models/queue_job_function.py:0
#, python-format
msgid "Model {} not found"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job_channel__name
#: model:ir.model.fields,field_description:queue_job.field_queue_job_function__name
msgid "Name"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__activity_date_deadline
msgid "Next Activity Deadline"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__activity_summary
msgid "Next Activity Summary"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__activity_type_id
msgid "Next Activity Type"
msgstr ""
#. module: queue_job
#: code:addons/queue_job/models/queue_job.py:0
#, python-format
msgid "No action available for this job"
msgstr ""
#. module: queue_job
#: code:addons/queue_job/models/queue_job.py:0
#, python-format
msgid "Not allowed to change field(s): {}"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__message_needaction_counter
msgid "Number of Actions"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__message_has_error_counter
msgid "Number of errors"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,help:queue_job.field_queue_job__message_needaction_counter
msgid "Number of messages which requires an action"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,help:queue_job.field_queue_job__message_has_error_counter
msgid "Number of messages with delivery error"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,help:queue_job.field_queue_job__message_unread_counter
msgid "Number of unread messages"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__override_channel
msgid "Override Channel"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job_channel__parent_id
msgid "Parent Channel"
msgstr ""
#. module: queue_job
#: code:addons/queue_job/models/queue_job_channel.py:0
#, python-format
msgid "Parent channel required."
msgstr ""
#. module: queue_job
#: model:ir.model.fields,help:queue_job.field_queue_job_function__edit_retry_pattern
msgid ""
"Pattern expressing from the count of retries on retryable errors, the number of of seconds to postpone the next execution.\n"
"Example: {1: 10, 5: 20, 10: 30, 15: 300}.\n"
"See the module description for details."
msgstr ""
#. module: queue_job
#: model:ir.model.fields.selection,name:queue_job.selection__queue_job__state__pending
#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_search
msgid "Pending"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__priority
msgid "Priority"
msgstr ""
#. module: queue_job
#: model:ir.ui.menu,name:queue_job.menu_queue
msgid "Queue"
msgstr ""
#. module: queue_job
#: model:ir.model,name:queue_job.model_queue_job
msgid "Queue Job"
msgstr ""
#. module: queue_job
#: code:addons/queue_job/models/queue_job.py:0
#, python-format
msgid "Queue jobs must created by calling 'with_delay()'."
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__record_ids
msgid "Record"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__records
msgid "Record(s)"
msgstr ""
#. module: queue_job
#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_form
msgid "Related"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job_function__edit_related_action
msgid "Related Action"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job_function__related_action
msgid "Related Action (serialized)"
msgstr ""
#. module: queue_job
#: code:addons/queue_job/models/queue_job.py:0
#, python-format
msgid "Related Record"
msgstr ""
#. module: queue_job
#: code:addons/queue_job/models/queue_job.py:0
#, python-format
msgid "Related Records"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job_channel__removal_interval
msgid "Removal Interval"
msgstr ""
#. module: queue_job
#: model_terms:ir.ui.view,arch_db:queue_job.view_requeue_job
msgid "Requeue"
msgstr ""
#. module: queue_job
#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_form
msgid "Requeue Job"
msgstr ""
#. module: queue_job
#: model:ir.actions.act_window,name:queue_job.action_requeue_job
#: model_terms:ir.ui.view,arch_db:queue_job.view_requeue_job
msgid "Requeue Jobs"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__activity_user_id
msgid "Responsible User"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__result
#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_form
msgid "Result"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job_function__edit_retry_pattern
msgid "Retry Pattern"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job_function__retry_pattern
msgid "Retry Pattern (serialized)"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__message_has_sms_error
msgid "SMS Delivery error"
msgstr ""
#. module: queue_job
#: model:ir.model,name:queue_job.model_queue_jobs_to_done
msgid "Set all selected jobs to done"
msgstr ""
#. module: queue_job
#: model_terms:ir.ui.view,arch_db:queue_job.view_set_jobs_done
msgid "Set jobs done"
msgstr ""
#. module: queue_job
#: model:ir.actions.act_window,name:queue_job.action_set_jobs_done
msgid "Set jobs to done"
msgstr ""
#. module: queue_job
#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_form
msgid "Set to 'Done'"
msgstr ""
#. module: queue_job
#: model_terms:ir.ui.view,arch_db:queue_job.view_set_jobs_done
msgid "Set to done"
msgstr ""
#. module: queue_job
#: code:addons/queue_job/models/queue_job.py:0
#, python-format
msgid ""
"Something bad happened during the execution of the job. More details in the "
"'Exception Information' section."
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__date_started
msgid "Start Date"
msgstr ""
#. module: queue_job
#: model:ir.model.fields.selection,name:queue_job.selection__queue_job__state__started
#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_search
msgid "Started"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__state
#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_search
msgid "State"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,help:queue_job.field_queue_job__activity_state
msgid ""
"Status based on activities\n"
"Overdue: Due date is already passed\n"
"Today: Activity date is today\n"
"Planned: Future activities."
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__func_string
msgid "Task"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,help:queue_job.field_queue_job_function__edit_related_action
msgid ""
"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."
msgstr ""
#. module: queue_job
#: model:ir.model.fields,help:queue_job.field_queue_job__max_retries
msgid ""
"The job will fail if the number of tries reach the max. retries.\n"
"Retries are infinite when empty."
msgstr ""
#. module: queue_job
#: model_terms:ir.ui.view,arch_db:queue_job.view_requeue_job
msgid "The selected jobs will be requeued."
msgstr ""
#. module: queue_job
#: model_terms:ir.ui.view,arch_db:queue_job.view_set_jobs_done
msgid "The selected jobs will be set to done."
msgstr ""
#. module: queue_job
#: model:ir.model.fields,help:queue_job.field_queue_job__activity_exception_decoration
msgid "Type of the exception activity on record."
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__uuid
msgid "UUID"
msgstr ""
#. module: queue_job
#: code:addons/queue_job/models/queue_job_function.py:0
#, python-format
msgid ""
"Unexpected format of Related Action for {}.\n"
"Example of valid format:\n"
"{{\"enable\": True, \"func_name\": \"related_action_foo\", \"kwargs\" {{\"limit\": 10}}}}"
msgstr ""
#. module: queue_job
#: code:addons/queue_job/models/queue_job_function.py:0
#, python-format
msgid ""
"Unexpected format of Retry Pattern for {}.\n"
"Example of valid format:\n"
"{{1: 300, 5: 600, 10: 1200, 15: 3000}}"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__message_unread
msgid "Unread Messages"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__message_unread_counter
msgid "Unread Messages Counter"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__user_id
msgid "User ID"
msgstr ""
#. module: queue_job
#: model:ir.model,name:queue_job.model_queue_requeue_job
msgid "Wizard to requeue a selection of jobs"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__worker_pid
msgid "Worker Pid"
msgstr ""

820
queue_job/i18n/zh_CN.po Executable file
View File

@@ -0,0 +1,820 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * queue_job
#
msgid ""
msgstr ""
"Project-Id-Version: Odoo Server 12.0\n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2020-03-23 06:13+0000\n"
"Last-Translator: 黎伟杰 <674416404@qq.com>\n"
"Language-Team: none\n"
"Language: zh_CN\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: \n"
"Plural-Forms: nplurals=1; plural=0;\n"
"X-Generator: Weblate 3.10\n"
#. module: queue_job
#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_form
msgid ""
"<span class=\"oe_grey oe_inline\"> If the max. retries is 0, the number of "
"retries is infinite.</span>"
msgstr ""
"<span class=\"oe_grey oe_inline\">如果最大重试次数是0则重试次数是无限的。</"
"span>"
#. module: queue_job
#: code:addons/queue_job/controllers/main.py:0
#, python-format
msgid "Access Denied"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__message_needaction
msgid "Action Needed"
msgstr "前置操作"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__activity_ids
msgid "Activities"
msgstr "活动"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__activity_exception_decoration
msgid "Activity Exception Decoration"
msgstr "活动异常装饰"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__activity_state
msgid "Activity State"
msgstr "活动状态"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__activity_type_icon
msgid "Activity Type Icon"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__args
msgid "Args"
msgstr "位置参数"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__message_attachment_count
msgid "Attachment Count"
msgstr "附件数量"
#. module: queue_job
#: model:ir.actions.server,name:queue_job.ir_cron_autovacuum_queue_jobs_ir_actions_server
#: model:ir.cron,cron_name:queue_job.ir_cron_autovacuum_queue_jobs
#: model:ir.cron,name:queue_job.ir_cron_autovacuum_queue_jobs
msgid "AutoVacuum Job Queue"
msgstr "自动清空作业队列"
#. module: queue_job
#: model:ir.model,name:queue_job.model_base
msgid "Base"
msgstr "基础"
#. module: queue_job
#: model_terms:ir.ui.view,arch_db:queue_job.view_requeue_job
#: model_terms:ir.ui.view,arch_db:queue_job.view_set_jobs_done
msgid "Cancel"
msgstr "取消"
#. module: queue_job
#: code:addons/queue_job/models/queue_job_channel.py:0
#, python-format
msgid "Cannot change the root channel"
msgstr "无法更改root频道"
#. module: queue_job
#: code:addons/queue_job/models/queue_job_channel.py:0
#, python-format
msgid "Cannot remove the root channel"
msgstr "无法删除root频道"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__channel
#: model:ir.model.fields,field_description:queue_job.field_queue_job_function__channel_id
#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_function_search
#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_search
msgid "Channel"
msgstr "频道"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__channel_method_name
msgid "Channel Method Name"
msgstr "频道方法名称"
#. module: queue_job
#: model:ir.model.constraint,message:queue_job.constraint_queue_job_channel_name_uniq
msgid "Channel complete name must be unique"
msgstr "频道完整名称必须是唯一的"
#. module: queue_job
#: model:ir.actions.act_window,name:queue_job.action_queue_job_channel
#: model:ir.ui.menu,name:queue_job.menu_queue_job_channel
#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_channel_form
#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_channel_search
msgid "Channels"
msgstr "频道"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__company_id
msgid "Company"
msgstr "公司"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job_channel__complete_name
#: model:ir.model.fields,field_description:queue_job.field_queue_job_function__channel
msgid "Complete Name"
msgstr "完整名称"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__date_created
msgid "Created Date"
msgstr "创建日期"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job_channel__create_uid
#: model:ir.model.fields,field_description:queue_job.field_queue_jobs_to_done__create_uid
#: model:ir.model.fields,field_description:queue_job.field_queue_requeue_job__create_uid
msgid "Created by"
msgstr "创建者"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job_channel__create_date
#: model:ir.model.fields,field_description:queue_job.field_queue_jobs_to_done__create_date
#: model:ir.model.fields,field_description:queue_job.field_queue_requeue_job__create_date
msgid "Created on"
msgstr "创建时间"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__retry
msgid "Current try"
msgstr "当前尝试"
#. module: queue_job
#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_form
msgid "Current try / max. retries"
msgstr "当前尝试/最大重试次数"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__date_done
msgid "Date Done"
msgstr "完成日期"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__name
msgid "Description"
msgstr "说明"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_ir_model_fields__display_name
#: model:ir.model.fields,field_description:queue_job.field_queue_job__display_name
#: model:ir.model.fields,field_description:queue_job.field_queue_job_channel__display_name
#: model:ir.model.fields,field_description:queue_job.field_queue_job_function__display_name
#: model:ir.model.fields,field_description:queue_job.field_queue_jobs_to_done__display_name
#: model:ir.model.fields,field_description:queue_job.field_queue_requeue_job__display_name
msgid "Display Name"
msgstr "显示名称"
#. module: queue_job
#: model:ir.model.fields.selection,name:queue_job.selection__queue_job__state__done
#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_search
msgid "Done"
msgstr "完成"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__date_enqueued
msgid "Enqueue Time"
msgstr "排队时间"
#. module: queue_job
#: model:ir.model.fields.selection,name:queue_job.selection__queue_job__state__enqueued
#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_search
msgid "Enqueued"
msgstr "排队"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__exc_info
msgid "Exception Info"
msgstr "异常信息"
#. module: queue_job
#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_form
msgid "Exception Information"
msgstr "异常信息"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__eta
msgid "Execute only after"
msgstr "仅在此之后执行"
#. module: queue_job
#: model:ir.model.fields.selection,name:queue_job.selection__queue_job__state__failed
#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_search
msgid "Failed"
msgstr "失败"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_ir_model_fields__ttype
msgid "Field Type"
msgstr ""
#. module: queue_job
#: model:ir.model,name:queue_job.model_ir_model_fields
msgid "Fields"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__message_follower_ids
msgid "Followers"
msgstr "关注者"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__message_channel_ids
msgid "Followers (Channels)"
msgstr "关注者(频道)"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__message_partner_ids
msgid "Followers (Partners)"
msgstr "关注者(业务伙伴)"
#. module: queue_job
#: model:ir.model.fields,help:queue_job.field_queue_job__activity_type_icon
msgid "Font awesome icon e.g. fa-tasks"
msgstr ""
#. module: queue_job
#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_function_search
#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_search
msgid "Group By"
msgstr "分组"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_ir_model_fields__id
#: model:ir.model.fields,field_description:queue_job.field_queue_job__id
#: model:ir.model.fields,field_description:queue_job.field_queue_job_channel__id
#: model:ir.model.fields,field_description:queue_job.field_queue_job_function__id
#: model:ir.model.fields,field_description:queue_job.field_queue_jobs_to_done__id
#: model:ir.model.fields,field_description:queue_job.field_queue_requeue_job__id
msgid "ID"
msgstr "ID"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__activity_exception_icon
msgid "Icon"
msgstr "图标"
#. module: queue_job
#: model:ir.model.fields,help:queue_job.field_queue_job__activity_exception_icon
msgid "Icon to indicate an exception activity."
msgstr "指示异常活动的图标。"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__identity_key
msgid "Identity Key"
msgstr "身份密钥"
#. module: queue_job
#: code:addons/queue_job/models/queue_job.py:0
#, fuzzy, python-format
msgid "If both parameters are 0, ALL jobs will be requeued!"
msgstr "所选作业将重新排队。"
#. module: queue_job
#: model:ir.model.fields,help:queue_job.field_queue_job__message_needaction
#: model:ir.model.fields,help:queue_job.field_queue_job__message_unread
msgid "If checked, new messages require your attention."
msgstr "确认后, 出现提示消息。"
#. module: queue_job
#: model:ir.model.fields,help:queue_job.field_queue_job__message_has_error
#: model:ir.model.fields,help:queue_job.field_queue_job__message_has_sms_error
msgid "If checked, some messages have a delivery error."
msgstr "如果勾选此项, 某些消息将会产生传递错误。"
#. module: queue_job
#: code:addons/queue_job/models/queue_job_function.py:0
#, python-format
msgid "Invalid job function: {}"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__message_is_follower
msgid "Is Follower"
msgstr "关注者"
#. module: queue_job
#: model:ir.model,name:queue_job.model_queue_job_channel
msgid "Job Channels"
msgstr "作业频道"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__job_function_id
#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_search
msgid "Job Function"
msgstr "作业函数"
#. module: queue_job
#: model:ir.actions.act_window,name:queue_job.action_queue_job_function
#: model:ir.model,name:queue_job.model_queue_job_function
#: model:ir.model.fields,field_description:queue_job.field_queue_job_channel__job_function_ids
#: model:ir.ui.menu,name:queue_job.menu_queue_job_function
#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_function_form
#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_function_search
msgid "Job Functions"
msgstr "作业函数"
#. module: queue_job
#: model:ir.module.category,name:queue_job.module_category_queue_job
#: model:ir.ui.menu,name:queue_job.menu_queue_job_root
msgid "Job Queue"
msgstr "作业队列"
#. module: queue_job
#: model:res.groups,name:queue_job.group_queue_job_manager
msgid "Job Queue Manager"
msgstr "作业队列管理员"
#. module: queue_job
#: model:ir.model.fields.selection,name:queue_job.selection__ir_model_fields__ttype__job_serialized
#, fuzzy
msgid "Job Serialized"
msgstr "作业失败"
#. module: queue_job
#: model:mail.message.subtype,name:queue_job.mt_job_failed
msgid "Job failed"
msgstr "作业失败"
#. module: queue_job
#: code:addons/queue_job/controllers/main.py:0
#, python-format
msgid "Job interrupted and set to Done: nothing to do."
msgstr "作业中断并设置为已完成:无需执行任何操作。"
#. module: queue_job
#: model:ir.actions.act_window,name:queue_job.action_queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_jobs_to_done__job_ids
#: model:ir.model.fields,field_description:queue_job.field_queue_requeue_job__job_ids
#: model:ir.ui.menu,name:queue_job.menu_queue_job
#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_form
#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_search
msgid "Jobs"
msgstr "作业"
#. module: queue_job
#: model:ir.actions.server,name:queue_job.ir_cron_queue_job_garbage_collector_ir_actions_server
#: model:ir.cron,cron_name:queue_job.ir_cron_queue_job_garbage_collector
#: model:ir.cron,name:queue_job.ir_cron_queue_job_garbage_collector
msgid "Jobs Garbage Collector"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__kwargs
msgid "Kwargs"
msgstr "关键字参数"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_ir_model_fields____last_update
#: model:ir.model.fields,field_description:queue_job.field_queue_job____last_update
#: model:ir.model.fields,field_description:queue_job.field_queue_job_channel____last_update
#: model:ir.model.fields,field_description:queue_job.field_queue_job_function____last_update
#: model:ir.model.fields,field_description:queue_job.field_queue_jobs_to_done____last_update
#: model:ir.model.fields,field_description:queue_job.field_queue_requeue_job____last_update
msgid "Last Modified on"
msgstr "最后修改日"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job_channel__write_uid
#: model:ir.model.fields,field_description:queue_job.field_queue_jobs_to_done__write_uid
#: model:ir.model.fields,field_description:queue_job.field_queue_requeue_job__write_uid
msgid "Last Updated by"
msgstr "最后更新者"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job_channel__write_date
#: model:ir.model.fields,field_description:queue_job.field_queue_jobs_to_done__write_date
#: model:ir.model.fields,field_description:queue_job.field_queue_requeue_job__write_date
msgid "Last Updated on"
msgstr "最后更新时间"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__message_main_attachment_id
msgid "Main Attachment"
msgstr "附件"
#. module: queue_job
#: code:addons/queue_job/models/queue_job.py:0
#, python-format
msgid "Manually set to done by %s"
msgstr "由%s手动设置为完成"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__max_retries
msgid "Max. retries"
msgstr "最大重试次数"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__message_has_error
msgid "Message Delivery error"
msgstr "消息递送错误"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__message_ids
msgid "Messages"
msgstr "消息"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job_function__method
#, fuzzy
msgid "Method"
msgstr "方法名称"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__method_name
msgid "Method Name"
msgstr "方法名称"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__model_name
#: model:ir.model.fields,field_description:queue_job.field_queue_job_function__model_id
msgid "Model"
msgstr "模型"
#. module: queue_job
#: code:addons/queue_job/models/queue_job_function.py:0
#, python-format
msgid "Model {} not found"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job_channel__name
#: model:ir.model.fields,field_description:queue_job.field_queue_job_function__name
msgid "Name"
msgstr "名称"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__activity_date_deadline
msgid "Next Activity Deadline"
msgstr "下一活动截止日期"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__activity_summary
msgid "Next Activity Summary"
msgstr "下一活动摘要"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__activity_type_id
msgid "Next Activity Type"
msgstr "下一活动类型"
#. module: queue_job
#: code:addons/queue_job/models/queue_job.py:0
#, python-format
msgid "No action available for this job"
msgstr "此作业无法执行任何操作"
#. module: queue_job
#: code:addons/queue_job/models/queue_job.py:0
#, python-format
msgid "Not allowed to change field(s): {}"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__message_needaction_counter
msgid "Number of Actions"
msgstr "操作次数"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__message_has_error_counter
msgid "Number of errors"
msgstr "错误数量"
#. module: queue_job
#: model:ir.model.fields,help:queue_job.field_queue_job__message_needaction_counter
msgid "Number of messages which requires an action"
msgstr "需要操作消息数量"
#. module: queue_job
#: model:ir.model.fields,help:queue_job.field_queue_job__message_has_error_counter
msgid "Number of messages with delivery error"
msgstr "递送错误消息数量"
#. module: queue_job
#: model:ir.model.fields,help:queue_job.field_queue_job__message_unread_counter
msgid "Number of unread messages"
msgstr "未读消息数量"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__override_channel
msgid "Override Channel"
msgstr "覆盖频道"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job_channel__parent_id
msgid "Parent Channel"
msgstr "父频道"
#. module: queue_job
#: code:addons/queue_job/models/queue_job_channel.py:0
#, python-format
msgid "Parent channel required."
msgstr "父频道必填。"
#. module: queue_job
#: model:ir.model.fields,help:queue_job.field_queue_job_function__edit_retry_pattern
msgid ""
"Pattern expressing from the count of retries on retryable errors, the number "
"of of seconds to postpone the next execution.\n"
"Example: {1: 10, 5: 20, 10: 30, 15: 300}.\n"
"See the module description for details."
msgstr ""
#. module: queue_job
#: model:ir.model.fields.selection,name:queue_job.selection__queue_job__state__pending
#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_search
msgid "Pending"
msgstr "等待"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__priority
msgid "Priority"
msgstr "优先级"
#. module: queue_job
#: model:ir.ui.menu,name:queue_job.menu_queue
msgid "Queue"
msgstr "队列"
#. module: queue_job
#: model:ir.model,name:queue_job.model_queue_job
msgid "Queue Job"
msgstr "队列作业"
#. module: queue_job
#: code:addons/queue_job/models/queue_job.py:0
#, python-format
msgid "Queue jobs must created by calling 'with_delay()'."
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__record_ids
msgid "Record"
msgstr "记录"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__records
#, fuzzy
msgid "Record(s)"
msgstr "记录"
#. module: queue_job
#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_form
msgid "Related"
msgstr "相关的"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job_function__edit_related_action
#, fuzzy
msgid "Related Action"
msgstr "相关记录"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job_function__related_action
msgid "Related Action (serialized)"
msgstr ""
#. module: queue_job
#: code:addons/queue_job/models/queue_job.py:0
#, python-format
msgid "Related Record"
msgstr "相关记录"
#. module: queue_job
#: code:addons/queue_job/models/queue_job.py:0
#, python-format
msgid "Related Records"
msgstr "相关记录"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job_channel__removal_interval
msgid "Removal Interval"
msgstr "清除间隔"
#. module: queue_job
#: model_terms:ir.ui.view,arch_db:queue_job.view_requeue_job
msgid "Requeue"
msgstr "重新排队"
#. module: queue_job
#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_form
msgid "Requeue Job"
msgstr "重新排队作业"
#. module: queue_job
#: model:ir.actions.act_window,name:queue_job.action_requeue_job
#: model_terms:ir.ui.view,arch_db:queue_job.view_requeue_job
msgid "Requeue Jobs"
msgstr "重新排队作业"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__activity_user_id
msgid "Responsible User"
msgstr "负责的用户"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__result
#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_form
msgid "Result"
msgstr "结果"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job_function__edit_retry_pattern
msgid "Retry Pattern"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job_function__retry_pattern
msgid "Retry Pattern (serialized)"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__message_has_sms_error
msgid "SMS Delivery error"
msgstr "短信传递错误"
#. module: queue_job
#: model:ir.model,name:queue_job.model_queue_jobs_to_done
msgid "Set all selected jobs to done"
msgstr "将所有选定的作业设置为完成"
#. module: queue_job
#: model_terms:ir.ui.view,arch_db:queue_job.view_set_jobs_done
msgid "Set jobs done"
msgstr "设置作业完成"
#. module: queue_job
#: model:ir.actions.act_window,name:queue_job.action_set_jobs_done
msgid "Set jobs to done"
msgstr "将作业设置为完成"
#. module: queue_job
#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_form
msgid "Set to 'Done'"
msgstr "设置为“完成”"
#. module: queue_job
#: model_terms:ir.ui.view,arch_db:queue_job.view_set_jobs_done
msgid "Set to done"
msgstr "设置为完成"
#. module: queue_job
#: code:addons/queue_job/models/queue_job.py:0
#, python-format
msgid ""
"Something bad happened during the execution of the job. More details in the "
"'Exception Information' section."
msgstr ""
"在执行作业期间发生了一些不好的事情。有关详细信息,请参见“异常信息”部分。"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__date_started
msgid "Start Date"
msgstr "开始日期"
#. module: queue_job
#: model:ir.model.fields.selection,name:queue_job.selection__queue_job__state__started
#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_search
msgid "Started"
msgstr "开始"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__state
#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_search
msgid "State"
msgstr "状态"
#. module: queue_job
#: model:ir.model.fields,help:queue_job.field_queue_job__activity_state
msgid ""
"Status based on activities\n"
"Overdue: Due date is already passed\n"
"Today: Activity date is today\n"
"Planned: Future activities."
msgstr ""
"基于活动的状态\n"
"逾期:已经超过截止日期\n"
"现今:活动日期是当天\n"
"计划:未来的活动。"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__func_string
msgid "Task"
msgstr "任务"
#. module: queue_job
#: model:ir.model.fields,help:queue_job.field_queue_job_function__edit_related_action
msgid ""
"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."
msgstr ""
#. module: queue_job
#: model:ir.model.fields,help:queue_job.field_queue_job__max_retries
msgid ""
"The job will fail if the number of tries reach the max. retries.\n"
"Retries are infinite when empty."
msgstr ""
"如果尝试次数达到最大重试次数,作业将失败。\n"
"空的时候重试是无限的。"
#. module: queue_job
#: model_terms:ir.ui.view,arch_db:queue_job.view_requeue_job
msgid "The selected jobs will be requeued."
msgstr "所选作业将重新排队。"
#. module: queue_job
#: model_terms:ir.ui.view,arch_db:queue_job.view_set_jobs_done
msgid "The selected jobs will be set to done."
msgstr "所选作业将设置为完成。"
#. module: queue_job
#: model:ir.model.fields,help:queue_job.field_queue_job__activity_exception_decoration
msgid "Type of the exception activity on record."
msgstr "记录的异常活动的类型。"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__uuid
msgid "UUID"
msgstr "UUID"
#. module: queue_job
#: code:addons/queue_job/models/queue_job_function.py:0
#, python-format
msgid ""
"Unexpected format of Related Action for {}.\n"
"Example of valid format:\n"
"{{\"enable\": True, \"func_name\": \"related_action_foo\", \"kwargs"
"\" {{\"limit\": 10}}}}"
msgstr ""
#. module: queue_job
#: code:addons/queue_job/models/queue_job_function.py:0
#, python-format
msgid ""
"Unexpected format of Retry Pattern for {}.\n"
"Example of valid format:\n"
"{{1: 300, 5: 600, 10: 1200, 15: 3000}}"
msgstr ""
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__message_unread
msgid "Unread Messages"
msgstr "未读消息"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__message_unread_counter
msgid "Unread Messages Counter"
msgstr "未读消息计数器"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__user_id
msgid "User ID"
msgstr "用户"
#. module: queue_job
#: model:ir.model,name:queue_job.model_queue_requeue_job
msgid "Wizard to requeue a selection of jobs"
msgstr "重新排队向导所选的作业"
#. module: queue_job
#: model:ir.model.fields,field_description:queue_job.field_queue_job__worker_pid
msgid "Worker Pid"
msgstr ""
#~ msgid "Website Messages"
#~ msgstr "网站消息"
#~ msgid "Website communication history"
#~ msgstr "网站交流历史"
#~ msgid "If checked new messages require your attention."
#~ msgstr "查看是否有需要留意的新消息。"
#~ msgid "Overdue"
#~ msgstr "逾期"
#~ msgid "Planned"
#~ msgstr "计划"
#~ msgid "Today"
#~ msgstr "今天"

736
queue_job/job.py Executable file
View File

@@ -0,0 +1,736 @@
# Copyright 2013-2020 Camptocamp
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html)
import hashlib
import inspect
import logging
import os
import sys
import uuid
from datetime import datetime, timedelta
from random import randint
import odoo
from .exception import FailedJobError, NoSuchJobError, RetryableJobError
PENDING = "pending"
ENQUEUED = "enqueued"
DONE = "done"
STARTED = "started"
FAILED = "failed"
STATES = [
(PENDING, "Pending"),
(ENQUEUED, "Enqueued"),
(STARTED, "Started"),
(DONE, "Done"),
(FAILED, "Failed"),
]
DEFAULT_PRIORITY = 10 # used by the PriorityQueue to sort the jobs
DEFAULT_MAX_RETRIES = 20
RETRY_INTERVAL = 1 * 60 # seconds
_logger = logging.getLogger(__name__)
class DelayableRecordset(object):
"""Allow to delay a method for a recordset
Usage::
delayable = DelayableRecordset(recordset, priority=20)
delayable.method(args, kwargs)
The method call will be processed asynchronously in the job queue, with
the passed arguments.
This class will generally not be used directly, it is used internally
by :meth:`~odoo.addons.queue_job.models.base.Base.with_delay`
"""
def __init__(
self,
recordset,
priority=None,
eta=None,
max_retries=None,
description=None,
channel=None,
identity_key=None,
):
self.recordset = recordset
self.priority = priority
self.eta = eta
self.max_retries = max_retries
self.description = description
self.channel = channel
self.identity_key = identity_key
def __getattr__(self, name):
if name in self.recordset:
raise AttributeError(
"only methods can be delayed ({} called on {})".format(
name, self.recordset
)
)
recordset_method = getattr(self.recordset, name)
def delay(*args, **kwargs):
return Job.enqueue(
recordset_method,
args=args,
kwargs=kwargs,
priority=self.priority,
max_retries=self.max_retries,
eta=self.eta,
description=self.description,
channel=self.channel,
identity_key=self.identity_key,
)
return delay
def __str__(self):
return "DelayableRecordset({}{})".format(
self.recordset._name, getattr(self.recordset, "_ids", "")
)
__repr__ = __str__
def identity_exact(job_):
"""Identity function using the model, method and all arguments as key
When used, this identity key will have the effect that when a job should be
created and a pending job with the exact same recordset and arguments, the
second will not be created.
It should be used with the ``identity_key`` argument:
.. python::
from odoo.addons.queue_job.job import identity_exact
# [...]
delayable = self.with_delay(identity_key=identity_exact)
delayable.export_record(force=True)
Alternative identity keys can be built using the various fields of the job.
For example, you could compute a hash using only some arguments of
the job.
.. python::
def identity_example(job_):
hasher = hashlib.sha1()
hasher.update(job_.model_name)
hasher.update(job_.method_name)
hasher.update(str(sorted(job_.recordset.ids)))
hasher.update(str(job_.args[1]))
hasher.update(str(job_.kwargs.get('foo', '')))
return hasher.hexdigest()
Usually you will probably always want to include at least the name of the
model and method.
"""
hasher = hashlib.sha1()
hasher.update(job_.model_name.encode("utf-8"))
hasher.update(job_.method_name.encode("utf-8"))
hasher.update(str(sorted(job_.recordset.ids)).encode("utf-8"))
hasher.update(str(job_.args).encode("utf-8"))
hasher.update(str(sorted(job_.kwargs.items())).encode("utf-8"))
return hasher.hexdigest()
class Job(object):
"""A Job is a task to execute. It is the in-memory representation of a job.
Jobs are stored in the ``queue.job`` Odoo Model, but they are handled
through this class.
.. attribute:: uuid
Id (UUID) of the job.
.. attribute:: state
State of the job, can pending, enqueued, started, done or failed.
The start state is pending and the final state is done.
.. attribute:: retry
The current try, starts at 0 and each time the job is executed,
it increases by 1.
.. attribute:: max_retries
The maximum number of retries allowed before the job is
considered as failed.
.. attribute:: args
Arguments passed to the function when executed.
.. attribute:: kwargs
Keyword arguments passed to the function when executed.
.. attribute:: description
Human description of the job.
.. attribute:: func
The python function itself.
.. attribute:: model_name
Odoo model on which the job will run.
.. attribute:: priority
Priority of the job, 0 being the higher priority.
.. attribute:: date_created
Date and time when the job was created.
.. attribute:: date_enqueued
Date and time when the job was enqueued.
.. attribute:: date_started
Date and time when the job was started.
.. attribute:: date_done
Date and time when the job was done.
.. attribute:: result
A description of the result (for humans).
.. attribute:: exc_info
Exception information (traceback) when the job failed.
.. attribute:: user_id
Odoo user id which created the job
.. attribute:: eta
Estimated Time of Arrival of the job. It will not be executed
before this date/time.
.. attribute:: recordset
Model recordset when we are on a delayed Model method
.. attribute::channel
The complete name of the channel to use to process the job. If
provided it overrides the one defined on the job's function.
.. attribute::identity_key
A key referencing the job, multiple job with the same key will not
be added to a channel if the existing job with the same key is not yet
started or executed.
"""
@classmethod
def load(cls, env, job_uuid):
"""Read a job from the Database"""
stored = cls.db_record_from_uuid(env, job_uuid)
if not stored:
raise NoSuchJobError(
"Job %s does no longer exist in the storage." % job_uuid
)
return cls._load_from_db_record(stored)
@classmethod
def _load_from_db_record(cls, job_db_record):
stored = job_db_record
args = stored.args
kwargs = stored.kwargs
method_name = stored.method_name
recordset = stored.records
method = getattr(recordset, method_name)
eta = None
if stored.eta:
eta = stored.eta
job_ = cls(
method,
args=args,
kwargs=kwargs,
priority=stored.priority,
eta=eta,
job_uuid=stored.uuid,
description=stored.name,
channel=stored.channel,
identity_key=stored.identity_key,
)
if stored.date_created:
job_.date_created = stored.date_created
if stored.date_enqueued:
job_.date_enqueued = stored.date_enqueued
if stored.date_started:
job_.date_started = stored.date_started
if stored.date_done:
job_.date_done = stored.date_done
job_.state = stored.state
job_.result = stored.result if stored.result else None
job_.exc_info = stored.exc_info if stored.exc_info else None
job_.retry = stored.retry
job_.max_retries = stored.max_retries
if stored.company_id:
job_.company_id = stored.company_id.id
job_.identity_key = stored.identity_key
job_.worker_pid = stored.worker_pid
return job_
def job_record_with_same_identity_key(self):
"""Check if a job to be executed with the same key exists."""
existing = (
self.env["queue.job"]
.sudo()
.search(
[
("identity_key", "=", self.identity_key),
("state", "in", [PENDING, ENQUEUED]),
],
limit=1,
)
)
return existing
@classmethod
def enqueue(
cls,
func,
args=None,
kwargs=None,
priority=None,
eta=None,
max_retries=None,
description=None,
channel=None,
identity_key=None,
):
"""Create a Job and enqueue it in the queue. Return the job uuid.
This expects the arguments specific to the job to be already extracted
from the ones to pass to the job function.
If the identity key is the same than the one in a pending job,
no job is created and the existing job is returned
"""
new_job = cls(
func=func,
args=args,
kwargs=kwargs,
priority=priority,
eta=eta,
max_retries=max_retries,
description=description,
channel=channel,
identity_key=identity_key,
)
if new_job.identity_key:
existing = new_job.job_record_with_same_identity_key()
if existing:
_logger.debug(
"a job has not been enqueued due to having "
"the same identity key (%s) than job %s",
new_job.identity_key,
existing.uuid,
)
return Job._load_from_db_record(existing)
new_job.store()
_logger.debug(
"enqueued %s:%s(*%r, **%r) with uuid: %s",
new_job.recordset,
new_job.method_name,
new_job.args,
new_job.kwargs,
new_job.uuid,
)
return new_job
@staticmethod
def db_record_from_uuid(env, job_uuid):
model = env["queue.job"].sudo()
record = model.search([("uuid", "=", job_uuid)], limit=1)
return record.with_env(env).sudo()
def __init__(
self,
func,
args=None,
kwargs=None,
priority=None,
eta=None,
job_uuid=None,
max_retries=None,
description=None,
channel=None,
identity_key=None,
):
"""Create a Job
:param func: function to execute
:type func: function
:param args: arguments for func
:type args: tuple
:param kwargs: keyworkd arguments for func
:type kwargs: dict
:param priority: priority of the job,
the smaller is the higher priority
:type priority: int
:param eta: the job can be executed only after this datetime
(or now + timedelta)
:type eta: datetime or timedelta
:param job_uuid: UUID of the job
:param max_retries: maximum number of retries before giving up and set
the job state to 'failed'. A value of 0 means infinite retries.
:param description: human description of the job. If None, description
is computed from the function doc or name
:param channel: The complete channel name to use to process the job.
:param identity_key: A hash to uniquely identify a job, or a function
that returns this hash (the function takes the job
as argument)
:param env: Odoo Environment
:type env: :class:`odoo.api.Environment`
"""
if args is None:
args = ()
if isinstance(args, list):
args = tuple(args)
assert isinstance(args, tuple), "%s: args are not a tuple" % args
if kwargs is None:
kwargs = {}
assert isinstance(kwargs, dict), "%s: kwargs are not a dict" % kwargs
if not _is_model_method(func):
raise TypeError("Job accepts only methods of Models")
recordset = func.__self__
env = recordset.env
self.method_name = func.__name__
self.recordset = recordset
self.env = env
self.job_model = self.env["queue.job"]
self.job_model_name = "queue.job"
self.job_config = (
self.env["queue.job.function"]
.sudo()
.job_config(
self.env["queue.job.function"].job_function_name(
self.model_name, self.method_name
)
)
)
self.state = PENDING
self.retry = 0
if max_retries is None:
self.max_retries = DEFAULT_MAX_RETRIES
else:
self.max_retries = max_retries
self._uuid = job_uuid
self.args = args
self.kwargs = kwargs
self.priority = priority
if self.priority is None:
self.priority = DEFAULT_PRIORITY
self.date_created = datetime.now()
self._description = description
if isinstance(identity_key, str):
self._identity_key = identity_key
self._identity_key_func = None
else:
# we'll compute the key on the fly when called
# from the function
self._identity_key = None
self._identity_key_func = identity_key
self.date_enqueued = None
self.date_started = None
self.date_done = None
self.result = None
self.exc_info = None
if "company_id" in env.context:
company_id = env.context["company_id"]
else:
company_id = env.company.id
self.company_id = company_id
self._eta = None
self.eta = eta
self.channel = channel
self.worker_pid = None
def perform(self):
"""Execute the job.
The job is executed with the user which has initiated it.
"""
self.retry += 1
try:
self.result = self.func(*tuple(self.args), **self.kwargs)
except RetryableJobError as err:
if err.ignore_retry:
self.retry -= 1
raise
elif not self.max_retries: # infinite retries
raise
elif self.retry >= self.max_retries:
type_, value, traceback = sys.exc_info()
# change the exception type but keep the original
# traceback and message:
# http://blog.ianbicking.org/2007/09/12/re-raising-exceptions/
new_exc = FailedJobError(
"Max. retries (%d) reached: %s" % (self.max_retries, value or type_)
)
raise new_exc from err
raise
return self.result
def store(self):
"""Store the Job"""
vals = {
"state": self.state,
"priority": self.priority,
"retry": self.retry,
"max_retries": self.max_retries,
"exc_info": self.exc_info,
"company_id": self.company_id,
"result": str(self.result) if self.result else False,
"date_enqueued": False,
"date_started": False,
"date_done": False,
"eta": False,
"identity_key": False,
"worker_pid": self.worker_pid,
}
if self.date_enqueued:
vals["date_enqueued"] = self.date_enqueued
if self.date_started:
vals["date_started"] = self.date_started
if self.date_done:
vals["date_done"] = self.date_done
if self.eta:
vals["eta"] = self.eta
if self.identity_key:
vals["identity_key"] = self.identity_key
job_model = self.env["queue.job"]
# The sentinel is used to prevent edition sensitive fields (such as
# method_name) from RPC methods.
edit_sentinel = job_model.EDIT_SENTINEL
db_record = self.db_record()
if db_record:
db_record.with_context(_job_edit_sentinel=edit_sentinel).write(vals)
else:
date_created = self.date_created
# The following values must never be modified after the
# creation of the job
vals.update(
{
"uuid": self.uuid,
"name": self.description,
"date_created": date_created,
"method_name": self.method_name,
"records": self.recordset,
"args": self.args,
"kwargs": self.kwargs,
}
)
# it the channel is not specified, lets the job_model compute
# the right one to use
if self.channel:
vals.update({"channel": self.channel})
job_model.with_context(_job_edit_sentinel=edit_sentinel).sudo().create(vals)
def db_record(self):
return self.db_record_from_uuid(self.env, self.uuid)
@property
def func(self):
recordset = self.recordset.with_context(job_uuid=self.uuid)
return getattr(recordset, self.method_name)
@property
def identity_key(self):
if self._identity_key is None:
if self._identity_key_func:
self._identity_key = self._identity_key_func(self)
return self._identity_key
@identity_key.setter
def identity_key(self, value):
if isinstance(value, str):
self._identity_key = value
self._identity_key_func = None
else:
# we'll compute the key on the fly when called
# from the function
self._identity_key = None
self._identity_key_func = value
@property
def description(self):
if self._description:
return self._description
elif self.func.__doc__:
return self.func.__doc__.splitlines()[0].strip()
else:
return "{}.{}".format(self.model_name, self.func.__name__)
@property
def uuid(self):
"""Job ID, this is an UUID """
if self._uuid is None:
self._uuid = str(uuid.uuid4())
return self._uuid
@property
def model_name(self):
return self.recordset._name
@property
def user_id(self):
return self.recordset.env.uid
@property
def eta(self):
return self._eta
@eta.setter
def eta(self, value):
if not value:
self._eta = None
elif isinstance(value, timedelta):
self._eta = datetime.now() + value
elif isinstance(value, int):
self._eta = datetime.now() + timedelta(seconds=value)
else:
self._eta = value
def set_pending(self, result=None, reset_retry=True):
self.state = PENDING
self.date_enqueued = None
self.date_started = None
self.worker_pid = None
if reset_retry:
self.retry = 0
if result is not None:
self.result = result
def set_enqueued(self):
self.state = ENQUEUED
self.date_enqueued = datetime.now()
self.date_started = None
self.worker_pid = None
def set_started(self):
self.state = STARTED
self.date_started = datetime.now()
self.worker_pid = os.getpid()
def set_done(self, result=None):
self.state = DONE
self.exc_info = None
self.date_done = datetime.now()
if result is not None:
self.result = result
def set_failed(self, exc_info=None):
self.state = FAILED
if exc_info is not None:
self.exc_info = exc_info
def __repr__(self):
return "<Job %s, priority:%d>" % (self.uuid, self.priority)
def _get_retry_seconds(self, seconds=None):
retry_pattern = self.job_config.retry_pattern
if not seconds and retry_pattern:
# ordered from higher to lower count of retries
patt = sorted(retry_pattern.items(), key=lambda t: t[0])
seconds = RETRY_INTERVAL
for retry_count, postpone_seconds in patt:
if self.retry >= retry_count:
seconds = postpone_seconds
else:
break
elif not seconds:
seconds = RETRY_INTERVAL
if isinstance(seconds, (list, tuple)):
seconds = randint(seconds[0], seconds[1])
return seconds
def postpone(self, result=None, seconds=None):
"""Postpone the job
Write an estimated time arrival to n seconds
later than now. Used when an retryable exception
want to retry a job later.
"""
eta_seconds = self._get_retry_seconds(seconds)
self.eta = timedelta(seconds=eta_seconds)
self.exc_info = None
if result is not None:
self.result = result
def related_action(self):
record = self.db_record()
if not self.job_config.related_action_enable:
return None
funcname = self.job_config.related_action_func_name
if not funcname:
funcname = record._default_related_action
if not isinstance(funcname, str):
raise ValueError(
"related_action must be the name of the "
"method on queue.job as string"
)
action = getattr(record, funcname)
action_kwargs = self.job_config.related_action_kwargs
return action(**action_kwargs)
def _is_model_method(func):
return inspect.ismethod(func) and isinstance(
func.__self__.__class__, odoo.models.MetaModel
)

149
queue_job/jobrunner/__init__.py Executable file
View File

@@ -0,0 +1,149 @@
# Copyright (c) 2015-2016 ACSONE SA/NV (<http://acsone.eu>)
# Copyright 2016 Camptocamp SA
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html)
import logging
from threading import Thread
import time
from odoo.service import server
from odoo.tools import config
try:
from odoo.addons.server_environment import serv_config
if serv_config.has_section("queue_job"):
queue_job_config = serv_config["queue_job"]
else:
queue_job_config = {}
except ImportError:
queue_job_config = config.misc.get("queue_job", {})
from .runner import QueueJobRunner, _channels
_logger = logging.getLogger(__name__)
START_DELAY = 5
# Here we monkey patch the Odoo server to start the job runner thread
# in the main server process (and not in forked workers). This is
# very easy to deploy as we don't need another startup script.
class QueueJobRunnerThread(Thread):
def __init__(self):
Thread.__init__(self)
self.daemon = True
self.runner = QueueJobRunner.from_environ_or_config()
def run(self):
# sleep a bit to let the workers start at ease
time.sleep(START_DELAY)
self.runner.run()
def stop(self):
self.runner.stop()
class WorkerJobRunner(server.Worker):
""" Jobrunner workers """
def __init__(self, multi):
super().__init__(multi)
self.watchdog_timeout = None
self.runner = QueueJobRunner.from_environ_or_config()
def sleep(self):
pass
def signal_handler(self, sig, frame):
_logger.debug("WorkerJobRunner (%s) received signal %s", self.pid, sig)
super().signal_handler(sig, frame)
self.runner.stop()
def process_work(self):
_logger.debug("WorkerJobRunner (%s) starting up", self.pid)
time.sleep(START_DELAY)
self.runner.run()
runner_thread = None
def _is_runner_enabled():
return not _channels().strip().startswith("root:0")
def _start_runner_thread(server_type):
global runner_thread
if not config["stop_after_init"]:
if _is_runner_enabled():
_logger.info("starting jobrunner thread (in %s)", server_type)
runner_thread = QueueJobRunnerThread()
runner_thread.start()
else:
_logger.info(
"jobrunner thread (in %s) NOT started, "
"because the root channel's capacity is set to 0",
server_type,
)
orig_prefork__init__ = server.PreforkServer.__init__
orig_prefork_process_spawn = server.PreforkServer.process_spawn
orig_prefork_worker_pop = server.PreforkServer.worker_pop
orig_threaded_start = server.ThreadedServer.start
orig_threaded_stop = server.ThreadedServer.stop
def prefork__init__(server, app):
res = orig_prefork__init__(server, app)
server.jobrunner = {}
return res
def prefork_process_spawn(server):
orig_prefork_process_spawn(server)
if not hasattr(server, "jobrunner"):
# if 'queue_job' is not in server wide modules, PreforkServer is
# not initialized with a 'jobrunner' attribute, skip this
return
if not server.jobrunner and _is_runner_enabled():
server.worker_spawn(WorkerJobRunner, server.jobrunner)
def prefork_worker_pop(server, pid):
res = orig_prefork_worker_pop(server, pid)
if not hasattr(server, "jobrunner"):
# if 'queue_job' is not in server wide modules, PreforkServer is
# not initialized with a 'jobrunner' attribute, skip this
return res
if pid in server.jobrunner:
server.jobrunner.pop(pid)
return res
def threaded_start(server, *args, **kwargs):
res = orig_threaded_start(server, *args, **kwargs)
_start_runner_thread("threaded server")
return res
def threaded_stop(server):
global runner_thread
if runner_thread:
runner_thread.stop()
res = orig_threaded_stop(server)
if runner_thread:
runner_thread.join()
runner_thread = None
return res
server.PreforkServer.__init__ = prefork__init__
server.PreforkServer.process_spawn = prefork_process_spawn
server.PreforkServer.worker_pop = prefork_worker_pop
server.ThreadedServer.start = threaded_start
server.ThreadedServer.stop = threaded_stop

1060
queue_job/jobrunner/channels.py Executable file

File diff suppressed because it is too large Load Diff

520
queue_job/jobrunner/runner.py Executable file
View File

@@ -0,0 +1,520 @@
# Copyright (c) 2015-2016 ACSONE SA/NV (<http://acsone.eu>)
# Copyright 2015-2016 Camptocamp SA
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html)
"""
What is the job runner?
-----------------------
The job runner is the main process managing the dispatch of delayed jobs to
available Odoo workers
How does it work?
-----------------
* It starts as a thread in the Odoo main process or as a new worker
* It receives postgres NOTIFY messages each time jobs are
added or updated in the queue_job table.
* It maintains an in-memory priority queue of jobs that
is populated from the queue_job tables in all databases.
* It does not run jobs itself, but asks Odoo to run them through an
anonymous ``/queue_job/runjob`` HTTP request. [1]_
How to use it?
--------------
* Optionally adjust your configuration through environment variables:
- ``ODOO_QUEUE_JOB_CHANNELS=root:4`` (or any other channels
configuration), default ``root:1``.
- ``ODOO_QUEUE_JOB_SCHEME=https``, default ``http``.
- ``ODOO_QUEUE_JOB_HOST=load-balancer``, default ``http_interface``
or ``localhost`` if unset.
- ``ODOO_QUEUE_JOB_PORT=443``, default ``http_port`` or 8069 if unset.
- ``ODOO_QUEUE_JOB_HTTP_AUTH_USER=jobrunner``, default empty.
- ``ODOO_QUEUE_JOB_HTTP_AUTH_PASSWORD=s3cr3t``, default empty.
- ``ODOO_QUEUE_JOB_JOBRUNNER_DB_HOST=master-db``, default ``db_host``
or ``False`` if unset.
- ``ODOO_QUEUE_JOB_JOBRUNNER_DB_PORT=5432``, default ``db_port``
or ``False`` if unset.
* Alternatively, configure the channels through the Odoo configuration
file, like:
.. code-block:: ini
[queue_job]
channels = root:4
scheme = https
host = load-balancer
port = 443
http_auth_user = jobrunner
http_auth_password = s3cr3t
jobrunner_db_host = master-db
jobrunner_db_port = 5432
* Or, if using ``anybox.recipe.odoo``, add this to your buildout configuration:
.. code-block:: ini
[odoo]
recipe = anybox.recipe.odoo
(...)
queue_job.channels = root:4
queue_job.scheme = https
queue_job.host = load-balancer
queue_job.port = 443
queue_job.http_auth_user = jobrunner
queue_job.http_auth_password = s3cr3t
* Start Odoo with ``--load=web,web_kanban,queue_job``
and ``--workers`` greater than 1 [2]_, or set the ``server_wide_modules``
option in The Odoo configuration file:
.. code-block:: ini
[options]
(...)
workers = 4
server_wide_modules = web,web_kanban,queue_job
(...)
* Or, if using ``anybox.recipe.odoo``:
.. code-block:: ini
[odoo]
recipe = anybox.recipe.odoo
(...)
options.workers = 4
options.server_wide_modules = web,web_kanban,queue_job
* Confirm the runner is starting correctly by checking the odoo log file:
.. code-block:: none
...INFO...queue_job.jobrunner.runner: starting
...INFO...queue_job.jobrunner.runner: initializing database connections
...INFO...queue_job.jobrunner.runner: queue job runner ready for db <dbname>
...INFO...queue_job.jobrunner.runner: database connections ready
* Create jobs (eg using base_import_async) and observe they
start immediately and in parallel.
* Tip: to enable debug logging for the queue job, use
``--log-handler=odoo.addons.queue_job:DEBUG``
Caveat
------
* After creating a new database or installing queue_job on an
existing database, Odoo must be restarted for the runner to detect it.
* When Odoo shuts down normally, it waits for running jobs to finish.
However, when the Odoo server crashes or is otherwise force-stopped,
running jobs are interrupted while the runner has no chance to know
they have been aborted. In such situations, jobs may remain in
``started`` or ``enqueued`` state after the Odoo server is halted.
Since the runner has no way to know if they are actually running or
not, and does not know for sure if it is safe to restart the jobs,
it does not attempt to restart them automatically. Such stale jobs
therefore fill the running queue and prevent other jobs to start.
You must therefore requeue them manually, either from the Jobs view,
or by running the following SQL statement *before starting Odoo*:
.. code-block:: sql
update queue_job set state='pending' where state in ('started', 'enqueued')
.. rubric:: Footnotes
.. [1] From a security standpoint, it is safe to have an anonymous HTTP
request because this request only accepts to run jobs that are
enqueued.
.. [2] It works with the threaded Odoo server too, although this way
of running Odoo is obviously not for production purposes.
"""
import datetime
import logging
import os
import select
import threading
import time
from contextlib import closing, contextmanager
import psycopg2
import requests
from psycopg2.extensions import ISOLATION_LEVEL_AUTOCOMMIT
import odoo
from odoo.tools import config
from . import queue_job_config
from .channels import ENQUEUED, NOT_DONE, PENDING, ChannelManager
SELECT_TIMEOUT = 60
ERROR_RECOVERY_DELAY = 5
_logger = logging.getLogger(__name__)
# Unfortunately, it is not possible to extend the Odoo
# server command line arguments, so we resort to environment variables
# to configure the runner (channels mostly).
#
# On the other hand, the odoo configuration file can be extended at will,
# so we check it in addition to the environment variables.
def _channels():
return (
os.environ.get("ODOO_QUEUE_JOB_CHANNELS")
or queue_job_config.get("channels")
or "root:1"
)
def _datetime_to_epoch(dt):
# important: this must return the same as postgresql
# EXTRACT(EPOCH FROM TIMESTAMP dt)
return (dt - datetime.datetime(1970, 1, 1)).total_seconds()
def _odoo_now():
dt = datetime.datetime.utcnow()
return _datetime_to_epoch(dt)
def _connection_info_for(db_name):
db_or_uri, connection_info = odoo.sql_db.connection_info_for(db_name)
for p in ("host", "port"):
cfg = os.environ.get(
"ODOO_QUEUE_JOB_JOBRUNNER_DB_%s" % p.upper()
) or queue_job_config.get("jobrunner_db_" + p)
if cfg:
connection_info[p] = cfg
return connection_info
def _async_http_get(scheme, host, port, user, password, db_name, job_uuid):
# Method to set failed job (due to timeout, etc) as pending,
# to avoid keeping it as enqueued.
def set_job_pending():
connection_info = _connection_info_for(db_name)
conn = psycopg2.connect(**connection_info)
conn.set_isolation_level(ISOLATION_LEVEL_AUTOCOMMIT)
with closing(conn.cursor()) as cr:
cr.execute(
"UPDATE queue_job SET state=%s, "
"date_enqueued=NULL, date_started=NULL "
"WHERE uuid=%s and state=%s "
"RETURNING uuid",
(PENDING, job_uuid, ENQUEUED),
)
if cr.fetchone():
_logger.warning(
"state of job %s was reset from %s to %s",
job_uuid,
ENQUEUED,
PENDING,
)
# TODO: better way to HTTP GET asynchronously (grequest, ...)?
# if this was python3 I would be doing this with
# asyncio, aiohttp and aiopg
def urlopen():
url = "{}://{}:{}/queue_job/runjob?db={}&job_uuid={}".format(
scheme, host, port, db_name, job_uuid
)
try:
auth = None
if user:
auth = (user, password)
# we are not interested in the result, so we set a short timeout
# but not too short so we trap and log hard configuration errors
response = requests.get(url, timeout=1, auth=auth)
# raise_for_status will result in either nothing, a Client Error
# for HTTP Response codes between 400 and 500 or a Server Error
# for codes between 500 and 600
response.raise_for_status()
except requests.Timeout:
set_job_pending()
except Exception:
_logger.exception("exception in GET %s", url)
set_job_pending()
thread = threading.Thread(target=urlopen)
thread.daemon = True
thread.start()
class Database(object):
def __init__(self, db_name):
self.db_name = db_name
connection_info = _connection_info_for(db_name)
self.conn = psycopg2.connect(**connection_info)
self.conn.set_isolation_level(ISOLATION_LEVEL_AUTOCOMMIT)
self.has_queue_job = self._has_queue_job()
if self.has_queue_job:
self._initialize()
def close(self):
# pylint: disable=except-pass
# if close fail for any reason, it's either because it's already closed
# and we don't care, or for any reason but anyway it will be closed on
# del
try:
self.conn.close()
except Exception:
pass
self.conn = None
def _has_queue_job(self):
with closing(self.conn.cursor()) as cr:
cr.execute(
"SELECT 1 FROM pg_tables WHERE tablename=%s", ("ir_module_module",)
)
if not cr.fetchone():
_logger.debug("%s doesn't seem to be an odoo db", self.db_name)
return False
cr.execute(
"SELECT 1 FROM ir_module_module WHERE name=%s AND state=%s",
("queue_job", "installed"),
)
if not cr.fetchone():
_logger.debug("queue_job is not installed for db %s", self.db_name)
return False
cr.execute(
"""SELECT COUNT(1)
FROM information_schema.triggers
WHERE event_object_table = %s
AND trigger_name = %s""",
("queue_job", "queue_job_notify"),
)
if cr.fetchone()[0] != 3: # INSERT, DELETE, UPDATE
_logger.error(
"queue_job_notify trigger is missing in db %s", self.db_name
)
return False
return True
def _initialize(self):
with closing(self.conn.cursor()) as cr:
cr.execute("LISTEN queue_job")
@contextmanager
def select_jobs(self, where, args):
# pylint: disable=sql-injection
# the checker thinks we are injecting values but we are not, we are
# adding the where conditions, values are added later properly with
# parameters
query = (
"SELECT channel, uuid, id as seq, date_created, "
"priority, EXTRACT(EPOCH FROM eta), state "
"FROM queue_job WHERE %s" % (where,)
)
with closing(self.conn.cursor("select_jobs", withhold=True)) as cr:
cr.execute(query, args)
yield cr
def keep_alive(self):
query = "SELECT 1"
with closing(self.conn.cursor()) as cr:
cr.execute(query)
def set_job_enqueued(self, uuid):
with closing(self.conn.cursor()) as cr:
cr.execute(
"UPDATE queue_job SET state=%s, "
"date_enqueued=date_trunc('seconds', "
" now() at time zone 'utc') "
"WHERE uuid=%s",
(ENQUEUED, uuid),
)
class QueueJobRunner(object):
def __init__(
self,
scheme="http",
host="localhost",
port=8069,
user=None,
password=None,
channel_config_string=None,
):
self.scheme = scheme
self.host = host
self.port = port
self.user = user
self.password = password
self.channel_manager = ChannelManager()
if channel_config_string is None:
channel_config_string = _channels()
self.channel_manager.simple_configure(channel_config_string)
self.db_by_name = {}
self._stop = False
self._stop_pipe = os.pipe()
@classmethod
def from_environ_or_config(cls):
scheme = os.environ.get("ODOO_QUEUE_JOB_SCHEME") or queue_job_config.get(
"scheme"
)
host = (
os.environ.get("ODOO_QUEUE_JOB_HOST")
or queue_job_config.get("host")
or config["http_interface"]
)
port = (
os.environ.get("ODOO_QUEUE_JOB_PORT")
or queue_job_config.get("port")
or config["http_port"]
)
user = os.environ.get("ODOO_QUEUE_JOB_HTTP_AUTH_USER") or queue_job_config.get(
"http_auth_user"
)
password = os.environ.get(
"ODOO_QUEUE_JOB_HTTP_AUTH_PASSWORD"
) or queue_job_config.get("http_auth_password")
runner = cls(
scheme=scheme or "http",
host=host or "localhost",
port=port or 8069,
user=user,
password=password,
)
return runner
def get_db_names(self):
if config["db_name"]:
db_names = config["db_name"].split(",")
else:
db_names = odoo.service.db.exp_list(True)
return db_names
def close_databases(self, remove_jobs=True):
for db_name, db in self.db_by_name.items():
try:
if remove_jobs:
self.channel_manager.remove_db(db_name)
db.close()
except Exception:
_logger.warning("error closing database %s", db_name, exc_info=True)
self.db_by_name = {}
def initialize_databases(self):
for db_name in self.get_db_names():
db = Database(db_name)
if db.has_queue_job:
self.db_by_name[db_name] = db
with db.select_jobs("state in %s", (NOT_DONE,)) as cr:
for job_data in cr:
self.channel_manager.notify(db_name, *job_data)
_logger.info("queue job runner ready for db %s", db_name)
def run_jobs(self):
now = _odoo_now()
for job in self.channel_manager.get_jobs_to_run(now):
if self._stop:
break
_logger.info("asking Odoo to run job %s on db %s", job.uuid, job.db_name)
self.db_by_name[job.db_name].set_job_enqueued(job.uuid)
_async_http_get(
self.scheme,
self.host,
self.port,
self.user,
self.password,
job.db_name,
job.uuid,
)
def process_notifications(self):
for db in self.db_by_name.values():
if not db.conn.notifies:
# If there are no activity in the queue_job table it seems that
# tcp keepalives are not sent (in that very specific scenario),
# causing some intermediaries (such as haproxy) to close the
# connection, making the jobrunner to restart on a socket error
db.keep_alive()
while db.conn.notifies:
if self._stop:
break
notification = db.conn.notifies.pop()
uuid = notification.payload
with db.select_jobs("uuid = %s", (uuid,)) as cr:
job_datas = cr.fetchone()
if job_datas:
self.channel_manager.notify(db.db_name, *job_datas)
else:
self.channel_manager.remove_job(uuid)
def wait_notification(self):
for db in self.db_by_name.values():
if db.conn.notifies:
# something is going on in the queue, no need to wait
return
# wait for something to happen in the queue_job tables
# we'll select() on database connections and the stop pipe
conns = [db.conn for db in self.db_by_name.values()]
conns.append(self._stop_pipe[0])
# look if the channels specify a wakeup time
wakeup_time = self.channel_manager.get_wakeup_time()
if not wakeup_time:
# this could very well be no timeout at all, because
# any activity in the job queue will wake us up, but
# let's have a timeout anyway, just to be safe
timeout = SELECT_TIMEOUT
else:
timeout = wakeup_time - _odoo_now()
# wait for a notification or a timeout;
# if timeout is negative (ie wakeup time in the past),
# do not wait; this should rarely happen
# because of how get_wakeup_time is designed; actually
# if timeout remains a large negative number, it is most
# probably a bug
_logger.debug("select() timeout: %.2f sec", timeout)
if timeout > 0:
conns, _, _ = select.select(conns, [], [], timeout)
if conns and not self._stop:
for conn in conns:
conn.poll()
def stop(self):
_logger.info("graceful stop requested")
self._stop = True
# wakeup the select() in wait_notification
os.write(self._stop_pipe[1], b".")
def run(self):
_logger.info("starting")
while not self._stop:
# outer loop does exception recovery
try:
_logger.info("initializing database connections")
# TODO: how to detect new databases or databases
# on which queue_job is installed after server start?
self.initialize_databases()
_logger.info("database connections ready")
# inner loop does the normal processing
while not self._stop:
self.process_notifications()
self.run_jobs()
self.wait_notification()
except KeyboardInterrupt:
self.stop()
except InterruptedError:
# Interrupted system call, i.e. KeyboardInterrupt during select
self.stop()
except Exception:
_logger.exception(
"exception: sleeping %ds and retrying", ERROR_RECOVERY_DELAY
)
self.close_databases()
time.sleep(ERROR_RECOVERY_DELAY)
self.close_databases(remove_jobs=False)
_logger.info("stopped")

5
queue_job/models/__init__.py Executable file
View File

@@ -0,0 +1,5 @@
from . import base
from . import ir_model_fields
from . import queue_job
from . import queue_job_channel
from . import queue_job_function

191
queue_job/models/base.py Executable file
View File

@@ -0,0 +1,191 @@
# Copyright 2016 Camptocamp
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html)
import functools
import logging
import os
from odoo import models
from ..job import DelayableRecordset
_logger = logging.getLogger(__name__)
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``
The returned instance allows to enqueue any method of the recordset's
Model.
Usage::
self.env['res.users'].with_delay().write({'name': 'test'})
``with_delay()`` accepts job properties which specify how the job will
be executed.
Usage with job properties::
delayable = env['a.model'].with_delay(priority=30, eta=60*60*5)
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
: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`).
:return: instance of a DelayableRecordset
:rtype: :class:`odoo.addons.queue_job.job.DelayableRecordset`
Note for developers: if you want to run tests or simply disable
jobs queueing for debugging purposes, you can:
a. set the env var `TEST_QUEUE_JOB_NO_DELAY=1`
b. pass a ctx key `test_queue_job_no_delay=1`
In tests you'll have to mute the logger like:
@mute_logger('odoo.addons.queue_job.models.base')
"""
if os.getenv("TEST_QUEUE_JOB_NO_DELAY"):
_logger.warning(
"`TEST_QUEUE_JOB_NO_DELAY` env var found. NO JOB scheduled."
)
return self
if self.env.context.get("test_queue_job_no_delay"):
_logger.warning(
"`test_queue_job_no_delay` ctx key found. NO JOB scheduled."
)
return self
return DelayableRecordset(
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 self.env.context.get("_job_force_sync")
or self.env.context.get("test_queue_job_no_delay")
):
# 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)

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"},
)

386
queue_job/models/queue_job.py Executable file
View File

@@ -0,0 +1,386 @@
# Copyright 2013-2020 Camptocamp SA
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html)
import logging
from datetime import datetime, timedelta
from odoo import _, api, exceptions, fields, models
from odoo.osv import expression
from ..fields import JobSerialized
from ..job import DONE, PENDING, STATES, 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",
"records",
"args",
"kwargs",
)
uuid = fields.Char(string="UUID", readonly=True, index=True, required=True)
user_id = fields.Many2one(
comodel_name="res.users",
string="User ID",
compute="_compute_user_id",
inverse="_inverse_user_id",
store=True,
)
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", compute="_compute_model_name", store=True, 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,
)
args = JobSerialized(readonly=True, base_type=tuple)
kwargs = JobSerialized(readonly=True, base_type=dict)
func_string = fields.Char(
string="Task", compute="_compute_func_string", readonly=True, store=True
)
state = fields.Selection(STATES, readonly=True, required=True, index=True)
priority = fields.Integer()
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)
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.",
)
channel_method_name = fields.Char(
readonly=True, compute="_compute_job_function", store=True
)
job_function_id = fields.Many2one(
comodel_name="queue.job.function",
compute="_compute_job_function",
string="Job Function",
readonly=True,
store=True,
)
override_channel = fields.Char()
channel = fields.Char(
compute="_compute_channel", inverse="_inverse_channel", store=True, index=True
)
identity_key = fields.Char()
worker_pid = fields.Integer()
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') AND identity_key IS NOT NULL;"
)
@api.depends("records")
def _compute_user_id(self):
for record in self:
record.user_id = record.records.env.uid
def _inverse_user_id(self):
for record in self.with_context(_job_edit_sentinel=self.EDIT_SENTINEL):
record.records = record.records.with_user(record.user_id.id)
@api.depends("records")
def _compute_model_name(self):
for record in self:
record.model_name = record.records._name
@api.depends("records")
def _compute_record_ids(self):
for record in self:
record.record_ids = record.records.ids
def _inverse_channel(self):
for record in self:
record.override_channel = record.channel
@api.depends("job_function_id.channel_id")
def _compute_channel(self):
for record in self:
channel = (
record.override_channel or record.job_function_id.channel or "root"
)
if record.channel != channel:
record.channel = channel
@api.depends("model_name", "method_name", "job_function_id.channel_id")
def _compute_job_function(self):
for record in self:
func_model = self.env["queue.job.function"]
channel_method_name = func_model.job_function_name(
record.model_name, record.method_name
)
function = func_model.search([("name", "=", channel_method_name)], limit=1)
record.channel_method_name = channel_method_name
record.job_function_id = function
@api.depends("model_name", "method_name", "records", "args", "kwargs")
def _compute_func_string(self):
for record in self:
model = repr(record.records)
args = [repr(arg) for arg in record.args]
kwargs = ["{}={!r}".format(key, val) for key, val in record.kwargs.items()]
all_args = ", ".join(args + kwargs)
record.func_string = "{}.{}({})".format(model, record.method_name, all_args)
@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 created by calling 'with_delay()'.")
)
return super().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
)
)
if vals.get("state") == "failed":
self._message_post_on_failure()
return super().write(vals)
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 _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)
elif state == PENDING:
job_.set_pending(result=result)
else:
raise ValueError("State not supported: %s" % state)
job_.store()
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 requeue(self):
self._change_job_state(PENDING)
return True
def _message_post_on_failure(self):
# subscribe the users now to avoid to subscribe them
# at every job creation
domain = self._subscribe_users_domain()
users = self.env["res.users"].search(domain)
self.message_subscribe(partner_ids=users.mapped("partner_id").ids)
for record in self:
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 the job. "
"More details in the 'Exception Information' section."
)
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),
("channel", "=", channel.complete_name),
],
limit=1000,
)
if jobs:
jobs.unlink()
else:
break
return True
def requeue_stuck_jobs(self, enqueued_delta=5, started_delta=0):
"""Fix jobs that are in a bad states
:param in_queue_delta: lookup time in minutes for jobs
that are in enqueued state
:param started_delta: lookup time in minutes for jobs
that are in enqueued state,
0 means that it is not checked
"""
self._get_stuck_jobs_to_requeue(
enqueued_delta=enqueued_delta, started_delta=started_delta
).requeue()
return True
def _get_stuck_jobs_domain(self, queue_dl, started_dl):
domain = []
now = fields.datetime.now()
if queue_dl:
queue_dl = now - timedelta(minutes=queue_dl)
domain.append(
[
"&",
("date_enqueued", "<=", fields.Datetime.to_string(queue_dl)),
("state", "=", "enqueued"),
]
)
if started_dl:
started_dl = now - timedelta(minutes=started_dl)
domain.append(
[
"&",
("date_started", "<=", fields.Datetime.to_string(started_dl)),
("state", "=", "started"),
]
)
if not domain:
raise exceptions.ValidationError(
_("If both parameters are 0, ALL jobs will be requeued!")
)
return expression.OR(domain)
def _get_stuck_jobs_to_requeue(self, enqueued_delta, started_delta):
job_model = self.env["queue.job"]
stuck_jobs = job_model.search(
self._get_stuck_jobs_domain(enqueued_delta, started_delta)
)
return stuck_jobs
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):
_logger.info("Running test job.")

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
)
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,253 @@
# 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 ",
)
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"].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):
raise exceptions.UserError(self._retry_pattern_format_error_message())
@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):
raise exceptions.UserError(self._related_action_format_error_message())
@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={},
)
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 = {
int(try_count): postpone_seconds
for try_count, postpone_seconds in self.retry_pattern.items()
}
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", {}),
)
def _retry_pattern_format_error_message(self):
return _(
"Unexpected format of Retry Pattern for {}.\n"
"Example of valid format:\n"
"{{1: 300, 5: 600, 10: 1200, 15: 3000}}"
).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:
int(value)
except ValueError:
raise exceptions.UserError(
record._retry_pattern_format_error_message()
)
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

33
queue_job/post_init_hook.py Executable file
View File

@@ -0,0 +1,33 @@
# Copyright 2020 ACSONE SA/NV
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
import logging
logger = logging.getLogger(__name__)
def post_init_hook(cr, registry):
# this is the trigger that sends notifications when jobs change
logger.info("Create queue_job_notify trigger")
cr.execute(
"""
DROP TRIGGER IF EXISTS queue_job_notify ON queue_job;
CREATE OR REPLACE
FUNCTION queue_job_notify() RETURNS trigger AS $$
BEGIN
IF TG_OP = 'DELETE' THEN
IF OLD.state != 'done' THEN
PERFORM pg_notify('queue_job', OLD.uuid);
END IF;
ELSE
PERFORM pg_notify('queue_job', NEW.uuid);
END IF;
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER queue_job_notify
AFTER INSERT OR UPDATE OR DELETE
ON queue_job
FOR EACH ROW EXECUTE PROCEDURE queue_job_notify();
"""
)

43
queue_job/readme/CONFIGURE.rst Executable file
View File

@@ -0,0 +1,43 @@
* Using environment variables and command line:
* Adjust environment variables (optional):
- ``ODOO_QUEUE_JOB_CHANNELS=root:4`` or any other channels configuration.
The default is ``root:1``
- if ``xmlrpc_port`` is not set: ``ODOO_QUEUE_JOB_PORT=8069``
* Start Odoo with ``--load=web,queue_job``
and ``--workers`` greater than 1. [1]_
* Using the Odoo configuration file:
.. code-block:: ini
[options]
(...)
workers = 6
server_wide_modules = web,queue_job
(...)
[queue_job]
channels = root:2
* Confirm the runner is starting correctly by checking the odoo log file:
.. code-block::
...INFO...queue_job.jobrunner.runner: starting
...INFO...queue_job.jobrunner.runner: initializing database connections
...INFO...queue_job.jobrunner.runner: queue job runner ready for db <dbname>
...INFO...queue_job.jobrunner.runner: database connections ready
* Create jobs (eg using ``base_import_async``) and observe they
start immediately and in parallel.
* Tip: to enable debug logging for the queue job, use
``--log-handler=odoo.addons.queue_job:DEBUG``
.. [1] It works with the threaded Odoo server too, although this way
of running Odoo is obviously not for production purposes.

View File

@@ -0,0 +1,11 @@
* Guewen Baconnier <guewen.baconnier@camptocamp.com>
* Stéphane Bidoul <stephane.bidoul@acsone.eu>
* Matthieu Dietrich <matthieu.dietrich@camptocamp.com>
* Jos De Graeve <Jos.DeGraeve@apertoso.be>
* David Lefever <dl@taktik.be>
* Laurent Mignon <laurent.mignon@acsone.eu>
* Laetitia Gangloff <laetitia.gangloff@acsone.eu>
* Cédric Pigeon <cedric.pigeon@acsone.eu>
* Tatiana Deribina <tatiana.deribina@avoin.systems>
* Souheil Bejaoui <souheil.bejaoui@acsone.eu>
* Eric Antones <eantones@nuobit.com>

View File

@@ -0,0 +1,46 @@
This addon adds an integrated Job Queue to Odoo.
It allows to postpone method calls executed asynchronously.
Jobs are executed in the background by a ``Jobrunner``, in their own transaction.
Example:
.. code-block:: python
from odoo import models, fields, api
class MyModel(models.Model):
_name = 'my.model'
def my_method(self, a, k=None):
_logger.info('executed with a: %s and k: %s', a, k)
class MyOtherModel(models.Model):
_name = 'my.other.model'
def button_do_stuff(self):
self.env['my.model'].with_delay().my_method('a', k=2)
In the snippet of code above, when we call ``button_do_stuff``, a job **capturing
the method and arguments** will be postponed. It will be executed as soon as the
Jobrunner has a free bucket, which can be instantaneous if no other job is
running.
Features:
* Views for jobs, jobs are stored in PostgreSQL
* Jobrunner: execute the jobs, highly efficient thanks to PostgreSQL's NOTIFY
* Channels: give a capacity for the root channel and its sub-channels and
segregate jobs in them. Allow for instance to restrict heavy jobs to be
executed one at a time while little ones are executed 4 at a times.
* Retries: Ability to retry jobs by raising a type of exception
* Retry Pattern: the 3 first tries, retry after 10 seconds, the 5 next tries,
retry after 1 minutes, ...
* Job properties: priorities, estimated time of arrival (ETA), custom
description, number of retries
* Related Actions: link an action on the job view, such as open the record
concerned by the job

18
queue_job/readme/HISTORY.rst Executable file
View File

@@ -0,0 +1,18 @@
.. [ The change log. The goal of this file is to help readers
understand changes between version. The primary audience is
end users and integrators. Purely technical changes such as
code refactoring must not be mentioned here.
This file may contain ONE level of section titles, underlined
with the ~ (tilde) character. Other section markers are
forbidden and will likely break the structure of the README.rst
or other documents where this fragment is included. ]
Next
~~~~
* [ADD] Run jobrunner as a worker process instead of a thread in the main
process (when running with --workers > 0)
* [REF] ``@job`` and ``@related_action`` deprecated, any method can be delayed,
and configured using ``queue.job.function`` records
* [MIGRATION] from 13.0 branched at rev. e24ff4b

1
queue_job/readme/INSTALL.rst Executable file
View File

@@ -0,0 +1 @@
Be sure to have the ``requests`` library.

18
queue_job/readme/ROADMAP.rst Executable file
View File

@@ -0,0 +1,18 @@
* After creating a new database or installing ``queue_job`` on an
existing database, Odoo must be restarted for the runner to detect it.
* When Odoo shuts down normally, it waits for running jobs to finish.
However, when the Odoo server crashes or is otherwise force-stopped,
running jobs are interrupted while the runner has no chance to know
they have been aborted. In such situations, jobs may remain in
``started`` or ``enqueued`` state after the Odoo server is halted.
Since the runner has no way to know if they are actually running or
not, and does not know for sure if it is safe to restart the jobs,
it does not attempt to restart them automatically. Such stale jobs
therefore fill the running queue and prevent other jobs to start.
You must therefore requeue them manually, either from the Jobs view,
or by running the following SQL statement *before starting Odoo*:
.. code-block:: sql
update queue_job set state='pending' where state in ('started', 'enqueued')

147
queue_job/readme/USAGE.rst Executable file
View File

@@ -0,0 +1,147 @@
To use this module, you need to:
#. Go to ``Job Queue`` menu
Developers
~~~~~~~~~~
**Configure default options for jobs**
In earlier versions, jobs could be configured using the ``@job`` decorator.
This is now obsolete, they can be configured using optional ``queue.job.function``
and ``queue.job.channel`` XML records.
Example of channel:
.. code-block:: XML
<record id="channel_sale" model="queue.job.channel">
<field name="name">sale</field>
<field name="parent_id" ref="queue_job.channel_root" />
</record>
Example of job function:
.. code-block:: XML
<record id="job_function_sale_order_action_done" model="queue.job.function">
<field name="model_id" ref="sale.model_sale_order"</field>
<field name="method">action_done</field>
<field name="channel_id" ref="channel_sale" />
<field name="related_action" eval='{"func_name": "custom_related_action"}' />
<field name="retry_pattern" eval="{1: 60, 2: 180, 3: 10, 5: 300}" />
</record>
The general form for the ``name`` is: ``<model.name>.method``.
The channel, related action and retry pattern options are optional, they are
documented below.
When writing modules, if 2+ modules add a job function or channel with the same
name (and parent for channels), they'll be merged in the same record, even if
they have different xmlids. On uninstall, the merged record is deleted when all
the modules using it are uninstalled.
**Job function: channel**
The channel where the job will be delayed. The default channel is ``root``.
**Job function: related action**
The *Related Action* appears as a button on the Job's view.
The button will execute the defined action.
The default one is to open the view of the record related to the job (form view
when there is a single record, list view for several records).
In many cases, the default related action is enough and doesn't need
customization, but it can be customized by providing a dictionary on the job
function:
.. code-block:: python
{
"enable": False,
"func_name": "related_action_partner",
"kwargs": {"name": "Partner"},
}
* ``enable``: when ``False``, the button has no effect (default: ``True``)
* ``func_name``: name of the method on ``queue.job`` that returns an action
* ``kwargs``: extra arguments to pass to the related action method
Example of related action code:
.. code-block:: python
class QueueJob(models.Model):
_inherit = 'queue.job'
def related_action_partner(self, name):
self.ensure_one()
model = self.model_name
partner = self.records
action = {
'name': name,
'type': 'ir.actions.act_window',
'res_model': model,
'view_type': 'form',
'view_mode': 'form',
'res_id': partner.id,
}
return action
**Job function: retry pattern**
When a job fails with a retryable error type, it is automatically
retried later. By default, the retry is always 10 minutes later.
A retry pattern can be configured on the job function. What a pattern represents
is "from X tries, postpone to Y seconds". It is expressed as a dictionary where
keys are tries and values are seconds to postpone as integers:
.. code-block:: python
{
1: 10,
5: 20,
10: 30,
15: 300,
}
Based on this configuration, we can tell that:
* 5 first retries are postponed 10 seconds later
* retries 5 to 10 postponed 20 seconds later
* retries 10 to 15 postponed 30 seconds later
* all subsequent retries postponed 5 minutes later
**Bypass jobs on running Odoo**
When you are developing (ie: connector modules) you might want
to bypass the queue job and run your code immediately.
To do so you can set `TEST_QUEUE_JOB_NO_DELAY=1` in your enviroment.
**Bypass jobs in tests**
When writing tests on job-related methods is always tricky to deal with
delayed recordsets. To make your testing life easier
you can set `test_queue_job_no_delay=True` in the context.
Tip: you can do this at test case level like this
.. code-block:: python
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.env = cls.env(context=dict(
cls.env.context,
test_queue_job_no_delay=True, # no jobs thanks
))
Then all your tests execute the job methods synchronously
without delaying any jobs.

View File

@@ -0,0 +1,6 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_queue_job_manager,queue job manager,queue_job.model_queue_job,queue_job.group_queue_job_manager,1,1,1,1
access_queue_job_function_manager,queue job functions manager,queue_job.model_queue_job_function,queue_job.group_queue_job_manager,1,1,1,1
access_queue_job_channel_manager,queue job channel manager,queue_job.model_queue_job_channel,queue_job.group_queue_job_manager,1,1,1,1
access_queue_requeue_job,queue requeue job manager,queue_job.model_queue_requeue_job,queue_job.group_queue_job_manager,1,1,1,1
access_queue_jobs_to_done,queue jobs to done manager,queue_job.model_queue_jobs_to_done,queue_job.group_queue_job_manager,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_queue_job_manager queue job manager queue_job.model_queue_job queue_job.group_queue_job_manager 1 1 1 1
3 access_queue_job_function_manager queue job functions manager queue_job.model_queue_job_function queue_job.group_queue_job_manager 1 1 1 1
4 access_queue_job_channel_manager queue job channel manager queue_job.model_queue_job_channel queue_job.group_queue_job_manager 1 1 1 1
5 access_queue_requeue_job queue requeue job manager queue_job.model_queue_requeue_job queue_job.group_queue_job_manager 1 1 1 1
6 access_queue_jobs_to_done queue jobs to done manager queue_job.model_queue_jobs_to_done queue_job.group_queue_job_manager 1 1 1 1

27
queue_job/security/security.xml Executable file
View File

@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<data>
<record model="ir.module.category" id="module_category_queue_job">
<field name="name">Job Queue</field>
<field name="sequence">20</field>
</record>
<record id="group_queue_job_manager" model="res.groups">
<field name="name">Job Queue Manager</field>
<field name="category_id" ref="module_category_queue_job" />
<field
name="users"
eval="[(4, ref('base.user_root')), (4, ref('base.user_admin'))]"
/>
</record>
</data>
<data noupdate="1">
<record id="queue_job_comp_rule" model="ir.rule">
<field name="name">Job Queue multi-company</field>
<field name="model_id" ref="model_queue_job" />
<field name="global" eval="True" />
<field
name="domain_force"
>['|', ('company_id', '=', False), ('company_id', 'in', company_ids)]</field>
</record>
</data>
</odoo>

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@@ -0,0 +1,150 @@
<?xml version="1.0" encoding="UTF-8" standalone="no" ?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
id="svg2"
version="1.1"
inkscape:version="0.92.4 (f8dce91, 2019-08-02)"
width="60"
height="60"
viewBox="0 0 60 60"
sodipodi:docname="icon.svg"
inkscape:export-filename="./icon.png"
inkscape:export-xdpi="192"
inkscape:export-ydpi="192"
>
<metadata id="metadata8">
<rdf:RDF>
<cc:Work rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title />
</cc:Work>
</rdf:RDF>
</metadata>
<defs id="defs6" />
<sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1920"
inkscape:window-height="1052"
id="namedview4"
showgrid="false"
inkscape:zoom="8"
inkscape:cx="20.921984"
inkscape:cy="9.7064211"
inkscape:window-x="1920"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="svg2"
showguides="true"
inkscape:guide-bbox="true"
>
<sodipodi:guide
position="4.140625,50.300781"
orientation="-0.70710678,0.70710678"
id="guide3348"
inkscape:locked="false"
/>
<sodipodi:guide
position="56.476562,43.945312"
orientation="-0.70710678,0.70710678"
id="guide3350"
inkscape:locked="false"
/>
<sodipodi:guide
position="0,41.75"
orientation="1,0"
id="guide3360"
inkscape:locked="false"
/>
<sodipodi:guide
position="60,60.074219"
orientation="1,0"
id="guide3362"
inkscape:locked="false"
/>
<sodipodi:guide
position="0.17578125,60"
orientation="0,1"
id="guide3364"
inkscape:locked="false"
/>
<sodipodi:guide
position="58.71196,-0.0055242717"
orientation="0,1"
id="guide3366"
inkscape:locked="false"
/>
<sodipodi:guide
position="15.558594,50.5"
orientation="-0.70710678,0.70710678"
id="guide3345"
inkscape:locked="false"
/>
<sodipodi:guide
position="56.101563,32.363281"
orientation="-0.70710678,0.70710678"
id="guide3347"
inkscape:locked="false"
/>
<sodipodi:guide
position="55.972656,21.027344"
orientation="-0.70710678,0.70710678"
id="guide3349"
inkscape:locked="false"
/>
<sodipodi:guide
position="55.863281,9.6914063"
orientation="-0.70710678,0.70710678"
id="guide3351"
inkscape:locked="false"
/>
</sodipodi:namedview>
<rect
style="opacity:1;fill:#e74c3c;fill-opacity:1"
id="rect4147"
width="60"
height="60"
x="0"
y="0"
ry="3.5"
/>
<path
style="opacity:1;fill:#000000;fill-opacity:0.39215686"
d="M 4.1318557,9.7144566 0.0078125,13.824141 4.2297534e-4,57.5 c 0,0 0.66042738466,1.160638 1.24957702466,1.75 0.6775221,0.677766 1.6249999,0.750548 1.6249999,0.750548 l 43.2963011,0.0083 9.791208,-9.800508 -4.088791,-7.131947 4.086426,-4.114418 -4.058547,-7.106958 4.016339,-4.017997 -4.036551,-7.171615 4.058726,-4.069362 -40.612885,-6.8632798 -4.011838,4.0145788 z"
id="rect4171"
inkscape:connector-curvature="0"
sodipodi:nodetypes="cccscccccccccccc"
/>
<g
id="g4169"
transform="matrix(0.3061173,0,0,0.3061173,-1.0360053,-1.0457906)"
style="fill:#ffffff;stroke:none"
/>
<text
xml:space="preserve"
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;line-height:0%;font-family:FontAwesome;-inkscape-font-specification:FontAwesome;letter-spacing:0px;word-spacing:0px;fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
x="3.8388314"
y="50.558071"
id="text3352"
><tspan
sodipodi:role="line"
id="tspan3354"
x="3.8388314"
y="50.558071"
style="font-size:52.32963562px;line-height:1.25"
></tspan></text>
</svg>

After

Width:  |  Height:  |  Size: 4.4 KiB

View File

@@ -0,0 +1,697 @@
<?xml version="1.0" encoding="utf-8" ?>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<meta name="generator" content="Docutils 0.15.1: http://docutils.sourceforge.net/" />
<title>Job Queue</title>
<style type="text/css">
/*
:Author: David Goodger (goodger@python.org)
:Id: $Id: html4css1.css 7952 2016-07-26 18:15:59Z milde $
:Copyright: This stylesheet has been placed in the public domain.
Default cascading style sheet for the HTML output of Docutils.
See http://docutils.sf.net/docs/howto/html-stylesheets.html for how to
customize this style sheet.
*/
/* used to remove borders from tables and images */
.borderless, table.borderless td, table.borderless th {
border: 0 }
table.borderless td, table.borderless th {
/* Override padding for "table.docutils td" with "! important".
The right padding separates the table cells. */
padding: 0 0.5em 0 0 ! important }
.first {
/* Override more specific margin styles with "! important". */
margin-top: 0 ! important }
.last, .with-subtitle {
margin-bottom: 0 ! important }
.hidden {
display: none }
.subscript {
vertical-align: sub;
font-size: smaller }
.superscript {
vertical-align: super;
font-size: smaller }
a.toc-backref {
text-decoration: none ;
color: black }
blockquote.epigraph {
margin: 2em 5em ; }
dl.docutils dd {
margin-bottom: 0.5em }
object[type="image/svg+xml"], object[type="application/x-shockwave-flash"] {
overflow: hidden;
}
/* Uncomment (and remove this text!) to get bold-faced definition list terms
dl.docutils dt {
font-weight: bold }
*/
div.abstract {
margin: 2em 5em }
div.abstract p.topic-title {
font-weight: bold ;
text-align: center }
div.admonition, div.attention, div.caution, div.danger, div.error,
div.hint, div.important, div.note, div.tip, div.warning {
margin: 2em ;
border: medium outset ;
padding: 1em }
div.admonition p.admonition-title, div.hint p.admonition-title,
div.important p.admonition-title, div.note p.admonition-title,
div.tip p.admonition-title {
font-weight: bold ;
font-family: sans-serif }
div.attention p.admonition-title, div.caution p.admonition-title,
div.danger p.admonition-title, div.error p.admonition-title,
div.warning p.admonition-title, .code .error {
color: red ;
font-weight: bold ;
font-family: sans-serif }
/* Uncomment (and remove this text!) to get reduced vertical space in
compound paragraphs.
div.compound .compound-first, div.compound .compound-middle {
margin-bottom: 0.5em }
div.compound .compound-last, div.compound .compound-middle {
margin-top: 0.5em }
*/
div.dedication {
margin: 2em 5em ;
text-align: center ;
font-style: italic }
div.dedication p.topic-title {
font-weight: bold ;
font-style: normal }
div.figure {
margin-left: 2em ;
margin-right: 2em }
div.footer, div.header {
clear: both;
font-size: smaller }
div.line-block {
display: block ;
margin-top: 1em ;
margin-bottom: 1em }
div.line-block div.line-block {
margin-top: 0 ;
margin-bottom: 0 ;
margin-left: 1.5em }
div.sidebar {
margin: 0 0 0.5em 1em ;
border: medium outset ;
padding: 1em ;
background-color: #ffffee ;
width: 40% ;
float: right ;
clear: right }
div.sidebar p.rubric {
font-family: sans-serif ;
font-size: medium }
div.system-messages {
margin: 5em }
div.system-messages h1 {
color: red }
div.system-message {
border: medium outset ;
padding: 1em }
div.system-message p.system-message-title {
color: red ;
font-weight: bold }
div.topic {
margin: 2em }
h1.section-subtitle, h2.section-subtitle, h3.section-subtitle,
h4.section-subtitle, h5.section-subtitle, h6.section-subtitle {
margin-top: 0.4em }
h1.title {
text-align: center }
h2.subtitle {
text-align: center }
hr.docutils {
width: 75% }
img.align-left, .figure.align-left, object.align-left, table.align-left {
clear: left ;
float: left ;
margin-right: 1em }
img.align-right, .figure.align-right, object.align-right, table.align-right {
clear: right ;
float: right ;
margin-left: 1em }
img.align-center, .figure.align-center, object.align-center {
display: block;
margin-left: auto;
margin-right: auto;
}
table.align-center {
margin-left: auto;
margin-right: auto;
}
.align-left {
text-align: left }
.align-center {
clear: both ;
text-align: center }
.align-right {
text-align: right }
/* reset inner alignment in figures */
div.align-right {
text-align: inherit }
/* div.align-center * { */
/* text-align: left } */
.align-top {
vertical-align: top }
.align-middle {
vertical-align: middle }
.align-bottom {
vertical-align: bottom }
ol.simple, ul.simple {
margin-bottom: 1em }
ol.arabic {
list-style: decimal }
ol.loweralpha {
list-style: lower-alpha }
ol.upperalpha {
list-style: upper-alpha }
ol.lowerroman {
list-style: lower-roman }
ol.upperroman {
list-style: upper-roman }
p.attribution {
text-align: right ;
margin-left: 50% }
p.caption {
font-style: italic }
p.credits {
font-style: italic ;
font-size: smaller }
p.label {
white-space: nowrap }
p.rubric {
font-weight: bold ;
font-size: larger ;
color: maroon ;
text-align: center }
p.sidebar-title {
font-family: sans-serif ;
font-weight: bold ;
font-size: larger }
p.sidebar-subtitle {
font-family: sans-serif ;
font-weight: bold }
p.topic-title {
font-weight: bold }
pre.address {
margin-bottom: 0 ;
margin-top: 0 ;
font: inherit }
pre.literal-block, pre.doctest-block, pre.math, pre.code {
margin-left: 2em ;
margin-right: 2em }
pre.code .ln { color: grey; } /* line numbers */
pre.code, code { background-color: #eeeeee }
pre.code .comment, code .comment { color: #5C6576 }
pre.code .keyword, code .keyword { color: #3B0D06; font-weight: bold }
pre.code .literal.string, code .literal.string { color: #0C5404 }
pre.code .name.builtin, code .name.builtin { color: #352B84 }
pre.code .deleted, code .deleted { background-color: #DEB0A1}
pre.code .inserted, code .inserted { background-color: #A3D289}
span.classifier {
font-family: sans-serif ;
font-style: oblique }
span.classifier-delimiter {
font-family: sans-serif ;
font-weight: bold }
span.interpreted {
font-family: sans-serif }
span.option {
white-space: nowrap }
span.pre {
white-space: pre }
span.problematic {
color: red }
span.section-subtitle {
/* font-size relative to parent (h1..h6 element) */
font-size: 80% }
table.citation {
border-left: solid 1px gray;
margin-left: 1px }
table.docinfo {
margin: 2em 4em }
table.docutils {
margin-top: 0.5em ;
margin-bottom: 0.5em }
table.footnote {
border-left: solid 1px black;
margin-left: 1px }
table.docutils td, table.docutils th,
table.docinfo td, table.docinfo th {
padding-left: 0.5em ;
padding-right: 0.5em ;
vertical-align: top }
table.docutils th.field-name, table.docinfo th.docinfo-name {
font-weight: bold ;
text-align: left ;
white-space: nowrap ;
padding-left: 0 }
/* "booktabs" style (no vertical lines) */
table.docutils.booktabs {
border: 0px;
border-top: 2px solid;
border-bottom: 2px solid;
border-collapse: collapse;
}
table.docutils.booktabs * {
border: 0px;
}
table.docutils.booktabs th {
border-bottom: thin solid;
text-align: left;
}
h1 tt.docutils, h2 tt.docutils, h3 tt.docutils,
h4 tt.docutils, h5 tt.docutils, h6 tt.docutils {
font-size: 100% }
ul.auto-toc {
list-style-type: none }
</style>
</head>
<body>
<div class="document" id="job-queue">
<h1 class="title">Job Queue</h1>
<!-- !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! This file is generated by oca-gen-addon-readme !!
!! changes will be overwritten. !!
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -->
<p><a class="reference external" href="https://odoo-community.org/page/development-status"><img alt="Mature" src="https://img.shields.io/badge/maturity-Mature-brightgreen.png" /></a> <a class="reference external" href="http://www.gnu.org/licenses/lgpl-3.0-standalone.html"><img alt="License: LGPL-3" src="https://img.shields.io/badge/licence-LGPL--3-blue.png" /></a> <a class="reference external" href="https://github.com/OCA/queue/tree/14.0/queue_job"><img alt="OCA/queue" src="https://img.shields.io/badge/github-OCA%2Fqueue-lightgray.png?logo=github" /></a> <a class="reference external" href="https://translation.odoo-community.org/projects/queue-14-0/queue-14-0-queue_job"><img alt="Translate me on Weblate" src="https://img.shields.io/badge/weblate-Translate%20me-F47D42.png" /></a> <a class="reference external" href="https://runbot.odoo-community.org/runbot/230/14.0"><img alt="Try me on Runbot" src="https://img.shields.io/badge/runbot-Try%20me-875A7B.png" /></a></p>
<p>This addon adds an integrated Job Queue to Odoo.</p>
<p>It allows to postpone method calls executed asynchronously.</p>
<p>Jobs are executed in the background by a <tt class="docutils literal">Jobrunner</tt>, in their own transaction.</p>
<p>Example:</p>
<pre class="code python literal-block">
<span class="kn">from</span> <span class="nn">odoo</span> <span class="kn">import</span> <span class="n">models</span><span class="p">,</span> <span class="n">fields</span><span class="p">,</span> <span class="n">api</span>
<span class="k">class</span> <span class="nc">MyModel</span><span class="p">(</span><span class="n">models</span><span class="o">.</span><span class="n">Model</span><span class="p">):</span>
<span class="n">_name</span> <span class="o">=</span> <span class="s1">'my.model'</span>
<span class="k">def</span> <span class="nf">my_method</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">a</span><span class="p">,</span> <span class="n">k</span><span class="o">=</span><span class="kc">None</span><span class="p">):</span>
<span class="n">_logger</span><span class="o">.</span><span class="n">info</span><span class="p">(</span><span class="s1">'executed with a: </span><span class="si">%s</span><span class="s1"> and k: </span><span class="si">%s</span><span class="s1">'</span><span class="p">,</span> <span class="n">a</span><span class="p">,</span> <span class="n">k</span><span class="p">)</span>
<span class="k">class</span> <span class="nc">MyOtherModel</span><span class="p">(</span><span class="n">models</span><span class="o">.</span><span class="n">Model</span><span class="p">):</span>
<span class="n">_name</span> <span class="o">=</span> <span class="s1">'my.other.model'</span>
<span class="k">def</span> <span class="nf">button_do_stuff</span><span class="p">(</span><span class="bp">self</span><span class="p">):</span>
<span class="bp">self</span><span class="o">.</span><span class="n">env</span><span class="p">[</span><span class="s1">'my.model'</span><span class="p">]</span><span class="o">.</span><span class="n">with_delay</span><span class="p">()</span><span class="o">.</span><span class="n">my_method</span><span class="p">(</span><span class="s1">'a'</span><span class="p">,</span> <span class="n">k</span><span class="o">=</span><span class="mi">2</span><span class="p">)</span>
</pre>
<p>In the snippet of code above, when we call <tt class="docutils literal">button_do_stuff</tt>, a job <strong>capturing
the method and arguments</strong> will be postponed. It will be executed as soon as the
Jobrunner has a free bucket, which can be instantaneous if no other job is
running.</p>
<p>Features:</p>
<ul class="simple">
<li>Views for jobs, jobs are stored in PostgreSQL</li>
<li>Jobrunner: execute the jobs, highly efficient thanks to PostgreSQLs NOTIFY</li>
<li>Channels: give a capacity for the root channel and its sub-channels and
segregate jobs in them. Allow for instance to restrict heavy jobs to be
executed one at a time while little ones are executed 4 at a times.</li>
<li>Retries: Ability to retry jobs by raising a type of exception</li>
<li>Retry Pattern: the 3 first tries, retry after 10 seconds, the 5 next tries,
retry after 1 minutes, …</li>
<li>Job properties: priorities, estimated time of arrival (ETA), custom
description, number of retries</li>
<li>Related Actions: link an action on the job view, such as open the record
concerned by the job</li>
</ul>
<p><strong>Table of contents</strong></p>
<div class="contents local topic" id="contents">
<ul class="simple">
<li><a class="reference internal" href="#installation" id="id3">Installation</a></li>
<li><a class="reference internal" href="#configuration" id="id4">Configuration</a></li>
<li><a class="reference internal" href="#usage" id="id5">Usage</a><ul>
<li><a class="reference internal" href="#developers" id="id6">Developers</a></li>
</ul>
</li>
<li><a class="reference internal" href="#known-issues-roadmap" id="id7">Known issues / Roadmap</a></li>
<li><a class="reference internal" href="#changelog" id="id8">Changelog</a><ul>
<li><a class="reference internal" href="#next" id="id9">Next</a></li>
</ul>
</li>
<li><a class="reference internal" href="#bug-tracker" id="id10">Bug Tracker</a></li>
<li><a class="reference internal" href="#credits" id="id11">Credits</a><ul>
<li><a class="reference internal" href="#authors" id="id12">Authors</a></li>
<li><a class="reference internal" href="#contributors" id="id13">Contributors</a></li>
<li><a class="reference internal" href="#maintainers" id="id14">Maintainers</a></li>
</ul>
</li>
</ul>
</div>
<div class="section" id="installation">
<h1><a class="toc-backref" href="#id3">Installation</a></h1>
<p>Be sure to have the <tt class="docutils literal">requests</tt> library.</p>
</div>
<div class="section" id="configuration">
<h1><a class="toc-backref" href="#id4">Configuration</a></h1>
<ul class="simple">
<li>Using environment variables and command line:<ul>
<li>Adjust environment variables (optional):<ul>
<li><tt class="docutils literal">ODOO_QUEUE_JOB_CHANNELS=root:4</tt> or any other channels configuration.
The default is <tt class="docutils literal">root:1</tt></li>
<li>if <tt class="docutils literal">xmlrpc_port</tt> is not set: <tt class="docutils literal">ODOO_QUEUE_JOB_PORT=8069</tt></li>
</ul>
</li>
<li>Start Odoo with <tt class="docutils literal"><span class="pre">--load=web,queue_job</span></tt>
and <tt class="docutils literal"><span class="pre">--workers</span></tt> greater than 1. <a class="footnote-reference" href="#id2" id="id1">[1]</a></li>
</ul>
</li>
<li>Using the Odoo configuration file:</li>
</ul>
<pre class="code ini literal-block">
<span class="k">[options]</span>
<span class="na">(...)</span>
<span class="na">workers</span> <span class="o">=</span> <span class="s">6</span>
<span class="na">server_wide_modules</span> <span class="o">=</span> <span class="s">web,queue_job</span>
<span class="na">(...)</span>
<span class="k">[queue_job]</span>
<span class="na">channels</span> <span class="o">=</span> <span class="s">root:2</span>
</pre>
<ul class="simple">
<li>Confirm the runner is starting correctly by checking the odoo log file:</li>
</ul>
<pre class="code literal-block">
...INFO...queue_job.jobrunner.runner: starting
...INFO...queue_job.jobrunner.runner: initializing database connections
...INFO...queue_job.jobrunner.runner: queue job runner ready for db &lt;dbname&gt;
...INFO...queue_job.jobrunner.runner: database connections ready
</pre>
<ul class="simple">
<li>Create jobs (eg using <tt class="docutils literal">base_import_async</tt>) and observe they
start immediately and in parallel.</li>
<li>Tip: to enable debug logging for the queue job, use
<tt class="docutils literal"><span class="pre">--log-handler=odoo.addons.queue_job:DEBUG</span></tt></li>
</ul>
<table class="docutils footnote" frame="void" id="id2" rules="none">
<colgroup><col class="label" /><col /></colgroup>
<tbody valign="top">
<tr><td class="label"><a class="fn-backref" href="#id1">[1]</a></td><td>It works with the threaded Odoo server too, although this way
of running Odoo is obviously not for production purposes.</td></tr>
</tbody>
</table>
</div>
<div class="section" id="usage">
<h1><a class="toc-backref" href="#id5">Usage</a></h1>
<p>To use this module, you need to:</p>
<ol class="arabic simple">
<li>Go to <tt class="docutils literal">Job Queue</tt> menu</li>
</ol>
<div class="section" id="developers">
<h2><a class="toc-backref" href="#id6">Developers</a></h2>
<p><strong>Configure default options for jobs</strong></p>
<p>In earlier versions, jobs could be configured using the <tt class="docutils literal">&#64;job</tt> decorator.
This is now obsolete, they can be configured using optional <tt class="docutils literal">queue.job.function</tt>
and <tt class="docutils literal">queue.job.channel</tt> XML records.</p>
<p>Example of channel:</p>
<pre class="code XML literal-block">
<span class="nt">&lt;record</span> <span class="na">id=</span><span class="s">&quot;channel_sale&quot;</span> <span class="na">model=</span><span class="s">&quot;queue.job.channel&quot;</span><span class="nt">&gt;</span>
<span class="nt">&lt;field</span> <span class="na">name=</span><span class="s">&quot;name&quot;</span><span class="nt">&gt;</span>sale<span class="nt">&lt;/field&gt;</span>
<span class="nt">&lt;field</span> <span class="na">name=</span><span class="s">&quot;parent_id&quot;</span> <span class="na">ref=</span><span class="s">&quot;queue_job.channel_root&quot;</span> <span class="nt">/&gt;</span>
<span class="nt">&lt;/record&gt;</span>
</pre>
<p>Example of job function:</p>
<pre class="code XML literal-block">
<span class="nt">&lt;record</span> <span class="na">id=</span><span class="s">&quot;job_function_sale_order_action_done&quot;</span> <span class="na">model=</span><span class="s">&quot;queue.job.function&quot;</span><span class="nt">&gt;</span>
<span class="nt">&lt;field</span> <span class="na">name=</span><span class="s">&quot;model_id&quot;</span> <span class="na">ref=</span><span class="s">&quot;sale.model_sale_order&quot;</span><span class="err">&lt;/field</span><span class="nt">&gt;</span>
<span class="nt">&lt;field</span> <span class="na">name=</span><span class="s">&quot;method&quot;</span><span class="nt">&gt;</span>action_done<span class="nt">&lt;/field&gt;</span>
<span class="nt">&lt;field</span> <span class="na">name=</span><span class="s">&quot;channel_id&quot;</span> <span class="na">ref=</span><span class="s">&quot;channel_sale&quot;</span> <span class="nt">/&gt;</span>
<span class="nt">&lt;field</span> <span class="na">name=</span><span class="s">&quot;related_action&quot;</span> <span class="na">eval=</span><span class="s">'{&quot;func_name&quot;: &quot;custom_related_action&quot;}'</span> <span class="nt">/&gt;</span>
<span class="nt">&lt;field</span> <span class="na">name=</span><span class="s">&quot;retry_pattern&quot;</span> <span class="na">eval=</span><span class="s">&quot;{1: 60, 2: 180, 3: 10, 5: 300}&quot;</span> <span class="nt">/&gt;</span>
<span class="nt">&lt;/record&gt;</span>
</pre>
<p>The general form for the <tt class="docutils literal">name</tt> is: <tt class="docutils literal"><span class="pre">&lt;model.name&gt;.method</span></tt>.</p>
<p>The channel, related action and retry pattern options are optional, they are
documented below.</p>
<p>When writing modules, if 2+ modules add a job function or channel with the same
name (and parent for channels), theyll be merged in the same record, even if
they have different xmlids. On uninstall, the merged record is deleted when all
the modules using it are uninstalled.</p>
<p><strong>Job function: channel</strong></p>
<p>The channel where the job will be delayed. The default channel is <tt class="docutils literal">root</tt>.</p>
<p><strong>Job function: related action</strong></p>
<p>The <em>Related Action</em> appears as a button on the Jobs view.
The button will execute the defined action.</p>
<p>The default one is to open the view of the record related to the job (form view
when there is a single record, list view for several records).
In many cases, the default related action is enough and doesnt need
customization, but it can be customized by providing a dictionary on the job
function:</p>
<pre class="code python literal-block">
<span class="p">{</span>
<span class="s2">&quot;enable&quot;</span><span class="p">:</span> <span class="kc">False</span><span class="p">,</span>
<span class="s2">&quot;func_name&quot;</span><span class="p">:</span> <span class="s2">&quot;related_action_partner&quot;</span><span class="p">,</span>
<span class="s2">&quot;kwargs&quot;</span><span class="p">:</span> <span class="p">{</span><span class="s2">&quot;name&quot;</span><span class="p">:</span> <span class="s2">&quot;Partner&quot;</span><span class="p">},</span>
<span class="p">}</span>
</pre>
<ul class="simple">
<li><tt class="docutils literal">enable</tt>: when <tt class="docutils literal">False</tt>, the button has no effect (default: <tt class="docutils literal">True</tt>)</li>
<li><tt class="docutils literal">func_name</tt>: name of the method on <tt class="docutils literal">queue.job</tt> that returns an action</li>
<li><tt class="docutils literal">kwargs</tt>: extra arguments to pass to the related action method</li>
</ul>
<p>Example of related action code:</p>
<pre class="code python literal-block">
<span class="k">class</span> <span class="nc">QueueJob</span><span class="p">(</span><span class="n">models</span><span class="o">.</span><span class="n">Model</span><span class="p">):</span>
<span class="n">_inherit</span> <span class="o">=</span> <span class="s1">'queue.job'</span>
<span class="k">def</span> <span class="nf">related_action_partner</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">name</span><span class="p">):</span>
<span class="bp">self</span><span class="o">.</span><span class="n">ensure_one</span><span class="p">()</span>
<span class="n">model</span> <span class="o">=</span> <span class="bp">self</span><span class="o">.</span><span class="n">model_name</span>
<span class="n">partner</span> <span class="o">=</span> <span class="bp">self</span><span class="o">.</span><span class="n">records</span>
<span class="n">action</span> <span class="o">=</span> <span class="p">{</span>
<span class="s1">'name'</span><span class="p">:</span> <span class="n">name</span><span class="p">,</span>
<span class="s1">'type'</span><span class="p">:</span> <span class="s1">'ir.actions.act_window'</span><span class="p">,</span>
<span class="s1">'res_model'</span><span class="p">:</span> <span class="n">model</span><span class="p">,</span>
<span class="s1">'view_type'</span><span class="p">:</span> <span class="s1">'form'</span><span class="p">,</span>
<span class="s1">'view_mode'</span><span class="p">:</span> <span class="s1">'form'</span><span class="p">,</span>
<span class="s1">'res_id'</span><span class="p">:</span> <span class="n">partner</span><span class="o">.</span><span class="n">id</span><span class="p">,</span>
<span class="p">}</span>
<span class="k">return</span> <span class="n">action</span>
</pre>
<p><strong>Job function: retry pattern</strong></p>
<p>When a job fails with a retryable error type, it is automatically
retried later. By default, the retry is always 10 minutes later.</p>
<p>A retry pattern can be configured on the job function. What a pattern represents
is “from X tries, postpone to Y seconds”. It is expressed as a dictionary where
keys are tries and values are seconds to postpone as integers:</p>
<pre class="code python literal-block">
<span class="p">{</span>
<span class="mi">1</span><span class="p">:</span> <span class="mi">10</span><span class="p">,</span>
<span class="mi">5</span><span class="p">:</span> <span class="mi">20</span><span class="p">,</span>
<span class="mi">10</span><span class="p">:</span> <span class="mi">30</span><span class="p">,</span>
<span class="mi">15</span><span class="p">:</span> <span class="mi">300</span><span class="p">,</span>
<span class="p">}</span>
</pre>
<p>Based on this configuration, we can tell that:</p>
<ul class="simple">
<li>5 first retries are postponed 10 seconds later</li>
<li>retries 5 to 10 postponed 20 seconds later</li>
<li>retries 10 to 15 postponed 30 seconds later</li>
<li>all subsequent retries postponed 5 minutes later</li>
</ul>
<p><strong>Bypass jobs on running Odoo</strong></p>
<p>When you are developing (ie: connector modules) you might want
to bypass the queue job and run your code immediately.</p>
<p>To do so you can set <cite>TEST_QUEUE_JOB_NO_DELAY=1</cite> in your enviroment.</p>
<p><strong>Bypass jobs in tests</strong></p>
<p>When writing tests on job-related methods is always tricky to deal with
delayed recordsets. To make your testing life easier
you can set <cite>test_queue_job_no_delay=True</cite> in the context.</p>
<p>Tip: you can do this at test case level like this</p>
<pre class="code python literal-block">
<span class="nd">&#64;classmethod</span>
<span class="k">def</span> <span class="nf">setUpClass</span><span class="p">(</span><span class="bp">cls</span><span class="p">):</span>
<span class="nb">super</span><span class="p">()</span><span class="o">.</span><span class="n">setUpClass</span><span class="p">()</span>
<span class="bp">cls</span><span class="o">.</span><span class="n">env</span> <span class="o">=</span> <span class="bp">cls</span><span class="o">.</span><span class="n">env</span><span class="p">(</span><span class="n">context</span><span class="o">=</span><span class="nb">dict</span><span class="p">(</span>
<span class="bp">cls</span><span class="o">.</span><span class="n">env</span><span class="o">.</span><span class="n">context</span><span class="p">,</span>
<span class="n">test_queue_job_no_delay</span><span class="o">=</span><span class="kc">True</span><span class="p">,</span> <span class="c1"># no jobs thanks</span>
<span class="p">))</span>
</pre>
<p>Then all your tests execute the job methods synchronously
without delaying any jobs.</p>
</div>
</div>
<div class="section" id="known-issues-roadmap">
<h1><a class="toc-backref" href="#id7">Known issues / Roadmap</a></h1>
<ul class="simple">
<li>After creating a new database or installing <tt class="docutils literal">queue_job</tt> on an
existing database, Odoo must be restarted for the runner to detect it.</li>
<li>When Odoo shuts down normally, it waits for running jobs to finish.
However, when the Odoo server crashes or is otherwise force-stopped,
running jobs are interrupted while the runner has no chance to know
they have been aborted. In such situations, jobs may remain in
<tt class="docutils literal">started</tt> or <tt class="docutils literal">enqueued</tt> state after the Odoo server is halted.
Since the runner has no way to know if they are actually running or
not, and does not know for sure if it is safe to restart the jobs,
it does not attempt to restart them automatically. Such stale jobs
therefore fill the running queue and prevent other jobs to start.
You must therefore requeue them manually, either from the Jobs view,
or by running the following SQL statement <em>before starting Odoo</em>:</li>
</ul>
<pre class="code sql literal-block">
<span class="k">update</span> <span class="n">queue_job</span> <span class="k">set</span> <span class="k">state</span><span class="o">=</span><span class="s1">'pending'</span> <span class="k">where</span> <span class="k">state</span> <span class="k">in</span> <span class="p">(</span><span class="s1">'started'</span><span class="p">,</span> <span class="s1">'enqueued'</span><span class="p">)</span>
</pre>
</div>
<div class="section" id="changelog">
<h1><a class="toc-backref" href="#id8">Changelog</a></h1>
<!-- [ The change log. The goal of this file is to help readers
understand changes between version. The primary audience is
end users and integrators. Purely technical changes such as
code refactoring must not be mentioned here.
This file may contain ONE level of section titles, underlined
with the ~ (tilde) character. Other section markers are
forbidden and will likely break the structure of the README.rst
or other documents where this fragment is included. ] -->
<div class="section" id="next">
<h2><a class="toc-backref" href="#id9">Next</a></h2>
<ul class="simple">
<li>[ADD] Run jobrunner as a worker process instead of a thread in the main
process (when running with workers &gt; 0)</li>
<li>[REF] <tt class="docutils literal">&#64;job</tt> and <tt class="docutils literal">&#64;related_action</tt> deprecated, any method can be delayed,
and configured using <tt class="docutils literal">queue.job.function</tt> records</li>
<li>[MIGRATION] from 13.0 branched at rev. e24ff4b</li>
</ul>
</div>
</div>
<div class="section" id="bug-tracker">
<h1><a class="toc-backref" href="#id10">Bug Tracker</a></h1>
<p>Bugs are tracked on <a class="reference external" href="https://github.com/OCA/queue/issues">GitHub Issues</a>.
In case of trouble, please check there if your issue has already been reported.
If you spotted it first, help us smashing it by providing a detailed and welcomed
<a class="reference external" href="https://github.com/OCA/queue/issues/new?body=module:%20queue_job%0Aversion:%2014.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**">feedback</a>.</p>
<p>Do not contact contributors directly about support or help with technical issues.</p>
</div>
<div class="section" id="credits">
<h1><a class="toc-backref" href="#id11">Credits</a></h1>
<div class="section" id="authors">
<h2><a class="toc-backref" href="#id12">Authors</a></h2>
<ul class="simple">
<li>Camptocamp</li>
<li>ACSONE SA/NV</li>
</ul>
</div>
<div class="section" id="contributors">
<h2><a class="toc-backref" href="#id13">Contributors</a></h2>
<ul class="simple">
<li>Guewen Baconnier &lt;<a class="reference external" href="mailto:guewen.baconnier&#64;camptocamp.com">guewen.baconnier&#64;camptocamp.com</a>&gt;</li>
<li>Stéphane Bidoul &lt;<a class="reference external" href="mailto:stephane.bidoul&#64;acsone.eu">stephane.bidoul&#64;acsone.eu</a>&gt;</li>
<li>Matthieu Dietrich &lt;<a class="reference external" href="mailto:matthieu.dietrich&#64;camptocamp.com">matthieu.dietrich&#64;camptocamp.com</a>&gt;</li>
<li>Jos De Graeve &lt;<a class="reference external" href="mailto:Jos.DeGraeve&#64;apertoso.be">Jos.DeGraeve&#64;apertoso.be</a>&gt;</li>
<li>David Lefever &lt;<a class="reference external" href="mailto:dl&#64;taktik.be">dl&#64;taktik.be</a>&gt;</li>
<li>Laurent Mignon &lt;<a class="reference external" href="mailto:laurent.mignon&#64;acsone.eu">laurent.mignon&#64;acsone.eu</a>&gt;</li>
<li>Laetitia Gangloff &lt;<a class="reference external" href="mailto:laetitia.gangloff&#64;acsone.eu">laetitia.gangloff&#64;acsone.eu</a>&gt;</li>
<li>Cédric Pigeon &lt;<a class="reference external" href="mailto:cedric.pigeon&#64;acsone.eu">cedric.pigeon&#64;acsone.eu</a>&gt;</li>
<li>Tatiana Deribina &lt;<a class="reference external" href="mailto:tatiana.deribina&#64;avoin.systems">tatiana.deribina&#64;avoin.systems</a>&gt;</li>
<li>Souheil Bejaoui &lt;<a class="reference external" href="mailto:souheil.bejaoui&#64;acsone.eu">souheil.bejaoui&#64;acsone.eu</a>&gt;</li>
<li>Eric Antones &lt;<a class="reference external" href="mailto:eantones&#64;nuobit.com">eantones&#64;nuobit.com</a>&gt;</li>
</ul>
</div>
<div class="section" id="maintainers">
<h2><a class="toc-backref" href="#id14">Maintainers</a></h2>
<p>This module is maintained by the OCA.</p>
<a class="reference external image-reference" href="https://odoo-community.org"><img alt="Odoo Community Association" src="https://odoo-community.org/logo.png" /></a>
<p>OCA, or the Odoo Community Association, is a nonprofit organization whose
mission is to support the collaborative development of Odoo features and
promote its widespread use.</p>
<p>Current <a class="reference external" href="https://odoo-community.org/page/maintainer-role">maintainer</a>:</p>
<p><a class="reference external" href="https://github.com/guewen"><img alt="guewen" src="https://github.com/guewen.png?size=40px" /></a></p>
<p>This module is part of the <a class="reference external" href="https://github.com/OCA/queue/tree/14.0/queue_job">OCA/queue</a> project on GitHub.</p>
<p>You are welcome to contribute. To learn how please visit <a class="reference external" href="https://odoo-community.org/page/Contribute">https://odoo-community.org/page/Contribute</a>.</p>
</div>
</div>
</div>
</body>
</html>

6
queue_job/tests/__init__.py Executable file
View File

@@ -0,0 +1,6 @@
from . import test_runner_channels
from . import test_runner_runner
from . import test_json_field
from . import test_model_job_channel
from . import test_model_job_function
from . import test_queue_job_protected_write

149
queue_job/tests/common.py Executable file
View File

@@ -0,0 +1,149 @@
# Copyright 2019 Camptocamp
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
import doctest
import logging
import sys
from contextlib import contextmanager
import mock
from ..job import Job
class JobCounter:
def __init__(self, env):
super().__init__()
self.env = env
self.existing = self.search_all()
def count_all(self):
return len(self.search_all())
def count_created(self):
return len(self.search_created())
def count_existing(self):
return len(self.existing)
def search_created(self):
return self.search_all() - self.existing
def search_all(self):
return self.env["queue.job"].search([])
class JobMixin:
def job_counter(self):
return JobCounter(self.env)
def perform_jobs(self, jobs):
for job in jobs.search_created():
Job.load(self.env, job.uuid).perform()
@contextmanager
def mock_with_delay():
"""Context Manager mocking ``with_delay()``
Mocking this method means we can decorrelate the tests in:
* the part that delay the job with the expected arguments
* the execution of the job itself
The first kind of test does not need to actually create the jobs in the
database, as we can inspect how the Mocks were called.
The second kind of test calls directly the method decorated by ``@job``
with the arguments that we want to test.
The context manager returns 2 mocks:
* the first allow to check that with_delay() was called and with which
arguments
* the second to check which job method was called and with which arguments.
Example of test::
def test_export(self):
with mock_with_delay() as (delayable_cls, delayable):
# inside this method, there is a call
# partner.with_delay(priority=15).export_record('test')
self.record.run_export()
# check 'with_delay()' part:
self.assertEqual(delayable_cls.call_count, 1)
# arguments passed in 'with_delay()'
delay_args, delay_kwargs = delayable_cls.call_args
self.assertEqual(
delay_args, (self.env['res.partner'],)
)
self.assertDictEqual(delay_kwargs, {priority: 15})
# check what's passed to the job method 'export_record'
self.assertEqual(delayable.export_record.call_count, 1)
delay_args, delay_kwargs = delayable.export_record.call_args
self.assertEqual(delay_args, ('test',))
self.assertDictEqual(delay_kwargs, {})
An example of the first kind of test:
https://github.com/camptocamp/connector-jira/blob/0ca4261b3920d5e8c2ae4bb0fc352ea3f6e9d2cd/connector_jira/tests/test_batch_timestamp_import.py#L43-L76 # noqa
And the second kind:
https://github.com/camptocamp/connector-jira/blob/0ca4261b3920d5e8c2ae4bb0fc352ea3f6e9d2cd/connector_jira/tests/test_import_task.py#L34-L46 # noqa
"""
with mock.patch(
"odoo.addons.queue_job.models.base.DelayableRecordset",
name="DelayableRecordset",
spec=True,
) as delayable_cls:
# prepare the mocks
delayable = mock.MagicMock(name="DelayableBinding")
delayable_cls.return_value = delayable
yield delayable_cls, delayable
class OdooDocTestCase(doctest.DocTestCase):
"""
We need a custom DocTestCase class in order to:
- define test_tags to run as part of standard tests
- output a more meaningful test name than default "DocTestCase.runTest"
"""
def __init__(self, doctest, optionflags=0, setUp=None, tearDown=None, checker=None):
super().__init__(
doctest._dt_test,
optionflags=optionflags,
setUp=setUp,
tearDown=tearDown,
checker=checker,
)
def setUp(self):
"""Log an extra statement which test is started."""
super(OdooDocTestCase, self).setUp()
logging.getLogger(__name__).info("Running tests for %s", self._dt_test.name)
def load_doctests(module):
"""
Generates a tests loading method for the doctests of the given module
https://docs.python.org/3/library/unittest.html#load-tests-protocol
"""
def load_tests(loader, tests, ignore):
"""
Apply the 'test_tags' attribute to each DocTestCase found by the DocTestSuite.
Also extend the DocTestCase class trivially to fit the class teardown
that Odoo backported for its own test classes from Python 3.8.
"""
if sys.version_info < (3, 8):
doctest.DocTestCase.doClassCleanups = lambda: None
doctest.DocTestCase.tearDown_exceptions = []
for test in doctest.DocTestSuite(module):
odoo_test = OdooDocTestCase(test)
odoo_test.test_tags = {"standard", "at_install", "queue_job", "doctest"}
tests.addTest(odoo_test)
return tests
return load_tests

View File

@@ -0,0 +1,140 @@
# copyright 2016 Camptocamp
# license lgpl-3.0 or later (http://www.gnu.org/licenses/lgpl.html)
import json
from datetime import date, datetime
from lxml import etree
from odoo.tests import common
# pylint: disable=odoo-addons-relative-import
# we are testing, we want to test as we were an external consumer of the API
from odoo.addons.queue_job.fields import JobDecoder, JobEncoder
class TestJson(common.TransactionCase):
def test_encoder_recordset(self):
demo_user = self.env.ref("base.user_demo")
partner = self.env(user=demo_user).ref("base.main_partner")
value = partner
value_json = json.dumps(value, cls=JobEncoder)
expected = {
"uid": demo_user.id,
"_type": "odoo_recordset",
"model": "res.partner",
"ids": [partner.id],
"su": False,
}
self.assertEqual(json.loads(value_json), expected)
def test_encoder_recordset_list(self):
demo_user = self.env.ref("base.user_demo")
partner = self.env(user=demo_user).ref("base.main_partner")
value = ["a", 1, partner]
value_json = json.dumps(value, cls=JobEncoder)
expected = [
"a",
1,
{
"uid": demo_user.id,
"_type": "odoo_recordset",
"model": "res.partner",
"ids": [partner.id],
"su": False,
},
]
self.assertEqual(json.loads(value_json), expected)
def test_decoder_recordset(self):
demo_user = self.env.ref("base.user_demo")
partner = self.env(user=demo_user).ref("base.main_partner")
value_json = (
'{"_type": "odoo_recordset",'
'"model": "res.partner",'
'"su": false,'
'"ids": [%s],"uid": %s}' % (partner.id, demo_user.id)
)
expected = partner
value = json.loads(value_json, cls=JobDecoder, env=self.env)
self.assertEqual(value, expected)
self.assertEqual(demo_user, expected.env.user)
def test_decoder_recordset_list(self):
demo_user = self.env.ref("base.user_demo")
partner = self.env(user=demo_user).ref("base.main_partner")
value_json = (
'["a", 1, '
'{"_type": "odoo_recordset",'
'"model": "res.partner",'
'"su": false,'
'"ids": [%s],"uid": %s}]' % (partner.id, demo_user.id)
)
expected = ["a", 1, partner]
value = json.loads(value_json, cls=JobDecoder, env=self.env)
self.assertEqual(value, expected)
self.assertEqual(demo_user, expected[2].env.user)
def test_decoder_recordset_list_without_user(self):
value_json = (
'["a", 1, {"_type": "odoo_recordset",' '"model": "res.users", "ids": [1]}]'
)
expected = ["a", 1, self.env.ref("base.user_root")]
value = json.loads(value_json, cls=JobDecoder, env=self.env)
self.assertEqual(value, expected)
def test_encoder_datetime(self):
value = ["a", 1, datetime(2017, 4, 19, 8, 48, 50, 1)]
value_json = json.dumps(value, cls=JobEncoder)
expected = [
"a",
1,
{"_type": "datetime_isoformat", "value": "2017-04-19T08:48:50.000001"},
]
self.assertEqual(json.loads(value_json), expected)
def test_decoder_datetime(self):
value_json = (
'["a", 1, {"_type": "datetime_isoformat",'
'"value": "2017-04-19T08:48:50.000001"}]'
)
expected = ["a", 1, datetime(2017, 4, 19, 8, 48, 50, 1)]
value = json.loads(value_json, cls=JobDecoder, env=self.env)
self.assertEqual(value, expected)
def test_encoder_date(self):
value = ["a", 1, date(2017, 4, 19)]
value_json = json.dumps(value, cls=JobEncoder)
expected = ["a", 1, {"_type": "date_isoformat", "value": "2017-04-19"}]
self.assertEqual(json.loads(value_json), expected)
def test_decoder_date(self):
value_json = '["a", 1, {"_type": "date_isoformat",' '"value": "2017-04-19"}]'
expected = ["a", 1, date(2017, 4, 19)]
value = json.loads(value_json, cls=JobDecoder, env=self.env)
self.assertEqual(value, expected)
def test_encoder_etree(self):
etree_el = etree.Element("root", attr="val")
etree_el.append(etree.Element("child", attr="val"))
value = ["a", 1, etree_el]
value_json = json.dumps(value, cls=JobEncoder)
expected = [
"a",
1,
{
"_type": "etree_element",
"value": '<root attr="val"><child attr="val"/></root>',
},
]
self.assertEqual(json.loads(value_json), expected)
def test_decoder_etree(self):
value_json = '["a", 1, {"_type": "etree_element", "value": \
"<root attr=\\"val\\"><child attr=\\"val\\"/></root>"}]'
etree_el = etree.Element("root", attr="val")
etree_el.append(etree.Element("child", attr="val"))
expected = ["a", 1, etree.tostring(etree_el)]
value = json.loads(value_json, cls=JobDecoder, env=self.env)
value[2] = etree.tostring(value[2])
self.assertEqual(value, expected)

View File

@@ -0,0 +1,50 @@
# copyright 2018 Camptocamp
# license lgpl-3.0 or later (http://www.gnu.org/licenses/lgpl.html)
from psycopg2 import IntegrityError
import odoo
from odoo.tests import common
class TestJobChannel(common.TransactionCase):
def setUp(self):
super().setUp()
self.Channel = self.env["queue.job.channel"]
self.root_channel = self.Channel.search([("name", "=", "root")])
def test_channel_new(self):
channel = self.Channel.new()
self.assertFalse(channel.name)
self.assertFalse(channel.complete_name)
def test_channel_create(self):
channel = self.Channel.create(
{"name": "sub", "parent_id": self.root_channel.id}
)
self.assertEqual(channel.name, "sub")
self.assertEqual(channel.complete_name, "root.sub")
channel2 = self.Channel.create({"name": "sub", "parent_id": channel.id})
self.assertEqual(channel2.name, "sub")
self.assertEqual(channel2.complete_name, "root.sub.sub")
@odoo.tools.mute_logger("odoo.sql_db")
def test_channel_complete_name_uniq(self):
channel = self.Channel.create(
{"name": "sub", "parent_id": self.root_channel.id}
)
self.assertEqual(channel.name, "sub")
self.assertEqual(channel.complete_name, "root.sub")
self.Channel.create({"name": "sub", "parent_id": self.root_channel.id})
with self.assertRaises(IntegrityError):
# Flush process all the pending recomputations (or at least the
# given field and flush the pending updates to the database.
# It is normally called on commit.
self.env["base"].flush()
def test_channel_name_get(self):
channel = self.Channel.create(
{"name": "sub", "parent_id": self.root_channel.id}
)
self.assertEqual(channel.name_get(), [(channel.id, "root.sub")])

View File

@@ -0,0 +1,56 @@
# copyright 2020 Camptocamp
# license lgpl-3.0 or later (http://www.gnu.org/licenses/lgpl.html)
from odoo import exceptions
from odoo.tests import common
class TestJobFunction(common.SavepointCase):
def test_function_name_compute(self):
function = self.env["queue.job.function"].create(
{"model_id": self.env.ref("base.model_res_users").id, "method": "read"}
)
self.assertEqual(function.name, "<res.users>.read")
def test_function_name_inverse(self):
function = self.env["queue.job.function"].create({"name": "<res.users>.read"})
self.assertEqual(function.model_id.model, "res.users")
self.assertEqual(function.method, "read")
def test_function_name_inverse_invalid_regex(self):
with self.assertRaises(exceptions.UserError):
self.env["queue.job.function"].create({"name": "<res.users.read"})
def test_function_name_inverse_model_not_found(self):
with self.assertRaises(exceptions.UserError):
self.env["queue.job.function"].create(
{"name": "<this.model.does.not.exist>.read"}
)
def test_function_job_config(self):
channel = self.env["queue.job.channel"].create(
{"name": "foo", "parent_id": self.env.ref("queue_job.channel_root").id}
)
self.env["queue.job.function"].create(
{
"model_id": self.env.ref("base.model_res_users").id,
"method": "read",
"channel_id": channel.id,
"edit_retry_pattern": "{1: 2, 3: 4}",
"edit_related_action": (
'{"enable": True,'
' "func_name": "related_action_foo",'
' "kwargs": {"b": 1}}'
),
}
)
self.assertEqual(
self.env["queue.job.function"].job_config("<res.users>.read"),
self.env["queue.job.function"].JobConfig(
channel="root.foo",
retry_pattern={1: 2, 3: 4},
related_action_enable=True,
related_action_func_name="related_action_foo",
related_action_kwargs={"b": 1},
),
)

View File

@@ -0,0 +1,25 @@
# copyright 2020 Camptocamp
# license lgpl-3.0 or later (http://www.gnu.org/licenses/lgpl.html)
from odoo import exceptions
from odoo.tests import common
class TestJobWriteProtected(common.SavepointCase):
def test_create_error(self):
with self.assertRaises(exceptions.AccessError):
self.env["queue.job"].create(
{"uuid": "test", "model_name": "res.partner", "method_name": "write"}
)
def test_write_protected_field_error(self):
job_ = self.env["res.partner"].with_delay().create({"name": "test"})
db_job = job_.db_record()
with self.assertRaises(exceptions.AccessError):
db_job.method_name = "unlink"
def test_write_allow_no_protected_field_error(self):
job_ = self.env["res.partner"].with_delay().create({"name": "test"})
db_job = job_.db_record()
db_job.priority = 30
self.assertEqual(db_job.priority, 30)

View File

@@ -0,0 +1,10 @@
# Copyright 2015-2016 Camptocamp SA
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html)
# pylint: disable=odoo-addons-relative-import
# we are testing, we want to test as we were an external consumer of the API
from odoo.addons.queue_job.jobrunner import channels
from .common import load_doctests
load_tests = load_doctests(channels)

View File

@@ -0,0 +1,10 @@
# Copyright 2015-2016 Camptocamp SA
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html)
# pylint: disable=odoo-addons-relative-import
# we are testing, we want to test as we were an external consumer of the API
from odoo.addons.queue_job.jobrunner import runner
from .common import load_doctests
load_tests = load_doctests(runner)

View File

@@ -0,0 +1,58 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<record id="view_queue_job_channel_form" model="ir.ui.view">
<field name="name">queue.job.channel.form</field>
<field name="model">queue.job.channel</field>
<field name="arch" type="xml">
<form string="Channels">
<group>
<field
name="name"
attrs="{'required': [('name', '!=', 'root')], 'readonly': [('name', '=', 'root')]}"
/>
<field
name="parent_id"
attrs="{'required': [('name', '!=', 'root')], 'readonly': [('name', '=', 'root')]}"
/>
<field name="complete_name" />
<field name="removal_interval" />
</group>
<group>
<field name="job_function_ids" widget="many2many_tags" />
</group>
</form>
</field>
</record>
<record id="view_queue_job_channel_tree" model="ir.ui.view">
<field name="name">queue.job.channel.tree</field>
<field name="model">queue.job.channel</field>
<field name="arch" type="xml">
<tree>
<field name="complete_name" />
</tree>
</field>
</record>
<record id="view_queue_job_channel_search" model="ir.ui.view">
<field name="name">queue.job.channel.search</field>
<field name="model">queue.job.channel</field>
<field name="arch" type="xml">
<search string="Channels">
<field name="name" />
<field name="complete_name" />
<field name="parent_id" />
</search>
</field>
</record>
<record id="action_queue_job_channel" model="ir.actions.act_window">
<field name="name">Channels</field>
<field name="res_model">queue.job.channel</field>
<field name="view_mode">tree,form</field>
<field name="context">{}</field>
<field name="view_id" ref="view_queue_job_channel_tree" />
</record>
</odoo>

View File

@@ -0,0 +1,58 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<record id="view_queue_job_function_form" model="ir.ui.view">
<field name="name">queue.job.function.form</field>
<field name="model">queue.job.function</field>
<field name="arch" type="xml">
<form string="Job Functions">
<group>
<field name="name" readonly="1" />
<field name="model_id" required="1" />
<field name="method" required="1" />
<field name="channel_id" />
<field name="edit_retry_pattern" widget="ace" />
<field name="edit_related_action" widget="ace" />
</group>
</form>
</field>
</record>
<record id="view_queue_job_function_tree" model="ir.ui.view">
<field name="name">queue.job.function.tree</field>
<field name="model">queue.job.function</field>
<field name="arch" type="xml">
<tree>
<field name="name" />
<field name="channel_id" />
</tree>
</field>
</record>
<record id="view_queue_job_function_search" model="ir.ui.view">
<field name="name">queue.job.function.search</field>
<field name="model">queue.job.function</field>
<field name="arch" type="xml">
<search string="Job Functions">
<field name="name" />
<field name="channel_id" />
<group expand="0" string="Group By">
<filter
name="group_by_channel"
string="Channel"
context="{'group_by': 'channel_id'}"
/>
</group>
</search>
</field>
</record>
<record id="action_queue_job_function" model="ir.actions.act_window">
<field name="name">Job Functions</field>
<field name="res_model">queue.job.function</field>
<field name="view_mode">tree,form</field>
<field name="context">{}</field>
<field name="view_id" ref="view_queue_job_function_tree" />
</record>
</odoo>

View File

@@ -0,0 +1,34 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<menuitem
id="menu_queue_job_root"
name="Job Queue"
web_icon="queue_job,static/description/icon.png"
groups="group_queue_job_manager"
/>
<menuitem id="menu_queue" name="Queue" parent="menu_queue_job_root" />
<menuitem
id="menu_queue_job"
action="action_queue_job"
sequence="10"
parent="menu_queue"
/>
<menuitem
id="menu_queue_job_channel"
action="action_queue_job_channel"
sequence="12"
parent="menu_queue"
/>
<menuitem
id="menu_queue_job_function"
action="action_queue_job_function"
sequence="14"
parent="menu_queue"
/>
</odoo>

View File

@@ -0,0 +1,188 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<record id="view_queue_job_form" model="ir.ui.view">
<field name="name">queue.job.form</field>
<field name="model">queue.job</field>
<field name="arch" type="xml">
<form string="Jobs" create="false" delete="false">
<header>
<button
name="requeue"
states="failed"
class="oe_highlight"
string="Requeue Job"
type="object"
groups="queue_job.group_queue_job_manager"
/>
<button
name="button_done"
states="pending,enqueued,failed"
class="oe_highlight"
string="Set to 'Done'"
type="object"
groups="queue_job.group_queue_job_manager"
/>
<button name="open_related_action" string="Related" type="object" />
<field
name="state"
widget="statusbar"
statusbar_visible="pending,enqueued,started,done"
statusbar_colors='{"failed":"red","done":"green"}'
/>
</header>
<sheet>
<h1>
<field name="name" class="oe_inline" />
</h1>
<group>
<field name="uuid" />
<field name="func_string" />
<field name="job_function_id" />
<field name="channel" />
</group>
<group>
<group>
<field name="priority" />
<field name="eta" />
<field
name="company_id"
groups="base.group_multi_company"
/>
<field name="user_id" />
<field name="worker_pid" groups="base.group_no_one" />
</group>
<group>
<field name="date_created" />
<field name="date_enqueued" />
<field name="date_started" />
<field name="date_done" />
</group>
</group>
<group colspan="4">
<div>
<label for="retry" string="Current try / max. retries" />
<field name="retry" class="oe_inline" /> /
<field name="max_retries" class="oe_inline" />
<span
class="oe_grey oe_inline"
> If the max. retries is 0, the number of retries is infinite.</span>
</div>
</group>
<group
name="result"
string="Result"
attrs="{'invisible': [('result', '=', False)]}"
>
<field nolabel="1" name="result" />
</group>
<group
name="exc_info"
string="Exception Information"
attrs="{'invisible': [('exc_info', '=', False)]}"
>
<field nolabel="1" name="exc_info" />
</group>
</sheet>
<div class="oe_chatter">
<field name="message_follower_ids" widget="mail_followers" />
<field name="activity_ids" widget="mail_activity" />
<field name="message_ids" widget="mail_thread" />
</div>
</form>
</field>
</record>
<record id="view_queue_job_tree" model="ir.ui.view">
<field name="name">queue.job.tree</field>
<field name="model">queue.job</field>
<field name="arch" type="xml">
<tree
create="false"
delete="false"
decoration-danger="state == 'failed'"
decoration-muted="state == 'done'"
>
<field name="name" />
<field name="model_name" />
<field name="state" />
<field name="eta" />
<field name="date_created" />
<field name="date_done" />
<field name="uuid" />
<field name="channel" />
<field name="company_id" groups="base.group_multi_company" />
</tree>
</field>
</record>
<record id="view_queue_job_search" model="ir.ui.view">
<field name="name">queue.job.search</field>
<field name="model">queue.job</field>
<field name="arch" type="xml">
<search string="Jobs">
<field name="uuid" />
<field name="name" />
<field name="func_string" />
<field name="channel" />
<field name="job_function_id" />
<field
name="company_id"
groups="base.group_multi_company"
widget="selection"
/>
<filter
name="pending"
string="Pending"
domain="[('state', '=', 'pending')]"
/>
<filter
name="enqueued"
string="Enqueued"
domain="[('state', '=', 'enqueued')]"
/>
<filter
name="started"
string="Started"
domain="[('state', '=', 'started')]"
/>
<filter name="done" string="Done" domain="[('state', '=', 'done')]" />
<filter
name="failed"
string="Failed"
domain="[('state', '=', 'failed')]"
/>
<group expand="0" string="Group By">
<filter
name="group_by_channel"
string="Channel"
context="{'group_by': 'channel'}"
/>
<filter
name="group_by_job_function_id"
string="Job Function"
context="{'group_by': 'job_function_id'}"
/>
<filter
name="group_by_state"
string="State"
context="{'group_by': 'state'}"
/>
</group>
</search>
</field>
</record>
<record id="action_queue_job" model="ir.actions.act_window">
<field name="name">Jobs</field>
<field name="res_model">queue.job</field>
<field name="view_mode">tree,form</field>
<field name="context">{'search_default_pending': 1,
'search_default_enqueued': 1,
'search_default_started': 1,
'search_default_failed': 1}</field>
<field name="view_id" ref="view_queue_job_tree" />
<field name="search_view_id" ref="view_queue_job_search" />
</record>
</odoo>

2
queue_job/wizards/__init__.py Executable file
View File

@@ -0,0 +1,2 @@
from . import queue_requeue_job
from . import queue_jobs_to_done

View File

@@ -0,0 +1,15 @@
# Copyright 2013-2020 Camptocamp SA
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html)
from odoo import models
class SetJobsToDone(models.TransientModel):
_inherit = "queue.requeue.job"
_name = "queue.jobs.to.done"
_description = "Set all selected jobs to done"
def set_done(self):
jobs = self.job_ids
jobs.button_done()
return {"type": "ir.actions.act_window_close"}

View File

@@ -0,0 +1,34 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<record id="view_set_jobs_done" model="ir.ui.view">
<field name="name">Set Jobs to Done</field>
<field name="model">queue.jobs.to.done</field>
<field name="arch" type="xml">
<form string="Set jobs done">
<group string="The selected jobs will be set to done.">
<field name="job_ids" nolabel="1" />
</group>
<footer>
<button
name="set_done"
string="Set to done"
type="object"
class="oe_highlight"
/>
<button string="Cancel" class="oe_link" special="cancel" />
</footer>
</form>
</field>
</record>
<record id="action_set_jobs_done" model="ir.actions.act_window">
<field name="name">Set jobs to done</field>
<field name="res_model">queue.jobs.to.done</field>
<field name="view_mode">form</field>
<field name="view_id" ref="view_set_jobs_done" />
<field name="target">new</field>
<field name="binding_model_id" ref="queue_job.model_queue_job" />
</record>
</odoo>

View File

@@ -0,0 +1,25 @@
# Copyright 2013-2020 Camptocamp SA
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html)
from odoo import fields, models
class QueueRequeueJob(models.TransientModel):
_name = "queue.requeue.job"
_description = "Wizard to requeue a selection of jobs"
def _default_job_ids(self):
res = False
context = self.env.context
if context.get("active_model") == "queue.job" and context.get("active_ids"):
res = context["active_ids"]
return res
job_ids = fields.Many2many(
comodel_name="queue.job", string="Jobs", default=lambda r: r._default_job_ids()
)
def requeue(self):
jobs = self.job_ids
jobs.requeue()
return {"type": "ir.actions.act_window_close"}

View File

@@ -0,0 +1,34 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<record id="view_requeue_job" model="ir.ui.view">
<field name="name">Requeue Jobs</field>
<field name="model">queue.requeue.job</field>
<field name="arch" type="xml">
<form string="Requeue Jobs">
<group string="The selected jobs will be requeued.">
<field name="job_ids" nolabel="1" />
</group>
<footer>
<button
name="requeue"
string="Requeue"
type="object"
class="oe_highlight"
/>
<button string="Cancel" class="oe_link" special="cancel" />
</footer>
</form>
</field>
</record>
<record id="action_requeue_job" model="ir.actions.act_window">
<field name="name">Requeue Jobs</field>
<field name="res_model">queue.requeue.job</field>
<field name="view_mode">form</field>
<field name="view_id" ref="view_requeue_job" />
<field name="target">new</field>
<field name="binding_model_id" ref="queue_job.model_queue_job" />
</record>
</odoo>