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)]}