Cómo personalizo mi Odoo con Claude Code

De 108 adjuntos en una tarea a un buscador en el chatter: 200 líneas de código, dos horas, y las cuatro veces que tropecé por el camino.

El chatter de Odoo es una de esas funcionalidades que das por hecho hasta que te muerde. Una project.task con 108 adjuntos. Informes de ITV, presupuestos, facturas, capturas de clientes, ficheros JSON de ground truth que usamos para entrenar un agente CAD interno. El chatter los muestra como una lista plana, sin orden. Para encontrar un fichero concreto haces scroll. No hay input. No hay filtro. No hay forma de preguntar "muéstrame el adjunto que contiene la palabra presupuesto". Este post va de la tarde que pasé eliminando esa fricción, y de las cuatro veces que tropecé por el camino.

Buscador encima de la caja de adjuntos del chatterEl input de búsqueda sobre una project.task con 108 adjuntos.

Primer instinto: seguro que OCA tiene esto

Mantenemos un espejo en vivo del catálogo OCA dentro de nuestro Odoo (el módulo oca_management, 2.985 addons instalables para 16.0 indexados con summary, README y descripciones generadas por IA). Cinco minutos de consultas devolvieron:

  • mail_message_search (OCA/social) — añade un campo de búsqueda en la vista lista de cualquier modelo mail.thread. Busca mensajes del chatter, no adjuntos. Ámbito equivocado: filtra la lista de tareas, no los adjuntos dentro de una tarea.
  • web_advanced_search (OCA/web) — mejor UI de búsqueda en el backend, pero no toca la caja de adjuntos del chatter.
  • dms (OCA/dms) — sistema completo de gestión documental. Jerárquico, con etiquetas, permisos, búsqueda. Módulo precioso. Herramienta equivocada: obliga a migrar fuera de ir.attachment.
  • url_attachment_search_fuzzy (OCA/server-tools) — añade un índice trigram, pero solo sobre el campo URL de ir.attachment. Acelera el login, no la búsqueda por contenido.
  • attachment_unindex_content (OCA/server-tools) — lo contrario de lo que queremos, deshabilita la indexación. Su existencia confirma que Odoo CE indexa contenido por defecto.

