diff --git a/odoo_health_report/__manifest__.py b/odoo_health_report/__manifest__.py index 92b987c64..e2e726d01 100644 --- a/odoo_health_report/__manifest__.py +++ b/odoo_health_report/__manifest__.py @@ -21,7 +21,7 @@ ###################################################################################### { 'name': 'Odoo Health Report', - 'version': '18.0.1.0.1', + 'version': '18.0.2.1.1', 'category': 'Productivity', 'summary': "Odoo Module Health Monitoring Tool", 'description': 'Displays odoo apps report in the menu and as a PDF report.', diff --git a/odoo_health_report/doc/RELEASE_NOTES.md b/odoo_health_report/doc/RELEASE_NOTES.md index cd3c045bb..f2082e50e 100644 --- a/odoo_health_report/doc/RELEASE_NOTES.md +++ b/odoo_health_report/doc/RELEASE_NOTES.md @@ -4,3 +4,8 @@ #### Version 18.0.1.0.1 #### ADD - Initial Commit for Odoo Health Report + +#### 11.07.2025 +#### Version 18.0.2.1.1 +#### UPDATE +- Added new features \ No newline at end of file diff --git a/odoo_health_report/models/check_odoo_python_guidelines.py b/odoo_health_report/models/check_odoo_python_guidelines.py new file mode 100644 index 000000000..946a2140e --- /dev/null +++ b/odoo_health_report/models/check_odoo_python_guidelines.py @@ -0,0 +1,711 @@ +# -*- coding: utf-8 -*- +###################################################################################### +# +# Cybrosys Technologies Pvt. Ltd. +# +# Copyright (C) 2025-TODAY Cybrosys Technologies() +# Author: Cybrosys Techno Solutions() +# +# This program is under the terms of the Odoo Proprietary License v1.0 (OPL-1) +# It is forbidden to publish, distribute, sublicense, or sell copies of the Software +# or modified copies of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NON INFRINGEMENT. +# IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +# DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, +# ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +# DEALINGS IN THE SOFTWARE. +# +###################################################################################### +import ast +import os +import re + +results = [] +module_technical_name = ['no_module'] + + +def is_camel_case(name): + """ + Check if a given name follows CamelCase format. + + Args: + name (str): The variable or class name. + + Returns: + bool: True if name is CamelCase. + """ + return re.match(r'^[A-Z][a-zA-Z0-9]+$', name) + +def is_snake_case(name): + """ + Check if a given name follows snake_case format. + + Args: + name (str): The variable or function name. + + Returns: + bool: True if name is snake_case. + """ + return re.match(r'^[a-z_][a-z0-9_]*$', name) + +def check_class_name(node, file_path): + """ + Validate that the class name follows CamelCase. + + Args: + node (ast.ClassDef): The class node from AST. + file_path (str): The source file path. + """ + if not is_camel_case(node.name): + file_path_shorten = f"{module_technical_name[0]}/{file_path.split(f'/{module_technical_name[0]}/')[1]}" + results.append({ + "file": file_path_shorten, + "line": node.lineno, + "issue": f"Class name '{node.name}' is not CamelCase", + "suggestion": "Use CamelCase (e.g., MyClassName)" + }) + +def check_model_class(node, file_path): + """ + Check Odoo model class rules, including naming consistency. + + Args: + node (ast.ClassDef): The class node from AST. + file_path (str): The source file path. + """ + model_type = None + model_name = None + inherit_found = False + name_found = False + + for base in node.bases: + if isinstance(base, ast.Attribute) and base.attr in ["Model", "TransientModel"]: + model_type = base.attr + + for item in node.body: + if isinstance(item, ast.Assign): + for target in item.targets: + if isinstance(target, ast.Name): + if target.id == "_name": + name_found = True + if isinstance(item.value, ast.Constant) and isinstance(item.value.value, str): + model_name = item.value.value + elif target.id == "_inherit": + inherit_found = True + + file_path_shorten = f"{module_technical_name[0]}/{file_path.split(f'/{module_technical_name[0]}/')[1]}" + + # Check if _name or _inherit is present + if model_type and not (name_found or inherit_found): + results.append({ + "file": file_path_shorten, + "line": node.lineno, + "issue": f"Class '{node.name}' is missing both '_name' and '_inherit'", + "suggestion": "Add either _name (for new model) or _inherit (for extending existing model)" + }) + + # Consistency Check + filename = os.path.basename(file_path).replace(".py", "") + expected_class_name = "".join(part.capitalize() for part in filename.split("_")) + expected_model_name = filename.replace("_", ".") + + if node.name != expected_class_name: + results.append({ + "file": file_path_shorten, + "line": node.lineno, + "issue": f"Class name '{node.name}' does not match file name '{filename}.py'", + "suggestion": f"Rename class to '{expected_class_name}' to match file name" + }) + + if model_name and model_type == "Model": + if model_name != expected_model_name: + results.append({ + "file": file_path_shorten, + "line": node.lineno, + "issue": f"_name '{model_name}' does not match file-based model name '{expected_model_name}'", + "suggestion": f"Use _name = '{expected_model_name}'" + }) + + if model_name: + if "." not in model_name: + results.append({ + "file": file_path_shorten, + "line": node.lineno, + "issue": f"Model name '{model_name}' should use dot notation", + "suggestion": "Use format: . (e.g., res.partner)" + }) + + if model_type == "TransientModel" and "wizard" in model_name: + results.append({ + "file": file_path_shorten, + "line": node.lineno, + "issue": f"Transient model name '{model_name}' contains 'wizard'", + "suggestion": "Avoid using 'wizard'; use format: ." + }) + + if ".report." in model_name and len(model_name.split(".")) < 3: + parts = model_name.split(".") + if len(parts) < 3: + results.append({ + "file": file_path_shorten, + "line": node.lineno, + "issue": f"Report model name '{model_name}' should follow '.report.'", + "suggestion": "Ensure full dot-notation like 'sale.report.invoice'" + }) + + if model_type == "Model": + last_part = model_name.split(".")[-1] + if last_part.endswith("s"): + results.append({ + "file": file_path_shorten, + "line": node.lineno, + "issue": f"Model name '{model_name}' seems plural", + "suggestion": "Use singular form (e.g., 'res.partner', not 'res.partners')" + }) + +def check_missing_docstring(node, file_path, type="function"): + """ + Check whether a class or function has a docstring. + + Args: + node (ast.AST): The node (ClassDef or FunctionDef). + file_path (str): File path. + type (str): Either 'function' or 'class'. + """ + file_path_shorten = f"{module_technical_name[0]}/{file_path.split(f'/{module_technical_name[0]}/')[1]}" + if not ast.get_docstring(node): + results.append({ + "file": file_path_shorten, + "line": node.lineno, + "issue": f"{type.capitalize()} '{node.name}' is missing a docstring", + "suggestion": f"Add a docstring to the {type}" + }) + +def check_function_name(node, file_path): + """ + Check function or method names for snake_case and method type prefixes. + + Args: + node (ast.FunctionDef): The function node. + file_path (str): Path to the source file. + """ + file_path_shorten = f"{module_technical_name[0]}/{file_path.split(f'/{module_technical_name[0]}/')[1]}" + if not is_snake_case(node.name): + results.append({ + "file": file_path_shorten, + "line": node.lineno, + "issue": f"Function name '{node.name}' is not snake_case", + "suggestion": "Use snake_case (e.g., my_function_name)" + }) + + for decorator in node.decorator_list: + if isinstance(decorator, ast.Attribute): + if decorator.attr in ["multi", "one"]: + results.append({ + "file": file_path_shorten, + "line": node.lineno, + "issue": f"Decorator '@api.{decorator.attr}' is deprecated", + "suggestion": "Remove it; use '@api.model' or '@api.depends' based on logic" + }) + check_method_prefix(node, file_path) + +def check_method_prefix(node, file_path): + """ + Check method prefix based on decorators like @api.depends, @api.onchange, etc. + + Args: + node (ast.FunctionDef): The method definition. + file_path (str): Path to the Python file. + """ + method_prefix_map = { + 'depends': '_compute_', + 'onchange': '_onchange_', + 'constrains': '_check_', + } + file_path_shorten = f"{module_technical_name[0]}/{file_path.split(f'/{module_technical_name[0]}/')[1]}" + + for decorator in node.decorator_list: + if isinstance(decorator, ast.Call) and isinstance(decorator.func, ast.Attribute): + deco_name = decorator.func.attr + expected_prefix = method_prefix_map.get(deco_name) + if expected_prefix and not node.name.startswith(expected_prefix): + results.append({ + "file": file_path_shorten, + "line": node.lineno, + "issue": f"Method '{node.name}' uses @{deco_name} but doesn't start with '{expected_prefix}'", + "suggestion": f"Prefix method name with '{expected_prefix}'" + }) + + if 'default_' in node.name and not node.name.startswith('_default_'): + results.append({ + "file": file_path_shorten, + "line": node.lineno, + "issue": f"Default method '{node.name}' should start with '_default_'", + "suggestion": "Rename method to start with '_default_'" + }) + + if 'selection' in node.name and not node.name.startswith('_selection_'): + results.append({ + "file": file_path_shorten, + "line": node.lineno, + "issue": f"Selection method '{node.name}' should start with '_selection_'", + "suggestion": "Rename method to start with '_selection_'" + }) + + if node.name.startswith('do_'): + results.append({ + "file": file_path_shorten, + "line": node.lineno, + "issue": f"Action method '{node.name}' should start with 'action_'", + "suggestion": "Rename method to start with 'action_'" + }) + +def check_field_suffix(node, file_path): + """ + Ensure field names have correct suffixes based on field type. + + Args: + node (ast.Assign): The field definition node. + file_path (str): Path to the Python file. + """ + file_path_shorten = f"{module_technical_name[0]}/{file_path.split(f'/{module_technical_name[0]}/')[1]}" + if isinstance(node.value, ast.Call) and isinstance(node.value.func, ast.Attribute): + field_type = node.value.func.attr + if field_type in ['Many2one', 'One2many', 'Many2many']: + target = node.targets[0] + name = target.id if isinstance(target, ast.Name) else getattr(target, 'attr', None) + if name: + correct_suffix = '_id' if field_type == 'Many2one' else '_ids' + if not name.endswith(correct_suffix): + results.append({ + "file": file_path_shorten, + "line": node.lineno, + "issue": f"{field_type} field '{name}' should end with '{correct_suffix}'", + "suggestion": f"Rename variable to end with '{correct_suffix}'" + }) + +def check_variable_naming(node, file_path): + """ + Check local variable naming conventions and suffixes for record/recordsets. + + Args: + node (ast.Assign): The assignment statement node. + file_path (str): Source file path. + """ + if not isinstance(node, ast.Assign) or not isinstance(node.value, ast.Call): + return + + targets = node.targets + if not targets: + return + + var_name = targets[0].id if isinstance(targets[0], ast.Name) else None + if not var_name: + return + + call_func = node.value.func + call_attr = call_func.attr if isinstance(call_func, ast.Attribute) else '' + + file_path_shorten = f"{module_technical_name[0]}/{file_path.split(f'/{module_technical_name[0]}/')[1]}" + + if isinstance(call_func, ast.Subscript): + if isinstance(call_func.value, ast.Attribute) and call_func.value.attr == 'env': + if not is_camel_case(var_name): + results.append({ + "file": file_path_shorten, + "line": node.lineno, + "issue": f"Model variable '{var_name}' is not CamelCase", + "suggestion": "Use CamelCase (e.g., MyModelVariable)" + }) + + if call_attr in ['browse', 'search', 'search_read']: + if call_attr == 'browse' and not var_name.endswith('_id'): + results.append({ + "file": file_path_shorten, + "line": node.lineno, + "issue": f"Variable '{var_name}' may represent a single record, use _id suffix", + "suggestion": "Rename variable to end with '_id'" + }) + elif call_attr in ['search', 'search_read'] and not var_name.endswith('_ids'): + results.append({ + "file": file_path_shorten, + "line": node.lineno, + "issue": f"Variable '{var_name}' may represent multiple records, use _ids suffix", + "suggestion": "Rename variable to end with '_ids'" + }) + + if not is_snake_case(var_name): + results.append({ + "file": file_path_shorten, + "line": node.lineno, + "issue": f"Variable name '{var_name}' is not snake_case", + "suggestion": "Use snake_case (e.g., my_variable)" + }) + +def check_python_file_name(file_path): + """ + Check if the Python file name follows snake_case convention. + + Args: + file_path (str): Full path to the Python file. + """ + filename = os.path.basename(file_path) + if not filename.endswith(".py") or filename in {"__init__.py", "__manifest__.py"}: + return + + name_without_ext = filename[:-3] # Remove .py + file_path_shorten = f"{module_technical_name[0]}/{file_path.split(f'/{module_technical_name[0]}/')[1]}" + + if not re.fullmatch(r"[a-z0-9_]+", name_without_ext): + results.append({ + "file": file_path_shorten, + "line": 0, + "issue": f"File name '{filename}' does not follow snake_case", + "suggestion": "Rename file using lowercase and underscores (e.g., my_model.py)" + }) + + if name_without_ext in {"temp", "test", "test1", "new", "misc"}: + results.append({ + "file": file_path_shorten, + "line": 0, + "issue": f"File name '{filename}' is too generic or non-descriptive", + "suggestion": "Use meaningful file names related to model or logic" + }) + +def check_manifest_file(file_path): + """ + Check the manifest file for any standard issues. + + Args: + file_path (str): Full path to the Python file. + """ + file_path_shorten = f"{module_technical_name[0]}/{file_path.split(f'/{module_technical_name[0]}/')[1]}" + with open(file_path, "r", encoding="utf-8") as f: + source = f.read() + try: + tree = ast.parse(source) + except SyntaxError: + results.append({ + "file": file_path_shorten, + "line": 0, + "issue": "Invalid Python syntax in __manifest__.py", + "suggestion": "Fix the syntax error" + }) + return + + manifest_data = {} + seen_keys = set() + + for node in ast.walk(tree): + if isinstance(node, ast.Dict): + for k_node, v_node in zip(node.keys, node.values): + if isinstance(k_node, ast.Str): + key = k_node.s + if key in seen_keys: + results.append({ + "file": file_path_shorten, + "line": k_node.lineno if hasattr(k_node, "lineno") else 1, + "issue": f"Duplicate field '{key}' in manifest", + "suggestion": "Remove the duplicate entry" + }) + seen_keys.add(key) + manifest_data[key] = v_node + break # Assume top-level dict is the manifest + + required_fields = {"name", "version", "depends", "author", "category", "summary", "data", "installable", "license"} + for field in required_fields: + if field not in manifest_data: + results.append({ + "file": file_path_shorten, + "line": 1, + "issue": f"Missing required field '{field}' in manifest", + "suggestion": f"Add '{field}' to the manifest dictionary" + }) + + # Version check + version = manifest_data.get("version") + if isinstance(version, ast.Constant): + version_val = version.value + if not re.fullmatch(r"\d+\.\d+\.\d+\.\d+\.\d+", version_val): + results.append({ + "file": file_path_shorten, + "line": version.lineno if hasattr(version, "lineno") else 1, + "issue": f"Version '{version_val}' does not follow '16.0.1.0.0' format", + "suggestion": "Use 5-level semantic versioning like '16.0.1.0.0'" + }) + + # Depends check + depends = manifest_data.get("depends") + if isinstance(depends, ast.List): + for d in depends.elts: + if isinstance(d, ast.Constant) and not d.value.strip(): + results.append({ + "file": file_path_shorten, + "line": d.lineno if hasattr(d, "lineno") else 1, + "issue": "Empty string in 'depends'", + "suggestion": "Remove empty string or specify actual dependency" + }) + + # Maintainer email format check + maintainer = manifest_data.get("maintainer") + if maintainer and isinstance(maintainer, ast.Constant): + email = maintainer.value + # Use this if not worked properly: r".*<[^@]+@[^@]+\.[^@]+>" + if "@" not in email or not re.match(r"[^@]+@[^@]+\.[^@]+", email): + results.append({ + "file": file_path_shorten, + "line": maintainer.lineno if hasattr(maintainer, "lineno") else 1, + "issue": f"Invalid email format for 'maintainer': '{email}'", + "suggestion": "Use a valid email address (e.g., john@example.com)" + }) + + # Check license + valid_licenses = {"LGPL-3", "AGPL-3", "OEEL-1", "OPL-1", "GPL-3", "MIT"} + license_node = manifest_data.get("license") + if isinstance(license_node, ast.Constant): + license_val = license_node.value + if license_val not in valid_licenses: + results.append({ + "file": file_path_shorten, + "line": license_node.lineno if hasattr(license_node, "lineno") else 1, + "issue": f"License '{license_val}' is not a recognized Odoo-compatible license", + "suggestion": f"Use one of the valid licenses: {', '.join(valid_licenses)}" + }) + # Data file order check: security/ files must come first + data_node = manifest_data.get("data") + if isinstance(data_node, ast.List): + found_non_security = False + for file_node in data_node.elts: + if isinstance(file_node, ast.Constant): + file_value = file_node.value + if not file_value.startswith("security/"): + found_non_security = True + elif found_non_security: + results.append({ + "file": file_path_shorten, + "line": file_node.lineno if hasattr(file_node, "lineno") else 1, + "issue": f"'security' file '{file_value}' appears after non-security files", + "suggestion": "Move all 'security/' files to the top of the 'data' list" + }) + + +def check_transient_model_location(py_file_path): + """ + Check the transient model is in the wizard folder. + + Args: + file_path (str): Full path to the Python file. + """ + file_path_shorten = f"{module_technical_name[0]}/{py_file_path.split(f'/{module_technical_name[0]}/')[1]}" + with open(py_file_path, "r", encoding="utf-8") as f: + source = f.read() + + try: + tree = ast.parse(source) + except SyntaxError: + return + + for node in ast.walk(tree): + if isinstance(node, ast.ClassDef): + for base in node.bases: + if isinstance(base, ast.Attribute) and base.attr == "TransientModel": + if "wizard" not in py_file_path.replace("\\", "/").split("/"): + results.append({ + "file": file_path_shorten, + "line": node.lineno, + "issue": f"TransientModel class '{node.name}' is not in a 'wizard/' directory", + "suggestion": "Move the file to a 'wizard/' folder to follow Odoo best practices" + }) + + +def check_report_model_location(py_file_path): + """ + Check the report model is in the report folder. + + Args: + file_path (str): Full path to the Python file. + """ + file_path_shorten = f"{module_technical_name[0]}/{py_file_path.split(f'/{module_technical_name[0]}/')[1]}" + with open(py_file_path, "r", encoding="utf-8") as f: + source = f.read() + + try: + tree = ast.parse(source) + except SyntaxError: + return + + for node in ast.walk(tree): + if isinstance(node, ast.ClassDef): + is_abstract_model = any( + isinstance(base, ast.Attribute) and base.attr == "AbstractModel" + for base in node.bases + ) + + if is_abstract_model: + for body_item in node.body: + if isinstance(body_item, ast.Assign): + for target in body_item.targets: + if isinstance(target, ast.Name) and target.id == "_name": + if isinstance(body_item.value, ast.Constant): + model_name = body_item.value.value + if (model_name.startswith("report.") and + "report" not in py_file_path.replace("\\", "/").split("/")): + results.append({ + "file": file_path_shorten, + "line": node.lineno, + "issue": f"Report model '{model_name}' is not in a 'report/' directory", + "suggestion": "Move the report model to a 'report/' folder" + }) + + +def check_controller_naming(module_path, module_name): + """ + Check the controller file naming. + + Args: + module_path (str): Full path to the Python file. + module_name (str): Module technical name. + """ + controller_dir = os.path.join(module_path, "controllers") + file_path_shorten = f"{module_technical_name[0]}/{module_path.split(f'/{module_technical_name[0]}/')[1]}" + if not os.path.isdir(controller_dir): + return + + for filename in os.listdir(controller_dir): + filepath = os.path.join(controller_dir, filename) + + if not filename.endswith(".py"): + continue + + if filename == "main.py": + results.append({ + "file": file_path_shorten, + "line": 1, + "issue": "'main.py' is deprecated for controller files", + "suggestion": f"Rename to '{module_name}.py' or the name of the inherited module" + }) + + if filename != f"{module_name}.py" and not filename.startswith("portal") and not filename.startswith("website"): + with open(filepath, "r", encoding="utf-8") as f: + content = f.read() + if "http.Controller" in content and f"{module_name}.py" not in os.listdir(controller_dir): + results.append({ + "file": file_path_shorten, + "line": 1, + "issue": f"'{filename}' may not follow the controller naming convention", + "suggestion": f"Use '{module_name}.py' for your base controller, " + f"or name after the module you're inheriting" + }) + + +def check_compute_method_exact_naming(py_file_path): + """ + Check the compute function naming. + + Args: + file_path (str): Full path to the Python file. + """ + file_path_shorten = f"{module_technical_name[0]}/{py_file_path.split(f'/{module_technical_name[0]}/')[1]}" + with open(py_file_path, "r", encoding="utf-8") as f: + source = f.read() + try: + tree = ast.parse(source) + except SyntaxError: + return + + for node in ast.walk(tree): + if isinstance(node, ast.Assign): + for target in node.targets: + if isinstance(target, ast.Name): + field_name = target.id + for value in ast.walk(node.value): + if isinstance(value, ast.Call) and isinstance(value.func, ast.Attribute): + if value.func.attr in { + "Char", "Text", "Integer", "Float", "Boolean", "Date", "Datetime", + "Many2one", "One2many", "Many2many", "Html", "Binary", "Selection" + }: + for keyword in value.keywords: + if keyword.arg == "compute" and isinstance(keyword.value, ast.Constant): + compute_func = keyword.value.value + expected_name = f"_compute_{field_name}" + if compute_func != expected_name: + results.append({ + "file": file_path_shorten, + "line": keyword.lineno, + "issue": f"Compute method should be named '{expected_name}' for " + f"field '{field_name}', found '{compute_func}'", + "suggestion": f"Rename compute method to '{expected_name}'" + }) + + +def analyze_file(file_path): + """ + Analyze a Python file using AST and apply all naming rules. + + Args: + file_path (str): Full path to the .py file. + """ + module_name = os.path.basename(file_path.rstrip("/\\")) # Get folder name + + check_python_file_name(file_path) + check_transient_model_location(file_path) + check_report_model_location(file_path) + check_controller_naming(file_path, module_name) + check_compute_method_exact_naming(file_path) + + try: + with open(file_path, "r", encoding="utf-8") as f: + tree = ast.parse(f.read(), filename=file_path) + except SyntaxError: + return + + for node in ast.walk(tree): + if isinstance(node, ast.ClassDef): + check_class_name(node, file_path) + check_model_class(node, file_path) + check_missing_docstring(node, file_path, type="class") + elif isinstance(node, ast.FunctionDef): + check_function_name(node, file_path) + check_missing_docstring(node, file_path, type="function") + elif isinstance(node, ast.Assign): + check_field_suffix(node, file_path) + check_variable_naming(node, file_path) + + +def scan_directory(directory): + """ + Recursively scan a directory and analyze all .py files, skipping __init__.py and __manifest__.py. + + Args: + directory (str): Path to the root directory. + """ + for root, _, files in os.walk(directory): + for file in files: + if file == "__manifest__.py": + check_manifest_file(os.path.join(root, file)) + if file.endswith(".py") and file not in {"__init__.py", "__manifest__.py"}: + analyze_file(os.path.join(root, file)) + + +def check_odoo_python_standards(name, path): + """ + Perform a scan of Python files in the given directory to check for Odoo-specific coding standard violations. + + This function clears previous results, sets the current module's technical name, and recursively scans the + given directory for Python files. Each file is analyzed for adherence to Odoo's Python coding standards, + and the results are accumulated. + + Args: + name (str): The technical name of the Odoo module being checked. + path (str): The absolute or relative file path to the directory containing the module's Python code. + + Returns: + list: A list of dictionaries containing information about detected coding standard violations. + """ + results.clear() + module_technical_name[0] = name + scan_directory(path) + return results diff --git a/odoo_health_report/models/check_python_violations.cpython-310-x86_64-linux-gnu.so b/odoo_health_report/models/check_python_violations.cpython-310-x86_64-linux-gnu.so deleted file mode 100755 index 36493fe8b..000000000 Binary files a/odoo_health_report/models/check_python_violations.cpython-310-x86_64-linux-gnu.so and /dev/null differ diff --git a/odoo_health_report/models/check_violations.py b/odoo_health_report/models/check_violations.py new file mode 100644 index 000000000..6cf4d7e9c --- /dev/null +++ b/odoo_health_report/models/check_violations.py @@ -0,0 +1,316 @@ +# -*- coding: utf-8 -*- +###################################################################################### +# +# Cybrosys Technologies Pvt. Ltd. +# +# Copyright (C) 2025-TODAY Cybrosys Technologies() +# Author: Cybrosys Techno Solutions() +# +# This program is under the terms of the Odoo Proprietary License v1.0 (OPL-1) +# It is forbidden to publish, distribute, sublicense, or sell copies of the Software +# or modified copies of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NON INFRINGEMENT. +# IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +# DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, +# ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +# DEALINGS IN THE SOFTWARE. +# +###################################################################################### +import json +import re +import subprocess + +import sys + +from odoo.modules import get_module_path +from . import check_odoo_python_guidelines + + +def violations_report(self, module): + """ + Check the violations for selected modules for PDF report. + + Args: + self (obj): The odoo ClassDef. + module (str): The module technical name. + + Returns: + dict: Module name and its violations + """ + violations = get_violations(module) + report_dict = { + 'module': self.env.ref(f'base.module_{module}').display_name, + 'violations': { + "odoo_standards": violations.get('odoo_standards_check'), + "style_lint": violations.get('style_lint_check'), + "code_quality": violations.get('code_quality_check'), + "maintainability_index": violations.get('mi_check'), + "import_sort": violations.get('import_sort_check'), + "code_format": violations.get('code_format_check'), + "code_complexity": violations.get('cc_check'), + "security_scan": violations.get('security_scan') + } + } + return report_dict + +def get_violations(module): + """ + Check the violations for selected module. + + Args: + module (str): The module technical name. + + Returns: + dict: Selected module violations + """ + return { + "style_lint_check": check_style_lint(module), + "code_quality_check": check_code_quality(module), + "mi_check": check_maintainability_index(module), + "import_sort_check": check_import_sort(module), + "code_format_check": check_code_format(module), + "cc_check": check_code_complexity(module), + "security_scan": scan_code_security(module), + "odoo_standards_check": check_odoo_python_guidelines.check_odoo_python_standards( + name=module, path=get_module_path(module)) + } + +def check_style_lint(module): + """ + Check the style and lint for selected module . + + Args: + module (str): The module technical name. + + Returns: + list of dictionaries for the violations with the file name and line number. + """ + excluded_files = ['__init__.py', '__manifest__.py', '__pycache__', '*.pyc', '.git'] + ignored_violations = ['E501', 'E301', 'E302'] + + venv_python = sys.executable + exclude_param = f"--exclude={','.join(excluded_files)}" + ignore_param = f"--ignore={','.join(ignored_violations)}" + module_path = get_module_path(module) + + result = subprocess.run( + [venv_python, '-m', 'flake8', '--select=D', exclude_param, ignore_param, module_path], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + violations = result.stdout.strip().splitlines() + + violations_list = [] + for violation in violations: + parts = violation.split(":", 3) + if len(parts) >= 4: + file_path = f"{module}/{parts[0].split(f'/{module}/')[1]}" + violations_list.append({ + 'file_name': file_path, + 'line_number': parts[1], + 'violation_message': parts[3] + }) + return violations_list + +def check_code_quality(module): + """ + Check the code quality for selected module . + + Args: + module (str): The module technical name. + + Returns: + list of dictionaries for the violations with the file name, code, line and column number. + """ + pylint_result = subprocess.run([ + sys.executable, '-m', 'pylint', + '--load-plugins=pylint_odoo', + '-d all', + '-e odoolint', + '--ignore-patterns=__init__.py,__manifest__.py', + get_module_path(module) + ], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + output = pylint_result.stdout.strip().splitlines() + result = [] + for rec in output: + if rec.startswith(('*', '-')) or rec == '': + continue + else: + rec = rec.split(':') + if len(rec) >= 5: + file_path = f"{module}/{rec[0].split(f'/{module}/')[1]}" + result.append({ + 'file': file_path, + 'line': rec[1], + 'column': rec[2], + 'code': rec[3].strip(), + 'message': rec[4].strip(), + }) + return result + +def check_maintainability_index(module): + """ + Check the maintainability index for selected module . + + Args: + module (str): The module technical name. + + Returns: + list of dictionaries for the file name and grade. + """ + result = subprocess.run([ + sys.executable, '-m', 'radon', + 'mi', + get_module_path(module), + ], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + excluded_files = ("__init__.py", "__manifest__.py") + mi_list = [] + for rec in result.stdout.splitlines(): + if not any(exclude in rec for exclude in excluded_files): + rec = rec.split(' - ') + if len(rec) >= 2: + file_path = f"{module}/{rec[0].split(f'/{module}/')[1]}" + mi_list.append({'file': file_path, 'grade': rec[1]}) + return mi_list + +def check_import_sort(module): + """ + Check the import sorting for selected module . + + Args: + module (str): The module technical name. + + Returns: + list of dictionaries for the file name and message. + """ + result = subprocess.run([ + sys.executable, '-m', 'isort', + '--check-only', + '--skip', '__init__.py', + '--skip', '__manifest__.py', + get_module_path(module), + ], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + import_sort_list = [] + for rec in result.stderr.splitlines(): + rec = rec.replace("ERROR: ", "") + parts = rec.split(" ", 1) + if len(parts) >= 2: + file_path = f"{module}/{parts[0].split(f'/{module}/')[1]}" + import_sort_list.append({'file': file_path, 'message': parts[1]}) + return import_sort_list + +def parse_black_output_simple(line): + """Simple parsing for all Black output types""" + actions = ['would reformat ', 'reformatted ', 'formatted ', 'would format '] + for action in actions: + if line.startswith(action): + filename = line[len(action):] + return action.strip(), filename + return None, None + +def is_file_line(line): + """Check if line is a file entry (not summary)""" + skip_patterns = ['💥', '💔', '!', 'files would be', 'file would be', 'All done'] + actions = ['would reformat ', 'reformatted ', 'formatted ', 'would format '] + if any(pattern in line for pattern in skip_patterns): + return False + return any(line.startswith(action) for action in actions) + +def check_code_format(module): + """ + Check the code format for selected module . + + Args: + module (str): The module technical name. + + Returns: + list of dictionaries for the file name and action. + """ + result = subprocess.run([ + sys.executable, '-m', 'black', + '--check', + '--exclude', '__init__.py|__manifest__.py', + get_module_path(module), + ], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + + code_format_list = [] + for line in result.stderr.strip().split('\n'): + line = line.strip() + if not line or not is_file_line(line): + continue + parsed = parse_black_output_simple(line) + if parsed[0] is not None: + code_format_list.append(parsed) + + return code_format_list + +def check_code_complexity(module): + """ + Check the code complexity for selected module . + + Args: + module (str): The module technical name. + + Returns: + list of dictionaries for the file name and grade. + """ + result = subprocess.run([ + sys.executable, '-m', 'radon', + 'cc', + '--json', + get_module_path(module), + ], + capture_output=True, + text=True + ) + + if result.returncode == 0 and result.stdout: + clean_stdout = re.sub(r'\x1b\[[0-9;]*m', '', result.stdout) + cc_dict = json.loads(clean_stdout.strip()) + return cc_dict + return {} + +def scan_code_security(module): + """ + Scan the code security for selected module . + + Args: + module (str): The module technical name. + + Returns: + list of dictionaries of the security scan. + """ + result = subprocess.run([ + sys.executable, '-m', 'bandit', + '-r', get_module_path(module), + '-x', '__init__.py,__manifest__.py', + '-f', 'json' + ], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True) + + if result.stdout: + security_list = json.loads(result.stdout).get('results', []) + return security_list + return [] diff --git a/odoo_health_report/models/module_quality_package.py b/odoo_health_report/models/module_quality_package.py index acc870931..abb754064 100644 --- a/odoo_health_report/models/module_quality_package.py +++ b/odoo_health_report/models/module_quality_package.py @@ -19,9 +19,14 @@ # DEALINGS IN THE SOFTWARE. # ###################################################################################### +import os +from collections import defaultdict, Counter +from pathlib import Path from odoo import api, models -from .check_python_violations import installed_modules, count_module_lines, module_and_icons, violations_report, get_violations +from odoo import modules +from odoo.modules import get_modules, get_module_path +from . import check_violations class ModuleQuality(models.AbstractModel): @@ -31,12 +36,49 @@ class ModuleQuality(models.AbstractModel): def get_installed_modules(self): """Function to fetch installed modules""" - return installed_modules(self) + return self.env['ir.module.module'].search([ + ('state', '=', 'installed'), + ('name', '!=', 'odoo_health_report') + ]) @api.model def count_lines_of_code_in_modules(self): """Count lines of Python, JavaScript, and XML code in all installed Odoo modules.""" - return count_module_lines(self) + module_ids = self.get_installed_modules() + total_lines = Counter() + exclude_files = {'__init__.py', '__manifest__.py'} + result = defaultdict(list) + + for module in module_ids: + module_name = module.name + author = module.author + module_path = get_module_path(module_name) + if not module_path: + continue + loc = Counter({'.py': 0, '.js': 0, '.xml': 0}) + for ext in loc: + for file in Path(module_path).rglob(f'*{ext}'): + if file.name in exclude_files: + continue + try: + with file.open('r', encoding='utf-8', errors='ignore') as f: + loc[ext] += sum(1 for _ in f) + except (IOError, UnicodeDecodeError): + continue + + result[author].append({ + 'technical_name': module_name, + 'module_name': module.display_name, + 'py_lines': loc['.py'], + 'js_lines': loc['.js'], + 'xml_lines': loc['.xml'] + }) + total_lines.update({ + 'py_lines': loc['.py'], + 'js_lines': loc['.js'], + 'xml_lines': loc['.xml'] + }) + return {'result': dict(result), 'total_lines': dict(total_lines)} @api.model def fields_and_apps_overview(self): @@ -79,14 +121,26 @@ class ModuleQuality(models.AbstractModel): @api.model def get_module_and_icons(self): """Retrieve all custom module name and icon as a dictionary""" - return module_and_icons(self) + module_and_icon = {} + is_updated = self.env['base.module.update'].search([], limit=1, order='id desc') + if not is_updated: + is_updated = self.env['base.module.update'].create({}) + is_updated.update_module() + + for module in get_modules(): + module_path = get_module_path(module) + if 'addons' not in module_path.split(os.sep) and module != 'odoo_health_report': + module_name = self.env.ref(f'base.module_{module}').display_name + module_icon = modules.module.get_module_icon(module) + module_and_icon[module] = [module_name, module_icon] + return module_and_icon @api.model def check_violations_report(self, module): """Check the violations for PDF report""" - return violations_report(self, module) + return check_violations.violations_report(self, module) @api.model def check_violations(self, module): """Check the violations and standards""" - return get_violations(module) \ No newline at end of file + return check_violations.get_violations(module) \ No newline at end of file diff --git a/odoo_health_report/reports/odoo_health_report.xml b/odoo_health_report/reports/odoo_health_report.xml index ca77924e1..5a2bbabc2 100644 --- a/odoo_health_report/reports/odoo_health_report.xml +++ b/odoo_health_report/reports/odoo_health_report.xml @@ -125,26 +125,126 @@

