diff --git a/spreadsheet_oca/README.rst b/spreadsheet_oca/README.rst index 5babde5d..0f9338d2 100644 --- a/spreadsheet_oca/README.rst +++ b/spreadsheet_oca/README.rst @@ -1,7 +1,3 @@ -.. image:: https://odoo-community.org/readme-banner-image - :target: https://odoo-community.org/get-involved?utm_source=readme - :alt: Odoo Community Association - =============== Spreadsheet Oca =============== @@ -17,7 +13,7 @@ Spreadsheet Oca .. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png :target: https://odoo-community.org/page/development-status :alt: Beta -.. |badge2| image:: https://img.shields.io/badge/license-AGPL--3-blue.png +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html :alt: License: AGPL-3 .. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fspreadsheet-lightgray.png?logo=github diff --git a/spreadsheet_oca/__manifest__.py b/spreadsheet_oca/__manifest__.py index be5be297..1d4e8458 100644 --- a/spreadsheet_oca/__manifest__.py +++ b/spreadsheet_oca/__manifest__.py @@ -5,7 +5,7 @@ "name": "Spreadsheet Oca", "summary": """ Allow to edit spreadsheets""", - "version": "18.0.1.2.3", + "version": "18.0.2.0.0", "license": "AGPL-3", "author": "CreuBlanca,Odoo Community Association (OCA)", "website": "https://github.com/OCA/spreadsheet", @@ -14,6 +14,7 @@ "security/security.xml", "security/ir.model.access.csv", "views/spreadsheet_spreadsheet.xml", + "views/spreadsheet_xlsx_export_views.xml", "data/spreadsheet_spreadsheet_import_mode.xml", "wizards/spreadsheet_select_row_number.xml", "wizards/spreadsheet_spreadsheet_import.xml", diff --git a/spreadsheet_oca/demo/demo_pivot_dashboard.json b/spreadsheet_oca/demo/demo_pivot_dashboard.json new file mode 100644 index 00000000..2ed2f20e --- /dev/null +++ b/spreadsheet_oca/demo/demo_pivot_dashboard.json @@ -0,0 +1,98 @@ +{ + "version": 21, + "sheets": [ + { + "id": "sheet_partners", + "name": "Partners by Country", + "colNumber": 26, + "rowNumber": 100, + "rows": {}, + "cols": { + "0": {"size": 220}, + "1": {"size": 140}, + "2": {"size": 140}, + "3": {"size": 140} + }, + "merges": [], + "cells": { + "A1": {"content": "=PIVOT(1)"} + }, + "conditionalFormats": [], + "figures": [], + "filterTables": [], + "tables": [], + "dataValidationRules": [], + "comments": {}, + "headerGroups": {"ROW": [], "COL": []}, + "areGridLinesVisible": true, + "isVisible": true + }, + { + "id": "sheet_regions", + "name": "Regions per Country", + "colNumber": 26, + "rowNumber": 100, + "rows": {}, + "cols": { + "0": {"size": 220}, + "1": {"size": 140} + }, + "merges": [], + "cells": { + "A1": {"content": "=PIVOT(2)"} + }, + "conditionalFormats": [], + "figures": [], + "filterTables": [], + "tables": [], + "dataValidationRules": [], + "comments": {}, + "headerGroups": {"ROW": [], "COL": []}, + "areGridLinesVisible": true, + "isVisible": true + } + ], + "settings": {}, + "customTableStyles": {}, + "styles": {}, + "formats": {}, + "borders": {}, + "revisionId": "START_REVISION", + "uniqueFigureIds": true, + "odooVersion": 12, + "globalFilters": [], + "pivots": { + "1": { + "type": "ODOO", + "id": "1", + "formulaId": "1", + "name": "Partners by Country & Type", + "model": "res.partner", + "domain": [["active", "=", true]], + "context": {}, + "measures": [{"id": "__count", "fieldName": "__count"}], + "rows": [{"fieldName": "country_id", "order": "desc"}], + "columns": [{"fieldName": "is_company"}], + "sortedColumn": null, + "fieldMatching": {} + }, + "2": { + "type": "ODOO", + "id": "2", + "formulaId": "2", + "name": "Regions per Country", + "model": "res.country.state", + "domain": [], + "context": {}, + "measures": [{"id": "__count", "fieldName": "__count"}], + "rows": [{"fieldName": "country_id", "order": "desc"}], + "columns": [], + "sortedColumn": null, + "fieldMatching": {} + } + }, + "pivotNextId": 3, + "lists": {}, + "listNextId": 1, + "chartOdooMenusReferences": {} +} diff --git a/spreadsheet_oca/demo/spreadsheet_spreadsheet.xml b/spreadsheet_oca/demo/spreadsheet_spreadsheet.xml index 11222ed5..b830e1f6 100644 --- a/spreadsheet_oca/demo/spreadsheet_spreadsheet.xml +++ b/spreadsheet_oca/demo/spreadsheet_spreadsheet.xml @@ -1,11 +1,90 @@ + + + + Müller GmbH + + + + + Hans Weber + + + + + Dupont SA + + + + + Marie Leclerc + + + + + British Solutions Ltd + + + + + James Clarke + + + + + Tanaka Industries + + + + + Silva Comércio Ltda + + + + + Ana Costa + + + + Patel Technologies Pvt Ltd + + + + + Priya Sharma + + + + + Outback Systems Pty Ltd + + + + + + - Demo spreadsheet + Sales Pipeline Summary + + + + Partner Pivot Dashboard + + + diff --git a/spreadsheet_oca/models/__init__.py b/spreadsheet_oca/models/__init__.py index c5ec2360..70dbb73e 100644 --- a/spreadsheet_oca/models/__init__.py +++ b/spreadsheet_oca/models/__init__.py @@ -1,3 +1,4 @@ +from . import cell_ref # noqa: F401 — shared helpers; must be first from . import spreadsheet_abstract from . import spreadsheet_spreadsheet_tag from . import spreadsheet_spreadsheet diff --git a/spreadsheet_oca/models/cell_ref.py b/spreadsheet_oca/models/cell_ref.py new file mode 100644 index 00000000..0e258ff1 --- /dev/null +++ b/spreadsheet_oca/models/cell_ref.py @@ -0,0 +1,140 @@ +# Copyright 2025 Ledo Enterprises LLC +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +""" +Shared cell-reference helpers for spreadsheet_oca. + +Used by spreadsheet_alert, spreadsheet_scenario, and spreadsheet_input_param +to avoid duplicating cell-address parsing and raw-JSON access logic. +""" + +import re + +# Pre-compiled pattern: column letters + row number (1-based, no zero row). +_CELL_REF_RE = re.compile(r"^([A-Za-z]+)([1-9][0-9]*)$") + + +def _idx_to_cell_address(col_idx, row_idx): + """Convert 0-based (col, row) to cell address like 'A1', 'B3', 'AA12'.""" + col_str = "" + c = col_idx + while True: + col_str = chr(ord("A") + c % 26) + col_str + c = c // 26 - 1 + if c < 0: + break + return f"{col_str}{row_idx + 1}" + + +def parse_cell_ref(ref): + """ + Parse a bare cell reference like 'B3' or 'AA12' into (col_index, row_index). + + Both indices are 0-based to match the o-spreadsheet JSON cell-map format. + Returns (None, None) on invalid input (empty string, zero row, etc.). + """ + m = _CELL_REF_RE.match(ref.strip()) + if not m: + return None, None + col_str, row_str = m.group(1).upper(), m.group(2) + col_idx = 0 + for ch in col_str: + col_idx = col_idx * 26 + (ord(ch) - ord("A") + 1) + col_idx -= 1 # convert to 0-based + row_idx = int(row_str) - 1 # convert to 0-based + return col_idx, row_idx + + +def parse_cell_key(key): + """ + Parse a possibly-qualified cell key into (sheet_name_or_None, col_idx, row_idx). + + Supported formats: + - ``"B3"`` — no sheet qualifier; sheet_name = None + - ``"Sheet1!B3"`` — explicit sheet qualifier + """ + key = key.strip() + if "!" in key: + sheet_part, addr_part = key.split("!", 1) + sheet_name = sheet_part.strip() + else: + sheet_name = None + addr_part = key + col_idx, row_idx = parse_cell_ref(addr_part) + return sheet_name, col_idx, row_idx + + +def _resolve_sheet(sheets, sheet_name=None): + """Return the target sheet dict from a list of sheets. + + If *sheet_name* is given, searches case-insensitively; falls back to the + first sheet if not found. Returns None when *sheets* is empty. + """ + if not sheets: + return None + if sheet_name: + for s in sheets: + if s.get("name", "").lower() == sheet_name.lower(): + return s + return sheets[0] + + +def read_cell_value(spreadsheet_raw, cell_ref, sheet_name=None): + """ + Read the value of a cell from a spreadsheet_raw JSON dict. + + *cell_ref* may be bare (``"B3"``) or sheet-qualified (``"Sheet1!B3"``). + *sheet_name*, when provided, overrides any sheet qualifier embedded in + *cell_ref* and forces lookup in the named sheet (falling back to sheet 0). + + Return value priority: + 1. The cell's evaluated ``"value"`` key (set by o-spreadsheet when the + workbook is saved after formula evaluation in the browser). + 2. The cell's ``"content"`` string (for static / hand-typed cells). + 3. ``None`` when the cell, sheet, or raw JSON is absent. + """ + sheets = (spreadsheet_raw or {}).get("sheets", []) + ref_sheet, col_idx, row_idx = parse_cell_key(cell_ref) + if col_idx is None: + return None + + target_name = sheet_name or ref_sheet + target_sheet = _resolve_sheet(sheets, target_name) + if target_sheet is None: + return None + + cells = target_sheet.get("cells", {}) + cell_addr = _idx_to_cell_address(col_idx, row_idx) + cell_data = cells.get(cell_addr, {}) + if not cell_data: + return None + + value = cell_data.get("value") + if value is None: + value = cell_data.get("content") + return value if value != "" else None + + +def write_cell_content(spreadsheet_raw, cell_ref, value, sheet_name=None): + """ + Write a value into ``cells[row][col]["content"]`` of *spreadsheet_raw* in-place. + + Creates nested dicts as needed. *cell_ref* and *sheet_name* follow the + same conventions as :func:`read_cell_value`. + + Returns the (mutated) *spreadsheet_raw* dict. + """ + sheets = (spreadsheet_raw or {}).get("sheets", []) + ref_sheet, col_idx, row_idx = parse_cell_key(cell_ref) + if col_idx is None: + return spreadsheet_raw + + target_name = sheet_name or ref_sheet + target_sheet = _resolve_sheet(sheets, target_name) + if target_sheet is None: + return spreadsheet_raw + + cells = target_sheet.setdefault("cells", {}) + cell_addr = _idx_to_cell_address(col_idx, row_idx) + cell_data = cells.setdefault(cell_addr, {}) + cell_data["content"] = str(value) if value is not None else "" + return spreadsheet_raw diff --git a/spreadsheet_oca/models/pivot_data.py b/spreadsheet_oca/models/pivot_data.py new file mode 100644 index 00000000..0233cc69 --- /dev/null +++ b/spreadsheet_oca/models/pivot_data.py @@ -0,0 +1,366 @@ +# Copyright 2025 Ledo Enterprises LLC +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +""" +Server-side pivot data helper. + +Replicates the read_group strategy used by the Odoo web PivotModel +(addons/web/static/src/views/pivot/pivot_model.js) to produce pivot table +data server-side, without executing any JavaScript. + +The JS pivot loads data by: + 1. Computing all row-groupby prefixes ("sections"): + rows=["partner_id","date:month"] → [[], ["partner_id"], + ["partner_id","date:month"]] + 2. Computing all col-groupby prefixes ("sections"): + cols=["stage_id"] → [[], ["stage_id"]] + 3. Taking the cartesian product (row_prefix × col_prefix) for "divisors". + 4. For each divisor [rowPrefix, colPrefix], calling: + read_group(domain, fields=measureSpecs, + groupby=rowPrefix+colPrefix, lazy=False) + +This module replicates that strategy in Python and exposes: + - ``get_pivot_data(model, domain, context, rows, columns, measures)`` + +Rows / columns are lists of dimension dicts: + {"fieldName": "date_order", "granularity": "month"} + {"fieldName": "partner_id"} (no granularity) + +Measures are lists of measure dicts: + {"fieldName": "amount_total", "aggregator": "sum"} + {"fieldName": "__count"} +""" + +import itertools +import logging + +_logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Helpers mirroring the JS helpers in pivot_model.js +# --------------------------------------------------------------------------- + +DATE_GRANULARITIES = {"day", "week", "month", "quarter", "year"} + + +def _dimension_to_groupby(dim): + """Convert a dimension dict to an Odoo read_group groupby string. + + {"fieldName": "date_order", "granularity": "month"} → "date_order:month" + {"fieldName": "partner_id"} → "partner_id" + """ + name = dim["fieldName"] + gran = dim.get("granularity") + return f"{name}:{gran}" if gran else name + + +def _sections(lst): + """Return all prefixes of lst including the empty prefix. + + sections(["a", "b", "c"]) → [[], ["a"], ["a", "b"], ["a", "b", "c"]] + + Mirrors the JS ``sections()`` helper. + """ + return [lst[:i] for i in range(len(lst) + 1)] + + +def _measure_to_field_spec(measure): + """Convert a measure dict to a read_group ``fields`` element. + + {"fieldName": "amount_total", "aggregator": "sum"} → "amount_total:sum" + {"fieldName": "__count"} → "__count" + """ + if measure["fieldName"] == "__count": + return "__count" + agg = measure.get("aggregator") or "sum" + return f"{measure['fieldName']}:{agg}" + + +# --------------------------------------------------------------------------- +# Main computation +# --------------------------------------------------------------------------- + + +def _get_pivot_data(env, model_name, domain, context, row_dims, col_dims, measures): + """Compute pivot table data using the same read_group strategy as the JS. + + Returns a dict: + { + "fields": {fieldName: {type, string, ...}}, + "groups": [ + { + "rowValues": ["2026-01", ...], # normalised group key values + "colValues": ["Confirmed", ...], + "rowGroupBy": ["date_order:month"], + "colGroupBy": ["stage_id"], + "count": 12, + "measures": {"amount_total:sum": 9800.0, ...}, + }, + ... + ], + "rowDimensions": [{"fieldName": ..., "granularity": ...}, ...], + "colDimensions": [{"fieldName": ..., "granularity": ...}, ...], + "measureSpecs": ["amount_total:sum", ...], + } + """ + Model = env[model_name].with_context(**(context or {})) + + # ── 1. Fields metadata (needed for label resolution) ───────────────── + all_field_names = [d["fieldName"] for d in row_dims + col_dims] + [ + m["fieldName"] for m in measures if m["fieldName"] != "__count" + ] + # fields_get returns {fieldName: {type, string, selection, ...}} + fields_meta = Model.fields_get( + all_field_names, attributes=["type", "string", "selection"] + ) + + # ── 2. Build groupby strings ────────────────────────────────────────── + row_groupbys = [_dimension_to_groupby(d) for d in row_dims] + col_groupbys = [_dimension_to_groupby(d) for d in col_dims] + measure_specs = [_measure_to_field_spec(m) for m in measures] + + # Ensure count is always fetched (JS always adds __count implicitly) + field_specs_with_count = measure_specs + ( + [] if "__count" in measure_specs else ["__count"] + ) + + # ── 3. Compute divisors (cartesian product of all prefixes) ────────── + row_sections = _sections(row_groupbys) + col_sections = _sections(col_groupbys) + divisors = list(itertools.product(row_sections, col_sections)) + + # ── 4. Fire read_group for each divisor ────────────────────────────── + groups = [] + for row_prefix, col_prefix in divisors: + groupby = row_prefix + col_prefix + try: + results = Model.read_group( + domain=domain or [], + fields=field_specs_with_count, + groupby=groupby, + lazy=False, + ) + except Exception: + _logger.exception( + "read_group failed for model=%s groupby=%s", model_name, groupby + ) + continue + + for rg in results: + group_entry = { + "rowGroupBy": row_prefix, + "colGroupBy": col_prefix, + "rowValues": _extract_group_values(rg, row_prefix, fields_meta), + "colValues": _extract_group_values(rg, col_prefix, fields_meta), + "count": rg.get("__count", 0), + "measures": _extract_measures(rg, measures, fields_meta), + "domain": rg.get("__domain", []), + } + groups.append(group_entry) + + return { + "fields": fields_meta, + "groups": groups, + "rowDimensions": row_dims, + "colDimensions": col_dims, + "measureSpecs": measure_specs, + } + + +def _extract_group_values(rg_row, groupby_list, fields_meta): + """Extract normalised group values from a read_group result row. + + Many2one fields return (id, display_name) — we normalise to the id (int). + Date/datetime fields return a formatted string (Odoo already handles + granularity in the groupby key). + """ + values = [] + for gb_spec in groupby_list: + field_name = gb_spec.split(":")[0] + raw = rg_row.get(gb_spec) or rg_row.get(field_name) + if raw is False or raw is None: + values.append(False) + elif isinstance(raw, list | tuple) and len(raw) == 2: + # Many2one: (id, display_name) — store id; JS uses id for grouping + values.append(raw[0]) + else: + values.append(raw) + return values + + +def _extract_measures(rg_row, measures, fields_meta): + """Extract measure values from a read_group result row.""" + result = {} + for measure in measures: + fname = measure["fieldName"] + agg = measure.get("aggregator") + if fname == "__count": + result["__count"] = rg_row.get("__count", 0) + continue + # read_group key: field_name (no aggregator suffix in result keys) + raw = rg_row.get(fname, 0) + if isinstance(raw, list | tuple): + # Many2one used as measure — count distinct occurrences + raw = 1 if raw else 0 + if raw is False: + raw = 0 + spec_key = f"{fname}:{agg}" if agg else fname + result[spec_key] = raw + return result + + +# --------------------------------------------------------------------------- +# Shared helpers for pivot iteration and HTML rendering +# --------------------------------------------------------------------------- + + +def collect_pivot_summaries(env, spreadsheet_raw, domain_transform=None): + """Iterate over ODOO-type pivots and return fresh data for each. + + Args: + env: Odoo environment. + spreadsheet_raw: dict — the spreadsheet's raw JSON data. + domain_transform: optional callable(domain) -> domain, applied to each + pivot's domain before querying (e.g. parameter substitution). + + Returns: + A tuple ``(summaries, failed_names)`` where *summaries* is a list of + ``{"name": ..., "model": ..., "result": ...}`` dicts, and + *failed_names* is a list of pivot display names that could not be loaded. + """ + pivots = spreadsheet_raw.get("pivots", {}) + summaries = [] + failed_names = [] + for pivot_id, pivot_def in pivots.items(): + if pivot_def.get("type") != "ODOO": + continue + model_name = pivot_def.get("model") + pivot_name = pivot_def.get("name") or f"Pivot #{pivot_id}" + if not model_name or model_name not in env: + _logger.warning( + "collect_pivot_summaries: unknown model %r — skipping pivot %s", + model_name, + pivot_id, + ) + failed_names.append(pivot_name) + continue + try: + domain = pivot_def.get("domain", []) + if domain_transform: + domain = domain_transform(domain) + result = _get_pivot_data( + env, + model_name, + domain, + pivot_def.get("context", {}), + pivot_def.get("rows", []), + pivot_def.get("columns", []), + pivot_def.get("measures", []), + ) + summaries.append( + { + "name": pivot_name, + "model": model_name, + "result": result, + } + ) + except Exception: + _logger.exception( + "collect_pivot_summaries: failed to compute pivot %s", + pivot_id, + ) + failed_names.append(pivot_name) + return summaries, failed_names + + +def render_pivot_table_html(summary, max_rows=10): + """Render a single pivot summary as an HTML table string. + + Args: + summary: dict with keys ``"name"``, ``"model"``, ``"result"`` + (as returned by ``collect_pivot_summaries``). + max_rows: maximum number of detail rows to include before truncating. + + Returns: + str — HTML fragment for the pivot table. + """ + result = summary["result"] + name = summary["name"] + model = summary["model"] + parts = [] + + parts.append( + f'

