Apuntes DAM
Volver al inicio

Dominios de Odoo: filtros en notación polaca

Ejercicio de PythonMuy difícilUnos 90 minutos

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

  1. La entrada tiene una tabla de pedidos (línea PEDIDOS, la cabecera y las filas, separadas por ;), una de clientes (CLIENTES, igual) y líneas DOMINIO lista. Los valores que son números se comparan como números.
  2. 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és N pedidos: nombre, nombre (o 1 pedido: … o 0 pedidos).
  3. 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].
  4. La forma explícita se escribe con los términos como tuplas de Python (repr) separados por , : por ejemplo ['&', ('estado', '=', 'sale'), ('total', '>', 1000)].
  5. Operadores: =, !=, >, >=, <, <= (estos cuatro, solo con números), in y not in (con una lista), like (contiene, distinguiendo mayúsculas) e ilike (contiene, sin distinguir). Un campo cliente.pais sigue el nombre del cliente del pedido hasta su fila en CLIENTES.
  6. 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_eval lanza SyntaxError), 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

PEDIDOS (los del primer ejemplo)
idnombreestadototalcliente
1S001sale1250.5Ana
2S002draft320Luis
3S003sale89.9Luis
4S004cancel2100Eva
5S005sale4300Eva
6S006sent760Ana
CLIENTES
nombrepaisvip
AnaESsi
LuisPTno
EvaESno

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, S006

Operadores 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).

python
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.

python
if x in ('&', '|'):
    a, i = analizar(dominio, i + 1)
    b, i = analizar(dominio, i)
    return (x, a, b), i
3. 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.

🐍PythonDominios de Odoo: filtros en notación polacaMuy difícil

Ejemplo

Entrada (lo que se escribe por teclado)
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 esperada
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, S006
⏳
Test oculto #3
⏳
Test oculto #4
0/4 tests pasados · pulsa un test para ver su entrada y su salida esperada

Solución explicada

Ver la solución completa
python
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_of con una jerarquía de categorías.
  • Traduce cada dominio a la cláusula WHERE de SQL equivalente.
  • Simplifica dominios: ['!', '!', A] es A y ['&', A, A] también.

Dónde se explica