Dominios de Odoo: filtros en notación polaca
Evalúa dominios de Odoo como los de las vistas, las reglas de acceso y las automatizaciones: notación polaca con &, | y !, los & implícitos entre términos, operadores =, in, ilike… y campos relacionados con punto (cliente.pais). Análisis recursivo y ast.literal_eval.
- Dominios de Odoo
- Notación polaca (prefija)
- & implícito
- Campos relacionados con punto
- ast.literal_eval
- Análisis recursivo
Enunciado
En Odoo, casi todo filtro es un dominio: una lista de condiciones escrita en notación polaca, con el operador delante de sus dos operandos. [('estado', '=', 'sale'), ('total', '>', 1000)] son los pedidos confirmados de más de 1000 €: entre dos términos seguidos hay un & implícito. Para un «o» hay que escribirlo delante: ['|', ('estado', '=', 'draft'), ('estado', '=', 'sent')].
Los dominios aparecen en los filtros de las vistas, en las reglas de registro (qué ve cada usuario), en las acciones automatizadas y en las búsquedas desde Python (env['sale.order'].search(dominio)), así que entenderlos bien es imprescindible para adaptar un ERP.
Qué tiene que hacer el programa
- La entrada tiene una tabla de pedidos (línea
PEDIDOS, la cabecera y las filas, separadas por;), una de clientes (CLIENTES, igual) y líneasDOMINIO lista. Los valores que son números se comparan como números. - Para cada dominio se escribe
Dominio lista(tal cual) y debajo, con dos espacios: si hay más de una expresión a nivel superior,Con los & implícitos: [...](la forma explícita, ver abajo); y despuésN pedidos: nombre, nombre(o1 pedido: …o0 pedidos). - Un dominio vacío
[]selecciona todos.&y|toman las dos expresiones siguientes,!una, y una tupla(campo, operador, valor)es un término. Las expresiones sueltas se unen con&de izquierda a derecha:[A, B, C]es['&', '&', A, B, C]. - La forma explícita se escribe con los términos como tuplas de Python (repr) separados por
,: por ejemplo['&', ('estado', '=', 'sale'), ('total', '>', 1000)]. - Operadores:
=,!=,>,>=,<,<=(estos cuatro, solo con números),inynot in(con una lista),like(contiene, distinguiendo mayúsculas) eilike(contiene, sin distinguir). Un campocliente.paissigue el nombre del cliente del pedido hasta su fila en CLIENTES. - Errores (en lugar del resultado):
Error: faltan términos,operador desconocido «x»,campo desconocido «x»,«>» necesita números en «campo»,«in» necesita una lista,elemento no válido: …y, si la lista no se puede leer (ast.literal_evallanzaSyntaxError),la lista no está bien escrita (revisa comas, comillas y paréntesis).
Entrada
PEDIDOS y debajo id;nombre;estado;total;cliente y una fila por pedido.
CLIENTES y debajo nombre;pais;vip y una fila por cliente.
Una línea DOMINIO [...] por consulta.
Datos de referencia
| id | nombre | estado | total | cliente |
|---|---|---|---|---|
| 1 | S001 | sale | 1250.5 | Ana |
| 2 | S002 | draft | 320 | Luis |
| 3 | S003 | sale | 89.9 | Luis |
| 4 | S004 | cancel | 2100 | Eva |
| 5 | S005 | sale | 4300 | Eva |
| 6 | S006 | sent | 760 | Ana |
| nombre | pais | vip |
|---|---|---|
| Ana | ES | si |
| Luis | PT | no |
| Eva | ES | no |
Ejemplos de ejecución
Tu programa debe escribir exactamente esta salida para estas entradas. Las pruebas del editor incluyen estos ejemplos y otros casos ocultos.
Filtros de un pedido de venta
Entrada
PEDIDOS
id;nombre;estado;total;cliente
1;S001;sale;1250.5;Ana
2;S002;draft;320;Luis
3;S003;sale;89.9;Luis
4;S004;cancel;2100;Eva
5;S005;sale;4300;Eva
6;S006;sent;760;Ana
CLIENTES
nombre;pais;vip
Ana;ES;si
Luis;PT;no
Eva;ES;no
DOMINIO [('estado', '=', 'sale'), ('total', '>', 1000)]
DOMINIO ['|', ('estado', '=', 'draft'), ('estado', '=', 'sent')]
DOMINIO [('cliente.pais', '=', 'ES'), '!', ('estado', '=', 'cancel')]
DOMINIO []Salida por consola
Dominio [('estado', '=', 'sale'), ('total', '>', 1000)]
Con los & implícitos: ['&', ('estado', '=', 'sale'), ('total', '>', 1000)]
2 pedidos: S001, S005
Dominio ['|', ('estado', '=', 'draft'), ('estado', '=', 'sent')]
2 pedidos: S002, S006
Dominio [('cliente.pais', '=', 'ES'), '!', ('estado', '=', 'cancel')]
Con los & implícitos: ['&', ('cliente.pais', '=', 'ES'), '!', ('estado', '=', 'cancel')]
3 pedidos: S001, S005, S006
Dominio []
6 pedidos: S001, S002, S003, S004, S005, S006Operadores y errores
Entrada
PEDIDOS
id;nombre;estado;total;cliente
1;S001;sale;1250.5;Ana
2;S002;draft;320;Luis
3;S003;sale;89.9;Luis
4;S004;cancel;2100;Eva
5;S005;sale;4300;Eva
6;S006;sent;760;Ana
CLIENTES
nombre;pais;vip
Ana;ES;si
Luis;PT;no
Eva;ES;no
DOMINIO [('estado', 'in', ['draft', 'sent']), ('cliente', 'ilike', 'LU')]
DOMINIO ['|', ('total', '<', 100)]
DOMINIO [('importe', '>', 5)]
DOMINIO [('estado', '~', 'sale')]Salida por consola
Dominio [('estado', 'in', ['draft', 'sent']), ('cliente', 'ilike', 'LU')]
Con los & implícitos: ['&', ('estado', 'in', ['draft', 'sent']), ('cliente', 'ilike', 'LU')]
1 pedido: S002
Dominio ['|', ('total', '<', 100)]
Error: faltan términos
Dominio [('importe', '>', 5)]
Error: campo desconocido «importe»
Dominio [('estado', '~', 'sale')]
Error: operador desconocido «~»Guía paso a paso
Intenta resolverlo por tu cuenta y abre un paso solo cuando te atasques: cada uno te acerca a la solución sin dártela entera.
1. De texto a lista
ast.literal_eval convierte el texto en una lista de Python con tuplas y cadenas, sin ejecutar código (no uses eval).
dominio = ast.literal_eval("[('estado', '=', 'sale')]")2. Leer una expresión en notación polaca
Una función recursiva analizar(dominio, i) que lee la expresión que empieza en la posición i y devuelve el árbol y la posición siguiente. Si encuentra & o |, se llama dos veces para leer sus operandos; si encuentra !, una.
if x in ('&', '|'):
a, i = analizar(dominio, i + 1)
b, i = analizar(dominio, i)
return (x, a, b), i3. Los & implícitos
Llama a analizar hasta el final de la lista: cada vuelta da una expresión completa. Si salen varias, únelas con & de izquierda a derecha.
4. Evaluar
Otra función recursiva que, para cada pedido, evalúa el árbol: los términos comparan el campo con el valor y los operadores combinan los resultados.
Resuélvelo aquí
El editor trae el esqueleto del programa. Pulsa «Ejecutar» para comprobarlo con los ejemplos y con 2 casos ocultos que buscan los errores típicos.
Ejemplo
PEDIDOS
id;nombre;estado;total;cliente
1;S001;sale;1250.5;Ana
2;S002;draft;320;Luis
3;S003;sale;89.9;Luis
4;S004;cancel;2100;Eva
5;S005;sale;4300;Eva
6;S006;sent;760;Ana
CLIENTES
nombre;pais;vip
Ana;ES;si
Luis;PT;no
Eva;ES;no
DOMINIO [('estado', '=', 'sale'), ('total', '>', 1000)]
DOMINIO ['|', ('estado', '=', 'draft'), ('estado', '=', 'sent')]
DOMINIO [('cliente.pais', '=', 'ES'), '!', ('estado', '=', 'cancel')]
DOMINIO []
Dominio [('estado', '=', 'sale'), ('total', '>', 1000)]
Con los & implícitos: ['&', ('estado', '=', 'sale'), ('total', '>', 1000)]
2 pedidos: S001, S005
Dominio ['|', ('estado', '=', 'draft'), ('estado', '=', 'sent')]
2 pedidos: S002, S006
Dominio [('cliente.pais', '=', 'ES'), '!', ('estado', '=', 'cancel')]
Con los & implícitos: ['&', ('cliente.pais', '=', 'ES'), '!', ('estado', '=', 'cancel')]
3 pedidos: S001, S005, S006
Dominio []
6 pedidos: S001, S002, S003, S004, S005, S006Solución explicada
Ver la solución completa
1import ast
2import sys
3
4OPERADORES = {"=", "!=", ">", ">=", "<", "<=", "in", "not in", "like", "ilike"}
5
6
7def valor(texto):
8 """Los números se comparan como números; el resto, como texto."""
9 try:
10 return float(texto)
11 except ValueError:
12 return texto
13
14
15def leer_tabla(lineas, i):
16 cabecera = lineas[i].split(";")
17 filas = []
18 i += 1
19 while i < len(lineas) and lineas[i] and lineas[i].upper() not in ("PEDIDOS", "CLIENTES") and not lineas[i].startswith("DOMINIO"):
20 filas.append({c: valor(v) for c, v in zip(cabecera, lineas[i].split(";"))})
21 i += 1
22 return filas, i
23
24
25def campo(registro, ruta, clientes):
26 """Sigue una ruta con puntos: cliente.pais lee el país del cliente del pedido."""
27 partes = ruta.split(".")
28 actual = registro
29 for k, parte in enumerate(partes):
30 if parte not in actual:
31 raise ValueError(f"campo desconocido «{ruta}»")
32 actual = actual[parte]
33 if k < len(partes) - 1:
34 relacionado = [c for c in clientes if c["nombre"] == actual]
35 if not relacionado:
36 return None
37 actual = relacionado[0]
38 return actual
39
40
41def cumple(registro, termino, clientes):
42 ruta, op, esperado = termino
43 v = campo(registro, ruta, clientes)
44 if op == "=":
45 return v == esperado
46 if op == "!=":
47 return v != esperado
48 if op in (">", ">=", "<", "<="):
49 if not isinstance(v, (int, float)) or not isinstance(esperado, (int, float)):
50 raise ValueError(f"«{op}» necesita números en «{ruta}»")
51 return {">": v > esperado, ">=": v >= esperado, "<": v < esperado, "<=": v <= esperado}[op]
52 if op in ("in", "not in"):
53 if not isinstance(esperado, (list, tuple)):
54 raise ValueError(f"«{op}» necesita una lista")
55 return (v in esperado) == (op == "in")
56 if op == "like":
57 return str(esperado) in str(v)
58 return str(esperado).lower() in str(v).lower()
59
60
61def analizar(dominio, i):
62 """Devuelve (expresión, posición siguiente). Una expresión es un término o un operador con sus argumentos."""
63 if i >= len(dominio):
64 raise ValueError("faltan términos")
65 x = dominio[i]
66 if x in ("&", "|"):
67 a, i = analizar(dominio, i + 1)
68 b, i = analizar(dominio, i)
69 return (x, a, b), i
70 if x == "!":
71 a, i = analizar(dominio, i + 1)
72 return ("!", a), i
73 if isinstance(x, (tuple, list)) and len(x) == 3 and isinstance(x[0], str):
74 if x[1] not in OPERADORES:
75 raise ValueError(f"operador desconocido «{x[1]}»")
76 return ("T", tuple(x)), i + 1
77 raise ValueError(f"elemento no válido: {x!r}")
78
79
80def evaluar(e, registro, clientes):
81 if e[0] == "T":
82 return cumple(registro, e[1], clientes)
83 if e[0] == "!":
84 return not evaluar(e[1], registro, clientes)
85 a, b = evaluar(e[1], registro, clientes), evaluar(e[2], registro, clientes)
86 return (a and b) if e[0] == "&" else (a or b)
87
88
89def texto(e):
90 if e[0] == "T":
91 return repr(e[1])
92 if e[0] == "!":
93 return f"'!', {texto(e[1])}"
94 return f"'{e[0]}', {texto(e[1])}, {texto(e[2])}"
95
96
97def main():
98 lineas = [l.strip() for l in sys.stdin.read().split("\n")]
99 pedidos, clientes = [], []
100 i = 0
101 while i < len(lineas):
102 l = lineas[i]
103 if l.upper() == "PEDIDOS":
104 pedidos, i = leer_tabla(lineas, i + 1)
105 continue
106 if l.upper() == "CLIENTES":
107 clientes, i = leer_tabla(lineas, i + 1)
108 continue
109 if l.startswith("DOMINIO"):
110 fuente = l[len("DOMINIO"):].strip()
111 print(f"Dominio {fuente}")
112 try:
113 dominio = ast.literal_eval(fuente)
114 if not isinstance(dominio, list):
115 raise ValueError("un dominio es una lista")
116 exprs = []
117 j = 0
118 while j < len(dominio):
119 e, j = analizar(dominio, j)
120 exprs.append(e)
121 # los términos sueltos se unen con & implícitos
122 total = exprs[0] if exprs else None
123 for e in exprs[1:]:
124 total = ("&", total, e)
125 if total is not None and len(exprs) > 1:
126 print(f" Con los & implícitos: [{texto(total)}]")
127 encontrados = [p for p in pedidos if total is None or evaluar(total, p, clientes)]
128 nombres = ", ".join(p["nombre"] for p in encontrados)
129 print(f" {len(encontrados)} pedido{'' if len(encontrados) == 1 else 's'}{': ' + nombres if nombres else ''}")
130 except SyntaxError:
131 print(" Error: la lista no está bien escrita (revisa comas, comillas y paréntesis)")
132 except ValueError as error:
133 print(f" Error: {error}")
134 i += 1
135
136
137main()La notación polaca no necesita paréntesis: cada operador sabe cuántos operandos tiene, así que la lista se lee de izquierda a derecha sin ambigüedad. Por eso el análisis es una recursión tan corta.
El & implícito es cómodo pero engaña: ['|', A, B, C] es (A o B) y C, no A o B o C. Para tres alternativas hacen falta dos |: ['|', '|', A, B, C].
Los campos con punto (partner_id.country_id.code en Odoo) recorren relaciones: Odoo los traduce a JOIN en SQL. Aquí se resuelve buscando el cliente por su nombre.
ast.literal_eval solo acepta literales (listas, tuplas, números, cadenas…), así que un dominio malicioso no puede ejecutar código, al contrario que eval.
Para ir más allá
- Añade el operador
child_ofcon una jerarquía de categorías. - Traduce cada dominio a la cláusula WHERE de SQL equivalente.
- Simplifica dominios:
['!', '!', A]esAy['&', A, A]también.