{name}' + f' ({model})

' + ) + + row_dims = result.get("rowDimensions", []) + groups = result.get("groups", []) + + # Grand total row + grand_totals = [ + g for g in groups if g["rowGroupBy"] == [] and g["colGroupBy"] == [] + ] + if grand_totals: + gt = grand_totals[0] + count = gt.get("count", 0) + parts.append( + f'

Total records: {count}

' + ) + for key, val in gt.get("measures", {}).items(): + if key != "__count" and val is not None: + parts.append( + f'

{key}: {val}

' + ) + + # Row breakdown table + if row_dims: + row_gb = [d["fieldName"] for d in row_dims] + row_groups = [ + g for g in groups if g["rowGroupBy"] == row_gb and g["colGroupBy"] == [] + ] + if row_groups: + measure_keys = [ + k for k in (row_groups[0].get("measures") or {}) if k != "__count" + ] + headers = ["Group"] + measure_keys + ["Count"] + parts.append( + '' + ) + parts.append("") + for h in headers: + parts.append( + '' + ) + parts.append("") + for g in row_groups[:max_rows]: + label = ", ".join(str(v) for v in g["rowValues"]) + parts.append("") + parts.append( + f'' + ) + for mk in measure_keys: + val = g.get("measures", {}).get(mk, "") + parts.append( + '' + ) + parts.append( + ''.format(g.get("count", "")) + ) + parts.append("") + if len(row_groups) > max_rows: + colspan = len(headers) + extra = len(row_groups) - max_rows + more_text = f"and {extra} more rows" + parts.append( + f'" + ) + parts.append("
{h}
{label}{val}{}
' + f"… {more_text}
") + + return "".join(parts) diff --git a/spreadsheet_oca/models/spreadsheet_spreadsheet.py b/spreadsheet_oca/models/spreadsheet_spreadsheet.py index 55a9ae9f..4fb22818 100644 --- a/spreadsheet_oca/models/spreadsheet_spreadsheet.py +++ b/spreadsheet_oca/models/spreadsheet_spreadsheet.py @@ -56,10 +56,79 @@ class SpreadsheetSpreadsheet(models.Model): string="Tags", comodel_name="spreadsheet.spreadsheet.tag" ) + # ── DRY helper for read_group-based count fields ───────────────────────── + + def _compute_related_count(self, comodel, field_name, extra_domain=None): + """Compute a count field by grouping *comodel* on ``spreadsheet_id``. + + By default the domain filters on ``active=True``; pass *extra_domain* + to override (e.g. ``[("status", "!=", "error")]`` for writeback logs). + """ + domain = [("spreadsheet_id", "in", self.ids)] + if extra_domain is not None: + domain += extra_domain + else: + domain.append(("active", "=", True)) + counts = self.env[comodel].read_group( + domain, ["spreadsheet_id"], ["spreadsheet_id"] + ) + count_map = {c["spreadsheet_id"][0]: c["spreadsheet_id_count"] for c in counts} + for rec in self: + rec[field_name] = count_map.get(rec.id, 0) + @api.depends("name") def _compute_filename(self): for record in self: - record.filename = "%s.json" % (self.name or _("Unnamed")) + record.filename = f"{record.name or _('Unnamed')}.json" + + # ── XLSX Export ────────────────────────────────────────────────────────── + + def action_export_xlsx(self): + """Export this spreadsheet as .xlsx and return a download action.""" + from .spreadsheet_xlsx_export import SpreadsheetXlsxExporter + + self.ensure_one() + exporter = SpreadsheetXlsxExporter(self.env, self) + xlsx_bytes = exporter.render() + + filename = f"{self.name or 'spreadsheet'}.xlsx" + attachment = self.env["ir.attachment"].create( + { + "name": filename, + "type": "binary", + "datas": base64.b64encode(xlsx_bytes), + "mimetype": ( + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + ), + "res_model": self._name, + "res_id": self.id, + } + ) + return { + "type": "ir.actions.act_url", + "url": f"/web/content/{attachment.id}?download=true", + "target": "self", + } + + @api.model + def get_xlsx_bytes(self, spreadsheet_id): + """Return raw .xlsx bytes (base64) for a spreadsheet.""" + from .spreadsheet_xlsx_export import SpreadsheetXlsxExporter + + spreadsheet = self.browse(spreadsheet_id) + spreadsheet.check_access("read") + exporter = SpreadsheetXlsxExporter(self.env, spreadsheet) + return base64.b64encode(exporter.render()).decode() + + # ── Pivot Data ──────────────────────────────────────────────────────────── + @api.model + def get_pivot_data(self, model_name, domain, context, row_dims, col_dims, measures): + """Return pivot table data computed server-side (JSON-RPC entry point).""" + from .pivot_data import _get_pivot_data + + return _get_pivot_data( + self.env, model_name, domain, context, row_dims, col_dims, measures + ) def create_document_from_attachment(self, attachment_ids): attachments = self.env["ir.attachment"].browse(attachment_ids) diff --git a/spreadsheet_oca/models/spreadsheet_xlsx_export.py b/spreadsheet_oca/models/spreadsheet_xlsx_export.py new file mode 100644 index 00000000..578c9617 --- /dev/null +++ b/spreadsheet_oca/models/spreadsheet_xlsx_export.py @@ -0,0 +1,467 @@ +# Copyright 2025 Ledo Enterprises LLC +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +""" +Headless XLSX export. + +Server-side generation of .xlsx files from spreadsheet.spreadsheet records, +without requiring a browser session. Addresses the gap described in +odoo/o-spreadsheet Issue #8061 (filed 2026-03-06). + +Two rendering strategies: + 1. Static cells: reads cell values from spreadsheet_raw JSON and writes + them verbatim to the worksheet (preserves text, numbers, booleans). + 2. Pivot sheets: for each ODOO pivot in the spreadsheet JSON, a dedicated + worksheet is generated with fresh data from _get_pivot_data(). This + ensures exported pivots reflect the current Odoo database state rather + than the snapshot saved client-side. + +The result is attached to the spreadsheet's Chatter and/or returned as an +ir.actions.act_url download. + +Usage from Python: + xlsx_bytes = SpreadsheetXlsxExporter(env, spreadsheet).render() + +Usage from Odoo UI: + spreadsheet.action_export_xlsx() # returns download action +""" + +import io +import logging + +import openpyxl +from openpyxl.styles import Alignment, Font, PatternFill +from openpyxl.utils import get_column_letter + +from odoo import _ + +from .pivot_data import collect_pivot_summaries + +_logger = logging.getLogger(__name__) + +# Header row style for pivot sheets +_PIVOT_HEADER_FILL = PatternFill( + start_color="4472C4", end_color="4472C4", fill_type="solid" +) +_PIVOT_HEADER_FONT = Font(color="FFFFFF", bold=True) +_PIVOT_SUBHEADER_FILL = PatternFill( + start_color="D6DCF0", end_color="D6DCF0", fill_type="solid" +) +_PIVOT_SUBHEADER_FONT = Font(bold=True) +_PIVOT_TOTAL_FONT = Font(bold=True, italic=True) + + +class SpreadsheetXlsxExporter: + """ + Renders a spreadsheet.spreadsheet record to an openpyxl Workbook. + + Call .render() to get a bytes object suitable for attachment or download. + """ + + def __init__(self, env, spreadsheet): + self.env = env + self.spreadsheet = spreadsheet + self.raw = spreadsheet.sudo().spreadsheet_raw or {} + + def render(self): + """Return the workbook as a bytes object.""" + wb = openpyxl.Workbook() + wb.remove(wb.active) # remove default empty sheet + + sheets = self.raw.get("sheets", []) + + # ── Render static sheet(s) ──────────────────────────────────────────── + for sheet_def in sheets: + sheet_name = sheet_def.get("name", "Sheet")[:31] # Excel limit + ws = wb.create_sheet(title=sheet_name) + self._render_static_sheet(ws, sheet_def) + + # ── Render one worksheet per ODOO pivot (with fresh data) ───────────── + summaries, _failed = collect_pivot_summaries(self.env, self.raw) + for summary in summaries: + pivot_name = summary["name"] + ws_name = (pivot_name[:28] + " +") if len(pivot_name) > 28 else pivot_name + # Deduplicate sheet names (Excel requires unique names) + existing = [s.title for s in wb.worksheets] + if ws_name in existing: + ws_name = f"{ws_name[:27]}_dup" + ws = wb.create_sheet(title=ws_name) + self._render_pivot_sheet_from_result(ws, summary) + + if not wb.worksheets: + ws = wb.create_sheet(title="Empty") + ws["A1"] = _("This spreadsheet has no sheets.") + + buf = io.BytesIO() + wb.save(buf) + return buf.getvalue() + + # ── Static sheet renderer ───────────────────────────────────────────────── + + def _render_static_sheet(self, ws, sheet_def): + """Copy static cell values from the sheet JSON to the worksheet. + + Cells are stored as a flat dict keyed by cell address strings + (e.g. ``"A1"``, ``"B3"``, ``"AA12"``), matching the o-spreadsheet + native format. + """ + cells = sheet_def.get("cells", {}) + # cells is {"A1": {content, style, ...}, "B3": {...}, ...} + for cell_addr, cell_data in cells.items(): + if not isinstance(cell_data, dict): + continue + # Parse cell address to (col_idx, row_idx) using our helper + from .cell_ref import parse_cell_ref + + col_idx, row_idx = parse_cell_ref(cell_addr) + if col_idx is None: + continue + content = cell_data.get("content", "") + if content is None or content == "": + continue + # openpyxl uses 1-based indices + xl_row = row_idx + 1 + xl_col = col_idx + 1 + # Strip leading "=" for formula cells — write as string + # (server-side we can't evaluate formulas) + if isinstance(content, str) and content.startswith("="): + # Write formula placeholder so user can see what was there + ws.cell(row=xl_row, column=xl_col).value = content + else: + # Try numeric conversion + ws.cell(row=xl_row, column=xl_col).value = _coerce_value(content) + + # Apply column auto-width (rough estimate) + _auto_width(ws) + + # ── Pivot sheet renderer ────────────────────────────────────────────────── + + def _render_pivot_sheet_from_result(self, ws, summary): + """Render a pre-computed pivot summary as a formatted Excel table.""" + display_name = summary["name"] + model_name = summary["model"] + result = summary["result"] + + row_dims = result.get("rowDimensions", []) + col_dims = result.get("colDimensions", []) + groups = result.get("groups", []) + measure_specs = result.get("measureSpecs", []) + + # Title row + title_cell = ws.cell(row=1, column=1, value=display_name) + title_cell.font = Font(bold=True, size=13) + ws.merge_cells( + start_row=1, + start_column=1, + end_row=1, + end_column=max(1, len(row_dims) + len(col_dims) + len(measure_specs)), + ) + + # Subtitle: model + domain + model_label = model_name + try: + model_label = self.env["ir.model"]._get(model_name).name or model_name + except Exception: + _logger.debug("Could not resolve model label for %s", model_name) + ws.cell(row=2, column=1, value=f"{model_label}").font = Font( + italic=True, color="666666" + ) + current_row = 4 + + # Build a flat table: row_headers | col_headers | measures + if not row_dims and not col_dims: + # Grand total only + current_row = self._write_grand_total( + ws, groups, measure_specs, current_row + ) + elif not col_dims: + # Row-only pivot (simple breakdown) + current_row = self._write_row_pivot( + ws, groups, row_dims, measure_specs, current_row + ) + else: + # Full cross-tab pivot + current_row = self._write_crosstab( + ws, groups, row_dims, col_dims, measure_specs, current_row + ) + + _auto_width(ws) + + def _write_grand_total(self, ws, groups, measure_specs, start_row): + totals = [g for g in groups if not g["rowGroupBy"] and not g["colGroupBy"]] + if not totals: + return start_row + gt = totals[0] + # Header + headers = ( + ["Total"] + [_format_measure_name(m) for m in measure_specs] + ["Count"] + ) + for ci, h in enumerate(headers, 1): + cell = ws.cell(row=start_row, column=ci, value=h) + cell.fill = _PIVOT_HEADER_FILL + cell.font = _PIVOT_HEADER_FONT + # Values + row_vals = [_("Grand Total")] + for spec in measure_specs: + row_vals.append(gt.get("measures", {}).get(spec)) + row_vals.append(gt.get("count", 0)) + for ci, v in enumerate(row_vals, 1): + ws.cell(row=start_row + 1, column=ci, value=v) + return start_row + 3 + + def _write_row_pivot(self, ws, groups, row_dims, measure_specs, start_row): + row_gb = [d["fieldName"] for d in row_dims] + row_groups = sorted( + [g for g in groups if g["rowGroupBy"] == row_gb and not g["colGroupBy"]], + key=lambda g: [str(v) for v in g["rowValues"]], + ) + totals = [g for g in groups if not g["rowGroupBy"] and not g["colGroupBy"]] + + # Header row + headers = [d["fieldName"] for d in row_dims] + headers += [_format_measure_name(m) for m in measure_specs] + headers.append("Count") + for ci, h in enumerate(headers, 1): + cell = ws.cell(row=start_row, column=ci, value=h) + cell.fill = _PIVOT_HEADER_FILL + cell.font = _PIVOT_HEADER_FONT + r = start_row + 1 + + for g in row_groups: + for ci, v in enumerate(g["rowValues"], 1): + ws.cell(row=r, column=ci, value=v) + offset = len(row_dims) + for si, spec in enumerate(measure_specs): + ws.cell( + row=r, column=offset + si + 1, value=g.get("measures", {}).get(spec) + ) + ws.cell( + row=r, column=offset + len(measure_specs) + 1, value=g.get("count", 0) + ) + r += 1 + + # Grand total row + if totals: + gt = totals[0] + cell = ws.cell(row=r, column=1, value=_("Grand Total")) + cell.font = _PIVOT_TOTAL_FONT + offset = len(row_dims) + for si, spec in enumerate(measure_specs): + c = ws.cell( + row=r, + column=offset + si + 1, + value=gt.get("measures", {}).get(spec), + ) + c.font = _PIVOT_TOTAL_FONT + ws.cell( + row=r, column=offset + len(measure_specs) + 1, value=gt.get("count", 0) + ).font = _PIVOT_TOTAL_FONT + r += 1 + + return r + 1 + + def _write_crosstab(self, ws, groups, row_dims, col_dims, measure_specs, start_row): + """Write a cross-tab with row headers on left, columns across top.""" + row_gb = [d["fieldName"] for d in row_dims] + col_gb = [d["fieldName"] for d in col_dims] + + # Collect unique col values + col_groups = sorted( + [g for g in groups if g["colGroupBy"] == col_gb and not g["rowGroupBy"]], + key=lambda g: [str(v) for v in g["colValues"]], + ) + col_keys = [tuple(g["colValues"]) for g in col_groups] + + # Collect unique row values + row_groups = sorted( + [g for g in groups if g["rowGroupBy"] == row_gb and not g["colGroupBy"]], + key=lambda g: [str(v) for v in g["rowValues"]], + ) + + # Cell value lookup: (row_values_tuple, col_values_tuple) → group + cell_map = {} + for g in groups: + if g["rowGroupBy"] == row_gb and g["colGroupBy"] == col_gb: + cell_map[(tuple(g["rowValues"]), tuple(g["colValues"]))] = g + + grand_totals = [ + g for g in groups if not g["rowGroupBy"] and not g["colGroupBy"] + ] + + num_row_dims = len(row_dims) + total_col = num_row_dims + len(col_keys) * len(measure_specs) + 1 + + r = self._write_crosstab_headers( + ws, row_dims, col_keys, measure_specs, total_col, start_row + ) + + r = self._write_crosstab_data( + ws, + row_groups, + col_keys, + cell_map, + groups, + row_gb, + num_row_dims, + measure_specs, + total_col, + grand_totals, + r, + ) + + return r + 1 + + def _write_crosstab_headers( + self, ws, row_dims, col_keys, measure_specs, total_col, r + ): + """Write column headers and measure sub-headers for a cross-tab.""" + num_row_dims = len(row_dims) + + for ci in range(num_row_dims): + cell = ws.cell(row=r, column=ci + 1, value=row_dims[ci]["fieldName"]) + cell.font = Font(bold=True) + + for ki, col_key in enumerate(col_keys): + label = " / ".join(str(v) for v in col_key) if col_key else _("(none)") + col_start = num_row_dims + ki * len(measure_specs) + 1 + if len(measure_specs) > 1: + ws.merge_cells( + start_row=r, + start_column=col_start, + end_row=r, + end_column=col_start + len(measure_specs) - 1, + ) + cell = ws.cell(row=r, column=col_start, value=label) + cell.fill = _PIVOT_HEADER_FILL + cell.font = _PIVOT_HEADER_FONT + cell.alignment = Alignment(horizontal="center") + + if len(measure_specs) > 1: + ws.merge_cells( + start_row=r, + start_column=total_col, + end_row=r, + end_column=total_col + len(measure_specs) - 1, + ) + total_hdr = ws.cell(row=r, column=total_col, value=_("Total")) + total_hdr.fill = _PIVOT_HEADER_FILL + total_hdr.font = _PIVOT_HEADER_FONT + if len(measure_specs) > 1: + total_hdr.alignment = Alignment(horizontal="center") + r += 1 + + # Measure sub-headers if multiple measures + if len(measure_specs) > 1: + for ki in range(len(col_keys)): + for si, spec in enumerate(measure_specs): + col_start = num_row_dims + ki * len(measure_specs) + si + 1 + cell = ws.cell( + row=r, + column=col_start, + value=_format_measure_name(spec), + ) + cell.fill = _PIVOT_SUBHEADER_FILL + cell.font = _PIVOT_SUBHEADER_FONT + for si, spec in enumerate(measure_specs): + cell = ws.cell( + row=r, + column=total_col + si, + value=_format_measure_name(spec), + ) + cell.fill = _PIVOT_SUBHEADER_FILL + cell.font = _PIVOT_SUBHEADER_FONT + r += 1 + + return r + + def _write_crosstab_data( + self, + ws, + row_groups, + col_keys, + cell_map, + groups, + row_gb, + num_row_dims, + measure_specs, + total_col, + grand_totals, + r, + ): + """Write data rows and grand total for a cross-tab.""" + for rg in row_groups: + row_key = tuple(rg["rowValues"]) + for ci, v in enumerate(rg["rowValues"], 1): + ws.cell(row=r, column=ci, value=v) + for ki, col_key in enumerate(col_keys): + cell_group = cell_map.get((row_key, col_key)) + for si, spec in enumerate(measure_specs): + col_pos = num_row_dims + ki * len(measure_specs) + si + 1 + val = ( + cell_group.get("measures", {}).get(spec) if cell_group else None + ) + ws.cell(row=r, column=col_pos, value=val) + # Row totals + row_total = [ + g + for g in groups + if g["rowGroupBy"] == row_gb + and not g["colGroupBy"] + and tuple(g["rowValues"]) == row_key + ] + if row_total: + for si, spec in enumerate(measure_specs): + v = row_total[0].get("measures", {}).get(spec) + ws.cell( + row=r, column=total_col + si, value=v + ).font = _PIVOT_TOTAL_FONT + r += 1 + + # Grand total row + if grand_totals: + gt = grand_totals[0] + cell = ws.cell(row=r, column=1, value=_("Grand Total")) + cell.font = _PIVOT_TOTAL_FONT + for si, spec in enumerate(measure_specs): + ws.cell( + row=r, + column=total_col + si, + value=gt.get("measures", {}).get(spec), + ).font = _PIVOT_TOTAL_FONT + r += 1 + + return r + + +def _coerce_value(v): + """Try to return v as int or float; otherwise return the string.""" + if isinstance(v, int | float | bool): + return v + s = str(v).strip() + try: + return int(s) + except (ValueError, TypeError): + _logger.debug("_coerce_value: %r is not an integer", s) + try: + return float(s.replace(",", "")) + except (ValueError, TypeError): + _logger.debug("_coerce_value: %r is not a float", s) + return s + + +def _format_measure_name(spec): + """Turn 'amount_total:sum' into 'Amount Total (Sum)'.""" + if ":" in spec: + field, agg = spec.split(":", 1) + return f"{field.replace('_', ' ').title()} ({agg.title()})" + return spec.replace("_", " ").title() + + +def _auto_width(ws, max_width=60): + """Set approximate column widths based on cell content length.""" + for col in ws.columns: + max_len = 0 + col_letter = get_column_letter(col[0].column) + for cell in col: + if cell.value is not None: + max_len = max(max_len, len(str(cell.value))) + ws.column_dimensions[col_letter].width = min(max_len + 4, max_width) diff --git a/spreadsheet_oca/static/description/index.html b/spreadsheet_oca/static/description/index.html index 69ccb105..c6303f28 100644 --- a/spreadsheet_oca/static/description/index.html +++ b/spreadsheet_oca/static/description/index.html @@ -3,7 +3,7 @@ -README.rst +Spreadsheet Oca -
+
+

