Browse Source

[ADD] module_auto_update: Create module (#882)

* [IMP] module_auto_update: Create new module
* Add checksum_dir and checksum_installed fields to ir.module.module
  * Add checksum_dir to compute current checksum of module directory
    in addons path
  * Add checksum_installed to store checksum of module directory
    when module was last installed or upgraded
* Use checksumdir Python library to compute module directory sha1
  hashes, ignoring pyc and pyo extensions
* Extend update_list method to compare modules' checksum_dir and
  checksum_installed, then change state of modules with differing
  checksums to 'to upgrade'
* Replace Apps/Updates menu item with menu item of same name, which
  updates apps list and displays tree view of ir.module.module
  records with state 'to upgrade'
* Extend create and write methods to store computed checksum_dir as
  checksum_installed during module installation and upgrade, and
  set checksum_installed to False on uninstall
* Use context to stop checksum_installed from being updated during
  upgrade/uninstall cancellation
* Add cron job to periodically check for module upgrades by
  comparing checksums, then perform any available upgrades
* Extend upgrade_module method (called by cron and 'Apply Scheduled
  Upgrades' menu item) to call update_list
* Add post_init_hook to store checksum_installed of existing
  modules
* Add test coverage

* [FIX] module_auto_update: Fix test broken by changes
* Use dummy module to test update_list method instead of
  module_auto_update
pull/1382/head
Brenton Hughes 7 years ago
committed by Stéphane Bidoul (ACSONE)
parent
commit
b2733bdaf4
No known key found for this signature in database GPG Key ID: BCAB2555446B5B92
  1. 76
      module_auto_update/README.rst
  2. 7
      module_auto_update/__init__.py
  3. 30
      module_auto_update/__manifest__.py
  4. 15
      module_auto_update/data/cron_data.xml
  5. 14
      module_auto_update/hooks.py
  6. 5
      module_auto_update/models/__init__.py
  7. 83
      module_auto_update/models/module.py
  8. 6
      module_auto_update/tests/__init__.py
  9. 212
      module_auto_update/tests/test_module.py
  10. 42
      module_auto_update/tests/test_module_upgrade.py
  11. 49
      module_auto_update/views/module_views.xml
  12. 5
      module_auto_update/wizards/__init__.py
  13. 21
      module_auto_update/wizards/module_upgrade.py

76
module_auto_update/README.rst

@ -0,0 +1,76 @@
.. image:: https://img.shields.io/badge/licence-LGPL--3-blue.svg
:target: http://www.gnu.org/licenses/lgpl-3.0-standalone.html
:alt: License: LGPL-3
==================
Module Auto Update
==================
This module will automatically check for and apply module upgrades on a schedule.
Upgrade checking is accomplished by comparing the SHA1 checksums of currently-installed modules to the checksums of corresponding modules in the addons directories.
Installation
============
Prior to installing this module, you need to:
#. Install checksumdir with `pip install checksumdir`
#. Ensure all installed modules are up-to-date. When installed, this module will assume the versions found in the addons directories are currently installed.
Configuration
=============
The default time for checking and applying upgrades is 3:00 AM (UTC). To change this schedule, modify the "Perform Module Upgrades" scheduled action.
This module will ignore .pyc and .pyo file extensions by default. To modify this, create a module_auto_update.checksum_excluded_extensions system parameter with the desired extensions listed as comma-separated values.
Usage
=====
Modules scheduled for upgrade can be viewed by clicking the "Updates" menu item in the Apps sidebar.
To perform upgrades manually, click the "Apply Scheduled Upgrades" menu item in the Apps sidebar.
.. image:: https://odoo-community.org/website/image/ir.attachment/5784_f2813bd/datas
:alt: Try me on Runbot
:target: https://runbot.odoo-community.org/runbot/149/10.0
Bug Tracker
===========
Bugs are tracked on `GitHub Issues
<https://github.com/OCA/server-tools/issues>`_. In case of trouble, please
check there if your issue has already been reported. If you spotted it first,
help us smash it by providing detailed and welcomed feedback.
Credits
=======
Images
------
* Odoo Community Association: `Icon <https://github.com/OCA/maintainer-tools/blob/master/template/module/static/description/icon.svg>`_.
Contributors
------------
* Brent Hughes <brent.hughes@laslabs.com>
* Juan José Scarafía <jjs@adhoc.com.ar>
Do not contact contributors directly about support or help with technical issues.
Maintainer
----------
.. image:: https://odoo-community.org/logo.png
:alt: Odoo Community Association
:target: https://odoo-community.org
This module is maintained by the OCA.
OCA, or the Odoo Community Association, is a nonprofit organization whose
mission is to support the collaborative development of Odoo features and
promote its widespread use.
To contribute to this module, please visit https://odoo-community.org.

7
module_auto_update/__init__.py

@ -0,0 +1,7 @@
# -*- coding: utf-8 -*-
# Copyright 2017 LasLabs Inc.
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl).
from . import models
from . import wizards
from .hooks import post_init_hook

30
module_auto_update/__manifest__.py

@ -0,0 +1,30 @@
# -*- coding: utf-8 -*-
# Copyright 2017 LasLabs Inc.
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl).
{
'name': 'Module Auto Update',
'summary': 'Automatically update Odoo modules',
'version': '10.0.1.0.0',
'category': 'Extra Tools',
'website': 'https://odoo-community.org/',
'author': 'LasLabs, '
'Juan José Scarafía, '
'Odoo Community Association (OCA)',
'license': 'LGPL-3',
'application': False,
'installable': True,
'post_init_hook': 'post_init_hook',
'external_dependencies': {
'python': [
'checksumdir',
],
},
'depends': [
'base',
],
'data': [
'views/module_views.xml',
'data/cron_data.xml',
],
}

