diff --git a/export_async_schedule/README.rst b/export_async_schedule/README.rst new file mode 100644 index 0000000000..8f5939f957 --- /dev/null +++ b/export_async_schedule/README.rst @@ -0,0 +1,157 @@ +============================= +Scheduled Asynchronous Export +============================= + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:170e4045b865c79a1eacbba69044d718814a3965bc3ad044ad2e4236f503153f + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fqueue-lightgray.png?logo=github + :target: https://github.com/OCA/queue/tree/17.0/export_async_schedule + :alt: OCA/queue +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/queue-17-0/queue-17-0-export_async_schedule + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/queue&target_branch=17.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +Schedule automated exports sent by email at regular intervals (hours, +days, weeks, months) to selected users. + +**Export Groups** allow bundling multiple exports into a single email +with multiple attachments - useful for consolidated reporting. + +**Table of contents** + +.. contents:: + :local: + +Configuration +============= + +Creating an Export List +----------------------- + +1. Open any model's list view (e.g., Partners, Sales Orders) +2. Select at least one record +3. Click **Action → Export** +4. Select fields to export +5. Save the field list with a meaningful name + +Configuring a Scheduled Export +------------------------------ + +Navigate to **Settings → Technical → Automation → Scheduled Exports** +and create a new record with: + +- Model and export list (created above) +- Export domain (filter records to export) +- Export format (CSV or Excel) +- Recipients (users who will receive the export) +- Schedule (frequency and next execution date) +- Language (for field labels in the export) + +A cron job runs hourly to execute scheduled exports and groups. + +Usage +===== + +When a scheduled export is configured, its execution is automatic based +on the schedule. + +Users receive an email with a download link for the exported file. +Attachments remain in the database for 7 days by default (configurable +via the ``attachment.ttl`` system parameter). + +Export Groups +------------- + +Group multiple exports into a single email: + +1. Navigate to **Settings > Technical > Automation > Grouped Scheduled + Exports** +2. Create a group specifying: + + - Recipients (users with email addresses) + - Email template + - Exports to include (select from standalone exports or create new + ones) + - Schedule (interval, next execution, language) + +3. Use **Send Test Email Now** to verify configuration + +**Important**: When an export is added to a group, it automatically +inherits the group's scheduling parameters (recipients, interval, +language, etc.). Individual exports within a group cannot be executed +separately - only the group's cron job triggers their execution as a +batch. + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* Camptocamp +* ACSONE SA/NV + +Contributors +------------ + +- Guewen Baconnier (Camptocamp) +- `Komit `__: + + - Cuong Nguyen Mtm + +- Stéphane Mangin (ACSONE SA/NV) + +Maintainers +----------- + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +.. |maintainer-guewen| image:: https://github.com/guewen.png?size=40px + :target: https://github.com/guewen + :alt: guewen +.. |maintainer-stephanemangin| image:: https://github.com/stephanemangin.png?size=40px + :target: https://github.com/stephanemangin + :alt: stephanemangin + +Current `maintainers `__: + +|maintainer-guewen| |maintainer-stephanemangin| + +This module is part of the `OCA/queue `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/export_async_schedule/__init__.py b/export_async_schedule/__init__.py new file mode 100644 index 0000000000..31660d6a96 --- /dev/null +++ b/export_async_schedule/__init__.py @@ -0,0 +1,3 @@ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from . import models diff --git a/export_async_schedule/__manifest__.py b/export_async_schedule/__manifest__.py new file mode 100644 index 0000000000..2c06431b75 --- /dev/null +++ b/export_async_schedule/__manifest__.py @@ -0,0 +1,28 @@ +# Copyright 2019 Camptocamp +# Copyright 2026 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +{ + "name": "Scheduled Asynchronous Export", + "summary": "Generate and send exports by emails on a schedule", + "version": "17.0.1.1.0", + "author": "Camptocamp, ACSONE SA/NV, Odoo Community Association (OCA)", + "license": "AGPL-3", + "website": "https://github.com/OCA/queue", + "category": "Generic Modules", + "depends": [ + "base_export_async", + "queue_job", + "mail", + ], + "data": [ + "security/ir.model.access.csv", + "data/mail_template.xml", + "data/ir_cron.xml", + "views/export_async_schedule_group_views.xml", + "views/export_async_schedule_views.xml", + ], + "installable": True, + "maintainers": ["guewen", "stephanemangin"], + "development_status": "Beta", +} diff --git a/export_async_schedule/data/ir_cron.xml b/export_async_schedule/data/ir_cron.xml new file mode 100644 index 0000000000..89797b4aa4 --- /dev/null +++ b/export_async_schedule/data/ir_cron.xml @@ -0,0 +1,30 @@ + + + + Send Scheduled Exports + + + + 1 + hours + -1 + + code + model.search([('next_execution', '<=', datetime.datetime.now())]).run_schedule() + + + + Send Grouped Scheduled Exports + + + + 1 + hours + -1 + + code + model._cron_run_scheduled_groups() + + diff --git a/export_async_schedule/data/mail_template.xml b/export_async_schedule/data/mail_template.xml new file mode 100644 index 0000000000..de6d486053 --- /dev/null +++ b/export_async_schedule/data/mail_template.xml @@ -0,0 +1,28 @@ + + + + Export Group - Scheduled Reporting + Scheduled Export - {{ object.display_name }} + + + +