Spreadsheet Oca

- - -Odoo Community Association - -
-

Spreadsheet Oca

-

Beta License: AGPL-3 OCA/spreadsheet Translate me on Weblate Try me on Runboat

+

Beta License: AGPL-3 OCA/spreadsheet Translate me on Weblate Try me on Runboat

This module adds a functionality for adding and editing Spreadsheets using Odoo CE.

It is an alternative to the proprietary module spreadsheet_edition @@ -397,9 +392,9 @@

Spreadsheet Oca

-

Usage

+

Usage

-

Create a new spreadsheet

+

Create a new spreadsheet

-

Development

+

Development

If you want to develop custom business functions, you can add others, based on the file https://github.com/odoo/odoo/blob/16.0/addons/spreadsheet_account/static/src/accounting_functions.js

-

Bug Tracker

+

Bug Tracker

Bugs are tracked on GitHub Issues. In case of trouble, please check there if your issue has already been reported. If you spotted it first, help us to smash it by providing a detailed and welcomed @@ -461,15 +456,15 @@

Bug Tracker

Do not contact contributors directly about support or help with technical issues.

-

Credits

+

Credits

-

Authors

+

Authors

  • CreuBlanca
-

Contributors

+

Contributors

-

Maintainers

+

Maintainers

This module is maintained by the OCA.

