Overview

How it is built

BI Studio started as a way out of writing report modules. On every Odoo project we ran, the client kept asking for reports, and each request was a little different from the last one. Sales by branch. Sales by branch and salesperson. The same thing again, but only for one product category. Each request ended one of two ways. Either somebody exported to Excel and built a pivot table that was out of date by Friday, or a developer wrote another small module, with its own deploy and its own test cycle. By the tenth "sales by something" module there is nothing left to learn from writing it, and the developer writing it is not building anything else.

Two tools had each solved half of this already. Crystal Reports showed what a printed business report needs: groups, subtotals, page headers and a layout an accountant recognises at a glance. Metabase showed that a business user can ask a database a question in a few clicks, without SQL. Both sit outside Odoo, with their own login, their own copy of who may see what, and a database connection somebody has to look after. BI Studio takes both ideas and puts them inside Odoo. The target was easy enough for the person asking the question and powerful enough that the implementer stops writing report modules.

This guide is for the people who install, extend, audit or resell it: Odoo developers, implementers and OEM partners. It covers how BI Studio works, and also why it works that way, because the reasons tell you where it fits a project and where it does not. The User Guide covers using it.

What is in the box

BI Studio is plain Odoo 18 Community code. It depends on base and web only, ships under OPL-1, and needs nothing outside a standard Odoo install. Excel output uses xlsxwriter, which Odoo already requires. Two MIT libraries are vendored under static/lib: GridStack 13.2.0 for the dashboard grid and Konva 10.3.2 for the report canvas.

Modules

bi_studio is the engine: datasets, questions, dashboards, reports, sharing, menus, packages. The other 14 modules are app packs. Each depends on bi_studio plus one Odoo app, installs automatically when that app is present, and carries data only.

Module Depends on Brings
bi_studio_sale sale 2 datasets, 8 questions, Sales dashboard
bi_studio_sale_margin sale_margin 3 datasets, Profit dashboard, 3 reports
bi_studio_crm crm 3 datasets, CRM dashboard
bi_studio_purchase purchase 2 datasets, Purchases dashboard
bi_studio_pos point_of_sale 3 datasets, POS Revenue and POS Profit
bi_studio_stock stock 4 datasets, Inventory dashboard, Stock Count Sheet
bi_studio_mrp mrp 4 datasets, Manufacturing dashboard
bi_studio_account account 6 datasets, 4 dashboards, General Ledger, Aged Receivables, Aged Payables
bi_studio_hr, _hr_attendance, _hr_holidays, _hr_timesheet, _hr_expense the matching HR app 1 to 3 datasets and one dashboard each
bi_studio_staffing the POS and Attendances packs Staffing vs Demand, built on their datasets

The path of one number

Take one bar from the shipped POS Profit dashboard: gross profit for one outlet, last month. This is everything that happens between the dataset definition and that bar.

1. The dataset. The Point of Sale pack ships a dataset called POS Sales Lines. One row is one ticket line. Its base model is pos.order.line, alias t0. Three forward joins bring in the ticket (t1, pos.order), the product variant (t2) and the product template (t3). Fifteen columns sit on top, among them Outlet (t1.config_id), Sales Amount (t0.price_subtotal), Total Cost (t0.total_cost) and Gross Profit, a calculated column defined as Sales Amount minus Total Cost. A dataset filter keeps tickets that are paid, done or invoiced. All of this is ordinary records in bi.dataset, bi.dataset.join, bi.dataset.line and bi.dataset.filter.

2. Generate. The definition compiles into one SQL statement, built with psycopg2.sql so every identifier is quoted by the driver. Cut down to the columns this example uses, it reads:

SELECT
    "t0"."id" AS "id",
    "t0"."id" AS "x_bi_source_id",
    "t1"."config_id" AS "x_bi_t1_config_id",
    "t0"."price_subtotal" AS "x_bi_t0_price_subtotal",
    "t0"."total_cost" AS "x_bi_t0_total_cost",
    (COALESCE("t0"."price_subtotal", 0) - COALESCE("t0"."total_cost", 0))
        AS "x_bi_t0_price_subtotal_minus_t0_total_cost",
    "t0"."company_id" AS "x_bi_company_id"
FROM "pos_order_line" AS "t0"
JOIN "pos_order" AS "t1" ON "t0"."order_id" = "t1"."id"
LEFT JOIN "product_product" AS "t2" ON "t0"."product_id" = "t2"."id"
LEFT JOIN "product_template" AS "t3" ON "t2"."product_tmpl_id" = "t3"."id"
WHERE "t1"."state" IN ('paid', 'done', 'invoiced')

PostgreSQL gets a view called x_bi_pos_sales_lines. On top of it Generate registers a manual Odoo model, x_bi.pos_sales_lines, with one field per column, list, pivot, graph and search views, access rules and a company rule. No join here can multiply rows, so id is simply the ticket line's own id. A click on the bar later opens real ticket lines.

3. The question. The shipped question Sales and Gross Profit by Outlet is stored as JSON on bi.question:

"group_by": [{"column": "x_bi_t1_config_id"}],
"measures": [
  {"column": "x_bi_t0_price_subtotal", "aggregate": "sum"},
  {"column": "x_bi_t0_price_subtotal_minus_t0_total_cost", "aggregate": "sum"}
],
"sort_column": "x_bi_t0_price_subtotal:sum"

When the dashboard asks for it, the server checks every column name against the dataset's own column list and every aggregate against a fixed set. It turns the Period filter into a domain on the Date column the dashboard mapped it to, and calls _read_group on x_bi.pos_sales_lines as the person looking at the screen. The browser never sends SQL.

4. The rules. _read_group passes through _apply_ir_rules like any ORM read. The company rule keeps rows whose x_bi_company_id the viewer may work in. Row security then applies the viewer's own record rules on pos.order.line, compiled into a sub-select and matched on x_bi_source_id. In a standard install those rules only separate companies, so a POS manager sees every outlet of their companies. A custom rule that limits a supervisor to one outlet would limit this bar in exactly the same way.

5. The labels. The grouped values come back as pos.config ids. Their names are read with sudo, the way Odoo's own many2one fields read them, so the bar says the outlet's name even for a viewer who cannot open the outlet's settings. The pack also wrote a warning into the Gross Profit column's help text and the question's description: cost is only known once a session is closed, so a session still open counts its whole sales as profit. That sentence is part of the number as much as the SQL is.

Dashboards run every tile through this same path. Reports read rows with search and read, also as the current user, and build a band tree that the HTML preview, the PDF and the Excel writer all render from. One security path, three outputs.

Client side

Four OWL client actions do the interactive work:

Tag Component
bi_studio_dashboard Dashboard viewer and grid editor
bi_studio_question_builder Question builder
bi_studio_report_params Report run form, as a page or a dialog
bi_studio_report_canvas Canvas report designer

Everything else is ordinary Odoo forms and lists. Assets go into web.assets_backend. There is no separate front end to deploy.