From 360e31ade431bb18f56a55896561f7dd97cb5b91 Mon Sep 17 00:00:00 2001 From: Christophe Monniez Date: Thu, 5 Mar 2020 17:35:50 +0100 Subject: [PATCH] [IMP] runbot: add a build stat model When a build is done, various numerical informations could be extracted from log files. e.g.: global query count or tests query count ... The extraction regular expression could be hard-coded in a custom step but there is no place holder where to store the retrieved information. In order to compare results, we need to store it. With this commit, a new model `runbot.build.stat` is used to store key/values pair linked to a build/config_step. That way, extracted values can be stored. Also, another `runbot.build.stat.regex` is used to store regular expressions that can be used to grep log files and extract values. The regular expression must contain a named group like this: `(?P.+)` The text catched by this group MUST be castable into a float. Optionally, another named group can be used in the regular expresion like this: `(?P.+)` This `key` group will then be used to augment the key name in the database. Example: Consider a log line like this one: `odoo.addons.website_blog.tests.test_ui tested in 10.35s` A regular expression like this, named `test_duration`: `odoo.addons.(?P.+) tested in (?P\d+\.\d+)s` Should store the following key:value: `{ 'key': 'test_duration.website_blog.tests.test_ui', 'value': 10.35 }` A `generic` boolean field is present on the build.stat.regex object, meaning that when no regex are linked to a make_stats config step, then, all the generic regex will be applied. A wizard is added to help the creation the regular expressions, allowing to test if they work against a user provided example. A _make_stats method is added to the ConfigStep model which is called during the _schedule of a build, just before calling the next step. The regex search is only apllied in steps that have the `make_stats` boolean field set to true. Also, the build branch have to be flagged `make_stats` too or the build can have a key `make_stats` in its config_data field. The `make_stats` field on the branch is a compute stored field. That way, sticky branches are automaticaly set `make_stats' true. Finally, an SQL view is used to facilitate the stats visualisation. --- runbot/__manifest__.py | 4 +- runbot/models/__init__.py | 12 +- runbot/models/branch.py | 7 ++ runbot/models/build.py | 6 +- runbot/models/build_config.py | 19 +++ runbot/models/build_stat.py | 98 +++++++++++++++ runbot/models/build_stat_regex.py | 72 +++++++++++ runbot/security/ir.model.access.csv | 11 +- runbot/tests/__init__.py | 1 + runbot/tests/test_build_stat.py | 135 +++++++++++++++++++++ runbot/views/build_views.xml | 11 ++ runbot/views/config_views.xml | 10 ++ runbot/views/stat_views.xml | 85 +++++++++++++ runbot/wizards/__init__.py | 1 + runbot/wizards/stat_regex_wizard.py | 71 +++++++++++ runbot/wizards/stat_regex_wizard_views.xml | 46 +++++++ 16 files changed, 585 insertions(+), 4 deletions(-) create mode 100644 runbot/models/build_stat.py create mode 100644 runbot/models/build_stat_regex.py create mode 100644 runbot/tests/test_build_stat.py create mode 100644 runbot/views/stat_views.xml create mode 100644 runbot/wizards/stat_regex_wizard.py create mode 100644 runbot/wizards/stat_regex_wizard_views.xml diff --git a/runbot/__manifest__.py b/runbot/__manifest__.py index 609c6e6f..693cb9a0 100644 --- a/runbot/__manifest__.py +++ b/runbot/__manifest__.py @@ -6,7 +6,7 @@ 'author': "Odoo SA", 'website': "http://runbot.odoo.com", 'category': 'Website', - 'version': '4.9', + 'version': '4.10', 'depends': ['website', 'base'], 'data': [ 'security/runbot_security.xml', @@ -21,7 +21,9 @@ 'views/error_log_views.xml', 'views/config_views.xml', 'views/res_config_settings_views.xml', + 'views/stat_views.xml', 'wizards/mutli_build_wizard_views.xml', + 'wizards/stat_regex_wizard_views.xml', 'templates/frontend.xml', 'templates/build.xml', 'templates/assets.xml', diff --git a/runbot/models/__init__.py b/runbot/models/__init__.py index 863258e4..f24dcb9f 100644 --- a/runbot/models/__init__.py +++ b/runbot/models/__init__.py @@ -1,4 +1,14 @@ # -*- coding: utf-8 -*- -from . import repo, branch, build, event, build_dependency, build_config, ir_cron, host, build_error +from . import repo +from . import branch +from . import build +from . import event +from . import build_dependency +from . import build_config +from . import ir_cron +from . import host +from . import build_error +from . import build_stat +from . import build_stat_regex from . import res_config_settings diff --git a/runbot/models/branch.py b/runbot/models/branch.py index d4aff4c0..bdeb7c95 100644 --- a/runbot/models/branch.py +++ b/runbot/models/branch.py @@ -41,6 +41,8 @@ class runbot_branch(models.Model): branch_config_id = fields.Many2one('runbot.build.config', 'Branch Config') config_id = fields.Many2one('runbot.build.config', 'Run Config', compute='_compute_config_id', inverse='_inverse_config_id') + make_stats = fields.Boolean('Extract stats from logs', compute='_compute_make_stats', store=True) + @api.depends('sticky', 'defined_sticky', 'target_branch_name', 'name') # won't be recompute if a new branch is marked as sticky or sticky is removed, but should be ok if not stored def _compute_closest_sticky(self): @@ -107,6 +109,11 @@ class runbot_branch(models.Model): for branch in self: branch.pull_branch_name = branch.pull_head_name.split(':')[-1] if branch.pull_head_name else branch.branch_name + @api.depends('sticky') + def _compute_make_stats(self): + for branch in self: + branch.make_stats = branch.sticky + @api.depends('name') def _get_branch_infos(self, pull_info=None): """compute branch_name, branch_url, pull_head_name and target_branch_name based on name""" diff --git a/runbot/models/build.py b/runbot/models/build.py index ca88d3d6..71564907 100644 --- a/runbot/models/build.py +++ b/runbot/models/build.py @@ -65,7 +65,7 @@ class runbot_build(models.Model): repo_id = fields.Many2one(related='branch_id.repo_id', readonly=True, store=True) name = fields.Char('Revno', required=True) description = fields.Char('Description', help='Informative description') - md_description = fields.Char(compute='_compute_md_description', String='MD Parsed Description', help='Informative description mardown parsed') + md_description = fields.Char(compute='_compute_md_description', String='MD Parsed Description', help='Informative description markdown parsed') host = fields.Char('Host') port = fields.Integer('Port') dest = fields.Char(compute='_compute_dest', type='char', string='Dest', readonly=1, store=True) @@ -80,6 +80,7 @@ class runbot_build(models.Model): log_ids = fields.One2many('ir.logging', 'build_id', string='Logs') error_log_ids = fields.One2many('ir.logging', 'build_id', domain=[('level', 'in', ['WARNING', 'ERROR', 'CRITICAL'])], string='Error Logs') config_data = JsonDictField('Config Data') + stat_ids = fields.One2many('runbot.build.stat', 'build_id', strings='Statistics values') # state machine @@ -738,6 +739,9 @@ class runbot_build(models.Model): build_values.update(results) + # compute statistics before starting next job + build.active_step._make_stats(build) + build.active_step.log_end(build) build_values.update(build._next_job_values()) # find next active_step or set to done diff --git a/runbot/models/build_config.py b/runbot/models/build_config.py index 02f84005..a9d59cb9 100644 --- a/runbot/models/build_config.py +++ b/runbot/models/build_config.py @@ -102,6 +102,8 @@ class ConfigStep(models.Model): step_order_ids = fields.One2many('runbot.build.config.step.order', 'step_id') group = fields.Many2one('runbot.build.config', 'Configuration group', help="Group of config's and config steps") group_name = fields.Char('Group name', related='group.name') + make_stats = fields.Boolean('Make stats', default=False) + build_stat_regex_ids = fields.Many2many('runbot.build.stat.regex', string='Stats Regexes') # install_odoo create_db = fields.Boolean('Create Db', default=True, track_visibility='onchange') # future custom_db_name = fields.Char('Custom Db Name', track_visibility='onchange') # future @@ -571,6 +573,23 @@ class ConfigStep(models.Model): build_values['local_result'] = build._get_worst_result([build.local_result, local_result]) return build_values + def _make_stats(self, build): + build._log('make_stats', 'Getting stats from log file') + log_path = build._path('logs', '%s.txt' % self.name) + if not os.path.exists(log_path): + build._log('make_stats', 'Log **%s.txt** file not found' % self.name, level='INFO', log_type='markdown') + return + if (build.branch_id.make_stats or build.config_data.get('make_stats')) and self.make_stats: + try: + regex_ids = self.build_stat_regex_ids + if not regex_ids: + regex_ids = regex_ids.search([('generic', '=', True)]) + key_values = regex_ids._find_in_file(log_path) + self.env['runbot.build.stat']._write_key_values(build, self, key_values) + except Exception as e: + message = '**An error occured while computing statistics of %s:**\n`%s`' % (build.job, str(e).replace('\\n', '\n').replace("\\'", "'")) + build._log('make_stats', message, level='INFO', log_type='markdown') + def _step_state(self): self.ensure_one() if self.job_type == 'run_odoo' or (self.job_type == 'python' and self.running_job): diff --git a/runbot/models/build_stat.py b/runbot/models/build_stat.py new file mode 100644 index 00000000..7e4b943f --- /dev/null +++ b/runbot/models/build_stat.py @@ -0,0 +1,98 @@ +import logging + +from odoo import models, fields, api, tools + +_logger = logging.getLogger(__name__) + + +class RunbotBuildStat(models.Model): + _name = "runbot.build.stat" + _description = "Statistics" + _sql_constraints = [ + ( + "build_config_key_unique", + "unique (build_id, config_step_id,key)", + "Build stats must be unique for the same build step", + ) + ] + + build_id = fields.Many2one("runbot.build", "Build", index=True, ondelete="cascade") + config_step_id = fields.Many2one( + "runbot.build.config.step", "Step", index=True, ondelete="cascade" + ) + key = fields.Char("key", index=True) + value = fields.Float("Value") + + @api.model + def _write_key_values(self, build, config_step, key_values): + if not key_values: + return self + build_stats = [ + { + "config_step_id": config_step.id, + "build_id": build.id, + "key": k, + "value": v, + } + for k, v in key_values.items() + ] + return self.create(build_stats) + + +class RunbotBuildStatSql(models.Model): + + _name = "runbot.build.stat.sql" + _description = "Build stat sql view" + _auto = False + + id = fields.Many2one("runbot.build.stat", readonly=True) + key = fields.Char("Key", readonly=True) + value = fields.Float("Value", readonly=True) + config_step_id = fields.Many2one( + "runbot.build.config.step", string="Config Step", readonly=True + ) + config_step_name = fields.Char(String="Config Step name", readonly=True) + build_id = fields.Many2one("runbot.build", string="Build", readonly=True) + build_config_id = fields.Many2one("runbot.build.config", string="Config", readonly=True) + build_name = fields.Char(String="Build name", readonly=True) + build_parent_path = fields.Char('Build Parent path') + host = fields.Char(string="Host", readonly=True) + branch_id = fields.Many2one("runbot.branch", string="Branch", readonly=True) + branch_name = fields.Char(string="Branch name", readonly=True) + branch_sticky = fields.Boolean(string="Sticky", readonly=True) + repo_id = fields.Many2one("runbot.repo", string="Repo", readonly=True) + repo_name = fields.Char(string="Repo name", readonly=True) + + def init(self): + """ Create SQL view for build stat """ + tools.drop_view_if_exists(self._cr, "runbot_build_stat_sql") + self._cr.execute( + """ CREATE VIEW runbot_build_stat_sql AS ( + SELECT + stat.id AS id, + stat.key AS key, + stat.value AS value, + step.id AS config_step_id, + step.name AS config_step_name, + bu.id AS build_id, + bu.config_id AS build_config_id, + bu.parent_path AS build_parent_path, + bu.name AS build_name, + bu.host AS build_host, + br.id AS branch_id, + br.branch_name AS branch_name, + br.sticky AS branch_sticky, + repo.id AS repo_id, + repo.name AS repo_name + FROM + runbot_build_stat AS stat + JOIN + runbot_build_config_step step ON stat.config_step_id = step.id + JOIN + runbot_build bu ON stat.build_id = bu.id + JOIN + runbot_branch br ON br.id = bu.branch_id + JOIN + runbot_repo repo ON br.repo_id = repo.id + )""" + ) diff --git a/runbot/models/build_stat_regex.py b/runbot/models/build_stat_regex.py new file mode 100644 index 00000000..d6246ef4 --- /dev/null +++ b/runbot/models/build_stat_regex.py @@ -0,0 +1,72 @@ +# -*- coding: utf-8 -*- +import logging +import os +import re + +from odoo import models, fields, api +from odoo.exceptions import ValidationError + +VALUE_PATTERN = r"\(\?P\.+\)" # used to verify value group pattern + +_logger = logging.getLogger(__name__) + + +class RunbotBuildStatRegex(models.Model): + """ A regular expression to extract a float/int value from a log file + The regulare should contain a named group like '(?P.+)'. + The result will be a key/value like {name: value} + A second named group '(?P.+)' can bu used to augment the key name + like {name.key_result: value} + A 'generic' regex will be used when no regex are defined on a make_stat + step. + """ + + _name = "runbot.build.stat.regex" + _description = "Statistics regex" + + name = fields.Char("Key Name") + regex = fields.Char("Regular Expression") + description = fields.Char("Description") + generic = fields.Boolean('Generic', help='Executed when no regex on the step', default=True) + config_step_ids = fields.Many2many('runbot.build.config.step', string='Config Steps') + + @api.constrains("name", "regex") + def _check_regex(self): + for rec in self: + try: + r = re.compile(rec.regex) + except re.error as e: + raise ValidationError("Unable to compile regular expression: %s" % e) + # verify that a named group exist in the pattern + if not re.search(VALUE_PATTERN, r.pattern): + raise ValidationError( + "The regular expresion should contain the name group pattern 'value' e.g: '(?P.+)'" + ) + + def _find_in_file(self, file_path): + """ Search file regexes and write stats + returns a dict of key:values + """ + if not os.path.exists(file_path): + return {} + key_values = {} + with open(file_path, "r") as log_file: + data = log_file.read() + for build_stat_regex in self: + for match in re.finditer(build_stat_regex.regex, data): + group_dict = match.groupdict() + try: + value = float(group_dict.get("value")) + except ValueError: + _logger.warning( + 'The matched value (%s) of "%s" cannot be converted into float' + % (group_dict.get("value"), build_stat_regex.regex) + ) + continue + key = ( + "%s.%s" % (build_stat_regex.name, group_dict["key"]) + if "key" in group_dict + else build_stat_regex.name + ) + key_values[key] = value + return key_values diff --git a/runbot/security/ir.model.access.csv b/runbot/security/ir.model.access.csv index 064e0b6a..83dbbd15 100644 --- a/runbot/security/ir.model.access.csv +++ b/runbot/security/ir.model.access.csv @@ -33,4 +33,13 @@ access_runbot_error_log_user,runbot_error_log_user,runbot.model_runbot_error_log access_runbot_error_log_manager,runbot_error_log_manager,runbot.model_runbot_error_log,runbot.group_runbot_admin,1,1,1,1 access_runbot_repo_hooktime,runbot_repo_hooktime,runbot.model_runbot_repo_hooktime,group_user,1,0,0,0 -access_runbot_repo_reftime,runbot_repo_reftime,runbot.model_runbot_repo_reftime,group_user,1,0,0,0 \ No newline at end of file +access_runbot_repo_reftime,runbot_repo_reftime,runbot.model_runbot_repo_reftime,group_user,1,0,0,0 + +access_runbot_build_stat_user,runbot_build_stat_user,runbot.model_runbot_build_stat,group_user,1,0,0,0 +access_runbot_build_stat_admin,runbot_build_stat_admin,runbot.model_runbot_build_stat,runbot.group_runbot_admin,1,1,1,1 + +access_runbot_build_stat_sql_user,runbot_build_stat_sql_user,runbot.model_runbot_build_stat_sql,group_user,1,0,0,0 +access_runbot_build_stat_sql_admin,runbot_build_stat_sql_admin,runbot.model_runbot_build_stat_sql,runbot.group_runbot_admin,1,0,0,0 + +access_runbot_build_stat_regex_user,access_runbot_build_stat_regex_user,model_runbot_build_stat_regex,runbot.group_user,1,0,0,0 +access_runbot_build_stat_regex_admin,access_runbot_build_stat_regex_admin,model_runbot_build_stat_regex,runbot.group_runbot_admin,1,1,1,1 diff --git a/runbot/tests/__init__.py b/runbot/tests/__init__.py index ce252503..46b41331 100644 --- a/runbot/tests/__init__.py +++ b/runbot/tests/__init__.py @@ -9,3 +9,4 @@ from . import test_cron from . import test_build_config_step from . import test_event from . import test_command +from . import test_build_stat diff --git a/runbot/tests/test_build_stat.py b/runbot/tests/test_build_stat.py new file mode 100644 index 00000000..ed55a135 --- /dev/null +++ b/runbot/tests/test_build_stat.py @@ -0,0 +1,135 @@ +# -*- coding: utf-8 -*- +from psycopg2 import IntegrityError +from unittest.mock import patch, mock_open +from odoo.exceptions import ValidationError +from odoo.tools import mute_logger +from .common import RunbotCase + + +class TestBuildStatRegex(RunbotCase): + def setUp(self): + super(TestBuildStatRegex, self).setUp() + self.StatRegex = self.env["runbot.build.stat.regex"] + self.ConfigStep = self.env["runbot.build.config.step"] + self.BuildStat = self.env["runbot.build.stat"] + + self.repo = self.Repo.create( + { + "name": "bla@example.com:foo/bar", + "server_files": "server.py", + "addons_paths": "addons,core/addons", + } + ) + self.branch = self.Branch.create( + {"repo_id": self.repo.id, "name": "refs/heads/master"} + ) + + self.Build = self.env["runbot.build"] + + self.build = self.create_build( + { + "branch_id": self.branch.id, + "name": "d0d0caca0000ffffffffffffffffffffffffffff", + "port": "1234", + "config_data": {"make_stats": True}, + } + ) + + self.config_step = self.env["runbot.build.config.step"].create( + { + "name": "a_nice_step", + "job_type": "install_odoo", + "make_stats": True, + "build_stat_regex_ids": [(0, 0, {"name": "query_count", "regex": r"odoo.addons.(?P.+) tested in .+, (?P\d+) queries", "generic": False})] + } + ) + + def test_build_stat_regex_validation(self): + + # test that a regex without a named key 'value' raises a ValidationError + with self.assertRaises(ValidationError): + self.StatRegex.create( + {"name": "query_count", "regex": "All post-tested in .+s, .+ queries"} + ) + + def test_build_stat_regex_find_in_file(self): + + file_content = """foo bar +2020-03-02 22:06:58,391 17 INFO xxx odoo.modules.module: odoo.addons.website_blog.tests.test_ui tested in 10.35s, 2501 queries +some garbage +2020-03-02 22:07:14,340 17 INFO xxx odoo.modules.module: odoo.addons.website_event.tests.test_ui tested in 9.26s, 2435 queries +nothing to see here +""" + self.start_patcher( + "isdir", "odoo.addons.runbot.models.build_stat_regex.os.path.exists", True + ) + with patch("builtins.open", mock_open(read_data=file_content)): + self.config_step._make_stats(self.build) + + self.assertEqual(self.BuildStat.search_count([('key', '=', 'query_count.website_blog.tests.test_ui'), ('value', '=', 2501.0)]), 1) + self.assertEqual(self.BuildStat.search_count([('key', '=', 'query_count.website_event.tests.test_ui'), ('value', '=', 2435.0)]), 1) + + # Check unicity + with self.assertRaises(IntegrityError): + with mute_logger("odoo.sql_db"): + with self.cr.savepoint(): # needed to continue tests + self.env["runbot.build.stat"]._write_key_values( + self.build, self.config_step, {'query_count.website_event.tests.test_ui': 2435} + ) + + # minimal test for RunbotBuildStatSql model + self.assertEqual(self.env['runbot.build.stat.sql'].search_count([('build_id', '=', self.build.id)]), 2) + + def test_build_stat_regex_generic(self): + """ test that regex are not used when generic is False and that _make_stats use all genreic regex if there are no regex on step """ + file_content = """foo bar +odoo.addons.foobar tested in 2s, 25 queries +useless 10 +chocolate 15 +""" + + self.config_step.build_stat_regex_ids = False + + # this one is not generic and thus should not be used + self.StatRegex.create({"name": "useless_count", "regex": r"(?Puseless) (?P\d+)", "generic": False}) + + # this is one is the only one that should be used + self.StatRegex.create({"name": "chocolate_count", "regex": r"(?Pchocolate) (?P\d+)"}) + + self.start_patcher( + "isdir", "odoo.addons.runbot.models.build_stat_regex.os.path.exists", True + ) + with patch("builtins.open", mock_open(read_data=file_content)): + self.config_step._make_stats(self.build) + + self.assertEqual(self.BuildStat.search_count([('key', '=', 'query_count.foobar'), ('value', '=', 25.0)]), 0) + self.assertEqual(self.BuildStat.search_count([('key', '=', 'useless_count.useless'), ('value', '=', 10.0)]), 0) + self.assertEqual(self.BuildStat.search_count([('key', '=', 'chocolate_count.chocolate'), ('value', '=', 15.0)]), 1) + + def test_build_stat_regex_find_in_file_perf(self): + + noise_lines = """2020-03-17 13:26:15,472 2376 INFO runbottest odoo.modules.loading: loading runbot/views/build_views.xml +2020-03-10 22:58:34,472 17 INFO 1709329-master-9938b2-all_no_autotag werkzeug: 127.0.0.1 - - [10/Mar/2020 22:58:34] "POST /mail/read_followers HTTP/1.1" 200 - 13 0.004 0.009 +2020-03-10 22:58:30,137 17 INFO ? werkzeug: 127.0.0.1 - - [10/Mar/2020 22:58:30] "GET /website/static/src/xml/website.editor.xml HTTP/1.1" 200 - - - - +""" + + match_lines = [ + "2020-03-02 22:06:58,391 17 INFO xxx odoo.modules.module: odoo.addons.website_blog.tests.test_ui tested in 10.35s, 2501 queries", + "2020-03-02 22:07:14,340 17 INFO xxx odoo.modules.module: odoo.addons.website_event.tests.test_ui tested in 9.26s, 2435 queries" + ] + + # generate a 13 MiB log file with two potential matches + log_data = "" + for l in match_lines: + log_data += noise_lines * 10000 + log_data += l + log_data += noise_lines * 10000 + + self.start_patcher( + "isdir", "odoo.addons.runbot.models.build_stat_regex.os.path.exists", True + ) + with patch("builtins.open", mock_open(read_data=log_data)): + self.config_step._make_stats(self.build) + + self.assertEqual(self.BuildStat.search_count([('key', '=', 'query_count.website_blog.tests.test_ui'), ('value', '=', 2501.0)]), 1) + self.assertEqual(self.BuildStat.search_count([('key', '=', 'query_count.website_event.tests.test_ui'), ('value', '=', 2435.0)]), 1) diff --git a/runbot/views/build_views.xml b/runbot/views/build_views.xml index c888934d..985d6d4a 100644 --- a/runbot/views/build_views.xml +++ b/runbot/views/build_views.xml @@ -52,6 +52,17 @@ + + + + + + + + + + + diff --git a/runbot/views/config_views.xml b/runbot/views/config_views.xml index 994fb93e..5560a25d 100644 --- a/runbot/views/config_views.xml +++ b/runbot/views/config_views.xml @@ -45,10 +45,20 @@ + + + + + + + + + + diff --git a/runbot/views/stat_views.xml b/runbot/views/stat_views.xml new file mode 100644 index 00000000..7a42d286 --- /dev/null +++ b/runbot/views/stat_views.xml @@ -0,0 +1,85 @@ + + + + + runbot.stat.sql.tree + runbot.build.stat.sql + + + + + + + + + + + + + + + Statistics + runbot.build.stat.sql + tree,graph,pivot + + + + + + + + runbot.build.stat.regex.form + runbot.build.stat.regex + +
+ + + + + + + + +
+
+
+ + + runbot.build.stat.regex.tree + runbot.build.stat.regex + + + + + + + + + + + + Stat regex + runbot.build.stat.regex + tree,form + + + +
+
diff --git a/runbot/wizards/__init__.py b/runbot/wizards/__init__.py index 8b3f3120..aa69f76c 100644 --- a/runbot/wizards/__init__.py +++ b/runbot/wizards/__init__.py @@ -1,3 +1,4 @@ # -*- coding: utf-8 -*- from . import multi_build_wizard +from . import stat_regex_wizard diff --git a/runbot/wizards/stat_regex_wizard.py b/runbot/wizards/stat_regex_wizard.py new file mode 100644 index 00000000..82fe4b50 --- /dev/null +++ b/runbot/wizards/stat_regex_wizard.py @@ -0,0 +1,71 @@ +# -*- coding: utf-8 -*- +import re + +from odoo import fields, models, api +from odoo.exceptions import ValidationError +from odoo.addons.runbot.models.build_stat_regex import VALUE_PATTERN + + +class StatRegexWizard(models.TransientModel): + _name = 'runbot.build.stat.regex.wizard' + _description = "Stat Regex Wizard" + + name = fields.Char("Key Name") + regex = fields.Char("Regular Expression") + description = fields.Char("Description") + generic = fields.Boolean('Generic', help='Executed when no regex on the step', default=True) + test_text = fields.Text("Test text") + key = fields.Char("Key") + value = fields.Float("Value") + message = fields.Char("Wizard message") + + def _validate_regex(self): + try: + regex = re.compile(self.regex) + except re.error as e: + raise ValidationError("Unable to compile regular expression: %s" % e) + if not re.search(VALUE_PATTERN, regex.pattern): + raise ValidationError( + "The regular expresion should contain the name group pattern 'value' e.g: '(?P.+)'" + ) + + @api.onchange('regex', 'test_text') + def _onchange_regex(self): + key = '' + value = False + self.message = '' + if self.regex and self.test_text: + self._validate_regex() + match = re.search(self.regex, self.test_text) + if match: + group_dict = match.groupdict() + try: + value = float(group_dict.get("value")) + except ValueError: + raise ValidationError('The matched value (%s) of "%s" cannot be converted into float' % (group_dict.get("value"), self.regex)) + key = ( + "%s.%s" % (self.name, group_dict["key"]) + if "key" in group_dict + else self.name + ) + else: + self.message = 'No match !' + self.key = key + self.value = value + + def save(self): + if self.regex and self.test_text: + self._validate_regex() + stat_regex = self.env['runbot.build.stat.regex'].create({ + 'name': self.name, + 'regex': self.regex, + 'description': self.description, + 'generic': self.generic, + }) + return { + 'name': 'Stat regex', + 'type': 'ir.actions.act_window', + 'res_model': 'runbot.build.stat.regex', + 'view_mode': 'form', + 'res_id': stat_regex.id + } diff --git a/runbot/wizards/stat_regex_wizard_views.xml b/runbot/wizards/stat_regex_wizard_views.xml new file mode 100644 index 00000000..1400b8ff --- /dev/null +++ b/runbot/wizards/stat_regex_wizard_views.xml @@ -0,0 +1,46 @@ + + + + + runbot_stat_regex_wizard + runbot.build.stat.regex.wizard + +
+ + + + + + + + + + + + +
+
+
+
+
+ + + Generate Stat Regex + ir.actions.act_window + runbot.build.stat.regex.wizard + form + + new + + + +
+