15
module_auto_update/data/cron_data.xml

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo noupdate="1">
<record model="ir.cron" id="module_check_upgrades_cron">
<field name="name">Perform Module Upgrades</field>
<field name="active" eval="True"/>
<field name="user_id" ref="base.user_root"/>
<field name="interval_number">1</field>
<field name="interval_type">days</field>
<field name="numbercall">-1</field>
<field name="nextcall" eval="(DateTime.now() + timedelta(days= +1)).strftime('%Y-%m-%d 3:00:00')"/>
<field name="model">base.module.upgrade</field>
<field name="function">upgrade_module</field>
<field name="args" eval="'()'"/>
</record>
</odoo>

14
module_auto_update/hooks.py

@ -0,0 +1,14 @@
# -*- coding: utf-8 -*-
# Copyright 2017 LasLabs Inc.
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl).
from odoo import SUPERUSER_ID, api
def post_init_hook(cr, registry):
env = api.Environment(cr, SUPERUSER_ID, {})
installed_modules = env['ir.module.module'].search([
('state', '=', 'installed'),
])
for r in installed_modules:
r.checksum_installed = r.checksum_dir

5
module_auto_update/models/__init__.py

@ -0,0 +1,5 @@
# -*- coding: utf-8 -*-
# Copyright 2017 LasLabs Inc.
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl).
from . import module

83
module_auto_update/models/module.py

