Odoo · Validación · Odoo 19
Validar datos en Odoo no es opcional: evita registros huérfanos, duplicados y estados imposibles. En Odoo 19 conviven @api.constrains (Python) y models.Constraint (SQL). Saber cuándo usar cada uno — y migrar desde _sql_constraints — es clave al actualizar módulos.
Panorama
Dos capas de validación
| Mecanismo | Dónde corre | Ideal para | Mensaje al usuario |
|---|---|---|---|
@api.constrains | Python / ORM | Reglas de negocio, condiciones complejas | ValidationError traducible |
models.Constraint | PostgreSQL | Unicidad, CHECK simples | Mensaje definido en la constraint |
required=True | ORM + BD | Campo obligatorio | Validación de formulario |
Regla práctica: unicidad e integridad estructural → SQL (
models.Constraint). Lógica de negocio que cruza varios campos o modelos →@api.constrains.
Python
@api.constrains — validación en Python
from odoo import api, fields, models
from odoo.exceptions import ValidationError
class ProjectAgileSprint(models.Model):
_name = 'project.agile.sprint'
_description = 'Agile Sprint'
date_start = fields.Date(required=True)
date_end = fields.Date(required=True)
@api.constrains('date_start', 'date_end')
def _check_dates(self):
for sprint in self:
if sprint.date_end < sprint.date_start:
raise ValidationError(
'End date must be on or after start date.'
)
Características importantes:
- Se ejecuta en
createywritecuando cambian los campos listados - Solo se dispara si esos campos están en
vals(en write parcial) - Puede leer otros registros con
search— cuidado con el rendimiento - Lanza
ValidationErrorcon mensaje en inglés (traducible víai18n)
constrains para unicidad global: dos peticiones simultáneas pueden pasar la validación Python y colarse antes del commit. Para «solo uno activo», combina constraint SQL + Python.
Odoo 19
models.Constraint — el sucesor de _sql_constraints
Desde Odoo 19, declara constraints SQL como atributos de clase en lugar de la tupla _sql_constraints:
# Odoo 19 ✅
class PickupZone(models.Model):
_name = 'pickup.zone'
zip_code = fields.Char(required=True)
_zone_zip_unique = models.Constraint(
'unique(zip_code)',
'Zip code must be unique.',
)
# Odoo 17/18 ❌ (deprecado en 19)
_sql_constraints = [
('zone_zip_unique', 'unique(zip_code)', 'Zip code must be unique.'),
]
Ventajas del nuevo estilo:
- Nombre de atributo legible (
_zone_zip_unique) - Mejor soporte en herramientas y migraciones
- Misma semántica SQL:
UNIQUE,CHECK, etc.
IntegrityError en logs suele apuntar a una constraint SQL, no a un constrains Python.Migración
Migrar de _sql_constraints a Odoo 19
| Antes (≤18) | Después (19) |
|---|---|
_sql_constraints = [('name_uniq', 'unique(name)', 'Msg')] |
_name_uniq = models.Constraint('unique(name)', 'Msg') |
('dates_check', 'check(end >= start)', 'Msg') |
_dates_check = models.Constraint('check(end >= start)', 'Msg') |
- Renombra cada tupla a un atributo
_descripcionúnico en el modelo - Elimina la lista
_sql_constraints - Actualiza el módulo (
-u mi_modulo) en entorno de prueba - Verifica en PostgreSQL que las constraints antiguas no duplican las nuevas
- Añade test que intente violar la regla y espere error
💡 CHECK vs constrains
CHECK (amount >= 0) en SQL es rápido y seguro ante concurrencia. Si la condición necesita leer otro modelo (res.company, configuración), quédate con @api.constrains.Unicidad
Patrones de unicidad
# Unicidad simple
_code_unique = models.Constraint(
'unique(code)',
'Code must be unique.',
)
# Unicidad compuesta (varios campos)
_project_dates_unique = models.Constraint(
'unique(project_id, date_start)',
'Only one sprint per project and start date.',
)
# Unicidad condicional → mejor en Python o índice parcial (avanzado)
@api.constrains('active', 'partner_id')
def _check_one_active_contract(self):
active = self.search([
('partner_id', '=', self.partner_id.id),
('active', '=', True),
])
if len(active) > 1:
raise ValidationError('Only one active contract per partner.')
Para «solo un registro activo por X», @api.constrains es habitual en Odoo; en alta concurrencia valora un índice único parcial en SQL o bloqueo explícito.
Errores
Errores habituales
| Error | Consecuencia | Solución |
|---|---|---|
constrains sin incluir todos los campos leídos | No se ejecuta al cambiar un campo relacionado | Añadir todos los campos al decorador |
| Validar unicidad solo en Python | Duplicados bajo carga concurrente | models.Constraint UNIQUE |
| Mensajes SQL en español hardcodeados | i18n inconsistente | Inglés en código + .po |
write masivo sin constrains disparado | Campos no listados en decorador | Incluir campos o usar SQL |
Mezclar _sql_constraints y models.Constraint | Duplicados al migrar a 19 | Solo el estilo nuevo en 19 |
Decisión
¿Qué mecanismo elijo?
SQL Constraint
unique(campo)check(campo >= 0)- Integridad ante concurrencia
@api.constrains
- Comparar fechas / estados
- Validar contra otro modelo
- Reglas condicionales complejas
Otros
@api.onchange— solo UI- Override
create/write— normalización - Tests + documentación
Tests
Probar constraints en tests
from odoo.exceptions import ValidationError
from odoo.tests import TransactionCase
class TestPickupZone(TransactionCase):
def test_zip_code_unique(self):
self.env['pickup.zone'].create({{'zip_code': '28001'}})
with self.assertRaises(ValidationError):
self.env['pickup.zone'].create({{'zip_code': '28001'}})
Para constraints SQL puras, assertRaises(IntegrityError) o captura del error de Odoo al envolver el IntegrityError de psycopg2.
Resumen
En Odoo 19 usa models.Constraint para unicidad y CHECK en SQL; reserva @api.constrains para reglas de negocio que el ORM debe explicar al usuario. Migra _sql_constraints antes de producción, no dupliques mecanismos y testea violaciones explícitas. La capa SQL protege la integridad; Python, la semántica del dominio.