diff --git a/README.md b/README.md index 902aef02e1..30befd2676 100644 --- a/README.md +++ b/README.md @@ -22,11 +22,11 @@ Available addons addon | version | maintainers | summary --- | --- | --- | --- [base_import_async](base_import_async/) | 17.0.1.0.0 | | Import CSV files in the background -[queue_job](queue_job/) | 17.0.1.4.3 | guewen | Job Queue +[queue_job](queue_job/) | 17.0.1.5.2 | guewen | Job Queue [queue_job_cron](queue_job_cron/) | 17.0.1.1.0 | | Scheduled Actions as Queue Jobs [queue_job_cron_jobrunner](queue_job_cron_jobrunner/) | 17.0.1.1.0 | ivantodorovich | Run jobs without a dedicated JobRunner [queue_job_subscribe](queue_job_subscribe/) | 17.0.1.0.0 | | Control which users are subscribed to queue job notifications -[test_queue_job](test_queue_job/) | 17.0.1.1.0 | | Queue Job Tests +[test_queue_job](test_queue_job/) | 17.0.1.2.0 | | Queue Job Tests [//]: # (end addons) diff --git a/base_export_async/README.rst b/base_export_async/README.rst new file mode 100644 index 0000000000..31bbf2202c --- /dev/null +++ b/base_export_async/README.rst @@ -0,0 +1,96 @@ +================= +Base Export Async +================= + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:da52a05130a89cd0000a39c9dd6315821387de2c310f9cf7cc1f7e6e8541fe55 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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/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%2Fqueue-lightgray.png?logo=github + :target: https://github.com/OCA/queue/tree/17.0/base_export_async + :alt: OCA/queue +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/queue-17-0/queue-17-0-base_export_async + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/queue&target_branch=17.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +Standard Export can be delayed in asynchronous jobs executed in the +background and then send by email to the user. + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +The user is presented with a new checkbox "Asynchronous export" in the +export screen. When selected, the export is delayed in a background job. + +The .csv or .xls file generated by the export will be sent by email to +the user who execute the export. + +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 +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* ACSONE SA/NV + +Contributors +------------ + +- Arnaud Pineux (ACSONE SA/NV) authored the initial prototype. +- Guewen Baconnier (Camptocamp) +- Stéphane Mangin (ACSONE SA/NV) + +Other credits +------------- + +The migration of this module from 16.0 to 17.0 was financially supported +by: + +- ACSONE SA/NV (https://www.acsone.eu/) + +Maintainers +----------- + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +This module is part of the `OCA/queue `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/base_export_async/__init__.py b/base_export_async/__init__.py new file mode 100644 index 0000000000..8e4b46541f --- /dev/null +++ b/base_export_async/__init__.py @@ -0,0 +1,4 @@ +# Copyright 2019 ACSONE SA/NV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from . import models diff --git a/base_export_async/__manifest__.py b/base_export_async/__manifest__.py new file mode 100644 index 0000000000..471722ca1a --- /dev/null +++ b/base_export_async/__manifest__.py @@ -0,0 +1,28 @@ +# Copyright 2019 ACSONE SA/NV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +{ + "name": "Base Export Async", + "summary": "Asynchronous export with job queue", + "version": "17.0.1.0.0", + "license": "AGPL-3", + "author": "ACSONE SA/NV, Odoo Community Association (OCA)", + "website": "https://github.com/OCA/queue", + "depends": ["web", "queue_job"], + "data": [ + "security/ir.model.access.csv", + "security/ir_rule.xml", + "data/config_parameter.xml", + "data/cron.xml", + "data/mail_template.xml", + ], + "demo": [], + "assets": { + "web.assets_backend": [ + "base_export_async/static/src/xml/base.xml", + "base_export_async/static/src/js/list_controller.esm.js", + "base_export_async/static/src/js/data_export.esm.js", + ], + }, + "installable": True, +} diff --git a/base_export_async/data/config_parameter.xml b/base_export_async/data/config_parameter.xml new file mode 100644 index 0000000000..90f4fb316b --- /dev/null +++ b/base_export_async/data/config_parameter.xml @@ -0,0 +1,7 @@ + + + + attachment.ttl + 7 + + diff --git a/base_export_async/data/cron.xml b/base_export_async/data/cron.xml new file mode 100644 index 0000000000..1bd9b7dacd --- /dev/null +++ b/base_export_async/data/cron.xml @@ -0,0 +1,12 @@ + + + + Delete Generated Exports + + code + model.cron_delete() + 1 + days + -1 + + diff --git a/base_export_async/data/mail_template.xml b/base_export_async/data/mail_template.xml new file mode 100644 index 0000000000..b39c012de1 --- /dev/null +++ b/base_export_async/data/mail_template.xml @@ -0,0 +1,24 @@ + + + + Delay Export + Export {{ object.model_description }} {{ datetime.date.today() }} + + + +

Your export is available here.

+

It will be automatically deleted the .

+
+

+ This is an automated message please do not reply. +

+
+
+
diff --git a/base_export_async/export.py b/base_export_async/export.py new file mode 100644 index 0000000000..da05534d6b --- /dev/null +++ b/base_export_async/export.py @@ -0,0 +1,66 @@ +# Copyright 2026 ACSONE SA/NV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import io + +from odoo.exceptions import UserError +from odoo.tools.misc import xlsxwriter +from odoo.tools.translate import _ + +from odoo.addons.web.controllers.export import ExportFormat, ExportXlsxWriter + + +class CleanedExportXlsxWriter(ExportXlsxWriter): + def __init__(self, field_names, row_count=0, decimal_places=None): + self.field_names = field_names + self.output = io.BytesIO() + self.workbook = xlsxwriter.Workbook(self.output, {"in_memory": True}) + self.base_style = self.workbook.add_format({"text_wrap": True}) + self.header_style = self.workbook.add_format({"bold": True}) + self.header_bold_style = self.workbook.add_format( + {"text_wrap": True, "bold": True, "bg_color": "#e9ecef"} + ) + self.date_style = self.workbook.add_format( + {"text_wrap": True, "num_format": "yyyy-mm-dd"} + ) + self.datetime_style = self.workbook.add_format( + {"text_wrap": True, "num_format": "yyyy-mm-dd hh:mm:ss"} + ) + self.worksheet = self.workbook.add_worksheet() + self.value = False + self.float_format = "#,##0.00" + self.monetary_format = f'#,##0.{max(decimal_places or [2]) * "0"}' + + if row_count > self.worksheet.xls_rowmax: + raise UserError( + _( + f"There are too many rows ({row_count} rows, limit:" + f" {self.worksheet.xls_rowmax}) to export as Excel" + f"2007-2013 (.xlsx) format. Consider splitting the export." + ) + ) + + +class ExcelExport(ExportFormat): + def __init__(self, env): + self.env = env + + @property + def content_type(self): + return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + + @property + def extension(self): + return ".xlsx" + + def from_data(self, fields, rows): + decimal_places = [ + res["decimal_places"] + for res in self.env["res.currency"].search_read([], ["decimal_places"]) + ] + with CleanedExportXlsxWriter(fields, len(rows), decimal_places) as xlsx_writer: + for row_index, row in enumerate(rows): + for cell_index, cell_value in enumerate(row): + xlsx_writer.write_cell(row_index + 1, cell_index, cell_value) + + return xlsx_writer.value diff --git a/base_export_async/i18n/base_export_async.pot b/base_export_async/i18n/base_export_async.pot new file mode 100644 index 0000000000..ca8e974aaa --- /dev/null +++ b/base_export_async/i18n/base_export_async.pot @@ -0,0 +1,144 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * base_export_async +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 16.0\n" +"Report-Msgid-Bugs-To: \n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: base_export_async +#. odoo-javascript +#: code:addons/base_export_async/static/src/xml/base.xml:0 +#, python-format +msgid "(You will receive the export by email)" +msgstr "" + +#. module: base_export_async +#: model:mail.template,body_html:base_export_async.delay_export_mail_template +msgid "" +"

Your export is available here.

\n" +"

It will be automatically deleted the .

\n" +"
\n" +"

\n" +" This is an automated message please do not reply.\n" +"

\n" +" " +msgstr "" + +#. module: base_export_async +#: model:ir.model,name:base_export_async.model_delay_export +msgid "Asynchronous Export" +msgstr "" + +#. module: base_export_async +#. odoo-javascript +#: code:addons/base_export_async/static/src/xml/base.xml:0 +#, python-format +msgid "Asynchronous export" +msgstr "" + +#. module: base_export_async +#: model:ir.model.fields,field_description:base_export_async.field_delay_export__create_uid +msgid "Created by" +msgstr "" + +#. module: base_export_async +#: model:ir.model.fields,field_description:base_export_async.field_delay_export__create_date +msgid "Created on" +msgstr "" + +#. module: base_export_async +#: model:mail.template,name:base_export_async.delay_export_mail_template +msgid "Delay Export" +msgstr "" + +#. module: base_export_async +#: model:ir.actions.server,name:base_export_async.to_delete_attachment_ir_actions_server +#: model:ir.cron,cron_name:base_export_async.to_delete_attachment +msgid "Delete Generated Exports" +msgstr "" + +#. module: base_export_async +#: model:ir.model.fields,field_description:base_export_async.field_delay_export__display_name +msgid "Display Name" +msgstr "" + +#. module: base_export_async +#: model:ir.model.fields,field_description:base_export_async.field_delay_export__expiration_date +msgid "Expiration Date" +msgstr "" + +#. module: base_export_async +#: model:mail.template,subject:base_export_async.delay_export_mail_template +msgid "Export {{ object.model_description }} {{ datetime.date.today() }}" +msgstr "" + +#. module: base_export_async +#. odoo-javascript +#: code:addons/base_export_async/static/src/js/list_controller.esm.js:0 +#, python-format +msgid "External ID" +msgstr "" + +#. module: base_export_async +#: model:ir.model.fields,field_description:base_export_async.field_delay_export__id +msgid "ID" +msgstr "" + +#. module: base_export_async +#: model:ir.model.fields,field_description:base_export_async.field_delay_export____last_update +msgid "Last Modified on" +msgstr "" + +#. module: base_export_async +#: model:ir.model.fields,field_description:base_export_async.field_delay_export__write_uid +msgid "Last Updated by" +msgstr "" + +#. module: base_export_async +#: model:ir.model.fields,field_description:base_export_async.field_delay_export__write_date +msgid "Last Updated on" +msgstr "" + +#. module: base_export_async +#: model:ir.model.fields,field_description:base_export_async.field_delay_export__model_description +msgid "Model Description" +msgstr "" + +#. module: base_export_async +#. odoo-javascript +#: code:addons/base_export_async/static/src/js/data_export.esm.js:0 +#, python-format +msgid "Please select fields to save export list..." +msgstr "" + +#. module: base_export_async +#: model:ir.model.fields,field_description:base_export_async.field_delay_export__url +msgid "Url" +msgstr "" + +#. module: base_export_async +#: model:ir.model.fields,field_description:base_export_async.field_delay_export__user_ids +msgid "Users" +msgstr "" + +#. module: base_export_async +#. odoo-python +#: code:addons/base_export_async/models/delay_export.py:0 +#, python-format +msgid "You must set an email address to your user." +msgstr "" + +#. module: base_export_async +#. odoo-javascript +#: code:addons/base_export_async/static/src/js/list_controller.esm.js:0 +#, python-format +msgid "You will receive the export file by email as soon as it is finished." +msgstr "" diff --git a/base_export_async/i18n/da.po b/base_export_async/i18n/da.po new file mode 100644 index 0000000000..0f916ce005 --- /dev/null +++ b/base_export_async/i18n/da.po @@ -0,0 +1,179 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * base_export_async +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 12.0\n" +"Report-Msgid-Bugs-To: \n" +"PO-Revision-Date: 2021-02-07 17:45+0000\n" +"Last-Translator: Hans Henrik Gabelgaard \n" +"Language-Team: none\n" +"Language: da\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"X-Generator: Weblate 4.3.2\n" + +#. module: base_export_async +#. odoo-javascript +#: code:addons/base_export_async/static/src/xml/base.xml:0 +#, python-format +msgid "(You will receive the export by email)" +msgstr "(Du vil modtage eksporten via mail)" + +#. module: base_export_async +#: model:mail.template,body_html:base_export_async.delay_export_mail_template +msgid "" +"

Your export is available here.

\n" +"

It will be automatically deleted the .

\n" +"
\n" +"

\n" +" This is an automated message " +"please do not reply.\n" +"

\n" +" " +msgstr "" + +#. module: base_export_async +#: model:ir.model,name:base_export_async.model_delay_export +msgid "Asynchronous Export" +msgstr "Asynkron export" + +#. module: base_export_async +#. odoo-javascript +#: code:addons/base_export_async/static/src/xml/base.xml:0 +#, python-format +msgid "Asynchronous export" +msgstr "Asynkron export" + +#. module: base_export_async +#: model:ir.model.fields,field_description:base_export_async.field_delay_export__create_uid +msgid "Created by" +msgstr "Oprettet af" + +#. module: base_export_async +#: model:ir.model.fields,field_description:base_export_async.field_delay_export__create_date +msgid "Created on" +msgstr "Oprettet den" + +#. module: base_export_async +#: model:mail.template,name:base_export_async.delay_export_mail_template +msgid "Delay Export" +msgstr "" + +#. module: base_export_async +#: model:ir.actions.server,name:base_export_async.to_delete_attachment_ir_actions_server +#: model:ir.cron,cron_name:base_export_async.to_delete_attachment +msgid "Delete Generated Exports" +msgstr "Slet genererede eksporter" + +#. module: base_export_async +#: model:ir.model.fields,field_description:base_export_async.field_delay_export__display_name +msgid "Display Name" +msgstr "Vist navn" + +#. module: base_export_async +#: model:ir.model.fields,field_description:base_export_async.field_delay_export__expiration_date +msgid "Expiration Date" +msgstr "" + +#. module: base_export_async +#: model:mail.template,subject:base_export_async.delay_export_mail_template +msgid "Export {{ object.model_description }} {{ datetime.date.today() }}" +msgstr "" + +#. module: base_export_async +#. odoo-javascript +#: code:addons/base_export_async/static/src/js/list_controller.esm.js:0 +#, python-format +msgid "External ID" +msgstr "External ID" + +#. module: base_export_async +#: model:ir.model.fields,field_description:base_export_async.field_delay_export__id +msgid "ID" +msgstr "ID" + +#. module: base_export_async +#: model:ir.model.fields,field_description:base_export_async.field_delay_export____last_update +msgid "Last Modified on" +msgstr "Senest rettet den" + +#. module: base_export_async +#: model:ir.model.fields,field_description:base_export_async.field_delay_export__write_uid +msgid "Last Updated by" +msgstr "Senest rettet af" + +#. module: base_export_async +#: model:ir.model.fields,field_description:base_export_async.field_delay_export__write_date +msgid "Last Updated on" +msgstr "Senest rettet den" + +#. module: base_export_async +#: model:ir.model.fields,field_description:base_export_async.field_delay_export__model_description +msgid "Model Description" +msgstr "" + +#. module: base_export_async +#. odoo-javascript +#: code:addons/base_export_async/static/src/js/data_export.esm.js:0 +#, python-format +msgid "Please select fields to save export list..." +msgstr "" + +#. module: base_export_async +#: model:ir.model.fields,field_description:base_export_async.field_delay_export__url +msgid "Url" +msgstr "" + +#. module: base_export_async +#: model:ir.model.fields,field_description:base_export_async.field_delay_export__user_ids +msgid "Users" +msgstr "Brugere" + +#. module: base_export_async +#. odoo-python +#: code:addons/base_export_async/models/delay_export.py:0 +#, python-format +msgid "You must set an email address to your user." +msgstr "Du skal have en email adresse på dit brugeropsæt." + +#. module: base_export_async +#. odoo-javascript +#: code:addons/base_export_async/static/src/js/list_controller.esm.js:0 +#, python-format +msgid "You will receive the export file by email as soon as it is finished." +msgstr "Du vil modtage et link til eksporten så snart den er færdig." + +#, python-format +#~ msgid "Please select fields to export..." +#~ msgstr "Vælg venligst felter til eksporten..." + +#, python-format +#~ msgid "" +#~ "\n" +#~ "

Your export is available here.

\n" +#~ "

It will be automatically deleted the {}.

\n" +#~ "

 

\n" +#~ "

\n" +#~ " This is an automated message please do not reply.\n" +#~ "

\n" +#~ " " +#~ msgstr "" +#~ "\n" +#~ "

Din export er tilgængelig her.

\n" +#~ "

Den vil atutomatisk blive slettet den {}.

\n" +#~ "

 

\n" +#~ "

\n" +#~ " Dette er en automatisk besked. Undlad venlisgt at " +#~ "besvare.\n" +#~ "

\n" +#~ " " + +#, python-format +#~ msgid "Export {} {}" +#~ msgstr "Eksport {} {}" diff --git a/base_export_async/i18n/de.po b/base_export_async/i18n/de.po new file mode 100644 index 0000000000..9790dcb024 --- /dev/null +++ b/base_export_async/i18n/de.po @@ -0,0 +1,188 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * base_export_async +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 12.0\n" +"Report-Msgid-Bugs-To: \n" +"PO-Revision-Date: 2019-07-04 14:43+0000\n" +"Last-Translator: Maria Sparenberg \n" +"Language-Team: none\n" +"Language: de\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"X-Generator: Weblate 3.7.1\n" + +#. module: base_export_async +#. odoo-javascript +#: code:addons/base_export_async/static/src/xml/base.xml:0 +#, python-format +msgid "(You will receive the export by email)" +msgstr "(Der Export wird per Mail bereitgestellt.)" + +#. module: base_export_async +#: model:mail.template,body_html:base_export_async.delay_export_mail_template +msgid "" +"

Your export is available here.

\n" +"

It will be automatically deleted the .

\n" +"
\n" +"

\n" +" This is an automated message " +"please do not reply.\n" +"

\n" +" " +msgstr "" + +#. module: base_export_async +#: model:ir.model,name:base_export_async.model_delay_export +#, fuzzy +msgid "Asynchronous Export" +msgstr "Asynchroner Export" + +#. module: base_export_async +#. odoo-javascript +#: code:addons/base_export_async/static/src/xml/base.xml:0 +#, python-format +msgid "Asynchronous export" +msgstr "Asynchroner Export" + +#. module: base_export_async +#: model:ir.model.fields,field_description:base_export_async.field_delay_export__create_uid +msgid "Created by" +msgstr "Erstellt von" + +#. module: base_export_async +#: model:ir.model.fields,field_description:base_export_async.field_delay_export__create_date +msgid "Created on" +msgstr "Erstellt am" + +#. module: base_export_async +#: model:mail.template,name:base_export_async.delay_export_mail_template +msgid "Delay Export" +msgstr "" + +#. module: base_export_async +#: model:ir.actions.server,name:base_export_async.to_delete_attachment_ir_actions_server +#: model:ir.cron,cron_name:base_export_async.to_delete_attachment +msgid "Delete Generated Exports" +msgstr "Erzeugte Exporte löschen" + +#. module: base_export_async +#: model:ir.model.fields,field_description:base_export_async.field_delay_export__display_name +msgid "Display Name" +msgstr "Anzeigename" + +#. module: base_export_async +#: model:ir.model.fields,field_description:base_export_async.field_delay_export__expiration_date +msgid "Expiration Date" +msgstr "" + +#. module: base_export_async +#: model:mail.template,subject:base_export_async.delay_export_mail_template +msgid "Export {{ object.model_description }} {{ datetime.date.today() }}" +msgstr "" + +#. module: base_export_async +#. odoo-javascript +#: code:addons/base_export_async/static/src/js/list_controller.esm.js:0 +#, python-format +msgid "External ID" +msgstr "Externe ID" + +#. module: base_export_async +#: model:ir.model.fields,field_description:base_export_async.field_delay_export__id +msgid "ID" +msgstr "ID" + +#. module: base_export_async +#: model:ir.model.fields,field_description:base_export_async.field_delay_export____last_update +msgid "Last Modified on" +msgstr "Zuletzt geändert am" + +#. module: base_export_async +#: model:ir.model.fields,field_description:base_export_async.field_delay_export__write_uid +msgid "Last Updated by" +msgstr "Zuletzt aktualisiert von" + +#. module: base_export_async +#: model:ir.model.fields,field_description:base_export_async.field_delay_export__write_date +msgid "Last Updated on" +msgstr "Zuletzt aktualisiert am" + +#. module: base_export_async +#: model:ir.model.fields,field_description:base_export_async.field_delay_export__model_description +msgid "Model Description" +msgstr "" + +#. module: base_export_async +#. odoo-javascript +#: code:addons/base_export_async/static/src/js/data_export.esm.js:0 +#, python-format +msgid "Please select fields to save export list..." +msgstr "" + +#. module: base_export_async +#: model:ir.model.fields,field_description:base_export_async.field_delay_export__url +msgid "Url" +msgstr "" + +#. module: base_export_async +#: model:ir.model.fields,field_description:base_export_async.field_delay_export__user_ids +msgid "Users" +msgstr "Benutzer" + +#. module: base_export_async +#. odoo-python +#: code:addons/base_export_async/models/delay_export.py:0 +#, python-format +msgid "You must set an email address to your user." +msgstr "" +"Der aktuelle Benutzer hat keine Email-Adresse. Es muss eine gesetzt werden." + +#. module: base_export_async +#. odoo-javascript +#: code:addons/base_export_async/static/src/js/list_controller.esm.js:0 +#, python-format +msgid "You will receive the export file by email as soon as it is finished." +msgstr "" +"Die Export-Datei wird per Email versendet, sobald der Export beendet ist." + +#, python-format +#~ msgid "Please select fields to export..." +#~ msgstr "Bitte Felder für den Export auswählen..." + +#, python-format +#~ msgid "" +#~ "\n" +#~ "

Your export is available here.

\n" +#~ "

It will be automatically deleted the {}.

\n" +#~ "

 

\n" +#~ "

\n" +#~ " This is an automated message please do not reply.\n" +#~ "

\n" +#~ " " +#~ msgstr "" +#~ "\n" +#~ "

Der Export ist hier verfügbar.

\n" +#~ "

Das {} wird automatisch gelöscht.

\n" +#~ "

 

\n" +#~ "

\n" +#~ " Dies ist eine automatisch erstellte Nachricht, bitte " +#~ "nicht darauf antworten.\n" +#~ "

\n" +#~ " " + +#, python-format +#~ msgid "Export {} {}" +#~ msgstr "Export {} {}" + +#~ msgid "Allow to delay the export" +#~ msgstr "Verzögerung des Exports erlauben" + +#~ msgid "The user doesn't have an email address." +#~ msgstr "Der Benutzer hat keine Email-Adresse." diff --git a/base_export_async/i18n/es.po b/base_export_async/i18n/es.po new file mode 100644 index 0000000000..5e2890a11e --- /dev/null +++ b/base_export_async/i18n/es.po @@ -0,0 +1,164 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * base_export_async +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 16.0\n" +"Report-Msgid-Bugs-To: \n" +"PO-Revision-Date: 2023-07-25 00:18+0000\n" +"Last-Translator: bencoronel \n" +"Language-Team: none\n" +"Language: es\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"X-Generator: Weblate 4.17\n" + +#. module: base_export_async +#. odoo-javascript +#: code:addons/base_export_async/static/src/xml/base.xml:0 +#, python-format +msgid "(You will receive the export by email)" +msgstr "(Recibirás la exportación por correo electrónico)" + +#. module: base_export_async +#: model:mail.template,body_html:base_export_async.delay_export_mail_template +msgid "" +"

Your export is available here.

\n" +"

It will be automatically deleted the .

\n" +"
\n" +"

\n" +" This is an automated message " +"please do not reply.\n" +"

\n" +" " +msgstr "" +"

Tu exportación está disponible aquí.

\n" +"

Se eliminará automáticamente el .

\n" +"
\n" +"

\n" +" Este es un mensaje " +"automatizado, por favor no responder.\n" +"

\n" +" " + +#. module: base_export_async +#: model:ir.model,name:base_export_async.model_delay_export +msgid "Asynchronous Export" +msgstr "Exportación Asíncrona" + +#. module: base_export_async +#. odoo-javascript +#: code:addons/base_export_async/static/src/xml/base.xml:0 +#, python-format +msgid "Asynchronous export" +msgstr "Exportación asíncrona" + +#. module: base_export_async +#: model:ir.model.fields,field_description:base_export_async.field_delay_export__create_uid +msgid "Created by" +msgstr "Creado por" + +#. module: base_export_async +#: model:ir.model.fields,field_description:base_export_async.field_delay_export__create_date +msgid "Created on" +msgstr "Creado el" + +#. module: base_export_async +#: model:mail.template,name:base_export_async.delay_export_mail_template +msgid "Delay Export" +msgstr "Aplazar Exportación" + +#. module: base_export_async +#: model:ir.actions.server,name:base_export_async.to_delete_attachment_ir_actions_server +#: model:ir.cron,cron_name:base_export_async.to_delete_attachment +msgid "Delete Generated Exports" +msgstr "Eliminar Exportaciones Generadas" + +#. module: base_export_async +#: model:ir.model.fields,field_description:base_export_async.field_delay_export__display_name +msgid "Display Name" +msgstr "Nombre mostrado" + +#. module: base_export_async +#: model:ir.model.fields,field_description:base_export_async.field_delay_export__expiration_date +msgid "Expiration Date" +msgstr "Fecha de vencimiento" + +#. module: base_export_async +#: model:mail.template,subject:base_export_async.delay_export_mail_template +msgid "Export {{ object.model_description }} {{ datetime.date.today() }}" +msgstr "Exportación {{ object.model_description }} {{ datetime.date.today() }}" + +#. module: base_export_async +#. odoo-javascript +#: code:addons/base_export_async/static/src/js/list_controller.esm.js:0 +#, python-format +msgid "External ID" +msgstr "ID Externo" + +#. module: base_export_async +#: model:ir.model.fields,field_description:base_export_async.field_delay_export__id +msgid "ID" +msgstr "ID" + +#. module: base_export_async +#: model:ir.model.fields,field_description:base_export_async.field_delay_export____last_update +msgid "Last Modified on" +msgstr "Última fecha de modificación" + +#. module: base_export_async +#: model:ir.model.fields,field_description:base_export_async.field_delay_export__write_uid +msgid "Last Updated by" +msgstr "Última Actualización por" + +#. module: base_export_async +#: model:ir.model.fields,field_description:base_export_async.field_delay_export__write_date +msgid "Last Updated on" +msgstr "Última fecha de actualización" + +#. module: base_export_async +#: model:ir.model.fields,field_description:base_export_async.field_delay_export__model_description +msgid "Model Description" +msgstr "Descripción del modelo" + +#. module: base_export_async +#. odoo-javascript +#: code:addons/base_export_async/static/src/js/data_export.esm.js:0 +#, python-format +msgid "Please select fields to save export list..." +msgstr "" +"Por favor, seleccione los campos para guardar la lista de exportación..." + +#. module: base_export_async +#: model:ir.model.fields,field_description:base_export_async.field_delay_export__url +msgid "Url" +msgstr "Url" + +#. module: base_export_async +#: model:ir.model.fields,field_description:base_export_async.field_delay_export__user_ids +msgid "Users" +msgstr "Usuarios" + +#. module: base_export_async +#. odoo-python +#: code:addons/base_export_async/models/delay_export.py:0 +#, python-format +msgid "You must set an email address to your user." +msgstr "" +"Debes proporcionar una dirección de correo electrónico para tu usuario." + +#. module: base_export_async +#. odoo-javascript +#: code:addons/base_export_async/static/src/js/list_controller.esm.js:0 +#, python-format +msgid "You will receive the export file by email as soon as it is finished." +msgstr "" +"Recibirás el archivo de exportación por correo electrónico tan pronto como " +"acabe." diff --git a/base_export_async/i18n/fr.po b/base_export_async/i18n/fr.po new file mode 100644 index 0000000000..64171be55e --- /dev/null +++ b/base_export_async/i18n/fr.po @@ -0,0 +1,191 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * base_export_async +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 12.0\n" +"Report-Msgid-Bugs-To: \n" +"PO-Revision-Date: 2024-05-01 15:36+0000\n" +"Last-Translator: c2cdidier \n" +"Language-Team: none\n" +"Language: fr\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=2; plural=n > 1;\n" +"X-Generator: Weblate 4.17\n" + +#. module: base_export_async +#. odoo-javascript +#: code:addons/base_export_async/static/src/xml/base.xml:0 +#, python-format +msgid "(You will receive the export by email)" +msgstr "(Vous recevrez cet export par courriel)" + +#. module: base_export_async +#: model:mail.template,body_html:base_export_async.delay_export_mail_template +msgid "" +"

Your export is available here.

\n" +"

It will be automatically deleted the .

\n" +"
\n" +"

\n" +" This is an automated message " +"please do not reply.\n" +"

\n" +" " +msgstr "" +"

Votre export est disponibleici.

\n" +"

Il sera automatiquement supprimé le.

\n" +"
\n" +"

\n" +" Ce message est " +"automatique, merci de ne pas y répondre.\n" +"

\n" +" " + +#. module: base_export_async +#: model:ir.model,name:base_export_async.model_delay_export +msgid "Asynchronous Export" +msgstr "Export asynchrone" + +#. module: base_export_async +#. odoo-javascript +#: code:addons/base_export_async/static/src/xml/base.xml:0 +#, python-format +msgid "Asynchronous export" +msgstr "Export asynchrone" + +#. module: base_export_async +#: model:ir.model.fields,field_description:base_export_async.field_delay_export__create_uid +msgid "Created by" +msgstr "Créé par" + +#. module: base_export_async +#: model:ir.model.fields,field_description:base_export_async.field_delay_export__create_date +msgid "Created on" +msgstr "Créé le" + +#. module: base_export_async +#: model:mail.template,name:base_export_async.delay_export_mail_template +msgid "Delay Export" +msgstr "Retarder l'exportation" + +#. module: base_export_async +#: model:ir.actions.server,name:base_export_async.to_delete_attachment_ir_actions_server +#: model:ir.cron,cron_name:base_export_async.to_delete_attachment +msgid "Delete Generated Exports" +msgstr "Supprimer les exports générés" + +#. module: base_export_async +#: model:ir.model.fields,field_description:base_export_async.field_delay_export__display_name +msgid "Display Name" +msgstr "Nom affiché" + +#. module: base_export_async +#: model:ir.model.fields,field_description:base_export_async.field_delay_export__expiration_date +msgid "Expiration Date" +msgstr "Date d'expiration" + +#. module: base_export_async +#: model:mail.template,subject:base_export_async.delay_export_mail_template +msgid "Export {{ object.model_description }} {{ datetime.date.today() }}" +msgstr "Exportation {{ object.model_description }} {{ datetime.date.today() }}" + +#. module: base_export_async +#. odoo-javascript +#: code:addons/base_export_async/static/src/js/list_controller.esm.js:0 +#, python-format +msgid "External ID" +msgstr "Identifiant externe" + +#. module: base_export_async +#: model:ir.model.fields,field_description:base_export_async.field_delay_export__id +msgid "ID" +msgstr "ID" + +#. module: base_export_async +#: model:ir.model.fields,field_description:base_export_async.field_delay_export____last_update +msgid "Last Modified on" +msgstr "Dernière modification le" + +#. module: base_export_async +#: model:ir.model.fields,field_description:base_export_async.field_delay_export__write_uid +msgid "Last Updated by" +msgstr "Dernière mise à jour par" + +#. module: base_export_async +#: model:ir.model.fields,field_description:base_export_async.field_delay_export__write_date +msgid "Last Updated on" +msgstr "Dernière mise à jour le" + +#. module: base_export_async +#: model:ir.model.fields,field_description:base_export_async.field_delay_export__model_description +msgid "Model Description" +msgstr "Description du modèle" + +#. module: base_export_async +#. odoo-javascript +#: code:addons/base_export_async/static/src/js/data_export.esm.js:0 +#, python-format +msgid "Please select fields to save export list..." +msgstr "" +"Veuillez sélectionner les champs pour enregistrer la liste d'exportation..." + +#. module: base_export_async +#: model:ir.model.fields,field_description:base_export_async.field_delay_export__url +msgid "Url" +msgstr "Url" + +#. module: base_export_async +#: model:ir.model.fields,field_description:base_export_async.field_delay_export__user_ids +msgid "Users" +msgstr "Utilisateurs" + +#. module: base_export_async +#. odoo-python +#: code:addons/base_export_async/models/delay_export.py:0 +#, python-format +msgid "You must set an email address to your user." +msgstr "Vous devez définir une adresse e-mail pour votre utilisateur." + +#. module: base_export_async +#. odoo-javascript +#: code:addons/base_export_async/static/src/js/list_controller.esm.js:0 +#, python-format +msgid "You will receive the export file by email as soon as it is finished." +msgstr "Vous recevrez le fichier d'export par courriel dès qu'il sera terminé." + +#, python-format +#~ msgid "Please select fields to export..." +#~ msgstr "Veuillez choisir les champs à exporter..." + +#, python-format +#~ msgid "" +#~ "\n" +#~ "

Your export is available here.

\n" +#~ "

It will be automatically deleted the {}.

\n" +#~ "

 

\n" +#~ "

\n" +#~ " This is an automated message please do not reply.\n" +#~ "

\n" +#~ " " +#~ msgstr "" +#~ "\n" +#~ "

Votre export est disponible ici.\n" +#~ "

Il sera automatiquement supprimé le {}.

\n" +#~ "

 

\n" +#~ "

\n" +#~ " Ceci est un message automatisé, merci de ne pas " +#~ "répondre.\n" +#~ "

\n" +#~ " " + +#, python-format +#~ msgid "Export {} {}" +#~ msgstr "Export {} {}" diff --git a/base_export_async/i18n/hr.po b/base_export_async/i18n/hr.po new file mode 100644 index 0000000000..4c54f497a4 --- /dev/null +++ b/base_export_async/i18n/hr.po @@ -0,0 +1,146 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * base_export_async +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 16.0\n" +"Report-Msgid-Bugs-To: \n" +"Last-Translator: Automatically generated\n" +"Language-Team: none\n" +"Language: hr\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=3; plural=n%10==1 && n%100!=11 ? 0 : n%10>=2 && " +"n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2;\n" + +#. module: base_export_async +#. odoo-javascript +#: code:addons/base_export_async/static/src/xml/base.xml:0 +#, python-format +msgid "(You will receive the export by email)" +msgstr "" + +#. module: base_export_async +#: model:mail.template,body_html:base_export_async.delay_export_mail_template +msgid "" +"

Your export is available here.

\n" +"

It will be automatically deleted the .

\n" +"
\n" +"

\n" +" This is an automated message please do not reply.\n" +"

\n" +" " +msgstr "" + +#. module: base_export_async +#: model:ir.model,name:base_export_async.model_delay_export +msgid "Asynchronous Export" +msgstr "" + +#. module: base_export_async +#. odoo-javascript +#: code:addons/base_export_async/static/src/xml/base.xml:0 +#, python-format +msgid "Asynchronous export" +msgstr "" + +#. module: base_export_async +#: model:ir.model.fields,field_description:base_export_async.field_delay_export__create_uid +msgid "Created by" +msgstr "" + +#. module: base_export_async +#: model:ir.model.fields,field_description:base_export_async.field_delay_export__create_date +msgid "Created on" +msgstr "" + +#. module: base_export_async +#: model:mail.template,name:base_export_async.delay_export_mail_template +msgid "Delay Export" +msgstr "" + +#. module: base_export_async +#: model:ir.actions.server,name:base_export_async.to_delete_attachment_ir_actions_server +#: model:ir.cron,cron_name:base_export_async.to_delete_attachment +msgid "Delete Generated Exports" +msgstr "" + +#. module: base_export_async +#: model:ir.model.fields,field_description:base_export_async.field_delay_export__display_name +msgid "Display Name" +msgstr "" + +#. module: base_export_async +#: model:ir.model.fields,field_description:base_export_async.field_delay_export__expiration_date +msgid "Expiration Date" +msgstr "" + +#. module: base_export_async +#: model:mail.template,subject:base_export_async.delay_export_mail_template +msgid "Export {{ object.model_description }} {{ datetime.date.today() }}" +msgstr "" + +#. module: base_export_async +#. odoo-javascript +#: code:addons/base_export_async/static/src/js/list_controller.esm.js:0 +#, python-format +msgid "External ID" +msgstr "" + +#. module: base_export_async +#: model:ir.model.fields,field_description:base_export_async.field_delay_export__id +msgid "ID" +msgstr "" + +#. module: base_export_async +#: model:ir.model.fields,field_description:base_export_async.field_delay_export____last_update +msgid "Last Modified on" +msgstr "" + +#. module: base_export_async +#: model:ir.model.fields,field_description:base_export_async.field_delay_export__write_uid +msgid "Last Updated by" +msgstr "" + +#. module: base_export_async +#: model:ir.model.fields,field_description:base_export_async.field_delay_export__write_date +msgid "Last Updated on" +msgstr "" + +#. module: base_export_async +#: model:ir.model.fields,field_description:base_export_async.field_delay_export__model_description +msgid "Model Description" +msgstr "" + +#. module: base_export_async +#. odoo-javascript +#: code:addons/base_export_async/static/src/js/data_export.esm.js:0 +#, python-format +msgid "Please select fields to save export list..." +msgstr "" + +#. module: base_export_async +#: model:ir.model.fields,field_description:base_export_async.field_delay_export__url +msgid "Url" +msgstr "" + +#. module: base_export_async +#: model:ir.model.fields,field_description:base_export_async.field_delay_export__user_ids +msgid "Users" +msgstr "" + +#. module: base_export_async +#. odoo-python +#: code:addons/base_export_async/models/delay_export.py:0 +#, python-format +msgid "You must set an email address to your user." +msgstr "" + +#. module: base_export_async +#. odoo-javascript +#: code:addons/base_export_async/static/src/js/list_controller.esm.js:0 +#, python-format +msgid "You will receive the export file by email as soon as it is finished." +msgstr "" diff --git a/base_export_async/i18n/it.po b/base_export_async/i18n/it.po new file mode 100644 index 0000000000..6d17c1cf06 --- /dev/null +++ b/base_export_async/i18n/it.po @@ -0,0 +1,160 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * base_export_async +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 12.0\n" +"Report-Msgid-Bugs-To: \n" +"PO-Revision-Date: 2024-04-04 11:38+0000\n" +"Last-Translator: mymage \n" +"Language-Team: none\n" +"Language: it\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"X-Generator: Weblate 4.17\n" + +#. module: base_export_async +#. odoo-javascript +#: code:addons/base_export_async/static/src/xml/base.xml:0 +#, python-format +msgid "(You will receive the export by email)" +msgstr "(Verrà inviata l'esportazione vie e-mail)" + +#. module: base_export_async +#: model:mail.template,body_html:base_export_async.delay_export_mail_template +msgid "" +"

Your export is available here.

\n" +"

It will be automatically deleted the .

\n" +"
\n" +"

\n" +" This is an automated message " +"please do not reply.\n" +"

\n" +" " +msgstr "" +"

L'esportazione è disponibile qui.

\n" +"

Verrà cancellata automaticamente il .

\n" +"
\n" +"

\n" +" Questo è un messaggio " +"atomatico, non rispondere.\n" +"

\n" +" " + +#. module: base_export_async +#: model:ir.model,name:base_export_async.model_delay_export +msgid "Asynchronous Export" +msgstr "Esportazione asincrona" + +#. module: base_export_async +#. odoo-javascript +#: code:addons/base_export_async/static/src/xml/base.xml:0 +#, python-format +msgid "Asynchronous export" +msgstr "Esportazione asincrona" + +#. module: base_export_async +#: model:ir.model.fields,field_description:base_export_async.field_delay_export__create_uid +msgid "Created by" +msgstr "Creato da" + +#. module: base_export_async +#: model:ir.model.fields,field_description:base_export_async.field_delay_export__create_date +msgid "Created on" +msgstr "Creato il" + +#. module: base_export_async +#: model:mail.template,name:base_export_async.delay_export_mail_template +msgid "Delay Export" +msgstr "Ritarda esportazione" + +#. module: base_export_async +#: model:ir.actions.server,name:base_export_async.to_delete_attachment_ir_actions_server +#: model:ir.cron,cron_name:base_export_async.to_delete_attachment +msgid "Delete Generated Exports" +msgstr "Cancella esportazioni generate" + +#. module: base_export_async +#: model:ir.model.fields,field_description:base_export_async.field_delay_export__display_name +msgid "Display Name" +msgstr "Nome visualizzato" + +#. module: base_export_async +#: model:ir.model.fields,field_description:base_export_async.field_delay_export__expiration_date +msgid "Expiration Date" +msgstr "Data di scadenza" + +#. module: base_export_async +#: model:mail.template,subject:base_export_async.delay_export_mail_template +msgid "Export {{ object.model_description }} {{ datetime.date.today() }}" +msgstr "Esportazione {{ object.model_description }} {{ datetime.date.today() }}" + +#. module: base_export_async +#. odoo-javascript +#: code:addons/base_export_async/static/src/js/list_controller.esm.js:0 +#, python-format +msgid "External ID" +msgstr "ID esterno" + +#. module: base_export_async +#: model:ir.model.fields,field_description:base_export_async.field_delay_export__id +msgid "ID" +msgstr "ID" + +#. module: base_export_async +#: model:ir.model.fields,field_description:base_export_async.field_delay_export____last_update +msgid "Last Modified on" +msgstr "Ultima modifica il" + +#. module: base_export_async +#: model:ir.model.fields,field_description:base_export_async.field_delay_export__write_uid +msgid "Last Updated by" +msgstr "Ultimo aggiornamento di" + +#. module: base_export_async +#: model:ir.model.fields,field_description:base_export_async.field_delay_export__write_date +msgid "Last Updated on" +msgstr "Ultimo aggiornamento il" + +#. module: base_export_async +#: model:ir.model.fields,field_description:base_export_async.field_delay_export__model_description +msgid "Model Description" +msgstr "Descrizione modello" + +#. module: base_export_async +#. odoo-javascript +#: code:addons/base_export_async/static/src/js/data_export.esm.js:0 +#, python-format +msgid "Please select fields to save export list..." +msgstr "Selezionare campi per salvare elenco esportazione..." + +#. module: base_export_async +#: model:ir.model.fields,field_description:base_export_async.field_delay_export__url +msgid "Url" +msgstr "URL" + +#. module: base_export_async +#: model:ir.model.fields,field_description:base_export_async.field_delay_export__user_ids +msgid "Users" +msgstr "Utenti" + +#. module: base_export_async +#. odoo-python +#: code:addons/base_export_async/models/delay_export.py:0 +#, python-format +msgid "You must set an email address to your user." +msgstr "Bisogna impostare una e-mail nel proprio utente." + +#. module: base_export_async +#. odoo-javascript +#: code:addons/base_export_async/static/src/js/list_controller.esm.js:0 +#, python-format +msgid "You will receive the export file by email as soon as it is finished." +msgstr "Verrà inviato via e-mail il file esportazione appena sarà completato." diff --git a/base_export_async/i18n/pt.po b/base_export_async/i18n/pt.po new file mode 100644 index 0000000000..2240c2f246 --- /dev/null +++ b/base_export_async/i18n/pt.po @@ -0,0 +1,180 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * base_export_async +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 12.0\n" +"Report-Msgid-Bugs-To: \n" +"PO-Revision-Date: 2021-03-04 16:45+0000\n" +"Last-Translator: Pedro Castro Silva \n" +"Language-Team: none\n" +"Language: pt\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=2; plural=n > 1;\n" +"X-Generator: Weblate 4.3.2\n" + +#. module: base_export_async +#. odoo-javascript +#: code:addons/base_export_async/static/src/xml/base.xml:0 +#, python-format +msgid "(You will receive the export by email)" +msgstr "(Receberá a exportação por email)" + +#. module: base_export_async +#: model:mail.template,body_html:base_export_async.delay_export_mail_template +msgid "" +"

Your export is available here.

\n" +"

It will be automatically deleted the .

\n" +"
\n" +"

\n" +" This is an automated message " +"please do not reply.\n" +"

\n" +" " +msgstr "" + +#. module: base_export_async +#: model:ir.model,name:base_export_async.model_delay_export +msgid "Asynchronous Export" +msgstr "Exportação Assíncrona" + +#. module: base_export_async +#. odoo-javascript +#: code:addons/base_export_async/static/src/xml/base.xml:0 +#, python-format +msgid "Asynchronous export" +msgstr "Exportação assíncrona" + +#. module: base_export_async +#: model:ir.model.fields,field_description:base_export_async.field_delay_export__create_uid +msgid "Created by" +msgstr "Criada por" + +#. module: base_export_async +#: model:ir.model.fields,field_description:base_export_async.field_delay_export__create_date +msgid "Created on" +msgstr "Criado em" + +#. module: base_export_async +#: model:mail.template,name:base_export_async.delay_export_mail_template +msgid "Delay Export" +msgstr "" + +#. module: base_export_async +#: model:ir.actions.server,name:base_export_async.to_delete_attachment_ir_actions_server +#: model:ir.cron,cron_name:base_export_async.to_delete_attachment +msgid "Delete Generated Exports" +msgstr "Eliminar Exportações Geradas" + +#. module: base_export_async +#: model:ir.model.fields,field_description:base_export_async.field_delay_export__display_name +msgid "Display Name" +msgstr "Nome a Apresentar" + +#. module: base_export_async +#: model:ir.model.fields,field_description:base_export_async.field_delay_export__expiration_date +msgid "Expiration Date" +msgstr "" + +#. module: base_export_async +#: model:mail.template,subject:base_export_async.delay_export_mail_template +msgid "Export {{ object.model_description }} {{ datetime.date.today() }}" +msgstr "" + +#. module: base_export_async +#. odoo-javascript +#: code:addons/base_export_async/static/src/js/list_controller.esm.js:0 +#, python-format +msgid "External ID" +msgstr "ID Externo" + +#. module: base_export_async +#: model:ir.model.fields,field_description:base_export_async.field_delay_export__id +msgid "ID" +msgstr "" + +#. module: base_export_async +#: model:ir.model.fields,field_description:base_export_async.field_delay_export____last_update +msgid "Last Modified on" +msgstr "Última Modificação em" + +#. module: base_export_async +#: model:ir.model.fields,field_description:base_export_async.field_delay_export__write_uid +msgid "Last Updated by" +msgstr "Última Atualização por" + +#. module: base_export_async +#: model:ir.model.fields,field_description:base_export_async.field_delay_export__write_date +msgid "Last Updated on" +msgstr "Última Atualização em" + +#. module: base_export_async +#: model:ir.model.fields,field_description:base_export_async.field_delay_export__model_description +msgid "Model Description" +msgstr "" + +#. module: base_export_async +#. odoo-javascript +#: code:addons/base_export_async/static/src/js/data_export.esm.js:0 +#, python-format +msgid "Please select fields to save export list..." +msgstr "" + +#. module: base_export_async +#: model:ir.model.fields,field_description:base_export_async.field_delay_export__url +msgid "Url" +msgstr "" + +#. module: base_export_async +#: model:ir.model.fields,field_description:base_export_async.field_delay_export__user_ids +msgid "Users" +msgstr "Utilizadores" + +#. module: base_export_async +#. odoo-python +#: code:addons/base_export_async/models/delay_export.py:0 +#, python-format +msgid "You must set an email address to your user." +msgstr "Tem que atribuir um email ao seu utilizador." + +#. module: base_export_async +#. odoo-javascript +#: code:addons/base_export_async/static/src/js/list_controller.esm.js:0 +#, python-format +msgid "You will receive the export file by email as soon as it is finished." +msgstr "" +"Receberá o ficheiro de exportação por email assim que este estiver terminado." + +#, python-format +#~ msgid "Please select fields to export..." +#~ msgstr "Por favor, selecione os campos a exportar..." + +#, python-format +#~ msgid "" +#~ "\n" +#~ "

Your export is available here.

\n" +#~ "

It will be automatically deleted the {}.

\n" +#~ "

 

\n" +#~ "

\n" +#~ " This is an automated message please do not reply.\n" +#~ "

\n" +#~ " " +#~ msgstr "" +#~ "\n" +#~ "

A sua exportação está disponível aqui.

\n" +#~ "

Será automaticamente eliminada em {}.

\n" +#~ "

 

\n" +#~ "

\n" +#~ " Esta é uma mensagem automática. Por favor, não responda.\n" +#~ "

\n" +#~ " " + +#, python-format +#~ msgid "Export {} {}" +#~ msgstr "Exportar {} {}" diff --git a/base_export_async/i18n/zh_CN.po b/base_export_async/i18n/zh_CN.po new file mode 100644 index 0000000000..6e9b54ac8a --- /dev/null +++ b/base_export_async/i18n/zh_CN.po @@ -0,0 +1,185 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * base_export_async +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 12.0\n" +"Report-Msgid-Bugs-To: \n" +"PO-Revision-Date: 2019-07-25 17:43+0000\n" +"Last-Translator: 黎伟杰 <674416404@qq.com>\n" +"Language-Team: none\n" +"Language: zh_CN\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=1; plural=0;\n" +"X-Generator: Weblate 3.7.1\n" + +#. module: base_export_async +#. odoo-javascript +#: code:addons/base_export_async/static/src/xml/base.xml:0 +#, python-format +msgid "(You will receive the export by email)" +msgstr "(您将通过电子邮件收到导出)" + +#. module: base_export_async +#: model:mail.template,body_html:base_export_async.delay_export_mail_template +msgid "" +"

Your export is available here.

\n" +"

It will be automatically deleted the .

\n" +"
\n" +"

\n" +" This is an automated message " +"please do not reply.\n" +"

\n" +" " +msgstr "" + +#. module: base_export_async +#: model:ir.model,name:base_export_async.model_delay_export +#, fuzzy +msgid "Asynchronous Export" +msgstr "异步导出" + +#. module: base_export_async +#. odoo-javascript +#: code:addons/base_export_async/static/src/xml/base.xml:0 +#, python-format +msgid "Asynchronous export" +msgstr "异步导出" + +#. module: base_export_async +#: model:ir.model.fields,field_description:base_export_async.field_delay_export__create_uid +msgid "Created by" +msgstr "创建者" + +#. module: base_export_async +#: model:ir.model.fields,field_description:base_export_async.field_delay_export__create_date +msgid "Created on" +msgstr "创建时间" + +#. module: base_export_async +#: model:mail.template,name:base_export_async.delay_export_mail_template +msgid "Delay Export" +msgstr "" + +#. module: base_export_async +#: model:ir.actions.server,name:base_export_async.to_delete_attachment_ir_actions_server +#: model:ir.cron,cron_name:base_export_async.to_delete_attachment +msgid "Delete Generated Exports" +msgstr "删除生成的导出" + +#. module: base_export_async +#: model:ir.model.fields,field_description:base_export_async.field_delay_export__display_name +msgid "Display Name" +msgstr "显示名称" + +#. module: base_export_async +#: model:ir.model.fields,field_description:base_export_async.field_delay_export__expiration_date +msgid "Expiration Date" +msgstr "" + +#. module: base_export_async +#: model:mail.template,subject:base_export_async.delay_export_mail_template +msgid "Export {{ object.model_description }} {{ datetime.date.today() }}" +msgstr "" + +#. module: base_export_async +#. odoo-javascript +#: code:addons/base_export_async/static/src/js/list_controller.esm.js:0 +#, python-format +msgid "External ID" +msgstr "外部ID" + +#. module: base_export_async +#: model:ir.model.fields,field_description:base_export_async.field_delay_export__id +msgid "ID" +msgstr "ID" + +#. module: base_export_async +#: model:ir.model.fields,field_description:base_export_async.field_delay_export____last_update +msgid "Last Modified on" +msgstr "最后修改时间" + +#. module: base_export_async +#: model:ir.model.fields,field_description:base_export_async.field_delay_export__write_uid +msgid "Last Updated by" +msgstr "最后更新者" + +#. module: base_export_async +#: model:ir.model.fields,field_description:base_export_async.field_delay_export__write_date +msgid "Last Updated on" +msgstr "最后更新时间" + +#. module: base_export_async +#: model:ir.model.fields,field_description:base_export_async.field_delay_export__model_description +msgid "Model Description" +msgstr "" + +#. module: base_export_async +#. odoo-javascript +#: code:addons/base_export_async/static/src/js/data_export.esm.js:0 +#, python-format +msgid "Please select fields to save export list..." +msgstr "" + +#. module: base_export_async +#: model:ir.model.fields,field_description:base_export_async.field_delay_export__url +msgid "Url" +msgstr "" + +#. module: base_export_async +#: model:ir.model.fields,field_description:base_export_async.field_delay_export__user_ids +msgid "Users" +msgstr "用户" + +#. module: base_export_async +#. odoo-python +#: code:addons/base_export_async/models/delay_export.py:0 +#, python-format +msgid "You must set an email address to your user." +msgstr "您必须为您的用户设置电子邮件地址。" + +#. module: base_export_async +#. odoo-javascript +#: code:addons/base_export_async/static/src/js/list_controller.esm.js:0 +#, python-format +msgid "You will receive the export file by email as soon as it is finished." +msgstr "完成后,您将通过电子邮件收到导出文件。" + +#, python-format +#~ msgid "Please select fields to export..." +#~ msgstr "请选择要导出的字段..." + +#, python-format +#~ msgid "" +#~ "\n" +#~ "

Your export is available here.

\n" +#~ "

It will be automatically deleted the {}.

\n" +#~ "

 

\n" +#~ "

\n" +#~ " This is an automated message please do not reply.\n" +#~ "

\n" +#~ " " +#~ msgstr "" +#~ "\n" +#~ "

你的导出可以用 这里.

\n" +#~ "

它将自动删除 {}。

\n" +#~ "

 

\n" +#~ "

\n" +#~ " 这是一条自动消息,请不要回复。\n" +#~ "

\n" +#~ " " + +#, python-format +#~ msgid "Export {} {}" +#~ msgstr "导出{} {}" + +#~ msgid "Allow to delay the export" +#~ msgstr "允许延迟导出" + +#~ msgid "The user doesn't have an email address." +#~ msgstr "用户没有电子邮件地址。" diff --git a/base_export_async/models/__init__.py b/base_export_async/models/__init__.py new file mode 100644 index 0000000000..f3652a9bf5 --- /dev/null +++ b/base_export_async/models/__init__.py @@ -0,0 +1,4 @@ +# Copyright 2019 ACSONE SA/NV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from . import delay_export diff --git a/base_export_async/models/delay_export.py b/base_export_async/models/delay_export.py new file mode 100644 index 0000000000..3cd936376f --- /dev/null +++ b/base_export_async/models/delay_export.py @@ -0,0 +1,153 @@ +# Copyright 2019 ACSONE SA/NV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import base64 +import json +import operator + +from dateutil.relativedelta import relativedelta + +from odoo import _, api, fields, models +from odoo.exceptions import UserError + +from odoo.addons.web.controllers.export import CSVExport + +from ..export import ExcelExport + + +class DelayExport(models.Model): + _name = "delay.export" + _description = "Asynchronous Export" + + user_ids = fields.Many2many("res.users", string="Users", index=True) + model_description = fields.Char() + url = fields.Char() + expiration_date = fields.Date() + + @api.model + def delay_export(self, data): + """Delay the export, called from js""" + params = json.loads(data.get("data")) + if not self.env.user.email: + raise UserError(_("You must set an email address to your user.")) + self.with_delay().export(params) + + @api.model + def _get_file_content(self, params): + export_format = params.get("format") + + items = operator.itemgetter( + "model", "fields", "ids", "domain", "import_compat", "context", "user_ids" + )(params) + (model_name, fields_name, ids, domain, import_compat, context, user_ids) = items + + model = self.env[model_name].with_context( + import_compat=import_compat, **context + ) + records = model.browse(ids) or model.search( + domain, offset=0, limit=False, order=False + ) + + if not model._is_an_ordinary_table(): + fields_name = [field for field in fields_name if field["name"] != "id"] + + field_names = [f["name"] for f in fields_name] + import_data = records.export_data(field_names).get("datas", []) + + if import_compat: + columns_headers = field_names + else: + columns_headers = [val["label"].strip() for val in fields_name] + + if export_format == "csv": + csv = CSVExport() + return csv.from_data(columns_headers, import_data) + else: + xls = ExcelExport(self.env) + return xls.from_data(columns_headers, import_data) + + @api.model + def export(self, params): + """Delayed export of a file sent by email + + The ``params`` is a dict of parameters, contains: + + * format: csv/excel + * model: model to export + * fields: list of fields to export, a list of dict: + [{'label': '', 'name': ''}] + * ids: list of ids to export + * domain: domain for the export + * context: context for the export (language, ...) + * import_compat: if the export is export/import compatible (boolean) + * user_ids: optional list of user ids who receive the file + """ + content = self._get_file_content(params) + + items = operator.itemgetter("model", "context", "format", "user_ids")(params) + model_name, context, export_format, user_ids = items + users = self.env["res.users"].browse(user_ids) + + export_record = self.sudo().create({"user_ids": [(6, 0, users.ids)]}) + + name = f"{model_name}.{export_format}" + attachment = ( + self.env["ir.attachment"] + .sudo() + .create( + { + "name": name, + "datas": base64.b64encode(content), + "type": "binary", + "res_model": self._name, + "res_id": export_record.id, + } + ) + ) + + url = "{}/web/content/ir.attachment/{}/datas/{}?download=true".format( + self.env["ir.config_parameter"].sudo().get_param("web.base.url"), + attachment.id, + attachment.name, + ) + + if any(user.has_group("base.group_portal") for user in users): + attachment.generate_access_token() + url += f"&access_token={attachment.access_token}" + + time_to_live = ( + self.env["ir.config_parameter"].sudo().get_param("attachment.ttl", 7) + ) + date_today = fields.Date.today() + expiration_date = fields.Date.to_string( + date_today + relativedelta(days=+int(time_to_live)) + ) + + odoo_bot = self.sudo().env.ref("base.partner_root") + email_from = odoo_bot.email + model_description = self.env[model_name]._description + export_record.write( + { + "url": url, + "expiration_date": expiration_date, + "model_description": model_description, + } + ) + + self.env.ref("base_export_async.delay_export_mail_template").send_mail( + export_record.id, + email_values={ + "email_from": email_from, + "reply_to": email_from, + "recipient_ids": [(6, 0, users.mapped("partner_id").ids)], + }, + ) + + @api.model + def cron_delete(self): + time_to_live = ( + self.env["ir.config_parameter"].sudo().get_param("attachment.ttl", 7) + ) + date_today = fields.Date.today() + date_to_delete = date_today + relativedelta(days=-int(time_to_live)) + self.search([("create_date", "<=", date_to_delete)]).unlink() diff --git a/base_export_async/pyproject.toml b/base_export_async/pyproject.toml new file mode 100644 index 0000000000..4231d0cccb --- /dev/null +++ b/base_export_async/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/base_export_async/readme/CONTRIBUTORS.md b/base_export_async/readme/CONTRIBUTORS.md new file mode 100644 index 0000000000..0c1be31012 --- /dev/null +++ b/base_export_async/readme/CONTRIBUTORS.md @@ -0,0 +1,3 @@ +- Arnaud Pineux (ACSONE SA/NV) authored the initial prototype. +- Guewen Baconnier (Camptocamp) +- Stéphane Mangin (ACSONE SA/NV) diff --git a/base_export_async/readme/CREDITS.md b/base_export_async/readme/CREDITS.md new file mode 100644 index 0000000000..3e46cd8386 --- /dev/null +++ b/base_export_async/readme/CREDITS.md @@ -0,0 +1,3 @@ +The migration of this module from 16.0 to 17.0 was financially supported by: + +- ACSONE SA/NV () diff --git a/base_export_async/readme/DESCRIPTION.md b/base_export_async/readme/DESCRIPTION.md new file mode 100644 index 0000000000..4802cbbdaa --- /dev/null +++ b/base_export_async/readme/DESCRIPTION.md @@ -0,0 +1,2 @@ +Standard Export can be delayed in asynchronous jobs executed in the background and then +send by email to the user. diff --git a/base_export_async/readme/USAGE.md b/base_export_async/readme/USAGE.md new file mode 100644 index 0000000000..ffa173d031 --- /dev/null +++ b/base_export_async/readme/USAGE.md @@ -0,0 +1,5 @@ +The user is presented with a new checkbox "Asynchronous export" in the export screen. +When selected, the export is delayed in a background job. + +The .csv or .xls file generated by the export will be sent by email to the user who +execute the export. diff --git a/base_export_async/security/ir.model.access.csv b/base_export_async/security/ir.model.access.csv new file mode 100644 index 0000000000..948323af24 --- /dev/null +++ b/base_export_async/security/ir.model.access.csv @@ -0,0 +1,3 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_delay_export,delay.export.user,model_delay_export,base.group_user,1,0,0,0 +access_delay_export_sudo,delay.export.sudo,model_delay_export,base.group_no_one,1,1,1,1 diff --git a/base_export_async/security/ir_rule.xml b/base_export_async/security/ir_rule.xml new file mode 100644 index 0000000000..0d748156f3 --- /dev/null +++ b/base_export_async/security/ir_rule.xml @@ -0,0 +1,13 @@ + + + + Only user can read delay.export + + + + + + + [('user_ids', 'in', user.id)] + + diff --git a/base_export_async/static/description/icon.png b/base_export_async/static/description/icon.png new file mode 100644 index 0000000000..3a0328b516 Binary files /dev/null and b/base_export_async/static/description/icon.png differ diff --git a/base_export_async/static/description/index.html b/base_export_async/static/description/index.html new file mode 100644 index 0000000000..ce84c48fa4 --- /dev/null +++ b/base_export_async/static/description/index.html @@ -0,0 +1,443 @@ + + + + + +Base Export Async + + + +
+

Base Export Async

+ + +

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

+

Standard Export can be delayed in asynchronous jobs executed in the +background and then send by email to the user.

+

Table of contents

+ +
+

Usage

+

The user is presented with a new checkbox “Asynchronous export” in the +export screen. When selected, the export is delayed in a background job.

+

The .csv or .xls file generated by the export will be sent by email to +the user who execute the export.

+
+
+

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 +feedback.

+

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

+
+
+

Credits

+
+

Authors

+
    +
  • ACSONE SA/NV
  • +
+
+
+

Contributors

+
    +
  • Arnaud Pineux (ACSONE SA/NV) authored the initial prototype.
  • +
  • Guewen Baconnier (Camptocamp)
  • +
  • Stéphane Mangin (ACSONE SA/NV)
  • +
+
+
+

Other credits

+

The migration of this module from 16.0 to 17.0 was financially supported +by:

+ +
+
+

Maintainers

+

This module is maintained by the OCA.

+ +Odoo Community Association + +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

This module is part of the OCA/queue project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+ + diff --git a/base_export_async/static/src/js/data_export.esm.js b/base_export_async/static/src/js/data_export.esm.js new file mode 100644 index 0000000000..fe4e489ff7 --- /dev/null +++ b/base_export_async/static/src/js/data_export.esm.js @@ -0,0 +1,29 @@ +/** @odoo-module */ +import {ExportDataDialog} from "@web/views/view_dialogs/export_data_dialog"; +import {patch} from "@web/core/utils/patch"; + +patch(ExportDataDialog.prototype, { + setup() { + super.setup(); + this.state.async = false; + }, + onToggleExportAsync(value) { + this.state.async = value; + }, + async onClickExportButton() { + if (!this.state.exportList.length) { + return this.notification.add( + this.env._t("Please select fields to save export list..."), + { + type: "danger", + } + ); + } + await this.props.download( + this.state.exportList, + this.state.isCompatible, + this.availableFormats[this.state.selectedFormat].tag, + this.state.async + ); + }, +}); diff --git a/base_export_async/static/src/js/list_controller.esm.js b/base_export_async/static/src/js/list_controller.esm.js new file mode 100644 index 0000000000..982eb389ad --- /dev/null +++ b/base_export_async/static/src/js/list_controller.esm.js @@ -0,0 +1,71 @@ +/** @odoo-module **/ +import {ListController} from "@web/views/list/list_controller"; +import {_t} from "@web/core/l10n/translation"; +import {download} from "@web/core/network/download"; +import {patch} from "@web/core/utils/patch"; +patch(ListController.prototype, { + async downloadExport(fields, import_compat, format, async = false) { + let ids = false; + if (!this.isDomainSelected) { + const resIds = await this.getSelectedResIds(); + ids = resIds.length > 0 && resIds; + } + const exportedFields = fields.map((field) => ({ + name: field.name || field.id, + label: field.label || field.string, + store: field.store, + type: field.field_type || field.type, + })); + if (import_compat) { + exportedFields.unshift({name: "id", label: this.env._t("External ID")}); + } + if (async) { + /* + Call the delay export if Async is checked + */ + this.env.services.ui.block(); + const args = [ + { + data: JSON.stringify({ + format: format, + model: this.model.root.resModel, + fields: exportedFields, + ids: ids, + domain: this.model.root.domain, + context: this.props.context, + import_compat: import_compat, + user_ids: [this.props.context.uid], + }), + }, + ]; + const orm = this.env.services.orm; + orm.call("delay.export", "delay_export", args).then(() => { + this.env.services.ui.unblock(); + this.env.services.notification.add( + _t( + "You will receive the export file by email as soon as it is finished." + ), + { + title: _t("Export Scheduled"), + type: "success", + } + ); + }); + } else { + await download({ + data: { + data: JSON.stringify({ + import_compat, + context: this.props.context, + domain: this.model.root.domain, + fields: exportedFields, + groupby: this.model.root.groupBy, + ids, + model: this.model.root.resModel, + }), + }, + url: `/web/export/${format}`, + }); + } + }, +}); diff --git a/base_export_async/static/src/xml/base.xml b/base_export_async/static/src/xml/base.xml new file mode 100644 index 0000000000..c0b92ee66a --- /dev/null +++ b/base_export_async/static/src/xml/base.xml @@ -0,0 +1,20 @@ + + + + + + + Asynchronous export (You will receive the export by email) + + + + + diff --git a/base_export_async/tests/__init__.py b/base_export_async/tests/__init__.py new file mode 100644 index 0000000000..f65982a510 --- /dev/null +++ b/base_export_async/tests/__init__.py @@ -0,0 +1,5 @@ +# Copyright 2019 ACSONE SA/NV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from . import test_base_export_async +from . import test_export diff --git a/base_export_async/tests/test_base_export_async.py b/base_export_async/tests/test_base_export_async.py new file mode 100644 index 0000000000..26e366eec0 --- /dev/null +++ b/base_export_async/tests/test_base_export_async.py @@ -0,0 +1,115 @@ +# Copyright 2019 ACSONE SA/NV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import json +from unittest.mock import patch + +import freezegun +from dateutil.relativedelta import relativedelta + +import odoo.tests.common as common +from odoo import fields + +from ..export import ExcelExport + +data_csv = { + "data": """{"format": "csv", "model": "res.partner", + "fields": [{"name": "id", "label": "External ID"}, + {"name": "display_name", "label": "Display Name"}, + {"name": "email", "label": "Email"}, + {"name": "phone", "label": "Phone"}], + "ids": false, + "domain": [], + "context": {"lang": "en_US", "tz": "Europe/Brussels", "uid": 2}, + "import_compat": false, + "user_ids": [6] + }""" +} + +data_xls = { + "data": """{"format": "xls", "model": "res.partner", + "fields": [{"name": "id", "label": "External ID"}, + {"name": "display_name", "label": "Display Name"}, + {"name": "email", "label": "Email"}, + {"name": "phone", "label": "Phone"}], + "ids": false, + "domain": [], + "context": {"lang": "en_US", "tz": "Europe/Brussels", "uid": 2}, + "import_compat": false, + "user_ids": [6] + }""" +} + + +class TestBaseExportAsync(common.TransactionCase): + def setUp(self): + super().setUp() + self.delay_export_obj = self.env["delay.export"] + self.job_obj = self.env["queue.job"] + + def test_delay_export(self): + """Check that the call create a new JOB""" + nbr_job = len(self.job_obj.search([])) + self.delay_export_obj.delay_export(data_csv) + new_nbr_job = len(self.job_obj.search([])) + self.assertEqual(new_nbr_job, nbr_job + 1) + + def test_export_csv(self): + """Check that the export generate an attachment and email""" + params = json.loads(data_csv.get("data")) + mails = self.env["mail.mail"].search([]) + attachments = self.env["ir.attachment"].search([]) + self.delay_export_obj.export(params) + new_mail = self.env["mail.mail"].search([]) - mails + new_attachment = self.env["ir.attachment"].search([]) - attachments + self.assertEqual(len(new_mail), 1) + self.assertEqual(new_attachment.name, "res.partner.csv") + + def test_export_xls(self): + """Check that the export generate an attachment and email""" + params = json.loads(data_xls.get("data")) + mails = self.env["mail.mail"].search([]) + attachments = self.env["ir.attachment"].search([]) + with patch.object(ExcelExport, "from_data", return_value=b"\x41\x42\x43\x44"): + self.delay_export_obj.export(params) + new_mail = self.env["mail.mail"].search([]) - mails + new_attachment = self.env["ir.attachment"].search([]) - attachments + self.assertEqual(len(new_mail), 1) + self.assertEqual(new_attachment.name, "res.partner.xls") + self.assertTrue(new_attachment.datas) + + def test_cron_delete(self): + """Check that cron delete attachment after TTL""" + params = json.loads(data_csv.get("data")) + attachments = self.env["ir.attachment"].search([]) + self.delay_export_obj.export(params) + new_attachment = self.env["ir.attachment"].search([]) - attachments + time_to_live = ( + self.env["ir.config_parameter"].sudo().get_param("attachment.ttl", 7) + ) + date_today = fields.Datetime.now() + date_past_ttl = date_today + relativedelta(days=int(time_to_live)) + with freezegun.freeze_time(date_past_ttl): + self.delay_export_obj.cron_delete() + + # The attachment must be deleted + self.assertFalse(new_attachment.exists()) + + def test_portal_export(self): + """Check that we make attachments externally accessible for portal users""" + portal_user = self.env["res.users"].create( + { + "login": "base_export_async_portal_user", + "name": "base_export_async_portal_user", + "groups_id": self.env.ref("base.group_portal").ids, + } + ) + params = json.loads(data_csv.get("data")) + params["user_ids"] = portal_user.ids + attachments = self.env["ir.attachment"].search([]) + mails = self.env["mail.mail"].search([]) + self.delay_export_obj.export(params) + new_attachment = self.env["ir.attachment"].search([]) - attachments + self.assertTrue(new_attachment.access_token) + new_mail = self.env["mail.mail"].search([]) - mails + self.assertIn("&access_token=", new_mail.body) diff --git a/base_export_async/tests/test_export.py b/base_export_async/tests/test_export.py new file mode 100644 index 0000000000..0d1f4a641e --- /dev/null +++ b/base_export_async/tests/test_export.py @@ -0,0 +1,121 @@ +# Copyright 2026 ACSONE SA/NV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import odoo.tests.common as common +from odoo.exceptions import UserError + +from ..export import CleanedExportXlsxWriter, ExcelExport + + +class TestCleanedExportXlsxWriter(common.TransactionCase): + def test_default_decimal_places(self): + """Check that default monetary format uses 2 decimal places""" + with CleanedExportXlsxWriter(["Col1"], row_count=0) as writer: + self.assertEqual(writer.monetary_format, "#,##0.00") + + def test_custom_decimal_places(self): + """Check that monetary format adapts to provided decimal places""" + with CleanedExportXlsxWriter( + ["Col1"], row_count=0, decimal_places=[3, 5, 2] + ) as writer: + self.assertEqual(writer.monetary_format, "#,##0.00000") + + def test_single_decimal_place(self): + """Check monetary format with a single decimal place value""" + with CleanedExportXlsxWriter( + ["Col1"], row_count=0, decimal_places=[4] + ) as writer: + self.assertEqual(writer.monetary_format, "#,##0.0000") + + def test_empty_decimal_places(self): + """Check that empty list falls back to 2 decimal places""" + with CleanedExportXlsxWriter( + ["Col1"], row_count=0, decimal_places=[] + ) as writer: + self.assertEqual(writer.monetary_format, "#,##0.00") + + def test_write_header(self): + """Check that headers are written correctly""" + headers = ["Name", "Email", "Phone"] + with CleanedExportXlsxWriter(headers, row_count=0) as writer: + self.assertEqual(writer.field_names, headers) + + def test_write_cell_string(self): + """Check that string values are written without error""" + with CleanedExportXlsxWriter(["Col1"], row_count=1) as writer: + writer.write_cell(1, 0, "test value") + self.assertTrue(writer.value) + + def test_write_cell_float(self): + """Check that float values are written without error""" + with CleanedExportXlsxWriter(["Col1"], row_count=1) as writer: + writer.write_cell(1, 0, 3.14) + self.assertTrue(writer.value) + + def test_too_many_rows(self): + """Check that UserError is raised when row count exceeds xlsx limit""" + with self.assertRaises(UserError): + CleanedExportXlsxWriter(["Col1"], row_count=1_048_577) + + def test_output_is_bytes(self): + """Check that the output value is bytes""" + with CleanedExportXlsxWriter(["Col1"], row_count=1) as writer: + writer.write_cell(1, 0, "data") + self.assertIsInstance(writer.value, bytes) + + +class TestExcelExport(common.TransactionCase): + def test_from_data_returns_bytes(self): + """Check that from_data produces valid xlsx bytes""" + exporter = ExcelExport(self.env) + result = exporter.from_data( + ["Name", "Value"], + [["Alice", 1], ["Bob", 2]], + ) + self.assertIsInstance(result, bytes) + self.assertTrue(len(result) > 0) + + def test_from_data_empty_rows(self): + """Check that from_data works with no data rows""" + exporter = ExcelExport(self.env) + result = exporter.from_data(["Name"], []) + self.assertIsInstance(result, bytes) + + def test_from_data_uses_currency_decimal_places(self): + """Check that decimal places are fetched from res.currency""" + exporter = ExcelExport(self.env) + currencies = self.env["res.currency"].search([]) + expected_max = max(currencies.mapped("decimal_places") or [2]) + result = exporter.from_data(["Col1"], [["test"]]) + self.assertIsInstance(result, bytes) + # Ensure we can at least verify the exporter ran with currency data + self.assertTrue(expected_max >= 0) + + def test_content_type(self): + """Check content_type property""" + exporter = ExcelExport(self.env) + self.assertEqual( + exporter.content_type, + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ) + + def test_extension(self): + """Check extension property""" + exporter = ExcelExport(self.env) + self.assertEqual(exporter.extension, ".xlsx") + + def test_from_data_mixed_types(self): + """Check that from_data handles mixed cell types""" + exporter = ExcelExport(self.env) + result = exporter.from_data( + ["String", "Int", "Float", "Bool"], + [["hello", 42, 3.14, True]], + ) + self.assertIsInstance(result, bytes) + + def test_no_request_dependency(self): + """Check that export works without HTTP request context""" + exporter = ExcelExport(self.env) + result = exporter.from_data(["Col"], [["val"]]) + self.assertIsInstance(result, bytes) + self.assertTrue(len(result) > 0) diff --git a/export_async_schedule/README.rst b/export_async_schedule/README.rst new file mode 100644 index 0000000000..05863558a1 --- /dev/null +++ b/export_async_schedule/README.rst @@ -0,0 +1,177 @@ +============================= +Scheduled Asynchronous Export +============================= + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:170e4045b865c79a1eacbba69044d718814a3965bc3ad044ad2e4236f503153f + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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/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%2Fqueue-lightgray.png?logo=github + :target: https://github.com/OCA/queue/tree/17.0/export_async_schedule + :alt: OCA/queue +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/queue-17-0/queue-17-0-export_async_schedule + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/queue&target_branch=17.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +Schedule automated exports sent by email at regular intervals (hours, +days, weeks, months) to selected users. + +**Export Groups** allow bundling multiple exports into a single email +with multiple attachments - useful for consolidated reporting. + +**Table of contents** + +.. contents:: + :local: + +Configuration +============= + +Creating an Export List +----------------------- + +1. Open any model's list view (e.g., Partners, Sales Orders) +2. Select at least one record +3. Click **Action → Export** +4. Select fields to export +5. Save the field list with a meaningful name + +Configuring a Scheduled Export +------------------------------ + +Navigate to **Settings → Technical → Automation → Scheduled Exports** +and create a new record with: + +- Model and export list (created above) +- Export domain (filter records to export) +- Export format (CSV or Excel) +- Recipients (users who will receive the export) +- Schedule (frequency and next execution date) +- Language (for field labels in the export) + +A cron job runs hourly to execute scheduled exports and groups. + +Usage +===== + +When a scheduled export is configured, its execution is automatic based +on the schedule. + +Users receive an email with a download link for the exported file. +Attachments remain in the database for 7 days by default (configurable +via the ``attachment.ttl`` system parameter). + +Export Groups +------------- + +Group multiple exports into a single email: + +1. Navigate to **Settings > Technical > Automation > Grouped Scheduled + Exports** +2. Create a group specifying: + + - Recipients (users with email addresses) + - Email template + - Exports to include (select from standalone exports or create new + ones) + - Schedule (interval, next execution, language) + +3. Use **Send Test Email Now** to verify configuration + +**Important**: When an export is added to a group, it automatically +inherits the group's scheduling parameters (recipients, interval, +language, etc.). Individual exports within a group cannot be executed +separately - only the group's cron job triggers their execution as a +batch. + +Known issues / Roadmap +====================== + +- We could configure a custom TTL (time-to-live) for each scheduled + export + +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 +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* Camptocamp +* ACSONE SA/NV + +Contributors +------------ + +- Guewen Baconnier (Camptocamp) +- `Komit `__: + + - Cuong Nguyen Mtm + +- Stéphane Mangin (ACSONE SA/NV) + +Other credits +------------- + +The migration of this module from 14.0 to 17.0 was financially supported +by: + +- ACSONE SA/NV (https://www.acsone.eu/) + +The migration of this module from 13.0 to 14.0 was financially supported +by: + +- Scaleway SAS (https://www.scaleway.com/) +- Komit (https://komit-consulting.com) + +Maintainers +----------- + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +.. |maintainer-guewen| image:: https://github.com/guewen.png?size=40px + :target: https://github.com/guewen + :alt: guewen +.. |maintainer-stephanemangin| image:: https://github.com/stephanemangin.png?size=40px + :target: https://github.com/stephanemangin + :alt: stephanemangin + +Current `maintainers `__: + +|maintainer-guewen| |maintainer-stephanemangin| + +This module is part of the `OCA/queue `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/export_async_schedule/__init__.py b/export_async_schedule/__init__.py new file mode 100644 index 0000000000..31660d6a96 --- /dev/null +++ b/export_async_schedule/__init__.py @@ -0,0 +1,3 @@ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from . import models diff --git a/export_async_schedule/__manifest__.py b/export_async_schedule/__manifest__.py new file mode 100644 index 0000000000..2c06431b75 --- /dev/null +++ b/export_async_schedule/__manifest__.py @@ -0,0 +1,28 @@ +# Copyright 2019 Camptocamp +# Copyright 2026 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +{ + "name": "Scheduled Asynchronous Export", + "summary": "Generate and send exports by emails on a schedule", + "version": "17.0.1.1.0", + "author": "Camptocamp, ACSONE SA/NV, Odoo Community Association (OCA)", + "license": "AGPL-3", + "website": "https://github.com/OCA/queue", + "category": "Generic Modules", + "depends": [ + "base_export_async", + "queue_job", + "mail", + ], + "data": [ + "security/ir.model.access.csv", + "data/mail_template.xml", + "data/ir_cron.xml", + "views/export_async_schedule_group_views.xml", + "views/export_async_schedule_views.xml", + ], + "installable": True, + "maintainers": ["guewen", "stephanemangin"], + "development_status": "Beta", +} diff --git a/export_async_schedule/data/ir_cron.xml b/export_async_schedule/data/ir_cron.xml new file mode 100644 index 0000000000..89797b4aa4 --- /dev/null +++ b/export_async_schedule/data/ir_cron.xml @@ -0,0 +1,30 @@ + + + + Send Scheduled Exports + + + + 1 + hours + -1 + + code + model.search([('next_execution', '<=', datetime.datetime.now())]).run_schedule() + + + + Send Grouped Scheduled Exports + + + + 1 + hours + -1 + + code + model._cron_run_scheduled_groups() + + diff --git a/export_async_schedule/data/mail_template.xml b/export_async_schedule/data/mail_template.xml new file mode 100644 index 0000000000..de6d486053 --- /dev/null +++ b/export_async_schedule/data/mail_template.xml @@ -0,0 +1,28 @@ + + + + Export Group - Scheduled Reporting + Scheduled Export - {{ object.display_name }} + + + +

Please find attached the scheduled reports: + + .

+

This email contains the following exports:

+
    + +
  • + +
  • +
    +
+
+

+ This is an automated message, please do not reply. +

+
+
+
diff --git a/export_async_schedule/i18n/export_async_schedule.pot b/export_async_schedule/i18n/export_async_schedule.pot new file mode 100644 index 0000000000..8fc346d9ac --- /dev/null +++ b/export_async_schedule/i18n/export_async_schedule.pot @@ -0,0 +1,200 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * export_async_schedule +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 14.0\n" +"Report-Msgid-Bugs-To: \n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: export_async_schedule +#: model:ir.model.fields,field_description:export_async_schedule.field_export_async_schedule__active +msgid "Active" +msgstr "" + +#. module: export_async_schedule +#: model_terms:ir.ui.view,arch_db:export_async_schedule.view_export_async_schedule_search +msgid "All" +msgstr "" + +#. module: export_async_schedule +#: model:ir.model.fields.selection,name:export_async_schedule.selection__export_async_schedule__export_format__csv +msgid "CSV" +msgstr "" + +#. module: export_async_schedule +#: model:ir.model.fields,field_description:export_async_schedule.field_export_async_schedule__create_uid +msgid "Created by" +msgstr "" + +#. module: export_async_schedule +#: model:ir.model.fields,field_description:export_async_schedule.field_export_async_schedule__create_date +msgid "Created on" +msgstr "" + +#. module: export_async_schedule +#: model:ir.model.fields.selection,name:export_async_schedule.selection__export_async_schedule__interval_unit__days +msgid "Day(s)" +msgstr "" + +#. module: export_async_schedule +#: model:ir.model.fields,field_description:export_async_schedule.field_export_async_schedule__display_name +msgid "Display Name" +msgstr "" + +#. module: export_async_schedule +#: model:ir.model.fields,field_description:export_async_schedule.field_export_async_schedule__end_of_month +msgid "End Of Month" +msgstr "" + +#. module: export_async_schedule +#: model:ir.model.fields.selection,name:export_async_schedule.selection__export_async_schedule__export_format__excel +msgid "Excel" +msgstr "" + +#. module: export_async_schedule +#: model:ir.model,name:export_async_schedule.model_export_async_schedule +msgid "Export Async Schedule" +msgstr "" + +#. module: export_async_schedule +#: model_terms:ir.ui.view,arch_db:export_async_schedule.view_export_async_schedule_form +msgid "Export Configuration" +msgstr "" + +#. module: export_async_schedule +#: model:ir.model.fields,field_description:export_async_schedule.field_export_async_schedule__domain +msgid "Export Domain" +msgstr "" + +#. module: export_async_schedule +#: model:ir.model.fields,field_description:export_async_schedule.field_export_async_schedule__export_format +msgid "Export Format" +msgstr "" + +#. module: export_async_schedule +#: model:ir.model.fields,field_description:export_async_schedule.field_export_async_schedule__ir_export_id +msgid "Export List" +msgstr "" + +#. module: export_async_schedule +#: model:ir.model.fields,help:export_async_schedule.field_export_async_schedule__lang +msgid "Exports will be translated in this language." +msgstr "" + +#. module: export_async_schedule +#: model_terms:ir.ui.view,arch_db:export_async_schedule.view_export_async_schedule_search +msgid "Group By" +msgstr "" + +#. module: export_async_schedule +#: model:ir.model.fields.selection,name:export_async_schedule.selection__export_async_schedule__interval_unit__hours +msgid "Hour(s)" +msgstr "" + +#. module: export_async_schedule +#: model:ir.model.fields,field_description:export_async_schedule.field_export_async_schedule__id +msgid "ID" +msgstr "" + +#. module: export_async_schedule +#: model:ir.model.fields,field_description:export_async_schedule.field_export_async_schedule__import_compat +msgid "Import-compatible Export" +msgstr "" + +#. module: export_async_schedule +#: model:ir.model.fields,field_description:export_async_schedule.field_export_async_schedule__interval +msgid "Interval" +msgstr "" + +#. module: export_async_schedule +#: model:ir.model.fields,field_description:export_async_schedule.field_export_async_schedule__lang +msgid "Language" +msgstr "" + +#. module: export_async_schedule +#: model:ir.model.fields,field_description:export_async_schedule.field_export_async_schedule____last_update +msgid "Last Modified on" +msgstr "" + +#. module: export_async_schedule +#: model:ir.model.fields,field_description:export_async_schedule.field_export_async_schedule__write_uid +msgid "Last Updated by" +msgstr "" + +#. module: export_async_schedule +#: model:ir.model.fields,field_description:export_async_schedule.field_export_async_schedule__write_date +msgid "Last Updated on" +msgstr "" + +#. module: export_async_schedule +#: model:ir.model.fields,field_description:export_async_schedule.field_export_async_schedule__model_id +#: model_terms:ir.ui.view,arch_db:export_async_schedule.view_export_async_schedule_search +msgid "Model" +msgstr "" + +#. module: export_async_schedule +#: model:ir.model.fields,field_description:export_async_schedule.field_export_async_schedule__model_name +msgid "Model Name" +msgstr "" + +#. module: export_async_schedule +#: model:ir.model.fields.selection,name:export_async_schedule.selection__export_async_schedule__interval_unit__months +msgid "Month(s)" +msgstr "" + +#. module: export_async_schedule +#: model:ir.model.fields,field_description:export_async_schedule.field_export_async_schedule__next_execution +#: model_terms:ir.ui.view,arch_db:export_async_schedule.view_export_async_schedule_search +msgid "Next Execution" +msgstr "" + +#. module: export_async_schedule +#: model:ir.model.fields,field_description:export_async_schedule.field_export_async_schedule__user_ids +msgid "Recipients" +msgstr "" + +#. module: export_async_schedule +#: model_terms:ir.actions.act_window,help:export_async_schedule.action_export_async_schedule +msgid "Schedule Exports to send by email" +msgstr "" + +#. module: export_async_schedule +#: model:ir.actions.act_window,name:export_async_schedule.action_export_async_schedule +#: model:ir.ui.menu,name:export_async_schedule.menu_export_async_schedule +#: model_terms:ir.ui.view,arch_db:export_async_schedule.view_export_async_schedule_search +msgid "Scheduled Exports" +msgstr "" + +#. module: export_async_schedule +#: model_terms:ir.ui.view,arch_db:export_async_schedule.view_export_async_schedule_form +msgid "Scheduling" +msgstr "" + +#. module: export_async_schedule +#: model:ir.actions.server,name:export_async_schedule.ir_cron_export_async_schedule_ir_actions_server +#: model:ir.cron,cron_name:export_async_schedule.ir_cron_export_async_schedule +#: model:ir.cron,name:export_async_schedule.ir_cron_export_async_schedule +msgid "Send Scheduled Exports" +msgstr "" + +#. module: export_async_schedule +#: model_terms:ir.ui.view,arch_db:export_async_schedule.view_export_async_schedule_form +msgid "Test Export" +msgstr "" + +#. module: export_async_schedule +#: model:ir.model.fields,field_description:export_async_schedule.field_export_async_schedule__interval_unit +msgid "Unit" +msgstr "" + +#. module: export_async_schedule +#: model:ir.model.fields.selection,name:export_async_schedule.selection__export_async_schedule__interval_unit__weeks +msgid "Week(s)" +msgstr "" diff --git a/export_async_schedule/i18n/fr.po b/export_async_schedule/i18n/fr.po new file mode 100644 index 0000000000..c59dd28c90 --- /dev/null +++ b/export_async_schedule/i18n/fr.po @@ -0,0 +1,203 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * export_async_schedule +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 14.0\n" +"Report-Msgid-Bugs-To: \n" +"PO-Revision-Date: 2021-02-09 17:45+0000\n" +"Last-Translator: Yann Papouin \n" +"Language-Team: none\n" +"Language: fr\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=2; plural=n > 1;\n" +"X-Generator: Weblate 4.3.2\n" + +#. module: export_async_schedule +#: model:ir.model.fields,field_description:export_async_schedule.field_export_async_schedule__active +msgid "Active" +msgstr "Actif" + +#. module: export_async_schedule +#: model_terms:ir.ui.view,arch_db:export_async_schedule.view_export_async_schedule_search +msgid "All" +msgstr "Tout" + +#. module: export_async_schedule +#: model:ir.model.fields.selection,name:export_async_schedule.selection__export_async_schedule__export_format__csv +msgid "CSV" +msgstr "CSV" + +#. module: export_async_schedule +#: model:ir.model.fields,field_description:export_async_schedule.field_export_async_schedule__create_uid +msgid "Created by" +msgstr "Créé par" + +#. module: export_async_schedule +#: model:ir.model.fields,field_description:export_async_schedule.field_export_async_schedule__create_date +msgid "Created on" +msgstr "Créé le" + +#. module: export_async_schedule +#: model:ir.model.fields.selection,name:export_async_schedule.selection__export_async_schedule__interval_unit__days +msgid "Day(s)" +msgstr "Jour(s)" + +#. module: export_async_schedule +#: model:ir.model.fields,field_description:export_async_schedule.field_export_async_schedule__display_name +msgid "Display Name" +msgstr "Nom affiché" + +#. module: export_async_schedule +#: model:ir.model.fields,field_description:export_async_schedule.field_export_async_schedule__end_of_month +msgid "End Of Month" +msgstr "Fin de mois" + +#. module: export_async_schedule +#: model:ir.model.fields.selection,name:export_async_schedule.selection__export_async_schedule__export_format__excel +msgid "Excel" +msgstr "Excel" + +#. module: export_async_schedule +#: model:ir.model,name:export_async_schedule.model_export_async_schedule +msgid "Export Async Schedule" +msgstr "Planification de l'export asynchrone" + +#. module: export_async_schedule +#: model_terms:ir.ui.view,arch_db:export_async_schedule.view_export_async_schedule_form +msgid "Export Configuration" +msgstr "Configuration de l'export" + +#. module: export_async_schedule +#: model:ir.model.fields,field_description:export_async_schedule.field_export_async_schedule__domain +msgid "Export Domain" +msgstr "Domaine de l'export" + +#. module: export_async_schedule +#: model:ir.model.fields,field_description:export_async_schedule.field_export_async_schedule__export_format +msgid "Export Format" +msgstr "Format d'export" + +#. module: export_async_schedule +#: model:ir.model.fields,field_description:export_async_schedule.field_export_async_schedule__ir_export_id +msgid "Export List" +msgstr "Liste d'export" + +#. module: export_async_schedule +#: model:ir.model.fields,help:export_async_schedule.field_export_async_schedule__lang +msgid "Exports will be translated in this language." +msgstr "Les exports seront traduits dans cette langue." + +#. module: export_async_schedule +#: model_terms:ir.ui.view,arch_db:export_async_schedule.view_export_async_schedule_search +msgid "Group By" +msgstr "Regrouper par" + +#. module: export_async_schedule +#: model:ir.model.fields.selection,name:export_async_schedule.selection__export_async_schedule__interval_unit__hours +msgid "Hour(s)" +msgstr "Heure(s)" + +#. module: export_async_schedule +#: model:ir.model.fields,field_description:export_async_schedule.field_export_async_schedule__id +msgid "ID" +msgstr "ID" + +#. module: export_async_schedule +#: model:ir.model.fields,field_description:export_async_schedule.field_export_async_schedule__import_compat +msgid "Import-compatible Export" +msgstr "Export compatible avec l'import" + +#. module: export_async_schedule +#: model:ir.model.fields,field_description:export_async_schedule.field_export_async_schedule__interval +msgid "Interval" +msgstr "Intervalle de temps" + +#. module: export_async_schedule +#: model:ir.model.fields,field_description:export_async_schedule.field_export_async_schedule__lang +msgid "Language" +msgstr "Langage" + +#. module: export_async_schedule +#: model:ir.model.fields,field_description:export_async_schedule.field_export_async_schedule____last_update +msgid "Last Modified on" +msgstr "Dernière modification le" + +#. module: export_async_schedule +#: model:ir.model.fields,field_description:export_async_schedule.field_export_async_schedule__write_uid +msgid "Last Updated by" +msgstr "Dernière mise à jour par" + +#. module: export_async_schedule +#: model:ir.model.fields,field_description:export_async_schedule.field_export_async_schedule__write_date +msgid "Last Updated on" +msgstr "Dernière mise à jour le" + +#. module: export_async_schedule +#: model:ir.model.fields,field_description:export_async_schedule.field_export_async_schedule__model_id +#: model_terms:ir.ui.view,arch_db:export_async_schedule.view_export_async_schedule_search +msgid "Model" +msgstr "Modèle" + +#. module: export_async_schedule +#: model:ir.model.fields,field_description:export_async_schedule.field_export_async_schedule__model_name +msgid "Model Name" +msgstr "Nom du modèle" + +#. module: export_async_schedule +#: model:ir.model.fields.selection,name:export_async_schedule.selection__export_async_schedule__interval_unit__months +msgid "Month(s)" +msgstr "Mois" + +#. module: export_async_schedule +#: model:ir.model.fields,field_description:export_async_schedule.field_export_async_schedule__next_execution +#: model_terms:ir.ui.view,arch_db:export_async_schedule.view_export_async_schedule_search +msgid "Next Execution" +msgstr "Prochaine exécution" + +#. module: export_async_schedule +#: model:ir.model.fields,field_description:export_async_schedule.field_export_async_schedule__user_ids +msgid "Recipients" +msgstr "Destinataires" + +#. module: export_async_schedule +#: model_terms:ir.actions.act_window,help:export_async_schedule.action_export_async_schedule +msgid "Schedule Exports to send by email" +msgstr "Planifier les exports à envoyer par courriel" + +#. module: export_async_schedule +#: model:ir.actions.act_window,name:export_async_schedule.action_export_async_schedule +#: model:ir.ui.menu,name:export_async_schedule.menu_export_async_schedule +#: model_terms:ir.ui.view,arch_db:export_async_schedule.view_export_async_schedule_search +msgid "Scheduled Exports" +msgstr "Exports planifiés" + +#. module: export_async_schedule +#: model_terms:ir.ui.view,arch_db:export_async_schedule.view_export_async_schedule_form +msgid "Scheduling" +msgstr "Ordonnancement" + +#. module: export_async_schedule +#: model:ir.actions.server,name:export_async_schedule.ir_cron_export_async_schedule_ir_actions_server +#: model:ir.cron,cron_name:export_async_schedule.ir_cron_export_async_schedule +#: model:ir.cron,name:export_async_schedule.ir_cron_export_async_schedule +msgid "Send Scheduled Exports" +msgstr "Envoyer des exports planifiés" + +#. module: export_async_schedule +#: model_terms:ir.ui.view,arch_db:export_async_schedule.view_export_async_schedule_form +msgid "Test Export" +msgstr "Export de test" + +#. module: export_async_schedule +#: model:ir.model.fields,field_description:export_async_schedule.field_export_async_schedule__interval_unit +msgid "Unit" +msgstr "Unité" + +#. module: export_async_schedule +#: model:ir.model.fields.selection,name:export_async_schedule.selection__export_async_schedule__interval_unit__weeks +msgid "Week(s)" +msgstr "Semaines(s)" diff --git a/export_async_schedule/i18n/it.po b/export_async_schedule/i18n/it.po new file mode 100644 index 0000000000..71f50eff3e --- /dev/null +++ b/export_async_schedule/i18n/it.po @@ -0,0 +1,201 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * export_async_schedule +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 14.0\n" +"Report-Msgid-Bugs-To: \n" +"Last-Translator: Automatically generated\n" +"Language-Team: none\n" +"Language: it\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" + +#. module: export_async_schedule +#: model:ir.model.fields,field_description:export_async_schedule.field_export_async_schedule__active +msgid "Active" +msgstr "" + +#. module: export_async_schedule +#: model_terms:ir.ui.view,arch_db:export_async_schedule.view_export_async_schedule_search +msgid "All" +msgstr "" + +#. module: export_async_schedule +#: model:ir.model.fields.selection,name:export_async_schedule.selection__export_async_schedule__export_format__csv +msgid "CSV" +msgstr "" + +#. module: export_async_schedule +#: model:ir.model.fields,field_description:export_async_schedule.field_export_async_schedule__create_uid +msgid "Created by" +msgstr "" + +#. module: export_async_schedule +#: model:ir.model.fields,field_description:export_async_schedule.field_export_async_schedule__create_date +msgid "Created on" +msgstr "" + +#. module: export_async_schedule +#: model:ir.model.fields.selection,name:export_async_schedule.selection__export_async_schedule__interval_unit__days +msgid "Day(s)" +msgstr "" + +#. module: export_async_schedule +#: model:ir.model.fields,field_description:export_async_schedule.field_export_async_schedule__display_name +msgid "Display Name" +msgstr "" + +#. module: export_async_schedule +#: model:ir.model.fields,field_description:export_async_schedule.field_export_async_schedule__end_of_month +msgid "End Of Month" +msgstr "" + +#. module: export_async_schedule +#: model:ir.model.fields.selection,name:export_async_schedule.selection__export_async_schedule__export_format__excel +msgid "Excel" +msgstr "" + +#. module: export_async_schedule +#: model:ir.model,name:export_async_schedule.model_export_async_schedule +msgid "Export Async Schedule" +msgstr "" + +#. module: export_async_schedule +#: model_terms:ir.ui.view,arch_db:export_async_schedule.view_export_async_schedule_form +msgid "Export Configuration" +msgstr "" + +#. module: export_async_schedule +#: model:ir.model.fields,field_description:export_async_schedule.field_export_async_schedule__domain +msgid "Export Domain" +msgstr "" + +#. module: export_async_schedule +#: model:ir.model.fields,field_description:export_async_schedule.field_export_async_schedule__export_format +msgid "Export Format" +msgstr "" + +#. module: export_async_schedule +#: model:ir.model.fields,field_description:export_async_schedule.field_export_async_schedule__ir_export_id +msgid "Export List" +msgstr "" + +#. module: export_async_schedule +#: model:ir.model.fields,help:export_async_schedule.field_export_async_schedule__lang +msgid "Exports will be translated in this language." +msgstr "" + +#. module: export_async_schedule +#: model_terms:ir.ui.view,arch_db:export_async_schedule.view_export_async_schedule_search +msgid "Group By" +msgstr "" + +#. module: export_async_schedule +#: model:ir.model.fields.selection,name:export_async_schedule.selection__export_async_schedule__interval_unit__hours +msgid "Hour(s)" +msgstr "" + +#. module: export_async_schedule +#: model:ir.model.fields,field_description:export_async_schedule.field_export_async_schedule__id +msgid "ID" +msgstr "" + +#. module: export_async_schedule +#: model:ir.model.fields,field_description:export_async_schedule.field_export_async_schedule__import_compat +msgid "Import-compatible Export" +msgstr "" + +#. module: export_async_schedule +#: model:ir.model.fields,field_description:export_async_schedule.field_export_async_schedule__interval +msgid "Interval" +msgstr "" + +#. module: export_async_schedule +#: model:ir.model.fields,field_description:export_async_schedule.field_export_async_schedule__lang +msgid "Language" +msgstr "" + +#. module: export_async_schedule +#: model:ir.model.fields,field_description:export_async_schedule.field_export_async_schedule____last_update +msgid "Last Modified on" +msgstr "" + +#. module: export_async_schedule +#: model:ir.model.fields,field_description:export_async_schedule.field_export_async_schedule__write_uid +msgid "Last Updated by" +msgstr "" + +#. module: export_async_schedule +#: model:ir.model.fields,field_description:export_async_schedule.field_export_async_schedule__write_date +msgid "Last Updated on" +msgstr "" + +#. module: export_async_schedule +#: model:ir.model.fields,field_description:export_async_schedule.field_export_async_schedule__model_id +#: model_terms:ir.ui.view,arch_db:export_async_schedule.view_export_async_schedule_search +msgid "Model" +msgstr "" + +#. module: export_async_schedule +#: model:ir.model.fields,field_description:export_async_schedule.field_export_async_schedule__model_name +msgid "Model Name" +msgstr "" + +#. module: export_async_schedule +#: model:ir.model.fields.selection,name:export_async_schedule.selection__export_async_schedule__interval_unit__months +msgid "Month(s)" +msgstr "" + +#. module: export_async_schedule +#: model:ir.model.fields,field_description:export_async_schedule.field_export_async_schedule__next_execution +#: model_terms:ir.ui.view,arch_db:export_async_schedule.view_export_async_schedule_search +msgid "Next Execution" +msgstr "" + +#. module: export_async_schedule +#: model:ir.model.fields,field_description:export_async_schedule.field_export_async_schedule__user_ids +msgid "Recipients" +msgstr "" + +#. module: export_async_schedule +#: model_terms:ir.actions.act_window,help:export_async_schedule.action_export_async_schedule +msgid "Schedule Exports to send by email" +msgstr "" + +#. module: export_async_schedule +#: model:ir.actions.act_window,name:export_async_schedule.action_export_async_schedule +#: model:ir.ui.menu,name:export_async_schedule.menu_export_async_schedule +#: model_terms:ir.ui.view,arch_db:export_async_schedule.view_export_async_schedule_search +msgid "Scheduled Exports" +msgstr "" + +#. module: export_async_schedule +#: model_terms:ir.ui.view,arch_db:export_async_schedule.view_export_async_schedule_form +msgid "Scheduling" +msgstr "" + +#. module: export_async_schedule +#: model:ir.actions.server,name:export_async_schedule.ir_cron_export_async_schedule_ir_actions_server +#: model:ir.cron,cron_name:export_async_schedule.ir_cron_export_async_schedule +#: model:ir.cron,name:export_async_schedule.ir_cron_export_async_schedule +msgid "Send Scheduled Exports" +msgstr "" + +#. module: export_async_schedule +#: model_terms:ir.ui.view,arch_db:export_async_schedule.view_export_async_schedule_form +msgid "Test Export" +msgstr "" + +#. module: export_async_schedule +#: model:ir.model.fields,field_description:export_async_schedule.field_export_async_schedule__interval_unit +msgid "Unit" +msgstr "" + +#. module: export_async_schedule +#: model:ir.model.fields.selection,name:export_async_schedule.selection__export_async_schedule__interval_unit__weeks +msgid "Week(s)" +msgstr "" diff --git a/export_async_schedule/models/__init__.py b/export_async_schedule/models/__init__.py new file mode 100644 index 0000000000..988a5f69e1 --- /dev/null +++ b/export_async_schedule/models/__init__.py @@ -0,0 +1,5 @@ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from . import export_async_schedule_mixin +from . import export_async_schedule +from . import export_async_schedule_group diff --git a/export_async_schedule/models/export_async_schedule.py b/export_async_schedule/models/export_async_schedule.py new file mode 100644 index 0000000000..3cac643961 --- /dev/null +++ b/export_async_schedule/models/export_async_schedule.py @@ -0,0 +1,195 @@ +# Copyright 2019 Camptocamp +# Copyright 2026 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from datetime import datetime + +from odoo import api, fields, models +from odoo.tools.safe_eval import safe_eval + + +class ExportAsyncSchedule(models.Model): + _name = "export.async.schedule" + _inherit = ["export.async.schedule.mixin", "mail.thread", "mail.activity.mixin"] + _description = "Export Async Schedule" + _rec_name = "display_name" + + display_name = fields.Char(compute="_compute_display_name", store=True, copy=False) + + # Override mixin fields to inherit from group when part of one + active = fields.Boolean( + compute="_compute_from_group", + store=True, + readonly=False, + default=True, + ) + user_ids = fields.Many2many( + relation="export_async_schedule_res_users_rel", + compute="_compute_from_group", + store=True, + readonly=False, + tracking=True, + required=True, + ) + next_execution = fields.Datetime( + compute="_compute_from_group", + store=True, + readonly=False, + default=fields.Datetime.now, + required=True, + tracking=True, + copy=False, + ) + interval = fields.Integer( + compute="_compute_from_group", + store=True, + readonly=False, + default=1, + required=True, + tracking=True, + ) + interval_unit = fields.Selection( + compute="_compute_from_group", + store=True, + readonly=False, + selection=[ + ("hours", "Hour(s)"), + ("days", "Day(s)"), + ("weeks", "Week(s)"), + ("months", "Month(s)"), + ], + default="months", + required=True, + tracking=True, + ) + end_of_month = fields.Boolean( + compute="_compute_from_group", store=True, readonly=False, tracking=True + ) + lang = fields.Selection( + compute="_compute_from_group", + store=True, + readonly=False, + default=lambda self: self.env.lang, + tracking=True, + ) + model_id = fields.Many2one( + comodel_name="ir.model", required=True, ondelete="cascade", tracking=True + ) + model_name = fields.Char(related="model_id.model", string="Model Name") + domain = fields.Char(string="Export Domain", default=[], tracking=True) + ir_export_id = fields.Many2one( + comodel_name="ir.exports", + string="Export List", + required=True, + domain="[('resource', '=', model_name)]", + ondelete="restrict", + tracking=True, + ) + export_format = fields.Selection( + selection=[("csv", "CSV"), ("excel", "Excel")], + default="csv", + required=True, + tracking=True, + ) + import_compat = fields.Boolean(string="Import-compatible Export", tracking=True) + group_id = fields.Many2one( + comodel_name="export.async.schedule.group", + help="Group that include this scheduled export.", + tracking=True, + ) + + @api.depends( + "group_id.active", + "group_id.user_ids", + "group_id.next_execution", + "group_id.interval", + "group_id.interval_unit", + "group_id.end_of_month", + "group_id.lang", + ) + def _compute_from_group(self): + for record in self: + if record.group_id: + record.active = record.group_id.active + record.user_ids = record.group_id.user_ids + record.next_execution = record.group_id.next_execution + record.interval = record.group_id.interval + record.interval_unit = record.group_id.interval_unit + record.end_of_month = record.group_id.end_of_month + record.lang = record.group_id.lang + + @api.depends("model_id.name", "ir_export_id.name") + def _compute_display_name(self): + for record in self: + record.display_name = f"{record.model_id.name}: {record.ir_export_id.name}" + + @api.model + def _get_fields_with_labels(self, model_name, export_fields): + self_fields = self.env[model_name]._fields + result = [] + for field_name in export_fields: + if "/" in field_name: + # The ir.exports.line model contains only the name of the + # field, and when we follow relations, the name of the fields + # joined by /. example: 'bank_ids/acc_number' + # Here, we follow the relations to get the labels + parts = field_name.split("/") + model_fields = self_fields + label_parts = [] + for cur_field_name in parts: + cur_field = model_fields[cur_field_name] + label_parts.append(cur_field._description_string(self.env)) + comodel_name = cur_field.comodel_name + if comodel_name: + model_fields = self.env[cur_field.comodel_name]._fields + label = "/".join(label_parts) + else: + label = self_fields[field_name]._description_string(self.env) + result.append({"label": label, "name": field_name}) + return result + + def _prepare_export_params(self): + export_fields = [ + export_field.name for export_field in self.ir_export_id.export_fields + ] + if self.import_compat: + export_fields = [ + {"label": export_field, "name": export_field} + for export_field in export_fields + ] + else: + export_fields = self._get_fields_with_labels( + self.model_name, + list(export_fields), + ) + export_format = self.export_format == "excel" and "xlsx" or self.export_format + return { + "format": export_format, + "model": self.model_name, + "fields": export_fields, + "ids": False, + "domain": safe_eval(self.domain), + "context": self.env.context, + "import_compat": self.import_compat, + "user_ids": self.user_ids.ids, + } + + def run_schedule(self): + """Called by cron to process due schedules (standalone only).""" + for record in self.filtered(lambda r: not r.group_id): + if record.next_execution > datetime.now(): + continue + record._do_export() + record.next_execution = record._compute_next_date() + + def action_export(self): + """Manual export action from UI. Skips grouped schedules.""" + for record in self.filtered(lambda r: not r.group_id): + record._do_export() + + def _do_export(self): + """Execute the export as a background job.""" + self.ensure_one() + record = self.with_context(lang=self.lang) + params = record._prepare_export_params() + self.env["delay.export"].with_delay().export(params) diff --git a/export_async_schedule/models/export_async_schedule_group.py b/export_async_schedule/models/export_async_schedule_group.py new file mode 100644 index 0000000000..198ebfe7ff --- /dev/null +++ b/export_async_schedule/models/export_async_schedule_group.py @@ -0,0 +1,177 @@ +# Copyright 2026 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import base64 +import logging +from datetime import datetime + +from odoo import _, api, fields, models +from odoo.exceptions import UserError, ValidationError + +_logger = logging.getLogger(__name__) + + +class ExportAsyncScheduleGroup(models.Model): + _name = "export.async.schedule.group" + _inherit = ["export.async.schedule.mixin", "mail.thread", "mail.activity.mixin"] + _description = "Export Async Schedule Group" + _rec_name = "display_name" + + name = fields.Char( + required=True, + tracking=True, + ) + + # Override user_ids to define explicit relation table + user_ids = fields.Many2many( + relation="export_async_schedule_group_res_users_rel", + tracking=True, + ) + + company_id = fields.Many2one( + comodel_name="res.company", + string="Company", + default=lambda self: self.env.company, + required=True, + tracking=True, + ) + export_ids = fields.One2many( + comodel_name="export.async.schedule", + inverse_name="group_id", + string="Scheduled Exports", + ) + mail_template_id = fields.Many2one( + comodel_name="mail.template", + string="Email Template", + required=True, + domain="[('model', '=', 'export.async.schedule.group')]", + help="Email template used to send the grouped exports.", + tracking=True, + ) + + user_ids_required = fields.Boolean( + compute="_compute_user_ids_required", + string="User IDs Required", + help="Indicates if user_ids is required based on template configuration.", + ) + + display_name = fields.Char(compute="_compute_display_name", store=True, copy=False) + + @api.depends("mail_template_id.email_to", "mail_template_id.partner_to") + def _compute_user_ids_required(self): + for record in self: + record.user_ids_required = not ( + record.mail_template_id.email_to or record.mail_template_id.partner_to + ) + + @api.depends("name", "company_id.name") + def _compute_display_name(self): + for record in self: + record.display_name = f"{record.company_id.name}: {record.name}" + + @api.constrains("user_ids") + def _check_users_have_email(self): + for record in self: + users_without_email = record.user_ids.filtered(lambda u: not u.email) + if users_without_email: + user_names = ", ".join(users_without_email.mapped("name")) + raise ValidationError( + _("The following users must have an email address: %s", user_names) + ) + + @api.constrains("export_ids") + def _check_has_exports(self): + for record in self: + if not record.export_ids: + raise ValidationError( + _("A group must have at least one scheduled export.") + ) + + def _get_export_file_content(self, export): + export = export.with_context(lang=export.lang) + params = export._prepare_export_params() + # Use a user context to avoid permission errors when accessing system models + # (e.g., res.currency) during export generation + if self.env.user and self.env.user.id: + user = self.env.user + else: + user = self.env.ref("base.user_admin") + return self.env["delay.export"].with_user(user)._get_file_content(params) + + def _get_export_filename(self, export): + export_name = export.ir_export_id.name or export.model_id.name + extension = "xlsx" if export.export_format == "excel" else export.export_format + return f"{export_name}.{extension}" + + @api.model + def _cron_run_scheduled_groups(self): + """Execute scheduled exports for groups whose next_execution is due.""" + groups = self.search([("next_execution", "<=", datetime.now())]) + for group in groups: + group.with_delay( + identity_key=f"export_group_{group.id}" + )._run_scheduled_group() + + def _run_scheduled_group(self): + self.ensure_one() + try: + self.action_export_group() + except Exception: + _logger.exception("Error exporting group %s", self.id) + finally: + self.next_execution = self._compute_next_date() + + def action_export_group(self): + self.ensure_one() + + # Collect emails from group users and template + all_emails = set(self.user_ids.filtered("email").mapped("email")) + if self.mail_template_id.email_to: + template_emails = [ + email.strip() + for email in self.mail_template_id.email_to.split(",") + if email.strip() + ] + all_emails.update(template_emails) + + recipient_emails = ",".join(sorted(all_emails)) + if not recipient_emails: + raise UserError(_("No recipients with valid email addresses configured.")) + + # Create attachments + attachments = self.env["ir.attachment"] + for export in self.export_ids: + content = self._get_export_file_content(export) + filename = self._get_export_filename(export) + attachment = attachments.create( + { + "name": filename, + "datas": base64.b64encode(content), + "type": "binary", + "res_model": self._name, + "res_id": self.id, + } + ) + attachments |= attachment + + # Send email + # Note: send_mail automatically uses template values for email_cc, email_bcc, + # reply_to, etc. Only provide email_to and email_from if needed. + email_values = { + "email_to": recipient_emails, + "attachment_ids": [(6, 0, attachments.ids)], + } + + # Only provide email_from if template doesn't have one configured + if not self.mail_template_id.email_from: + odoo_bot = self.env.ref("base.partner_root") + email_values["email_from"] = odoo_bot.email + + self.mail_template_id.send_mail( + self.id, + email_values=email_values, + ) + + def action_test_export(self): + self.ensure_one() + self.action_export_group() diff --git a/export_async_schedule/models/export_async_schedule_mixin.py b/export_async_schedule/models/export_async_schedule_mixin.py new file mode 100644 index 0000000000..d6b1226bfb --- /dev/null +++ b/export_async_schedule/models/export_async_schedule_mixin.py @@ -0,0 +1,72 @@ +# Copyright 2026 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from datetime import datetime + +from dateutil.relativedelta import relativedelta + +from odoo import api, fields, models + +from odoo.addons.base.models.res_partner import _lang_get + + +class ExportAsyncScheduleMixin(models.AbstractModel): + _name = "export.async.schedule.mixin" + _description = "Export Async Schedule Mixin" + + active = fields.Boolean(default=True) + user_ids = fields.Many2many( + string="Recipients", + comodel_name="res.users", + ) + + next_execution = fields.Datetime( + default=fields.Datetime.now, required=True, tracking=True, copy=False + ) + interval = fields.Integer(default=1, required=True, tracking=True) + interval_unit = fields.Selection( + selection=[ + ("hours", "Hour(s)"), + ("days", "Day(s)"), + ("weeks", "Week(s)"), + ("months", "Month(s)"), + ], + string="Unit", + default="months", + required=True, + tracking=True, + ) + end_of_month = fields.Boolean(tracking=True) + lang = fields.Selection( + _lang_get, + string="Language", + default=lambda self: self.env.lang, + help="Exports will be translated in this language.", + tracking=True, + ) + + def _compute_next_date(self): + self.ensure_one() + next_execution = self.next_execution + if next_execution < datetime.now(): + next_execution = datetime.now() + return next_execution + relativedelta(**self._get_next_date_args()) + + def _get_next_date_args(self): + """Return the arguments for relativedelta. Override to customize.""" + args = {self.interval_unit: self.interval} + if self.interval_unit == "months" and self.end_of_month: + args.update({"day": 31, "hour": 23, "minute": 59, "second": 59}) + return args + + def _get_recipient_emails(self): + """Return comma-separated email addresses of recipients with valid emails.""" + self.ensure_one() + return ",".join(self.user_ids.filtered("email").mapped("email")) + + @api.onchange("end_of_month") + def _onchange_end_of_month(self): + if self.end_of_month: + self.next_execution = self.next_execution + relativedelta( + day=31, hour=23, minute=59, second=59 + ) diff --git a/export_async_schedule/pyproject.toml b/export_async_schedule/pyproject.toml new file mode 100644 index 0000000000..4231d0cccb --- /dev/null +++ b/export_async_schedule/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/export_async_schedule/readme/CONFIGURE.md b/export_async_schedule/readme/CONFIGURE.md new file mode 100644 index 0000000000..f255abc678 --- /dev/null +++ b/export_async_schedule/readme/CONFIGURE.md @@ -0,0 +1,21 @@ +## Creating an Export List + +1. Open any model's list view (e.g., Partners, Sales Orders) +2. Select at least one record +3. Click **Action → Export** +4. Select fields to export +5. Save the field list with a meaningful name + +## Configuring a Scheduled Export + +Navigate to **Settings → Technical → Automation → Scheduled Exports** and create a new +record with: + +- Model and export list (created above) +- Export domain (filter records to export) +- Export format (CSV or Excel) +- Recipients (users who will receive the export) +- Schedule (frequency and next execution date) +- Language (for field labels in the export) + +A cron job runs hourly to execute scheduled exports and groups. diff --git a/export_async_schedule/readme/CONTRIBUTORS.md b/export_async_schedule/readme/CONTRIBUTORS.md new file mode 100644 index 0000000000..010bd7e813 --- /dev/null +++ b/export_async_schedule/readme/CONTRIBUTORS.md @@ -0,0 +1,4 @@ +- Guewen Baconnier (Camptocamp) +- [Komit](https://komit-consulting.com): + - Cuong Nguyen Mtm \ +- Stéphane Mangin (ACSONE SA/NV) diff --git a/export_async_schedule/readme/CREDITS.md b/export_async_schedule/readme/CREDITS.md new file mode 100644 index 0000000000..3baa48e957 --- /dev/null +++ b/export_async_schedule/readme/CREDITS.md @@ -0,0 +1,9 @@ +The migration of this module from 14.0 to 17.0 was financially supported by: + +- ACSONE SA/NV () + + +The migration of this module from 13.0 to 14.0 was financially supported by: + +- Scaleway SAS () +- Komit () diff --git a/export_async_schedule/readme/DESCRIPTION.md b/export_async_schedule/readme/DESCRIPTION.md new file mode 100644 index 0000000000..56f5b32d0a --- /dev/null +++ b/export_async_schedule/readme/DESCRIPTION.md @@ -0,0 +1,5 @@ +Schedule automated exports sent by email at regular intervals (hours, days, weeks, +months) to selected users. + +**Export Groups** allow bundling multiple exports into a single email with multiple +attachments - useful for consolidated reporting. diff --git a/export_async_schedule/readme/ROADMAP.md b/export_async_schedule/readme/ROADMAP.md new file mode 100644 index 0000000000..a59070f728 --- /dev/null +++ b/export_async_schedule/readme/ROADMAP.md @@ -0,0 +1 @@ +- We could configure a custom TTL (time-to-live) for each scheduled export diff --git a/export_async_schedule/readme/USAGE.md b/export_async_schedule/readme/USAGE.md new file mode 100644 index 0000000000..33cb008e6d --- /dev/null +++ b/export_async_schedule/readme/USAGE.md @@ -0,0 +1,23 @@ +When a scheduled export is configured, its execution is automatic based on the +schedule. + +Users receive an email with a download link for the exported file. Attachments remain +in the database for 7 days by default (configurable via the `attachment.ttl` system +parameter). + +## Export Groups + +Group multiple exports into a single email: + +1. Navigate to **Settings > Technical > Automation > Grouped Scheduled Exports** +2. Create a group specifying: + - Recipients (users with email addresses) + - Email template + - Exports to include (select from standalone exports or create new ones) + - Schedule (interval, next execution, language) +3. Use **Send Test Email Now** to verify configuration + +**Important**: When an export is added to a group, it automatically inherits the +group's scheduling parameters (recipients, interval, language, etc.). Individual +exports within a group cannot be executed separately - only the group's cron job +triggers their execution as a batch. diff --git a/export_async_schedule/security/ir.model.access.csv b/export_async_schedule/security/ir.model.access.csv new file mode 100644 index 0000000000..126dc00fe2 --- /dev/null +++ b/export_async_schedule/security/ir.model.access.csv @@ -0,0 +1,3 @@ +"id","name","model_id:id","group_id:id","perm_read","perm_write","perm_create","perm_unlink" +"access_export_async_schedule","export_async_schedule","model_export_async_schedule","base.group_system",1,1,1,1 +"access_export_async_schedule_group","export_async_schedule_group","model_export_async_schedule_group","base.group_system",1,1,1,1 diff --git a/export_async_schedule/static/description/icon.png b/export_async_schedule/static/description/icon.png new file mode 100644 index 0000000000..3a0328b516 Binary files /dev/null and b/export_async_schedule/static/description/icon.png differ diff --git a/export_async_schedule/static/description/index.html b/export_async_schedule/static/description/index.html new file mode 100644 index 0000000000..9ea67169b4 --- /dev/null +++ b/export_async_schedule/static/description/index.html @@ -0,0 +1,523 @@ + + + + + +Scheduled Asynchronous Export + + + +
+

Scheduled Asynchronous Export

+ + +

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

+

Schedule automated exports sent by email at regular intervals (hours, +days, weeks, months) to selected users.

+

Export Groups allow bundling multiple exports into a single email +with multiple attachments - useful for consolidated reporting.

+

Table of contents

+ +
+

Configuration

+
+

Creating an Export List

+
    +
  1. Open any model’s list view (e.g., Partners, Sales Orders)
  2. +
  3. Select at least one record
  4. +
  5. Click Action → Export
  6. +
  7. Select fields to export
  8. +
  9. Save the field list with a meaningful name
  10. +
+
+
+

Configuring a Scheduled Export

+

Navigate to Settings → Technical → Automation → Scheduled Exports +and create a new record with:

+
    +
  • Model and export list (created above)
  • +
  • Export domain (filter records to export)
  • +
  • Export format (CSV or Excel)
  • +
  • Recipients (users who will receive the export)
  • +
  • Schedule (frequency and next execution date)
  • +
  • Language (for field labels in the export)
  • +
+

A cron job runs hourly to execute scheduled exports and groups.

+
+
+
+

Usage

+

When a scheduled export is configured, its execution is automatic based +on the schedule.

+

Users receive an email with a download link for the exported file. +Attachments remain in the database for 7 days by default (configurable +via the attachment.ttl system parameter).

+
+

Export Groups

+

Group multiple exports into a single email:

+
    +
  1. Navigate to Settings > Technical > Automation > Grouped Scheduled +Exports
  2. +
  3. Create a group specifying:
      +
    • Recipients (users with email addresses)
    • +
    • Email template
    • +
    • Exports to include (select from standalone exports or create new +ones)
    • +
    • Schedule (interval, next execution, language)
    • +
    +
  4. +
  5. Use Send Test Email Now to verify configuration
  6. +
+

Important: When an export is added to a group, it automatically +inherits the group’s scheduling parameters (recipients, interval, +language, etc.). Individual exports within a group cannot be executed +separately - only the group’s cron job triggers their execution as a +batch.

+
+
+
+

Known issues / Roadmap

+
    +
  • We could configure a custom TTL (time-to-live) for each scheduled +export
  • +
+
+
+

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 +feedback.

+

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

+
+
+

Credits

+
+

Authors

+
    +
  • Camptocamp
  • +
  • ACSONE SA/NV
  • +
+
+
+

Contributors

+ +
+
+

Other credits

+

The migration of this module from 14.0 to 17.0 was financially supported +by:

+ +

The migration of this module from 13.0 to 14.0 was financially supported +by:

+ +
+
+

Maintainers

+

This module is maintained by the OCA.

+ +Odoo Community Association + +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

Current maintainers:

+

guewen stephanemangin

+

This module is part of the OCA/queue project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+ + diff --git a/export_async_schedule/tests/__init__.py b/export_async_schedule/tests/__init__.py new file mode 100644 index 0000000000..e0941ed7d4 --- /dev/null +++ b/export_async_schedule/tests/__init__.py @@ -0,0 +1,5 @@ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from . import test_export_async_schedule +from . import test_export_async_schedule_group +from . import test_export_async_schedule_group_relation diff --git a/export_async_schedule/tests/common.py b/export_async_schedule/tests/common.py new file mode 100644 index 0000000000..8431f17ec1 --- /dev/null +++ b/export_async_schedule/tests/common.py @@ -0,0 +1,79 @@ +# Copyright 2026 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from datetime import datetime, timedelta + +from odoo.tests.common import TransactionCase + + +class TestExportAsyncScheduleGroupBase(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.env = cls.env(context=dict(cls.env.context, tracking_disable=True)) + + cls.partner_model = cls.env.ref("base.model_res_partner") + + cls.ir_export = cls.env["ir.exports"].create( + { + "name": "Test Partner Export", + "resource": "res.partner", + } + ) + cls.env["ir.exports.line"].create( + { + "export_id": cls.ir_export.id, + "name": "name", + } + ) + cls.env["ir.exports.line"].create( + { + "export_id": cls.ir_export.id, + "name": "email", + } + ) + + cls.user = cls.env.ref("base.user_admin") + + cls.export = cls.env["export.async.schedule"].create( + { + "model_id": cls.partner_model.id, + "ir_export_id": cls.ir_export.id, + "user_ids": [(6, 0, [cls.user.id])], + "domain": "[]", + "export_format": "excel", + "next_execution": datetime.now() + timedelta(days=1), + "interval": 1, + "interval_unit": "days", + } + ) + + cls.mail_template = cls.env.ref( + "export_async_schedule.mail_template_export_group" + ) + + cls.group = cls.env["export.async.schedule.group"].create( + { + "name": "Test Export Group", + "user_ids": [(6, 0, [cls.user.id])], + "mail_template_id": cls.mail_template.id, + "next_execution": datetime.now() - timedelta(hours=1), + "interval": 1, + "interval_unit": "days", + } + ) + cls.export.group_id = cls.group + + def _create_standalone_export(self): + return self.env["export.async.schedule"].create( + { + "model_id": self.partner_model.id, + "ir_export_id": self.ir_export.id, + "user_ids": [(6, 0, [self.user.id])], + "domain": "[]", + "export_format": "excel", + "next_execution": datetime.now() + timedelta(days=1), + "interval": 1, + "interval_unit": "days", + } + ) diff --git a/export_async_schedule/tests/test_export_async_schedule.py b/export_async_schedule/tests/test_export_async_schedule.py new file mode 100644 index 0000000000..f60b85ea96 --- /dev/null +++ b/export_async_schedule/tests/test_export_async_schedule.py @@ -0,0 +1,189 @@ +# Copyright 2019 Camptocamp +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from datetime import datetime + +from dateutil.relativedelta import relativedelta + +from odoo.tests import common + +from odoo.addons.queue_job.tests.common import mock_with_delay + + +class TestExportAsyncSchedule(common.TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.Schedule = cls.env["export.async.schedule"] + cls._create_schedule() + + @classmethod + def _create_schedule(cls): + cls.ir_export = cls.env["ir.exports"].create( + { + "name": "test", + "resource": "res.partner", + "export_fields": [ + (0, 0, {"name": "display_name"}), + (0, 0, {"name": "email"}), + (0, 0, {"name": "phone"}), + (0, 0, {"name": "title/shortcut"}), + ], + } + ) + model = cls.env["ir.model"].search([("model", "=", "res.partner")]) + user = cls.env.ref("base.user_admin") + cls.schedule = cls.Schedule.create( + { + "model_id": model.id, + "user_ids": [(4, user.id)], + "domain": '[("is_company", "=", True)]', + "ir_export_id": cls.ir_export.id, + "export_format": "csv", + "import_compat": True, + "lang": "en_US", + } + ) + + def test_fields_with_labels(self): + """Test export fields are converted to display labels.""" + export_fields = [ + "display_name", + "email", + "phone", + "title/shortcut", + "parent_id/company_id/name", + ] + result = self.env["export.async.schedule"]._get_fields_with_labels( + "res.partner", export_fields + ) + expected = [ + {"label": "Display Name", "name": "display_name"}, + {"label": "Email", "name": "email"}, + {"label": "Phone", "name": "phone"}, + {"label": "Title/Abbreviation", "name": "title/shortcut"}, + { + "label": "Related Company/Company/Company Name", + "name": "parent_id/company_id/name", + }, + ] + self.assertEqual(result, expected) + + def test_prepare_export_params_compatible(self): + """Test export params with import_compat mode.""" + prepared = self.schedule._prepare_export_params() + expected = { + "context": {}, + "domain": [("is_company", "=", True)], + "fields": [ + {"label": "display_name", "name": "display_name"}, + {"label": "email", "name": "email"}, + {"label": "phone", "name": "phone"}, + {"label": "title/shortcut", "name": "title/shortcut"}, + ], + "format": "csv", + "ids": False, + "import_compat": True, + "model": "res.partner", + "user_ids": [self.env.ref("base.user_admin").id], + } + self.assertDictEqual(prepared, expected) + + def test_prepare_export_params_friendly(self): + """Test export params with friendly labels.""" + self.schedule.import_compat = False + prepared = self.schedule._prepare_export_params() + expected = { + "context": {}, + "domain": [("is_company", "=", True)], + "fields": [ + {"label": "Display Name", "name": "display_name"}, + {"label": "Email", "name": "email"}, + {"label": "Phone", "name": "phone"}, + {"label": "Title/Abbreviation", "name": "title/shortcut"}, + ], + "format": "csv", + "ids": False, + "import_compat": False, + "model": "res.partner", + "user_ids": [self.env.ref("base.user_admin").id], + } + self.assertDictEqual(prepared, expected) + + def test_schedule_next_date(self): + """Test next execution date computation for various intervals.""" + start_date = datetime.now() + relativedelta(hours=1) + + def assert_next_schedule(interval, unit, expected): + self.schedule.next_execution = start_date + self.schedule.interval = interval + self.schedule.interval_unit = unit + self.assertEqual(self.schedule._compute_next_date(), expected) + + assert_next_schedule(1, "hours", start_date + relativedelta(hours=1)) + assert_next_schedule(2, "hours", start_date + relativedelta(hours=2)) + assert_next_schedule(1, "days", start_date + relativedelta(days=1)) + assert_next_schedule(2, "days", start_date + relativedelta(days=2)) + assert_next_schedule(1, "weeks", start_date + relativedelta(weeks=1)) + assert_next_schedule(2, "weeks", start_date + relativedelta(weeks=2)) + assert_next_schedule(1, "months", start_date + relativedelta(months=1)) + assert_next_schedule(2, "months", start_date + relativedelta(months=2)) + + self.schedule.end_of_month = True + assert_next_schedule( + 1, + "months", + start_date + relativedelta(months=1, day=31, hour=23, minute=59, second=59), + ) + assert_next_schedule( + 2, + "months", + start_date + relativedelta(months=2, day=31, hour=23, minute=59, second=59), + ) + + def test_run_schedule(self): + """Test schedule execution only happens when next_execution is past.""" + in_future = datetime.now() + relativedelta(minutes=1) + self.schedule.next_execution = in_future + self.schedule.run_schedule() + self.assertEqual(self.schedule.next_execution, in_future) + + in_past = datetime.now() - relativedelta(minutes=1) + self.schedule.next_execution = in_past + self.schedule.run_schedule() + self.assertGreater(self.schedule.next_execution, in_past) + + def test_delay_job(self): + """Test export job is enqueued with correct parameters.""" + with mock_with_delay() as (delayable_cls, delayable): + self.schedule.action_export() + + self.assertEqual(delayable_cls.call_count, 1) + delay_args, __ = delayable_cls.call_args + self.assertEqual((self.env["delay.export"],), delay_args) + + self.assertEqual(delayable.export.call_count, 1) + delay_args, delay_kwargs = delayable.export.call_args + expected_params = ( + { + "context": {"lang": "en_US"}, + "domain": [("is_company", "=", True)], + "fields": [ + {"label": "display_name", "name": "display_name"}, + {"label": "email", "name": "email"}, + {"label": "phone", "name": "phone"}, + {"label": "title/shortcut", "name": "title/shortcut"}, + ], + "format": "csv", + "ids": False, + "import_compat": True, + "model": "res.partner", + "user_ids": [2], + }, + ) + + self.assertEqual(delay_args, expected_params) + + def test_compute_display_name(self): + """Test export display name format.""" + self.assertEqual(self.schedule.display_name, "Contact: test") diff --git a/export_async_schedule/tests/test_export_async_schedule_group.py b/export_async_schedule/tests/test_export_async_schedule_group.py new file mode 100644 index 0000000000..87117839e7 --- /dev/null +++ b/export_async_schedule/tests/test_export_async_schedule_group.py @@ -0,0 +1,167 @@ +# Copyright 2026 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from datetime import datetime, timedelta +from unittest.mock import patch + +from odoo.exceptions import ValidationError + +from odoo.addons.queue_job.tests.common import trap_jobs + +from .common import TestExportAsyncScheduleGroupBase + + +class TestExportAsyncScheduleGroup(TestExportAsyncScheduleGroupBase): + def test_compute_next_date(self): + """Test computation of next execution date.""" + next_date = self.group._compute_next_date() + self.assertGreater(next_date, datetime.now()) + + def test_get_export_filename(self): + """Test export filename generation with format extension.""" + self.export.export_format = "excel" + filename = self.group._get_export_filename(self.export) + self.assertEqual(filename, "Test Partner Export.xlsx") + + def test_action_export_group(self): + """Test export group action creates attachments and sends mail.""" + with patch.object( + type(self.group), + "_get_export_file_content", + return_value=b"test content", + ): + with patch.object( + type(self.env["mail.template"]), + "send_mail", + return_value=True, + ) as mock_send: + self.group.action_export_group() + mock_send.assert_called_once() + call_args = mock_send.call_args + self.assertIn("email_values", call_args[1]) + email_values = call_args[1]["email_values"] + self.assertIn("attachment_ids", email_values) + self.assertTrue(email_values["attachment_ids"]) + + def test_cron_run_scheduled_groups(self): + """Test cron job enqueues scheduled groups.""" + self.group.next_execution = datetime.now() - timedelta(hours=1) + old_next_execution = self.group.next_execution + with trap_jobs() as trap: + self.env["export.async.schedule.group"]._cron_run_scheduled_groups() + trap.assert_jobs_count(1) + trap.assert_enqueued_job( + self.group._run_scheduled_group, + ) + self.assertEqual(self.group.next_execution, old_next_execution) + + def test_check_users_have_email(self): + """Test validation error when a user without an email is added.""" + user_no_email = self.env["res.users"].create( + { + "name": "Test User No Email", + "login": "test_no_email", + "email": False, + } + ) + with self.assertRaises(ValidationError): + self.group.user_ids = [(4, user_no_email.id)] + + def test_check_has_exports(self): + """Test validation error when group has no exports.""" + with self.assertRaises(ValidationError): + self.group.export_ids = False + + def test_action_test_export(self): + """Test send test export calls send_mail.""" + with patch.object( + type(self.group), + "_get_export_file_content", + return_value=b"test content", + ): + with patch.object( + type(self.env["mail.template"]), + "send_mail", + return_value=True, + ) as mock_send: + self.group.action_test_export() + mock_send.assert_called_once() + + def test_compute_display_name(self): + """Test display name includes group name and company.""" + self.assertIn("Test Export Group", self.group.display_name) + self.assertIn(self.group.company_id.name, self.group.display_name) + + def test_user_ids_required_when_template_has_no_recipients(self): + """Test user_ids is required when template has no email_to or partner_to.""" + # Template without recipients + template_no_recipients = self.env["mail.template"].create( + { + "name": "Template No Recipients", + "model_id": self.env.ref( + "export_async_schedule.model_export_async_schedule_group" + ).id, + } + ) + self.group.mail_template_id = template_no_recipients + self.assertTrue(self.group.user_ids_required) + + def test_user_ids_not_required_when_template_has_email_to(self): + """Test user_ids is not required when template has email_to.""" + template_with_email = self.env["mail.template"].create( + { + "name": "Template With Email", + "model_id": self.env.ref( + "export_async_schedule.model_export_async_schedule_group" + ).id, + "email_to": "test@example.com", + } + ) + self.group.mail_template_id = template_with_email + self.assertFalse(self.group.user_ids_required) + + def test_user_ids_not_required_when_template_has_partner_to(self): + """Test user_ids is not required when template has partner_to.""" + partner = self.env["res.partner"].create( + {"name": "Test Partner", "email": "partner@example.com"} + ) + template_with_partner = self.env["mail.template"].create( + { + "name": "Template With Partner", + "model_id": self.env.ref( + "export_async_schedule.model_export_async_schedule_group" + ).id, + "partner_to": str(partner.id), + } + ) + self.group.mail_template_id = template_with_partner + self.assertFalse(self.group.user_ids_required) + + def test_get_export_filename_csv(self): + """Test CSV export filename generation.""" + self.export.export_format = "csv" + filename = self.group._get_export_filename(self.export) + self.assertEqual(filename, "Test Partner Export.csv") + + def test_get_export_filename_excel(self): + """Test Excel export filename generation.""" + self.export.export_format = "excel" + filename = self.group._get_export_filename(self.export) + self.assertEqual(filename, "Test Partner Export.xlsx") + + def test_export_file_content_with_user_context(self): + """ + Test that export file content generation uses proper user context. + + Note: This test mocks _get_export_file_content because the actual + implementation requires base_export_async which isn't available + in test context without HTTP request. + """ + # Mock the file content generation to avoid request context issues + with patch.object( + type(self.group), + "_get_export_file_content", + return_value=b"test content", + ): + content = self.group._get_export_file_content(self.export) + self.assertTrue(content) diff --git a/export_async_schedule/tests/test_export_async_schedule_group_relation.py b/export_async_schedule/tests/test_export_async_schedule_group_relation.py new file mode 100644 index 0000000000..2d1aa6a5fd --- /dev/null +++ b/export_async_schedule/tests/test_export_async_schedule_group_relation.py @@ -0,0 +1,82 @@ +# Copyright 2026 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from datetime import datetime, timedelta + +from odoo.addons.queue_job.tests.common import trap_jobs + +from .common import TestExportAsyncScheduleGroupBase + + +class TestExportAsyncScheduleGroupRelation(TestExportAsyncScheduleGroupBase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.env["res.lang"]._activate_lang("fr_FR") + + @classmethod + def tearDownClass(cls): + cls.env["res.lang"].search([("code", "=", "fr_FR")]).active = False + super().tearDownClass() + + def test_export_group_id(self): + """Test export is linked to correct group.""" + self.assertEqual(self.export.group_id, self.group) + + def test_export_not_part_of_group(self): + """Test standalone export has no group.""" + export_alone = self._create_standalone_export() + self.assertFalse(export_alone.group_id) + + def test_export_individual_export_allowed_when_not_in_group(self): + """Test standalone export can be executed individually.""" + export_alone = self._create_standalone_export() + with trap_jobs() as trap: + export_alone.action_export() + trap.assert_jobs_count(1) + + def test_export_run_schedule_skips_grouped(self): + """Test grouped export is not run individually.""" + self.export.next_execution = datetime.now() - timedelta(hours=1) + with trap_jobs() as trap: + self.export.run_schedule() + trap.assert_jobs_count(0) + + def test_computed_fields_from_group(self): + """Test export computed fields are updated when group changes.""" + new_execution = datetime.now() + timedelta(days=2) + self.group.write( + { + "active": False, + "next_execution": new_execution, + "interval": 5, + "interval_unit": "days", + "end_of_month": True, + "lang": "fr_FR", + } + ) + self.assertFalse(self.export.active) + self.assertEqual(self.export.next_execution, new_execution) + self.assertEqual(self.export.interval, 5) + self.assertEqual(self.export.interval_unit, "days") + self.assertTrue(self.export.end_of_month) + self.assertEqual(self.export.lang, "fr_FR") + + def test_adding_export_to_group_computes_values(self): + """Test export inherits group values when added to group.""" + export_alone = self._create_standalone_export() + export_alone.write( + { + "active": True, + "interval": 7, + "interval_unit": "days", + } + ) + export_alone.group_id = self.group + self.assertEqual(export_alone.active, self.group.active) + self.assertEqual(export_alone.user_ids, self.group.user_ids) + self.assertEqual(export_alone.next_execution, self.group.next_execution) + self.assertEqual(export_alone.interval, self.group.interval) + self.assertEqual(export_alone.interval_unit, self.group.interval_unit) + self.assertEqual(export_alone.end_of_month, self.group.end_of_month) + self.assertEqual(export_alone.lang, self.group.lang) diff --git a/export_async_schedule/views/export_async_schedule_group_views.xml b/export_async_schedule/views/export_async_schedule_group_views.xml new file mode 100644 index 0000000000..1bbe3627a5 --- /dev/null +++ b/export_async_schedule/views/export_async_schedule_group_views.xml @@ -0,0 +1,208 @@ + + + + + + export.async.schedule.tree.simplified + export.async.schedule + + + + + + + + + + + + + + export.async.schedule.form.simplified + export.async.schedule + +
+ + + + + + + + + + +
+
+
+ + + + export.async.schedule.group.tree + export.async.schedule.group + + + + + + + + + + + + + + + + + export.async.schedule.group.form + export.async.schedule.group + +
+
+
+ +
+ +
+
+

+ +

+
+ + + + + + + + + + + + + + + + +
+
+ + + +
+
+
+
+ + + export.async.schedule.group.search + export.async.schedule.group + + + + + + + + + + + + + + + + + + Grouped Exports + ir.actions.act_window + export.async.schedule.group + tree,form + {'search_default_active': 1} + +

+ Create your first export group +

+

+ Group multiple scheduled exports together to send them in a single email. +
+ Perfect for consolidating reports that need to be sent together. +

+
+
+ + +
diff --git a/export_async_schedule/views/export_async_schedule_views.xml b/export_async_schedule/views/export_async_schedule_views.xml new file mode 100644 index 0000000000..442dba55ef --- /dev/null +++ b/export_async_schedule/views/export_async_schedule_views.xml @@ -0,0 +1,186 @@ + + + + export.async.schedule.tree + export.async.schedule + + + + + + + + + + + + + + + + + + + export.async.schedule.form + export.async.schedule + +
+
+
+ +
+ +
+
+

+ +

+
+ Part of: + +
+
+ + + + + + + + + + + + + + + + + + +
+
+ + + +
+ +
+
+ + + export.async.schedule.search + export.async.schedule + + + + + + + + + + + + + + + + + + + + + + + Scheduled Exports + ir.actions.act_window + export.async.schedule + tree,form + {'search_default_active': 1} + +

+ Create your first scheduled export +

+

+ Schedule exports to be automatically sent by email on a regular basis. +
+ You can configure the frequency, recipients, and export format. +

+
+
+ + +
diff --git a/queue_job/README.rst b/queue_job/README.rst index 37e9529ff2..f05207e79c 100644 --- a/queue_job/README.rst +++ b/queue_job/README.rst @@ -11,7 +11,7 @@ Job Queue !! This file is generated by oca-gen-addon-readme !! !! changes will be overwritten. !! !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! - !! source digest: sha256:5c7d6692e11a4d5d03e2fd23cfcf6068d23e685be5e1bc7f760de482512c3083 + !! source digest: sha256:177da9c735cd42cf48e1a72d0f8f5a19680f8408cc7a8919c69e2ca7e7f350fe !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! .. |badge1| image:: https://img.shields.io/badge/maturity-Mature-brightgreen.png diff --git a/queue_job/__manifest__.py b/queue_job/__manifest__.py index c61f234e8c..9d215d5069 100644 --- a/queue_job/__manifest__.py +++ b/queue_job/__manifest__.py @@ -2,7 +2,7 @@ { "name": "Job Queue", - "version": "17.0.1.4.3", + "version": "17.0.1.5.2", "author": "Camptocamp,ACSONE SA/NV,Odoo Community Association (OCA)", "website": "https://github.com/OCA/queue", "license": "LGPL-3", diff --git a/queue_job/controllers/main.py b/queue_job/controllers/main.py index f9d54fed0f..5bebf823ca 100644 --- a/queue_job/controllers/main.py +++ b/queue_job/controllers/main.py @@ -74,7 +74,13 @@ def _enqueue_dependent_jobs(self, env, job): else: break - @http.route("/queue_job/runjob", type="http", auth="none", save_session=False) + @http.route( + "/queue_job/runjob", + type="http", + auth="none", + save_session=False, + readonly=False, + ) def runjob(self, db, job_uuid, **kw): http.request.session.db = db env = http.request.env(user=SUPERUSER_ID) diff --git a/queue_job/i18n/de.po b/queue_job/i18n/de.po index f575a55297..500a6574b3 100644 --- a/queue_job/i18n/de.po +++ b/queue_job/i18n/de.po @@ -661,6 +661,7 @@ msgstr "Ausstehend" #. module: queue_job #: model:ir.model.fields,field_description:queue_job.field_queue_job__priority +#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_search msgid "Priority" msgstr "Priorität" @@ -901,6 +902,11 @@ msgstr "" msgid "Time required to execute this job in seconds. Average when grouped." msgstr "" +#. module: queue_job +#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_search +msgid "Tried many times" +msgstr "" + #. module: queue_job #: model:ir.model.fields,help:queue_job.field_queue_job__activity_exception_decoration msgid "Type of the exception activity on record." diff --git a/queue_job/i18n/es.po b/queue_job/i18n/es.po index 5d599f54c9..d92889f6c1 100644 --- a/queue_job/i18n/es.po +++ b/queue_job/i18n/es.po @@ -669,6 +669,7 @@ msgstr "Pendiente" #. module: queue_job #: model:ir.model.fields,field_description:queue_job.field_queue_job__priority +#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_search msgid "Priority" msgstr "Prioridad" @@ -913,6 +914,11 @@ msgstr "" "Tiempo requerido para ejecutar este trabajo en segundos. Promedio cuando se " "agrupa." +#. module: queue_job +#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_search +msgid "Tried many times" +msgstr "" + #. module: queue_job #: model:ir.model.fields,help:queue_job.field_queue_job__activity_exception_decoration msgid "Type of the exception activity on record." diff --git a/queue_job/i18n/it.po b/queue_job/i18n/it.po index 9697a44886..79de0d25de 100644 --- a/queue_job/i18n/it.po +++ b/queue_job/i18n/it.po @@ -6,7 +6,7 @@ msgid "" msgstr "" "Project-Id-Version: Odoo Server 17.0\n" "Report-Msgid-Bugs-To: \n" -"PO-Revision-Date: 2025-06-09 10:26+0000\n" +"PO-Revision-Date: 2026-01-05 20:42+0000\n" "Last-Translator: mymage \n" "Language-Team: none\n" "Language: it\n" @@ -668,6 +668,7 @@ msgstr "In attesa" #. module: queue_job #: model:ir.model.fields,field_description:queue_job.field_queue_job__priority +#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_search msgid "Priority" msgstr "Priorità" @@ -910,6 +911,11 @@ msgid "Time required to execute this job in seconds. Average when grouped." msgstr "" "Tempo in secondi richiesto per eseguire il lavoro. Medio quando raggruppati." +#. module: queue_job +#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_search +msgid "Tried many times" +msgstr "Provato molte volte" + #. module: queue_job #: model:ir.model.fields,help:queue_job.field_queue_job__activity_exception_decoration msgid "Type of the exception activity on record." diff --git a/queue_job/i18n/queue_job.pot b/queue_job/i18n/queue_job.pot index fc8e2bbbdb..682dc3d201 100644 --- a/queue_job/i18n/queue_job.pot +++ b/queue_job/i18n/queue_job.pot @@ -651,6 +651,7 @@ msgstr "" #. module: queue_job #: model:ir.model.fields,field_description:queue_job.field_queue_job__priority +#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_search msgid "Priority" msgstr "" @@ -877,6 +878,11 @@ msgstr "" msgid "Time required to execute this job in seconds. Average when grouped." msgstr "" +#. module: queue_job +#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_search +msgid "Tried many times" +msgstr "" + #. module: queue_job #: model:ir.model.fields,help:queue_job.field_queue_job__activity_exception_decoration msgid "Type of the exception activity on record." diff --git a/queue_job/i18n/zh_CN.po b/queue_job/i18n/zh_CN.po index 804ca86780..af9d046e6a 100644 --- a/queue_job/i18n/zh_CN.po +++ b/queue_job/i18n/zh_CN.po @@ -666,6 +666,7 @@ msgstr "等待" #. module: queue_job #: model:ir.model.fields,field_description:queue_job.field_queue_job__priority +#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_search msgid "Priority" msgstr "优先级" @@ -904,6 +905,11 @@ msgstr "时间(秒)" msgid "Time required to execute this job in seconds. Average when grouped." msgstr "以秒为单位执行此任务所需的时间。分组时为平均值。" +#. module: queue_job +#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_search +msgid "Tried many times" +msgstr "" + #. module: queue_job #: model:ir.model.fields,help:queue_job.field_queue_job__activity_exception_decoration msgid "Type of the exception activity on record." diff --git a/queue_job/job.py b/queue_job/job.py index a473be5cd0..594f1948ab 100644 --- a/queue_job/job.py +++ b/queue_job/job.py @@ -9,7 +9,6 @@ import uuid import weakref from datetime import datetime, timedelta -from functools import total_ordering from random import randint import odoo @@ -104,7 +103,6 @@ def identity_exact_hasher(job_): return hasher -@total_ordering class Job: """A Job is a task to execute. It is the in-memory representation of a job. @@ -367,65 +365,6 @@ def job_record_with_same_identity_key(self): ) return existing - # TODO to deprecate (not called anymore) - @classmethod - def enqueue( - cls, - func, - args=None, - kwargs=None, - priority=None, - eta=None, - max_retries=None, - description=None, - channel=None, - identity_key=None, - ): - """Create a Job and enqueue it in the queue. Return the job uuid. - - This expects the arguments specific to the job to be already extracted - from the ones to pass to the job function. - - If the identity key is the same than the one in a pending job, - no job is created and the existing job is returned - - """ - new_job = cls( - func=func, - args=args, - kwargs=kwargs, - priority=priority, - eta=eta, - max_retries=max_retries, - description=description, - channel=channel, - identity_key=identity_key, - ) - return new_job._enqueue_job() - - # TODO to deprecate (not called anymore) - def _enqueue_job(self): - if self.identity_key: - existing = self.job_record_with_same_identity_key() - if existing: - _logger.debug( - "a job has not been enqueued due to having " - "the same identity key (%s) than job %s", - self.identity_key, - existing.uuid, - ) - return Job._load_from_db_record(existing) - self.store() - _logger.debug( - "enqueued %s:%s(*%r, **%r) with uuid: %s", - self.recordset, - self.method_name, - self.args, - self.kwargs, - self.uuid, - ) - return self - @staticmethod def db_record_from_uuid(env, job_uuid): # TODO remove in 15.0 or 16.0 @@ -749,16 +688,6 @@ def __eq__(self, other): def __hash__(self): return self.uuid.__hash__() - def sorting_key(self): - return self.eta, self.priority, self.date_created, self.seq - - def __lt__(self, other): - if self.eta and not other.eta: - return True - elif not self.eta and other.eta: - return False - return self.sorting_key() < other.sorting_key() - def db_record(self): return self.db_records_from_uuids(self.env, [self.uuid]) diff --git a/queue_job/jobrunner/channels.py b/queue_job/jobrunner/channels.py index 6e33a73189..cb0a23f01f 100644 --- a/queue_job/jobrunner/channels.py +++ b/queue_job/jobrunner/channels.py @@ -2,6 +2,7 @@ # Copyright 2015-2016 Camptocamp SA # License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html) import logging +from collections import namedtuple from functools import total_ordering from heapq import heappop, heappush from weakref import WeakValueDictionary @@ -10,6 +11,7 @@ from ..job import CANCELLED, DONE, ENQUEUED, FAILED, PENDING, STARTED, WAIT_DEPENDENCIES NOT_DONE = (WAIT_DEPENDENCIES, PENDING, ENQUEUED, STARTED, FAILED) +JobSortingKey = namedtuple("SortingKey", "eta priority date_created seq") _logger = logging.getLogger(__name__) @@ -108,7 +110,7 @@ class ChannelJob: job that are necessary to prioritise them. Channel jobs are comparable according to the following rules: - * jobs with an eta come before all other jobs + * jobs with an eta cannot be compared with jobs without * then jobs with a smaller eta come first * then jobs with a smaller priority come first * then jobs with a smaller creation time come first @@ -135,14 +137,18 @@ class ChannelJob: >>> j3 < j1 True - j4 and j5 comes even before j3, because they have an eta + j4 and j5 have an eta, they cannot be compared with j3 >>> j4 = ChannelJob(None, None, 4, ... seq=0, date_created=4, priority=9, eta=9) >>> j5 = ChannelJob(None, None, 5, ... seq=0, date_created=5, priority=9, eta=9) - >>> j4 < j5 < j3 + >>> j4 < j5 True + >>> j4 < j3 + Traceback (most recent call last): + ... + TypeError: '<' not supported between instances of 'int' and 'NoneType' j6 has same date_created and priority as j5 but a smaller eta @@ -153,7 +159,7 @@ class ChannelJob: Here is the complete suite: - >>> j6 < j4 < j5 < j3 < j1 < j2 + >>> j6 < j4 < j5 and j3 < j1 < j2 True j0 has the same properties as j1 but they are not considered @@ -173,14 +179,13 @@ class ChannelJob: """ + __slots__ = ("db_name", "channel", "uuid", "_sorting_key", "__weakref__") + def __init__(self, db_name, channel, uuid, seq, date_created, priority, eta): self.db_name = db_name self.channel = channel self.uuid = uuid - self.seq = seq - self.date_created = date_created - self.priority = priority - self.eta = eta + self._sorting_key = JobSortingKey(eta, priority, date_created, seq) def __repr__(self): return "" % self.uuid @@ -191,18 +196,36 @@ def __eq__(self, other): def __hash__(self): return id(self) + def set_no_eta(self): + self._sorting_key = JobSortingKey(None, *self._sorting_key[1:]) + + @property + def seq(self): + return self._sorting_key.seq + + @property + def date_created(self): + return self._sorting_key.date_created + + @property + def priority(self): + return self._sorting_key.priority + + @property + def eta(self): + return self._sorting_key.eta + def sorting_key(self): - return self.eta, self.priority, self.date_created, self.seq + # DEPRECATED + return self._sorting_key def sorting_key_ignoring_eta(self): - return self.priority, self.date_created, self.seq + return self._sorting_key[1:] def __lt__(self, other): - if self.eta and not other.eta: - return True - elif not self.eta and other.eta: - return False - return self.sorting_key() < other.sorting_key() + # Do not compare job where ETA is set with job where it is not + # If one job 'eta' is set, and the other is None, it raises TypeError + return self._sorting_key < other._sorting_key class ChannelQueue: @@ -312,7 +335,7 @@ def remove(self, job): def pop(self, now): while self._eta_queue and self._eta_queue[0].eta <= now: eta_job = self._eta_queue.pop() - eta_job.eta = None + eta_job.set_no_eta() self._queue.add(eta_job) if self.sequential and self._eta_queue and self._queue: eta_job = self._eta_queue[0] diff --git a/queue_job/jobrunner/runner.py b/queue_job/jobrunner/runner.py index a0db6751db..18a46222a7 100644 --- a/queue_job/jobrunner/runner.py +++ b/queue_job/jobrunner/runner.py @@ -365,23 +365,26 @@ def _query_requeue_dead_jobs(self): ELSE exc_info END) WHERE - id in ( - SELECT - queue_job_id - FROM - queue_job_lock - WHERE - queue_job_id in ( - SELECT - id - FROM - queue_job - WHERE - state IN ('enqueued','started') - AND date_enqueued < - (now() AT TIME ZONE 'utc' - INTERVAL '10 sec') - ) - FOR UPDATE SKIP LOCKED + state IN ('enqueued','started') + AND date_enqueued < (now() AT TIME ZONE 'utc' - INTERVAL '10 sec') + AND ( + id in ( + SELECT + queue_job_id + FROM + queue_job_lock + WHERE + queue_job_lock.queue_job_id = queue_job.id + FOR UPDATE SKIP LOCKED + ) + OR NOT EXISTS ( + SELECT + 1 + FROM + queue_job_lock + WHERE + queue_job_lock.queue_job_id = queue_job.id + ) ) RETURNING uuid """ @@ -404,6 +407,12 @@ def requeue_dead_jobs(self): However, when the Odoo server crashes or is otherwise force-stopped, running jobs are interrupted while the runner has no chance to know they have been aborted. + + This also handles orphaned jobs (enqueued but never started, no lock). + This edge case occurs when the runner marks a job as 'enqueued' + but the HTTP request to start the job never reaches the Odoo server + (e.g., due to server shutdown/crash between setting enqueued and + the controller receiving the request). """ with closing(self.conn.cursor()) as cr: @@ -438,6 +447,17 @@ def __init__( self._stop = False self._stop_pipe = os.pipe() + def __del__(self): + # pylint: disable=except-pass + try: + os.close(self._stop_pipe[0]) + except OSError: + pass + try: + os.close(self._stop_pipe[1]) + except OSError: + pass + @classmethod def from_environ_or_config(cls): scheme = os.environ.get("ODOO_QUEUE_JOB_SCHEME") or queue_job_config.get( diff --git a/queue_job/models/queue_job.py b/queue_job/models/queue_job.py index 55ee7e526c..a3fafff0ae 100644 --- a/queue_job/models/queue_job.py +++ b/queue_job/models/queue_job.py @@ -6,7 +6,7 @@ from datetime import datetime, timedelta from odoo import _, api, exceptions, fields, models -from odoo.tools import config, html_escape +from odoo.tools import config, html_escape, index_exists from odoo.addons.base_sparse_field.models.fields import Serialized @@ -91,7 +91,7 @@ class QueueJob(models.Model): func_string = fields.Char(string="Task", readonly=True) state = fields.Selection(STATES, readonly=True, required=True, index=True) - priority = fields.Integer() + priority = fields.Integer(group_operator=False) exc_name = fields.Char(string="Exception", readonly=True) exc_message = fields.Char(string="Exception Message", readonly=True, tracking=True) exc_info = fields.Text(string="Exception Info", readonly=True) @@ -130,16 +130,21 @@ class QueueJob(models.Model): worker_pid = fields.Integer(readonly=True) def init(self): - self._cr.execute( - "SELECT indexname FROM pg_indexes WHERE indexname = %s ", - ("queue_job_identity_key_state_partial_index",), - ) - if not self._cr.fetchone(): + index_1 = "queue_job_identity_key_state_partial_index" + index_2 = "queue_job_channel_date_done_date_created_index" + if not index_exists(self._cr, index_1): + # Used by Job.job_record_with_same_identity_key self._cr.execute( "CREATE INDEX queue_job_identity_key_state_partial_index " "ON queue_job (identity_key) WHERE state in ('pending', " "'enqueued', 'wait_dependencies') AND identity_key IS NOT NULL;" ) + if not index_exists(self._cr, index_2): + # Used by .autovacuum + self._cr.execute( + "CREATE INDEX queue_job_channel_date_done_date_created_index " + "ON queue_job (channel, date_done, date_created);" + ) @api.depends("records") def _compute_record_ids(self): @@ -350,8 +355,11 @@ def _message_post_on_failure(self): # at every job creation domain = self._subscribe_users_domain() base_users = self.env["res.users"].search(domain) + suscribe_job_creator = self._subscribe_job_creator() for record in self: - users = base_users | record.user_id + users = base_users + if suscribe_job_creator: + users |= record.user_id record.message_subscribe(partner_ids=users.mapped("partner_id").ids) msg = record._message_failed_job() if msg: @@ -368,6 +376,14 @@ def _subscribe_users_domain(self): domain.append(("company_id", "in", companies.ids)) return domain + @api.model + def _subscribe_job_creator(self): + """ + Whether the user that created the job should be subscribed to the job, + in addition to users determined by `_subscribe_users_domain` + """ + return True + def _message_failed_job(self): """Return a message which will be posted on the job when it is failed. @@ -405,6 +421,7 @@ def autovacuum(self): ("date_cancelled", "<=", deadline), ("channel", "=", channel.complete_name), ], + order="date_done, date_created", limit=1000, ) if jobs: diff --git a/queue_job/static/description/index.html b/queue_job/static/description/index.html index 5d64e6a9d2..a6c54ab739 100644 --- a/queue_job/static/description/index.html +++ b/queue_job/static/description/index.html @@ -372,7 +372,7 @@

Job Queue

!! This file is generated by oca-gen-addon-readme !! !! changes will be overwritten. !! !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -!! source digest: sha256:5c7d6692e11a4d5d03e2fd23cfcf6068d23e685be5e1bc7f760de482512c3083 +!! source digest: sha256:177da9c735cd42cf48e1a72d0f8f5a19680f8408cc7a8919c69e2ca7e7f350fe !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -->

Mature License: LGPL-3 OCA/queue Translate me on Weblate Try me on Runboat

This addon adds an integrated Job Queue to Odoo.

diff --git a/queue_job/tests/common.py b/queue_job/tests/common.py index ccd63cff15..434eadfc34 100644 --- a/queue_job/tests/common.py +++ b/queue_job/tests/common.py @@ -255,6 +255,7 @@ def _add_job(self, *args, **kwargs): if not job.identity_key or all( j.identity_key != job.identity_key for j in self.enqueued_jobs ): + self._prepare_context(job) self.enqueued_jobs.append(job) patcher = mock.patch.object(job, "store") @@ -273,6 +274,13 @@ def _add_job(self, *args, **kwargs): ) return job + def _prepare_context(self, job): + # pylint: disable=context-overridden + job_model = job.job_model.with_context({}) + field_records = job_model._fields["records"] + # Filter the context to simulate store/load of the job + job.recordset = field_records.convert_to_write(job.recordset, job_model) + def __enter__(self): return self diff --git a/queue_job/tests/test_requeue_dead_job.py b/queue_job/tests/test_requeue_dead_job.py index c6c82a2f4d..180e1294eb 100644 --- a/queue_job/tests/test_requeue_dead_job.py +++ b/queue_job/tests/test_requeue_dead_job.py @@ -131,3 +131,28 @@ def test_requeue_dead_jobs(self): # because we committed the cursor, the savepoint of the test method is # gone, and this would break TransactionCase cleanups self.cr.execute("SAVEPOINT test_%d" % self._savepoint_id) + + def test_requeue_orphaned_jobs(self): + uuid = "test_enqueued_job" + queue_job = self.create_dummy_job(uuid) + job_obj = Job.load(self.env, queue_job.uuid) + + # Only enqueued job, don't set it to started to simulate the scenario + # that system shutdown before job is starting + job_obj.set_enqueued() + job_obj.date_enqueued = datetime.now() - timedelta(minutes=1) + job_obj.store() + + # job is now picked up by the requeue query (which includes orphaned jobs) + query = Database(self.env.cr.dbname)._query_requeue_dead_jobs() + self.env.cr.execute(query) + uuids_requeued = self.env.cr.fetchall() + self.assertTrue(queue_job.uuid in j[0] for j in uuids_requeued) + + # clean up + queue_job.unlink() + self.env.cr.commit() # pylint: disable=E8102 + + # because we committed the cursor, the savepoint of the test method is + # gone, and this would break TransactionCase cleanups + self.cr.execute("SAVEPOINT test_%d" % self._savepoint_id) diff --git a/queue_job/tests/test_runner_runner.py b/queue_job/tests/test_runner_runner.py index c6486e27ef..131ce6322d 100644 --- a/queue_job/tests/test_runner_runner.py +++ b/queue_job/tests/test_runner_runner.py @@ -3,8 +3,57 @@ # pylint: disable=odoo-addons-relative-import # we are testing, we want to test as we were an external consumer of the API +import os + +from odoo.tests import BaseCase, tagged + from odoo.addons.queue_job.jobrunner import runner from .common import load_doctests load_tests = load_doctests(runner) + + +@tagged("-at_install", "post_install") +class TestRunner(BaseCase): + @classmethod + def _is_open_file_descriptor(cls, fd): + try: + os.fstat(fd) + return True + except OSError: + return False + + def test_runner_file_descriptor(self): + a_runner = runner.QueueJobRunner.from_environ_or_config() + + read_fd, write_fd = a_runner._stop_pipe + self.assertTrue(self._is_open_file_descriptor(read_fd)) + self.assertTrue(self._is_open_file_descriptor(write_fd)) + + del a_runner + + self.assertFalse(self._is_open_file_descriptor(read_fd)) + self.assertFalse(self._is_open_file_descriptor(write_fd)) + + def test_runner_file_closed_read_descriptor(self): + a_runner = runner.QueueJobRunner.from_environ_or_config() + + read_fd, write_fd = a_runner._stop_pipe + os.close(read_fd) + + del a_runner + + self.assertFalse(self._is_open_file_descriptor(read_fd)) + self.assertFalse(self._is_open_file_descriptor(write_fd)) + + def test_runner_file_closed_write_descriptor(self): + a_runner = runner.QueueJobRunner.from_environ_or_config() + + read_fd, write_fd = a_runner._stop_pipe + os.close(write_fd) + + del a_runner + + self.assertFalse(self._is_open_file_descriptor(read_fd)) + self.assertFalse(self._is_open_file_descriptor(write_fd)) diff --git a/queue_job/views/queue_job_views.xml b/queue_job/views/queue_job_views.xml index be12b4294b..d39d5aeb0a 100644 --- a/queue_job/views/queue_job_views.xml +++ b/queue_job/views/queue_job_views.xml @@ -25,7 +25,7 @@ />