Odoo gives you three independent mechanisms to control what users see: the groups attribute on field definitions (who can see a field at all), the domain attribute on list and search views (which records appear), and the attrs and invisible conditions on view elements (whether specific elements display for a given record state). Mixing them up - using invisible where groups is needed, or using groups where domain is needed - creates subtle bugs that pass testing but fail in production.
Layer 1: groups - who can see the field#
The groups attribute on a field definition controls whether the field renders at all for users not in the specified group. It is evaluated server-side at view rendering time.
On the Python field:
class SaleOrder(models.Model):
_inherit = 'sale.order'
margin = fields.Float(groups='product.group_show_purchase_price')Non-members see the field as empty (False/0). The field is not redacted in the API response for security purposes - it is simply excluded from the default fields_get and not rendered in the view.
On a view element:
<field name="margin" groups="product.group_show_purchase_price"/>The view element is removed from the rendered arch before sending to the client. Non-members see no trace of the element in the DOM.
When to use groups:
- Hiding salary, cost, or margin data from non-managers.
- Hiding advanced configuration fields from basic users.
- Hiding admin-only buttons from regular users.
Important: groups on a view element does not protect the underlying data through the ORM. A user without the group can still call env['sale.order'].read([1], ['margin']) via RPC if they know the field name. True data protection requires the groups= attribute on the Python field definition.
Layer 2: domain - which records appear#
A domain expression filters which records a view displays or a Many2one field offers as options.
On a list/search view:
<record id="view_sale_order_list_mine" model="ir.ui.view">
<field name="model">sale.order</field>
<field name="arch" type="xml">
<list>
<field name="name"/>
<field name="partner_id"/>
<field name="amount_total"/>
</list>
</field>
<field name="domain">[('user_id', '=', uid)]</field>
</record>This view only shows orders assigned to the current user. The domain is evaluated server-side; it does not prevent users from querying other records via the API.
On a Many2one field in a view:
<field name="product_id" domain="[('type', '=', 'consu'), ('sale_ok', '=', True)]"/>The dropdown only shows consumable, saleable products.
Dynamic domain:
<field name="product_id" domain="[('categ_id', '=', categ_id)]"/>The domain references another field's value (categ_id). Odoo evaluates this on the client side - it changes as the user edits the form.
Layer 3: invisible - conditional display#
The invisible attribute (or legacy attrs={'invisible': [...]}) controls whether a view element renders based on the current record's field values. It is evaluated client-side.
Modern syntax (Odoo 16+):
<field name="delivery_date" invisible="state not in ['sale', 'done']"/>
<button name="action_cancel" invisible="state == 'cancel'"/>
<group invisible="partner_id == False">
<field name="partner_credit_limit"/>
</group>Legacy attrs syntax (still valid):
<field name="delivery_date" attrs="{'invisible': [('state', 'not in', ['sale', 'done'])]}"/>When to use invisible:
- Hiding fields that are irrelevant in certain states (e.g., delivery date on a draft order).
- Hiding buttons after an action completes (e.g., "Confirm" button after state = confirmed).
- Conditional display of related records that only exist in certain workflows.
When NOT to use invisible:
- Do not use
invisibleas a security mechanism. The element is still in the DOM and the field value is still in the JSON response. A determined user can read invisible fields from the browser.
readonly - preventing edits#
readonly prevents editing without hiding the field.
<field name="amount_total" readonly="1"/>
<!-- Or conditionally: -->
<field name="partner_id" readonly="state != 'draft'"/>Server-side readonly protection:
The view-level readonly is cosmetic. It prevents UI edits but not API writes. To prevent API writes, override write() or use _check_constraint():
def write(self, vals):
for rec in self:
if rec.state != 'draft' and 'partner_id' in vals:
raise UserError("Cannot change partner after confirmation.")
return super().write(vals)required - mandatory fields#
required on a field means the form cannot be saved without a value.
<field name="street" required="partner_type == 'delivery'"/>Conditional required: the field is mandatory only when partner_type equals delivery.
Server-side required enforcement for fields not always required:
@api.constrains('partner_type', 'street')
def _check_delivery_address(self):
for rec in self:
if rec.partner_type == 'delivery' and not rec.street:
raise ValidationError("Delivery address requires a street.")Combining the layers#
A typical pattern for a manager-only, confirmed-order-only field:
# Model: visible only to managers
internal_margin = fields.Float(groups='sales_team.group_sale_manager')<!-- View: hidden on draft orders, editable only by managers -->
<field name="internal_margin"
groups="sales_team.group_sale_manager"
readonly="state != 'sale'"
invisible="state == 'draft'"/>This combination:
- Hides the field from non-managers at the Python level (no API exposure).
- Hides the UI element from non-managers.
- Hides it from everyone on draft orders.
- Allows only managers to edit it when the order is confirmed.

