Browse Source

base_cron_oneshot: improve, document, test

pull/1352/head
Simone Orsi 6 years ago
parent
commit
064a507bd9
  1. 21
      base_cron_oneshot/README.rst
  2. 13
      base_cron_oneshot/__manifest__.py
  3. 18
      base_cron_oneshot/data/ir_cron.xml
  4. 2
      base_cron_oneshot/data/ir_sequence.xml
  5. 97
      base_cron_oneshot/models/ir_cron.py
  6. 2
      base_cron_oneshot/readme/CONTRIBUTORS.rst
  7. 8
      base_cron_oneshot/readme/DESCRIPTION.rst
  8. 4
      base_cron_oneshot/readme/HISTORY.rst
  9. 20
      base_cron_oneshot/readme/USAGE.rst
  10. 1
      base_cron_oneshot/tests/__init__.py
  11. 94
      base_cron_oneshot/tests/test_cron.py
  12. 2
      base_cron_oneshot/views/ir_cron.xml

21
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.

13
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). # 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", 'category': "Extra Tools",
'version': "11.0.1.0.0", 'version': "11.0.1.0.0",
'author': "Camptocamp SA, "
'author': "Camptocamp, "
"Odoo Community Association (OCA)", "Odoo Community Association (OCA)",
'website': "https://github.com/OCA/server-tools", 'website': "https://github.com/OCA/server-tools",
'license': "AGPL-3", 'license': "AGPL-3",
'data': [ 'data': [
'views/ir_cron.xml',
'data/ir_sequence.xml', 'data/ir_sequence.xml',
'data/ir_cron.xml',
'views/ir_cron.xml',
], ],
} }

18
base_cron_oneshot/data/ir_cron.xml

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<record id="cron_oneshot_cleanup" model="ir.cron" forcecreate="True">
<field name="name">Oneshot cron cleanup</field>
<field name="user_id" ref="base.user_root"/>
<field name="model_id" ref="model_ir_cron"/>
<field name="state">code</field>
<field name="code">model.cron_oneshot_cleanup()</field>
<field name="interval_number">1</field>
<field name="interval_type">days</field>
<!-- make it run the day after installation at midnight -->
<field name="nextcall" eval="(DateTime.now() + timedelta(days=1)).strftime('%Y-%m-%d 00:00:00')" />
<field name="numbercall">-1</field>
<field name="doall" eval="False"/>
</record>
</data>
</odoo>

2
base_cron_oneshot/data/ir_sequence.xml

@ -3,6 +3,8 @@
<record id="seq_oneshot_name" model="ir.sequence"> <record id="seq_oneshot_name" model="ir.sequence">
<field name="name">Oneshot cron names sequence</field> <field name="name">Oneshot cron names sequence</field>
<field name="code">cron.oneshot</field>
<field name="prefix">Oneshot#</field>
</record> </record>
</odoo> </odoo>

97
base_cron_oneshot/models/ir_cron.py

@ -1,6 +1,10 @@
# Copyright (C) 2018 by Camptocamp # Copyright (C) 2018 by Camptocamp
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). # 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): class IrCron(models.Model):
@ -9,44 +13,73 @@ class IrCron(models.Model):
oneshot = fields.Boolean( oneshot = fields.Boolean(
string='Single use', string='Single use',
default=False, default=False,
help='Enable this to run the cron only once. '
'The cron will be deleted right after execution.'
) )
@api.model @api.model
def create(self, vals): def create(self, vals):
if vals.get('oneshot'): 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 @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: 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, 'model_id': model.id,
'code': code, '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()

2
base_cron_oneshot/readme/CONTRIBUTORS.rst

@ -0,0 +1,2 @@
* Simone Orsi <simone.orsi@camptocamp.com>
* Artem Kostyuk <a.kostyuk@mobilunity.com>

8
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.

4
base_cron_oneshot/readme/HISTORY.rst

@ -0,0 +1,4 @@
11.0.1.0.0 (2018-08-30)
~~~~~~~~~~~~~~~~~~~~~~~
* First release

20
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)

1
base_cron_oneshot/tests/__init__.py

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

94
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)

2
base_cron_oneshot/views/ir_cron.xml

@ -11,7 +11,7 @@
</field> </field>
<field name="numbercall" position="attributes"> <field name="numbercall" position="attributes">
<attribute name="attrs"> <attribute name="attrs">
{'invisible': [('oneshot', '=', True)]}
{'readonly': [('oneshot', '=', True)]}
</attribute> </attribute>
</field> </field>
</field> </field>

Loading…
Cancel
Save