Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 96 additions & 0 deletions om_excel_reports/README.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
==================================
Odoo2XL - Odoo Excel Reports
==================================

Adds Excel export to the Odoo **Print** flow. Any PDF report can be exported as a real
spreadsheet, not a picture of the PDF. The export is generated on the server: no browser
extension is needed and nothing leaves your Odoo server.

Features
========

* **Print chooser:** printing a PDF report (Print menu, Print buttons, wizard reports) first
asks *PDF / Excel / Both / Preview* in a popup ("Choose the format. Esc cancels.").
* **Preview:** the *Preview* button opens the PDF in a new browser tab. The tab is served
inline under the report's real file name (its "Printed Report Name", for example
``Quotation - CQ/08/0009.pdf``), so the PDF viewer's download keeps that name instead of
an id. Downloads (PDF and Excel) use the same naming. If the browser blocks pop-ups, a
warning is shown and the PDF is downloaded instead.
* Amounts, quantities (with units), percentages and dates are exported as real Excel values;
codes and references (leading zeros, VAT, IBAN...) stay text.
* Bold totals, indentation, frozen header row, filters on the main table and sensible column
widths.
* Several records printed at once give one sheet per document, plus an **All lines** sheet
(pivot-friendly) when they share the same table layout.
* Report filters (dates, journals, target moves) are kept above the table.
* Print-ready: landscape, all columns fit the page width.
* The header and footer repeated by the report layout are dropped.

Configuration
=============

* **Chooser:** set the system parameter ``om_excel_reports.ask_format`` to ``0`` to turn off the
PDF / Excel / Both popup for everyone. Printing then always gives the PDF, and the Excel export
is no longer offered in the interface.
* **Reports with their own Excel export:** if a module (for example one built on ``report_xlsx``)
already provides an ``xlsx`` report action on the same model as a PDF report, the popup is not
shown for that report, so users do not get two Excel versions of it.

Access follows the normal report access of the user: the Excel file is generated from the
same HTML that the report renders.

Technical notes
===============

* The module does not change any ``ir.actions.report`` record or field; it only adds the
``_render_excel`` method, the routes and the web client dialog.
* Routes: ``/report/excel/<report_name>[/<ids>]`` (download through ``/report/download``, type
``excel``) and ``/om_excel_reports/preview/<report_name>[/<ids>]`` (inline PDF with its file
name).
* Converter: ``lib/html_to_xlsx.py`` (lxml and xlsxwriter, no Odoo dependency, unit-testable).
* Odoo 20: PDF reports may use an engine-specific type (``qweb-pdf-wkhtmltopdf``,
``qweb-pdf-paper-muncher``); all ``qweb-pdf*`` reports can be exported.
* Python dependencies: ``xlsxwriter`` and ``lxml``.

Limitations
===========

* Images, logos and barcodes are skipped.
* Reports without tables are exported as plain text rows.
* Number and date recognition uses the user's language settings (decimal and thousands
separators, date format).

Tests
=====

::

odoo-bin -d <db> -i web_tour,om_excel_reports --test-enable --test-tags=/om_excel_reports

``web_tour`` is only needed for the browser tours. Converter-only tests::

python3 -m unittest discover -s tests -p test_converter.py

Credits
=======

