From c4a2b6718db137753c47329838e9333b48d0539d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20BEAU?= Date: Fri, 20 Nov 2020 15:39:01 +0100 Subject: [PATCH 01/10] [IMP] : black, isort, prettier --- .pre-commit-config.yaml | 2 - queue_job/__manifest__.py | 6 +- queue_job/job.py | 2 +- queue_job/models/base.py | 2 +- queue_job/models/queue_job.py | 9 +- queue_job/static/description/icon.svg | 238 +++++++++--------- queue_job_cron/__manifest__.py | 2 +- queue_job_subscribe/__manifest__.py | 2 +- requirements.txt | 2 + setup/queue_job/odoo/addons/queue_job | 1 + setup/queue_job/setup.py | 6 + .../test_queue_job/odoo/addons/test_queue_job | 1 + setup/test_queue_job/setup.py | 6 + test_queue_job/__manifest__.py | 4 +- test_queue_job/models/test_models.py | 4 +- test_queue_job/tests/test_job.py | 6 +- 16 files changed, 162 insertions(+), 131 deletions(-) create mode 100644 requirements.txt create mode 120000 setup/queue_job/odoo/addons/queue_job create mode 100644 setup/queue_job/setup.py create mode 120000 setup/test_queue_job/odoo/addons/test_queue_job create mode 100644 setup/test_queue_job/setup.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 39468209d6..3b2487c16b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -3,11 +3,9 @@ exclude: | # NOT INSTALLABLE ADDONS ^base_export_async/| ^base_import_async/| - ^queue_job/| ^queue_job_cron/| ^queue_job_subscribe/| ^test_base_import_async/| - ^test_queue_job/| # END NOT INSTALLABLE ADDONS # Files and folders generated by bots, to avoid loops ^setup/|/static/description/index\.html$| diff --git a/queue_job/__manifest__.py b/queue_job/__manifest__.py index 0b1d16e56e..74e70419f7 100644 --- a/queue_job/__manifest__.py +++ b/queue_job/__manifest__.py @@ -3,9 +3,9 @@ { "name": "Job Queue", - "version": "13.0.3.2.0", + "version": "14.0.1.0.0", "author": "Camptocamp,ACSONE SA/NV,Odoo Community Association (OCA)", - "website": "https://github.com/OCA/queue/queue_job", + "website": "https://github.com/OCA/queue", "license": "LGPL-3", "category": "Generic Modules", "depends": ["mail"], @@ -17,7 +17,7 @@ "data/queue_data.xml", "data/queue_job_function_data.xml", ], - "installable": False, + "installable": True, "development_status": "Mature", "maintainers": ["guewen"], "post_init_hook": "post_init_hook", diff --git a/queue_job/job.py b/queue_job/job.py index 568808c002..36c2645520 100644 --- a/queue_job/job.py +++ b/queue_job/job.py @@ -392,7 +392,7 @@ def __init__( channel=None, identity_key=None, ): - """ Create a Job + """Create a Job :param func: function to execute :type func: function diff --git a/queue_job/models/base.py b/queue_job/models/base.py index 3bb4d78361..961f266cf5 100644 --- a/queue_job/models/base.py +++ b/queue_job/models/base.py @@ -44,7 +44,7 @@ def with_delay( channel=None, identity_key=None, ): - """ Return a ``DelayableRecordset`` + """Return a ``DelayableRecordset`` The returned instance allows to enqueue any method of the recordset's Model. diff --git a/queue_job/models/queue_job.py b/queue_job/models/queue_job.py index 1da0eaf86d..a758f58df0 100644 --- a/queue_job/models/queue_job.py +++ b/queue_job/models/queue_job.py @@ -69,7 +69,9 @@ class QueueJob(models.Model): # 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, + string="Record(s)", + readonly=True, + base_type=models.BaseModel, ) args = JobSerialized(readonly=True, base_type=tuple) kwargs = JobSerialized(readonly=True, base_type=dict) @@ -522,7 +524,10 @@ 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, + compute="_compute_name", + inverse="_inverse_name", + index=True, + store=True, ) # model and method should be required, but the required flag doesn't diff --git a/queue_job/static/description/icon.svg b/queue_job/static/description/icon.svg index a5fbb71bb6..fd5da3366b 100644 --- a/queue_job/static/description/icon.svg +++ b/queue_job/static/description/icon.svg @@ -1,138 +1,150 @@ - + - - + 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" +> + - + image/svg+xml - + - + + 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" + > + position="4.140625,50.300781" + orientation="-0.70710678,0.70710678" + id="guide3348" + inkscape:locked="false" + /> + position="56.476562,43.945312" + orientation="-0.70710678,0.70710678" + id="guide3350" + inkscape:locked="false" + /> + position="0,41.75" + orientation="1,0" + id="guide3360" + inkscape:locked="false" + /> + position="60,60.074219" + orientation="1,0" + id="guide3362" + inkscape:locked="false" + /> + position="0.17578125,60" + orientation="0,1" + id="guide3364" + inkscape:locked="false" + /> + position="58.71196,-0.0055242717" + orientation="0,1" + id="guide3366" + inkscape:locked="false" + /> + position="15.558594,50.5" + orientation="-0.70710678,0.70710678" + id="guide3345" + inkscape:locked="false" + /> + position="56.101563,32.363281" + orientation="-0.70710678,0.70710678" + id="guide3347" + inkscape:locked="false" + /> + position="55.972656,21.027344" + orientation="-0.70710678,0.70710678" + id="guide3349" + inkscape:locked="false" + /> + position="55.863281,9.6914063" + orientation="-0.70710678,0.70710678" + id="guide3351" + inkscape:locked="false" + /> + style="opacity:1;fill:#e74c3c;fill-opacity:1" + id="rect4147" + width="60" + height="60" + x="0" + y="0" + ry="3.5" + /> + 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" + /> + id="g4169" + transform="matrix(0.3061173,0,0,0.3061173,-1.0360053,-1.0457906)" + style="fill:#ffffff;stroke:none" + /> + 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" + > diff --git a/queue_job_cron/__manifest__.py b/queue_job_cron/__manifest__.py index 4379e830f6..24f7e3233f 100644 --- a/queue_job_cron/__manifest__.py +++ b/queue_job_cron/__manifest__.py @@ -5,7 +5,7 @@ "name": "Scheduled Actions as Queue Jobs", "version": "13.0.2.1.0", "author": "ACSONE SA/NV,Odoo Community Association (OCA)", - "website": "https://github.com/OCA/queue/tree/12.0/queue_job_cron", + "website": "https://github.com/OCA/queue", "license": "AGPL-3", "category": "Generic Modules", "depends": ["queue_job"], diff --git a/queue_job_subscribe/__manifest__.py b/queue_job_subscribe/__manifest__.py index 7223a50293..7b6453f193 100644 --- a/queue_job_subscribe/__manifest__.py +++ b/queue_job_subscribe/__manifest__.py @@ -4,7 +4,7 @@ "name": "Queue Job Subscribe", "version": "13.0.1.0.0", "author": "Acsone SA/NV, Odoo Community Association (OCA)", - "website": "https://github.com/OCA/queue_job", + "website": "https://github.com/OCA/queue", "summary": "Control which users are subscribed to queue job notifications", "license": "AGPL-3", "category": "Generic Modules", diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000000..b4d39fb9e0 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +# generated from manifests external_dependencies +requests diff --git a/setup/queue_job/odoo/addons/queue_job b/setup/queue_job/odoo/addons/queue_job new file mode 120000 index 0000000000..ac796aaa1c --- /dev/null +++ b/setup/queue_job/odoo/addons/queue_job @@ -0,0 +1 @@ +../../../../queue_job \ No newline at end of file diff --git a/setup/queue_job/setup.py b/setup/queue_job/setup.py new file mode 100644 index 0000000000..28c57bb640 --- /dev/null +++ b/setup/queue_job/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +) diff --git a/setup/test_queue_job/odoo/addons/test_queue_job b/setup/test_queue_job/odoo/addons/test_queue_job new file mode 120000 index 0000000000..0473d5583c --- /dev/null +++ b/setup/test_queue_job/odoo/addons/test_queue_job @@ -0,0 +1 @@ +../../../../test_queue_job \ No newline at end of file diff --git a/setup/test_queue_job/setup.py b/setup/test_queue_job/setup.py new file mode 100644 index 0000000000..28c57bb640 --- /dev/null +++ b/setup/test_queue_job/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +) diff --git a/test_queue_job/__manifest__.py b/test_queue_job/__manifest__.py index a09c89b07f..6b4dfa89e5 100644 --- a/test_queue_job/__manifest__.py +++ b/test_queue_job/__manifest__.py @@ -3,7 +3,7 @@ { "name": "Queue Job Tests", - "version": "13.0.2.1.0", + "version": "14.0.1.0.0", "author": "Camptocamp,Odoo Community Association (OCA)", "license": "LGPL-3", "category": "Generic Modules", @@ -14,5 +14,5 @@ "data/queue_job_function_data.xml", "security/ir.model.access.csv", ], - "installable": False, + "installable": True, } diff --git a/test_queue_job/models/test_models.py b/test_queue_job/models/test_models.py index 36fdb1c6f9..9d5e4fc457 100644 --- a/test_queue_job/models/test_models.py +++ b/test_queue_job/models/test_models.py @@ -34,7 +34,7 @@ class TestQueueJob(models.Model): name = fields.Char() def testing_method(self, *args, **kwargs): - """ Method used for tests + """Method used for tests Return always the arguments and keyword arguments received """ @@ -79,7 +79,7 @@ def job_sub_channel(self): # TODO deprecated by :job-no-decorator: @property def dummy_property(self): - """ Return foo + """Return foo Only there to check that properties are compatible with the automatic registration of job methods diff --git a/test_queue_job/tests/test_job.py b/test_queue_job/tests/test_job.py index f9b679cc27..83222f1af8 100644 --- a/test_queue_job/tests/test_job.py +++ b/test_queue_job/tests/test_job.py @@ -327,7 +327,7 @@ class TestJobs(JobCommonCase): """ Test jobs on other methods or with different job configuration """ def test_description(self): - """ If no description is given to the job, it + """If no description is given to the job, it should be computed from the function """ # if a docstring is defined for the function @@ -610,8 +610,8 @@ def test_job_default_company_id(self): ) def test_job_no_company_id(self): - """ if we put an empty company_id in the context - jobs are created without company_id + """if we put an empty company_id in the context + jobs are created without company_id """ env = self.env(context={"company_id": None}) stored = self._create_job(env) From 1dbfc1184fb25920c046eb6715b83e53623eef01 Mon Sep 17 00:00:00 2001 From: Eric Antones Date: Fri, 16 Oct 2020 18:53:22 +0200 Subject: [PATCH 02/10] [MIG] queue_job: Migration to 14.0 --- .../13.0.1.2.0/post-migrate-queue-job.py | 19 ------------------- queue_job/models/ir_model_fields.py | 5 ++++- queue_job/readme/CONTRIBUTORS.rst | 1 + queue_job/security/ir.model.access.csv | 2 ++ 4 files changed, 7 insertions(+), 20 deletions(-) delete mode 100644 queue_job/migrations/13.0.1.2.0/post-migrate-queue-job.py diff --git a/queue_job/migrations/13.0.1.2.0/post-migrate-queue-job.py b/queue_job/migrations/13.0.1.2.0/post-migrate-queue-job.py deleted file mode 100644 index 11ddf11737..0000000000 --- a/queue_job/migrations/13.0.1.2.0/post-migrate-queue-job.py +++ /dev/null @@ -1,19 +0,0 @@ -# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html) - -from odoo import SUPERUSER_ID, api - - -def migrate(cr, version): - with api.Environment.manage(): - env = api.Environment(cr, SUPERUSER_ID, {}) - rule = env.ref("queue_job.queue_job_comp_rule", raise_if_not_found=False) - if rule: - domain = """[ - '|', - ('company_id', '=', False), - ('company_id', 'in', company_ids) - ]""" - values = { - "domain_force": domain, - } - rule.write(values) diff --git a/queue_job/models/ir_model_fields.py b/queue_job/models/ir_model_fields.py index 30d48dc236..5a31fcdc5f 100644 --- a/queue_job/models/ir_model_fields.py +++ b/queue_job/models/ir_model_fields.py @@ -7,4 +7,7 @@ class IrModelFields(models.Model): _inherit = "ir.model.fields" - ttype = fields.Selection(selection_add=[("job_serialized", "Job Serialized")]) + ttype = fields.Selection( + selection_add=[("job_serialized", "Job Serialized")], + ondelete={"job_serialized": "cascade"}, + ) diff --git a/queue_job/readme/CONTRIBUTORS.rst b/queue_job/readme/CONTRIBUTORS.rst index 9694c9654a..0f8bb1a3b2 100644 --- a/queue_job/readme/CONTRIBUTORS.rst +++ b/queue_job/readme/CONTRIBUTORS.rst @@ -8,3 +8,4 @@ * Cédric Pigeon * Tatiana Deribina * Souheil Bejaoui +* Eric Antones diff --git a/queue_job/security/ir.model.access.csv b/queue_job/security/ir.model.access.csv index e90eee9ae4..9242305158 100644 --- a/queue_job/security/ir.model.access.csv +++ b/queue_job/security/ir.model.access.csv @@ -2,3 +2,5 @@ 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 From 19214c3148a3e82ff2db6b4445cfaf9101ac10c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20BEAU?= Date: Fri, 20 Nov 2020 15:58:04 +0100 Subject: [PATCH 03/10] [REF] remove deprecated feature --- queue_job/job.py | 252 ---------------------- queue_job/models/base.py | 15 -- queue_job/models/queue_job.py | 29 --- queue_job/readme/HISTORY.rst | 22 +- test_queue_job/models/test_models.py | 11 - test_queue_job/tests/common.py | 3 - test_queue_job/tests/test_job_channels.py | 13 +- 7 files changed, 2 insertions(+), 343 deletions(-) diff --git a/queue_job/job.py b/queue_job/job.py index 36c2645520..06eb6c0516 100644 --- a/queue_job/job.py +++ b/queue_job/job.py @@ -1,7 +1,6 @@ # Copyright 2013-2020 Camptocamp # License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html) -import functools import hashlib import inspect import logging @@ -684,9 +683,6 @@ def __repr__(self): def _get_retry_seconds(self, seconds=None): retry_pattern = self.job_config.retry_pattern - if not retry_pattern: - # TODO deprecated by :job-no-decorator: - retry_pattern = getattr(self.func, "retry_pattern", None) if not seconds and retry_pattern: # ordered from higher to lower count of retries patt = sorted(retry_pattern.items(), key=lambda t: t[0]) @@ -719,13 +715,6 @@ def related_action(self): return None funcname = self.job_config.related_action_func_name - if not funcname and hasattr(self.func, "related_action"): - # TODO deprecated by :job-no-decorator: - funcname = self.func.related_action - # decorator is set but empty: disable the default one - if not funcname: - return None - if not funcname: funcname = record._default_related_action if not isinstance(funcname, str): @@ -735,9 +724,6 @@ def related_action(self): ) action = getattr(record, funcname) action_kwargs = self.job_config.related_action_kwargs - if not action_kwargs: - # TODO deprecated by :job-no-decorator: - action_kwargs = getattr(self.func, "kwargs", {}) return action(**action_kwargs) @@ -745,241 +731,3 @@ def _is_model_method(func): return inspect.ismethod(func) and isinstance( func.__self__.__class__, odoo.models.MetaModel ) - - -# TODO deprecated by :job-no-decorator: -def job(func=None, default_channel="root", retry_pattern=None): - """Decorator for job methods. - - Deprecated. Use ``queue.job.function`` XML records (details in - ``readme/USAGE.rst``). - - It enables the possibility to use a Model's method as a job function. - - Optional argument: - - :param default_channel: the channel wherein the job will be assigned. This - channel is set at the installation of the module - and can be manually changed later using the views. - :param retry_pattern: The retry pattern to use for postponing a job. - If a job is postponed and there is no eta - specified, the eta will be determined from the - dict in retry_pattern. When no retry pattern - is provided, jobs will be retried after - :const:`RETRY_INTERVAL` seconds. - :type retry_pattern: dict(retry_count,retry_eta_seconds) - - Indicates that a method of a Model can be delayed in the Job Queue. - - When a method has the ``@job`` decorator, its calls can then be delayed - with:: - - recordset.with_delay(priority=10).the_method(args, **kwargs) - - Where ``the_method`` is the method decorated with ``@job``. Its arguments - and keyword arguments will be kept in the Job Queue for its asynchronous - execution. - - ``default_channel`` indicates in which channel the job must be executed - - ``retry_pattern`` is a dict where keys are the count of retries and the - values are the delay to postpone a job. - - Example: - - .. code-block:: python - - class ProductProduct(models.Model): - _inherit = 'product.product' - - @job - def export_one_thing(self, one_thing): - # work - # export one_thing - - # [...] - - env['a.model'].export_one_thing(the_thing_to_export) - # => normal and synchronous function call - - env['a.model'].with_delay().export_one_thing(the_thing_to_export) - # => the job will be executed as soon as possible - - 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 - - @job(default_channel='root.subchannel') - def export_one_thing(one_thing): - # work - # export one_thing - - @job(retry_pattern={1: 10 * 60, - 5: 20 * 60, - 10: 30 * 60, - 15: 12 * 60 * 60}) - def retryable_example(): - # 5 first retries postponed 10 minutes later - # retries 5 to 10 postponed 20 minutes later - # retries 10 to 15 postponed 30 minutes later - # all subsequent retries postponed 12 hours later - raise RetryableJobError('Must be retried later') - - env['a.model'].with_delay().retryable_example() - - - See also: :py:func:`related_action` a related action can be attached - to a job - """ - if func is None: - return functools.partial( - job, default_channel=default_channel, retry_pattern=retry_pattern - ) - - xml_fields = [ - ' \n' - ' _test_job\n' - ] - if default_channel: - xml_fields.append(' ') - if retry_pattern: - xml_fields.append(' {retry_pattern}') - - _logger.info( - "@job is deprecated and no longer needed (on %s), it is advised to use an " - "XML record (activate DEBUG log for snippet)", - func.__name__, - ) - if _logger.isEnabledFor(logging.DEBUG): - xml_record = ( - '\n' + "\n".join(xml_fields) + "\n" - ).format(**{"method": func.__name__, "retry_pattern": retry_pattern}) - _logger.debug( - "XML snippet (to complete) for replacing @job on %s:\n%s", - func.__name__, - xml_record, - ) - - def delay_from_model(*args, **kwargs): - raise AttributeError( - "method.delay() can no longer be used, the general form is " - "env['res.users'].with_delay().method()" - ) - - assert default_channel == "root" or default_channel.startswith( - "root." - ), "The channel path must start by 'root'" - assert retry_pattern is None or isinstance( - retry_pattern, dict - ), "retry_pattern must be a dict" - - delay_func = delay_from_model - - func.delayable = True - func.delay = delay_func - func.retry_pattern = retry_pattern - func.default_channel = default_channel - return func - - -# TODO deprecated by :job-no-decorator: -def related_action(action=None, **kwargs): - """Attach a *Related Action* to a job (decorator) - - Deprecated. Use ``queue.job.function`` XML records (details in - ``readme/USAGE.rst``). - - A *Related Action* will appear as a button on the Odoo view. - The button will execute the action, usually it will open the - form view of the record related to the job. - - The ``action`` must be a method on the `queue.job` model. - - Example usage: - - .. code-block:: python - - class QueueJob(models.Model): - _inherit = 'queue.job' - - def related_action_partner(self): - self.ensure_one() - model = self.model_name - partner = self.records - # possibly get the real ID if partner_id is a binding ID - action = { - 'name': _("Partner"), - 'type': 'ir.actions.act_window', - 'res_model': model, - 'view_type': 'form', - 'view_mode': 'form', - 'res_id': partner.id, - } - return action - - class ResPartner(models.Model): - _inherit = 'res.partner' - - @job - @related_action(action='related_action_partner') - def export_partner(self): - # ... - - The kwargs are transmitted to the action: - - .. code-block:: python - - class QueueJob(models.Model): - _inherit = 'queue.job' - - def related_action_product(self, extra_arg=1): - assert extra_arg == 2 - model = self.model_name - ... - - class ProductProduct(models.Model): - _inherit = 'product.product' - - @job - @related_action(action='related_action_product', extra_arg=2) - def export_product(self): - # ... - - """ - - def decorate(func): - related_action_dict = { - "func_name": action, - } - if kwargs: - related_action_dict["kwargs"] = kwargs - - xml_fields = ( - ' \n' - ' _test_job\n' - ' {related_action}' - ) - - _logger.info( - "@related_action is deprecated and no longer needed (on %s)," - " it is advised to use an XML record (activate DEBUG log for snippet)", - func.__name__, - ) - if _logger.isEnabledFor(logging.DEBUG): - xml_record = ( - '\n' + xml_fields + "\n" - ).format(**{"method": func.__name__, "related_action": action}) - _logger.debug( - "XML snippet (to complete) for replacing @related_action on %s:\n%s", - func.__name__, - xml_record, - ) - - func.related_action = action - func.kwargs = kwargs - return func - - return decorate diff --git a/queue_job/models/base.py b/queue_job/models/base.py index 961f266cf5..a05da489b3 100644 --- a/queue_job/models/base.py +++ b/queue_job/models/base.py @@ -1,7 +1,6 @@ # Copyright 2016 Camptocamp # License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html) -import inspect import logging import os @@ -21,20 +20,6 @@ class Base(models.AbstractModel): _inherit = "base" - # TODO deprecated by :job-no-decorator: - def _register_hook(self): - """Register marked jobs""" - super(Base, self)._register_hook() - job_methods = [ - method - for __, method in inspect.getmembers( - self.__class__, predicate=inspect.isfunction - ) - if getattr(method, "delayable", None) - ] - for job_method in job_methods: - self.env["queue.job.function"]._register_job(self, job_method) - def with_delay( self, priority=None, diff --git a/queue_job/models/queue_job.py b/queue_job/models/queue_job.py index a758f58df0..9d2f3caab4 100644 --- a/queue_job/models/queue_job.py +++ b/queue_job/models/queue_job.py @@ -613,28 +613,6 @@ def _inverse_edit_related_action(self): def job_function_name(model_name, method_name): return "<{}>.{}".format(model_name, method_name) - # TODO deprecated by :job-no-decorator: - def _find_or_create_channel(self, channel_path): - channel_model = self.env["queue.job.channel"] - parts = channel_path.split(".") - parts.reverse() - channel_name = parts.pop() - assert channel_name == "root", "A channel path starts with 'root'" - # get the root channel - channel = channel_model.search([("name", "=", channel_name)]) - while parts: - channel_name = parts.pop() - parent_channel = channel - channel = channel_model.search( - [("name", "=", channel_name), ("parent_id", "=", parent_channel.id)], - limit=1, - ) - if not channel: - channel = channel_model.create( - {"name": channel_name, "parent_id": parent_channel.id} - ) - return channel - def job_default_config(self): return self.JobConfig( channel="root", @@ -752,10 +730,3 @@ def unlink(self): res = super().unlink() self.clear_caches() return res - - # TODO deprecated by :job-no-decorator: - def _register_job(self, model, job_method): - func_name = self.job_function_name(model._name, job_method.__name__) - if not self.search_count([("name", "=", func_name)]): - channel = self._find_or_create_channel(job_method.default_channel) - self.create({"name": func_name, "channel_id": channel.id}) diff --git a/queue_job/readme/HISTORY.rst b/queue_job/readme/HISTORY.rst index 072e87fd31..ef19313c45 100644 --- a/queue_job/readme/HISTORY.rst +++ b/queue_job/readme/HISTORY.rst @@ -15,24 +15,4 @@ Next process (when running with --workers > 0) * [REF] ``@job`` and ``@related_action`` deprecated, any method can be delayed, and configured using ``queue.job.function`` records - - -13.0.1.2.0 (2020-03-10) -~~~~~~~~~~~~~~~~~~~~~~~ - -* Fix Multi-company access rules - - -13.0.1.1.0 (2019-11-01) -~~~~~~~~~~~~~~~~~~~~~~~ - -Important: the license has been changed from AGPL3 to LGPL3. - -* Remove deprecated default company method - (details on `#180 `_) - - -13.0.1.0.0 (2019-10-14) -~~~~~~~~~~~~~~~~~~~~~~~ - -* [MIGRATION] from 12.0 branched at rev. 0138cd0 +* [MIGRATION] from 13.0 branched at rev. e24ff4b diff --git a/test_queue_job/models/test_models.py b/test_queue_job/models/test_models.py index 9d5e4fc457..5a93fa0b4f 100644 --- a/test_queue_job/models/test_models.py +++ b/test_queue_job/models/test_models.py @@ -76,17 +76,6 @@ def job_b(self): def job_sub_channel(self): return - # TODO deprecated by :job-no-decorator: - @property - def dummy_property(self): - """Return foo - - Only there to check that properties are compatible - with the automatic registration of job methods - and their default channels. - """ - return "foo" - class TestRelatedAction(models.Model): diff --git a/test_queue_job/tests/common.py b/test_queue_job/tests/common.py index 3a67dea4da..63ca8cca48 100644 --- a/test_queue_job/tests/common.py +++ b/test_queue_job/tests/common.py @@ -12,9 +12,6 @@ def setUp(self): self.queue_job = self.env["queue.job"] self.user = self.env["res.users"] self.method = self.env["test.queue.job"].testing_method - self.env["queue.job.function"]._register_job( - self.env["test.queue.job"], self.method - ) def _create_job(self): test_job = Job(self.method) diff --git a/test_queue_job/tests/test_job_channels.py b/test_queue_job/tests/test_job_channels.py index 01173e0dd9..e8585b01e8 100644 --- a/test_queue_job/tests/test_job_channels.py +++ b/test_queue_job/tests/test_job_channels.py @@ -4,7 +4,7 @@ import odoo.tests.common as common from odoo import exceptions -from odoo.addons.queue_job.job import Job, job +from odoo.addons.queue_job.job import Job class TestJobChannels(common.TransactionCase): @@ -85,17 +85,6 @@ def test_set_channel_from_record(self): self.assertEquals(channel.parent_id.parent_id.name, "root") self.assertEquals(job_func.channel, "root.sub.subsub") - # TODO deprecated by :job-no-decorator: - def test_job_decorator(self): - """ Test the job decorator """ - default_channel = "channel" - retry_pattern = {1: 5} - partial = job( - None, default_channel=default_channel, retry_pattern=retry_pattern - ) - self.assertEquals(partial.keywords.get("default_channel"), default_channel) - self.assertEquals(partial.keywords.get("retry_pattern"), retry_pattern) - def test_default_removal_interval(self): channel = self.channel_model.create( {"name": "number", "parent_id": self.root_channel.id} From e795c607ac1bd7a7d8fc71d90e6261150a954a9b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20BEAU?= Date: Fri, 20 Nov 2020 16:08:47 +0100 Subject: [PATCH 04/10] [MIG] finish migration --- queue_job/models/queue_job.py | 2 +- test_queue_job/tests/test_job_channels.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/queue_job/models/queue_job.py b/queue_job/models/queue_job.py index 9d2f3caab4..f7e1d0d0bd 100644 --- a/queue_job/models/queue_job.py +++ b/queue_job/models/queue_job.py @@ -250,7 +250,7 @@ def _message_post_on_failure(self): for record in self: msg = record._message_failed_job() if msg: - record.message_post(body=msg, subtype="queue_job.mt_job_failed") + 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""" diff --git a/test_queue_job/tests/test_job_channels.py b/test_queue_job/tests/test_job_channels.py index e8585b01e8..16c942603b 100644 --- a/test_queue_job/tests/test_job_channels.py +++ b/test_queue_job/tests/test_job_channels.py @@ -30,9 +30,9 @@ def test_channel_tree(self): self.channel_model.create({"name": "sub"}) def test_channel_root(self): - with self.assertRaises(exceptions.Warning): + with self.assertRaises(exceptions.UserError): self.root_channel.unlink() - with self.assertRaises(exceptions.Warning): + with self.assertRaises(exceptions.UserError): self.root_channel.name = "leaf" def test_channel_on_job(self): From 296ffae29089cd8c0b9c52954b6345fd9b474a17 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Sat, 28 Nov 2020 18:32:30 +0100 Subject: [PATCH 05/10] Adapt JobSerialized field to 14.0 changes The _slots attribute is no longer used. The field properties are defined as class attributes. See https://github.com/odoo/odoo/pull/48705 --- queue_job/fields.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/queue_job/fields.py b/queue_job/fields.py index 2658e01c73..f05d722c7b 100644 --- a/queue_job/fields.py +++ b/queue_job/fields.py @@ -25,7 +25,7 @@ class JobSerialized(fields.Field): type = "job_serialized" column_type = ("text", "text") - _slots = {"_base_type": type} + _base_type = None # these are the default values when we convert an empty value _default_json_mapping = { From 9660b2985b902e62c47513ae0fd7a29376188f03 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Sat, 28 Nov 2020 18:40:06 +0100 Subject: [PATCH 06/10] Remove 13.0 migrations scripts --- .../migrations/13.0.2.0.0/post-migration.py | 23 --------------- .../migrations/13.0.3.2.0/pre-migration.py | 28 ------------------- 2 files changed, 51 deletions(-) delete mode 100644 queue_job/migrations/13.0.2.0.0/post-migration.py delete mode 100644 queue_job/migrations/13.0.3.2.0/pre-migration.py diff --git a/queue_job/migrations/13.0.2.0.0/post-migration.py b/queue_job/migrations/13.0.2.0.0/post-migration.py deleted file mode 100644 index a399e53e36..0000000000 --- a/queue_job/migrations/13.0.2.0.0/post-migration.py +++ /dev/null @@ -1,23 +0,0 @@ -# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html) - -import logging - -from odoo import SUPERUSER_ID, api, exceptions - -_logger = logging.getLogger(__name__) - - -def migrate(cr, version): - with api.Environment.manage(): - env = api.Environment(cr, SUPERUSER_ID, {}) - for job_func in env["queue.job.function"].search([]): - try: - # trigger inverse field to set model_id and method - job_func.name = job_func.name - except exceptions.UserError: - # ignore invalid entries not to block migration - _logger.error( - "could not migrate job function '%s' (id: %s), invalid name", - job_func.name, - job_func.id, - ) diff --git a/queue_job/migrations/13.0.3.2.0/pre-migration.py b/queue_job/migrations/13.0.3.2.0/pre-migration.py deleted file mode 100644 index 897846fa83..0000000000 --- a/queue_job/migrations/13.0.3.2.0/pre-migration.py +++ /dev/null @@ -1,28 +0,0 @@ -# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html) - -import logging - -from odoo.tools.sql import column_exists - -_logger = logging.getLogger(__name__) - - -def migrate(cr, version): - if not column_exists(cr, "queue_job", "records"): - cr.execute( - """ - ALTER TABLE queue_job - ADD COLUMN records text; - """ - ) - cr.execute( - """ - UPDATE queue_job - SET records = '{"_type": "odoo_recordset"' - || ', "model": "' || model_name || '"' - || ', "uid": ' || user_id - || ', "ids": ' || record_ids - || '}' - WHERE records IS NULL; - """ - ) From 9ad73d8c13bec07f412bdafa579377acfe0b5fc7 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Sat, 28 Nov 2020 18:53:14 +0100 Subject: [PATCH 07/10] Reorganize models and views in different files Split one model per file, as advised in the contribution guide. --- queue_job/__init__.py | 1 + queue_job/__manifest__.py | 5 + queue_job/exception.py | 2 +- queue_job/fields.py | 2 +- queue_job/jobrunner/__init__.py | 4 +- queue_job/jobrunner/channels.py | 2 +- queue_job/models/__init__.py | 2 + queue_job/models/queue_job.py | 357 +----------------- queue_job/models/queue_job_channel.py | 94 +++++ queue_job/models/queue_job_function.py | 242 ++++++++++++ queue_job/tests/common.py | 2 +- queue_job/tests/test_model_job_channel.py | 2 +- queue_job/views/queue_job_channel_views.xml | 58 +++ queue_job/views/queue_job_function_views.xml | 58 +++ queue_job/views/queue_job_menus.xml | 34 ++ queue_job/views/queue_job_views.xml | 186 +-------- queue_job/wizards/__init__.py | 2 + queue_job/wizards/queue_jobs_to_done.py | 15 + .../wizards/queue_jobs_to_done_views.xml | 34 ++ queue_job/wizards/queue_requeue_job.py | 25 ++ queue_job/wizards/queue_requeue_job_views.xml | 34 ++ 21 files changed, 618 insertions(+), 543 deletions(-) create mode 100644 queue_job/models/queue_job_channel.py create mode 100644 queue_job/models/queue_job_function.py create mode 100644 queue_job/views/queue_job_channel_views.xml create mode 100644 queue_job/views/queue_job_function_views.xml create mode 100644 queue_job/views/queue_job_menus.xml create mode 100644 queue_job/wizards/__init__.py create mode 100644 queue_job/wizards/queue_jobs_to_done.py create mode 100644 queue_job/wizards/queue_jobs_to_done_views.xml create mode 100644 queue_job/wizards/queue_requeue_job.py create mode 100644 queue_job/wizards/queue_requeue_job_views.xml diff --git a/queue_job/__init__.py b/queue_job/__init__.py index 34b2e85788..6ead7beb1f 100644 --- a/queue_job/__init__.py +++ b/queue_job/__init__.py @@ -1,5 +1,6 @@ from . import controllers from . import fields from . import models +from . import wizards from . import jobrunner from .hooks.post_init_hook import post_init_hook diff --git a/queue_job/__manifest__.py b/queue_job/__manifest__.py index 74e70419f7..41dd5bfe66 100644 --- a/queue_job/__manifest__.py +++ b/queue_job/__manifest__.py @@ -14,6 +14,11 @@ "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", ], diff --git a/queue_job/exception.py b/queue_job/exception.py index 41f89991c4..093344ed3d 100644 --- a/queue_job/exception.py +++ b/queue_job/exception.py @@ -29,7 +29,7 @@ class RetryableJobError(JobError): """ def __init__(self, msg, seconds=None, ignore_retry=False): - super(RetryableJobError, self).__init__(msg) + super().__init__(msg) self.seconds = seconds self.ignore_retry = ignore_retry diff --git a/queue_job/fields.py b/queue_job/fields.py index f05d722c7b..50183993d8 100644 --- a/queue_job/fields.py +++ b/queue_job/fields.py @@ -97,7 +97,7 @@ class JobDecoder(json.JSONDecoder): def __init__(self, *args, **kwargs): env = kwargs.pop("env") - super(JobDecoder, self).__init__(object_hook=self.object_hook, *args, **kwargs) + super().__init__(object_hook=self.object_hook, *args, **kwargs) assert env self.env = env diff --git a/queue_job/jobrunner/__init__.py b/queue_job/jobrunner/__init__.py index 9de83aeeb6..7ad479ff33 100644 --- a/queue_job/jobrunner/__init__.py +++ b/queue_job/jobrunner/__init__.py @@ -51,7 +51,7 @@ class WorkerJobRunner(server.Worker): """ Jobrunner workers """ def __init__(self, multi): - super(WorkerJobRunner, self).__init__(multi) + super().__init__(multi) self.watchdog_timeout = None self.runner = QueueJobRunner.from_environ_or_config() @@ -60,7 +60,7 @@ def sleep(self): def signal_handler(self, sig, frame): _logger.debug("WorkerJobRunner (%s) received signal %s", self.pid, sig) - super(WorkerJobRunner, self).signal_handler(sig, frame) + super().signal_handler(sig, frame) self.runner.stop() def process_work(self): diff --git a/queue_job/jobrunner/channels.py b/queue_job/jobrunner/channels.py index 4a08f9f874..1f42a10cc9 100644 --- a/queue_job/jobrunner/channels.py +++ b/queue_job/jobrunner/channels.py @@ -117,7 +117,7 @@ class SafeSet(set): def remove(self, o): # pylint: disable=missing-return,except-pass try: - super(SafeSet, self).remove(o) + super().remove(o) except KeyError: pass diff --git a/queue_job/models/__init__.py b/queue_job/models/__init__.py index b7b1791462..4744e7ab46 100644 --- a/queue_job/models/__init__.py +++ b/queue_job/models/__init__.py @@ -1,3 +1,5 @@ from . import base from . import ir_model_fields from . import queue_job +from . import queue_job_channel +from . import queue_job_function diff --git a/queue_job/models/queue_job.py b/queue_job/models/queue_job.py index f7e1d0d0bd..9376fab07d 100644 --- a/queue_job/models/queue_job.py +++ b/queue_job/models/queue_job.py @@ -1,13 +1,10 @@ -# Copyright 2013-2016 Camptocamp SA +# 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 datetime import datetime, timedelta -from odoo import _, api, exceptions, fields, models, tools +from odoo import _, api, exceptions, fields, models from odoo.osv import expression from ..fields import JobSerialized @@ -16,9 +13,6 @@ _logger = logging.getLogger(__name__) -regex_job_function_name = re.compile(r"^<([0-9a-z_\.]+)>\.([0-9a-zA-Z_]+)$") - - class QueueJob(models.Model): """Model storing the jobs to be executed.""" @@ -383,350 +377,3 @@ def related_action_open_record(self): def _test_job(self): _logger.info("Running test job.") - - -class RequeueJob(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"} - - -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"} - - -class JobChannel(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(JobChannel, self).write(values) - - def unlink(self): - for channel in self: - if channel.name == "root": - raise exceptions.UserError(_("Cannot remove the root channel")) - return super(JobChannel, self).unlink() - - def name_get(self): - result = [] - for record in self: - result.append((record.id, record.complete_name)) - return result - - -class JobFunction(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.\n" - "Example: {1: 10, 5: 20, 10: 30, 15: 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: - self.retry_pattern = ast.literal_eval(self.edit_retry_pattern or "{}") - except (ValueError, TypeError): - 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: - self.related_action = ast.literal_eval(self.edit_related_action or "{}") - except (ValueError, TypeError): - 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 diff --git a/queue_job/models/queue_job_channel.py b/queue_job/models/queue_job_channel.py new file mode 100644 index 0000000000..374e7417f7 --- /dev/null +++ b/queue_job/models/queue_job_channel.py @@ -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 diff --git a/queue_job/models/queue_job_function.py b/queue_job/models/queue_job_function.py new file mode 100644 index 0000000000..023e26013e --- /dev/null +++ b/queue_job/models/queue_job_function.py @@ -0,0 +1,242 @@ +# 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.\n" + "Example: {1: 10, 5: 20, 10: 30, 15: 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: + self.retry_pattern = ast.literal_eval(self.edit_retry_pattern or "{}") + except (ValueError, TypeError): + 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: + self.related_action = ast.literal_eval(self.edit_related_action or "{}") + except (ValueError, TypeError): + 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 diff --git a/queue_job/tests/common.py b/queue_job/tests/common.py index 6795965b76..175e75a314 100644 --- a/queue_job/tests/common.py +++ b/queue_job/tests/common.py @@ -9,7 +9,7 @@ class JobCounter: def __init__(self, env): - super(JobCounter, self).__init__() + super().__init__() self.env = env self.existing = self.search_all() diff --git a/queue_job/tests/test_model_job_channel.py b/queue_job/tests/test_model_job_channel.py index 42ba7f3271..3fdc7b4c74 100644 --- a/queue_job/tests/test_model_job_channel.py +++ b/queue_job/tests/test_model_job_channel.py @@ -9,7 +9,7 @@ class TestJobChannel(common.TransactionCase): def setUp(self): - super(TestJobChannel, self).setUp() + super().setUp() self.Channel = self.env["queue.job.channel"] self.root_channel = self.Channel.search([("name", "=", "root")]) diff --git a/queue_job/views/queue_job_channel_views.xml b/queue_job/views/queue_job_channel_views.xml new file mode 100644 index 0000000000..0841a2514e --- /dev/null +++ b/queue_job/views/queue_job_channel_views.xml @@ -0,0 +1,58 @@ + + + + + queue.job.channel.form + queue.job.channel + +
+ + + + + + + + + +
+
+
+ + + queue.job.channel.tree + queue.job.channel + + + + + + + + + queue.job.channel.search + queue.job.channel + + + + + + + + + + + Channels + queue.job.channel + tree,form + {} + + + +
diff --git a/queue_job/views/queue_job_function_views.xml b/queue_job/views/queue_job_function_views.xml new file mode 100644 index 0000000000..a6e2ce402c --- /dev/null +++ b/queue_job/views/queue_job_function_views.xml @@ -0,0 +1,58 @@ + + + + + queue.job.function.form + queue.job.function + +
+ + + + + + + + +
+
+
+ + + queue.job.function.tree + queue.job.function + + + + + + + + + + queue.job.function.search + queue.job.function + + + + + + + + + + + + + Job Functions + queue.job.function + tree,form + {} + + + +
diff --git a/queue_job/views/queue_job_menus.xml b/queue_job/views/queue_job_menus.xml new file mode 100644 index 0000000000..d288d7c0b9 --- /dev/null +++ b/queue_job/views/queue_job_menus.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + diff --git a/queue_job/views/queue_job_views.xml b/queue_job/views/queue_job_views.xml index 1197495ead..a68c03d34f 100644 --- a/queue_job/views/queue_job_views.xml +++ b/queue_job/views/queue_job_views.xml @@ -1,5 +1,6 @@ + queue.job.form queue.job @@ -91,6 +92,7 @@
+ queue.job.tree queue.job @@ -113,6 +115,7 @@
+ queue.job.search queue.job @@ -169,6 +172,7 @@
+ Jobs queue.job @@ -180,185 +184,5 @@ - - Requeue Jobs - queue.requeue.job - -
- - - -
-
-
-
-
- - Set Jobs to Done - queue.jobs.to.done - -
- - - -
-
-
-
-
- - Requeue Jobs - queue.requeue.job - form - - new - - - - Set jobs to done - queue.jobs.to.done - form - - new - - - - queue.job.channel.form - queue.job.channel - -
- - - - - - - - - -
-
-
- - queue.job.channel.tree - queue.job.channel - - - - - - - - queue.job.channel.search - queue.job.channel - - - - - - - - - - Channels - queue.job.channel - tree,form - {} - - - - queue.job.function.form - queue.job.function - -
- - - - - - - - -
-
-
- - queue.job.function.tree - queue.job.function - - - - - - - - - queue.job.function.search - queue.job.function - - - - - - - - - - - - Job Functions - queue.job.function - tree,form - {} - - - - - - - + diff --git a/queue_job/wizards/__init__.py b/queue_job/wizards/__init__.py new file mode 100644 index 0000000000..0794047e75 --- /dev/null +++ b/queue_job/wizards/__init__.py @@ -0,0 +1,2 @@ +from . import queue_requeue_job +from . import queue_jobs_to_done diff --git a/queue_job/wizards/queue_jobs_to_done.py b/queue_job/wizards/queue_jobs_to_done.py new file mode 100644 index 0000000000..ff1366ffed --- /dev/null +++ b/queue_job/wizards/queue_jobs_to_done.py @@ -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"} diff --git a/queue_job/wizards/queue_jobs_to_done_views.xml b/queue_job/wizards/queue_jobs_to_done_views.xml new file mode 100644 index 0000000000..fde48ab3dd --- /dev/null +++ b/queue_job/wizards/queue_jobs_to_done_views.xml @@ -0,0 +1,34 @@ + + + + + Set Jobs to Done + queue.jobs.to.done + +
+ + + +
+
+
+
+
+ + + Set jobs to done + queue.jobs.to.done + form + + new + + + +
diff --git a/queue_job/wizards/queue_requeue_job.py b/queue_job/wizards/queue_requeue_job.py new file mode 100644 index 0000000000..67d2ffcbdc --- /dev/null +++ b/queue_job/wizards/queue_requeue_job.py @@ -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"} diff --git a/queue_job/wizards/queue_requeue_job_views.xml b/queue_job/wizards/queue_requeue_job_views.xml new file mode 100644 index 0000000000..9a4ca935e8 --- /dev/null +++ b/queue_job/wizards/queue_requeue_job_views.xml @@ -0,0 +1,34 @@ + + + + + Requeue Jobs + queue.requeue.job + +
+ + + +
+
+
+
+
+ + + Requeue Jobs + queue.requeue.job + form + + new + + + +
From 9bb8041cd96845982141684308189213a7c99608 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Sat, 28 Nov 2020 19:30:01 +0100 Subject: [PATCH 08/10] Fix error with undefined related action kwargs --- queue_job/models/queue_job_function.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/queue_job/models/queue_job_function.py b/queue_job/models/queue_job_function.py index 023e26013e..045653d3e4 100644 --- a/queue_job/models/queue_job_function.py +++ b/queue_job/models/queue_job_function.py @@ -161,7 +161,7 @@ def job_config(self, name): 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"), + related_action_kwargs=config.related_action.get("kwargs", {}), ) def _retry_pattern_format_error_message(self): From a70d8fb841e4c9150845b059fdb2c90a70ca8b9b Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Sat, 28 Nov 2020 19:36:24 +0100 Subject: [PATCH 09/10] Add import shortcut for identity_exact Now that the `@job` decorater is deleted, `identity_exact` is the only thing we may need to import from "job.py". Being able to import it from "odoo.addons.queue_job" is nicer than from "odoo.addons.queue_job.job". --- queue_job/__init__.py | 3 +++ test_queue_job/tests/test_job.py | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/queue_job/__init__.py b/queue_job/__init__.py index 6ead7beb1f..75f80cf5aa 100644 --- a/queue_job/__init__.py +++ b/queue_job/__init__.py @@ -4,3 +4,6 @@ from . import wizards from . import jobrunner from .hooks.post_init_hook import post_init_hook + +# shortcuts +from .job import identity_exact diff --git a/test_queue_job/tests/test_job.py b/test_queue_job/tests/test_job.py index 83222f1af8..0abe79f474 100644 --- a/test_queue_job/tests/test_job.py +++ b/test_queue_job/tests/test_job.py @@ -9,6 +9,7 @@ import odoo.tests.common as common from odoo import SUPERUSER_ID +from odoo.addons.queue_job import identity_exact from odoo.addons.queue_job.exception import ( FailedJobError, NoSuchJobError, @@ -22,7 +23,6 @@ RETRY_INTERVAL, STARTED, Job, - identity_exact, ) from .common import JobCommonCase From d93531597ce2d8a87571d1512a241b2faafc239c Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Sat, 28 Nov 2020 19:46:18 +0100 Subject: [PATCH 10/10] Improve error handling on invalid job function dicts --- queue_job/models/queue_job_function.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/queue_job/models/queue_job_function.py b/queue_job/models/queue_job_function.py index 045653d3e4..ef6c1b849d 100644 --- a/queue_job/models/queue_job_function.py +++ b/queue_job/models/queue_job_function.py @@ -104,8 +104,12 @@ def _compute_edit_retry_pattern(self): def _inverse_edit_retry_pattern(self): try: - self.retry_pattern = ast.literal_eval(self.edit_retry_pattern or "{}") - except (ValueError, TypeError): + 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") @@ -115,8 +119,12 @@ def _compute_edit_related_action(self): def _inverse_edit_related_action(self): try: - self.related_action = ast.literal_eval(self.edit_related_action or "{}") - except (ValueError, TypeError): + 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