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..ffe49451be --- /dev/null +++ b/export_async_schedule/models/export_async_schedule_group.py @@ -0,0 +1,171 @@ +# 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() + return self.env["delay.export"]._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..3549b7e99b --- /dev/null +++ b/export_async_schedule/tests/test_export_async_schedule_group.py @@ -0,0 +1,150 @@ +# 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") 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. +

+