Ese último punto es importante. El módulo CE attachment_indexation ya extrae texto de PDFs, DOCX, XLSX, PPTX, ODT y text/* hacia ir_attachment.index_content. La materia prima está. Lo que falta es la UI para usarla.

El plan

Parchear el componente Owl mail.AttachmentBox para añadir un input de búsqueda con debounce encima de la lista de ficheros. Cuando el usuario escribe, lanzar un RPC ir.attachment.search filtrado al registro actual, buscando por name O index_content. Después parchear el compute attachments del modelo AttachmentList para limitar los registros mostrados a los IDs devueltos.

Unas 80 líneas de JavaScript y 25 de QWeb XML. Ni vista nueva, ni menú nuevo, ni fichero de datos.

El parche del lado de la vista

Owl en el módulo mail de Odoo 16 usa un framework de mensajería propio donde los modelos se registran vía registerModel y se parchean vía registerPatch. Para añadir estado reactivo a AttachmentBoxView:

import { registerPatch } from "@mail/model/model_core";
import { attr } from "@mail/model/model_field";
import { clear } from "@mail/model/model_field_command";

const SEARCH_DEBOUNCE_MS = 300;

registerPatch({
    name: "AttachmentBoxView",
    recordMethods: {
        onInputSearch(ev) {
            const query = (ev.target.value || "").trim();
            this.update({ searchQuery: query });
            if (this._casSearchTimeout) clearTimeout(this._casSearchTimeout);
            this._casSearchTimeout = setTimeout(() => {
                if (this.exists()) this._casRunSearch();
            }, SEARCH_DEBOUNCE_MS);
        },
        async _casRunSearch() {
            const q = this.searchQuery;
            const thread = this.chatter && this.chatter.thread;
            if (!q) {
                this.update({ matchedAttachmentIds: clear(), isSearching: false });
                return;
            }
            this.update({ isSearching: true });
            const ids = await this.messaging.rpc({
                model: "ir.attachment",
                method: "search",
                args: [[
                    ["res_model", "=", thread.model],
                    ["res_id", "=", thread.id],
                    "|",
                    ["name", "ilike", q],
                    ["index_content", "ilike", q],
                ]],
                kwargs: { limit: 1000 },
            });
            if (this.exists() && this.searchQuery === q) {
                this.update({ matchedAttachmentIds: ids, isSearching: false });
            }
        },
    },
    fields: {
        searchQuery: attr({ default: "" }),
        matchedAttachmentIds: attr(),
        isSearching: attr({ default: false }),
    },
});

Tres detalles que merece la pena destacar. Primero, el debounce: tipear no debe disparar un RPC por tecla, así que esperamos 300 ms de inactividad antes de enviar. Segundo, el guard de obsolescencia this.searchQuery === q: cuando vuelve el RPC, el usuario puede haber escrito tres letras más y disparado una búsqueda más reciente; ignoramos resultados tardíos. Tercero, this.exists(): el registro puede haberse destruido (cerraste la tarea), y escribir en un registro muerto lanza excepción. Los tres son fáciles de olvidar e imposibles de testear si no piensas en ellos antes.

El parche del lado de la lista

Los registros mostrados vienen de AttachmentList.attachments, un compute que devuelve thread.allAttachments. Lo envolvemos:

registerPatch({
    name: "AttachmentList",
    fields: {
        attachments: {
            compute() {
                const result = this._super();
                const owner = this.attachmentBoxViewOwner;
                if (!owner) return result;
                const matched = owner.matchedAttachmentIds;
                if (!Array.isArray(matched)) return result;
                if (!Array.isArray(result)) return result;
                const allowed = new Set(matched);
                return result.filter((a) => allowed.has(a.id));
            },
        },
    },
});

El parche cae al original cuando no hay filtro activo (matchedAttachmentIds es undefined). Cuando el usuario limpia la búsqueda, lo devolvemos a undefined y vuelve la lista original. Sin bug de reactividad, sin parpadeo de lista vacía, sin doble render.

La plantilla

Añadir el input encima de la lista con t-inherit:

<t t-inherit="mail.AttachmentBox" t-inherit-mode="extension" owl="1">
    <xpath expr="//div[hasclass('o_AttachmentBox_content')]" position="before">
        <div class="o_CasSearchBar input-group input-group-sm px-2 pb-2"
             t-if="attachmentBoxView and attachmentBoxView.chatter and attachmentBoxView.chatter.thread">
            <span class="input-group-text bg-white">
                <i t-attf-class="fa #{attachmentBoxView.isSearching ? 'fa-spinner fa-spin' : 'fa-search'}"/>
            </span>
            <input type="text" class="form-control"
                   placeholder="Buscar por nombre o contenido..."
                   t-att-value="attachmentBoxView.searchQuery"
                   t-on-input="(ev) => attachmentBoxView.onInputSearch(ev)"/>
        </div>
    </xpath>
</t>

El icono alterna entre lupa y spinner según isSearching. Todo el bloque está condicionado a que exista el thread — sin eso, un registro nuevo no guardado renderiza un buscador encima de nada y el RPC casca al no tener res_id.

El problema con los JSON

Tras instalar el módulo en producción, la búsqueda funcionó preciosa para PDFs, DOCXs, capturas (por nombre), Excel. Después busqué una clave dentro de un JSON y obtuve cero resultados, a pesar de que el fichero estaba ahí con un nombre perfectamente buscable. El match por nombre funcionaba. El match por contenido, no.

Leer el código de attachment_indexation lo explicó. El módulo cubre PDF (vía pdfminer), DOCX/PPTX/XLSX (parseando el XML del .zip) y ODT/ODS. Para todo lo demás cae al base de CE, que solo indexa mimetypes text/*. El JSON llega como application/json — fuera de ámbito. El campo index_content de nuestros adjuntos JSON era, literalmente, la cadena "application".

Fix de 18 líneas:

JSON_LIKE_MIMETYPES = (
    "application/json",
    "application/ld+json",
    "application/geo+json",
    "application/manifest+json",
)

class IrAttachment(models.Model):
    _inherit = "ir.attachment"

    @api.model
    def _index(self, bin_data, mimetype, checksum=None):
        if bin_data and mimetype and mimetype.split(";")[0].strip() in JSON_LIKE_MIMETYPES:
            return bin_data.decode("utf-8", "replace")
        return super()._index(bin_data, mimetype, checksum=checksum)

Más attachment_indexation añadido a los depends del manifest (overrideamos su _index, así que tenemos que cargar después), versión bumpeada a 16.0.1.1.0. El bucle de reindexación one-shot para los JSON existentes vive en una sesión de Odoo shell, no en el módulo — es una migración, no una feature.

El baile del despliegue

Nuestra regla: todo cambio aterriza primero en dev (un Odoo separado en pve1) antes de tocar prod (la instancia de ipve1). Para este módulo:

  1. Editar local, commit, push a GitLab.
  2. Restaurar el último backup de elPanocho desde ct-200 (nuestro contenedor de backups) a pve1 ct-116.
  3. Ejecutar odoo -u all -d elPanocho usando odoo-dev.conf (puerto 8079, workers=0, --stop-after-init). Tres minutos para 191 módulos.
  4. Instalar el módulo: odoo -i chatter_attachment_search -d elPanocho --stop-after-init.
  5. Verificar en el navegador en la URL de dev. Hard reload para esquivar la caché de bundles.
  6. Si está verde: pull en el CT de prod, repetir el install, reiniciar el servicio Odoo de producción para que los workers recarguen el registry.
  7. Además: borrar las filas ir.attachment con los web.assets_* cacheados, para que la siguiente petición autenticada las regenere. Si no, prod sirve la URL del bundle pre-install hasta que caduque la caché.

El paso 7 me costó diez minutos la primera vez. Instalar un módulo vía --stop-after-init no invalida los assets en el registry de prod que ya estaba corriendo. Hard refresh en el navegador no ayuda porque el HTML sigue referenciando la URL antigua del bundle. La solución son dos DELETEs en SQL más una petición autenticada al backend.

Los tropiezos, en breve

Cuatro momentos en los que tuve que deshacer y rehacer:

  1. Autor de git incorrecto. El repo en ipve1 tenía user.email = me@lemontreecloud.com ya en su config. La dirección correcta es mi cuenta de gmail personal. Asumí que el valor ya configurado era el bueno y commiteé con el autor equivocado. git commit --amend --author + force-with-lease lo arregló, pero queda como recordatorio permanente: que un valor esté ya ahí no significa que sea correcto.
  2. Rebase silencioso tras push rechazado. El primer push fue rechazado porque el remoto tenía commits que yo no tenía local. Rebaseé y pusheé sin decir nada. Reflejo equivocado — debí pararme y preguntar primero. Los efectos colaterales de un rebase silencioso pueden ser mucho peores que el push rechazado que lo disparó.
  3. Invalidación de bundle olvidada. Ver arriba. Reiniciar prod, vaciar bundles, después probar.
  4. Contenido JSON sin indexar. La búsqueda "funcionaba" en la demo con una tarea que solo tenía PDFs. El primer test contra JSONs reales reveló el hueco. Ya está cubierto, pero queda el recordatorio de que "funciona en el camino feliz" no es "funciona".

Lo que cuesta el módulo y lo que devuelve

Seis ficheros, unas 200 líneas:

  • __manifest__.py — depende de mail y attachment_indexation.
  • models/ir_attachment.py — override de _index para JSON.
  • static/src/models/attachment_box_view_patch.js — estado de búsqueda y RPC.
  • static/src/models/attachment_list_patch.js — compute del filtro.
  • static/src/components/attachment_box_patch.xml — input por t-inherit.
  • static/src/components/attachment_box_patch.scss — tres líneas de CSS para el focus ring.

En el chatter, la sección Files ahora tiene un input. Tecleas "presupuesto", "ground_truth", "factura", un número de serie, una clave dentro de un JSON — la lista filtra en 300 ms. Tanto nombre como contenido se buscan en un único RPC.

En la tarea de 108 adjuntos que arrancó este post, encontrar el fichero correcto es ahora una línea. Las dos horas que se fueron en el módulo se pagan solas la primera tarde de uso.

Descarga el módulo

chatter_attachment_search-16.0.1.2.0.zip

11.0 KB · Odoo 16 · AGPL-3 · 18 ficheros (incl. tests + readme + i18n)

Descomprime en tu addons path, reinicia Odoo, -i chatter_attachment_search -d tu_bd.

Reflexiones sobre Claude Code como herramienta de personalización

Las ganancias no obvias no fueron la velocidad tecleando JavaScript. Estaban en otros sitios:

  • Triaje OCA. La decisión "ningún módulo encaja exacto" pasó en cinco minutos de consultas al espejo en vivo con ejemplos concretos en la mano. Sin eso, mi primer instinto habría sido instalar dms y arrepentirme tres semanas después al toparme con la fricción de la migración.
  • Leer fuente del core bajo demanda. El problema del contenido JSON salió a la luz porque Claude abrió attachment_indexation/models/ir_attachment.py y vio la rama text/*-only. Yo había mirado ese fichero por encima en el pasado, pero nunca con suficiente atención como para saber que JSON se quedaba fuera.
  • Verbosidad en el despliegue. Cada comando se ejecutaba delante de mí con toda su salida. Los cuatro tropiezos de arriba salieron a la superficie porque podía ver qué estaba pasando. Ninguno provocó una caída en producción porque cada uno se cazó antes del paso siguiente.

Esto no es piloto automático. Leo cada diff, apruebo cada comando que toca estado compartido, y rechazo cualquier cosa que huela raro. El apalancamiento es real, pero el apalancamiento es mío — Claude amplifica cómo trabajo, no sustituye al criterio.

El módulo se llama chatter_attachment_search, versión 16.0.1.1.0. Vive en nuestro repo interno de apps. Si alguna vez lo subimos upstream a OCA, apuntaremos a este post como justificación de diseño.

pbs.local: un servidor de backup hecho con hardware desechado y dos discos jubilados
Cómo un puerto SATA muerto, módulos DDR3 sin ECC desiguales y 683 errores CRC históricos se convirtieron en un Proxmox Backup Server de 7,2 TB en una tarde.