Customization (Properties)¶
Add tenant-defined fields to any record without writing code or running a schema migration. Administrators declare properties from the Settings UI; values land in a JSON column on the host record and surface alongside the static fields.
@api.model("blog.post", custom_properties=True)
class Post(DomainModel):
title = fields.Char(required=True)
body = fields.Text()
The custom_properties=True flag auto-injects a properties JSONB column on the table and a validator hook on the model. From that point on, an administrator can attach custom fields to blog.post from Settings → Customizations → Custom Fields — no Python change, no Alembic migration, no redeploy.
What you get¶
@api.model(..., custom_properties=True)— opt-in flag that auto-injects thepropertiesJSON column plus a pre-create / pre-update validator hook.ir.model.property.definition— per-tenant property schema: target model, key, label, type, default, optional comodel.ir.model.property.selection— option rows forselection-typed properties.RecordSet.get_property(key)/set_property(key, value)— typed accessors that read and write thepropertiesJSON.<DynamicProperties/>DSL element — drop into a form view to render the editable Properties tab.ir.model/ir.model.field/ir.model.field.selection— read-only persistent mirror of the runtime registry, refreshed on everyede migrate upgrade.env.ref("base.model_res_partner")— resolver from a stable external ID to the live record. Use it in XML data files or runtime code to reference any seeded row by name instead of UUID.- Settings → Customizations → Custom Fields — admin list/form UI for managing definitions.
How to use it¶
Mark a model as customizable¶
from ede.core import api, fields
from ede.core.kernel.model import DomainModel
@api.model("blog.post", custom_properties=True)
class Post(DomainModel):
title = fields.Char(required=True)
body = fields.Text()
Generate and apply an Alembic migration — ede migrate generate -m "blog.post: enable custom_properties" adds a blog_post.properties JSONB column defaulting to {}. The model is now ready to receive tenant-defined properties.
Define a property (Settings UI)¶
Navigate to Settings → Customizations → Custom Fields → New. Pick the target model from the model_id selector (the picker is filtered to models that opted into custom_properties=True), enter a key, label, and type, then save. The property is live immediately — every form view that includes <DynamicProperties/> shows the new field on next reload.
Define a property (XML data file)¶
<record id="prop_blog_post_topic_tags" model="ir.model.property.definition">
<field name="model_id" ref="base.model_blog_post"/>
<field name="key">topic_tags</field>
<field name="label">Topic Tags</field>
<field name="property_type">many2many</field>
<field name="comodel_id" ref="base.model_blog_tag"/>
</record>
model_id and comodel_id are many-to-one references resolved by external ID. The registry sync seeds those external IDs automatically — see Resolve a registry entry by external ID below.
Read and write property values¶
post = env.models["blog.post"].browse(post_uuid)
# Read
tags = post.get_property("topic_tags")
# Write — dispatches ede.update, so events, hooks, and audit logs all see it
post.set_property("topic_tags", [tag_uuid_1, tag_uuid_2])
set_property dispatches ede.update like any other field write — see Commands and events. The validator hook fires automatically before the write commits.
Render the Properties tab on a form view¶
<form>
<sheet>
<group>
<field name="title"/>
<field name="body"/>
</group>
<notebook>
<page string="Custom Properties">
<DynamicProperties/>
</page>
</notebook>
</sheet>
</form>
<DynamicProperties/> is a self-closing leaf. The presentation engine inlines a propertiesSchema block into the action's RenderPlan, and the React PropertiesEditor widget reuses the existing field-editor registry to render the right widget per type.
Resolve a registry entry by external ID¶
post_model = env.ref("base.model_blog_post") # → ir.model record
title_field = env.ref("base.field_blog_post__title") # → ir.model.field record
env.ref() is the read path for any row registered through ir.data.reference. The registry sync seeds canonical external IDs on every ede migrate upgrade:
| Pattern | Resolves to |
|---|---|
<app_key>.model_<key> |
ir.model row for the model |
<app_key>.field_<key>__<name> |
ir.model.field row for the field |
<app_key>.selection_<key>__<field>__<option> |
ir.model.field.selection row for an Enum option |
<key> is the model key with dots replaced by underscores (res.partner → res_partner). Pass required=True to raise instead of returning None on a miss.
Property types¶
property_type |
Stored as | Notes |
|---|---|---|
char |
string | Single-line text. |
integer |
int | |
decimal |
decimal | Full precision preserved in JSON. |
boolean |
bool | |
date |
ISO date string | |
datetime |
ISO datetime, UTC | |
selection |
option key (string) | Declare options as ir.model.property.selection rows linked back to the definition. |
reference |
target record_uuid |
Requires a comodel_id pointing at the target ir.model. Single-valued (many-to-one). |
many2many |
list of target record_uuids |
Same comodel_id requirement; multi-valued. |
The validator hook coerces every incoming value to the declared type and, for reference / many2many, checks that every UUID actually exists. A bad UUID raises a 422 with field path properties.<key>.
property_type cannot be changed once a definition is saved — both the UI and the backend hook reject the update. Delete and recreate the definition instead.
How it composes with other features¶
- Form views — drop
<DynamicProperties/>into any notebook page. - Commands & events —
set_propertydispatchesede.update, so all hooks and events fire normally. - Permissions — RBAC permissions on
ir.model.property.definitioncontrol who can manage the schema.
CLI¶
| Flag | Command | Purpose |
|---|---|---|
--no-registry-sync |
ede migrate upgrade |
Skip the ir.model* mirror sync. Mirrors the existing --no-cleanup flag; useful in CI or when debugging upgrade-time issues. |
Reference¶
- Models:
src/ede/foundation/base/models/ir_model_property.py,src/ede/foundation/base/models/ir_model.py - Registry sync:
src/ede/foundation/base/services/registry_sync.py - Validator hook:
src/ede/foundation/base/services/property_validator.py - DSL parser branch:
src/ede/core/services/presentation/dsl/parser.py(DynamicPropertieselement) - Frontend widget:
src/frontend/src/workspace/views/PropertiesEditor.tsx - Admin menus:
src/ede/foundation/base/data/customization_menus.xml - RBAC seed:
src/ede/foundation/base/data/ir.rbac.permission.csv