@ -0,0 +1,83 @@
# -*- coding: utf-8 -*-
# Copyright 2017 LasLabs Inc.
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl).
import logging
from odoo import api, fields, models
from odoo.modules.module import get_module_path
_logger = logging.getLogger(__name__)
try:
from checksumdir import dirhash
except ImportError:
_logger.debug('Cannot `import checksumdir`.')
class Module(models.Model):
_inherit = 'ir.module.module'
checksum_dir = fields.Char(
compute='_compute_checksum_dir',
)
checksum_installed = fields.Char()
@api.depends('name')
def _compute_checksum_dir(self):
exclude = self.env["ir.config_parameter"].get_param(
"module_auto_update.checksum_excluded_extensions",
"pyc,pyo",
).split(",")
for r in self:
r.checksum_dir = dirhash(
get_module_path(r.name),
'sha1',
excluded_extensions=exclude,
)
def _store_checksum_installed(self, vals):
if self.env.context.get('retain_checksum_installed'):
return
if 'checksum_installed' not in vals:
if vals.get('state') == 'installed':
for r in self:
r.checksum_installed = r.checksum_dir
elif vals.get('state') == 'uninstalled':
self.write({'checksum_installed': False})
@api.multi
def button_uninstall_cancel(self):
return super(
Module,
self.with_context(retain_checksum_installed=True),
).button_uninstall_cancel()
@api.multi
def button_upgrade_cancel(self):
return super(
Module,
self.with_context(retain_checksum_installed=True),
).button_upgrade_cancel()
@api.model
def create(self, vals):
res = super(Module, self).create(vals)
res._store_checksum_installed(vals)
return res
@api.model
def update_list(self):
res = super(Module, self).update_list()
installed_modules = self.search([('state', '=', 'installed')])
upgradeable_modules = installed_modules.filtered(
lambda r: r.checksum_dir != r.checksum_installed,
)
upgradeable_modules.write({'state': "to upgrade"})
return res
@api.multi
def write(self, vals):
res = super(Module, self).write(vals)
self._store_checksum_installed(vals)
return res

6
module_auto_update/tests/__init__.py

@ -0,0 +1,6 @@
# -*- coding: utf-8 -*-
# Copyright 2017 LasLabs Inc.
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl).
from . import test_module
from . import test_module_upgrade

212
module_auto_update/tests/test_module.py

