Create Dynamic Reports in Odoo 18
By Braincuber Team
Published on January 28, 2026
Static reports show fixed data at a point in time. Dynamic reports let users filter, sort, and analyze data in real-time without refreshing. They choose date ranges, groupings, and metrics—the report adapts instantly. In Odoo 18, dynamic reports combine OWL components (frontend), Python models (backend), and SQL queries (data) into an interactive dashboard accessible from any module.
This tutorial builds an Inventory Analysis Report from scratch. You'll create a client action, OWL component with filters, Python model with SQL queries, and export functionality (PDF/XLSX). The report aggregates stock movements by warehouse, product category, and time period—exactly what warehouse managers need for inventory decisions.
What You'll Build: A dynamic Inventory Analysis Report with date range filters, multiple report views (by warehouse, product, category), PDF/XLSX export, and clickable rows that navigate to source records.
Dynamic vs Static Reports
Real-Time Filtering
Users apply filters (dates, categories, warehouses) without page reload. Data updates instantly via AJAX calls to Python methods.
SQL-Powered Performance
Direct SQL queries aggregate thousands of records in milliseconds. No ORM overhead for read-only analytics.
Multi-Format Export
Export to PDF (QWeb templates) or XLSX (xlsxwriter). Users download reports for offline analysis or sharing.
Interactive Navigation
Click any row to drill down to source documents. View stock moves, products, or warehouses directly from the report.
Module Structure
Implementation Steps
Create Client Action & Menu
Define the menu entry and client action that triggers the OWL component.
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<!-- Client Action -->
<record id="action_inventory_dynamic_report" model="ir.actions.client">
<field name="name">Inventory Analysis</field>
<field name="tag">inventory_dynamic_report</field>
</record>
<!-- Menu Item -->
<menuitem id="menu_inventory_dynamic_report"
name="Dynamic Inventory Report"
parent="stock.menu_warehouse_report"
action="action_inventory_dynamic_report"
sequence="99"/>
</odoo>
Key Point: The tag field (inventory_dynamic_report) must match the action registry key in your JavaScript file. This links the menu click to your OWL component.
Build OWL Component
Create the JavaScript component that handles UI, filters, and data loading.
/** @odoo-module */
import { Component, useState, useRef } from "@odoo/owl";
import { registry } from "@web/core/registry";
import { useService } from "@web/core/utils/hooks";
import { download } from "@web/core/network/download";
class InventoryDynamicReport extends Component {
setup() {
this.orm = useService("orm");
this.action = useService("action");
this.uiService = useService("ui");
this.dateFrom = useRef("date_from");
this.dateTo = useRef("date_to");
this.reportType = useRef("report_type");
this.state = useState({
reportLines: [],
reportData: null,
currentReport: "by_warehouse",
wizardId: null,
});
this.loadData();
}
async loadData(wizardId = null) {
try {
if (!wizardId) {
this.state.wizardId = await this.orm.create(
"dynamic.inventory.report", [{}]
);
}
this.state.reportData = await this.orm.call(
"dynamic.inventory.report",
"get_report_data",
[this.state.wizardId]
);
this.state.reportLines = this.state.reportData?.report_lines || [];
} catch (error) {
console.error("Failed to load report data:", error);
}
}
async applyFilter() {
const filterData = {
date_from: this.dateFrom.el?.value || false,
date_to: this.dateTo.el?.value || false,
report_type: this.reportType.el?.value || "by_warehouse",
};
this.state.currentReport = filterData.report_type;
await this.orm.write(
"dynamic.inventory.report",
this.state.wizardId,
filterData
);
await this.loadData(this.state.wizardId);
}
viewStockMove(ev) {
const moveId = parseInt(ev.target.dataset.id);
return this.action.doAction({
type: "ir.actions.act_window",
res_model: "stock.move",
res_id: moveId,
views: [[false, "form"]],
target: "current",
});
}
async exportXlsx() {
this.uiService.block();
await download({
url: "/inventory_dynamic_xlsx_report",
data: {
model: "dynamic.inventory.report",
wizard_id: this.state.wizardId,
report_data: JSON.stringify(this.state.reportData),
},
});
this.uiService.unblock();
}
async printPdf() {
return this.action.doAction({
type: "ir.actions.report",
report_type: "qweb-pdf",
report_name: "inventory_dynamic_report.inventory_report_pdf",
report_file: "inventory_dynamic_report.inventory_report_pdf",
data: { report_data: this.state.reportData },
});
}
}
InventoryDynamicReport.template = "InventoryDynamicReport";
registry.category("actions").add("inventory_dynamic_report", InventoryDynamicReport);
Create Python Model with SQL Queries
Build the transient model that executes SQL queries and returns aggregated data.
from odoo import api, fields, models
class DynamicInventoryReport(models.TransientModel):
_name = "dynamic.inventory.report"
_description = "Dynamic Inventory Report Wizard"
date_from = fields.Date(string="Start Date")
date_to = fields.Date(string="End Date")
report_type = fields.Selection([
("by_warehouse", "By Warehouse"),
("by_product", "By Product"),
("by_category", "By Category"),
("by_movement", "By Movement Type"),
], string="Report Type", default="by_warehouse")
def get_report_data(self):
"""Main method called by OWL component via RPC."""
self.ensure_one()
report_lines = self._get_report_lines()
return {
"report_lines": report_lines,
"date_from": self.date_from,
"date_to": self.date_to,
"report_type": self.report_type,
}
def _get_report_lines(self):
"""Execute SQL based on report type."""
if self.report_type == "by_warehouse":
return self._report_by_warehouse()
elif self.report_type == "by_product":
return self._report_by_product()
elif self.report_type == "by_category":
return self._report_by_category()
elif self.report_type == "by_movement":
return self._report_by_movement()
return []
def _report_by_warehouse(self):
"""Aggregate stock moves by warehouse."""
query = """
SELECT
sw.name AS warehouse_name,
sw.id AS warehouse_id,
COUNT(sm.id) AS move_count,
SUM(sm.product_uom_qty) AS total_qty,
SUM(sm.product_uom_qty * pp.list_price) AS total_value
FROM stock_move sm
JOIN stock_location sl ON sm.location_dest_id = sl.id
JOIN stock_warehouse sw ON sl.warehouse_id = sw.id
JOIN product_product pp ON sm.product_id = pp.id
WHERE sm.state = 'done'
"""
if self.date_from:
query += " AND sm.date >= %s"
if self.date_to:
query += " AND sm.date <= %s"
query += " GROUP BY sw.id, sw.name ORDER BY total_qty DESC"
params = self._build_params()
self.env.cr.execute(query, params)
return self.env.cr.dictfetchall()
def _report_by_product(self):
"""Aggregate stock moves by product."""
query = """
SELECT
pt.name AS product_name,
pp.id AS product_id,
pp.default_code AS product_code,
SUM(sm.product_uom_qty) AS total_qty,
SUM(sm.product_uom_qty * pp.list_price) AS total_value
FROM stock_move sm
JOIN product_product pp ON sm.product_id = pp.id
JOIN product_template pt ON pp.product_tmpl_id = pt.id
WHERE sm.state = 'done'
"""
if self.date_from:
query += " AND sm.date >= %s"
if self.date_to:
query += " AND sm.date <= %s"
query += " GROUP BY pp.id, pt.name, pp.default_code ORDER BY total_qty DESC"
params = self._build_params()
self.env.cr.execute(query, params)
return self.env.cr.dictfetchall()
def _report_by_category(self):
"""Aggregate stock moves by product category."""
query = """
SELECT
pc.name AS category_name,
pc.id AS category_id,
COUNT(DISTINCT pp.id) AS product_count,
SUM(sm.product_uom_qty) AS total_qty,
SUM(sm.product_uom_qty * pp.list_price) AS total_value
FROM stock_move sm
JOIN product_product pp ON sm.product_id = pp.id
JOIN product_template pt ON pp.product_tmpl_id = pt.id
JOIN product_category pc ON pt.categ_id = pc.id
WHERE sm.state = 'done'
"""
if self.date_from:
query += " AND sm.date >= %s"
if self.date_to:
query += " AND sm.date <= %s"
query += " GROUP BY pc.id, pc.name ORDER BY total_value DESC"
params = self._build_params()
self.env.cr.execute(query, params)
return self.env.cr.dictfetchall()
def _report_by_movement(self):
"""Aggregate by movement type (in/out)."""
query = """
SELECT
CASE
WHEN sl_dest.usage = 'internal' THEN 'Stock In'
WHEN sl_src.usage = 'internal' THEN 'Stock Out'
ELSE 'Internal Transfer'
END AS movement_type,
COUNT(sm.id) AS move_count,
SUM(sm.product_uom_qty) AS total_qty
FROM stock_move sm
JOIN stock_location sl_src ON sm.location_id = sl_src.id
JOIN stock_location sl_dest ON sm.location_dest_id = sl_dest.id
WHERE sm.state = 'done'
"""
if self.date_from:
query += " AND sm.date >= %s"
if self.date_to:
query += " AND sm.date <= %s"
query += " GROUP BY movement_type ORDER BY total_qty DESC"
params = self._build_params()
self.env.cr.execute(query, params)
return self.env.cr.dictfetchall()
def _build_params(self):
"""Build query parameters from filter fields."""
params = []
if self.date_from:
params.append(self.date_from)
if self.date_to:
params.append(self.date_to)
return tuple(params)
Build QWeb Template
Create the OWL template that renders the report UI with filters and data tables.
<?xml version="1.0" encoding="UTF-8"?>
<templates>
<t t-name="InventoryDynamicReport" owl="1">
<div class="o_inventory_report">
<!-- Header -->
<div class="p-4 bg-white border-bottom">
<h1 class="h3 mb-0">Inventory Analysis Report</h1>
</div>
<!-- Filter Bar -->
<div class="p-3 bg-light border-bottom d-flex gap-3 align-items-end">
<div>
<label class="form-label mb-1">Start Date</label>
<input type="date" class="form-control" t-ref="date_from"/>
</div>
<div>
<label class="form-label mb-1">End Date</label>
<input type="date" class="form-control" t-ref="date_to"/>
</div>
<div>
<label class="form-label mb-1">Report Type</label>
<select class="form-select" t-ref="report_type">
<option value="by_warehouse">By Warehouse</option>
<option value="by_product">By Product</option>
<option value="by_category">By Category</option>
<option value="by_movement">By Movement Type</option>
</select>
</div>
<button class="btn btn-primary" t-on-click="applyFilter">
Apply Filters
</button>
<button class="btn btn-success" t-on-click="exportXlsx">
Export XLSX
</button>
<button class="btn btn-danger" t-on-click="printPdf">
Print PDF
</button>
</div>
<!-- Report Table -->
<div class="p-4" style="overflow-y: auto; height: 70vh;">
<!-- By Warehouse -->
<t t-if="state.currentReport === 'by_warehouse'">
<table class="table table-striped table-hover">
<thead class="table-dark">
<tr>
<th>Warehouse</th>
<th class="text-end">Moves</th>
<th class="text-end">Total Qty</th>
<th class="text-end">Total Value</th>
</tr>
</thead>
<tbody>
<t t-foreach="state.reportLines" t-as="line" t-key="line.warehouse_id">
<tr>
<td><t t-esc="line.warehouse_name"/></td>
<td class="text-end"><t t-esc="line.move_count"/></td>
<td class="text-end"><t t-esc="line.total_qty"/></td>
<td class="text-end">$<t t-esc="line.total_value?.toFixed(2)"/></td>
</tr>
</t>
</tbody>
</table>
</t>
<!-- By Product -->
<t t-if="state.currentReport === 'by_product'">
<table class="table table-striped table-hover">
<thead class="table-dark">
<tr>
<th>Product Code</th>
<th>Product Name</th>
<th class="text-end">Total Qty</th>
<th class="text-end">Total Value</th>
</tr>
</thead>
<tbody>
<t t-foreach="state.reportLines" t-as="line" t-key="line.product_id">
<tr>
<td><t t-esc="line.product_code"/></td>
<td><t t-esc="line.product_name"/></td>
<td class="text-end"><t t-esc="line.total_qty"/></td>
<td class="text-end">$<t t-esc="line.total_value?.toFixed(2)"/></td>
</tr>
</t>
</tbody>
</table>
</t>
<!-- By Category -->
<t t-if="state.currentReport === 'by_category'">
<table class="table table-striped table-hover">
<thead class="table-dark">
<tr>
<th>Category</th>
<th class="text-end">Products</th>
<th class="text-end">Total Qty</th>
<th class="text-end">Total Value</th>
</tr>
</thead>
<tbody>
<t t-foreach="state.reportLines" t-as="line" t-key="line.category_id">
<tr>
<td><t t-esc="line.category_name"/></td>
<td class="text-end"><t t-esc="line.product_count"/></td>
<td class="text-end"><t t-esc="line.total_qty"/></td>
<td class="text-end">$<t t-esc="line.total_value?.toFixed(2)"/></td>
</tr>
</t>
</tbody>
</table>
</t>
</div>
</div>
</t>
</templates>
Add XLSX Export Controller
Create the HTTP controller that generates Excel files.
import json
import io
from odoo import http
from odoo.http import content_disposition, request
try:
import xlsxwriter
except ImportError:
xlsxwriter = None
class InventoryReportController(http.Controller):
@http.route('/inventory_dynamic_xlsx_report', type='http',
auth='user', methods=['POST'], csrf=False)
def export_xlsx(self, model, wizard_id, report_data, **kw):
"""Generate and download XLSX report."""
data = json.loads(report_data)
output = io.BytesIO()
workbook = xlsxwriter.Workbook(output, {'in_memory': True})
worksheet = workbook.add_worksheet('Inventory Report')
# Styles
header_format = workbook.add_format({
'bold': True,
'bg_color': '#714B67',
'font_color': 'white',
'border': 1,
})
cell_format = workbook.add_format({'border': 1})
number_format = workbook.add_format({'border': 1, 'num_format': '#,##0.00'})
# Headers based on report type
report_type = data.get('report_type', 'by_warehouse')
headers = self._get_headers(report_type)
for col, header in enumerate(headers):
worksheet.write(0, col, header, header_format)
# Data rows
for row_idx, line in enumerate(data.get('report_lines', []), start=1):
values = self._get_row_values(line, report_type)
for col, value in enumerate(values):
fmt = number_format if isinstance(value, (int, float)) else cell_format
worksheet.write(row_idx, col, value, fmt)
workbook.close()
output.seek(0)
response = request.make_response(
output.read(),
headers=[
('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'),
('Content-Disposition', content_disposition('inventory_report.xlsx')),
]
)
return response
def _get_headers(self, report_type):
"""Return column headers based on report type."""
headers_map = {
'by_warehouse': ['Warehouse', 'Moves', 'Total Qty', 'Total Value'],
'by_product': ['Product Code', 'Product Name', 'Total Qty', 'Total Value'],
'by_category': ['Category', 'Products', 'Total Qty', 'Total Value'],
'by_movement': ['Movement Type', 'Moves', 'Total Qty'],
}
return headers_map.get(report_type, [])
def _get_row_values(self, line, report_type):
"""Extract row values based on report type."""
if report_type == 'by_warehouse':
return [line.get('warehouse_name'), line.get('move_count'),
line.get('total_qty'), line.get('total_value')]
elif report_type == 'by_product':
return [line.get('product_code'), line.get('product_name'),
line.get('total_qty'), line.get('total_value')]
elif report_type == 'by_category':
return [line.get('category_name'), line.get('product_count'),
line.get('total_qty'), line.get('total_value')]
elif report_type == 'by_movement':
return [line.get('movement_type'), line.get('move_count'),
line.get('total_qty')]
return []
Conclusion
Dynamic reports in Odoo 18 combine OWL's reactive UI, Python's data processing power, and SQL's aggregation efficiency. Users filter data in real-time, export to PDF/XLSX, and drill into source records—all from a single interface. This pattern applies to any module: sales analysis, accounting summaries, HR metrics, or manufacturing KPIs.
Key Takeaways: Client actions link menus to OWL components. ORM calls bridge JavaScript and Python. SQL queries optimize read-heavy analytics. QWeb templates render dynamic tables. HTTP controllers handle file exports.
