From 2c92b5d30746230a5c5f21f705a56df2b70affa8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Todorovich?= Date: Fri, 6 Jun 2025 11:37:01 -0300 Subject: [PATCH 001/108] [FIX] queue_job: cancel job waiting dependencies --- queue_job/views/queue_job_views.xml | 2 +- queue_job/wizards/queue_jobs_to_cancelled.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/queue_job/views/queue_job_views.xml b/queue_job/views/queue_job_views.xml index 3d7a368971..bc49bf2005 100644 --- a/queue_job/views/queue_job_views.xml +++ b/queue_job/views/queue_job_views.xml @@ -25,7 +25,7 @@ /> + + + + + + + + + + + + + + + + + + + + + + + + export.async.schedule.search + export.async.schedule + + + + + + + + + + + + + + + + + + + + Scheduled Exports + ir.actions.act_window + export.async.schedule + form + Schedule Exports to send by email + + + + + From 8458567ef39fdccd7b06d7536d53c55b158a81ed Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Fri, 22 Nov 2019 13:36:17 +0100 Subject: [PATCH 025/108] [UPD] README.rst --- export_async_schedule/README.rst | 117 +++++ .../static/description/index.html | 457 ++++++++++++++++++ 2 files changed, 574 insertions(+) create mode 100644 export_async_schedule/README.rst create mode 100644 export_async_schedule/static/description/index.html diff --git a/export_async_schedule/README.rst b/export_async_schedule/README.rst new file mode 100644 index 0000000000..bcb9420462 --- /dev/null +++ b/export_async_schedule/README.rst @@ -0,0 +1,117 @@ +============================= +Scheduled Asynchronous Export +============================= + +.. !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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/12.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-12-0/queue-12-0-export_async_schedule + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runbot-Try%20me-875A7B.png + :target: https://runbot.odoo-community.org/runbot/230/12.0 + :alt: Try me on Runbot + +|badge1| |badge2| |badge3| |badge4| |badge5| + +Add a new Automation feature: Scheduled Exports. +Based on an export list and a domain, an email is sent every X +hours/days/weeks/months to a selection of users. + +**Table of contents** + +.. contents:: + :local: + +Configuration +============= + +The configuration of a scheduled export is based on export lists. + +To create an export list: + +* open the list view of the model to export +* select at least one record, and open "Action → Export" +* select the fields to export and save using "Save fields list". + +To configure a scheduled export: + +* open "Settings → Technical → Automation → Scheduled Exports" +* create a scheduled export by filling the form + +A Scheduled Action named "Send Scheduled Exports" checks every hour +if Scheduled Exports have to be executed. + +Usage +===== + +When the configuration of a Scheduled Export is done, their execution +is automatic. + +Users will receive an email containing a link to download the exported file at +the specified frequency. The attachments stay in the database for 7 days by +default (it can be changed with the system parameter ``attachment.ttl``. + +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 smashing it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +~~~~~~~ + +* Camptocamp + +Contributors +~~~~~~~~~~~~ + +* Guewen Baconnier (Camptocamp) + +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 + +Current `maintainer `__: + +|maintainer-guewen| + +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/static/description/index.html b/export_async_schedule/static/description/index.html new file mode 100644 index 0000000000..caa67a6395 --- /dev/null +++ b/export_async_schedule/static/description/index.html @@ -0,0 +1,457 @@ + + + + + + +Scheduled Asynchronous Export + + + +
+

Scheduled Asynchronous Export

+ + +

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

+

Add a new Automation feature: Scheduled Exports. +Based on an export list and a domain, an email is sent every X +hours/days/weeks/months to a selection of users.

+

Table of contents

+ +
+

Configuration

+

The configuration of a scheduled export is based on export lists.

+

To create an export list:

+
    +
  • open the list view of the model to export
  • +
  • select at least one record, and open “Action → Export”
  • +
  • select the fields to export and save using “Save fields list”.
  • +
+

To configure a scheduled export:

+
    +
  • open “Settings → Technical → Automation → Scheduled Exports”
  • +
  • create a scheduled export by filling the form
  • +
+

A Scheduled Action named “Send Scheduled Exports” checks every hour +if Scheduled Exports have to be executed.

+
+
+

Usage

+

When the configuration of a Scheduled Export is done, their execution +is automatic.

+

Users will receive an email containing a link to download the exported file at +the specified frequency. The attachments stay in the database for 7 days by +default (it can be changed with the system parameter attachment.ttl.

+
+
+

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 smashing it by providing a detailed and welcomed +feedback.

+

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

+
+
+

Credits

+
+

Authors

+
    +
  • Camptocamp
  • +
+
+
+

Contributors

+
    +
  • Guewen Baconnier (Camptocamp)
  • +
+
+
+

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 maintainer:

+

guewen

+

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.

+
+
+
+ + From e64a22936a1c5d8608349058e78f2d82695fd06d Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Fri, 22 Nov 2019 17:40:15 +0100 Subject: [PATCH 026/108] Add end of month option for the schedules --- .../models/export_async_schedule.py | 12 ++++++++++++ .../tests/test_export_async_schedule.py | 14 ++++++++++++++ .../views/export_async_schedule_views.xml | 3 +++ 3 files changed, 29 insertions(+) diff --git a/export_async_schedule/models/export_async_schedule.py b/export_async_schedule/models/export_async_schedule.py index 6737910687..f4ad93d790 100644 --- a/export_async_schedule/models/export_async_schedule.py +++ b/export_async_schedule/models/export_async_schedule.py @@ -60,6 +60,7 @@ class ExportAsyncSchedule(models.Model): default="months", required=True, ) + end_of_month = fields.Boolean() def name_get(self): result = [] @@ -79,8 +80,19 @@ def run_schedule(self): def _compute_next_date(self): args = {self.interval_unit: self.interval} + if self.interval_unit == "months" and self.end_of_month: + # dateutil knows how to deal with variable days of months, + # it will put the latest possible day + args.update({"day": 31, "hour": 23, "minute": 59, "second": 59}) return self.next_execution + relativedelta(**args) + @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 + ) + @api.model def _get_fields_with_labels(self, model_name, export_fields): self_fields = self.env[model_name]._fields diff --git a/export_async_schedule/tests/test_export_async_schedule.py b/export_async_schedule/tests/test_export_async_schedule.py index 8cfb507b75..1bd6b38a00 100644 --- a/export_async_schedule/tests/test_export_async_schedule.py +++ b/export_async_schedule/tests/test_export_async_schedule.py @@ -133,6 +133,20 @@ def assert_next_schedule(interval, unit, expected): 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): in_future = datetime.now() + relativedelta(minutes=1) self.schedule.next_execution = in_future diff --git a/export_async_schedule/views/export_async_schedule_views.xml b/export_async_schedule/views/export_async_schedule_views.xml index dbb293638a..34e4f4146a 100644 --- a/export_async_schedule/views/export_async_schedule_views.xml +++ b/export_async_schedule/views/export_async_schedule_views.xml @@ -50,6 +50,9 @@ + From f35c69f399363ab7a0749c2b8e4447160e1d61ac Mon Sep 17 00:00:00 2001 From: oca-travis Date: Thu, 11 Jun 2020 19:00:51 +0000 Subject: [PATCH 027/108] [UPD] Update export_async_schedule.pot --- .../i18n/export_async_schedule.pot | 202 ++++++++++++++++++ 1 file changed, 202 insertions(+) create mode 100644 export_async_schedule/i18n/export_async_schedule.pot 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..5167dc57cd --- /dev/null +++ b/export_async_schedule/i18n/export_async_schedule.pot @@ -0,0 +1,202 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * export_async_schedule +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 12.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 +#: selection:export.async.schedule,export_format:0 +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 +#: selection:export.async.schedule,interval_unit:0 +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 +#: selection:export.async.schedule,export_format:0 +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 +#: selection:export.async.schedule,interval_unit:0 +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 +#: selection:export.async.schedule,interval_unit:0 +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 +#: model_terms:ir.ui.view,arch_db:export_async_schedule.view_export_async_schedule_tree +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 +#: selection:export.async.schedule,interval_unit:0 +msgid "Week(s)" +msgstr "" + From 8c9a212e8f739cc2d541b072bfec2119c9b8d0da Mon Sep 17 00:00:00 2001 From: OCA-git-bot Date: Thu, 11 Jun 2020 19:22:27 +0000 Subject: [PATCH 028/108] [UPD] README.rst --- export_async_schedule/static/description/index.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/export_async_schedule/static/description/index.html b/export_async_schedule/static/description/index.html index caa67a6395..8dd8ef6787 100644 --- a/export_async_schedule/static/description/index.html +++ b/export_async_schedule/static/description/index.html @@ -3,7 +3,7 @@ - + Scheduled Asynchronous Export + + +
+

Base Export Async

+ + +

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

+

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 smashing 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.

+
+
+

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.