Odoo Community Association @@ -500,6 +495,5 @@

Maintainers

-
diff --git a/spreadsheet_oca/tests/__init__.py b/spreadsheet_oca/tests/__init__.py new file mode 100644 index 00000000..e329d917 --- /dev/null +++ b/spreadsheet_oca/tests/__init__.py @@ -0,0 +1,2 @@ +from . import test_pivot_data +from . import test_xlsx_export diff --git a/spreadsheet_oca/tests/test_pivot_data.py b/spreadsheet_oca/tests/test_pivot_data.py new file mode 100644 index 00000000..c569ec5e --- /dev/null +++ b/spreadsheet_oca/tests/test_pivot_data.py @@ -0,0 +1,179 @@ +# Copyright 2025 Ledo Enterprises LLC +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +""" +Tests for server-side pivot data computation. + +These tests verify that _get_pivot_data() produces the same grouping +structure that the Odoo JS PivotModel would produce via read_group. + +Run against odoo_test (which has sale, account installed): + docker exec -i odoo-prod odoo test -d odoo_test \ + --test-tags spreadsheet_oca.TestPivotData --stop-after-init +""" + +from odoo.tests import TransactionCase + +from ..models.pivot_data import _dimension_to_groupby, _get_pivot_data, _sections + + +class TestPivotDataHelpers(TransactionCase): + """Unit tests for the pure-Python helpers (no DB needed).""" + + def test_sections_empty(self): + self.assertEqual(_sections([]), [[]]) + + def test_sections_one(self): + self.assertEqual(_sections(["a"]), [[], ["a"]]) + + def test_sections_two(self): + self.assertEqual(_sections(["a", "b"]), [[], ["a"], ["a", "b"]]) + + def test_dimension_no_granularity(self): + self.assertEqual( + _dimension_to_groupby({"fieldName": "partner_id"}), "partner_id" + ) + + def test_dimension_with_granularity(self): + self.assertEqual( + _dimension_to_groupby({"fieldName": "date_order", "granularity": "month"}), + "date_order:month", + ) + + +class TestPivotData(TransactionCase): + """Integration tests using res.partner (always available, no demo needed).""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + # Create a handful of partners in different countries to group by + cls.country_be = cls.env.ref("base.be") + cls.country_us = cls.env.ref("base.us") + cls.partners = cls.env["res.partner"].create( + [ + {"name": "Alpha", "country_id": cls.country_be.id, "is_company": True}, + {"name": "Beta", "country_id": cls.country_be.id, "is_company": True}, + {"name": "Gamma", "country_id": cls.country_us.id, "is_company": True}, + {"name": "Delta", "country_id": cls.country_us.id, "is_company": False}, + ] + ) + cls.domain = [("id", "in", cls.partners.ids)] + + # ── Helpers ───────────────────────────────────────────────────────────── + + def _run(self, row_dims, col_dims, measures): + return _get_pivot_data( + self.env, + "res.partner", + self.domain, + {}, + row_dims, + col_dims, + measures, + ) + + def _groups_for(self, result, row_prefix, col_prefix): + """Return groups matching the given row/col groupby prefix.""" + return [ + g + for g in result["groups"] + if g["rowGroupBy"] == row_prefix and g["colGroupBy"] == col_prefix + ] + + # ── Grand-total (no groupby) ──────────────────────────────────────────── + + def test_grand_total_count(self): + """With no dims, one group with count = number of partners.""" + result = self._run([], [], [{"fieldName": "__count"}]) + groups = self._groups_for(result, [], []) + self.assertEqual(len(groups), 1) + self.assertEqual(groups[0]["count"], 4) + + # ── Single row groupby ────────────────────────────────────────────────── + + def test_row_groupby_country(self): + """Row groupby country_id → one group per country + grand total.""" + row_dims = [{"fieldName": "country_id"}] + result = self._run(row_dims, [], [{"fieldName": "__count"}]) + + # Grand total (rowGroupBy=[], colGroupBy=[]) + totals = self._groups_for(result, [], []) + self.assertEqual(len(totals), 1) + self.assertEqual(totals[0]["count"], 4) + + # Per-country groups (rowGroupBy=["country_id"], colGroupBy=[]) + country_groups = self._groups_for(result, ["country_id"], []) + self.assertEqual(len(country_groups), 2) + counts_by_country = {g["rowValues"][0]: g["count"] for g in country_groups} + self.assertEqual(counts_by_country[self.country_be.id], 2) + self.assertEqual(counts_by_country[self.country_us.id], 2) + + # ── Row + col groupby ─────────────────────────────────────────────────── + + def test_row_and_col_groupby(self): + """Row=country_id, Col=is_company → 2×2 cell values.""" + row_dims = [{"fieldName": "country_id"}] + col_dims = [{"fieldName": "is_company"}] + result = self._run(row_dims, col_dims, [{"fieldName": "__count"}]) + + # Divisors: ([], []) ([], [is_company]) + # ([country_id], []) ([country_id], [is_company]) + # → 4 divisors, each producing N read_group rows + divisor_keys = { + (tuple(g["rowGroupBy"]), tuple(g["colGroupBy"])) for g in result["groups"] + } + self.assertIn(((), ()), divisor_keys) + self.assertIn(((), ("is_company",)), divisor_keys) + self.assertIn((("country_id",), ()), divisor_keys) + self.assertIn((("country_id",), ("is_company",)), divisor_keys) + + # BE / is_company=True → Alpha + Beta = 2 + cell_groups = self._groups_for(result, ["country_id"], ["is_company"]) + be_company = [ + g + for g in cell_groups + if g["rowValues"] == [self.country_be.id] and g["colValues"] == [True] + ] + self.assertEqual(len(be_company), 1) + self.assertEqual(be_company[0]["count"], 2) + + # US / is_company=False → Delta = 1 + us_individual = [ + g + for g in cell_groups + if g["rowValues"] == [self.country_us.id] and g["colValues"] == [False] + ] + self.assertEqual(len(us_individual), 1) + self.assertEqual(us_individual[0]["count"], 1) + + # ── Return structure ──────────────────────────────────────────────────── + + def test_return_fields_metadata(self): + """Result includes fields metadata for all used fields.""" + row_dims = [{"fieldName": "country_id"}] + result = self._run(row_dims, [], [{"fieldName": "__count"}]) + self.assertIn("country_id", result["fields"]) + self.assertEqual(result["fields"]["country_id"]["type"], "many2one") + + def test_return_dimensions_and_specs(self): + """Result echoes back row/col dims and measure specs.""" + row_dims = [{"fieldName": "country_id"}] + measures = [{"fieldName": "__count"}] + result = self._run(row_dims, [], measures) + self.assertEqual(result["rowDimensions"], row_dims) + self.assertEqual(result["colDimensions"], []) + self.assertEqual(result["measureSpecs"], ["__count"]) + + # ── Domain filtering ──────────────────────────────────────────────────── + + def test_domain_filters_correctly(self): + """Domain restricts records — only BE partners.""" + be_domain = [ + ("id", "in", self.partners.ids), + ("country_id", "=", self.country_be.id), + ] + result = _get_pivot_data( + self.env, "res.partner", be_domain, {}, [], [], [{"fieldName": "__count"}] + ) + totals = self._groups_for(result, [], []) + self.assertEqual(totals[0]["count"], 2) diff --git a/spreadsheet_oca/tests/test_xlsx_export.py b/spreadsheet_oca/tests/test_xlsx_export.py new file mode 100644 index 00000000..a8db69b5 --- /dev/null +++ b/spreadsheet_oca/tests/test_xlsx_export.py @@ -0,0 +1,314 @@ +# Copyright 2025 Ledo Enterprises LLC +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +""" +Tests for headless XLSX export (spreadsheet_xlsx_export). +""" + +import base64 +import io + +import openpyxl + +from odoo.tests import TransactionCase +from odoo.tools import mute_logger + +from ..models.spreadsheet_xlsx_export import ( + SpreadsheetXlsxExporter, + _coerce_value, + _format_measure_name, +) + + +class TestHelpers(TransactionCase): + """Unit tests for pure helper functions.""" + + def test_coerce_value_int(self): + self.assertEqual(_coerce_value("42"), 42) + + def test_coerce_value_float(self): + self.assertAlmostEqual(_coerce_value("3.14"), 3.14) + + def test_coerce_value_float_comma(self): + # Commas stripped as thousands separators: "1,234.56" → 1234.56 + self.assertAlmostEqual(_coerce_value("1,234.56"), 1234.56) + + def test_coerce_value_string(self): + self.assertEqual(_coerce_value("hello"), "hello") + + def test_coerce_value_bool_passthrough(self): + self.assertTrue(_coerce_value(True)) + self.assertFalse(_coerce_value(False)) + + def test_format_measure_name_with_colon(self): + self.assertEqual( + _format_measure_name("amount_total:sum"), + "Amount Total (Sum)", + ) + + def test_format_measure_name_plain(self): + self.assertEqual(_format_measure_name("amount_total"), "Amount Total") + + +class TestXlsxExport(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.spreadsheet = cls.env["spreadsheet.spreadsheet"].create( + {"name": "Test Export Sheet"} + ) + + def _load_wb(self, xlsx_bytes): + """Open xlsx bytes as an openpyxl workbook.""" + return openpyxl.load_workbook(io.BytesIO(xlsx_bytes)) + + # ── render() basics ─────────────────────────────────────────────────────── + + def test_render_returns_bytes(self): + xlsx = SpreadsheetXlsxExporter(self.env, self.spreadsheet).render() + self.assertIsInstance(xlsx, bytes) + self.assertGreater(len(xlsx), 0) + + def test_render_empty_spreadsheet_creates_fallback_sheet(self): + self.spreadsheet.write({"spreadsheet_raw": {}}) + wb = self._load_wb(SpreadsheetXlsxExporter(self.env, self.spreadsheet).render()) + self.assertEqual(len(wb.sheetnames), 1) + self.assertEqual(wb.sheetnames[0], "Empty") + + def test_render_static_sheet_values(self): + raw = { + "sheets": [ + { + "id": "s1", + "name": "Summary", + "cells": { + "A1": {"content": "Revenue"}, + "B1": {"content": "12345"}, + "A2": {"content": "Cost"}, + "B2": {"content": "7000"}, + }, + } + ] + } + self.spreadsheet.write({"spreadsheet_raw": raw}) + wb = self._load_wb(SpreadsheetXlsxExporter(self.env, self.spreadsheet).render()) + + self.assertIn("Summary", wb.sheetnames) + ws = wb["Summary"] + self.assertEqual(ws.cell(1, 1).value, "Revenue") + self.assertEqual(ws.cell(1, 2).value, 12345) # numeric coercion + self.assertEqual(ws.cell(2, 1).value, "Cost") + self.assertEqual(ws.cell(2, 2).value, 7000) + + def test_render_formula_cells_written_verbatim(self): + raw = { + "sheets": [ + { + "id": "s1", + "name": "Formulas", + "cells": { + "A1": {"content": "=SUM(B1:B10)"}, + }, + } + ] + } + self.spreadsheet.write({"spreadsheet_raw": raw}) + wb = self._load_wb(SpreadsheetXlsxExporter(self.env, self.spreadsheet).render()) + ws = wb["Formulas"] + # Formula preserved as a string (server can't evaluate it) + self.assertEqual(ws.cell(1, 1).value, "=SUM(B1:B10)") + + def test_render_multiple_static_sheets(self): + raw = { + "sheets": [ + {"id": "s1", "name": "Sheet1", "cells": {"A1": {"content": "A"}}}, + {"id": "s2", "name": "Sheet2", "cells": {"A1": {"content": "B"}}}, + ] + } + self.spreadsheet.write({"spreadsheet_raw": raw}) + wb = self._load_wb(SpreadsheetXlsxExporter(self.env, self.spreadsheet).render()) + self.assertEqual(wb.sheetnames, ["Sheet1", "Sheet2"]) + + @mute_logger("odoo.addons.spreadsheet_oca.models.pivot_data") + def test_render_with_unknown_pivot_model_graceful(self): + """Unknown model in pivot definition should not crash; pivot is skipped.""" + raw = { + "sheets": [{"id": "s1", "name": "Data", "cells": {}}], + "pivots": { + "1": { + "type": "ODOO", + "model": "nonexistent.model.xyz", + "domain": [], + "context": {}, + "rows": [], + "columns": [], + "measures": [], + "name": "Bad Pivot", + } + }, + } + self.spreadsheet.write({"spreadsheet_raw": raw}) + # Should not raise — unknown model is silently skipped + wb = self._load_wb(SpreadsheetXlsxExporter(self.env, self.spreadsheet).render()) + # Only the static sheet, no pivot sheet for the invalid model + self.assertNotIn("Bad Pivot", wb.sheetnames) + self.assertIn("Data", wb.sheetnames) + + def test_render_pivot_non_odoo_type_skipped(self): + """Non-ODOO pivot types are skipped (no extra worksheet).""" + raw = { + "sheets": [{"id": "s1", "name": "Data", "cells": {}}], + "pivots": { + "1": {"type": "CUSTOM", "name": "Custom Pivot"}, + }, + } + self.spreadsheet.write({"spreadsheet_raw": raw}) + wb = self._load_wb(SpreadsheetXlsxExporter(self.env, self.spreadsheet).render()) + # Only the static sheet, no pivot sheet for CUSTOM type + self.assertEqual(wb.sheetnames, ["Data"]) + + def test_render_pivot_grand_total_only(self): + """ + A pivot with no row/col dimensions produces a grand-total table. + Use res.partner (always present) with count measure. + """ + raw = { + "sheets": [{"id": "s1", "name": "Data", "cells": {}}], + "pivots": { + "1": { + "type": "ODOO", + "model": "res.partner", + "domain": [["id", "=", 1]], # narrow domain for speed + "context": {}, + "rows": [], + "columns": [], + "measures": [], + "name": "Partner Count", + } + }, + } + self.spreadsheet.write({"spreadsheet_raw": raw}) + wb = self._load_wb(SpreadsheetXlsxExporter(self.env, self.spreadsheet).render()) + self.assertIn("Partner Count", wb.sheetnames) + + def test_render_pivot_row_breakdown(self): + """Row-only pivot creates a table with header + data rows.""" + raw = { + "sheets": [{"id": "s1", "name": "Data", "cells": {}}], + "pivots": { + "1": { + "type": "ODOO", + "model": "res.partner", + "domain": [["id", "in", [1, 3]]], + "context": {}, + "rows": [{"fieldName": "id", "order": "asc", "type": "integer"}], + "columns": [], + "measures": [], + "name": "By ID", + } + }, + } + self.spreadsheet.write({"spreadsheet_raw": raw}) + # Should not raise; pivot sheet created + wb = self._load_wb(SpreadsheetXlsxExporter(self.env, self.spreadsheet).render()) + self.assertIn("By ID", wb.sheetnames) + + def test_render_pivot_crosstab(self): + """Cross-tab pivot (both row and column groupby) produces correct layout. + + Uses res.partner with row=is_company and col=type, exercising the + _write_crosstab code path. + """ + raw = { + "sheets": [{"id": "s1", "name": "Data", "cells": {}}], + "pivots": { + "1": { + "type": "ODOO", + "model": "res.partner", + "domain": [], + "context": {}, + "rows": [ + {"fieldName": "is_company", "order": "asc", "type": "boolean"} + ], + "columns": [ + {"fieldName": "type", "order": "asc", "type": "selection"} + ], + "measures": [], + "name": "Cross-Tab Test", + } + }, + } + self.spreadsheet.write({"spreadsheet_raw": raw}) + wb = self._load_wb(SpreadsheetXlsxExporter(self.env, self.spreadsheet).render()) + self.assertIn("Cross-Tab Test", wb.sheetnames) + ws = wb["Cross-Tab Test"] + # Row 1 is the title row with the pivot name + self.assertEqual(ws.cell(1, 1).value, "Cross-Tab Test") + # Row 4+ should contain column headers — at minimum the row dimension + # header ("is_company") should appear somewhere in the header area. + header_values = [ws.cell(4, c).value for c in range(1, ws.max_column + 1)] + self.assertIn("is_company", header_values) + + # ── action_export_xlsx ──────────────────────────────────────────────────── + + def test_action_export_xlsx_returns_act_url(self): + self.spreadsheet.write({"spreadsheet_raw": {}}) + action = self.spreadsheet.action_export_xlsx() + self.assertEqual(action["type"], "ir.actions.act_url") + self.assertIn("/web/content/", action["url"]) + self.assertIn("download=true", action["url"]) + + def test_action_export_xlsx_creates_attachment(self): + self.spreadsheet.write({"spreadsheet_raw": {}}) + before = self.env["ir.attachment"].search_count( + [ + ("res_model", "=", "spreadsheet.spreadsheet"), + ("res_id", "=", self.spreadsheet.id), + ] + ) + self.spreadsheet.action_export_xlsx() + after = self.env["ir.attachment"].search_count( + [ + ("res_model", "=", "spreadsheet.spreadsheet"), + ("res_id", "=", self.spreadsheet.id), + ] + ) + self.assertEqual(after, before + 1) + + def test_action_export_xlsx_filename(self): + self.spreadsheet.write({"spreadsheet_raw": {}, "name": "Sales KPI"}) + self.spreadsheet.action_export_xlsx() + att = self.env["ir.attachment"].search( + [ + ("res_model", "=", "spreadsheet.spreadsheet"), + ("res_id", "=", self.spreadsheet.id), + ], + order="id desc", + limit=1, + ) + self.assertEqual(att.name, "Sales KPI.xlsx") + + def test_action_export_xlsx_attachment_is_valid_xlsx(self): + self.spreadsheet.write({"spreadsheet_raw": {}}) + self.spreadsheet.action_export_xlsx() + att = self.env["ir.attachment"].search( + [ + ("res_model", "=", "spreadsheet.spreadsheet"), + ("res_id", "=", self.spreadsheet.id), + ], + order="id desc", + limit=1, + ) + xlsx_bytes = base64.b64decode(att.datas) + # Should open as a valid workbook + wb = self._load_wb(xlsx_bytes) + self.assertGreater(len(wb.sheetnames), 0) + + # ── get_xlsx_bytes ──────────────────────────────────────────────────────── + + def test_get_xlsx_bytes_returns_base64(self): + self.spreadsheet.write({"spreadsheet_raw": {}}) + b64 = self.env["spreadsheet.spreadsheet"].get_xlsx_bytes(self.spreadsheet.id) + self.assertIsInstance(b64, str) + # Must be valid base64 + decoded = base64.b64decode(b64) + self._load_wb(decoded) # valid xlsx diff --git a/spreadsheet_oca/views/spreadsheet_xlsx_export_views.xml b/spreadsheet_oca/views/spreadsheet_xlsx_export_views.xml new file mode 100644 index 00000000..a1449729 --- /dev/null +++ b/spreadsheet_oca/views/spreadsheet_xlsx_export_views.xml @@ -0,0 +1,28 @@ + + + + + spreadsheet.spreadsheet.xlsx.export.form (in spreadsheet_oca) + spreadsheet.spreadsheet + + + +