Please find attached the scheduled reports: + + .

+

This email contains the following exports:

+
    + +
  • + +
  • +
    +
+
+

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

+
+
+
diff --git a/export_async_schedule/models/__init__.py b/export_async_schedule/models/__init__.py new file mode 100644 index 0000000000..988a5f69e1 --- /dev/null +++ b/export_async_schedule/models/__init__.py @@ -0,0 +1,5 @@ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from . import export_async_schedule_mixin +from . import export_async_schedule +from . import export_async_schedule_group diff --git a/export_async_schedule/models/export_async_schedule.py b/export_async_schedule/models/export_async_schedule.py new file mode 100644 index 0000000000..3cac643961 --- /dev/null +++ b/export_async_schedule/models/export_async_schedule.py @@ -0,0 +1,195 @@ +# Copyright 2019 Camptocamp +# Copyright 2026 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from datetime import datetime + +from odoo import api, fields, models +from odoo.tools.safe_eval import safe_eval + + +class ExportAsyncSchedule(models.Model): + _name = "export.async.schedule" + _inherit = ["export.async.schedule.mixin", "mail.thread", "mail.activity.mixin"] + _description = "Export Async Schedule" + _rec_name = "display_name" + + display_name = fields.Char(compute="_compute_display_name", store=True, copy=False) + + # Override mixin fields to inherit from group when part of one + active = fields.Boolean( + compute="_compute_from_group", + store=True, + readonly=False, + default=True, + ) + user_ids = fields.Many2many( + relation="export_async_schedule_res_users_rel", + compute="_compute_from_group", + store=True, + readonly=False, + tracking=True, + required=True, + ) + next_execution = fields.Datetime( + compute="_compute_from_group", + store=True, + readonly=False, + default=fields.Datetime.now, + required=True, + tracking=True, + copy=False, + ) + interval = fields.Integer( + compute="_compute_from_group", + store=True, + readonly=False, + default=1, + required=True, + tracking=True, + ) + interval_unit = fields.Selection( + compute="_compute_from_group", + store=True, + readonly=False, + selection=[ + ("hours", "Hour(s)"), + ("days", "Day(s)"), + ("weeks", "Week(s)"), + ("months", "Month(s)"), + ], + default="months", + required=True, + tracking=True, + ) + end_of_month = fields.Boolean( + compute="_compute_from_group", store=True, readonly=False, tracking=True + ) + lang = fields.Selection( + compute="_compute_from_group", + store=True, + readonly=False, + default=lambda self: self.env.lang, + tracking=True, + ) + model_id = fields.Many2one( + comodel_name="ir.model", required=True, ondelete="cascade", tracking=True + ) + model_name = fields.Char(related="model_id.model", string="Model Name") + domain = fields.Char(string="Export Domain", default=[], tracking=True) + ir_export_id = fields.Many2one( + comodel_name="ir.exports", + string="Export List", + required=True, + domain="[('resource', '=', model_name)]", + ondelete="restrict", + tracking=True, + ) + export_format = fields.Selection( + selection=[("csv", "CSV"), ("excel", "Excel")], + default="csv", + required=True, + tracking=True, + ) + import_compat = fields.Boolean(string="Import-compatible Export", tracking=True) + group_id = fields.Many2one( + comodel_name="export.async.schedule.group", + help="Group that include this scheduled export.", + tracking=True, + ) + + @api.depends( + "group_id.active", + "group_id.user_ids", + "group_id.next_execution", + "group_id.interval", + "group_id.interval_unit", + "group_id.end_of_month", + "group_id.lang", + ) + def _compute_from_group(self): + for record in self: + if record.group_id: + record.active = record.group_id.active + record.user_ids = record.group_id.user_ids + record.next_execution = record.group_id.next_execution + record.interval = record.group_id.interval + record.interval_unit = record.group_id.interval_unit + record.end_of_month = record.group_id.end_of_month + record.lang = record.group_id.lang + + @api.depends("model_id.name", "ir_export_id.name") + def _compute_display_name(self): + for record in self: + record.display_name = f"{record.model_id.name}: {record.ir_export_id.name}" + + @api.model + def _get_fields_with_labels(self, model_name, export_fields): + self_fields = self.env[model_name]._fields + result = [] + for field_name in export_fields: + if "/" in field_name: + # The ir.exports.line model contains only the name of the + # field, and when we follow relations, the name of the fields + # joined by /. example: 'bank_ids/acc_number' + # Here, we follow the relations to get the labels + parts = field_name.split("/") + model_fields = self_fields + label_parts = [] + for cur_field_name in parts: + cur_field = model_fields[cur_field_name] + label_parts.append(cur_field._description_string(self.env)) + comodel_name = cur_field.comodel_name + if comodel_name: + model_fields = self.env[cur_field.comodel_name]._fields + label = "/".join(label_parts) + else: + label = self_fields[field_name]._description_string(self.env) + result.append({"label": label, "name": field_name}) + return result + + def _prepare_export_params(self): + export_fields = [ + export_field.name for export_field in self.ir_export_id.export_fields + ] + if self.import_compat: + export_fields = [ + {"label": export_field, "name": export_field} + for export_field in export_fields + ] + else: + export_fields = self._get_fields_with_labels( + self.model_name, + list(export_fields), + ) + export_format = self.export_format == "excel" and "xlsx" or self.export_format + return { + "format": export_format, + "model": self.model_name, + "fields": export_fields, + "ids": False, + "domain": safe_eval(self.domain), + "context": self.env.context, + "import_compat": self.import_compat, + "user_ids": self.user_ids.ids, + } + + def run_schedule(self): + """Called by cron to process due schedules (standalone only).""" + for record in self.filtered(lambda r: not r.group_id): + if record.next_execution > datetime.now(): + continue + record._do_export() + record.next_execution = record._compute_next_date() + + def action_export(self): + """Manual export action from UI. Skips grouped schedules.""" + for record in self.filtered(lambda r: not r.group_id): + record._do_export() + + def _do_export(self): + """Execute the export as a background job.""" + self.ensure_one() + record = self.with_context(lang=self.lang) + params = record._prepare_export_params() + self.env["delay.export"].with_delay().export(params) diff --git a/export_async_schedule/models/export_async_schedule_group.py b/export_async_schedule/models/export_async_schedule_group.py new file mode 100644 index 0000000000..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 new file mode 100644 index 0000000000..f255abc678 --- /dev/null +++ b/export_async_schedule/readme/CONFIGURE.md @@ -0,0 +1,21 @@ +## Creating an Export List + +1. Open any model's list view (e.g., Partners, Sales Orders) +2. Select at least one record +3. Click **Action → Export** +4. Select fields to export +5. Save the field list with a meaningful name + +## Configuring a Scheduled Export + +Navigate to **Settings → Technical → Automation → Scheduled Exports** and create a new +record with: + +- Model and export list (created above) +- Export domain (filter records to export) +- Export format (CSV or Excel) +- Recipients (users who will receive the export) +- Schedule (frequency and next execution date) +- Language (for field labels in the export) + +A cron job runs hourly to execute scheduled exports and groups. diff --git a/export_async_schedule/readme/CONTRIBUTORS.md b/export_async_schedule/readme/CONTRIBUTORS.md new file mode 100644 index 0000000000..010bd7e813 --- /dev/null +++ b/export_async_schedule/readme/CONTRIBUTORS.md @@ -0,0 +1,4 @@ +- Guewen Baconnier (Camptocamp) +- [Komit](https://komit-consulting.com): + - Cuong Nguyen Mtm \ +- Stéphane Mangin (ACSONE SA/NV) diff --git a/export_async_schedule/readme/DESCRIPTION.md b/export_async_schedule/readme/DESCRIPTION.md new file mode 100644 index 0000000000..56f5b32d0a --- /dev/null +++ b/export_async_schedule/readme/DESCRIPTION.md @@ -0,0 +1,5 @@ +Schedule automated exports sent by email at regular intervals (hours, days, weeks, +months) to selected users. + +**Export Groups** allow bundling multiple exports into a single email with multiple +attachments - useful for consolidated reporting. diff --git a/export_async_schedule/readme/USAGE.md b/export_async_schedule/readme/USAGE.md new file mode 100644 index 0000000000..33cb008e6d --- /dev/null +++ b/export_async_schedule/readme/USAGE.md @@ -0,0 +1,23 @@ +When a scheduled export is configured, its execution is automatic based on the +schedule. + +Users receive an email with a download link for the exported file. Attachments remain +in the database for 7 days by default (configurable via the `attachment.ttl` system +parameter). + +## Export Groups + +Group multiple exports into a single email: + +1. Navigate to **Settings > Technical > Automation > Grouped Scheduled Exports** +2. Create a group specifying: + - Recipients (users with email addresses) + - Email template + - Exports to include (select from standalone exports or create new ones) + - Schedule (interval, next execution, language) +3. Use **Send Test Email Now** to verify configuration + +**Important**: When an export is added to a group, it automatically inherits the +group's scheduling parameters (recipients, interval, language, etc.). Individual +exports within a group cannot be executed separately - only the group's cron job +triggers their execution as a batch. diff --git a/export_async_schedule/security/ir.model.access.csv b/export_async_schedule/security/ir.model.access.csv new file mode 100644 index 0000000000..126dc00fe2 --- /dev/null +++ b/export_async_schedule/security/ir.model.access.csv @@ -0,0 +1,3 @@ +"id","name","model_id:id","group_id:id","perm_read","perm_write","perm_create","perm_unlink" +"access_export_async_schedule","export_async_schedule","model_export_async_schedule","base.group_system",1,1,1,1 +"access_export_async_schedule_group","export_async_schedule_group","model_export_async_schedule_group","base.group_system",1,1,1,1 diff --git a/export_async_schedule/static/description/index.html b/export_async_schedule/static/description/index.html new file mode 100644 index 0000000000..8ec52acd96 --- /dev/null +++ b/export_async_schedule/static/description/index.html @@ -0,0 +1,500 @@ + + + + + +Scheduled Asynchronous Export + + + +
+

Scheduled Asynchronous Export

+ + +

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

+

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

+

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

+

Table of contents

+ +
+

Configuration

+
+

Creating an Export List

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

Configuring a Scheduled Export

+

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

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

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

+
+
+
+

Usage

+

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

+

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

+
+

Export Groups

+

Group multiple exports into a single email:

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

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

+
+
+
+

Bug Tracker

+

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

+

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

+
+
+

Credits

+
+

Authors

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

Contributors

+ +
+
+

Maintainers

+

This module is maintained by the OCA.

+ +Odoo Community Association + +

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

+

Current maintainers:

+

guewen stephanemangin

+

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

+

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

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

+ +

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

+ Create your first export group +

+

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

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

+ +

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

+ Create your first scheduled export +

+

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

+
+
+ + +
diff --git a/test-requirements.txt b/test-requirements.txt new file mode 100644 index 0000000000..8feebd2a35 --- /dev/null +++ b/test-requirements.txt @@ -0,0 +1,2 @@ +odoo-addon-export_async_schedule @ git+https://github.com/OCA/queue.git@refs/pull/893/head#subdirectory=export_async_schedule +odoo-addon-base_export_async @ git+https://github.com/OCA/queue.git@refs/pull/894/head#subdirectory=base_export_async