[ADD] base_company_dependant: widget UX multicompañía para campos com…#379
[ADD] base_company_dependant: widget UX multicompañía para campos com…#379jjscarafia merged 8 commits intoingadhoc:19.0from
Conversation
…pany_dependent
En Odoo 18/19 los campos `company_dependent` dejaron de usar `ir.property` y
ahora se almacenan directamente en la tabla del modelo como una columna JSONB
(ej. `{"1": 45, "2": false}`). El ORM resuelve el valor para la compañía activa
antes de enviarlo al cliente, pero esto produce tres pain points:
1. El usuario no sabe si el valor que ve es "específico" (clave explícita en
el JSON) o el "fallback global" (clave ausente, resuelve por ir.default).
2. Modificar el valor puede afectar todas las compañías sin que el usuario
lo sepa (estaba tocando el default).
3. Para revisar el valor de cada compañía hay que hacer context-switching en
el menú superior.
Nuevo módulo `base_company_dependant` que replica el paradigma del "Asistente
de Traducciones" (icono `fa-globe`) pero para valores multicompañía, usando un
icono `fa-building-o`.
---
**`models/ir_ui_view.py`** — `Base(_inherit="base")`
- Sobrescribe `_get_view_field_attributes()` para añadir `"company_dependent"`
a la lista de atributos que el ORM serializa al cliente al cargar una vista.
- Sin este fix el frontend nunca recibe el metadato y el widget no se activa.
- Sigue el mismo patrón que `html_editor` usa para exponer `sanitize`.
**`models/base_company_dependant.py`** — `models.AbstractModel` `base.company.dependant`
- `get_company_dependent_meta(res_model, res_id)`:
· Una sola query SQL para TODOS los campos `company_dependent` del modelo
(evita N+1 al cargar el formulario con múltiples campos).
· Devuelve `{field_name: is_specific}` donde `is_specific=True` significa
que el `company_id` activo tiene clave explícita en el JSON.
- `get_company_dependent_values(res_model, res_id, field_name)`:
· Lee la columna JSONB cruda via raw SQL (`psycopg2.sql.Identifier`).
· Resuelve el fallback consultando `ir.default`.
· Devuelve por cada compañía accesible (`env.companies`):
`{company_id, company_name, is_specific, value_id, display_value}`.
· Soporta tipos `many2one`, `float` e `integer`.
- `set_company_dependent_values(res_model, res_id, field_name, values_dict)`:
· Recibe `{str(company_id): value | false | "RESET"}`.
· `false` → guarda la clave con valor `false` (vacío explícito, is_specific=True).
· `"RESET"` → hace `pop()` de la clave (restaura al fallback global).
· Escribe el JSON crudo y llama a `invalidate_recordset` para limpiar caché ORM.
· Todos los identificadores SQL van por `psycopg2.sql.Identifier` (no interpolación).
---
**`static/src/company_dependent_service.js`** — Servicio `company_dependent`
- Caché por `resModel:resId`: la primera llamada dispara la RPC, las siguientes
comparten la misma Promise en vuelo (batching automático).
- Una vez resuelta, cachea el objeto plano para lecturas síncronas (`getMetaSync`).
- `invalidate(resModel, resId)` para forzar re-fetch tras guardar en el diálogo.
**`static/src/many2one_patch.js`** — Patch de `Many2OneField`
- No modifica el registro en el registry; usa `patch()` sobre el prototipo.
- `isCompanyDependent` getter: `props.record.fields[name].company_dependent === true`.
- En `setup()`: llama a `useService("company_dependent")` y `useState` siempre
(sin condicional, respetando el orden de hooks de OWL). Solo registra
`onWillStart` si `isCompanyDependent` es verdadero.
- `_loadCDMeta()`: no invalida la caché (para aprovechar el batching cuando
hay múltiples campos company_dependent en el mismo formulario). La invalidación
la hace el diálogo antes de llamar al callback `onSaved`.
- Reemplaza `Many2OneField.template` por `base_company_dependant.Many2OneField`
e inyecta `CompanyDependentButton` en `Many2OneField.components`.
**`static/src/company_dependent_button.js`** — `CompanyDependentButton`
- Renderiza el icono `fa-building-o`.
- Color dinámico: `text-primary` (específico), `text-muted` (fallback),
`text-secondary opacity-50` (cargando/null).
- Tooltip descriptivo según estado.
- Al hacer clic: guarda el registro (`record.save()`) antes de abrir el diálogo,
igual que hace `TranslationButton`.
**`static/src/company_dependent_dialog.js`** — `CompanyDependentDialog`
- Carga datos via `get_company_dependent_values` en `onWillStart`.
- `_getEffectiveRow(row)`: combina el estado original con los cambios pendientes
(local state) para renderizado optimista sin necesidad de re-fetch.
- `getAutoCompleteSources(row)`: genera sources para `AutoComplete` con `onSelect`
**dentro de cada opción** (API correcta de AutoComplete; el callback NO va
como prop del componente).
- `onAutoCompleteChange(row, {inputValue, isOptionSelected})`: detecta vaciado
real (usuario borró texto y salió sin seleccionar) vs. selección normal.
- `onResetRow(row)`: marca `{ is_reset: true }` → el guardado enviará `"RESET"`.
- `onSave()`: construye el dict para `set_company_dependent_values`, invalida
la caché del servicio, y llama a `onSaved` (que recarga el record y re-fetchea
el meta para actualizar el color del icono).
- Getters para todos los strings con caracteres especiales (`_t()`) para evitar
errores del tokenizador OWL con acentos/eñes en expresiones de template.
**`static/src/templates.xml`**
- `base_company_dependant.Many2OneField`: wrapper flex con clase dinámica
`o_cd_fallback` (activa CSS muted) + `<CompanyDependentButton>` condicional.
- `base_company_dependant.CompanyDependentButton`: botón con `fa-building-o`,
color según `isSpecific`.
- `base_company_dependant.CompanyDependentDialog`: tabla responsive con columnas
Compañía / Valor (AutoComplete) / Estado (badge) / Reset (botón).
- Todos los operadores lógicos en expresiones: `&&` y `||` (JS), no `and`/`or`
(Python). Strings en props de componentes con comillas simples internas.
Arrow functions con bloques `if` extraídas a métodos del componente.
**`static/src/company_dependent.css`**
- `.o_cd_fallback` aplica `color: var(--bs-secondary-color)` + `font-style: italic`
al input y a los links readonly del campo.
- `.o_cd_btn` alinea el icono inline sin romper el layout flex del formulario.
---
1. `company_dependent` no llegaba al cliente → fix en `_get_view_field_attributes`.
2. Tokenizer OWL: strings con acentos/espacios en props de componentes → getters JS.
3. Operadores `and`/`or` en templates OWL → reemplazados por `&&`/`||`.
4. Arrow function con bloque `if` en template → extraída a método del componente.
5. `class="o_input w-100"` en prop de componente evaluado como JS → `'o_input w-100'`.
6. Prop `onSelect` en `<AutoComplete>` no existe → movido a `option.onSelect()`.
7. Batching roto al invalidar caché en `onWillStart` → invalidar solo desde diálogo.
There was a problem hiding this comment.
Pull request overview
Este PR incorpora un nuevo módulo base_company_dependant para mejorar la UX de campos company_dependent en Odoo 19, agregando un indicador visual y un diálogo de edición multicompañía (estilo “Asistente de Traducciones”) para gestionar valores específicos vs. fallback sin cambiar de compañía activa.
Changes:
- Añade un widget/patch en frontend (Many2One) con botón e indicador de estado “específico vs. fallback” y un diálogo multicompañía para editar/resetear valores.
- Implementa un servicio JS con caché/batching para cargar metadata
is_specificpor registro. - Agrega un backend RPC (
base.company.dependant) que lee/escribe directamente la columna JSONB de camposcompany_dependent, y un hook enir.ui.viewpara exponer el atributocompany_dependental cliente.
Reviewed changes
Copilot reviewed 12 out of 12 changed files in this pull request and generated 10 comments.
Show a summary per file
| File | Description |
|---|---|
| base_company_dependant/static/src/templates.xml | Templates OWL del Many2One extendido, botón e interfaz del diálogo. |
| base_company_dependant/static/src/many2one_patch.js | Patch de Many2OneField para cargar meta y renderizar el botón/estilo. |
| base_company_dependant/static/src/company_dependent_service.js | Servicio JS con caché y batching de RPC por registro. |
| base_company_dependant/static/src/company_dependent_dialog.js | Lógica del diálogo: carga, autocomplete, cambios locales y guardado. |
| base_company_dependant/static/src/company_dependent_button.js | Botón que abre el diálogo y fuerza record.save() previo. |
| base_company_dependant/static/src/company_dependent.css | Estilos para estado fallback, botón e interfaz del diálogo. |
| base_company_dependant/models/ir_ui_view.py | Expone company_dependent en atributos serializados de campos al cliente. |
| base_company_dependant/models/base_company_dependant.py | API backend para meta/lectura/escritura de JSONB company_dependent. |
| base_company_dependant/models/init.py | Exporta los modelos del módulo. |
| base_company_dependant/manifest.py | Manifest y carga de assets backend. |
| base_company_dependant/init.py | Inicialización del módulo. |
| base_company_dependant/README.rst | Documentación funcional y técnica del módulo. |
| def _resolve_m2o_display(self, comodel_name, value_id): | ||
| """Devuelve el display_name de un registro Many2one, o None si no existe.""" | ||
| if not value_id: | ||
| return None | ||
| try: | ||
| record = self.env[comodel_name].sudo().browse(int(value_id)) | ||
| if record.exists(): | ||
| return record.display_name | ||
| except Exception: | ||
| pass | ||
| return None |
There was a problem hiding this comment.
_resolve_m2o_display usa sudo() y atrapa cualquier excepción sin registrar nada. Esto puede filtrar display_name de registros a los que el usuario no tiene acceso, y además oculta errores reales. Sería mejor evitar sudo(), y en todo caso capturar AccessError de forma explícita (devolviendo None) y loguear el resto; de paso se elimina el _logger actualmente sin uso (ruff/pyflakes lo va a marcar).
| field = model_obj._fields.get(field_name) | ||
|
|
||
| if not field or not field.company_dependent: | ||
| raise ValueError(f"El campo '{field_name}' en '{res_model}' no es company_dependent.") | ||
|
|
There was a problem hiding this comment.
Aquí se lanza ValueError con un f-string no traducible. Como esto se invoca vía RPC desde el backend, suele ser más útil levantar UserError/ValidationError con _() para que el mensaje sea amigable y traducible, y para evitar trazas innecesarias en cliente.
| get labelSpecific() { | ||
| return _t("Especifico"); | ||
| } | ||
|
|
||
| get labelDefault() { | ||
| return _t("Por Defecto"); | ||
| } |
There was a problem hiding this comment.
Textos en UI sin tildes: "Especifico" → "Específico" y (si se mantiene el estilo) conviene consistencia con mayúsculas en "Por defecto". Al estar en _t(), se mostrarán tal cual al usuario.
| get loadingText() { | ||
| return _t("Cargando valores por compania..."); | ||
| } |
There was a problem hiding this comment.
Texto de carga con faltas: "compania" debería ser "compañía" (y, si aplica, tildes en "Cargando valores por compañía...").
| get footerNoteHtml() { | ||
| // Devuelve partes separadas para el template (evita innerHTML crudo) | ||
| return { | ||
| vaciar: _t("Vaciar"), | ||
| vaciarDesc: _t("guarda un valor vacio explicito (badge Especifico)."), | ||
| reset: _t("Reset"), | ||
| resetDesc: _t("elimina la clave del JSON y restaura el valor por defecto global."), | ||
| }; |
There was a problem hiding this comment.
En el pie se usan textos sin tildes: "vacio", "explicito" deberían ser "vacío", "explícito". Al estar en _t(), conviene corregirlos para que la UI se vea profesional.
| Fase 2 (pendiente) | ||
| ================== | ||
|
|
||
| * Soporte para campos ``Float`` e ``Integer``. | ||
| * Botón «Copiar a todas las compañías hijas». | ||
|
|
There was a problem hiding this comment.
El README marca Float/Integer como "Fase 2 (pendiente)", pero en este PR ya hay soporte en el diálogo/template y en backend para float/integer. Conviene actualizar esta sección para evitar documentación desfasada (o ajustar el código si realmente no se quería incluir aún).
| self.env["ir.model.access"].check(res_model, "read") | ||
| model_obj = self.env[res_model] | ||
| field = model_obj._fields.get(field_name) | ||
|
|
||
| if not field or not field.company_dependent: | ||
| raise ValueError(f"El campo '{field_name}' en '{res_model}' no es company_dependent.") | ||
|
|
||
| raw_json = self._get_raw_json(model_obj._table, field_name, res_id) | ||
|
|
||
| # Fallback global: ir.default | ||
| fallback_value = self.env["ir.default"]._get(res_model, field_name) | ||
|
|
There was a problem hiding this comment.
Estos métodos hacen SELECT/UPDATE directo sobre la tabla y solo validan ACLs del modelo (ir.model.access), pero no aplican reglas de registro (record rules). Así, un usuario podría leer valores de un res_id al que no tiene acceso o incluso inferir datos saltándose el ORM. Antes de leer por SQL, conviene hacer browse(res_id).exists() y forzar check_access_rule('read') (y/o una lectura ORM mínima) para asegurar que el registro es accesible.
| self.env["ir.model.access"].check(res_model, "read") | ||
| model_obj = self.env[res_model] | ||
| company_key = str(self.env.company.id) | ||
|
|
||
| cd_fields = [name for name, f in model_obj._fields.items() if f.company_dependent and f.store] | ||
| if not cd_fields: | ||
| return {} | ||
|
|
||
| # Una sola SELECT para todos los campos company_dependent | ||
| self.env.cr.execute( | ||
| sql.SQL("SELECT {cols} FROM {table} WHERE id = %s").format( | ||
| cols=sql.SQL(", ").join(sql.Identifier(f) for f in cd_fields), | ||
| table=sql.Identifier(model_obj._table), | ||
| ), | ||
| (res_id,), | ||
| ) |
There was a problem hiding this comment.
El meta se obtiene por SQL y puede revelar si existen claves específicas (y potencialmente valores en otras partes) para registros que el usuario no debería ver, porque no se aplican record rules. Igual que en get_company_dependent_values, es recomendable validar browse(res_id).exists() y check_access_rule('read') antes del SELECT.
| get autoCompletePlaceholder() { | ||
| return _t("Sin valor (vaciar explicitamente)"); | ||
| } |
There was a problem hiding this comment.
Hay varios textos traducibles con faltas de ortografía (sin tilde): "explicitamente" debería ser "explícitamente". Al ir dentro de _t(), esto termina en el UI tal cual.
| self.env["ir.model.access"].check(res_model, "write") | ||
| model_obj = self.env[res_model] | ||
| field = model_obj._fields.get(field_name) | ||
|
|
||
| if not field or not field.company_dependent: | ||
| raise ValueError(f"El campo '{field_name}' en '{res_model}' no es company_dependent.") | ||
|
|
||
| raw_json = self._get_raw_json(model_obj._table, field_name, res_id) | ||
|
|
||
| for company_id_str, value in values_dict.items(): | ||
| key = str(company_id_str) | ||
| if value == "RESET": | ||
| raw_json.pop(key, None) | ||
| else: | ||
| raw_json[key] = value | ||
|
|
There was a problem hiding this comment.
En la escritura se actualiza el JSONB por SQL sin validar reglas de registro ni que el res_id exista. Además, values_dict permite enviar company_ids arbitrarios: habría que limitar/validar que los IDs estén dentro de env.companies (o del set permitido por el usuario), y rechazar/ignorar el resto. Recomiendo: browse(res_id).exists() + check_access_rule('write') antes del UPDATE y filtrar values_dict por compañías accesibles.
…display
**Domain filtering in multicompany dialog**
- Replace `field.get_comodel_domain()` with new `_get_field_static_domain()` helper.
In Odoo 19, `get_comodel_domain` silently returns `Domain.TRUE` for string
domains (e.g. `"[('account_type', '=', 'asset_receivable')]"`), causing the
autocomplete to ignore the field's static filter. The new helper handles all
three domain formats: callable, list/Domain, and string (via `ast.literal_eval`).
- Each dialog row now receives the correct combined domain:
static field domain + `_check_company_domain(company)`.
**Write via ORM instead of raw JSON**
- `set_company_dependent_values`: explicit values (ID or False) are now written
via `record.with_company(company).write(...)` so that `check_company`,
`ondelete` constraints and access rules are enforced by the ORM.
- RESET operation still uses direct SQL (the only case where the ORM has no
native equivalent: removing a key from the JSONB column).
**Fallback resolution**
- Replace `ir.default._get()` with `model_obj.default_get([field_name])` to
also resolve defaults defined in Python code (`default=...`), not only those
stored in `ir.default`.
- When `is_specific=False`, `value_id` and `display_value` are now populated
from the resolved fallback so the dialog input shows the inherited value in grey.
e7f8d9b to
e59f9c9
Compare
e59f9c9 to
24c6286
Compare
|
@roboadhoc r+ |
|
@jjscarafia because this PR has multiple commits, I need to know how to merge it:
|
|
@roboadhoc rebase-ff nobump |
|
Merge method set to rebase and fast-forward. |
|
@jjscarafia 'ci/runbot-modified-modules' failed on this reviewed PR. |
4 similar comments
|
@jjscarafia 'ci/runbot-modified-modules' failed on this reviewed PR. |
|
@jjscarafia 'ci/runbot-modified-modules' failed on this reviewed PR. |
|
@jjscarafia 'ci/runbot-modified-modules' failed on this reviewed PR. |
|
@jjscarafia 'ci/runbot-modified-modules' failed on this reviewed PR. |

…pany_dependent
En Odoo 18/19 los campos
company_dependentdejaron de usarir.propertyy ahora se almacenan directamente en la tabla del modelo como una columna JSONB (ej.{"1": 45, "2": false}). El ORM resuelve el valor para la compañía activa antes de enviarlo al cliente, pero esto produce tres pain points:Nuevo módulo
base_company_dependantque replica el paradigma del "Asistente de Traducciones" (iconofa-globe) pero para valores multicompañía, usando un iconofa-building-o.models/ir_ui_view.py—Base(_inherit="base")_get_view_field_attributes()para añadir"company_dependent"a la lista de atributos que el ORM serializa al cliente al cargar una vista.html_editorusa para exponersanitize.models/base_company_dependant.py—models.AbstractModelbase.company.dependantget_company_dependent_meta(res_model, res_id): · Una sola query SQL para TODOS los camposcompany_dependentdel modelo (evita N+1 al cargar el formulario con múltiples campos). · Devuelve{field_name: is_specific}dondeis_specific=Truesignifica que elcompany_idactivo tiene clave explícita en el JSON.get_company_dependent_values(res_model, res_id, field_name): · Lee la columna JSONB cruda via raw SQL (psycopg2.sql.Identifier). · Resuelve el fallback consultandoir.default. · Devuelve por cada compañía accesible (env.companies):{company_id, company_name, is_specific, value_id, display_value}. · Soporta tiposmany2one,floateinteger.set_company_dependent_values(res_model, res_id, field_name, values_dict): · Recibe{str(company_id): value | false | "RESET"}. ·false→ guarda la clave con valorfalse(vacío explícito, is_specific=True). ·"RESET"→ hacepop()de la clave (restaura al fallback global). · Escribe el JSON crudo y llama ainvalidate_recordsetpara limpiar caché ORM. · Todos los identificadores SQL van porpsycopg2.sql.Identifier(no interpolación).static/src/company_dependent_service.js— Serviciocompany_dependentresModel:resId: la primera llamada dispara la RPC, las siguientes comparten la misma Promise en vuelo (batching automático).getMetaSync).invalidate(resModel, resId)para forzar re-fetch tras guardar en el diálogo.static/src/many2one_patch.js— Patch deMany2OneFieldpatch()sobre el prototipo.isCompanyDependentgetter:props.record.fields[name].company_dependent === true.setup(): llama auseService("company_dependent")yuseStatesiempre (sin condicional, respetando el orden de hooks de OWL). Solo registraonWillStartsiisCompanyDependentes verdadero._loadCDMeta(): no invalida la caché (para aprovechar el batching cuando hay múltiples campos company_dependent en el mismo formulario). La invalidación la hace el diálogo antes de llamar al callbackonSaved.Many2OneField.templateporbase_company_dependant.Many2OneFielde inyectaCompanyDependentButtonenMany2OneField.components.static/src/company_dependent_button.js—CompanyDependentButtonfa-building-o.text-primary(específico),text-muted(fallback),text-secondary opacity-50(cargando/null).record.save()) antes de abrir el diálogo, igual que haceTranslationButton.static/src/company_dependent_dialog.js—CompanyDependentDialogget_company_dependent_valuesenonWillStart._getEffectiveRow(row): combina el estado original con los cambios pendientes (local state) para renderizado optimista sin necesidad de re-fetch.getAutoCompleteSources(row): genera sources paraAutoCompletecononSelectdentro de cada opción (API correcta de AutoComplete; el callback NO va como prop del componente).onAutoCompleteChange(row, {inputValue, isOptionSelected}): detecta vaciado real (usuario borró texto y salió sin seleccionar) vs. selección normal.onResetRow(row): marca{ is_reset: true }→ el guardado enviará"RESET".onSave(): construye el dict paraset_company_dependent_values, invalida la caché del servicio, y llama aonSaved(que recarga el record y re-fetchea el meta para actualizar el color del icono)._t()) para evitar errores del tokenizador OWL con acentos/eñes en expresiones de template.static/src/templates.xmlbase_company_dependant.Many2OneField: wrapper flex con clase dinámicao_cd_fallback(activa CSS muted) +<CompanyDependentButton>condicional.base_company_dependant.CompanyDependentButton: botón confa-building-o, color segúnisSpecific.base_company_dependant.CompanyDependentDialog: tabla responsive con columnas Compañía / Valor (AutoComplete) / Estado (badge) / Reset (botón).&&y||(JS), noand/or(Python). Strings en props de componentes con comillas simples internas. Arrow functions con bloquesifextraídas a métodos del componente.static/src/company_dependent.css.o_cd_fallbackaplicacolor: var(--bs-secondary-color)+font-style: italical input y a los links readonly del campo..o_cd_btnalinea el icono inline sin romper el layout flex del formulario.company_dependentno llegaba al cliente → fix en_get_view_field_attributes.and/oren templates OWL → reemplazados por&&/||.ifen template → extraída a método del componente.class="o_input w-100"en prop de componente evaluado como JS →'o_input w-100'.onSelecten<AutoComplete>no existe → movido aoption.onSelect().onWillStart→ invalidar solo desde diálogo.