diff --git a/base_cron_oneshot/README.rst b/base_cron_oneshot/README.rst new file mode 100644 index 000000000..21cd7854d --- /dev/null +++ b/base_cron_oneshot/README.rst @@ -0,0 +1,21 @@ +**This file is going to be generated by oca-gen-addon-readme.** + +*Manual changes will be overwritten.* + +Please provide content in the ``readme`` directory: + +* **DESCRIPTION.rst** (required) +* INSTALL.rst (optional) +* CONFIGURE.rst (optional) +* **USAGE.rst** (optional, highly recommended) +* DEVELOP.rst (optional) +* ROADMAP.rst (optional) +* HISTORY.rst (optional, recommended) +* **CONTRIBUTORS.rst** (optional, highly recommended) +* CREDITS.rst (optional) + +Content of this README will also be drawn from the addon manifest, +from keys such as name, authors, maintainers, development_status, +and license. + +A good, one sentence summary in the manifest is also highly recommended. diff --git a/base_cron_oneshot/__manifest__.py b/base_cron_oneshot/__manifest__.py index c5c0d2942..5b6a32534 100644 --- a/base_cron_oneshot/__manifest__.py +++ b/base_cron_oneshot/__manifest__.py @@ -1,18 +1,17 @@ -# Copyright (C) 2018 by Camptocamp +# Copyright (C) 2018 Camptocamp # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). { - 'name': """Single use crons""", - 'summary': """Allows creating of single-use disposable crons""", + 'name': """Oneshot cron""", + 'summary': """Allows creating of single-use disposable crons.""", 'category': "Extra Tools", 'version': "11.0.1.0.0", - - 'author': "Camptocamp SA, " + 'author': "Camptocamp, " "Odoo Community Association (OCA)", 'website': "https://github.com/OCA/server-tools", 'license': "AGPL-3", - 'data': [ - 'views/ir_cron.xml', 'data/ir_sequence.xml', + 'data/ir_cron.xml', + 'views/ir_cron.xml', ], } diff --git a/base_cron_oneshot/data/ir_cron.xml b/base_cron_oneshot/data/ir_cron.xml new file mode 100644 index 000000000..801874608 --- /dev/null +++ b/base_cron_oneshot/data/ir_cron.xml @@ -0,0 +1,18 @@ + + + + + Oneshot cron cleanup + + + code + model.cron_oneshot_cleanup() + 1 + days + + + -1 + + + + diff --git a/base_cron_oneshot/data/ir_sequence.xml b/base_cron_oneshot/data/ir_sequence.xml index aca378f8e..06fedb1ea 100644 --- a/base_cron_oneshot/data/ir_sequence.xml +++ b/base_cron_oneshot/data/ir_sequence.xml @@ -3,6 +3,8 @@ Oneshot cron names sequence + cron.oneshot + Oneshot# diff --git a/base_cron_oneshot/models/ir_cron.py b/base_cron_oneshot/models/ir_cron.py index 28707df39..5887169c1 100644 --- a/base_cron_oneshot/models/ir_cron.py +++ b/base_cron_oneshot/models/ir_cron.py @@ -1,6 +1,10 @@ # Copyright (C) 2018 by Camptocamp # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). -from odoo import api, fields, models +from odoo import api, fields, models, _ +from datetime import datetime, timedelta +import logging + +_logger = logging.getLogger(__name__) class IrCron(models.Model): @@ -9,44 +13,73 @@ class IrCron(models.Model): oneshot = fields.Boolean( string='Single use', default=False, + help='Enable this to run the cron only once. ' + 'The cron will be deleted right after execution.' ) @api.model def create(self, vals): if vals.get('oneshot'): - # quite silent - fail loudly if vals['numbercall'] is given? - vals['numbercall'] = 1 - return super(IrCron, self).create(vals) - - @classmethod - def _process_job(cls, job_cr, job, cron_cr): - res = super(IrCron, cls)._process_job(job_cr, job, cron_cr) - try: - with api.Environment.manage(): - cron = api.Environment(job_cr, job['user_id'], {})[cls._name] - if job.get('oneshot'): - # log this? - cron.browse(job.get('id')).unlink() - finally: - job_cr.commit() - cron_cr.commit() - return res + vals.update(self._oneshot_defaults(**vals)) + return super().create(vals) + + def _oneshot_defaults( + self, name=None, delay=('minutes', 10), nextcall=None, **kw): + if nextcall is None: + nextcall = fields.Datetime.to_string( + datetime.now() + timedelta(**dict([delay, ])) + ) + return { + 'state': 'code', + # TODO: shall we enforce `doall` too? + # enforce numbercall + 'numbercall': 1, + # make sure name is automatic + 'name': self._oneshot_make_name(name), + 'nextcall': nextcall, + } + + def _oneshot_make_name(self, name=None): + name = ' ' + (name if name else '') + return '{}{}'.format( + self.env['ir.sequence'].next_by_code('cron.oneshot'), name + ) @api.model - def schedule_oneshot( - self, model=False, method=False, params=False, code=False): - # XXX: still under construction. - if not model: - # generic case, use `base` cause we don't really care - model = self.env.ref('base.model_base').id - if not isinstance(model, str): - model = model.id + def schedule_oneshot(self, model_name, method=None, code=None, + delay=('minutes', 10), **kw): + """Create a one shot cron. + + :param model_name: a string matching an odoo model name + :param method: an existing method to call on the model + :param code: custom code to run on the model + :param delay: timedelta compat values for delay as tuple + :param kw: custom values for the cron + """ + assert method or code, _('Provide a method or some code!') if method and not code: - code = 'model.{method}'.format(**locals()) - oneshot_name_seq = self.env.ref('base_cron_oneshot.seq_oneshot_name') - self.create({ - 'name': 'Oneshot #{}'.format(oneshot_name_seq.next()), - # TODO: retrieve actual ID of a model + code = 'model.{}()'.format(method) + model = self.env['ir.model']._get(model_name) + vals = { 'model_id': model.id, 'code': code, - }) + } + vals.update(self._oneshot_defaults(delay=delay)) + vals.update(kw) + # make sure is a oneshot cron ;) + vals['oneshot'] = True + return self.create(vals) + + def _oneshot_cleanup_domain(self): + # TODO: any better way to select them? + return [ + ('oneshot', '=', True), + ('numbercall', '=', 0), # already executed + ('active', '=', False), # already executed and numbercall=0 + ] + + @api.model + def cron_oneshot_cleanup(self): + self.with_context( + active_test=False + ).search(self._oneshot_cleanup_domain()).unlink() diff --git a/base_cron_oneshot/readme/CONTRIBUTORS.rst b/base_cron_oneshot/readme/CONTRIBUTORS.rst new file mode 100644 index 000000000..a57337770 --- /dev/null +++ b/base_cron_oneshot/readme/CONTRIBUTORS.rst @@ -0,0 +1,2 @@ +* Simone Orsi +* Artem Kostyuk diff --git a/base_cron_oneshot/readme/DESCRIPTION.rst b/base_cron_oneshot/readme/DESCRIPTION.rst new file mode 100644 index 000000000..1d1725970 --- /dev/null +++ b/base_cron_oneshot/readme/DESCRIPTION.rst @@ -0,0 +1,8 @@ +This module extends the functionality of Odoo crons +to allow you to create single-purpose crons without any further setup or modules +such as `queue_job`. + +The typical use case is: you have an expensive task to run on demand and only once. + +A main cron called "Oneshot cron cleanup" will delete already executed crons each day. +You might want to tune it according to your needs. diff --git a/base_cron_oneshot/readme/HISTORY.rst b/base_cron_oneshot/readme/HISTORY.rst new file mode 100644 index 000000000..5e43cac6a --- /dev/null +++ b/base_cron_oneshot/readme/HISTORY.rst @@ -0,0 +1,4 @@ +11.0.1.0.0 (2018-08-30) +~~~~~~~~~~~~~~~~~~~~~~~ + +* First release diff --git a/base_cron_oneshot/readme/USAGE.rst b/base_cron_oneshot/readme/USAGE.rst new file mode 100644 index 000000000..916600f9d --- /dev/null +++ b/base_cron_oneshot/readme/USAGE.rst @@ -0,0 +1,20 @@ +You can create crons as usual via the admin interface or via code. +The important thing, in both case, is to set `oneshot` flag as true. + +Developer shortcut +------------------ + +You can easily create a oneshot cron like this: + +.. code-block:: python + + cron = self.env['ir.cron'].schedule_oneshot( + 'res.partner', method='my_cron_method') + +If you need to customize other parameters you can pass them as keyword args: + +.. code-block:: python + + my_values = {...} + cron = self.env['ir.cron'].schedule_oneshot( + 'res.partner', method='my_cron_method', **my_values) diff --git a/base_cron_oneshot/tests/__init__.py b/base_cron_oneshot/tests/__init__.py new file mode 100644 index 000000000..006a6d224 --- /dev/null +++ b/base_cron_oneshot/tests/__init__.py @@ -0,0 +1 @@ +from . import test_cron diff --git a/base_cron_oneshot/tests/test_cron.py b/base_cron_oneshot/tests/test_cron.py new file mode 100644 index 000000000..6dc108d9d --- /dev/null +++ b/base_cron_oneshot/tests/test_cron.py @@ -0,0 +1,94 @@ +# Copyright (C) 2018 Camptocamp +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +# pylint disable=anomalous-backslash-in-string + +from odoo import api +from odoo.tests import common + +import datetime +import mock + + +MOCK_PATH = 'odoo.addons.base_cron_oneshot.models.ir_cron' + + +class OneshotTestCase(common.SavepointCase): + + @property + def cron_model(self): + return self.env['ir.cron'] + + @mock.patch(MOCK_PATH + '.datetime') + def test_defaults(self, mocked_dt): + mocked_dt.now.return_value = datetime.datetime(2018, 8, 31, 10, 30) + cron = self.cron_model.create({ + 'oneshot': True, + 'name': 'Foo', + 'model_id': self.env['ir.model']._get('ir.cron').id, + 'state': 'code', + 'code': 'model.some_method()', + 'interval_number': 1, + 'interval_type': 'days', + 'numbercall': 5, # won't have any effect + }) + self.assertRegexpMatches(cron.name, 'Oneshot#\d+ Foo') + self.assertEqual(cron.numbercall, 1) + # call postponed by 10mins + self.assertEqual(cron.nextcall, '2018-08-31 10:40:00') + + def test_schedule_oneshot_check(self): + with self.assertRaises(AssertionError) as err: + self.cron_model.schedule_oneshot('res.partner') + self.assertEqual(str(err.exception), 'Provide a method or some code!') + + @mock.patch(MOCK_PATH + '.datetime') + def test_schedule_oneshot_method(self, mocked_dt): + mocked_dt.now.return_value = datetime.datetime(2018, 8, 31, 16, 30) + cron = self.cron_model.schedule_oneshot( + 'res.partner', method='read', delay=('minutes', 30)) + self.assertRegexpMatches(cron.name, 'Oneshot#\d+') + self.assertEqual(cron.numbercall, 1) + self.assertEqual(cron.code, 'model.read()') + self.assertEqual( + cron.model_id, self.env['ir.model']._get('res.partner')) + self.assertEqual(cron.nextcall, '2018-08-31 17:00:00') + + def test_schedule_oneshot_code(self): + cron = self.cron_model.schedule_oneshot( + 'res.partner', code='env["res.partner"].search([])') + self.assertRegexpMatches(cron.name, 'Oneshot#\d+') + self.assertEqual(cron.numbercall, 1) + self.assertEqual(cron.state, 'code') + self.assertEqual(cron.code, 'env["res.partner"].search([])') + self.assertEqual( + cron.model_id, self.env['ir.model']._get('res.partner')) + + +class OneshotProcessTestCase(common.TransactionCase): + + def setUp(self): + super().setUp() + deleted = [] + + @api.multi + def unlink(self): + deleted.extend(self.ids) + # do nothing as the original one will try to read the lock + # for the current record which is NOT committed + # and has no real ID. + return + + self.env['ir.cron']._patch_method('unlink', unlink) + self.addCleanup(self.env['ir.cron']._revert_method, 'unlink') + self.deleted = deleted + + def test_schedule_oneshot_cleanup(self): + cron1 = self.env['ir.cron'].schedule_oneshot( + 'res.partner', code='env["res.partner"].search([])') + cron2 = self.env['ir.cron'].schedule_oneshot( + 'res.partner', code='env["res.partner"].read([])') + # simulate excuted + cron1.write({'numbercall': 0, 'active': False}) + self.env['ir.cron'].cron_oneshot_cleanup() + self.assertIn(cron1.id, self.deleted) + self.assertNotIn(cron2.id, self.deleted) diff --git a/base_cron_oneshot/views/ir_cron.xml b/base_cron_oneshot/views/ir_cron.xml index 9ff8fd46d..7878fad57 100644 --- a/base_cron_oneshot/views/ir_cron.xml +++ b/base_cron_oneshot/views/ir_cron.xml @@ -11,7 +11,7 @@ - {'invisible': [('oneshot', '=', True)]} + {'readonly': [('oneshot', '=', True)]}