[ADD] base modules
This commit is contained in:
384
queue_job/README.rst
Executable file
384
queue_job/README.rst
Executable 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
9
queue_job/__init__.py
Executable 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
29
queue_job/__manifest__.py
Executable 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",
|
||||
}
|
||||
1
queue_job/controllers/__init__.py
Executable file
1
queue_job/controllers/__init__.py
Executable file
@@ -0,0 +1 @@
|
||||
from . import main
|
||||
144
queue_job/controllers/main.py
Executable file
144
queue_job/controllers/main.py
Executable 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
37
queue_job/data/queue_data.xml
Executable 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>
|
||||
6
queue_job/data/queue_job_function_data.xml
Executable file
6
queue_job/data/queue_job_function_data.xml
Executable 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
43
queue_job/exception.py
Executable 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
118
queue_job/fields.py
Executable 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
826
queue_job/i18n/de.po
Executable 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
782
queue_job/i18n/queue_job.pot
Executable 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
820
queue_job/i18n/zh_CN.po
Executable 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
736
queue_job/job.py
Executable 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
149
queue_job/jobrunner/__init__.py
Executable 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
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
520
queue_job/jobrunner/runner.py
Executable 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
5
queue_job/models/__init__.py
Executable 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
191
queue_job/models/base.py
Executable 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)
|
||||
13
queue_job/models/ir_model_fields.py
Executable file
13
queue_job/models/ir_model_fields.py
Executable 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
386
queue_job/models/queue_job.py
Executable 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.")
|
||||
94
queue_job/models/queue_job_channel.py
Executable file
94
queue_job/models/queue_job_channel.py
Executable 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
|
||||
253
queue_job/models/queue_job_function.py
Executable file
253
queue_job/models/queue_job_function.py
Executable 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
33
queue_job/post_init_hook.py
Executable 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
43
queue_job/readme/CONFIGURE.rst
Executable 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.
|
||||
11
queue_job/readme/CONTRIBUTORS.rst
Executable file
11
queue_job/readme/CONTRIBUTORS.rst
Executable 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>
|
||||
46
queue_job/readme/DESCRIPTION.rst
Executable file
46
queue_job/readme/DESCRIPTION.rst
Executable 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
18
queue_job/readme/HISTORY.rst
Executable 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
1
queue_job/readme/INSTALL.rst
Executable file
@@ -0,0 +1 @@
|
||||
Be sure to have the ``requests`` library.
|
||||
18
queue_job/readme/ROADMAP.rst
Executable file
18
queue_job/readme/ROADMAP.rst
Executable 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
147
queue_job/readme/USAGE.rst
Executable 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.
|
||||
6
queue_job/security/ir.model.access.csv
Executable file
6
queue_job/security/ir.model.access.csv
Executable 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
|
||||
|
27
queue_job/security/security.xml
Executable file
27
queue_job/security/security.xml
Executable 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>
|
||||
BIN
queue_job/static/description/icon.png
Executable file
BIN
queue_job/static/description/icon.png
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 2.0 KiB |
150
queue_job/static/description/icon.svg
Executable file
150
queue_job/static/description/icon.svg
Executable 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 |
697
queue_job/static/description/index.html
Executable file
697
queue_job/static/description/index.html
Executable 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 PostgreSQL’s 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 <dbname>
|
||||
...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">@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"><record</span> <span class="na">id=</span><span class="s">"channel_sale"</span> <span class="na">model=</span><span class="s">"queue.job.channel"</span><span class="nt">></span>
|
||||
<span class="nt"><field</span> <span class="na">name=</span><span class="s">"name"</span><span class="nt">></span>sale<span class="nt"></field></span>
|
||||
<span class="nt"><field</span> <span class="na">name=</span><span class="s">"parent_id"</span> <span class="na">ref=</span><span class="s">"queue_job.channel_root"</span> <span class="nt">/></span>
|
||||
<span class="nt"></record></span>
|
||||
</pre>
|
||||
<p>Example of job function:</p>
|
||||
<pre class="code XML literal-block">
|
||||
<span class="nt"><record</span> <span class="na">id=</span><span class="s">"job_function_sale_order_action_done"</span> <span class="na">model=</span><span class="s">"queue.job.function"</span><span class="nt">></span>
|
||||
<span class="nt"><field</span> <span class="na">name=</span><span class="s">"model_id"</span> <span class="na">ref=</span><span class="s">"sale.model_sale_order"</span><span class="err"></field</span><span class="nt">></span>
|
||||
<span class="nt"><field</span> <span class="na">name=</span><span class="s">"method"</span><span class="nt">></span>action_done<span class="nt"></field></span>
|
||||
<span class="nt"><field</span> <span class="na">name=</span><span class="s">"channel_id"</span> <span class="na">ref=</span><span class="s">"channel_sale"</span> <span class="nt">/></span>
|
||||
<span class="nt"><field</span> <span class="na">name=</span><span class="s">"related_action"</span> <span class="na">eval=</span><span class="s">'{"func_name": "custom_related_action"}'</span> <span class="nt">/></span>
|
||||
<span class="nt"><field</span> <span class="na">name=</span><span class="s">"retry_pattern"</span> <span class="na">eval=</span><span class="s">"{1: 60, 2: 180, 3: 10, 5: 300}"</span> <span class="nt">/></span>
|
||||
<span class="nt"></record></span>
|
||||
</pre>
|
||||
<p>The general form for the <tt class="docutils literal">name</tt> is: <tt class="docutils literal"><span class="pre"><model.name>.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), 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.</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 Job’s 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 doesn’t 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">"enable"</span><span class="p">:</span> <span class="kc">False</span><span class="p">,</span>
|
||||
<span class="s2">"func_name"</span><span class="p">:</span> <span class="s2">"related_action_partner"</span><span class="p">,</span>
|
||||
<span class="s2">"kwargs"</span><span class="p">:</span> <span class="p">{</span><span class="s2">"name"</span><span class="p">:</span> <span class="s2">"Partner"</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">@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 > 0)</li>
|
||||
<li>[REF] <tt class="docutils literal">@job</tt> and <tt class="docutils literal">@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 <<a class="reference external" href="mailto:guewen.baconnier@camptocamp.com">guewen.baconnier@camptocamp.com</a>></li>
|
||||
<li>Stéphane Bidoul <<a class="reference external" href="mailto:stephane.bidoul@acsone.eu">stephane.bidoul@acsone.eu</a>></li>
|
||||
<li>Matthieu Dietrich <<a class="reference external" href="mailto:matthieu.dietrich@camptocamp.com">matthieu.dietrich@camptocamp.com</a>></li>
|
||||
<li>Jos De Graeve <<a class="reference external" href="mailto:Jos.DeGraeve@apertoso.be">Jos.DeGraeve@apertoso.be</a>></li>
|
||||
<li>David Lefever <<a class="reference external" href="mailto:dl@taktik.be">dl@taktik.be</a>></li>
|
||||
<li>Laurent Mignon <<a class="reference external" href="mailto:laurent.mignon@acsone.eu">laurent.mignon@acsone.eu</a>></li>
|
||||
<li>Laetitia Gangloff <<a class="reference external" href="mailto:laetitia.gangloff@acsone.eu">laetitia.gangloff@acsone.eu</a>></li>
|
||||
<li>Cédric Pigeon <<a class="reference external" href="mailto:cedric.pigeon@acsone.eu">cedric.pigeon@acsone.eu</a>></li>
|
||||
<li>Tatiana Deribina <<a class="reference external" href="mailto:tatiana.deribina@avoin.systems">tatiana.deribina@avoin.systems</a>></li>
|
||||
<li>Souheil Bejaoui <<a class="reference external" href="mailto:souheil.bejaoui@acsone.eu">souheil.bejaoui@acsone.eu</a>></li>
|
||||
<li>Eric Antones <<a class="reference external" href="mailto:eantones@nuobit.com">eantones@nuobit.com</a>></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
6
queue_job/tests/__init__.py
Executable 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
149
queue_job/tests/common.py
Executable 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
|
||||
140
queue_job/tests/test_json_field.py
Executable file
140
queue_job/tests/test_json_field.py
Executable 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)
|
||||
50
queue_job/tests/test_model_job_channel.py
Executable file
50
queue_job/tests/test_model_job_channel.py
Executable 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")])
|
||||
56
queue_job/tests/test_model_job_function.py
Executable file
56
queue_job/tests/test_model_job_function.py
Executable 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},
|
||||
),
|
||||
)
|
||||
25
queue_job/tests/test_queue_job_protected_write.py
Executable file
25
queue_job/tests/test_queue_job_protected_write.py
Executable 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)
|
||||
10
queue_job/tests/test_runner_channels.py
Executable file
10
queue_job/tests/test_runner_channels.py
Executable 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)
|
||||
10
queue_job/tests/test_runner_runner.py
Executable file
10
queue_job/tests/test_runner_runner.py
Executable 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)
|
||||
58
queue_job/views/queue_job_channel_views.xml
Executable file
58
queue_job/views/queue_job_channel_views.xml
Executable 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>
|
||||
58
queue_job/views/queue_job_function_views.xml
Executable file
58
queue_job/views/queue_job_function_views.xml
Executable 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>
|
||||
34
queue_job/views/queue_job_menus.xml
Executable file
34
queue_job/views/queue_job_menus.xml
Executable 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>
|
||||
188
queue_job/views/queue_job_views.xml
Executable file
188
queue_job/views/queue_job_views.xml
Executable 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
2
queue_job/wizards/__init__.py
Executable file
@@ -0,0 +1,2 @@
|
||||
from . import queue_requeue_job
|
||||
from . import queue_jobs_to_done
|
||||
15
queue_job/wizards/queue_jobs_to_done.py
Executable file
15
queue_job/wizards/queue_jobs_to_done.py
Executable 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"}
|
||||
34
queue_job/wizards/queue_jobs_to_done_views.xml
Executable file
34
queue_job/wizards/queue_jobs_to_done_views.xml
Executable 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>
|
||||
25
queue_job/wizards/queue_requeue_job.py
Executable file
25
queue_job/wizards/queue_requeue_job.py
Executable 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"}
|
||||
34
queue_job/wizards/queue_requeue_job_views.xml
Executable file
34
queue_job/wizards/queue_requeue_job_views.xml
Executable 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>
|
||||
Reference in New Issue
Block a user