Server-side version of the Odoo2XL browser extension by Odoo Mates
(https://github.com/odoomates/odoo2xl): the same conversion rules, re-implemented in Python so
the export runs inside Odoo.

Odoo2XL is not affiliated with or endorsed by Odoo S.A.

Contributors
------------

* Akshay Manoj

Author & Maintainer
-------------------

This module is maintained by the Odoo Mates <odoomates@gmail.com>

License
=======

LGPL-3
2 changes: 2 additions & 0 deletions om_excel_reports/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Part of Odoo2XL - Odoo Excel Reports (om_excel_reports). License: LGPL-3.
from . import controllers, models
57 changes: 57 additions & 0 deletions om_excel_reports/__manifest__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# Part of Odoo2XL - Odoo Excel Reports (om_excel_reports). License: LGPL-3.
{
'name': 'Odoo2XL - Odoo Excel Reports',
'version': '1.0',
'author': 'Odoo Mates',
'maintainer': 'Odoo Mates',
'support': 'odoomates@gmail.com',
'category': 'Productivity',
'summary': 'Excel Reports, Export PDF Report to Excel, XLSX Report, Print Report in Excel, '
'Report to Spreadsheet, Odoo2XL, Excel Export of Any Report',
'description': """
Odoo2XL - Odoo Excel Reports
============================
Adds Excel export to the Odoo Print flow. Any PDF report can be exported, and
the result is a real spreadsheet - not a picture of the PDF.

* Works for every PDF report: invoices, quotations, delivery slips and any
other report, including custom and third-party ones - no per-report code.
* PDF / Excel / Both / Preview chooser when printing, including wizard reports.
* Reports that already have their own Excel export (an xlsx report action on the
same model) keep it: the chooser is not shown for them.
* Amounts are numbers with currency formatting, dates are real Excel dates,
account codes and references stay text.
* Quantities keep their unit and still sum.
* Bold totals, indented account levels, frozen header row and filters on the
main table.
* Report filters (dates, journals, target moves) are kept above the table.
* Several records: one sheet per document, plus an "All lines" sheet with a
Document column for pivot tables.
* Files are named after the record reference or the report name.
* Print-ready: all columns fit the page width (landscape).
* Generated on the server, inside Odoo - no browser extension and no data
leaves your system.

Limitations: images, logos and barcodes are skipped; reports without tables
are exported as text rows.

Server-side version of the Odoo2XL browser extension by Odoo Mates.
Not affiliated with or endorsed by Odoo S.A.
""",
'license': 'LGPL-3',
'depends': ['web'],
'external_dependencies': {'python': ['xlsxwriter', 'lxml']},
'assets': {
'web.assets_backend': [
'om_excel_reports/static/src/js/*.js',
'om_excel_reports/static/src/xml/*.xml',
'om_excel_reports/static/src/scss/*.scss',
],
'web.assets_tests': [
'om_excel_reports/static/tests/tours/*.js',
],
},
'images': ['static/description/banner.png'],
'installable': True,
'application': False,
}
1 change: 1 addition & 0 deletions om_excel_reports/controllers/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from . import main
122 changes: 122 additions & 0 deletions om_excel_reports/controllers/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
# Part of Odoo2XL - Odoo Excel Reports (om_excel_reports). License: LGPL-3.
import json
import logging
from urllib.parse import parse_qsl, urlsplit

import werkzeug.exceptions

from odoo.http import request, route
from odoo.http.dispatcher import serialize_exception
from odoo.http.stream import content_disposition
from odoo.tools.misc import html_escape
from odoo.tools.safe_eval import safe_eval, time

from odoo.addons.web.controllers.report import ReportController

from ..models.ir_actions_report import XLSX_MIMETYPE, is_pdf_report_type

_logger = logging.getLogger(__name__)


class ReportExcelController(ReportController):

@staticmethod
def _report_filename(report, docids, extension):
"""File name used by Odoo for a printed report, with ``extension``.

Mirrors core: the report's "Printed Report Name" expression for a single record
(e.g. ``Quotation - CQ/08/0009``), otherwise the report name.
"""
filename = '%s.%s' % (report.name, extension)
if docids:
if isinstance(docids, str):
docids = [int(x) for x in docids.split(',') if x.isdigit()]
records = request.env[report.model].browse(docids)
if report.print_report_name and len(records) == 1:
name = safe_eval(report.print_report_name, {'object': records, 'time': time})
filename = '%s.%s' % (name, extension)
return filename

@route()
def report_routes(self, reportname, docids=None, converter=None, **data):
"""Add the ``excel`` converter: /report/excel/<reportname>[/<docids>]."""
if converter != 'excel':
return super().report_routes(reportname, docids=docids, converter=converter, **data)
context = dict(request.env.context)
if docids:
docids = [int(i) for i in docids.split(',') if i.isdigit()]
if data.get('options'):
data.update(json.loads(data.pop('options')))
if data.get('context'):
data['context'] = json.loads(data['context'])
context.update(data['context'])
content = request.env['ir.actions.report'].with_context(context)._render_excel(
reportname, docids, data=data)[0]
return request.make_response(content, headers=[
('Content-Type', XLSX_MIMETYPE), ('Content-Length', len(content))])

@route()
def report_download(self, data, context=None, token=None, readonly=True):
"""Handle the download of ``excel`` reports; everything else goes to core."""
requestcontent = json.loads(data)
url, type_ = requestcontent[0], requestcontent[1]
if type_ != 'excel':
return super().report_download(data, context=context, token=token, readonly=readonly)
reportname = '???'
try:
reportname = url.split('/report/excel/')[1].split('?')[0]
docids = None
if '/' in reportname:
reportname, docids = reportname.split('/')
if docids:
response = self.report_routes(reportname, docids=docids, converter='excel', context=context)
else:
query = dict(parse_qsl(urlsplit(url).query))
if 'context' in query:
context = json.dumps({**json.loads(context or '{}'), **json.loads(query.pop('context'))})
response = self.report_routes(reportname, converter='excel', context=context, **query)

report = request.env['ir.actions.report']._get_report_from_name(reportname)
filename = self._report_filename(report, docids, 'xlsx')
response.headers.add('Content-Disposition', content_disposition(filename))
return response
except Exception as exc:
_logger.warning('Error while generating Excel report %s', reportname, exc_info=True)
error = {'code': 0, 'message': 'Odoo Server Error', 'data': serialize_exception(exc)}
res = request.make_response(html_escape(json.dumps(error)))
raise werkzeug.exceptions.InternalServerError(response=res) from exc

@route([
'/om_excel_reports/preview/<reportname>',
'/om_excel_reports/preview/<reportname>/<docids>',
], type='http', auth='user')
def report_preview(self, reportname, docids=None, **data):
"""Show a PDF report inline (new browser tab) under its real file name.

The ``inline`` Content-Disposition carries the same name as a normal download, so
"Download" in the browser's PDF viewer saves e.g. ``Quotation - CQ_08_0009.pdf``
instead of the record id.
"""
response = self.report_routes(reportname, docids=docids, converter='pdf', **data)
report = request.env['ir.actions.report']._get_report_from_name(reportname)
filename = self._report_filename(report, docids, 'pdf')
response.headers['Content-Disposition'] = content_disposition(filename, disposition_type='inline')
return response

@route('/om_excel_reports/excel_allowed', type='jsonrpc', auth='user', readonly=True)
def excel_allowed(self, report_name):
"""Tell the web client how to treat a PDF report.

:return: ``{'allowed': bool, 'ask': bool}`` - whether the report may be exported to
Excel, and whether the PDF / Excel / Both chooser should be shown before printing
(system parameter ``om_excel_reports.ask_format``, on by default). A report that
already has a dedicated Excel export (an ``xlsx`` report action on the same model,
from another module) is not offered a second one.
"""
Report = request.env['ir.actions.report']
report = Report._get_report_from_name(report_name)
dedicated = report and Report.sudo().search_count(
[('model', '=', report.model), ('report_type', '=', 'xlsx')], limit=1)
allowed = bool(report and is_pdf_report_type(report.report_type) and not dedicated)
ask = request.env['ir.config_parameter'].sudo().get_bool('om_excel_reports.ask_format', True)
return {'allowed': allowed, 'ask': ask}
Empty file.
Loading
Loading