Violations of

+ + +

+ Odoo Code Standards Check +

+ + + + + + + + + + + + +
File NameLineViolation MessageSuggestion
+ + + +

+
+ No issues found
+
- +

Style & Linting Check

- - - - - - - - - - -
File NameLineViolation Message
- - -

+ + + + + + + + + + + +
File NameLineViolation Message
+ + +

+
+ No issues found
+
+ + + +

+ General Code Quality +

+ + + + + + + + + + + + + +
File NameLineColumnCodeViolation Message
+ + + + +

+
+ No issues found
+
+ + + +

+ Maintainability Index +

+ + + + + + + + + + +
File NameGrade
+ +

+
+ No issues found
+
+ + + +

+ Import Sorting Check +

+ + + + + + + + + + +
File NameMessage
+ +

+
+ No issues found
@@ -152,20 +252,92 @@

Code Formatting Check

- - - - - - - - - -
File NameAction
- -

+ + + + + + + + + + +
File NameAction
+ +

+
+ No issues found
+ + +

+ Code Complexity +

+ + +

File:

+ + + + + + + + + + + + + + + + +
TypeNameClass NameLine No.End LineComplexityRank
+ + + + + + + +

+
+ No issues found
+
+
+ + + +

+ Security Scan +

+ + + + + + + + + + + + + + + +
Test IDTest NameSeverityConfidenceIssue TextFileLine
+ + + + + + +