@ -0,0 +1,212 @@
# -*- coding: utf-8 -*-
# Copyright 2017 LasLabs Inc.
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl).
import logging
import tempfile
import mock
from odoo.modules import get_module_path
from odoo.tests.common import TransactionCase
from .. import post_init_hook
_logger = logging.getLogger(__name__)
try:
from checksumdir import dirhash
except ImportError:
_logger.debug('Cannot `import checksumdir`.')
model = 'odoo.addons.module_auto_update.models.module'
class TestModule(TransactionCase):
def setUp(self):
super(TestModule, self).setUp()
module_name = 'module_auto_update'
self.own_module = self.env['ir.module.module'].search([
('name', '=', module_name),
])
self.own_dir_path = get_module_path(module_name)
self.own_checksum = dirhash(
self.own_dir_path,
'sha1',
excluded_extensions=['pyc', 'pyo'],
)
@mock.patch('%s.get_module_path' % model)
def create_test_module(self, vals, get_module_path_mock):
get_module_path_mock.return_value = self.own_dir_path
test_module = self.env['ir.module.module'].create(vals)
return test_module
def test_compute_checksum_dir(self):
"""It should compute the directory's SHA-1 hash"""
self.assertEqual(
self.own_module.checksum_dir, self.own_checksum,
'Module directory checksum not computed properly',
)
def test_compute_checksum_dir_ignore_excluded(self):
"""It should exclude .pyc/.pyo extensions from checksum
calculations"""
with tempfile.NamedTemporaryFile(
suffix='.pyc', dir=self.own_dir_path):
self.assertEqual(
self.own_module.checksum_dir, self.own_checksum,
'SHA1 checksum does not ignore excluded extensions',
)
def test_compute_checksum_dir_recomputes_when_file_added(self):
"""It should return a different value when a non-.pyc/.pyo file is
added to the module directory"""
with tempfile.NamedTemporaryFile(
suffix='.py', dir=self.own_dir_path):
self.assertNotEqual(
self.own_module.checksum_dir, self.own_checksum,
'SHA1 checksum not recomputed',
)
def test_store_checksum_installed_state_installed(self):
"""It should set the module's checksum_installed equal to
checksum_dir when vals contain state 'installed'"""
self.own_module.checksum_installed = 'test'
self.own_module._store_checksum_installed({'state': 'installed'})
self.assertEqual(
self.own_module.checksum_installed, self.own_module.checksum_dir,
'Setting state to installed does not store checksum_dir '
'as checksum_installed',
)
def test_store_checksum_installed_state_uninstalled(self):
"""It should clear the module's checksum_installed when vals
contain state 'uninstalled'"""
self.own_module.checksum_installed = 'test'
self.own_module._store_checksum_installed({'state': 'uninstalled'})
self.assertEqual(
self.own_module.checksum_installed, False,
'Setting state to uninstalled does not clear checksum_installed',
)
def test_store_checksum_installed_vals_contain_checksum_installed(self):
"""It should not set checksum_installed to False or checksum_dir when
a checksum_installed is included in vals"""
self.own_module.checksum_installed = 'test'
self.own_module._store_checksum_installed({
'state': 'installed',
'checksum_installed': 'test',
})
self.assertEqual(
self.own_module.checksum_installed, 'test',
'Providing checksum_installed in vals did not prevent overwrite',
)
def test_store_checksum_installed_with_retain_context(self):
"""It should not set checksum_installed to False or checksum_dir when
self has context retain_checksum_installed=True"""
self.own_module.checksum_installed = 'test'
self.own_module.with_context(
retain_checksum_installed=True,
)._store_checksum_installed({'state': 'installed'})
self.assertEqual(
self.own_module.checksum_installed, 'test',
'Providing retain_checksum_installed context did not prevent '
'overwrite',
)
def test_button_uninstall_cancel(self):
"""It should preserve checksum_installed when cancelling uninstall"""
self.own_module.write({'state': 'to remove'})
self.own_module.checksum_installed = 'test'
self.own_module.button_uninstall_cancel()
self.assertEqual(
self.own_module.checksum_installed, 'test',
'Uninstall cancellation does not preserve checksum_installed',
)
def test_button_upgrade_cancel(self):
"""It should preserve checksum_installed when cancelling upgrades"""
self.own_module.write({'state': 'to upgrade'})
self.own_module.checksum_installed = 'test'
self.own_module.button_upgrade_cancel()
self.assertEqual(
self.own_module.checksum_installed, 'test',
'Upgrade cancellation does not preserve checksum_installed',
)
def test_create(self):
"""It should call _store_checksum_installed method"""
_store_checksum_installed_mock = mock.MagicMock()
self.env['ir.module.module']._patch_method(
'_store_checksum_installed',
_store_checksum_installed_mock,
)
vals = {
'name': 'module_auto_update_test_module',
'state': 'installed',
}
self.create_test_module(vals)
_store_checksum_installed_mock.assert_called_once_with(vals)
self.env['ir.module.module']._revert_method(
'_store_checksum_installed',
)
@mock.patch('%s.get_module_path' % model)
def test_update_list(self, get_module_path_mock):
"""It should change the state of modules with different
checksum_dir and checksum_installed to 'to upgrade'"""
get_module_path_mock.return_value = self.own_dir_path
vals = {
'name': 'module_auto_update_test_module',
'state': 'installed',
}
test_module = self.create_test_module(vals)
test_module.checksum_installed = 'test'
self.env['ir.module.module'].update_list()
self.assertEqual(
test_module.state, 'to upgrade',
'List update does not mark upgradeable modules "to upgrade"',
)
def test_update_list_only_changes_installed(self):
"""It should not change the state of a module with a former state
other than 'installed' to 'to upgrade'"""
vals = {
'name': 'module_auto_update_test_module',
'state': 'uninstalled',
}
test_module = self.create_test_module(vals)
self.env['ir.module.module'].update_list()
self.assertNotEqual(
test_module.state, 'to upgrade',
'List update changed state of an uninstalled module',
)
def test_write(self):
"""It should call _store_checksum_installed method"""
_store_checksum_installed_mock = mock.MagicMock()
self.env['ir.module.module']._patch_method(
'_store_checksum_installed',
_store_checksum_installed_mock,
)
vals = {'state': 'installed'}
self.own_module.write(vals)
_store_checksum_installed_mock.assert_called_once_with(vals)
self.env['ir.module.module']._revert_method(
'_store_checksum_installed',
)
def test_post_init_hook(self):
"""It should set checksum_installed equal to checksum_dir for all
installed modules"""
installed_modules = self.env['ir.module.module'].search([
('state', '=', 'installed'),
])
post_init_hook(self.env.cr, None)
self.assertListEqual(
installed_modules.mapped('checksum_dir'),
installed_modules.mapped('checksum_installed'),
'Installed modules did not have checksum_installed stored',
)

