この記事は現在英語版のみです。翻訳は近日公開予定です。
A user clicks a button that should open a wizard popup. Nothing visible happens. The browser console shows a clean response. The server log shows the action method ran successfully. But the modal never appears.
A user clicks a button that should open a wizard popup. Nothing visible happens. The browser console shows a clean response. The server log shows the action method ran successfully. But the modal never appears. This is a quiet but maddening bug on Odoo 17.0/18.0/19.0 — the action ran, the action returned, but the client-side router did not interpret the return as "open a modal".
Quick Fix
Verify the action method returns a properly-shaped action dict with target='new':
def action_open_priority_wizard(self):
self.ensure_one()
return {
'name': 'Set Priority',
'type': 'ir.actions.act_window',
'res_model': 'sale.order.priority.wizard',
'view_mode': 'form',
'target': 'new', # critical — opens as modal
'context': {'default_order_id': self.id},
}
Without 'target': 'new', Odoo navigates to a full-page form instead of opening a modal. With a typo or missing key, the client silently does nothing.
Why This Happens
When a Python button method returns a dictionary, Odoo's web client interprets it as an action to execute. The interpretation depends on every key being correct. The five common failure modes:
- Missing
'target': 'new'— the action opens as a full page navigation instead of a modal, sometimes silently if the current breadcrumb stack is mid-flow. - Wrong
type—'ir.actions.act_window'versus'ir.actions.client'versus'ir.actions.server'each have different semantics. Wrong type = wrong handler. - Missing
view_idfor a model that has no default form. The client errors silently in some versions. - Permissions — the user does not have access to the wizard model. The action returns successfully but the next step (opening the wizard form) hits an
AccessErrorthat is swallowed. - JavaScript error in the frame surrounding the action processor, which aborts before the action is interpreted.
Step-by-Step Diagnosis
1. Open the JS console. F12, Console tab. Click the button. Look for any error.
2. Inspect the network response. F12, Network tab. The /web/dataset/call_kw request should return a 200 with a body containing your action dict. Verify every key:
{
"type": "ir.actions.act_window",
"res_model": "sale.order.priority.wizard",
"view_mode": "form",
"target": "new",
"context": {"default_order_id": 14}
}
If target is missing or anything else is wrong, the bug is in your Python.
3. Check ACL on the wizard model.
env['sale.order.priority.wizard'].with_user(user_id).check_access_rights('create', raise_exception=False)
False means the user cannot create the wizard, which silently fails the modal open.
4. Verify the wizard's form view exists.
SELECT id, name, type FROM ir_ui_view
WHERE model = 'sale.order.priority.wizard' AND type = 'form';
If empty, you have a model but no form view — the modal has nothing to render.
5. Test the action manually.
action = order.action_open_priority_wizard()
print(action)
# Verify shape matches expectation
Permanent Fix
Always return the full action dict with all required keys:
class SaleOrder(models.Model):
_inherit = 'sale.order'
def action_open_priority_wizard(self):
self.ensure_one()
return {
'name': _('Set Priority'),
'type': 'ir.actions.act_window',
'res_model': 'sale.order.priority.wizard',
'view_mode': 'form',
'view_id': self.env.ref('my_module.view_priority_wizard_form').id,
'target': 'new',
'context': {
'default_order_id': self.id,
'default_priority': self.priority,
},
}
Including the explicit view_id is belt-and-braces — it removes ambiguity if there are multiple form views for the wizard model.
For wizards triggered from a list view multi-select, also include active_ids:
def action_set_priority_bulk(self):
return {
'type': 'ir.actions.act_window',
'res_model': 'sale.order.priority.wizard',
'view_mode': 'form',
'target': 'new',
'context': {
'active_model': 'sale.order',
'active_ids': self.ids,
'active_id': self.ids[0] if self.ids else False,
},
}
The wizard's compute methods can read self.env.context.get('active_ids') to operate on the selection.
For wizards that should stay open after the action, return another action:
class SaleOrderPriorityWizard(models.TransientModel):
_name = 'sale.order.priority.wizard'
def action_apply(self):
order = self.env['sale.order'].browse(self.env.context['active_ids'])
order.priority = self.priority
return {
'type': 'ir.actions.act_window_close', # or another wizard
}
act_window_close cleanly closes the modal. Returning nothing leaves the modal open with no feedback.
For ACL fixes, grant the wizard model's create/read rights to the relevant group:
<record id="access_priority_wizard_user" model="ir.model.access">
<field name="name">priority.wizard user</field>
<field name="model_id" ref="my_module.model_sale_order_priority_wizard"/>
<field name="group_id" ref="sales_team.group_sale_salesman"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="True"/>
<field name="perm_create" eval="True"/>
<field name="perm_unlink" eval="True"/>
</record>
Wizard models are TransientModel — they are written to constantly during use, so users need full CRUD on them.
How to Prevent It
- Standardize wizard return dicts. Put a helper on a base mixin so every wizard call returns the same shape:
class ActionMixin(models.AbstractModel):
_name = 'my_module.action.mixin'
def _wizard_action(self, model, view_xmlid, ctx=None):
return {
'type': 'ir.actions.act_window',
'res_model': model,
'view_mode': 'form',
'view_id': self.env.ref(view_xmlid).id,
'target': 'new',
'context': dict(self.env.context, **(ctx or {})),
}
- Test wizard opens with Playwright. A 5-line test that clicks the button and asserts the modal element is visible catches every regression.
- Always grant ACL to wizards in the same group as the trigger button. Make this part of the module's test data — install the module, attempt to trigger the wizard as the smallest-privilege user who should have access, fail loudly if blocked.
- No silent server actions. Server-side methods that return
Nonefrom a button on a form leave the user with no visual feedback. Even areturn {'effect': 'rainbow_man'}is better than nothing. - Lint return dicts. A simple grep for
return {inside button methods, plus a check thattarget: 'new'is present for any method namedaction_open_*, catches most of these.
Related Errors
- Button onclick fires no action — closely related, sometimes the same root cause.
- Form view renders blank — what happens when the modal opens but the wizard form is broken.
- AccessError on res.users — sibling ACL error.
- attrs/states deprecated in Odoo 19 — buttons may stop firing actions after 19.0 upgrade if attrs were used.
Frequently Asked Questions
Why does my wizard open as a full page instead of a modal?
Almost always missing 'target': 'new'. The client defaults to full-page navigation when target is unset.
Can I open a wizard from JavaScript directly?
Yes, via this.actionService.doAction({...}):
import { useService } from "@web/core/utils/hooks";
setup() {
this.action = useService("action");
}
async openWizard() {
await this.action.doAction({
type: "ir.actions.act_window",
res_model: "sale.order.priority.wizard",
view_mode: "form",
target: "new",
});
}
But you still need ACL and a form view, just like the Python path.
Why does my wizard show stale data the second time I open it?
TransientModel rows are not deleted between opens. If you set a default in default_get and write to it, subsequent opens see the previous write. Use @api.model_create_multi to ensure fresh defaults, or explicitly clear stale rows in default_get.
How do I pass a complex object (like multiple ids) to a wizard?
Through context:
'context': {
'active_ids': self.ids,
'default_partner_ids': [(6, 0, partner_ids)],
}
For one2many and many2many initial values, the default_<field> context key with the right command tuple format works. For complex setup, override default_get on the wizard model to read from active_ids and compute the initial state explicitly.
How do I chain two wizards?
Return another action from the first wizard's submit:
def action_step1(self):
# do step 1 work
return {
'type': 'ir.actions.act_window',
'res_model': 'my.wizard.step2',
'view_mode': 'form',
'target': 'new',
'context': {'default_step1_result_id': self.id},
}
The first modal closes, the second opens immediately. Users see a continuous flow.
Should TransientModel records be cleaned up?
Odoo auto-cleans TransientModel rows older than transient_max_hours (default 1 hour) via a built-in vacuum cron. You normally do not need to do anything. If you store sensitive data in a wizard, set a shorter retention with _transient_max_hours = 0.1 on your wizard class.
Need help with a tricky Odoo error? ECOSIRE's Odoo experts have shipped 215+ modules — get expert help.
執筆者
ECOSIRE Research and Development Teamテクニカルライティング
ECOSIRE のテクニカルライティングチームは、Odoo ERP、Shopify による EC、AI エージェント、Power BI による分析、GoHighLevel による自動化、そしてエンタープライズソフトウェアのベストプラクティスを扱っています。私たちのガイドは、企業が十分な情報に基づいてテクノロジーを判断できるよう支援します。
関連記事
AI を活用した顧客セグメンテーション: RFM から予測クラスタリングまで
AI が顧客セグメンテーションを静的な RFM 分析から動的な予測クラスタリングにどのように変換するかを学びます。 Python、Odoo、および実際の ROI データを使用した実装ガイド。
サプライチェーン最適化のための AI: 可視性、予測、自動化
AI を使用してサプライ チェーンの運用を変革します。需要の検知、サプライヤーのリスク スコアリング、ルートの最適化、倉庫の自動化、混乱の予測などです。 2026年のガイド。
B2B卸売Eコマースガイド:バイヤーポータル・掛け払い・顧客別価格
バイヤーが実際に使うB2B卸売ポータルの作り方:顧客別価格、掛け払いと与信限度、発注書と承認付きチェックアウト、注文履歴からの再注文。