diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0d20b64 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +*.pyc diff --git a/galicea_environment_checkup/README.rst b/galicea_environment_checkup/README.rst new file mode 100644 index 0000000..b82dd29 --- /dev/null +++ b/galicea_environment_checkup/README.rst @@ -0,0 +1,88 @@ +About +===== + +This add-on allows you to: + +- programmatically check software dependencies required by your add-on, as well as inform the Administrator as to how to meet them, +- add custom verification for Odoo instance set-up and inform the Administrator about any inconsistencies. + +Dependency checks +================= +.. image:: /galicea_environment_checkup/static/description/dependencies.png + +How-to +------ +Just add ``environment_checkup`` entry to ``__manifest__.py`` + +.. code:: + + { + ... + 'environment_checkup': { + 'dependencies': { + 'python': [ + { + 'name': 'Crypto', + 'version': '>=2.6.2', + 'install': "pip install 'PyCrypto>=2.6.1'" + }, + ], + 'external': [ + { + 'name': 'wkhtmltopdf', + 'install': "apt install wkhtmltopdf" + }, + { + 'name': 'git', + 'version': '^3.0.0', + 'install': "apt install git" + } + ], + 'internal': [ + { + 'name': 'web', + 'version': '~10.0.1.0' + } + ] + } + } + } + +Custom in-code checks +===================== +.. image:: /galicea_environment_checkup/static/description/custom.png + +How-to +------ + +1. Add the check + +``system_checks.py`` + +.. code:: + + # -*- coding: utf-8 -*- + + import cgi + from odoo.addons.galicea_environment_checkup import custom_check, CheckSuccess, CheckWarning, CheckFail + + @custom_check + def check_mail(env): + users_without_emails = env['res.users'].sudo().search([('email', '=', False)]) + + if users_without_emails: + raise CheckWarning( + 'Some users don\'t have their e-mails set up.', + details='See user {}.'.format(cgi.escape(users_without_emails[0].name)) + ) + + return CheckSuccess('All users have their e-mails set.') + +2. Make sure it's loaded + +``__init__.py`` + +.. code:: + + # -*- coding: utf-8 -*- + from . import system_checks diff --git a/galicea_environment_checkup/__init__.py b/galicea_environment_checkup/__init__.py new file mode 100644 index 0000000..3bb7ff0 --- /dev/null +++ b/galicea_environment_checkup/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- + +from . import models +from . import controllers + +from environment_checkup.custom import custom_check +from environment_checkup.core import CheckFail, CheckWarning, CheckSuccess diff --git a/galicea_environment_checkup/__manifest__.py b/galicea_environment_checkup/__manifest__.py new file mode 100644 index 0000000..b8948db --- /dev/null +++ b/galicea_environment_checkup/__manifest__.py @@ -0,0 +1,27 @@ +# -*- coding: utf-8 -*- +{ + 'name': "Galicea Enviromnent Check-up", + + 'summary': """ + Programmatically validate environment, including internal and external + dependencies""", + + 'author': "Maciej Wawro", + 'maintainer': "Galicea", + 'website': "http://galicea.pl", + + 'category': 'Technical Settings', + 'version': '10.0.1.0', + + 'depends': ['web'], + + 'data': [ + 'views/data.xml', + 'views/views.xml', + 'views/environment_checks.xml' + ], + + 'qweb': ['static/src/xml/templates.xml'], + + 'installable': True +} diff --git a/galicea_environment_checkup/controllers/__init__.py b/galicea_environment_checkup/controllers/__init__.py new file mode 100644 index 0000000..72304cb --- /dev/null +++ b/galicea_environment_checkup/controllers/__init__.py @@ -0,0 +1,3 @@ +# -*- coding: utf-8 -*- + +from . import dashboard diff --git a/galicea_environment_checkup/controllers/dashboard.py b/galicea_environment_checkup/controllers/dashboard.py new file mode 100644 index 0000000..cb239d0 --- /dev/null +++ b/galicea_environment_checkup/controllers/dashboard.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- + +from odoo import http +from odoo.exceptions import AccessError +from odoo.http import request + +from ..environment_checkup.runtime import all_installed_checks, display_data +from ..environment_checkup.core import CheckResult + +class Dashboard(http.Controller): + @http.route('/galicea_environment_checkup/data', type='json', auth='user') + def data(self, request, **kw): + if not request.env.user.has_group('base.group_erp_manager'): + raise AccessError("Access Denied") + + checks = all_installed_checks(request.env) + response = display_data(request.env, checks) + + priority = { + CheckResult.FAIL: 0, + CheckResult.WARNING: 1, + CheckResult.SUCCESS: 2 + } + response.sort(key=lambda res: (priority[res['result']], res['module'])) + + return response diff --git a/galicea_environment_checkup/environment_checkup/__init__.py b/galicea_environment_checkup/environment_checkup/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/galicea_environment_checkup/environment_checkup/core.py b/galicea_environment_checkup/environment_checkup/core.py new file mode 100644 index 0000000..d31ae7b --- /dev/null +++ b/galicea_environment_checkup/environment_checkup/core.py @@ -0,0 +1,49 @@ +# -*- coding: utf-8 -*- + +import logging +_logger = logging.getLogger(__name__) + +class CheckResult(object): + SUCCESS = 'success' + WARNING = 'warning' + FAIL = 'fail' + + def __init__(self, result, message, details = None): + super(CheckResult, self).__init__() + + self.result = result + self.message = message + self.details = details + +class CheckSuccess(CheckResult): + def __init__(self, message, **kwargs): + super(CheckSuccess, self).__init__(CheckResult.SUCCESS, message, **kwargs) + +class CheckIssue(CheckResult, Exception): + def __init__(self, result, message, **kwargs): + Exception.__init__(self, message) + CheckResult.__init__(self, result, message, **kwargs) + +class CheckFail(CheckIssue): + def __init__(self, message, **kwargs): + super(CheckFail, self).__init__(CheckResult.FAIL, message, **kwargs) + +class CheckWarning(CheckIssue): + def __init__(self, message, **kwargs): + super(CheckWarning, self).__init__(CheckResult.WARNING, message, **kwargs) + +class Check(object): + def __init__(self, module): + self.module = module + + def run(self, env): + try: + return self._run(env) + except CheckIssue as issue: + return issue + except Exception as ex: + _logger.exception(ex) + return CheckFail('Check failed when processing: {}'.format(ex)) + + def _run(self, env): + raise NotImplementedError('Should be overriden by the subclass') diff --git a/galicea_environment_checkup/environment_checkup/custom.py b/galicea_environment_checkup/environment_checkup/custom.py new file mode 100644 index 0000000..ec0b8f9 --- /dev/null +++ b/galicea_environment_checkup/environment_checkup/custom.py @@ -0,0 +1,30 @@ +# -*- coding: utf-8 -*- + +import collections + +from core import Check + +custom_checks_per_module = collections.defaultdict(list) + +class CustomCheck(Check): + def __init__(self, module, func): + super(CustomCheck, self).__init__(module) + self.func = func + + def _run(self, env): + return self.func(env) + +def custom_check(func): + try: + module = func.__module__.split('.')[2] + except IndexError: + module = '' + + custom_checks_per_module[module].append( + CustomCheck(module=module, func=func) + ) + + return func + +def get_checks_for_module(module_name): + return custom_checks_per_module[module_name] diff --git a/galicea_environment_checkup/environment_checkup/dependencies.py b/galicea_environment_checkup/environment_checkup/dependencies.py new file mode 100644 index 0000000..13aeeeb --- /dev/null +++ b/galicea_environment_checkup/environment_checkup/dependencies.py @@ -0,0 +1,181 @@ +# -*- coding: utf-8 -*- + +import subprocess +import re +import cgi +from odoo.modules.module import load_information_from_description_file +from odoo.tools import which + +from core import Check, CheckSuccess, CheckWarning, CheckFail + +class DependencyCheck(Check): + dependency_type = None + + def __init__(self, module, dependency): + super(DependencyCheck, self).__init__(module) + self.dependency = dependency + + def _dependency_installed(self, env, name): + raise NotImplementedError('Should be overriden by the subclass') + + def _installed_version(self, env, name): + raise NotImplementedError('Should be overriden by the subclass') + + def _details(self): + if 'install' in self.dependency: + return 'Install command:
{}
'.format(self.dependency['install']) + return None + + def __has_required_version(self, installed_version, version_expression): + version_operator = '=' + version = self.dependency['version'] + if version[:1] in ['=', '~', '^']: + version_operator = version[:1] + version = version[1:] + elif version[:2] in ['>=']: + version_operator = version[:2] + version = version[2:] + + try: + parsed_version = map(int, version.split('.')) + except ValueError: + raise CheckFail( + 'Invalid version expression', + details = """ +Allowed expressions are
=x.y.z
,
>=x.y.z
,
^x.z.y
, +
~x.y.z. Got 
{}
""".format(cgi.escape(self.dependency['version'])) + ) + parsed_installed_version = map(int, installed_version.split('.')) + + parsed_version.extend(0 for _ in range(len(parsed_installed_version) - len(parsed_version))) + parsed_installed_version.extend(0 for _ in range(len(parsed_version) - len(parsed_installed_version))) + + if version_operator == '^': + if parsed_installed_version[:1] != parsed_version[:1]: + return False + version_operator = '>=' + elif version_operator == '~': + if parsed_installed_version[:2] != parsed_version[:2]: + return False + version_operator = '>=' + + if version_operator == '>=': + return tuple(parsed_installed_version) >= tuple(parsed_version) + elif version_operator == '=': + return tuple(parsed_installed_version) == tuple(parsed_version) + + assert False + + def _run(self, env): + name = self.dependency['name'] + if not self._dependency_installed(env, name): + raise CheckFail( + 'Required {} - {} - is not installed.'.format(self.dependency_type, name), + details=self._details() + ) + if 'version' in self.dependency: + version_expression = self.dependency['version'] + installed_version = self._installed_version(env, name) + if not self.__has_required_version(installed_version, version_expression): + raise CheckWarning( + 'Required {} - {} - has version {}, but {} is needed.'.format( + self.dependency_type, + name, + installed_version, + version_expression + ), + details=self._details() + ) + return CheckSuccess( + 'Required {} - {} - is installed.'.format(self.dependency_type, name), + details=self._details() + ) + +class InternalDependencyCheck(DependencyCheck): + dependency_type = 'Odoo module' + + def _dependency_installed(self, env, name): + return name in env.registry._init_modules + + def _installed_version(self, env, name): + return env['ir.module.module'].sudo().search([('name', '=', name)]).latest_version + +class PythonDependencyCheck(DependencyCheck): + dependency_type = 'Python module' + + def _dependency_installed(self, env, name): + try: + __import__(name) + return True + except ImportError: + return False + + def _installed_version(self, env, name): + try: + return __import__(name).__version__ + except AttributeError: + raise CheckWarning( + 'Could not detect version of the Python module: {}.'.format(name), + details=self._details() + ) + +class ExternalDependencyCheck(DependencyCheck): + dependency_type = 'system executable' + + def _dependency_installed(self, env, name): + try: + which(name) + return True + except IOError: + return False + + def _installed_version(self, env, name): + try: + exe = which(name) + out = subprocess.check_output([exe, '--version']) + match = re.search('[\d.]+', out) + if not match: + raise CheckWarning( + 'Unable to detect version for executable {}'.format(name), + details="Command {} --version returned
{}
".format(exe, out) + ) + return match.group(0) + except subprocess.CalledProcessError as e: + raise CheckWarning( + 'Unable to detect version for executable {}: {}'.format(name, e), + details=self._details() + ) + +def get_checks_for_module(module_name): + result = [] + + manifest = load_information_from_description_file(module_name) + manifest_checks = manifest.get('environment_checkup') or {} + dependencies = manifest_checks.get('dependencies') or {} + + for dependency in dependencies.get('python') or []: + result.append(PythonDependencyCheck(module_name, dependency)) + for dependency in dependencies.get('external') or []: + result.append(ExternalDependencyCheck(module_name, dependency)) + for dependency in dependencies.get('internal') or []: + result.append(InternalDependencyCheck(module_name, dependency)) + + return result + +def get_checks_for_module_recursive(module): + class ModuleDFS(object): + def __init__(self): + self.visited_modules = set() + self.checks = [] + + def visit(self, module): + if module.name in self.visited_modules: + return + self.visited_modules.add(module.name) + self.checks += get_checks_for_module(module.name) + for module_dependency in module.dependencies_id: + if module_dependency.depend_id: + self.visit(module_dependency.depend_id) + return self + + return ModuleDFS().visit(module).checks diff --git a/galicea_environment_checkup/environment_checkup/runtime.py b/galicea_environment_checkup/environment_checkup/runtime.py new file mode 100644 index 0000000..b9bc9b7 --- /dev/null +++ b/galicea_environment_checkup/environment_checkup/runtime.py @@ -0,0 +1,24 @@ +# -*- coding: utf-8 -*- + +import custom, dependencies + +def all_installed_checks(env): + result = [] + installed_modules = env.registry._init_modules + for module_name in installed_modules: + result += custom.get_checks_for_module(module_name) + result += dependencies.get_checks_for_module(module_name) + return result + +def display_data(env, checks): + response = [] + for check in checks: + result = check.run(env) + response.append({ + 'module': check.module, + 'message': result.message, + 'details': result.details, + 'result': result.result + }) + + return response diff --git a/galicea_environment_checkup/environment_checkup/utils.py b/galicea_environment_checkup/environment_checkup/utils.py new file mode 100644 index 0000000..e69de29 diff --git a/galicea_environment_checkup/models/__init__.py b/galicea_environment_checkup/models/__init__.py new file mode 100644 index 0000000..d515a91 --- /dev/null +++ b/galicea_environment_checkup/models/__init__.py @@ -0,0 +1 @@ +from . import ext_module diff --git a/galicea_environment_checkup/models/ext_module.py b/galicea_environment_checkup/models/ext_module.py new file mode 100644 index 0000000..e4089de --- /dev/null +++ b/galicea_environment_checkup/models/ext_module.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- + +import json +from odoo import api, fields, models + +from ..environment_checkup import dependencies +from ..environment_checkup.runtime import display_data + +class Module(models.Model): + _inherit = 'ir.module.module' + + dependency_checks = fields.Text( + compute='_compute_dependency_checks' + ) + + @api.one + def _compute_dependency_checks(self): + checks = dependencies.get_checks_for_module_recursive(self) + self.dependency_checks = json.dumps(display_data(self.env, checks)) diff --git a/galicea_environment_checkup/static/description/custom.png b/galicea_environment_checkup/static/description/custom.png new file mode 100644 index 0000000..80d3617 Binary files /dev/null and b/galicea_environment_checkup/static/description/custom.png differ diff --git a/galicea_environment_checkup/static/description/dependencies.png b/galicea_environment_checkup/static/description/dependencies.png new file mode 100644 index 0000000..1d6642f Binary files /dev/null and b/galicea_environment_checkup/static/description/dependencies.png differ diff --git a/galicea_environment_checkup/static/description/icon.png b/galicea_environment_checkup/static/description/icon.png new file mode 100644 index 0000000..548dfc9 Binary files /dev/null and b/galicea_environment_checkup/static/description/icon.png differ diff --git a/galicea_environment_checkup/static/src/js/environment_checkup.js b/galicea_environment_checkup/static/src/js/environment_checkup.js new file mode 100644 index 0000000..c6fec5a --- /dev/null +++ b/galicea_environment_checkup/static/src/js/environment_checkup.js @@ -0,0 +1,108 @@ +odoo.define('galicea_environment_checkup', function(require) { + "use strict"; + + var core = require('web.core'); + var form_common = require('web.form_common'); + var Widget = require('web.Widget'); + var session = require('web.session'); + var QWeb = core.qweb; + var SystrayMenu = require('web.SystrayMenu'); + var Model = require('web.Model'); + + var Users = new Model('res.users'); + + var SystrayIcon = Widget.extend({ + tagName: 'li', + events: { + "click": "on_click", + }, + + start: function(){ + this.load(this.all_dashboards); + return this._super(); + }, + + load: function(dashboards){ + var self = this; + var loading_done = new $.Deferred(); + Users.call('has_group', ['base.group_erp_manager']).then(function(is_admin) { + if (is_admin) { + session.rpc('/galicea_environment_checkup/data', {}) + .then(function (data) { + var counts = { 'success': 0, 'warning': 0, 'fail': 0 }; + data.forEach(function (check) { ++counts[check.result]; }); + + var result; + if (counts['fail']) { + result = 'fail'; + } else if (counts['warning']) { + result = 'warning'; + } else { + result = 'success'; + } + + self.replaceElement(QWeb.render('GaliceaEnvironmentCheckupIcon', { + 'result': result, + 'count': counts['warning'] + counts['fail'] + })); + loading_done.resolve(); + }); + } else { + loading_done.resolve(); + } + }); + + return loading_done; + }, + + on_click: function (event) { + event.preventDefault(); + this.do_action('galicea_environment_checkup.dashboard_action', {clear_breadcrumbs: true}); + }, + }); + + SystrayMenu.Items.push(SystrayIcon); + + var Dashboard = Widget.extend({ + start: function(){ + return this.load(this.all_dashboards); + }, + + load: function(dashboards) { + var self = this; + var loading_done = new $.Deferred(); + session.rpc('/galicea_environment_checkup/data', {}) + .then(function (data) { + self.replaceElement(QWeb.render('GaliceaEnvironmentCheckupDashboard', {'data': data})); + loading_done.resolve(); + }); + return loading_done; + }, + }); + + core.action_registry.add('galicea_environment_checkup.dashboard', Dashboard); + + var FormWidget = form_common.AbstractField.extend({ + init: function() { + this._super.apply(this, arguments); + this.set("value", "[]"); + }, + + render_value: function() { + var data = JSON.parse(this.get('value')); + if (data.length == 0) { + this.replaceElement('
'); + return; + } + this.replaceElement(QWeb.render('GaliceaEnvironmentCheckupFormWidget', {'data': data})); + }, + }); + + core.form_widget_registry.add('environment_checks', FormWidget); + + return { + SystrayIcon: SystrayIcon, + Dashboard: Dashboard, + FormWidget: FormWidget + }; +}); diff --git a/galicea_environment_checkup/static/src/xml/templates.xml b/galicea_environment_checkup/static/src/xml/templates.xml new file mode 100644 index 0000000..12fa26e --- /dev/null +++ b/galicea_environment_checkup/static/src/xml/templates.xml @@ -0,0 +1,71 @@ + + + + +
  • + + + + + + + + + + + + + + +
  • +
    + + +
    + +
    +
    +
    + + + + + + + + + +
    +
    + Module: +

    +
    + +
    +
    +
    +
    +
    +
    +
    + + +
    +
    +
    +

    Environment check-up

    + + + +
    +
    +
    +
    + + +

    Module dependencies

    + + + + +
    diff --git a/galicea_environment_checkup/views/data.xml b/galicea_environment_checkup/views/data.xml new file mode 100644 index 0000000..bc0c392 --- /dev/null +++ b/galicea_environment_checkup/views/data.xml @@ -0,0 +1,9 @@ + + +