+
+
+
+ + From a7ce70ad419ef309a536e4151247430b3b413db4 Mon Sep 17 00:00:00 2001 From: Arnaud Pineux Date: Thu, 25 Apr 2019 09:56:29 +0200 Subject: [PATCH 052/108] [IMP] URL to attachment to avoid email size limit --- base_export_async/__manifest__.py | 4 ++- base_export_async/data/config_parameter.xml | 7 ++++ base_export_async/data/cron.xml | 12 +++++++ base_export_async/models/__init__.py | 1 + base_export_async/models/attachment.py | 31 ++++++++++++++++++ base_export_async/models/delay_export.py | 36 ++++++++++++++++----- 6 files changed, 82 insertions(+), 9 deletions(-) create mode 100644 base_export_async/data/config_parameter.xml create mode 100644 base_export_async/data/cron.xml create mode 100644 base_export_async/models/attachment.py diff --git a/base_export_async/__manifest__.py b/base_export_async/__manifest__.py index b9acd7081e..51fcfd2968 100644 --- a/base_export_async/__manifest__.py +++ b/base_export_async/__manifest__.py @@ -9,7 +9,7 @@ 'version': '12.0.1.0.0', 'license': 'AGPL-3', 'author': 'ACSONE SA/NV, Odoo Community Association (OCA)', - 'website': 'https://acsone.eu/', + 'website': 'https://github.com/OCA/queue', 'depends': [ 'web', 'queue_job' @@ -17,6 +17,8 @@ 'data': [ 'views/assets.xml', 'security/ir.model.access.csv', + 'data/config_parameter.xml', + 'data/cron.xml', ], 'demo': [ ], diff --git a/base_export_async/data/config_parameter.xml b/base_export_async/data/config_parameter.xml new file mode 100644 index 0000000000..83cb915c65 --- /dev/null +++ b/base_export_async/data/config_parameter.xml @@ -0,0 +1,7 @@ + + + + attachment.time.to.live + 7 + + diff --git a/base_export_async/data/cron.xml b/base_export_async/data/cron.xml new file mode 100644 index 0000000000..63535dd18f --- /dev/null +++ b/base_export_async/data/cron.xml @@ -0,0 +1,12 @@ + + + + Delete export generated attachment + + code + model.cron_delete() + 1 + days + -1 + + diff --git a/base_export_async/models/__init__.py b/base_export_async/models/__init__.py index f3652a9bf5..e0aba64362 100644 --- a/base_export_async/models/__init__.py +++ b/base_export_async/models/__init__.py @@ -1,4 +1,5 @@ # Copyright 2019 ACSONE SA/NV # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +from . import attachment from . import delay_export diff --git a/base_export_async/models/attachment.py b/base_export_async/models/attachment.py new file mode 100644 index 0000000000..b354c878db --- /dev/null +++ b/base_export_async/models/attachment.py @@ -0,0 +1,31 @@ +# Copyright 2019 ACSONE SA/NV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from dateutil.relativedelta import relativedelta +from odoo import api, models, fields + + +class Attachment(models.Model): + _inherit = "ir.attachment" + + to_delete = fields.Boolean(string="To delete by CRON", default=False) + + @api.model_cr + def init(self): + self._cr.execute( + "SELECT indexname FROM pg_indexes WHERE " + "indexname = 'ir_attachment_to_delete_create_date'") + if not self._cr.fetchone(): + self._cr.execute( + "CREATE INDEX ir_attachment_to_delete_create_date " + "ON ir_attachment (to_delete, create_date)") + + @api.model + def cron_delete(self): + time_to_live = self.env.\ + ref('base_export_async.attachment_time_to_live').value + date_today = fields.Date.from_string(fields.Date.today()) + date_to_delete = fields.Date.to_string( + date_today + relativedelta(days=-int(time_to_live))) + self.search([('to_delete', '=', True), + ('create_date', '<=', date_to_delete)]).unlink() diff --git a/base_export_async/models/delay_export.py b/base_export_async/models/delay_export.py index 46da1907b5..e3413ba6ca 100644 --- a/base_export_async/models/delay_export.py +++ b/base_export_async/models/delay_export.py @@ -5,6 +5,7 @@ import json import operator import base64 +from dateutil.relativedelta import relativedelta from odoo import api, fields, models, _ from odoo.addons.queue_job.job import job @@ -64,23 +65,42 @@ def export(self, params): xls = ExcelExport() result = xls.from_data(columns_headers, import_data) + name = "{}.{}".format(model_name, export_format) attachment = self.env['ir.attachment'].create({ - 'name': "{}.{}".format(model_name, export_format), + 'name': name, 'datas': base64.b64encode(result), - 'datas_fname': "{}.{}".format(model_name, export_format), - 'type': 'binary' + 'datas_fname': name, + 'type': 'binary', + 'to_delete': True, }) - odoobot = self.env.ref("base.partner_root") - email_from = odoobot.email + url = "{}/web/content/ir.attachment/{}/datas/{}?download=true".format( + self.env['ir.config_parameter'].sudo().get_param('web.base.url'), + attachment.id, + attachment.name, + ) + + time_to_live = self.env. \ + ref('base_export_async.attachment_time_to_live').value + date_today = fields.Date.from_string(fields.Date.today()) + expiration_date = fields.Date.to_string( + date_today + relativedelta(days=+int(time_to_live))) + + odoo_bot = self.env.ref("base.partner_root") + email_from = odoo_bot.email self.env['mail.mail'].create({ 'email_from': email_from, 'reply_to': email_from, 'email_to': user.email, 'subject': _("Export {} {}").format( model_name, fields.Date.to_string(fields.Date.today())), - 'body_html': _("This is an automated \ - message please do not reply."), - 'attachment_ids': [(4, attachment.id)], + 'body_html': _(""" +

Your export is available here.

+

It will be automatically deleted the {}.

+

 

+

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

+ """).format(url, expiration_date), 'auto_delete': True, }) From c8e6084e047b9a180c1781858f186fae74eba66b Mon Sep 17 00:00:00 2001 From: Arnaud Pineux Date: Thu, 25 Apr 2019 10:17:01 +0200 Subject: [PATCH 053/108] [TEST] Check that cron delete attachment after TTL --- .../tests/test_base_export_async.py | 34 +++++++++++++++++-- 1 file changed, 31 insertions(+), 3 deletions(-) diff --git a/base_export_async/tests/test_base_export_async.py b/base_export_async/tests/test_base_export_async.py index a31782b420..6ba1996793 100644 --- a/base_export_async/tests/test_base_export_async.py +++ b/base_export_async/tests/test_base_export_async.py @@ -1,8 +1,11 @@ # Copyright 2019 ACSONE SA/NV # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). -import odoo.tests.common as common import json +from dateutil.relativedelta import relativedelta + +from odoo import fields +import odoo.tests.common as common data_csv = {'data': """{"format": "csv", "model": "res.partner", "fields": [{"name": "id", "label": "External ID"}, @@ -43,18 +46,43 @@ 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_mail.attachment_ids[0].datas_fname, + self.assertEqual(new_attachment.datas_fname, "res.partner.csv") + self.assertTrue(new_attachment.to_delete) 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([]) 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_mail.attachment_ids[0].datas_fname, + self.assertEqual(new_attachment.datas_fname, "res.partner.xls") + self.assertTrue(new_attachment.to_delete) + + 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. \ + ref('base_export_async.attachment_time_to_live').value + date_today = fields.Date.from_string(fields.Date.today()) + date_to_delete = fields.Date.to_string( + date_today + relativedelta(days=-int(time_to_live))) + # Update create_date with today - TTL + new_attachment.write({ + 'create_date': date_to_delete + }) + self.env['ir.attachment'].sudo().cron_delete() + # The attachment must be deleted + self.assertFalse(new_attachment.exists()) From a635453f370af15397f4d50fbb0ef893bcdec6f1 Mon Sep 17 00:00:00 2001 From: Arnaud Pineux Date: Thu, 25 Apr 2019 15:07:50 +0200 Subject: [PATCH 054/108] [IMP] Improve security for the asynchronous export by linking the attachment to a record only visible to the user --- base_export_async/__manifest__.py | 1 + base_export_async/data/cron.xml | 4 +-- base_export_async/models/__init__.py | 1 - base_export_async/models/attachment.py | 31 ------------------- base_export_async/models/delay_export.py | 18 +++++++++-- .../security/ir.model.access.csv | 3 +- base_export_async/security/ir_rule.xml | 13 ++++++++ .../tests/test_base_export_async.py | 6 ++-- 8 files changed, 36 insertions(+), 41 deletions(-) delete mode 100644 base_export_async/models/attachment.py create mode 100644 base_export_async/security/ir_rule.xml diff --git a/base_export_async/__manifest__.py b/base_export_async/__manifest__.py index 51fcfd2968..7d82ffabdd 100644 --- a/base_export_async/__manifest__.py +++ b/base_export_async/__manifest__.py @@ -17,6 +17,7 @@ 'data': [ 'views/assets.xml', 'security/ir.model.access.csv', + 'security/ir_rule.xml', 'data/config_parameter.xml', 'data/cron.xml', ], diff --git a/base_export_async/data/cron.xml b/base_export_async/data/cron.xml index 63535dd18f..ba42369ac3 100644 --- a/base_export_async/data/cron.xml +++ b/base_export_async/data/cron.xml @@ -1,8 +1,8 @@ - Delete export generated attachment - + Delete Generated Exports + code model.cron_delete() 1 diff --git a/base_export_async/models/__init__.py b/base_export_async/models/__init__.py index e0aba64362..f3652a9bf5 100644 --- a/base_export_async/models/__init__.py +++ b/base_export_async/models/__init__.py @@ -1,5 +1,4 @@ # Copyright 2019 ACSONE SA/NV # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). -from . import attachment from . import delay_export diff --git a/base_export_async/models/attachment.py b/base_export_async/models/attachment.py deleted file mode 100644 index b354c878db..0000000000 --- a/base_export_async/models/attachment.py +++ /dev/null @@ -1,31 +0,0 @@ -# Copyright 2019 ACSONE SA/NV -# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). - -from dateutil.relativedelta import relativedelta -from odoo import api, models, fields - - -class Attachment(models.Model): - _inherit = "ir.attachment" - - to_delete = fields.Boolean(string="To delete by CRON", default=False) - - @api.model_cr - def init(self): - self._cr.execute( - "SELECT indexname FROM pg_indexes WHERE " - "indexname = 'ir_attachment_to_delete_create_date'") - if not self._cr.fetchone(): - self._cr.execute( - "CREATE INDEX ir_attachment_to_delete_create_date " - "ON ir_attachment (to_delete, create_date)") - - @api.model - def cron_delete(self): - time_to_live = self.env.\ - ref('base_export_async.attachment_time_to_live').value - date_today = fields.Date.from_string(fields.Date.today()) - date_to_delete = fields.Date.to_string( - date_today + relativedelta(days=-int(time_to_live))) - self.search([('to_delete', '=', True), - ('create_date', '<=', date_to_delete)]).unlink() diff --git a/base_export_async/models/delay_export.py b/base_export_async/models/delay_export.py index e3413ba6ca..8ebb23ba73 100644 --- a/base_export_async/models/delay_export.py +++ b/base_export_async/models/delay_export.py @@ -20,6 +20,8 @@ class DelayExport(models.Model): _name = 'delay.export' _description = 'Allow to delay the export' + user_id = fields.Many2one('res.users', string='User', index=True) + @api.model def delay_export(self, data): params = json.loads(data.get('data')) @@ -65,13 +67,16 @@ def export(self, params): xls = ExcelExport() result = xls.from_data(columns_headers, import_data) + export_record = self.sudo().create({'user_id': user.id}) + name = "{}.{}".format(model_name, export_format) attachment = self.env['ir.attachment'].create({ 'name': name, 'datas': base64.b64encode(result), 'datas_fname': name, 'type': 'binary', - 'to_delete': True, + 'res_model': self._name, + 'res_id': export_record.id, }) url = "{}/web/content/ir.attachment/{}/datas/{}?download=true".format( @@ -80,7 +85,7 @@ def export(self, params): attachment.name, ) - time_to_live = self.env. \ + time_to_live = self.sudo().env. \ ref('base_export_async.attachment_time_to_live').value date_today = fields.Date.from_string(fields.Date.today()) expiration_date = fields.Date.to_string( @@ -104,3 +109,12 @@ def export(self, params): """).format(url, expiration_date), 'auto_delete': True, }) + + @api.model + def cron_delete(self): + time_to_live = self.env. \ + ref('base_export_async.attachment_time_to_live').value + date_today = fields.Date.from_string(fields.Date.today()) + date_to_delete = fields.Date.to_string( + date_today + relativedelta(days=-int(time_to_live))) + self.search([('create_date', '<=', date_to_delete)]).unlink() diff --git a/base_export_async/security/ir.model.access.csv b/base_export_async/security/ir.model.access.csv index 810456be20..948323af24 100644 --- a/base_export_async/security/ir.model.access.csv +++ b/base_export_async/security/ir.model.access.csv @@ -1,2 +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,,1,1,1,1 +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..7ed488ed37 --- /dev/null +++ b/base_export_async/security/ir_rule.xml @@ -0,0 +1,13 @@ + + + + Only user can read delay.export + + + + + + + [('user_id', '=', user.id)] + + diff --git a/base_export_async/tests/test_base_export_async.py b/base_export_async/tests/test_base_export_async.py index 6ba1996793..66dd7a0846 100644 --- a/base_export_async/tests/test_base_export_async.py +++ b/base_export_async/tests/test_base_export_async.py @@ -53,7 +53,6 @@ def test_export_csv(self): self.assertEqual(len(new_mail), 1) self.assertEqual(new_attachment.datas_fname, "res.partner.csv") - self.assertTrue(new_attachment.to_delete) def test_export_xls(self): """ Check that the export generate an attachment and email""" @@ -66,7 +65,6 @@ def test_export_xls(self): self.assertEqual(len(new_mail), 1) self.assertEqual(new_attachment.datas_fname, "res.partner.xls") - self.assertTrue(new_attachment.to_delete) def test_cron_delete(self): """ Check that cron delete attachment after TTL""" @@ -80,9 +78,9 @@ def test_cron_delete(self): date_to_delete = fields.Date.to_string( date_today + relativedelta(days=-int(time_to_live))) # Update create_date with today - TTL - new_attachment.write({ + self.delay_export_obj.search([]).write({ 'create_date': date_to_delete }) - self.env['ir.attachment'].sudo().cron_delete() + self.delay_export_obj.sudo().cron_delete() # The attachment must be deleted self.assertFalse(new_attachment.exists()) From f4591e65c55d24f1684feb2f516eb4b2cc5fc4db Mon Sep 17 00:00:00 2001 From: Arnaud Pineux Date: Thu, 25 Apr 2019 16:05:58 +0200 Subject: [PATCH 055/108] [IMP] Improve the use of dates and config_parameter --- base_export_async/data/config_parameter.xml | 2 +- base_export_async/models/delay_export.py | 11 +++++------ base_export_async/tests/test_base_export_async.py | 9 ++++----- 3 files changed, 10 insertions(+), 12 deletions(-) diff --git a/base_export_async/data/config_parameter.xml b/base_export_async/data/config_parameter.xml index 83cb915c65..1a2d4ae383 100644 --- a/base_export_async/data/config_parameter.xml +++ b/base_export_async/data/config_parameter.xml @@ -1,5 +1,5 @@ - + attachment.time.to.live 7 diff --git a/base_export_async/models/delay_export.py b/base_export_async/models/delay_export.py index 8ebb23ba73..af95f89560 100644 --- a/base_export_async/models/delay_export.py +++ b/base_export_async/models/delay_export.py @@ -85,9 +85,9 @@ def export(self, params): attachment.name, ) - time_to_live = self.sudo().env. \ - ref('base_export_async.attachment_time_to_live').value - date_today = fields.Date.from_string(fields.Date.today()) + time_to_live = self.env['ir.config_parameter'].sudo(). \ + get_param('attachment.time.to.live', 7) + date_today = fields.Date.today() expiration_date = fields.Date.to_string( date_today + relativedelta(days=+int(time_to_live))) @@ -114,7 +114,6 @@ def export(self, params): def cron_delete(self): time_to_live = self.env. \ ref('base_export_async.attachment_time_to_live').value - date_today = fields.Date.from_string(fields.Date.today()) - date_to_delete = fields.Date.to_string( - date_today + relativedelta(days=-int(time_to_live))) + 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/tests/test_base_export_async.py b/base_export_async/tests/test_base_export_async.py index 66dd7a0846..1b7b508dcb 100644 --- a/base_export_async/tests/test_base_export_async.py +++ b/base_export_async/tests/test_base_export_async.py @@ -72,11 +72,10 @@ def test_cron_delete(self): 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. \ - ref('base_export_async.attachment_time_to_live').value - date_today = fields.Date.from_string(fields.Date.today()) - date_to_delete = fields.Date.to_string( - date_today + relativedelta(days=-int(time_to_live))) + time_to_live = self.env['ir.config_parameter'].sudo(). \ + get_param('attachment.time.to.live', 7) + date_today = fields.Date.today() + date_to_delete = date_today + relativedelta(days=-int(time_to_live)) # Update create_date with today - TTL self.delay_export_obj.search([]).write({ 'create_date': date_to_delete From cf1727412b24d8a2f3fff1eae342839a4adf988c Mon Sep 17 00:00:00 2001 From: Arnaud Pineux Date: Tue, 30 Apr 2019 09:00:51 +0200 Subject: [PATCH 056/108] [FIX] Can read odoobot email address --- base_export_async/models/delay_export.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/base_export_async/models/delay_export.py b/base_export_async/models/delay_export.py index af95f89560..0c8fb4580d 100644 --- a/base_export_async/models/delay_export.py +++ b/base_export_async/models/delay_export.py @@ -91,7 +91,7 @@ def export(self, params): expiration_date = fields.Date.to_string( date_today + relativedelta(days=+int(time_to_live))) - odoo_bot = self.env.ref("base.partner_root") + odoo_bot = self.sudo().env.ref("base.partner_root") email_from = odoo_bot.email self.env['mail.mail'].create({ 'email_from': email_from, From d048f220579e0a199b36b1efe00024f7ea090bba Mon Sep 17 00:00:00 2001 From: Arnaud Pineux Date: Tue, 30 Apr 2019 13:34:25 +0200 Subject: [PATCH 057/108] [IMP] Change Warning by UserError --- base_export_async/models/delay_export.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/base_export_async/models/delay_export.py b/base_export_async/models/delay_export.py index 0c8fb4580d..786d5b0bbc 100644 --- a/base_export_async/models/delay_export.py +++ b/base_export_async/models/delay_export.py @@ -10,7 +10,7 @@ from odoo import api, fields, models, _ from odoo.addons.queue_job.job import job from odoo.addons.web.controllers.main import CSVExport, ExcelExport -from odoo.exceptions import Warning +from odoo.exceptions import UserError _logger = logging.getLogger(__name__) @@ -26,7 +26,7 @@ class DelayExport(models.Model): def delay_export(self, data): params = json.loads(data.get('data')) if not self.env.user.email: - raise Warning(_("You must set an email address to your user.")) + raise UserError(_("You must set an email address to your user.")) self.with_delay().export(params) @api.model @@ -40,7 +40,7 @@ def export(self, params): 'domain', 'import_compat', 'context')(params) user = self.env['res.users'].browse([context.get('uid')]) if not user or not user.email: - raise Warning(_("The user doesn't have an email address.")) + raise UserError(_("The user doesn't have an email address.")) model = self.env[model_name].with_context( import_compat=import_compat, **context) From 4fb5684edfebc0c2b309cef2b3574aead22fb236 Mon Sep 17 00:00:00 2001 From: Arnaud Pineux Date: Fri, 24 May 2019 12:06:19 +0200 Subject: [PATCH 058/108] [IMP] Improve naming --- base_export_async/data/config_parameter.xml | 4 ++-- base_export_async/models/delay_export.py | 7 ++++--- base_export_async/tests/test_base_export_async.py | 2 +- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/base_export_async/data/config_parameter.xml b/base_export_async/data/config_parameter.xml index 1a2d4ae383..e3c1e5e35e 100644 --- a/base_export_async/data/config_parameter.xml +++ b/base_export_async/data/config_parameter.xml @@ -1,7 +1,7 @@ - - attachment.time.to.live + + attachment.ttl 7 diff --git a/base_export_async/models/delay_export.py b/base_export_async/models/delay_export.py index 786d5b0bbc..e12ea724b3 100644 --- a/base_export_async/models/delay_export.py +++ b/base_export_async/models/delay_export.py @@ -86,11 +86,12 @@ def export(self, params): ) time_to_live = self.env['ir.config_parameter'].sudo(). \ - get_param('attachment.time.to.live', 7) + get_param('attachment.ttl', 7) date_today = fields.Date.today() expiration_date = fields.Date.to_string( date_today + relativedelta(days=+int(time_to_live))) + # TODO : move to email template odoo_bot = self.sudo().env.ref("base.partner_root") email_from = odoo_bot.email self.env['mail.mail'].create({ @@ -112,8 +113,8 @@ def export(self, params): @api.model def cron_delete(self): - time_to_live = self.env. \ - ref('base_export_async.attachment_time_to_live').value + 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/tests/test_base_export_async.py b/base_export_async/tests/test_base_export_async.py index 1b7b508dcb..a9c589ecbc 100644 --- a/base_export_async/tests/test_base_export_async.py +++ b/base_export_async/tests/test_base_export_async.py @@ -73,7 +73,7 @@ def test_cron_delete(self): 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.time.to.live', 7) + get_param('attachment.ttl', 7) date_today = fields.Date.today() date_to_delete = date_today + relativedelta(days=-int(time_to_live)) # Update create_date with today - TTL From 534ac9148d3be60062f5c42cb8ec81146e6a5732 Mon Sep 17 00:00:00 2001 From: Arnaud Pineux Date: Fri, 24 May 2019 14:42:44 +0200 Subject: [PATCH 059/108] [IMP] Split method + comments --- base_export_async/models/delay_export.py | 18 +++++++++++++----- base_export_async/static/src/js/data_export.js | 14 ++++++++++++++ 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/base_export_async/models/delay_export.py b/base_export_async/models/delay_export.py index e12ea724b3..d1ebfa2ff6 100644 --- a/base_export_async/models/delay_export.py +++ b/base_export_async/models/delay_export.py @@ -30,8 +30,7 @@ def delay_export(self, data): self.with_delay().export(params) @api.model - @job - def export(self, params): + def _get_file_content(self, params): export_format = params.get('format') raw_data = export_format != 'csv' @@ -62,17 +61,26 @@ def export(self, params): if export_format == 'csv': csv = CSVExport() - result = csv.from_data(columns_headers, import_data) + return csv.from_data(columns_headers, import_data) else: xls = ExcelExport() - result = xls.from_data(columns_headers, import_data) + return xls.from_data(columns_headers, import_data) + + @api.model + @job + def export(self, params): + content = self._get_file_content(params) + + model_name, context, export_format = \ + operator.itemgetter('model', 'context', 'format')(params) + user = self.env['res.users'].browse([context.get('uid')]) export_record = self.sudo().create({'user_id': user.id}) name = "{}.{}".format(model_name, export_format) attachment = self.env['ir.attachment'].create({ 'name': name, - 'datas': base64.b64encode(result), + 'datas': base64.b64encode(content), 'datas_fname': name, 'type': 'binary', 'res_model': self._name, diff --git a/base_export_async/static/src/js/data_export.js b/base_export_async/static/src/js/data_export.js index 3a5cdc3898..06a7af02a0 100644 --- a/base_export_async/static/src/js/data_export.js +++ b/base_export_async/static/src/js/data_export.js @@ -9,6 +9,11 @@ odoo.define('base_export_async.DataExport', function(require) { var _t = core._t; DataExport.include({ + /* + Overwritten Object responsible for the standard export. + A flag (checkbox) Async is added and if checked, call the + delay export instead of the standard export. + */ start: function() { this._super.apply(this, arguments); this.async = this.$('#async_export'); @@ -16,6 +21,9 @@ odoo.define('base_export_async.DataExport', function(require) { export_data: function() { var self = this; if (self.async.is(":checked")) { + /* + Checks from the standard method + */ var exported_fields = this.$( '.o_fields_list option').map( function() { @@ -43,6 +51,9 @@ odoo.define('base_export_async.DataExport', function(require) { var export_format = this.$export_format_inputs .filter(':checked').val(); + /* + Call the delay export if Async is checked + */ framework.blockUI(); this._rpc({ model: 'delay.export', @@ -82,6 +93,9 @@ odoo.define('base_export_async.DataExport', function(require) { )); }); } else { + /* + Call the standard method if Async is not checked + */ this._super.apply(this, arguments); } }, From 123c7bca1d15eb800dbbdece76b9e61db91ffbe8 Mon Sep 17 00:00:00 2001 From: Arnaud Pineux Date: Wed, 19 Jun 2019 15:29:12 +0200 Subject: [PATCH 060/108] [IMP] Add the model description instead of name on the email subject --- base_export_async/models/delay_export.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/base_export_async/models/delay_export.py b/base_export_async/models/delay_export.py index d1ebfa2ff6..925ee88b34 100644 --- a/base_export_async/models/delay_export.py +++ b/base_export_async/models/delay_export.py @@ -102,12 +102,13 @@ def export(self, params): # TODO : move to email template odoo_bot = self.sudo().env.ref("base.partner_root") email_from = odoo_bot.email + model_description = self.env[model_name]._description self.env['mail.mail'].create({ 'email_from': email_from, 'reply_to': email_from, 'email_to': user.email, 'subject': _("Export {} {}").format( - model_name, fields.Date.to_string(fields.Date.today())), + model_description, fields.Date.to_string(fields.Date.today())), 'body_html': _("""

Your export is available here.

It will be automatically deleted the {}.

From 4f6b0b4ffa6d830314a8e62b332c92635b848b35 Mon Sep 17 00:00:00 2001 From: oca-travis Date: Fri, 21 Jun 2019 09:46:57 +0000 Subject: [PATCH 061/108] [UPD] Update base_export_async.pot --- base_export_async/i18n/base_export_async.pot | 133 +++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 base_export_async/i18n/base_export_async.pot 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..3f450bb117 --- /dev/null +++ b/base_export_async/i18n/base_export_async.pot @@ -0,0 +1,133 @@ +# 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" +"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 +#: code:addons/base_export_async/models/delay_export.py:112 +#, 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 "" + +#. module: base_export_async +#. openerp-web +#: code:addons/base_export_async/static/src/xml/base.xml:9 +#, python-format +msgid "(You will receive the export by email)" +msgstr "" + +#. module: base_export_async +#: model:ir.model,name:base_export_async.model_delay_export +msgid "Allow to delay the export" +msgstr "" + +#. module: base_export_async +#. openerp-web +#: code:addons/base_export_async/static/src/xml/base.xml:9 +#, 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:ir.actions.server,name:base_export_async.to_delete_attachment_ir_actions_server +#: model:ir.cron,cron_name:base_export_async.to_delete_attachment +#: model:ir.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 +#: code:addons/base_export_async/models/delay_export.py:110 +#, python-format +msgid "Export {} {}" +msgstr "" + +#. module: base_export_async +#. openerp-web +#: code:addons/base_export_async/static/src/js/data_export.js:47 +#, 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 +#. openerp-web +#: code:addons/base_export_async/static/src/js/data_export.js:39 +#, python-format +msgid "Please select fields to export..." +msgstr "" + +#. module: base_export_async +#: code:addons/base_export_async/models/delay_export.py:42 +#, python-format +msgid "The user doesn't have an email address." +msgstr "" + +#. module: base_export_async +#: model:ir.model.fields,field_description:base_export_async.field_delay_export__user_id +msgid "User" +msgstr "" + +#. module: base_export_async +#: code:addons/base_export_async/models/delay_export.py:29 +#, python-format +msgid "You must set an email address to your user." +msgstr "" + +#. module: base_export_async +#. openerp-web +#: code:addons/base_export_async/static/src/js/data_export.js:91 +#, python-format +msgid "You will receive the export file by email as soon as it is finished." +msgstr "" + From 93442de0e0b6abbb031444e9dedb485f27376238 Mon Sep 17 00:00:00 2001 From: Maria Sparenberg Date: Thu, 4 Jul 2019 12:15:22 +0000 Subject: [PATCH 062/108] Added translation using Weblate (German) --- base_export_async/i18n/de.po | 133 +++++++++++++++++++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 base_export_async/i18n/de.po diff --git a/base_export_async/i18n/de.po b/base_export_async/i18n/de.po new file mode 100644 index 0000000000..883bf72944 --- /dev/null +++ b/base_export_async/i18n/de.po @@ -0,0 +1,133 @@ +# 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" +"Last-Translator: Automatically generated\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" + +#. module: base_export_async +#: code:addons/base_export_async/models/delay_export.py:112 +#, 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 "" + +#. module: base_export_async +#. openerp-web +#: code:addons/base_export_async/static/src/xml/base.xml:9 +#, python-format +msgid "(You will receive the export by email)" +msgstr "" + +#. module: base_export_async +#: model:ir.model,name:base_export_async.model_delay_export +msgid "Allow to delay the export" +msgstr "" + +#. module: base_export_async +#. openerp-web +#: code:addons/base_export_async/static/src/xml/base.xml:9 +#, 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:ir.actions.server,name:base_export_async.to_delete_attachment_ir_actions_server +#: model:ir.cron,cron_name:base_export_async.to_delete_attachment +#: model:ir.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 +#: code:addons/base_export_async/models/delay_export.py:110 +#, python-format +msgid "Export {} {}" +msgstr "" + +#. module: base_export_async +#. openerp-web +#: code:addons/base_export_async/static/src/js/data_export.js:47 +#, 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 +#. openerp-web +#: code:addons/base_export_async/static/src/js/data_export.js:39 +#, python-format +msgid "Please select fields to export..." +msgstr "" + +#. module: base_export_async +#: code:addons/base_export_async/models/delay_export.py:42 +#, python-format +msgid "The user doesn't have an email address." +msgstr "" + +#. module: base_export_async +#: model:ir.model.fields,field_description:base_export_async.field_delay_export__user_id +msgid "User" +msgstr "" + +#. module: base_export_async +#: code:addons/base_export_async/models/delay_export.py:29 +#, python-format +msgid "You must set an email address to your user." +msgstr "" + +#. module: base_export_async +#. openerp-web +#: code:addons/base_export_async/static/src/js/data_export.js:91 +#, python-format +msgid "You will receive the export file by email as soon as it is finished." +msgstr "" From 763e181a7767f20369b3ccbbe0d051fffe32508d Mon Sep 17 00:00:00 2001 From: Maria Sparenberg Date: Thu, 4 Jul 2019 12:17:52 +0000 Subject: [PATCH 063/108] Translated using Weblate (German) Currently translated at 100.0% (19 of 19 strings) Translation: queue-12.0/queue-12.0-base_export_async Translate-URL: https://translation.odoo-community.org/projects/queue-12-0/queue-12-0-base_export_async/de/ --- base_export_async/i18n/de.po | 47 +++++++++++++++++++++++------------- 1 file changed, 30 insertions(+), 17 deletions(-) diff --git a/base_export_async/i18n/de.po b/base_export_async/i18n/de.po index 883bf72944..72d0bdfbc3 100644 --- a/base_export_async/i18n/de.po +++ b/base_export_async/i18n/de.po @@ -6,13 +6,15 @@ msgid "" msgstr "" "Project-Id-Version: Odoo Server 12.0\n" "Report-Msgid-Bugs-To: \n" -"Last-Translator: Automatically generated\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 #: code:addons/base_export_async/models/delay_export.py:112 @@ -26,104 +28,114 @@ msgid "\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" +" " #. module: base_export_async #. openerp-web #: code:addons/base_export_async/static/src/xml/base.xml:9 #, python-format msgid "(You will receive the export by email)" -msgstr "" +msgstr "(Der Export wird per Mail bereitgestellt.)" #. module: base_export_async #: model:ir.model,name:base_export_async.model_delay_export msgid "Allow to delay the export" -msgstr "" +msgstr "Verzögerung des Exports erlauben" #. module: base_export_async #. openerp-web #: code:addons/base_export_async/static/src/xml/base.xml:9 #, python-format msgid "Asynchronous export" -msgstr "" +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 "" +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 "" +msgstr "Erstellt am" #. 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 #: model:ir.cron,name:base_export_async.to_delete_attachment msgid "Delete Generated Exports" -msgstr "" +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 "" +msgstr "Anzeigename" #. module: base_export_async #: code:addons/base_export_async/models/delay_export.py:110 #, python-format msgid "Export {} {}" -msgstr "" +msgstr "Export {} {}" #. module: base_export_async #. openerp-web #: code:addons/base_export_async/static/src/js/data_export.js:47 #, python-format msgid "External ID" -msgstr "" +msgstr "Externe ID" #. module: base_export_async #: model:ir.model.fields,field_description:base_export_async.field_delay_export__id msgid "ID" -msgstr "" +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 "" +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 "" +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 "" +msgstr "Zuletzt aktualisiert am" #. module: base_export_async #. openerp-web #: code:addons/base_export_async/static/src/js/data_export.js:39 #, python-format msgid "Please select fields to export..." -msgstr "" +msgstr "Bitte Felder für den Export auswählen..." #. module: base_export_async #: code:addons/base_export_async/models/delay_export.py:42 #, python-format msgid "The user doesn't have an email address." -msgstr "" +msgstr "Der Benutzer hat keine Email-Adresse." #. module: base_export_async #: model:ir.model.fields,field_description:base_export_async.field_delay_export__user_id msgid "User" -msgstr "" +msgstr "Benutzer" #. module: base_export_async #: code:addons/base_export_async/models/delay_export.py:29 #, 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 #. openerp-web @@ -131,3 +143,4 @@ msgstr "" #, 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." From 8ab5a1773d7a75db3de84839b5b3ef23b1a81dfd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=8E=E4=BC=9F=E6=9D=B0?= <674416404@qq.com> Date: Thu, 25 Jul 2019 15:34:58 +0000 Subject: [PATCH 064/108] Added translation using Weblate (Chinese (Simplified)) --- base_export_async/i18n/zh_Hans.po | 133 ++++++++++++++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 base_export_async/i18n/zh_Hans.po diff --git a/base_export_async/i18n/zh_Hans.po b/base_export_async/i18n/zh_Hans.po new file mode 100644 index 0000000000..74ccdc368f --- /dev/null +++ b/base_export_async/i18n/zh_Hans.po @@ -0,0 +1,133 @@ +# 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" +"Last-Translator: Automatically generated\n" +"Language-Team: none\n" +"Language: zh_Hans\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" + +#. module: base_export_async +#: code:addons/base_export_async/models/delay_export.py:112 +#, 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 "" + +#. module: base_export_async +#. openerp-web +#: code:addons/base_export_async/static/src/xml/base.xml:9 +#, python-format +msgid "(You will receive the export by email)" +msgstr "" + +#. module: base_export_async +#: model:ir.model,name:base_export_async.model_delay_export +msgid "Allow to delay the export" +msgstr "" + +#. module: base_export_async +#. openerp-web +#: code:addons/base_export_async/static/src/xml/base.xml:9 +#, 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:ir.actions.server,name:base_export_async.to_delete_attachment_ir_actions_server +#: model:ir.cron,cron_name:base_export_async.to_delete_attachment +#: model:ir.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 +#: code:addons/base_export_async/models/delay_export.py:110 +#, python-format +msgid "Export {} {}" +msgstr "" + +#. module: base_export_async +#. openerp-web +#: code:addons/base_export_async/static/src/js/data_export.js:47 +#, 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 +#. openerp-web +#: code:addons/base_export_async/static/src/js/data_export.js:39 +#, python-format +msgid "Please select fields to export..." +msgstr "" + +#. module: base_export_async +#: code:addons/base_export_async/models/delay_export.py:42 +#, python-format +msgid "The user doesn't have an email address." +msgstr "" + +#. module: base_export_async +#: model:ir.model.fields,field_description:base_export_async.field_delay_export__user_id +msgid "User" +msgstr "" + +#. module: base_export_async +#: code:addons/base_export_async/models/delay_export.py:29 +#, python-format +msgid "You must set an email address to your user." +msgstr "" + +#. module: base_export_async +#. openerp-web +#: code:addons/base_export_async/static/src/js/data_export.js:91 +#, python-format +msgid "You will receive the export file by email as soon as it is finished." +msgstr "" From 5b0b2a2cce54f8d4d6675bbfc750db469731dee8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=8E=E4=BC=9F=E6=9D=B0?= <674416404@qq.com> Date: Thu, 25 Jul 2019 15:37:12 +0000 Subject: [PATCH 065/108] Translated using Weblate (Chinese (Simplified)) Currently translated at 100.0% (19 of 19 strings) Translation: queue-12.0/queue-12.0-base_export_async Translate-URL: https://translation.odoo-community.org/projects/queue-12-0/queue-12-0-base_export_async/zh_Hans/ --- base_export_async/i18n/zh_Hans.po | 48 +++++++++++++++++++------------ 1 file changed, 29 insertions(+), 19 deletions(-) diff --git a/base_export_async/i18n/zh_Hans.po b/base_export_async/i18n/zh_Hans.po index 74ccdc368f..8d8974305c 100644 --- a/base_export_async/i18n/zh_Hans.po +++ b/base_export_async/i18n/zh_Hans.po @@ -6,13 +6,15 @@ msgid "" msgstr "" "Project-Id-Version: Odoo Server 12.0\n" "Report-Msgid-Bugs-To: \n" -"Last-Translator: Automatically generated\n" +"PO-Revision-Date: 2019-07-25 17:43+0000\n" +"Last-Translator: 黎伟杰 <674416404@qq.com>\n" "Language-Team: none\n" "Language: zh_Hans\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 #: code:addons/base_export_async/models/delay_export.py:112 @@ -26,108 +28,116 @@ msgid "\n" "

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

你的导出可以用 这里.

\n" +"

它将自动删除 {}。

\n" +"

 

\n" +"

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

\n" +" " #. module: base_export_async #. openerp-web #: code:addons/base_export_async/static/src/xml/base.xml:9 #, python-format msgid "(You will receive the export by email)" -msgstr "" +msgstr "(您将通过电子邮件收到导出)" #. module: base_export_async #: model:ir.model,name:base_export_async.model_delay_export msgid "Allow to delay the export" -msgstr "" +msgstr "允许延迟导出" #. module: base_export_async #. openerp-web #: code:addons/base_export_async/static/src/xml/base.xml:9 #, python-format msgid "Asynchronous export" -msgstr "" +msgstr "异步导出" #. module: base_export_async #: model:ir.model.fields,field_description:base_export_async.field_delay_export__create_uid msgid "Created by" -msgstr "" +msgstr "创建者" #. module: base_export_async #: model:ir.model.fields,field_description:base_export_async.field_delay_export__create_date msgid "Created on" -msgstr "" +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 #: model:ir.cron,name:base_export_async.to_delete_attachment msgid "Delete Generated Exports" -msgstr "" +msgstr "删除生成的导出" #. module: base_export_async #: model:ir.model.fields,field_description:base_export_async.field_delay_export__display_name msgid "Display Name" -msgstr "" +msgstr "显示名称" #. module: base_export_async #: code:addons/base_export_async/models/delay_export.py:110 #, python-format msgid "Export {} {}" -msgstr "" +msgstr "导出{} {}" #. module: base_export_async #. openerp-web #: code:addons/base_export_async/static/src/js/data_export.js:47 #, python-format msgid "External ID" -msgstr "" +msgstr "外部ID" #. module: base_export_async #: model:ir.model.fields,field_description:base_export_async.field_delay_export__id msgid "ID" -msgstr "" +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 "" +msgstr "最后修改时间" #. module: base_export_async #: model:ir.model.fields,field_description:base_export_async.field_delay_export__write_uid msgid "Last Updated by" -msgstr "" +msgstr "最后更新者" #. module: base_export_async #: model:ir.model.fields,field_description:base_export_async.field_delay_export__write_date msgid "Last Updated on" -msgstr "" +msgstr "最后更新时间" #. module: base_export_async #. openerp-web #: code:addons/base_export_async/static/src/js/data_export.js:39 #, python-format msgid "Please select fields to export..." -msgstr "" +msgstr "请选择要导出的字段..." #. module: base_export_async #: code:addons/base_export_async/models/delay_export.py:42 #, python-format msgid "The user doesn't have an email address." -msgstr "" +msgstr "用户没有电子邮件地址。" #. module: base_export_async #: model:ir.model.fields,field_description:base_export_async.field_delay_export__user_id msgid "User" -msgstr "" +msgstr "用户" #. module: base_export_async #: code:addons/base_export_async/models/delay_export.py:29 #, python-format msgid "You must set an email address to your user." -msgstr "" +msgstr "您必须为您的用户设置电子邮件地址。" #. module: base_export_async #. openerp-web #: code:addons/base_export_async/static/src/js/data_export.js:91 #, python-format msgid "You will receive the export file by email as soon as it is finished." -msgstr "" +msgstr "完成后,您将通过电子邮件收到导出文件。" From 0af1b5c7108b1979d16809f7754c084947bf49dc Mon Sep 17 00:00:00 2001 From: OCA-git-bot Date: Mon, 29 Jul 2019 03:31:44 +0000 Subject: [PATCH 066/108] [UPD] README.rst --- base_export_async/static/description/index.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/base_export_async/static/description/index.html b/base_export_async/static/description/index.html index 6a727127d5..7620c7e958 100644 --- a/base_export_async/static/description/index.html +++ b/base_export_async/static/description/index.html @@ -3,7 +3,7 @@ - + Base Export Async -
-

Base Export Async

+
+ + +Odoo Community Association + +
+

Base Export Async

-

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

+

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

@@ -385,7 +390,7 @@

Base Export Async

-

Usage

+

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.

@@ -393,7 +398,7 @@

Usage

to the user who execute the export.

-

Bug Tracker

+

Bug Tracker

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

Bug Tracker

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

-

Credits

+

Credits

-

Authors

+

Authors

  • ACSONE SA/NV
-

Contributors

+

Contributors

  • Arnaud Pineux (ACSONE SA/NV) authored the initial prototype.
  • Guewen Baconnier (Camptocamp)
-

Maintainers

+

Maintainers

This module is maintained by the OCA.

Odoo Community Association @@ -429,5 +434,6 @@

Maintainers

+
From 8fcdd914ef72c3253731ad7c1cd55c4b209ff2cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Mangin?= Date: Wed, 11 Feb 2026 11:01:09 +0100 Subject: [PATCH 106/108] [IMP] base_export_async: pre-commit auto fixes --- base_export_async/README.rst | 38 ++++++++--------- base_export_async/models/delay_export.py | 2 +- base_export_async/pyproject.toml | 3 ++ base_export_async/readme/CONTRIBUTORS.md | 2 + base_export_async/readme/CONTRIBUTORS.rst | 2 - .../{DESCRIPTION.rst => DESCRIPTION.md} | 3 +- base_export_async/readme/USAGE.md | 5 +++ base_export_async/readme/USAGE.rst | 6 --- .../static/description/index.html | 42 ++++++++----------- 9 files changed, 48 insertions(+), 55 deletions(-) create mode 100644 base_export_async/pyproject.toml create mode 100644 base_export_async/readme/CONTRIBUTORS.md delete mode 100644 base_export_async/readme/CONTRIBUTORS.rst rename base_export_async/readme/{DESCRIPTION.rst => DESCRIPTION.md} (55%) create mode 100644 base_export_async/readme/USAGE.md delete mode 100644 base_export_async/readme/USAGE.rst diff --git a/base_export_async/README.rst b/base_export_async/README.rst index 3cf70f08d2..3c10ffae7c 100644 --- a/base_export_async/README.rst +++ b/base_export_async/README.rst @@ -1,7 +1,3 @@ -.. image:: https://odoo-community.org/readme-banner-image - :target: https://odoo-community.org/get-involved?utm_source=readme - :alt: Odoo Community Association - ================= Base Export Async ================= @@ -17,22 +13,23 @@ Base Export Async .. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png :target: https://odoo-community.org/page/development-status :alt: Beta -.. |badge2| image:: https://img.shields.io/badge/license-AGPL--3-blue.png +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html :alt: License: AGPL-3 .. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fqueue-lightgray.png?logo=github - :target: https://github.com/OCA/queue/tree/16.0/base_export_async + :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-16-0/queue-16-0-base_export_async + :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=16.0 + :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. +Standard Export can be delayed in asynchronous jobs executed in the +background and then send by email to the user. **Table of contents** @@ -42,12 +39,11 @@ Standard Export can be delayed in asynchronous jobs executed in the background a 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 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. +The .csv or .xls file generated by the export will be sent by email to +the user who execute the export. Bug Tracker =========== @@ -55,7 +51,7 @@ 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 `_. +`feedback `_. Do not contact contributors directly about support or help with technical issues. @@ -63,18 +59,18 @@ Credits ======= Authors -~~~~~~~ +------- * ACSONE SA/NV Contributors -~~~~~~~~~~~~ +------------ -* Arnaud Pineux (ACSONE SA/NV) authored the initial prototype. -* Guewen Baconnier (Camptocamp) +- Arnaud Pineux (ACSONE SA/NV) authored the initial prototype. +- Guewen Baconnier (Camptocamp) Maintainers -~~~~~~~~~~~ +----------- This module is maintained by the OCA. @@ -86,6 +82,6 @@ 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. +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/models/delay_export.py b/base_export_async/models/delay_export.py index ee0ae25ab0..00ea791c11 100644 --- a/base_export_async/models/delay_export.py +++ b/base_export_async/models/delay_export.py @@ -88,7 +88,7 @@ def export(self, params): export_record = self.sudo().create({"user_ids": [(6, 0, users.ids)]}) - name = "{}.{}".format(model_name, export_format) + name = f"{model_name}.{export_format}" attachment = ( self.env["ir.attachment"] .sudo() 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..ac7b3fd83b --- /dev/null +++ b/base_export_async/readme/CONTRIBUTORS.md @@ -0,0 +1,2 @@ +- Arnaud Pineux (ACSONE SA/NV) authored the initial prototype. +- Guewen Baconnier (Camptocamp) diff --git a/base_export_async/readme/CONTRIBUTORS.rst b/base_export_async/readme/CONTRIBUTORS.rst deleted file mode 100644 index b1a90f9127..0000000000 --- a/base_export_async/readme/CONTRIBUTORS.rst +++ /dev/null @@ -1,2 +0,0 @@ -* Arnaud Pineux (ACSONE SA/NV) authored the initial prototype. -* Guewen Baconnier (Camptocamp) diff --git a/base_export_async/readme/DESCRIPTION.rst b/base_export_async/readme/DESCRIPTION.md similarity index 55% rename from base_export_async/readme/DESCRIPTION.rst rename to base_export_async/readme/DESCRIPTION.md index 91e212d4b3..28fef1b090 100644 --- a/base_export_async/readme/DESCRIPTION.rst +++ b/base_export_async/readme/DESCRIPTION.md @@ -1 +1,2 @@ -Standard Export can be delayed in asynchronous jobs executed in the background and then send by email to the user. +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..fe45f0b33a --- /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/readme/USAGE.rst b/base_export_async/readme/USAGE.rst deleted file mode 100644 index 74693b08d0..0000000000 --- a/base_export_async/readme/USAGE.rst +++ /dev/null @@ -1,6 +0,0 @@ -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/static/description/index.html b/base_export_async/static/description/index.html index 147c0c8c2b..6a4eb6562b 100644 --- a/base_export_async/static/description/index.html +++ b/base_export_async/static/description/index.html @@ -3,7 +3,7 @@ -README.rst +Base Export Async -
+
+

Base Export Async

- - -Odoo Community Association - -
-

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.

+

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

    @@ -390,38 +386,37 @@

    Base Export Async

-

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.

+

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

+

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.

+feedback.

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

-

Credits

+

Credits

-

Authors

+

Authors

  • ACSONE SA/NV
-

Contributors

+

Contributors

  • Arnaud Pineux (ACSONE SA/NV) authored the initial prototype.
  • Guewen Baconnier (Camptocamp)
-

Maintainers

+

Maintainers

This module is maintained by the OCA.

Odoo Community Association @@ -429,11 +424,10 @@

Maintainers

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.

+

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.

-
From 7f53ab79c4e06a8218e1db981da1bf434b89caae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Mangin?= Date: Wed, 18 Feb 2026 15:55:10 +0100 Subject: [PATCH 107/108] [MIG] base_export_async: Migration to 17.0 --- base_export_async/README.rst | 9 ++ base_export_async/__manifest__.py | 2 +- base_export_async/data/config_parameter.xml | 8 +- base_export_async/data/cron.xml | 18 +-- base_export_async/data/mail_template.xml | 30 ++--- base_export_async/export.py | 66 ++++++++++ base_export_async/models/delay_export.py | 6 +- base_export_async/readme/CONTRIBUTORS.md | 1 + base_export_async/readme/CREDITS.md | 3 + base_export_async/readme/DESCRIPTION.md | 4 +- base_export_async/readme/USAGE.md | 8 +- base_export_async/security/ir_rule.xml | 20 +-- .../static/description/index.html | 14 +- .../static/src/js/data_export.esm.js | 5 +- .../static/src/js/list_controller.esm.js | 24 ++-- base_export_async/static/src/xml/base.xml | 4 +- base_export_async/tests/__init__.py | 1 + .../tests/test_base_export_async.py | 8 +- base_export_async/tests/test_export.py | 121 ++++++++++++++++++ 19 files changed, 278 insertions(+), 74 deletions(-) create mode 100644 base_export_async/export.py create mode 100644 base_export_async/readme/CREDITS.md create mode 100644 base_export_async/tests/test_export.py diff --git a/base_export_async/README.rst b/base_export_async/README.rst index 3c10ffae7c..31bbf2202c 100644 --- a/base_export_async/README.rst +++ b/base_export_async/README.rst @@ -68,6 +68,15 @@ 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 ----------- diff --git a/base_export_async/__manifest__.py b/base_export_async/__manifest__.py index f43ed29632..471722ca1a 100644 --- a/base_export_async/__manifest__.py +++ b/base_export_async/__manifest__.py @@ -4,7 +4,7 @@ { "name": "Base Export Async", "summary": "Asynchronous export with job queue", - "version": "16.0.1.2.0", + "version": "17.0.1.0.0", "license": "AGPL-3", "author": "ACSONE SA/NV, Odoo Community Association (OCA)", "website": "https://github.com/OCA/queue", diff --git a/base_export_async/data/config_parameter.xml b/base_export_async/data/config_parameter.xml index 38ff75fd6e..90f4fb316b 100644 --- a/base_export_async/data/config_parameter.xml +++ b/base_export_async/data/config_parameter.xml @@ -1,7 +1,7 @@ - - attachment.ttl - 7 - + + attachment.ttl + 7 + diff --git a/base_export_async/data/cron.xml b/base_export_async/data/cron.xml index 7e12c92a02..1bd9b7dacd 100644 --- a/base_export_async/data/cron.xml +++ b/base_export_async/data/cron.xml @@ -1,12 +1,12 @@ - - Delete Generated Exports - - code - model.cron_delete() - 1 - days - -1 - + + 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 index 18aaf926d8..b39c012de1 100644 --- a/base_export_async/data/mail_template.xml +++ b/base_export_async/data/mail_template.xml @@ -1,26 +1,24 @@ - - Delay Export - + Delay Export + Export {{ object.model_description }} {{ datetime.date.today() }} - - - -

Your export is available + + +

Your export is available here.

-

It will be automatically deleted the .

-
-

- 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/models/delay_export.py b/base_export_async/models/delay_export.py index 00ea791c11..3cd936376f 100644 --- a/base_export_async/models/delay_export.py +++ b/base_export_async/models/delay_export.py @@ -10,7 +10,9 @@ from odoo import _, api, fields, models from odoo.exceptions import UserError -from odoo.addons.web.controllers.export import CSVExport, ExcelExport +from odoo.addons.web.controllers.export import CSVExport + +from ..export import ExcelExport class DelayExport(models.Model): @@ -61,7 +63,7 @@ def _get_file_content(self, params): csv = CSVExport() return csv.from_data(columns_headers, import_data) else: - xls = ExcelExport() + xls = ExcelExport(self.env) return xls.from_data(columns_headers, import_data) @api.model diff --git a/base_export_async/readme/CONTRIBUTORS.md b/base_export_async/readme/CONTRIBUTORS.md index ac7b3fd83b..0c1be31012 100644 --- a/base_export_async/readme/CONTRIBUTORS.md +++ b/base_export_async/readme/CONTRIBUTORS.md @@ -1,2 +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 index 28fef1b090..4802cbbdaa 100644 --- a/base_export_async/readme/DESCRIPTION.md +++ b/base_export_async/readme/DESCRIPTION.md @@ -1,2 +1,2 @@ -Standard Export can be delayed in asynchronous jobs executed in the -background and then send by email to the user. +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 index fe45f0b33a..ffa173d031 100644 --- a/base_export_async/readme/USAGE.md +++ b/base_export_async/readme/USAGE.md @@ -1,5 +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 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. +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_rule.xml b/base_export_async/security/ir_rule.xml index d7934f09ae..0d748156f3 100644 --- a/base_export_async/security/ir_rule.xml +++ b/base_export_async/security/ir_rule.xml @@ -1,13 +1,13 @@ - - Only user can read delay.export - - - - - - - [('user_ids', 'in', user.id)] - + + Only user can read delay.export + + + + + + + [('user_ids', 'in', user.id)] + diff --git a/base_export_async/static/description/index.html b/base_export_async/static/description/index.html index 6a4eb6562b..ce84c48fa4 100644 --- a/base_export_async/static/description/index.html +++ b/base_export_async/static/description/index.html @@ -380,7 +380,8 @@

Base Export Async

  • Credits
  • @@ -413,10 +414,19 @@

    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

    +

    Maintainers

    This module is maintained by the OCA.

    Odoo Community Association diff --git a/base_export_async/static/src/js/data_export.esm.js b/base_export_async/static/src/js/data_export.esm.js index 9bed26fedc..fe4e489ff7 100644 --- a/base_export_async/static/src/js/data_export.esm.js +++ b/base_export_async/static/src/js/data_export.esm.js @@ -1,11 +1,10 @@ /** @odoo-module */ - import {ExportDataDialog} from "@web/views/view_dialogs/export_data_dialog"; import {patch} from "@web/core/utils/patch"; -patch(ExportDataDialog.prototype, "base_export_async", { +patch(ExportDataDialog.prototype, { setup() { - this._super(); + super.setup(); this.state.async = false; }, onToggleExportAsync(value) { diff --git a/base_export_async/static/src/js/list_controller.esm.js b/base_export_async/static/src/js/list_controller.esm.js index bc8925b4c9..982eb389ad 100644 --- a/base_export_async/static/src/js/list_controller.esm.js +++ b/base_export_async/static/src/js/list_controller.esm.js @@ -1,14 +1,9 @@ /** @odoo-module **/ - -import {blockUI, unblockUI} from "web.framework"; - -import Dialog from "web.Dialog"; import {ListController} from "@web/views/list/list_controller"; -import {_t} from "web.core"; +import {_t} from "@web/core/l10n/translation"; import {download} from "@web/core/network/download"; import {patch} from "@web/core/utils/patch"; - -patch(ListController.prototype, "base_export_async", { +patch(ListController.prototype, { async downloadExport(fields, import_compat, format, async = false) { let ids = false; if (!this.isDomainSelected) { @@ -28,7 +23,7 @@ patch(ListController.prototype, "base_export_async", { /* Call the delay export if Async is checked */ - blockUI(); + this.env.services.ui.block(); const args = [ { data: JSON.stringify({ @@ -44,13 +39,16 @@ patch(ListController.prototype, "base_export_async", { }, ]; const orm = this.env.services.orm; - orm.call("delay.export", "delay_export", args).then(function () { - unblockUI(); - Dialog.alert( - this, + 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 { diff --git a/base_export_async/static/src/xml/base.xml b/base_export_async/static/src/xml/base.xml index c6d3bc22a9..c0b92ee66a 100644 --- a/base_export_async/static/src/xml/base.xml +++ b/base_export_async/static/src/xml/base.xml @@ -7,7 +7,7 @@ t-inherit-mode="extension" > - 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 index ab60bd836f..f65982a510 100644 --- a/base_export_async/tests/__init__.py +++ b/base_export_async/tests/__init__.py @@ -2,3 +2,4 @@ # 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 index 4a2771d0c1..26e366eec0 100644 --- a/base_export_async/tests/test_base_export_async.py +++ b/base_export_async/tests/test_base_export_async.py @@ -2,7 +2,6 @@ # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). import json -from unittest import mock from unittest.mock import patch import freezegun @@ -11,7 +10,7 @@ import odoo.tests.common as common from odoo import fields -from odoo.addons.web.controllers.export import ExcelExport +from ..export import ExcelExport data_csv = { "data": """{"format": "csv", "model": "res.partner", @@ -47,10 +46,6 @@ def setUp(self): super().setUp() self.delay_export_obj = self.env["delay.export"] self.job_obj = self.env["queue.job"] - with patch("odoo.http._request_stack") as mock_request_stack: - mock_request = mock.Mock(env=self.env) - mock_request_stack.push(mock_request) - self.addCleanup(mock_request_stack.pop) def test_delay_export(self): """Check that the call create a new JOB""" @@ -81,6 +76,7 @@ def test_export_xls(self): 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""" 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) From d937023632d64884b9a2b7bbdc13ee0a02bb12f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Mangin?= Date: Wed, 11 Feb 2026 20:41:48 +0100 Subject: [PATCH 108/108] [17.0][IMP] export_async_schedule: improve module ergonomy and functionnality What has been added: - New way to group exports in a single/multiple emails - Views ergonomy redux (increase readability and add chatter history) - light refactoring (addition of a mixin) --- export_async_schedule/README.rst | 77 +++++-- export_async_schedule/__manifest__.py | 10 +- export_async_schedule/data/ir_cron.xml | 13 ++ export_async_schedule/data/mail_template.xml | 28 +++ export_async_schedule/models/__init__.py | 2 + .../models/export_async_schedule.py | 171 ++++++++------ .../models/export_async_schedule_group.py | 177 +++++++++++++++ .../models/export_async_schedule_mixin.py | 72 ++++++ export_async_schedule/readme/CONFIGURE.md | 26 ++- export_async_schedule/readme/CONTRIBUTORS.md | 2 +- export_async_schedule/readme/DESCRIPTION.md | 7 +- export_async_schedule/readme/USAGE.md | 26 ++- .../security/ir.model.access.csv | 1 + .../static/description/index.html | 119 ++++++---- export_async_schedule/tests/__init__.py | 4 + export_async_schedule/tests/common.py | 79 +++++++ .../tests/test_export_async_schedule.py | 20 +- .../tests/test_export_async_schedule_group.py | 167 ++++++++++++++ ...st_export_async_schedule_group_relation.py | 82 +++++++ .../export_async_schedule_group_views.xml | 208 ++++++++++++++++++ .../views/export_async_schedule_views.xml | 184 +++++++++++----- test-requirements.txt | 1 + 22 files changed, 1262 insertions(+), 214 deletions(-) create mode 100644 export_async_schedule/data/mail_template.xml create mode 100644 export_async_schedule/models/export_async_schedule_group.py create mode 100644 export_async_schedule/models/export_async_schedule_mixin.py create mode 100644 export_async_schedule/tests/common.py create mode 100644 export_async_schedule/tests/test_export_async_schedule_group.py create mode 100644 export_async_schedule/tests/test_export_async_schedule_group_relation.py create mode 100644 export_async_schedule/views/export_async_schedule_group_views.xml diff --git a/export_async_schedule/README.rst b/export_async_schedule/README.rst index c2adaa34f3..05863558a1 100644 --- a/export_async_schedule/README.rst +++ b/export_async_schedule/README.rst @@ -28,9 +28,11 @@ Scheduled Asynchronous Export |badge1| |badge2| |badge3| |badge4| |badge5| -Add a new Automation feature: Scheduled Exports. Based on an export list -and a domain, an email is sent every X hours/days/weeks/months to a -selection of users. +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** @@ -40,32 +42,62 @@ selection of users. Configuration ============= -The configuration of a scheduled export is based on export lists. +Creating an Export List +----------------------- -To create 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 -- open the list view of the model to export -- select at least one record, and open "Action → Export" -- select the fields to export and save using "Save fields list". +Configuring a Scheduled Export +------------------------------ -To configure a scheduled export: +Navigate to **Settings → Technical → Automation → Scheduled Exports** +and create a new record with: -- open "Settings → Technical → Automation → Scheduled Exports" -- create a scheduled export by filling the form +- 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 Scheduled Action named "Send Scheduled Exports" checks every hour if -Scheduled Exports have to be executed. +A cron job runs hourly to execute scheduled exports and groups. Usage ===== -When the configuration of a Scheduled Export is done, their execution is -automatic. +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 -Users will receive an email containing a link to download the exported -file at the specified frequency. The attachments stay in the database -for 7 days by default (it can be changed with the system parameter -``attachment.ttl``. +**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 ====================== @@ -132,10 +164,13 @@ 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 `maintainer `__: +Current `maintainers `__: -|maintainer-guewen| +|maintainer-guewen| |maintainer-stephanemangin| This module is part of the `OCA/queue `_ project on GitHub. diff --git a/export_async_schedule/__manifest__.py b/export_async_schedule/__manifest__.py index c14ba8a5c3..2c06431b75 100644 --- a/export_async_schedule/__manifest__.py +++ b/export_async_schedule/__manifest__.py @@ -1,10 +1,11 @@ # 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.0.0", + "version": "17.0.1.1.0", "author": "Camptocamp, ACSONE SA/NV, Odoo Community Association (OCA)", "license": "AGPL-3", "website": "https://github.com/OCA/queue", @@ -12,13 +13,16 @@ "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", - "security/ir.model.access.csv", ], "installable": True, - "maintainers": ["guewen"], + "maintainers": ["guewen", "stephanemangin"], "development_status": "Beta", } diff --git a/export_async_schedule/data/ir_cron.xml b/export_async_schedule/data/ir_cron.xml index edd4338e61..89797b4aa4 100644 --- a/export_async_schedule/data/ir_cron.xml +++ b/export_async_schedule/data/ir_cron.xml @@ -14,4 +14,17 @@ name="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/models/__init__.py b/export_async_schedule/models/__init__.py index cb2b01369e..988a5f69e1 100644 --- a/export_async_schedule/models/__init__.py +++ b/export_async_schedule/models/__init__.py @@ -1,3 +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 index a99030c6eb..3cac643961 100644 --- a/export_async_schedule/models/export_async_schedule.py +++ b/export_async_schedule/models/export_async_schedule.py @@ -1,97 +1,128 @@ # 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 dateutil.relativedelta import relativedelta - from odoo import api, fields, models from odoo.tools.safe_eval import safe_eval -from odoo.addons.base.models.res_partner import _lang_get - 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" - active = fields.Boolean(default=True) + display_name = fields.Char(compute="_compute_display_name", store=True, copy=False) - # Export configuration - model_id = fields.Many2one( - comodel_name="ir.model", required=True, ondelete="cascade" + # Override mixin fields to inherit from group when part of one + active = fields.Boolean( + compute="_compute_from_group", + store=True, + readonly=False, + default=True, ) - model_name = fields.Char(related="model_id.model", string="Model Name") user_ids = fields.Many2many( - string="Recipients", comodel_name="res.users", required=True - ) - domain = fields.Char(string="Export Domain", default=[]) - ir_export_id = fields.Many2one( - comodel_name="ir.exports", - string="Export List", + relation="export_async_schedule_res_users_rel", + compute="_compute_from_group", + store=True, + readonly=False, + tracking=True, required=True, - domain="[('resource', '=', model_name)]", - ondelete="restrict", ) - export_format = fields.Selection( - selection=[("csv", "CSV"), ("excel", "Excel")], - default="csv", + next_execution = fields.Datetime( + compute="_compute_from_group", + store=True, + readonly=False, + default=fields.Datetime.now, required=True, + tracking=True, + copy=False, ) - import_compat = fields.Boolean(string="Import-compatible Export") - lang = fields.Selection( - _lang_get, - string="Language", - default=lambda self: self.env.lang, - help="Exports will be translated in this language.", + interval = fields.Integer( + compute="_compute_from_group", + store=True, + readonly=False, + default=1, + required=True, + tracking=True, ) - - # Scheduling - next_execution = fields.Datetime(default=fields.Datetime.now, required=True) - interval = fields.Integer(default=1, required=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)"), ], - string="Unit", 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, ) - end_of_month = fields.Boolean() - @api.depends("model_id", "ir_export_id") + @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}" - def run_schedule(self): - for record in self: - if record.next_execution > datetime.now(): - continue - record.action_export() - record.next_execution = record._compute_next_date() - - def _compute_next_date(self): - next_execution = self.next_execution - if next_execution < datetime.now(): - next_execution = datetime.now() - args = {self.interval_unit: self.interval} - if self.interval_unit == "months" and self.end_of_month: - # dateutil knows how to deal with variable days of months, - # it will put the latest possible day - args.update({"day": 31, "hour": 23, "minute": 59, "second": 59}) - return next_execution + relativedelta(**args) - - @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 - ) - @api.model def _get_fields_with_labels(self, model_name, export_fields): self_fields = self.env[model_name]._fields @@ -129,7 +160,7 @@ def _prepare_export_params(self): else: export_fields = self._get_fields_with_labels( self.model_name, - [export_field for export_field in export_fields], + list(export_fields), ) export_format = self.export_format == "excel" and "xlsx" or self.export_format return { @@ -143,8 +174,22 @@ def _prepare_export_params(self): "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): - for record in self: - record = record.with_context(lang=record.lang) - params = record._prepare_export_params() - record.env["delay.export"].with_delay().export(params) + """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/readme/CONFIGURE.md b/export_async_schedule/readme/CONFIGURE.md index 278b5b78a8..f255abc678 100644 --- a/export_async_schedule/readme/CONFIGURE.md +++ b/export_async_schedule/readme/CONFIGURE.md @@ -1,15 +1,21 @@ -The configuration of a scheduled export is based on export lists. +## Creating an Export List -To create 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 -- open the list view of the model to export -- select at least one record, and open "Action → Export" -- select the fields to export and save using "Save fields list". +## Configuring a Scheduled Export -To configure a scheduled export: +Navigate to **Settings → Technical → Automation → Scheduled Exports** and create a new +record with: -- open "Settings → Technical → Automation → Scheduled Exports" -- create a scheduled export by filling the form +- 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 Scheduled Action named "Send Scheduled Exports" checks every hour if Scheduled Exports -have to be executed. +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 index 60fc06800f..010bd7e813 100644 --- a/export_async_schedule/readme/CONTRIBUTORS.md +++ b/export_async_schedule/readme/CONTRIBUTORS.md @@ -1,4 +1,4 @@ - Guewen Baconnier (Camptocamp) - [Komit](https://komit-consulting.com): - Cuong Nguyen Mtm \ -- Stéphane Mangin (ACSONE SA/NV) \ No newline at end of file +- Stéphane Mangin (ACSONE SA/NV) diff --git a/export_async_schedule/readme/DESCRIPTION.md b/export_async_schedule/readme/DESCRIPTION.md index ac4ef91777..56f5b32d0a 100644 --- a/export_async_schedule/readme/DESCRIPTION.md +++ b/export_async_schedule/readme/DESCRIPTION.md @@ -1,2 +1,5 @@ -Add a new Automation feature: Scheduled Exports. Based on an export list and a domain, -an email is sent every X hours/days/weeks/months to a selection of users. +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/USAGE.md b/export_async_schedule/readme/USAGE.md index aa2a06e205..33cb008e6d 100644 --- a/export_async_schedule/readme/USAGE.md +++ b/export_async_schedule/readme/USAGE.md @@ -1,5 +1,23 @@ -When the configuration of a Scheduled Export is done, their execution is automatic. +When a scheduled export is configured, its execution is automatic based on the +schedule. -Users will receive an email containing a link to download the exported file at the -specified frequency. The attachments stay in the database for 7 days by default (it can -be changed with the system parameter `attachment.ttl`. +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 index 4e3a697a18..126dc00fe2 100644 --- a/export_async_schedule/security/ir.model.access.csv +++ b/export_async_schedule/security/ir.model.access.csv @@ -1,2 +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/index.html b/export_async_schedule/static/description/index.html index 5e366aabf6..9ea67169b4 100644 --- a/export_async_schedule/static/description/index.html +++ b/export_async_schedule/static/description/index.html @@ -370,60 +370,99 @@

    Scheduled Asynchronous Export

    !! source digest: sha256:170e4045b865c79a1eacbba69044d718814a3965bc3ad044ad2e4236f503153f !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -->

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

    -

    Add a new Automation feature: Scheduled Exports. Based on an export list -and a domain, an email is sent every X hours/days/weeks/months to a -selection of users.

    +

    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

    -

    The configuration of a scheduled export is based on export lists.

    -

    To create an export list:

    -
      -
    • open the list view of the model to export
    • -
    • select at least one record, and open “Action → Export”
    • -
    • select the fields to export and save using “Save fields list”.
    • -
    -

    To configure a scheduled export:

    +
    +

    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:

      -
    • open “Settings → Technical → Automation → Scheduled Exports”
    • -
    • create a scheduled export by filling the form
    • +
    • 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 Scheduled Action named “Send Scheduled Exports” checks every hour if -Scheduled Exports have to be executed.

    +

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

    +
    -

    Usage

    -

    When the configuration of a Scheduled Export is done, their execution is -automatic.

    -

    Users will receive an email containing a link to download the exported -file at the specified frequency. The attachments stay in the database -for 7 days by default (it can be changed with the system parameter -attachment.ttl.

    +

    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

    +

    Known issues / Roadmap

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

    Bug Tracker

    +

    Bug Tracker

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

    Bug Tracker

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

    -

    Credits

    +

    Credits

    -

    Authors

    +

    Authors

    • Camptocamp
    • ACSONE SA/NV
    -

    Contributors

    +

    Contributors

    -

    Other credits

    +

    Other credits

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

    -

    Maintainers

    +

    Maintainers

    This module is maintained by the OCA.

    Odoo Community Association @@ -473,8 +512,8 @@

    Maintainers

    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 maintainer:

    -

    guewen

    +

    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 index 69f905f9cf..e0941ed7d4 100644 --- a/export_async_schedule/tests/__init__.py +++ b/export_async_schedule/tests/__init__.py @@ -1 +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 index ed44e9761e..f60b85ea96 100644 --- a/export_async_schedule/tests/test_export_async_schedule.py +++ b/export_async_schedule/tests/test_export_async_schedule.py @@ -46,6 +46,7 @@ def _create_schedule(cls): ) def test_fields_with_labels(self): + """Test export fields are converted to display labels.""" export_fields = [ "display_name", "email", @@ -69,12 +70,11 @@ def test_fields_with_labels(self): 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)], - # in 'import compatible' mode, the header (label) - # is equal to the field name "fields": [ {"label": "display_name", "name": "display_name"}, {"label": "email", "name": "email"}, @@ -90,13 +90,12 @@ def test_prepare_export_params_compatible(self): 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)], - # in 'import compatible' mode, the header (label) - # is equal to the field name "fields": [ {"label": "Display Name", "name": "display_name"}, {"label": "Email", "name": "email"}, @@ -112,13 +111,13 @@ def test_prepare_export_params_friendly(self): 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)) @@ -143,29 +142,26 @@ def assert_next_schedule(interval, unit, expected): ) 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() - # nothing happened because we have not reached the next execution 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() - # it has been executed and the date changed to the next execution 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() - # check 'with_delay()' part: self.assertEqual(delayable_cls.call_count, 1) - # arguments passed in 'with_delay()' delay_args, __ = delayable_cls.call_args self.assertEqual((self.env["delay.export"],), delay_args) - # check what's passed to the job method 'export' self.assertEqual(delayable.export.call_count, 1) delay_args, delay_kwargs = delayable.export.call_args expected_params = ( @@ -187,3 +183,7 @@ def test_delay_job(self): ) 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 index 3fffd23940..442dba55ef 100644 --- a/export_async_schedule/views/export_async_schedule_views.xml +++ b/export_async_schedule/views/export_async_schedule_views.xml @@ -4,12 +4,22 @@ export.async.schedule.tree export.async.schedule - - - - - - + + + + + + + + + + + + @@ -20,54 +30,88 @@
    -
    -
    -
    - - - - - - - - - - + string="Test Export Now" + class="btn-primary" + invisible="group_id" + icon="fa-play-circle" + /> + + + +
    + +
    +
    +

    + +

    +
    + Part of: + +
    +
    + + + + + + + + + - - - - - + + + + + + + - +
    +
    + + + +
    @@ -77,30 +121,39 @@ export.async.schedule + + - - + + + @@ -111,7 +164,18 @@ Scheduled Exports ir.actions.act_window export.async.schedule - Schedule Exports to send by email + 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. +

    +