42
module_auto_update/tests/test_module_upgrade.py

@ -0,0 +1,42 @@
# -*- coding: utf-8 -*-
# Copyright 2017 LasLabs Inc.
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl).
import mock
from odoo.modules import get_module_path
from odoo.modules.registry import Registry
from odoo.tests.common import TransactionCase
class TestModuleUpgrade(TransactionCase):
def setUp(self):
super(TestModuleUpgrade, self).setUp()
module_name = 'module_auto_update'
self.own_module = self.env['ir.module.module'].search([
('name', '=', module_name),
])
self.own_dir_path = get_module_path(module_name)
def test_upgrade_module_cancel(self):
"""It should preserve checksum_installed when cancelling upgrades"""
self.own_module.write({'state': 'to upgrade'})
self.own_module.checksum_installed = 'test'
self.env['base.module.upgrade'].upgrade_module_cancel()
self.assertEqual(
self.own_module.checksum_installed, 'test',
'Upgrade cancellation does not preserve checksum_installed',
)
@mock.patch.object(Registry, 'new')
def test_upgrade_module(self, new_mock):
"""It should call update_list method on ir.module.module"""
update_list_mock = mock.MagicMock()
self.env['ir.module.module']._patch_method(
'update_list',
update_list_mock,
)
self.env['base.module.upgrade'].upgrade_module()
update_list_mock.assert_called_once_with()
self.env['ir.module.module']._revert_method('update_list')

49
module_auto_update/views/module_views.xml

@ -0,0 +1,49 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Module Search View -->
<record id="module_view_search" model="ir.ui.view">
<field name="name">updates.module.search</field>
<field name="model">ir.module.module</field>
<field name="inherit_id" ref="base.view_module_filter"/>
<field name="arch" type="xml">
<field name="category_id" position="after">
<filter name="scheduled_upgrades" string="Scheduled Upgrades" domain="[('state', '=', 'to upgrade')]"/>
</field>
</field>
</record>
<!--Open Updates Action (updates apps list first)-->
<record id="module_action_open_updates" model="ir.actions.server">
<field name="name">Open Updates and Update Apps List Server Action</field>
<field name="model_id" ref="model_ir_module_module"/>
<field name="code">
if model.update_list():
action = {
'name': 'Updates',
'type': 'ir.actions.act_window',
'res_model': 'ir.module.module',
'view_type': 'form',
'view_mode': 'tree,form',
'target': 'main',
'context': '{"search_default_scheduled_upgrades": 1}',
}
</field>
</record>
<!--Apps / Updates menu item-->
<menuitem
name="Updates"
action="module_action_open_updates"
id="module_menu_updates"
groups="base.group_no_one"
parent="base.menu_management"
sequence="20"/>
<!-- Menu in Settings > Technical for standard Updates link -->
<menuitem parent="base.menu_custom" sequence="27" name="Modules" id="menu_default_modules"/>
<!-- Moved standard Updates link -->
<record model="ir.ui.menu" id="base.menu_module_updates">
<field name="parent_id" ref="menu_default_modules"/>
</record>
</odoo>

5
module_auto_update/wizards/__init__.py

@ -0,0 +1,5 @@
# -*- coding: utf-8 -*-
# Copyright 2017 LasLabs Inc.
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl).
from . import module_upgrade

21
module_auto_update/wizards/module_upgrade.py

@ -0,0 +1,21 @@
# -*- coding: utf-8 -*-
# Copyright 2017 LasLabs Inc.
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl).
from odoo import api, models
class ModuleUpgrade(models.TransientModel):
_inherit = 'base.module.upgrade'
@api.multi
def upgrade_module_cancel(self):
return super(
ModuleUpgrade,
self.with_context(retain_checksum_installed=True),
).upgrade_module_cancel()
@api.multi
def upgrade_module(self):
self.env['ir.module.module'].update_list()
super(ModuleUpgrade, self).upgrade_module()
Loading…
Cancel
Save