+
+ No issues found

+
diff --git a/odoo_health_report/static/src/xml/module_quality.xml b/odoo_health_report/static/src/xml/module_quality.xml index 1fee0da50..bba2c932c 100644 --- a/odoo_health_report/static/src/xml/module_quality.xml +++ b/odoo_health_report/static/src/xml/module_quality.xml @@ -180,6 +180,33 @@ + +
+

Odoo Code Standards Check for:

+ + + + + + + + + + + + + + + + +
FileLineIssueSuggestion
+ + +
+ + No issues found + +
@@ -209,6 +236,86 @@
+ +
+

General Code Quality for:

+ + + + + + + + + + + + + + + + + +
File NameLineColumnCodeViolation Message
+ + + + + +
+ + No issues found + +
+ + +
+

Maintainability Index for:

+ + + + + + + + + + + + + +
File NameGrade
+ +
+ + No issues found + +
+ + +
+

Import Sorting Check for:

+ + + + + + + + + + + + + +
File NameMessage
+ +
+ + No issues found + +
+

Code Formatting Check for:

@@ -228,8 +335,82 @@ + + No issues found + +
+ + +
+ +

Code Complexity for:

+ +
File:
+ + + + + + + + + + + + + + + + + + + +
TypeNameClass NameLine No.End LineComplexityRank
+ + + + + +
+ + No issues found + +
+ +
+

Security Scan for:

+ + + + + + + + + + + + + + + + + + +
Test IDTest NameSeverityConfidenceIssue TextFileLine
+ + + + + + +
+ + No issues found + +