How to Create a Custom XLSX Export in Odoo 19 List Views
Odoo 19's standard export tool is generic — it gives users a raw column dump with no branding, no number formatting, and no control over column order or width. When a client needs a polished, ready-to-share Excel file straight from a list view, you need a custom export. This complete tutorial is a beginner guide and step by step guide that shows you exactly how to build one. You will learn how to extend the OWL (Odoo Web Library) list controller, inject a conditional download button into the control panel, and stream a fully formatted XLSX file from a Python HTTP controller using the xlsxwriter library. The result respects the user's active filters and supports exporting either the entire filtered domain or just the records they selected. This complete tutorial walks through every file you need so you can ship the feature end to end.
What You'll Learn:
- How to extend Odoo 19's ListController with a custom OWL controller and register a new view type
- How to build a download payload from the list's active domain and the user's current selection
- How to call Odoo's download service to hit a custom HTTP route from the browser
- How to inject a conditional download button into the control panel using a context flag
- How to write a Python http.Controller route that parses the payload and rebuilds the search domain
- How to generate an in-memory Excel file with xlsxwriter, custom formats, and a reusable COLUMNS schema
- How to return the workbook bytes as a proper XLSX HTTP response with the correct content disposition
- How to register the custom view via js_class and declare assets and dependencies in the manifest
Why Build a Custom XLSX Export Instead of Using Odoo's Default?
Odoo's built-in export dialog is fine for ad-hoc data pulls, but it cannot produce a client-ready spreadsheet. It does not let you set fixed column widths, apply bold bordered headers, format numbers to two decimal places, rename columns to business-friendly labels, or control the export order. A custom export gives you full control over the final file while keeping the familiar list-view experience for the user — they simply click a download icon in the toolbar and a formatted Excel file is generated server-side and streamed straight to their browser.
The architecture has two halves. The frontend is an OWL controller that extends the standard ListController; it reads the list's current domain and selection, packages them into a JSON payload, and calls Odoo's download service. The backend is a Python http.Controller route that receives the payload, rebuilds the search domain, runs the search, and uses xlsxwriter to assemble a formatted workbook entirely in memory before returning the raw bytes. In this tutorial we build the feature against the timesheets.analysis.report model as a concrete example, but the same pattern applies to any model.
Custom List Controller
Extend Odoo 19's ListController in an OWL module and register it as a new view type. The controller exposes an onDirectExportData handler that reads the model root's domain and selection, then triggers the download — all without leaving the list view.
Conditional Download Button
Inherit web.ListView and inject a download button into the control panel's additional-actions slot. A context flag (from_daily_timesheet) shows the button only where you want it, keeping every other list view untouched.
xlsxwriter Backend
A Python http.Controller builds the workbook in memory with xlsxwriter. Reusable cell, number, and header formats plus a declarative COLUMNS schema keep the export tidy, with bordered headers, fixed widths, and two-decimal numeric formatting.
Domain & Selection Export
The payload carries the active domain so filters are respected. If the user has ticked specific rows, the controller narrows the export to those record IDs instead. One handler covers both "export everything filtered" and "export only what I picked".
File Structure: What You Will Create
A custom export feature spans four source files plus the manifest. The table below lists each file and its purpose so you can scaffold the module before writing any code.
| File Path | Purpose |
|---|---|
| static/src/js/daily_timesheet_list.js | OWL controller extending ListController; builds the payload and calls the download service. Registers the custom view type. |
| static/src/xml/daily_timesheet_list.xml | OWL template inheriting web.ListView; injects the conditional download button into the control panel. |
| controllers/daily_timesheet.py | Python http.Controller route that parses the payload, rebuilds the domain, and streams the formatted XLSX file. |
| views/timesheet_views.xml | Inherited list view record that sets js_class to the registered custom view type so the controller and button apply. |
| __manifest__.py | Declares the JS and XML assets under web.assets_backend and lists xlsxwriter as an external Python dependency. |
Step by Step Guide: Building the Custom XLSX Export
This step by step guide walks through the six stages in build order. You will scaffold the module, extend the controller, wire up the button, write the Python route, register the view, and finally declare the assets and dependencies that make everything load.
Set Up the Module Structure
Create a custom module (for example daily_timesheet) with the standard Odoo layout. Add a static/src/js folder for the OWL controller, a static/src/xml folder for the template, a controllers folder for the Python route (with an __init__.py that imports the controller), and a views folder for the inherited list view. Make sure xlsxwriter is installed in your Python environment with pip install xlsxwriter before you start, because the backend controller imports it at module load time.
Extend the List Controller
In daily_timesheet_list.js, subclass ListController and add an onDirectExportData method. Build a payload containing this.model.root.domain. If the user has selected rows but not "select all", map the selection to record IDs and add them to the payload. Then call Odoo's download service, passing the JSON-encoded payload and the custom route URL. Finally, set the controller's template and register the view under the type name daily_timesheet_list so it can be referenced by js_class.
Add the Download Button
In daily_timesheet_list.xml, inherit web.ListView in primary mode and use an xpath to inject a button inside the control panel's control-panel-additional-actions slot. Wrap the button in a t-if that checks a context flag (from_daily_timesheet) so it only appears on the views you intend. Wire the button's click event to onDirectExportData and give it a download icon so users instantly recognise its purpose.
Build the Python Controller
In daily_timesheet.py, define an http.route of type http with auth='user' and the POST method. Parse the JSON payload, read the domain and optional ids, and rebuild the search domain accordingly. Search the timesheets.analysis.report model, then create an in-memory xlsxwriter workbook. Define reusable formats and a declarative COLUMNS schema, write the header and data rows, and return the bytes via request.make_response with the correct XLSX content type and content disposition.
Register the Custom View
Create an inherited ir.ui.view record that extends the existing list view (here hr_timesheet.timesheets_analysis_report_list). Use an xpath on the <list> element to set the js_class attribute to daily_timesheet_list — the exact name you registered in the JS file. This binds your custom controller and template to that list view. Remember to pass the from_daily_timesheet context flag in the window action so the download button appears.
Declare Assets and Dependencies
In __manifest__.py, add the JS and XML files to the web.assets_backend bundle so Odoo loads them in the backend client. Declare xlsxwriter under external_dependencies > python so Odoo refuses to install the module if the library is missing rather than crashing at runtime. Add the views XML to the data list. Then upgrade the module and hard-refresh the browser to load the new assets.
Step 1 & 2: The OWL List Controller (daily_timesheet_list.js)
This is the heart of the frontend. The controller extends ListController and adds onDirectExportData, which builds a payload from the current domain, optionally narrows it to the selected record IDs, and calls Odoo's download service against the custom route. At the bottom it registers a brand-new view type named daily_timesheet_list by spreading the standard listView and swapping in our controller.
/** @odoo-module */
import { ListController } from "@web/views/list/list_controller";
import { listView } from "@web/views/list/list_view";
import { registry } from "@web/core/registry";
import { download } from "@web/core/network/download";
export class DailyTimesheetListController extends ListController {
async onDirectExportData() {
const payload = {
domain: this.model.root.domain,
};
if (!this.model.root.isDomainSelected && this.model.root.selection.length > 0) {
const selectedIds = this.model.root.selection.map((r) => r.resId);
payload.ids = selectedIds;
}
await download({
data: {
data: JSON.stringify(payload),
},
url: `/daily_timesheet/export_xlsx`,
});
}
}
DailyTimesheetListController.template = "daily_timesheet.ListView";
export const dailyTimesheetListView = {
...listView,
Controller: DailyTimesheetListController,
};
registry.category("views").add("daily_timesheet_list", dailyTimesheetListView);
A few details are worth calling out. this.model.root.domain is the list's currently active search domain, including any filters or search facets the user applied — so exporting the domain always respects what the user sees. The isDomainSelected flag is true only when the user clicked "select all records matching this filter"; in that case we deliberately omit ids and let the backend export the whole filtered domain. When specific rows are ticked, selection.map((r) => r.resId) collects their database IDs and the backend narrows to exactly those records.
Step 3: The Download Button Template (daily_timesheet_list.xml)
This OWL template inherits the standard web.ListView in primary mode and uses an xpath to inject a download button into the control panel's additional-actions slot. The button is wrapped in a t-if on a context flag so it only renders where you want it, and its click is bound to onDirectExportData.
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="daily_timesheet.ListView" t-inherit="web.ListView" t-inherit-mode="primary">
<xpath expr="//t[@t-set-slot='control-panel-additional-actions']" position="inside">
<t t-if="props.context.from_daily_timesheet">
<button type="button" class="btn btn-light o_button_export ms-1" style="font-size: 16px; padding: 4px 8px;" t-on-click="onDirectExportData" title="Download">
<i class="fa fa-download"/>
</button>
</t>
</xpath>
</t>
</templates>
The key is the t-inherit-mode="primary" attribute, which creates a new template (daily_timesheet.ListView) based on web.ListView without altering the original. The props.context.from_daily_timesheet check means the button is invisible everywhere unless that flag is present in the action context, which we set in Step 5. This keeps the rest of your Odoo instance completely unaffected.
Step 4: The Python Export Controller (daily_timesheet.py)
This is the backend route. It parses the JSON payload, rebuilds the domain (or filters by the selected IDs), searches the report model, and builds a formatted in-memory workbook with xlsxwriter. The declarative COLUMNS list keeps the header labels, value accessors, widths, formats, and flags in one readable place. Note the imports at the top — you need io, json, xlsxwriter, plus http, request, and content_disposition from Odoo.
import io
import json
import xlsxwriter
from odoo import http
from odoo.http import request, content_disposition
class DailyTimesheetExportController(http.Controller):
@http.route('/daily_timesheet/export_xlsx', type='http', auth='user', methods=['POST'])
def export_xlsx(self, data, **kwargs):
params = json.loads(data)
domain = params.get('domain', [])
ids = params.get('ids', None)
model = request.env['timesheets.analysis.report']
if ids:
domain = [('id', 'in', ids)]
records = model.search(domain, order='date asc, employee_id asc')
output = io.BytesIO()
workbook = xlsxwriter.Workbook(output, {'in_memory': True})
sheet = workbook.add_worksheet('Daily Timesheet')
normal_header_fmt = workbook.add_format({
'bold': True, 'border': 1, 'align': 'center', 'valign': 'vcenter',
})
cell_fmt = workbook.add_format({
'border': 1, 'align': 'left', 'valign': 'vcenter',
})
number_fmt = workbook.add_format({
'border': 1, 'align': 'right', 'valign': 'vcenter', 'num_format': '0.00',
})
COLUMNS = [
('Employee', lambda r: r.employee_id.name or '', 20, cell_fmt, False),
('Description', lambda r: r.name or '', 30, cell_fmt, False),
('Activity Group', lambda r: r.project_id.name or '', 25, cell_fmt, True),
('Task', lambda r: r.task_id.name or '', 25, cell_fmt, False),
('Business Unit', lambda r: r.company_id.name or '', 20, cell_fmt, True),
('Department', lambda r: r.department_id.name or '', 20, cell_fmt, False),
('Time Spent', lambda r: r.unit_amount, 12, number_fmt, False)
]
sheet.set_row(0, 20)
for col_idx, (label, _, width, _, is_yellow) in enumerate(COLUMNS):
fmt = normal_header_fmt
sheet.write(0, col_idx, label, fmt)
sheet.set_column(col_idx, col_idx, width)
for row_idx, record in enumerate(records, start=1):
sheet.set_row(row_idx, 15)
for col_idx, (_, accessor, _, fmt, _) in enumerate(COLUMNS):
try:
value = accessor(record)
except Exception:
value = ''
if isinstance(value, float):
sheet.write_number(row_idx, col_idx, value, fmt)
else:
sheet.write(row_idx, col_idx, value or '', fmt)
workbook.close()
output.seek(0)
xlsx_data = output.read()
return request.make_response(
xlsx_data,
headers=[
('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'),
('Content-Disposition', content_disposition('daily_timesheet.xlsx')),
],
)
Walking through it: the route is registered as type='http' (not JSON-RPC) so it can return a raw file download, with auth='user' so only logged-in users can call it. The data argument is the JSON string our OWL controller sent; json.loads turns it back into a dict. If ids is present we replace the domain entirely with [('id', 'in', ids)] to export exactly the selected records, otherwise we keep the user's filtered domain. The workbook is built with {'in_memory': True} so nothing touches disk. After writing, workbook.close() finalises the file, we rewind the buffer with seek(0), read the bytes, and hand them to request.make_response with the XLSX MIME type and a content_disposition header that gives the browser a filename.
Understanding the COLUMNS Schema Tuple
Each entry in the COLUMNS list is a five-element tuple. Keeping all the per-column metadata together makes the export trivially extensible — to add a column you append one tuple. The table below explains each position.
| Position | Element | Meaning |
|---|---|---|
| 1 | label | The business-friendly column header text written into row 0 (e.g. "Employee", "Time Spent"). |
| 2 | accessor lambda | A function that takes a record and returns the cell value (e.g. lambda r: r.employee_id.name or ''). Wrapped in try/except so a missing relation never breaks the export. |
| 3 | width | The fixed column width in Excel character units, applied via sheet.set_column. |
| 4 | format | The xlsxwriter cell format for data rows — cell_fmt for text or number_fmt for numeric two-decimal values. |
| 5 | flag | A boolean (here named is_yellow) reserved for conditional styling such as highlighting a column. Available in the header loop for future use. |
Step 5: Register the Custom View (views/timesheet_views.xml)
This inherited view record points the existing list view at your custom controller by setting the js_class attribute on the <list> element to daily_timesheet_list — the exact name registered in the JS file. With this in place, the custom controller and button template take over for that list view.
<record id="timesheets_analysis_report_list_inherited" model="ir.ui.view">
<field name="name">timesheets.analysis.report.list.inherited</field>
<field name="model">timesheets.analysis.report</field>
<field name="inherit_id" ref="hr_timesheet.timesheets_analysis_report_list"/>
<field name="arch" type="xml">
<xpath expr="//list" position="attributes">
<attribute name="js_class">daily_timesheet_list</attribute>
</xpath>
</field>
</record>
The js_class attribute is the bridge between the XML view definition and your JavaScript. When Odoo renders this list, it looks up daily_timesheet_list in the views registry, finds the entry you added with registry.category("views").add(...), and uses your DailyTimesheetListController and daily_timesheet.ListView template instead of the defaults. Don't forget to pass {'from_daily_timesheet': True} in the context of the window action that opens this list, so the conditional button actually appears.
Step 6: Declare Assets and the xlsxwriter Dependency
Finally, the manifest must register the frontend assets and declare the external Python dependency. Add the JS and XML files to the web.assets_backend bundle and list xlsxwriter under external_dependencies.
{
'name': 'Daily Timesheet XLSX Export',
'version': '19.0.1.0.0',
'depends': ['web', 'hr_timesheet'],
'external_dependencies': {
'python': ['xlsxwriter'],
},
'data': [
'views/timesheet_views.xml',
],
'assets': {
'web.assets_backend': [
'daily_timesheet/static/src/js/daily_timesheet_list.js',
'daily_timesheet/static/src/xml/daily_timesheet_list.xml',
],
},
'installable': True,
'license': 'LGPL-3',
}
xlsxwriter Must Be Installed and Declared
The controller imports xlsxwriter at module load time, so the library must be present in your Python environment — run pip install xlsxwriter. You must also declare it under external_dependencies > python in the manifest. If you skip either step, Odoo will fail to load the module (or raise an ImportError the first time the route is hit), and the scheduled actions and views inside the module will not register correctly.
Key Insight: Export the Filtered Domain OR the Selected IDs
The single most useful behaviour of this export is that it adapts to what the user is doing. If they apply filters and click download without selecting anything, the payload carries the active domain and the backend exports every matching record. If they tick specific rows, the OWL controller adds those resId values to the payload and the backend rebuilds the domain as [('id', 'in', ids)] to export only those. The one exception is "select all matching" — when isDomainSelected is true, the controller intentionally omits the IDs so the full filtered domain is exported rather than just the visible page. This single handler covers every real-world export scenario without extra buttons or dialogs.
Frequently Asked Questions
Why use an http route instead of a JSON-RPC controller for the export?
An http-type route can return raw binary content with custom headers, which is exactly what a file download needs. A JSON-RPC route always wraps its result in a JSON envelope, so the browser cannot treat it as a downloadable file. Odoo's download service is designed to POST to an http route and stream the response straight to the user's disk.
How do I export only the records the user selected in the list?
In the OWL controller, check this.model.root.selection. When it is non-empty and isDomainSelected is false, map the selection to resId values and add them to the payload as ids. The Python controller then rebuilds the domain as [('id', 'in', ids)], so only those records are searched and exported.
The download button doesn't appear — what's wrong?
The button is wrapped in t-if="props.context.from_daily_timesheet", so it only shows when that context flag is present. Make sure your window action passes {'from_daily_timesheet': True} in its context. Also confirm the assets are declared in web.assets_backend, the module is upgraded, and you hard-refreshed the browser to load the new JS and XML.
How do I add custom formatting like colored or bold cells?
Create additional formats with workbook.add_format({...}) — for example add 'bg_color': '#FFFF00' for a yellow fill or 'font_color' for text colour. Then reference the new format in the relevant COLUMNS tuple, or use the boolean flag (the fifth element) to decide which format to apply in the write loop.
Can I reuse this pattern for any other Odoo 19 model?
Yes. Change the model name in the controller's request.env[...] call, rewrite the COLUMNS schema for that model's fields, point the OWL controller at a new route URL, and register a new view type name. The architecture — controller, button template, http route, and inherited view with js_class — stays identical regardless of the target model.
Need Custom Odoo 19 Development?
Our certified Odoo developers build custom exports, OWL components, and Python integrations tailored to your business. From formatted Excel reports to complex workflow automation, we ship production-ready modules that fit your processes.
About the author
Odoo Practice Lead, Braincuber Technologies
Leads the Odoo practice at Braincuber. Has delivered Odoo ERP implementations, NetSuite/Tally migrations, and Shopify–Odoo integrations for US mid-market and D2C brands. Owns scoping, data migration, and go-live for every Odoo engagement.
