From 32589eeed729a744c97475657ed85b77727a1209 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 30 Nov 2025 15:49:56 +0000 Subject: [PATCH] feat: Add scripts to fix _sql_constraints warnings Co-authored-by: jeremi --- FIX_SQL_CONSTRAINTS.md | 94 ++++++++++ find_and_fix_sql_constraints.sh | 43 +++++ fix_sql_constraints.py | 302 ++++++++++++++++++++++++++++++++ migrate_sql_constraints.py | 245 ++++++++++++++++++++++++++ run_fix_sql_constraints.sh | 53 ++++++ 5 files changed, 737 insertions(+) create mode 100644 FIX_SQL_CONSTRAINTS.md create mode 100755 find_and_fix_sql_constraints.sh create mode 100644 fix_sql_constraints.py create mode 100755 migrate_sql_constraints.py create mode 100755 run_fix_sql_constraints.sh diff --git a/FIX_SQL_CONSTRAINTS.md b/FIX_SQL_CONSTRAINTS.md new file mode 100644 index 0000000..dd7d197 --- /dev/null +++ b/FIX_SQL_CONSTRAINTS.md @@ -0,0 +1,94 @@ +# Fixing _sql_constraints Warnings in Odoo 19 + +## Problem +Odoo 19 has deprecated the `_sql_constraints` attribute and requires using `model.Constraint` decorators instead. + +**Warning message:** +``` +Model attribute '_sql_constraints' is no longer supported, please define model.Constraint on the model. +``` + +## Solution + +Two scripts have been created to help fix this issue: + +### 1. `migrate_sql_constraints.py` (Recommended) +A simpler regex-based script that finds and converts `_sql_constraints` patterns. + +### 2. `fix_sql_constraints.py` +A more complex AST-based script with additional features. + +## Usage + +### When addons are available (inside Docker container or after cloning): + +```bash +# Dry run to see what would be fixed +python3 /workspace/migrate_sql_constraints.py --path /opt/odoo/custom/src --dry-run + +# Actually fix the files +python3 /workspace/migrate_sql_constraints.py --path /opt/odoo/custom/src +``` + +### From host machine (if addons are mounted): + +```bash +python3 /workspace/migrate_sql_constraints.py --path /workspace/odoo/custom/src +``` + +## Conversion Pattern + +### Old Format (Odoo 18 and earlier): +```python +_sql_constraints = [ + ('name_unique', 'UNIQUE(name)', 'Name must be unique!'), + ('code_company_unique', 'UNIQUE(code, company_id)', 'Code must be unique per company!'), +] +``` + +### New Format (Odoo 19): +```python +from odoo import models +from odoo.exceptions import ValidationError + +@models.constraint('name') +def _check_name_unique(self): + for record in self: + if self.search_count([ + ('name', '=', record.name), + ('id', '!=', record.id) + ]) > 0: + raise ValidationError('Name must be unique!') + +@models.constraint('code', 'company_id') +def _check_code_company_unique(self): + for record in self: + if self.search_count([ + ('code', '=', record.code), + ('company_id', '=', record.company_id.id), + ('id', '!=', record.id) + ]) > 0: + raise ValidationError('Code must be unique per company!') +``` + +## Finding Files Manually + +To find files with `_sql_constraints`: + +```bash +find /opt/odoo/custom/src -name "*.py" -exec grep -l "_sql_constraints" {} \; +``` + +## Notes + +- The scripts automatically add required imports (`from odoo import models` and `from odoo.exceptions import ValidationError`) +- CHECK constraints may need manual conversion as SQL expressions need to be translated to Python +- Always test after applying fixes +- Make sure to commit changes to the appropriate addon repositories + +## Troubleshooting + +If scripts don't find files: +1. Ensure addon repositories are cloned (check `/opt/odoo/custom/src/` or `/workspace/odoo/custom/src/`) +2. Run from inside the Docker container if addons are only available there +3. Check that the path includes the addon directories diff --git a/find_and_fix_sql_constraints.sh b/find_and_fix_sql_constraints.sh new file mode 100755 index 0000000..2cda46c --- /dev/null +++ b/find_and_fix_sql_constraints.sh @@ -0,0 +1,43 @@ +#!/bin/bash +# Script to find and fix _sql_constraints in Odoo addons +# This script searches for _sql_constraints and helps convert them to model.Constraint + +set -e + +SEARCH_PATH="${1:-/opt/odoo/custom/src}" +DRY_RUN="${2:-false}" + +echo "Searching for _sql_constraints in: $SEARCH_PATH" + +# Find all Python files with _sql_constraints +files=$(find "$SEARCH_PATH" -type f -name "*.py" -exec grep -l "_sql_constraints" {} \; 2>/dev/null || true) + +if [ -z "$files" ]; then + echo "No files with _sql_constraints found in $SEARCH_PATH" + echo "Trying alternative locations..." + # Try common Odoo paths + for alt_path in "/workspace/odoo/custom/src" "/opt/odoo" "/usr/lib/python3*/dist-packages/odoo/addons"; do + if [ -d "$alt_path" ]; then + files=$(find "$alt_path" -type f -name "*.py" -exec grep -l "_sql_constraints" {} \; 2>/dev/null || true) + if [ -n "$files" ]; then + echo "Found files in: $alt_path" + break + fi + fi + done +fi + +if [ -z "$files" ]; then + echo "No files found. The addons may need to be pulled/cloned first." + echo "Run this script from within a Docker container or after cloning addon repos." + exit 0 +fi + +echo "Found files with _sql_constraints:" +echo "$files" | while read -r file; do + echo " - $file" +done + +echo "" +echo "To fix these files, run:" +echo " python3 /workspace/fix_sql_constraints.py --path $SEARCH_PATH" diff --git a/fix_sql_constraints.py b/fix_sql_constraints.py new file mode 100644 index 0000000..950ec3c --- /dev/null +++ b/fix_sql_constraints.py @@ -0,0 +1,302 @@ +#!/usr/bin/env python3 +""" +Script to migrate _sql_constraints to model.Constraint in Odoo 19. + +This script searches for deprecated _sql_constraints and converts them to +the new model.Constraint format. + +Usage: + python3 fix_sql_constraints.py [--path PATH] [--dry-run] +""" + +import ast +import argparse +import os +import re +import sys +from pathlib import Path +from typing import List, Tuple, Optional + + +class SQLConstraintVisitor(ast.NodeVisitor): + """AST visitor to find _sql_constraints definitions.""" + + def __init__(self): + self.constraints = [] + self.current_class = None + + def visit_ClassDef(self, node): + old_class = self.current_class + self.current_class = node.name + self.generic_visit(node) + self.current_class = old_class + + def visit_Assign(self, node): + for target in node.targets: + if isinstance(target, ast.Name) and target.id == '_sql_constraints': + if isinstance(node.value, ast.List): + constraints = [] + for elt in node.value.elts: + if isinstance(elt, ast.Tuple) and len(elt.elts) >= 2: + constraint_name = None + constraint_sql = None + constraint_message = None + + if len(elt.elts) >= 1 and isinstance(elt.elts[0], ast.Constant): + constraint_name = elt.elts[0].value + if len(elt.elts) >= 2 and isinstance(elt.elts[1], ast.Constant): + constraint_sql = elt.elts[1].value + if len(elt.elts) >= 3 and isinstance(elt.elts[2], ast.Constant): + constraint_message = elt.elts[2].value + + if constraint_name and constraint_sql: + self.constraints.append({ + 'class': self.current_class, + 'name': constraint_name, + 'sql': constraint_sql, + 'message': constraint_message or '', + 'node': node, + }) + self.generic_visit(node) + + +def parse_sql_constraint(sql: str) -> dict: + """Parse SQL constraint to extract constraint type and fields.""" + sql = sql.strip().upper() + + # UNIQUE constraint + unique_match = re.match(r'UNIQUE\s*\((.*?)\)', sql) + if unique_match: + fields = [f.strip().strip('"\'') for f in unique_match.group(1).split(',')] + return {'type': 'unique', 'fields': fields} + + # CHECK constraint + check_match = re.match(r'CHECK\s*\((.*?)\)', sql, re.DOTALL) + if check_match: + return {'type': 'check', 'expression': check_match.group(1)} + + return {'type': 'unknown', 'sql': sql} + + +def generate_constraint_code(constraint: dict, parsed: dict) -> str: + """Generate the new model.Constraint code.""" + constraint_name = constraint['name'] + constraint_message = constraint['message'] or f"Constraint violation: {constraint_name}" + + if parsed['type'] == 'unique': + fields = parsed['fields'] + field_params = ', '.join(f"'{f}'" for f in fields) + + # Build search domain for uniqueness check + domain_parts = [] + for field in fields: + # Handle related fields (e.g., company_id.id) + if '.' in field: + field_parts = field.split('.') + domain_parts.append(f"('{field_parts[0]}', '=', record.{field})") + else: + domain_parts.append(f"('{field}', '=', record.{field})") + + domain_str = ',\n '.join(domain_parts) + + code = f""" @models.constraint({field_params}) + def _{constraint_name}(self): + for record in self: + if self.search_count([ + {domain_str}, + ('id', '!=', record.id) + ]) > 0: + raise ValidationError({repr(constraint_message)}) +""" + return code + + elif parsed['type'] == 'check': + # For CHECK constraints, we need to convert SQL expression to Python + # This is more complex and may need manual adjustment + code = f""" @models.constraint() + def _{constraint_name}(self): + for record in self: + # TODO: Convert SQL CHECK expression to Python validation + # Original SQL: {parsed['expression']} + # raise ValidationError({repr(constraint_message)}) + pass +""" + return code + + else: + # Unknown constraint type - keep as comment for manual conversion + code = f""" # TODO: Convert this constraint manually + # Original: {constraint['sql']} + # @models.constraint() + # def _{constraint_name}(self): + # raise ValidationError({repr(constraint_message)}) +""" + return code + + +def fix_file(file_path: Path, dry_run: bool = False) -> Tuple[bool, List[str]]: + """Fix _sql_constraints in a Python file.""" + try: + content = file_path.read_text(encoding='utf-8') + except Exception as e: + return False, [f"Error reading file: {e}"] + + # Parse AST + try: + tree = ast.parse(content) + except SyntaxError as e: + return False, [f"Syntax error in file: {e}"] + + # Find constraints + visitor = SQLConstraintVisitor() + visitor.visit(tree) + + if not visitor.constraints: + return False, [] + + # Check if models is imported + has_models_import = 'from odoo import models' in content or 'import odoo.models as models' in content + has_validation_error = 'from odoo.exceptions import ValidationError' in content or 'ValidationError' in content + + # Generate fixes + fixes = [] + imports_needed = [] + + if not has_models_import: + imports_needed.append("from odoo import models") + if not has_validation_error: + imports_needed.append("from odoo.exceptions import ValidationError") + + # Build replacement code + lines = content.split('\n') + new_lines = [] + i = 0 + skip_until = -1 + + while i < len(lines): + # Check if this line is part of a _sql_constraints assignment + line = lines[i] + + # Find the _sql_constraints assignment + constraint_found = False + for constraint in visitor.constraints: + node = constraint['node'] + if node.lineno - 1 == i: + constraint_found = True + # Skip the original _sql_constraints assignment + # Find where it ends (multiline list) + start_line = i + paren_count = 0 + bracket_count = 0 + in_string = False + string_char = None + + j = i + while j < len(lines): + for char in lines[j]: + if char in ('"', "'") and (j == i or lines[j][max(0, lines[j].index(char)-1)] != '\\'): + if not in_string: + in_string = True + string_char = char + elif char == string_char: + in_string = False + string_char = None + elif not in_string: + if char == '(': + paren_count += 1 + elif char == ')': + paren_count -= 1 + elif char == '[': + bracket_count += 1 + elif char == ']': + bracket_count -= 1 + + if bracket_count == 0 and paren_count == 0 and not in_string: + skip_until = j + break + j += 1 + + # Generate new constraint code + parsed = parse_sql_constraint(constraint['sql']) + constraint_code = generate_constraint_code(constraint, parsed) + new_lines.append(constraint_code) + break + + if i <= skip_until: + i += 1 + if i > skip_until: + skip_until = -1 + continue + + new_lines.append(line) + i += 1 + + # Add imports if needed + if imports_needed and not dry_run: + # Find the best place to add imports (after existing odoo imports) + import_insert_pos = 0 + for i, line in enumerate(new_lines): + if line.startswith('from odoo') or line.startswith('import odoo'): + import_insert_pos = i + 1 + break + + for imp in imports_needed: + if imp not in '\n'.join(new_lines): + new_lines.insert(import_insert_pos, imp) + import_insert_pos += 1 + + if dry_run: + fixes.append(f"Would fix {len(visitor.constraints)} constraint(s) in {file_path}") + for constraint in visitor.constraints: + parsed = parse_sql_constraint(constraint['sql']) + fixes.append(f" - {constraint['name']}: {constraint['sql']} -> {parsed['type']}") + else: + new_content = '\n'.join(new_lines) + file_path.write_text(new_content, encoding='utf-8') + fixes.append(f"Fixed {len(visitor.constraints)} constraint(s) in {file_path}") + + return True, fixes + + +def main(): + parser = argparse.ArgumentParser(description='Fix _sql_constraints in Odoo 19') + parser.add_argument('--path', default='.', help='Path to search for Python files') + parser.add_argument('--dry-run', action='store_true', help='Show what would be fixed without making changes') + args = parser.parse_args() + + path = Path(args.path) + if not path.exists(): + print(f"Error: Path {path} does not exist", file=sys.stderr) + sys.exit(1) + + # Find all Python files + python_files = list(path.rglob('*.py')) + + if not python_files: + print(f"No Python files found in {path}") + return + + print(f"Searching {len(python_files)} Python files for _sql_constraints...") + + fixed_count = 0 + all_fixes = [] + + for py_file in python_files: + # Skip __pycache__ and virtual environments + if '__pycache__' in str(py_file) or 'venv' in str(py_file) or '.venv' in str(py_file): + continue + + changed, fixes = fix_file(py_file, dry_run=args.dry_run) + if changed: + fixed_count += 1 + all_fixes.extend(fixes) + + if all_fixes: + print("\n".join(all_fixes)) + print(f"\n{'Would fix' if args.dry_run else 'Fixed'} {fixed_count} file(s)") + else: + print("No _sql_constraints found") + + +if __name__ == '__main__': + main() diff --git a/migrate_sql_constraints.py b/migrate_sql_constraints.py new file mode 100755 index 0000000..c345ca8 --- /dev/null +++ b/migrate_sql_constraints.py @@ -0,0 +1,245 @@ +#!/usr/bin/env python3 +""" +Simple script to migrate _sql_constraints to model.Constraint decorators in Odoo 19. + +This script uses regex to find and convert _sql_constraints patterns. +""" + +import re +import sys +from pathlib import Path +from typing import List, Tuple + + +def parse_constraints(content: str) -> List[dict]: + """Extract _sql_constraints from Python code.""" + constraints = [] + + # Pattern to match _sql_constraints = [ ... ] + pattern = r'_sql_constraints\s*=\s*\[(.*?)\]' + + # Match multiline constraints + matches = re.finditer(pattern, content, re.DOTALL) + + for match in matches: + constraints_text = match.group(1) + # Extract individual constraint tuples: ('name', 'SQL', 'message') + constraint_pattern = r"\(['\"]([^'\"]+)['\"],\s*['\"]([^'\"]+)['\"],\s*['\"]([^'\"]*)['\"]?\)" + constraint_matches = re.finditer(constraint_pattern, constraints_text) + + for cm in constraint_matches: + constraints.append({ + 'name': cm.group(1), + 'sql': cm.group(2), + 'message': cm.group(3) if len(cm.groups()) > 2 else '', + 'start': match.start(), + 'end': match.end(), + }) + + return constraints + + +def parse_sql_constraint(sql: str) -> dict: + """Parse SQL constraint to extract type and fields.""" + sql = sql.strip().upper() + + # UNIQUE constraint + unique_match = re.match(r'UNIQUE\s*\((.*?)\)', sql, re.IGNORECASE) + if unique_match: + fields_str = unique_match.group(1) + # Split by comma and clean up + fields = [f.strip().strip('"\'') for f in fields_str.split(',')] + return {'type': 'unique', 'fields': fields} + + # CHECK constraint + check_match = re.match(r'CHECK\s*\((.*?)\)', sql, re.IGNORECASE | re.DOTALL) + if check_match: + return {'type': 'check', 'expression': check_match.group(1)} + + return {'type': 'unknown', 'sql': sql} + + +def generate_constraint_method(constraint: dict, parsed: dict, indent: str = " ") -> str: + """Generate Python method code for the constraint.""" + constraint_name = constraint['name'] + constraint_message = constraint['message'] or f"Constraint violation: {constraint_name}" + + if parsed['type'] == 'unique': + fields = parsed['fields'] + field_params = ', '.join(f"'{f}'" for f in fields) + + # Build domain parts + domain_parts = [] + for field in fields: + if '.' in field: + # Handle related fields like company_id.id + base_field = field.split('.')[0] + domain_parts.append(f"{indent} ('{base_field}', '=', record.{field})") + else: + domain_parts.append(f"{indent} ('{field}', '=', record.{field})") + + domain_str = ',\n'.join(domain_parts) + + code = f"""{indent}@models.constraint({field_params}) +{indent}def _{constraint_name}(self): +{indent} for record in self: +{indent} if self.search_count([ +{domain_str}, +{indent} ('id', '!=', record.id) +{indent} ]) > 0: +{indent} raise ValidationError({repr(constraint_message)}) +""" + return code + + elif parsed['type'] == 'check': + # CHECK constraints need manual conversion + code = f"""{indent}@models.constraint() +{indent}def _{constraint_name}(self): +{indent} for record in self: +{indent} # TODO: Convert SQL CHECK expression to Python validation +{indent} # Original SQL: {parsed['expression']} +{indent} # raise ValidationError({repr(constraint_message)}) +{indent} pass +""" + return code + + else: + # Unknown - leave as comment + code = f"""{indent}# TODO: Convert this constraint manually +{indent}# Original: {constraint['sql']} +{indent}# @models.constraint() +{indent}# def _{constraint_name}(self): +{indent}# raise ValidationError({repr(constraint_message)}) +""" + return code + + +def fix_file(file_path: Path, dry_run: bool = False) -> Tuple[bool, List[str]]: + """Fix _sql_constraints in a file.""" + try: + content = file_path.read_text(encoding='utf-8') + except Exception as e: + return False, [f"Error reading {file_path}: {e}"] + + constraints = parse_constraints(content) + + if not constraints: + return False, [] + + # Check imports + needs_models = 'from odoo import models' not in content and 'import odoo.models' not in content + needs_validation = 'from odoo.exceptions import ValidationError' not in content and 'ValidationError' not in content + + if dry_run: + fixes = [f"Would fix {len(constraints)} constraint(s) in {file_path}"] + for c in constraints: + parsed = parse_sql_constraint(c['sql']) + fixes.append(f" - {c['name']}: {c['sql']} -> {parsed['type']}") + return True, fixes + + # Replace constraints (work backwards to preserve positions) + new_content = content + imports_to_add = [] + + # Replace from end to start to preserve positions + for constraint in reversed(constraints): + parsed = parse_sql_constraint(constraint['sql']) + method_code = generate_constraint_method(constraint, parsed) + + # Find the _sql_constraints assignment and replace it + pattern = r'_sql_constraints\s*=\s*\[.*?\]' + match = re.search(pattern, new_content, re.DOTALL) + if match: + # Replace with method code + new_content = new_content[:match.start()] + method_code + new_content[match.end():] + + # Add imports if needed + if needs_models or needs_validation: + # Find import section + import_pattern = r'(^from odoo import.*?$|^import odoo.*?$)' + imports = re.findall(import_pattern, new_content, re.MULTILINE) + + if imports: + # Add after last import + last_import_pos = new_content.rfind(imports[-1]) + len(imports[-1]) + new_imports = [] + if needs_models: + new_imports.append("from odoo import models") + if needs_validation: + new_imports.append("from odoo.exceptions import ValidationError") + + new_content = ( + new_content[:last_import_pos] + + '\n' + '\n'.join(new_imports) + + new_content[last_import_pos:] + ) + else: + # Add at the top after any shebang or encoding + lines = new_content.split('\n') + insert_pos = 0 + if lines and lines[0].startswith('#!'): + insert_pos = 1 + if lines[insert_pos].startswith('#') and 'coding' in lines[insert_pos].lower(): + insert_pos = 2 + + new_imports = [] + if needs_models: + new_imports.append("from odoo import models") + if needs_validation: + new_imports.append("from odoo.exceptions import ValidationError") + + lines.insert(insert_pos, '\n'.join(new_imports)) + new_content = '\n'.join(lines) + + # Write back + try: + file_path.write_text(new_content, encoding='utf-8') + return True, [f"Fixed {len(constraints)} constraint(s) in {file_path}"] + except Exception as e: + return False, [f"Error writing {file_path}: {e}"] + + +def main(): + import argparse + + parser = argparse.ArgumentParser(description='Migrate _sql_constraints to model.Constraint') + parser.add_argument('--path', default='.', help='Path to search') + parser.add_argument('--dry-run', action='store_true', help='Show what would be fixed') + args = parser.parse_args() + + path = Path(args.path) + if not path.exists(): + print(f"Error: {path} does not exist", file=sys.stderr) + sys.exit(1) + + # Find Python files + python_files = list(path.rglob('*.py')) + + if not python_files: + print(f"No Python files found in {path}") + return + + print(f"Searching {len(python_files)} files for _sql_constraints...") + + fixed_count = 0 + all_fixes = [] + + for py_file in python_files: + # Skip cache and venv + if '__pycache__' in str(py_file) or any(x in str(py_file) for x in ['/venv/', '/.venv/', '/env/']): + continue + + changed, fixes = fix_file(py_file, dry_run=args.dry_run) + if changed: + fixed_count += 1 + all_fixes.extend(fixes) + + if all_fixes: + print('\n'.join(all_fixes)) + print(f"\n{'Would fix' if args.dry_run else 'Fixed'} {fixed_count} file(s)") + else: + print("No _sql_constraints found") + + +if __name__ == '__main__': + main() diff --git a/run_fix_sql_constraints.sh b/run_fix_sql_constraints.sh new file mode 100755 index 0000000..e400ea4 --- /dev/null +++ b/run_fix_sql_constraints.sh @@ -0,0 +1,53 @@ +#!/bin/bash +# Wrapper script to fix _sql_constraints warnings +# This can be run from host or inside Docker container + +set -e + +# Default paths to check +PATHS=( + "/opt/odoo/custom/src" + "/workspace/odoo/custom/src" + "/workspace/odoo/custom/src/openspp_modules" + "/workspace/odoo/custom/src/muk_addons" +) + +DRY_RUN="${1:-}" + +echo "Searching for addon directories with _sql_constraints..." + +FOUND_PATH="" +for path in "${PATHS[@]}"; do + if [ -d "$path" ]; then + # Check if this path has Python files with _sql_constraints + if find "$path" -name "*.py" -exec grep -l "_sql_constraints" {} \; 2>/dev/null | head -1 | grep -q .; then + FOUND_PATH="$path" + echo "Found addons with _sql_constraints in: $path" + break + fi + fi +done + +if [ -z "$FOUND_PATH" ]; then + echo "No addon directories with _sql_constraints found in:" + for path in "${PATHS[@]}"; do + echo " - $path" + done + echo "" + echo "The addons may need to be cloned first, or you may need to run this" + echo "from inside a Docker container where addons are available." + echo "" + echo "To run manually:" + echo " python3 /workspace/migrate_sql_constraints.py --path " + exit 0 +fi + +if [ "$DRY_RUN" = "--dry-run" ] || [ "$DRY_RUN" = "-n" ]; then + echo "Running in dry-run mode..." + python3 /workspace/migrate_sql_constraints.py --path "$FOUND_PATH" --dry-run +else + echo "Fixing _sql_constraints in: $FOUND_PATH" + python3 /workspace/migrate_sql_constraints.py --path "$FOUND_PATH" + echo "" + echo "Done! Please test your changes and restart Odoo to verify warnings are gone." +fi