هذه المقالة متاحة حاليًا باللغة الإنجليزية فقط. الترجمة قريبا.
By the end of this recipe, your Odoo 19 form view will have a custom action button — at the top of the form, in the statusbar, or in a button-box — that triggers a Python method, optionally shows a confirmation dialog, and updates the UI based on the result. Skill required: Odoo developer with basic Python and XML. Time required: 30 minutes per button. ECOSIRE has shipped hundreds of custom buttons for clients, and the recipe below is the playbook.
The single most common UI customization in Odoo is "add a button that does X". The recipe below covers the four button types and when to use which.
What you will need
- Odoo version: 17, 18, or 19. View inheritance is identical.
- Custom module skeleton.
- Time: 30 min per button.
Step-by-step
1. Choose the button type
| Type | Where it appears | When to use |
|---|---|---|
| Header button | Top-left, before statusbar | Workflow actions (Confirm, Cancel) |
| Statusbar button | Visible on certain states | State transitions (Approve, Reject) |
| Button-box button | Top-right, with stat counter | Cross-relation views (View 5 Orders) |
| Page button | Inside a notebook page | Sub-resource actions |
2. Write the action method
models/sale_order.py:
from odoo import models
from odoo.exceptions import UserError
class SaleOrder(models.Model):
_inherit = 'sale.order'
def action_send_to_archive(self):
"""Send the SO PDF to a custom archive system."""
for order in self:
if order.state != 'done':
raise UserError(f'{order.name} is not in done state.')
pdf_data = self.env.ref('sale.action_report_saleorder')._render_qweb_pdf([order.id])[0]
# Upload to S3 or whatever
self.env['archive.service'].push(pdf_data, order.name)
order.message_post(body=f'Archived to external system on {fields.Datetime.now()}')
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': 'Archive Complete',
'message': f'{len(self)} orders archived.',
'type': 'success',
'sticky': False,
},
}
The return value can be True (do nothing) or a dict (action) that triggers UI behavior.
Verification: from odoo-bin shell, calling order.action_send_to_archive() runs without errors.
3. Add a header button via view inheritance
views/sale_order_views.xml:
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_order_form_archive_button" model="ir.ui.view">
<field name="name">sale.order.form.archive</field>
<field name="model">sale.order</field>
<field name="inherit_id" ref="sale.view_order_form"/>
<field name="arch" type="xml">
<xpath expr="//header/button[@name='action_confirm']" position="after">
<button name="action_send_to_archive"
type="object"
string="Archive"
class="btn-primary"
invisible="state != 'done'"
confirm="Send this order to the archive system?"
groups="sales_team.group_sale_manager"/>
</xpath>
</field>
</record>
</odoo>
Key attributes:
name: matches the Python method name.type='object': calls a method on the model.string: button label (usestring="$lang_string$"for i18n).class: Bootstrap button class.invisible: domain expression for conditional visibility.confirm: dialog text shown before action.groups: only visible to users in this group.
Verification: open a Done sale order; the Archive button appears. Open a Draft SO; it's hidden.
4. Add a button-box button (cross-relation count)
<xpath expr="//div[hasclass('oe_button_box')]" position="inside">
<button name="action_view_archive_history"
type="object"
class="oe_stat_button"
icon="fa-archive">
<field name="archive_count" widget="statinfo" string="Archives"/>
</button>
</xpath>
Pair with a count field on the model:
archive_count = fields.Integer(compute='_compute_archive_count')
def _compute_archive_count(self):
counts = self.env['archive.entry'].read_group(
[('source_order_id', 'in', self.ids)],
['source_order_id'], ['source_order_id']
)
map_count = {c['source_order_id'][0]: c['source_order_id_count'] for c in counts}
for so in self:
so.archive_count = map_count.get(so.id, 0)
def action_view_archive_history(self):
return {
'name': f'Archives for {self.name}',
'type': 'ir.actions.act_window',
'res_model': 'archive.entry',
'view_mode': 'list,form',
'domain': [('source_order_id', '=', self.id)],
}
Verification: the form shows a "5 Archives" stat button; clicking opens the list.
5. Add a context menu item (advanced)
For multi-record actions on the list view, register a server action:
<record id="action_archive_orders_bulk" model="ir.actions.server">
<field name="name">Archive Selected Orders</field>
<field name="model_id" ref="sale.model_sale_order"/>
<field name="binding_model_id" ref="sale.model_sale_order"/>
<field name="state">code</field>
<field name="code">records.action_send_to_archive()</field>
</record>
Verification: in the SO list, select multiple records; the Action menu shows "Archive Selected Orders".
6. Add a confirmation dialog programmatically
For more sophisticated than confirm="...", return a wizard:
def action_send_to_archive(self):
return {
'type': 'ir.actions.act_window',
'res_model': 'archive.confirm.wizard',
'view_mode': 'form',
'target': 'new', # opens as modal
'context': {'default_order_ids': self.ids},
}
Where archive.confirm.wizard is a TransientModel with its own form view and a confirm button.
Verification: clicking the button opens a custom dialog; clicking confirm in the dialog runs the action.
7. Refresh the form after action
Sometimes you want to refresh the form so the user sees updated data:
return {'type': 'ir.actions.client', 'tag': 'reload'}
Or to update specific fields without full reload:
return {'type': 'ir.actions.client', 'tag': 'reload', 'params': {'field_ids': [field_id]}}
Verification: after the action, the form data reflects the change without manual refresh.
8. Add success notification
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': 'Archive Complete',
'message': f'{len(self)} orders archived.',
'type': 'success', # success, warning, info, danger
'sticky': False,
'next': {'type': 'ir.actions.client', 'tag': 'reload'},
},
}
The next action runs after the user dismisses the notification — chaining a reload here keeps the UI in sync.
Verification: green toast appears top-right; form reloads.
Common mistakes
- Forgetting
type='object'. Without it, the button'snameis treated as a server action XML ID instead of a method. - Using
attrs={'invisible': [...]}. Deprecated; useinvisible="state != 'done'"directly. - Not handling multi-record case. Bulk select + button click passes a recordset, not a single record. Iterate.
- Forgetting
@api.model_create_multiif the action creates records. Single-recordcreate()calls fail in v17+. - Hardcoded view ID lookups. Use
self.env.ref('module.xml_id')not raw IDs.
Going further
Conditional button label: string="Archive" t-if="state == 'done'" t-else="Cannot Archive" (with QWeb expressions in v17+). Lets the same button serve different purposes depending on state.
Inline JavaScript: for buttons that need client-side logic (no server round-trip), write a small OWL component instead of a server-action button. Good for instant filters or display toggles.
Workflow integration: pair custom buttons with mail.activity to assign follow-up tasks automatically. Click "Approve", spawn a To-Do for the next reviewer.
Audit log: log every button click to a custom audit table for compliance. SOX / HIPAA jurisdictions require this for financial/clinical data changes.
Buttons triggering reports: name="action_report_xyz", type="action", id="ir.actions.report" opens the report directly. Faster than going through the print menu.
Multi-button sequences: chain buttons via the next action — clicking Approve fires the approval method then opens the next-step wizard automatically.
Disabled state vs hidden: disabled="state != 'done'" shows the button greyed out with hover-text explaining why; invisible hides it entirely. Pick based on UX preference.
Permissions error messaging: when a user without the right group sees a button (because group filtering is on the action method), provide a clear message instead of a cryptic security error.
Async actions with progress bar: for long-running actions, return a progress wizard that polls a backend method for completion percentage. Better UX than spinning forever.
Confirmation with checkbox: instead of a simple confirm dialog, return a wizard with "I understand this is irreversible" checkbox. Forces the user to acknowledge before destructive operations.
Bulk action with selection summary: the wizard wizard shows "You've selected 47 records" before confirming. Catches accidental bulk operations.
Keyboard shortcuts: bind buttons to keyboard shortcuts via the --data-hotkey attribute (Odoo 17+). Power users save clicks.
Sticky header: in long forms, set the header to position:sticky so action buttons remain visible while scrolling. Improves UX on long-content forms.
For complex form-view customizations including dynamic kanban cards, embedded charts, and inline actions, ECOSIRE Odoo customization builds the entire UI layer. Pair this with how to override an existing method using inheritance.
Frequently Asked Questions
Why doesn't my button show up?
Three usual culprits: the view is not loaded (check data in manifest), the inherited view ID is wrong (typo in inherit_id), or you're missing user permissions (groups excluded the current user). Also check the XPath — if it doesn't match anything, the inheritance silently does nothing.
How do I make the button only visible based on a custom field?
invisible="not my_custom_field" works for boolean fields. For more complex conditions: invisible="my_custom_field not in ['draft', 'pending']". The expression is evaluated client-side using py.js, so simple Python is fine.
Can a button trigger a wizard?
Yes — return an ir.actions.act_window dict with target='new'. The user sees a modal with the wizard form. The wizard is a models.TransientModel whose form view has its own buttons.
How do I localize the button label?
Use Odoo's translation system. Run odoo-bin --i18n-export=fr.po -l fr -d production to extract; translate the PO file. The string attribute on the button is automatically extractable.
Can a button call multiple methods?
Yes — write a single Python method that calls the others in sequence. Cleaner than chaining UI actions.
How do I trigger a button programmatically (e.g., from a cron)?
Just call the method directly: self.env['sale.order'].browse(id).action_send_to_archive(). The method works the same whether triggered by UI or code.
What's the difference between type='object' and type='action'?
type='object': calls a method on the model. type='action': triggers a server action defined in ir.actions.server. Server actions are reusable (can be triggered from automation rules too); methods are model-specific.
How do I show a confirmation only for certain users?
Group-based: groups="sales_team.group_sale_user" on a wrapper button shown to that group with confirm="...", and a separate button for managers without confirmation.
Can the button label change dynamically?
In v17+ yes via QWeb expressions in the view. Pre-v17, use attrs={'string': [...]} (deprecated). Or render the button via a small computed string field whose value drives the label.
How do I prevent double-clicks?
Disable the button after the first click and re-enable on response. The OWL framework does this automatically for type='object' buttons via its built-in debounce.
What about mobile?
All button types render in the Odoo mobile app. Test on mobile after adding to ensure tap targets are large enough.
Can I add an icon to the button?
Yes: icon="fa-archive" (Font Awesome) for stat buttons. For top buttons, prepend an <i class="fa fa-archive"/> inside the button. Or use class="fa fa-archive" on the button itself.
For complex UI work including custom widgets and dynamic forms, ECOSIRE Odoo customization ships fixed-price engagements. Or read how to build an Odoo dashboard tile for the dashboard-side complement.
بقلم
ECOSIRE TeamTechnical Writing
The ECOSIRE technical writing team covers Odoo ERP, Shopify eCommerce, AI agents, Power BI analytics, GoHighLevel automation, and enterprise software best practices. Our guides help businesses make informed technology decisions.
ECOSIRE
قم بتحويل أعمالك باستخدام Odoo ERP
تنفيذ وتخصيص ودعم خبير Odoo لتبسيط عملياتك.
مقالات ذات صلة
كيفية إضافة حقل مخصص في Odoo بدون الاستوديو (2026)
قم بإضافة حقول مخصصة عبر وحدة مخصصة في Odoo 19: وراثة النموذج، وامتداد العرض، والحقول المحسوبة، وقرارات المتجر/غير المتجر. الكود أولاً، يتم التحكم في الإصدار.
كيفية إضافة تقرير مخصص في أودو باستخدام التخطيط الخارجي
أنشئ تقرير PDF يحمل علامة تجارية في Odoo 19 باستخدام web.external_layout: قالب QWeb، تنسيق الورق، ربط الإجراء. مع طباعة الشعار + تجاوزات التذييل.
كيفية عمل نسخة احتياطية واستعادة قاعدة بيانات Odoo (بدون توقف)
دليل الإنتاج: pg_dump + filestore tarball، ودورة حياة S3، والاسترداد في الوقت المناسب، واستعادة اختبار cron، وRTO في أقل من 30 دقيقة. تم اختباره بواسطة ECOSIRE.