Dashboard Approaches in Odoo
Odoo provides several ways to build dashboards, ranging from zero-code to fully custom OWL components:
- Stat buttons on forms — quick KPI displays on existing forms
- Kanban dashboard views — card-based overview with aggregate data
- Custom OWL components — fully custom JavaScript dashboards
- Pivot/Graph views — built-in analytical views
Stat Buttons
The simplest dashboard element. Stat buttons sit at the top of form views and show a count or value with an icon:
<div class="oe_button_box" name="button_box">
<button class="oe_stat_button" icon="fa-envelope"
name="action_view_messages" type="object">
<field name="message_count" widget="statinfo" string="Messages"/>
</button>
<button class="oe_stat_button" icon="fa-money"
name="action_view_invoices" type="object">
<field name="invoice_count" widget="statinfo" string="Invoices"/>
</button>
<button class="oe_stat_button" icon="fa-star"
name="action_view_rating" type="object">
<field name="avg_rating" widget="statinfo" string="Rating"/>
</button>
</div>The Python side defines the computed fields and action methods:
class Partner(models.Model):
_inherit = 'res.partner'
invoice_count = fields.Integer(compute='_compute_invoice_count')
def _compute_invoice_count(self):
data = self.env['account.move'].read_group(
[('partner_id', 'in', self.ids), ('move_type', '=', 'out_invoice')],
['partner_id'], ['partner_id']
)
mapped = {d['partner_id'][0]: d['partner_id_count'] for d in data}
for partner in self:
partner.invoice_count = mapped.get(partner.id, 0)
def action_view_invoices(self):
return {
'type': 'ir.actions.act_window',
'name': 'Invoices',
'res_model': 'account.move',
'view_mode': 'tree,form',
'domain': [('partner_id', '=', self.id), ('move_type', '=', 'out_invoice')],
}Kanban Dashboard View
Kanban views with dashboard class provide a card-based overview. This is the pattern used by CRM, Sales, and Accounting dashboards:
<record id="view_sale_dashboard" model="ir.ui.view">
<field name="name">sale.dashboard</field>
<field name="model">sale.report</field>
<field name="arch" type="xml">
<kanban class="o_kanban_dashboard">
<templates>
<t t-name="card">
<div class="o_kanban_card_content">
<div class="o_kanban_primary_left">
<span class="o_value"><t t-esc="record.amount_total.value"/></span>
<span class="o_label">Total Revenue</span>
</div>
</div>
</t>
</templates>
</kanban>
</field>
</record>Dashboard Data with read_group
Dashboard controllers typically aggregate data using read_group:
class SaleDashboard(models.Model):
_name = 'sale.dashboard'
_description = 'Sales Dashboard'
_auto = False # no database table — it is a virtual model
def _get_dashboard_data(self):
Order = self.env['sale.order']
today = fields.Date.today()
month_start = today.replace(day=1)
year_start = today.replace(month=1, day=1)
# This month
month_data = Order.read_group(
[('state', '=', 'sale'), ('date_order', '>=', month_start)],
['amount_total:sum'], []
)
# By salesperson
by_user = Order.read_group(
[('state', '=', 'sale'), ('date_order', '>=', year_start)],
['amount_total:sum', 'user_id'],
['user_id'],
orderby='amount_total desc',
limit=5
)
# Monthly trend
trend = Order.read_group(
[('state', '=', 'sale'), ('date_order', '>=', year_start)],
['amount_total:sum', 'date_order'],
['date_order:month']
)
return {
'month_total': month_data[0].get('amount_total', 0) if month_data else 0,
'month_count': month_data[0].get('__count', 0) if month_data else 0,
'top_salespeople': [{
'name': d['user_id'][1] if d['user_id'] else 'Unassigned',
'total': d['amount_total'],
} for d in by_user],
'monthly_trend': [{
'month': d['date_order:month'],
'total': d['amount_total'],
} for d in trend],
}SQL View for Complex Dashboards
For dashboards requiring joins or complex aggregations, create a SQL view model:
class SaleReport(models.Model):
_name = 'sale.report.custom'
_description = 'Custom Sale Report'
_auto = False # uses a database VIEW, not a table
_order = 'date desc'
date = fields.Date(readonly=True)
partner_id = fields.Many2one('res.partner', readonly=True)
user_id = fields.Many2one('res.users', readonly=True)
amount = fields.Float(readonly=True)
margin = fields.Float(readonly=True)
def init(self):
tools.drop_view_if_exists(self.env.cr, self._table)
self.env.cr.execute(f"""
CREATE OR REPLACE VIEW {self._table} AS (
SELECT
so.id AS id,
so.date_order::date AS date,
so.partner_id,
so.user_id,
so.amount_total AS amount,
so.margin AS margin
FROM sale_order so
WHERE so.state = 'sale'
)
""")This model can then use all standard Odoo views — tree, pivot, graph, kanban — and benefits from read_group aggregation.
OWL Component Dashboard
For fully custom dashboards with charts and interactive elements, build an OWL component:
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { Component, onWillStart, useState } from "@odoo/owl";
import { useService } from "@web/core/utils/hooks";
class SalesDashboard extends Component {
static template = "my_module.SalesDashboard";
setup() {
this.orm = useService("orm");
this.state = useState({ data: null, loading: true });
onWillStart(async () => {
this.state.data = await this.orm.call(
"sale.dashboard", "_get_dashboard_data", []
);
this.state.loading = false;
});
}
}
registry.category("actions").add("sale_custom_dashboard", SalesDashboard);Register it as a client action:
<record id="action_sale_dashboard" model="ir.actions.client">
<field name="name">Sales Dashboard</field>
<field name="tag">sale_custom_dashboard</field>
</record>Best Practices
- Start simple: stat buttons and pivot views before building custom OWL dashboards
- Use
read_groupfor all aggregations — never load individual records for dashboard counts - Cache dashboard data if it is expensive to compute — use
@ormcachewith a short TTL - For SQL view models, always use
_auto = Falseandinit()withCREATE OR REPLACE VIEW - Make dashboard actions accessible via menu items for discoverability
- Test dashboard performance with production-scale data — read_group on millions of records can still be slow without proper indexes