From 971ca89448e500c0e143c6c88196afd71eb125db Mon Sep 17 00:00:00 2001 From: Enric Tobella Date: Mon, 4 Mar 2024 15:57:50 +0100 Subject: [PATCH 1/2] [ADD] automation_oca --- automation_oca/README.rst | 169 ++++++ automation_oca/__init__.py | 3 + automation_oca/__manifest__.py | 38 ++ automation_oca/controllers/__init__.py | 1 + automation_oca/controllers/main.py | 65 ++ automation_oca/data/cron.xml | 28 + automation_oca/demo/demo.xml | 62 ++ automation_oca/models/__init__.py | 10 + .../models/automation_configuration.py | 290 +++++++++ .../models/automation_configuration_step.py | 508 ++++++++++++++++ automation_oca/models/automation_filter.py | 24 + automation_oca/models/automation_record.py | 191 ++++++ .../models/automation_record_step.py | 397 ++++++++++++ automation_oca/models/automation_tag.py | 20 + automation_oca/models/link_tracker.py | 45 ++ automation_oca/models/mail_activity.py | 15 + automation_oca/models/mail_mail.py | 51 ++ automation_oca/models/mail_thread.py | 70 +++ automation_oca/readme/CONTRIBUTORS.md | 1 + automation_oca/readme/CREDITS.md | 3 + automation_oca/readme/DESCRIPTION.md | 7 + automation_oca/readme/USAGE.md | 49 ++ automation_oca/security/ir.model.access.csv | 14 + automation_oca/security/security.xml | 33 + .../static/description/configuration.png | Bin 0 -> 55863 bytes automation_oca/static/description/icon.png | Bin 0 -> 37729 bytes automation_oca/static/description/icon.svg | 99 +++ automation_oca/static/description/index.html | 516 ++++++++++++++++ .../automation_activity.esm.js | 53 ++ .../automation_graph/automation_graph.esm.js | 104 ++++ .../automation_graph/automation_graph.xml | 10 + .../automation_kanban/automation_kanban.scss | 128 ++++ .../automation_kanban_compiler.esm.js | 22 + .../automation_kanban_record.esm.js | 17 + .../automation_kanban_renderer.esm.js | 38 ++ automation_oca/tests/__init__.py | 5 + automation_oca/tests/common.py | 93 +++ .../tests/test_automation_action.py | 205 +++++++ .../tests/test_automation_activity.py | 144 +++++ automation_oca/tests/test_automation_base.py | 546 +++++++++++++++++ automation_oca/tests/test_automation_mail.py | 568 ++++++++++++++++++ .../tests/test_automation_security.py | 105 ++++ .../views/automation_configuration.xml | 488 +++++++++++++++ .../views/automation_configuration_step.xml | 186 ++++++ automation_oca/views/automation_filter.xml | 65 ++ automation_oca/views/automation_record.xml | 297 +++++++++ .../views/automation_record_step.xml | 142 +++++ automation_oca/views/automation_tag.xml | 40 ++ automation_oca/views/link_tracker_clicks.xml | 38 ++ automation_oca/views/menu.xml | 21 + automation_oca/wizards/__init__.py | 2 + .../wizards/automation_configuration_test.py | 43 ++ .../wizards/automation_configuration_test.xml | 49 ++ .../wizards/mail_compose_message.py | 20 + .../automation_oca/odoo/addons/automation_oca | 1 + setup/automation_oca/setup.py | 6 + test-requirements.txt | 1 + 57 files changed, 6146 insertions(+) create mode 100644 automation_oca/README.rst create mode 100644 automation_oca/__init__.py create mode 100644 automation_oca/__manifest__.py create mode 100644 automation_oca/controllers/__init__.py create mode 100644 automation_oca/controllers/main.py create mode 100644 automation_oca/data/cron.xml create mode 100644 automation_oca/demo/demo.xml create mode 100644 automation_oca/models/__init__.py create mode 100644 automation_oca/models/automation_configuration.py create mode 100644 automation_oca/models/automation_configuration_step.py create mode 100644 automation_oca/models/automation_filter.py create mode 100644 automation_oca/models/automation_record.py create mode 100644 automation_oca/models/automation_record_step.py create mode 100644 automation_oca/models/automation_tag.py create mode 100644 automation_oca/models/link_tracker.py create mode 100644 automation_oca/models/mail_activity.py create mode 100644 automation_oca/models/mail_mail.py create mode 100644 automation_oca/models/mail_thread.py create mode 100644 automation_oca/readme/CONTRIBUTORS.md create mode 100644 automation_oca/readme/CREDITS.md create mode 100644 automation_oca/readme/DESCRIPTION.md create mode 100644 automation_oca/readme/USAGE.md create mode 100644 automation_oca/security/ir.model.access.csv create mode 100644 automation_oca/security/security.xml create mode 100644 automation_oca/static/description/configuration.png create mode 100644 automation_oca/static/description/icon.png create mode 100644 automation_oca/static/description/icon.svg create mode 100644 automation_oca/static/description/index.html create mode 100644 automation_oca/static/src/fields/automation_activity/automation_activity.esm.js create mode 100644 automation_oca/static/src/fields/automation_graph/automation_graph.esm.js create mode 100644 automation_oca/static/src/fields/automation_graph/automation_graph.xml create mode 100644 automation_oca/static/src/views/automation_kanban/automation_kanban.scss create mode 100644 automation_oca/static/src/views/automation_kanban/automation_kanban_compiler.esm.js create mode 100644 automation_oca/static/src/views/automation_kanban/automation_kanban_record.esm.js create mode 100644 automation_oca/static/src/views/automation_kanban/automation_kanban_renderer.esm.js create mode 100644 automation_oca/tests/__init__.py create mode 100644 automation_oca/tests/common.py create mode 100644 automation_oca/tests/test_automation_action.py create mode 100644 automation_oca/tests/test_automation_activity.py create mode 100644 automation_oca/tests/test_automation_base.py create mode 100644 automation_oca/tests/test_automation_mail.py create mode 100644 automation_oca/tests/test_automation_security.py create mode 100644 automation_oca/views/automation_configuration.xml create mode 100644 automation_oca/views/automation_configuration_step.xml create mode 100644 automation_oca/views/automation_filter.xml create mode 100644 automation_oca/views/automation_record.xml create mode 100644 automation_oca/views/automation_record_step.xml create mode 100644 automation_oca/views/automation_tag.xml create mode 100644 automation_oca/views/link_tracker_clicks.xml create mode 100644 automation_oca/views/menu.xml create mode 100644 automation_oca/wizards/__init__.py create mode 100644 automation_oca/wizards/automation_configuration_test.py create mode 100644 automation_oca/wizards/automation_configuration_test.xml create mode 100644 automation_oca/wizards/mail_compose_message.py create mode 120000 setup/automation_oca/odoo/addons/automation_oca create mode 100644 setup/automation_oca/setup.py create mode 100644 test-requirements.txt diff --git a/automation_oca/README.rst b/automation_oca/README.rst new file mode 100644 index 0000000..0f2fda7 --- /dev/null +++ b/automation_oca/README.rst @@ -0,0 +1,169 @@ +============== +Automation Oca +============== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:5d9710e6bc1697d84bcd87bdd88f0cf9544b2f82053cf7dc3f5c232f788a4a07 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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%2Fautomation-lightgray.png?logo=github + :target: https://github.com/OCA/automation/tree/16.0/automation_oca + :alt: OCA/automation +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/automation-16-0/automation-16-0-automation_oca + :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/automation&target_branch=16.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module allows to automate several process according to some rules. + +This is useful for creating automated actions on your database like: + +- Send a welcome email to all new partners (or filtered according to + some rules) +- Remember to online customers that they forgot their basket with some + items +- Send documents to sign to all new employees + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +Configure your processes +------------------------ + +1. Access the ``Automation`` menu. +2. Create a new Configuration. +3. Set the model and filters. +4. Create the different activities. +5. Press ``Start``. Now, every 6 hours, a process will check if new + records need to be created. +6. Inside the process, you can check all the created items. + +|Configuration Screenshot| + +Configuration of activities +--------------------------- + +Activities can trigger one of the following options: + +- ``Email``: Sends an email using a template. +- ``Server Action``: Executes a server action. +- ``Activity``: Creates an activity to the related record. + +All the activities need to specify the moment of execution. We will set +the number of hours/days and a trigger type: + +- ``Start of workflow``: It will be executed at the + previously-configured time after we create the record. +- ``Execution of another activity``: It will be executed at the + previously-configured time after the previous activity is finished + properly. +- ``Mail opened``: It will be executed at the previously-configured + time after the mail from the previous activity is opened. +- ``Mail not opened``: It will be executed at the previously-configured + time after the mail from the previous activity is sent if it is not + opened before this time. +- ``Mail replied``: It will be executed at the previously-configured + time after the mail from the previous activity is replied. +- ``Mail not replied``: It will be executed at the + previously-configured time after the mail from the previous activity + is opened if it has not been replied. +- ``Mail clicked``: It will be executed at the previously-configured + time after the links of the mail from the previous activity are + clicked. +- ``Mail not clicked``: It will be executed at the + previously-configured time after the mail from the previous activity + is opened and no links are clicked. +- ``Mail bounced``: It will be executed at the previously-configured + time after the mail from the previous activity is bounced back for + any reason. +- ``Activity done``: It will be executed at the previously-configured + time after the activity from the previous action is done. +- ``Activity not done``: It will be executed at the + previously-configured time after the previous action is executed if + the related activity is not done. + +Important to remember to define a proper template when sending the +email. It will the template without using a notification template. Also, +it is important to define correctly the text partner or email to field +on the template + +Records creation +---------------- + +Records are created using a cron action. This action is executed every 6 +hours by default. + +Activity execution +------------------ + +Activities are executed using a cron action. This action is executed +every hour by default. On the record view, you can execute manually an +action. + +.. |Configuration Screenshot| image:: https://raw.githubusercontent.com/OCA/automation/16.0/automation_oca/static/description/configuration.png + +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 +------- + +* Dixmit + +Contributors +------------ + +- Enric Tobella (`Dixmit `__) + +Other credits +------------- + +The development of this module has been financially supported by: + +- Associacion Española de Odoo (`AEODOO `__) + +Maintainers +----------- + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +This module is part of the `OCA/automation `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/automation_oca/__init__.py b/automation_oca/__init__.py new file mode 100644 index 0000000..ada0b87 --- /dev/null +++ b/automation_oca/__init__.py @@ -0,0 +1,3 @@ +from . import controllers +from . import models +from . import wizards diff --git a/automation_oca/__manifest__.py b/automation_oca/__manifest__.py new file mode 100644 index 0000000..961f6cd --- /dev/null +++ b/automation_oca/__manifest__.py @@ -0,0 +1,38 @@ +# Copyright 2024 Dixmit +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +{ + "name": "Automation Oca", + "summary": """ + Automate actions in threaded models""", + "version": "16.0.1.0.0", + "license": "AGPL-3", + "category": "Automation", + "author": "Dixmit,Odoo Community Association (OCA)", + "website": "https://github.com/OCA/automation", + "depends": ["mail", "link_tracker"], + "data": [ + "security/security.xml", + "security/ir.model.access.csv", + "views/menu.xml", + "wizards/automation_configuration_test.xml", + "views/automation_record.xml", + "views/automation_record_step.xml", + "views/automation_configuration_step.xml", + "views/automation_configuration.xml", + "views/link_tracker_clicks.xml", + "views/automation_filter.xml", + "views/automation_tag.xml", + "data/cron.xml", + ], + "assets": { + "web.assets_backend": [ + "automation_oca/static/src/**/*.js", + "automation_oca/static/src/**/*.xml", + "automation_oca/static/src/**/*.scss", + ], + }, + "demo": [ + "demo/demo.xml", + ], +} diff --git a/automation_oca/controllers/__init__.py b/automation_oca/controllers/__init__.py new file mode 100644 index 0000000..12a7e52 --- /dev/null +++ b/automation_oca/controllers/__init__.py @@ -0,0 +1 @@ +from . import main diff --git a/automation_oca/controllers/main.py b/automation_oca/controllers/main.py new file mode 100644 index 0000000..4c9ac9a --- /dev/null +++ b/automation_oca/controllers/main.py @@ -0,0 +1,65 @@ +# Copyright 2024 Dixmit +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import base64 + +from werkzeug.exceptions import NotFound + +from odoo import http, tools +from odoo.http import Response, request +from odoo.tools import consteq + + +class AutomationOCAController(http.Controller): + # ------------------------------------------------------------ + # TRACKING + # ------------------------------------------------------------ + + @http.route( + "/automation_oca/track///blank.gif", + type="http", + auth="public", + ) + def automation_oca_mail_open(self, record_id, token, **post): + """Email tracking. Blank item added. + We will return the blank item allways, but we will make the request only if + the data is correct""" + if consteq( + token, + tools.hmac(request.env(su=True), "automation_oca", record_id), + ): + request.env["automation.record.step"].sudo().browse( + record_id + )._set_mail_open() + response = Response() + response.mimetype = "image/gif" + response.data = base64.b64decode( + b"R0lGODlhAQABAIAAANvf7wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw==" + # This is the code of a blank small image + ) + + return response + + @http.route( + "/r//au//", type="http", auth="public" + ) + def automation_oca_redirect(self, code, record_id, token, **post): + # don't assume geoip is set, it is part of the website module + # which mass_mailing doesn't depend on + country_code = request.geoip.get("country_code") + automation_record_step_id = False + if consteq( + token, + tools.hmac(request.env(su=True), "automation_oca", record_id), + ): + automation_record_step_id = record_id + request.env["link.tracker.click"].sudo().add_click( + code, + ip=request.httprequest.remote_addr, + country_code=country_code, + automation_record_step_id=automation_record_step_id, + ) + redirect_url = request.env["link.tracker"].get_url_from_code(code) + if not redirect_url: + raise NotFound() + return request.redirect(redirect_url, code=301, local=False) diff --git a/automation_oca/data/cron.xml b/automation_oca/data/cron.xml new file mode 100644 index 0000000..d270db4 --- /dev/null +++ b/automation_oca/data/cron.xml @@ -0,0 +1,28 @@ + + + + Automation: Execute scheduled activities + + code + model._cron_automation_steps() + + 1 + hours + -1 + + + + + Automation: Create records + + code + model.cron_automation() + + 6 + hours + -1 + + + + + diff --git a/automation_oca/demo/demo.xml b/automation_oca/demo/demo.xml new file mode 100644 index 0000000..a5d38b7 --- /dev/null +++ b/automation_oca/demo/demo.xml @@ -0,0 +1,62 @@ + + + + Automatic Process + + + Demo + + + + Blacklist Partner + ir.actions.server + code + + +for record in records.filtered(lambda r: not r.is_blacklisted): + env["mail.blacklist"].create({"email": record.email}) + + + + Welcome + + {{object.id}} + Welcome! Thanks for being part of our database + +

Welcome!

+

Thanks for becoming a contact.

+

Kind regards,

+
+
+ + Welcome email + + + + [('email', '!=', False)] + + + Send email + + mail + 2 + + + + Blacklist bounced + + action + mail_bounce + + 24 + + 1 + + +
diff --git a/automation_oca/models/__init__.py b/automation_oca/models/__init__.py new file mode 100644 index 0000000..15f916f --- /dev/null +++ b/automation_oca/models/__init__.py @@ -0,0 +1,10 @@ +from . import automation_configuration +from . import automation_configuration_step +from . import automation_record +from . import automation_record_step +from . import mail_mail +from . import mail_thread +from . import link_tracker +from . import automation_filter +from . import automation_tag +from . import mail_activity diff --git a/automation_oca/models/automation_configuration.py b/automation_oca/models/automation_configuration.py new file mode 100644 index 0000000..26c1e1f --- /dev/null +++ b/automation_oca/models/automation_configuration.py @@ -0,0 +1,290 @@ +# Copyright 2024 Dixmit +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from collections import defaultdict + +from odoo import _, api, fields, models +from odoo.exceptions import ValidationError +from odoo.tools.safe_eval import safe_eval + + +class AutomationConfiguration(models.Model): + + _name = "automation.configuration" + _description = "Automation Configuration" + _inherit = ["mail.thread"] + + name = fields.Char(required=True) + active = fields.Boolean(default=True) + tag_ids = fields.Many2many("automation.tag") + company_id = fields.Many2one("res.company") + domain = fields.Char( + required=True, default="[]", help="Filter to apply", compute="_compute_domain" + ) + editable_domain = fields.Char(required=True, default="[]", help="Filter to apply") + model_id = fields.Many2one( + "ir.model", + domain=[("is_mail_thread", "=", True)], + required=True, + ondelete="cascade", + help="Model where the configuration is applied", + ) + filter_id = fields.Many2one("automation.filter") + filter_domain = fields.Binary(compute="_compute_filter_domain") + model = fields.Char(related="model_id.model") + field_id = fields.Many2one( + "ir.model.fields", + domain="[('model_id', '=', model_id), " + "('ttype', 'in', ['char', 'selection', 'integer', 'text', 'many2one'])]", + help="Used to avoid duplicates", + ) + is_periodic = fields.Boolean( + help="Mark it if you want to make the execution periodic" + ) + # The idea of flow of states will be: + # draft -> run -> done -> draft (for periodic execution) + # -> on demand -> done -> draft (for on demand execution) + state = fields.Selection( + [ + ("draft", "Draft"), + ("periodic", "Periodic"), + ("ondemand", "On demand"), + ("done", "Done"), + ], + default="draft", + required=True, + group_expand="_group_expand_states", + ) + automation_step_ids = fields.One2many( + "automation.configuration.step", inverse_name="configuration_id" + ) + automation_direct_step_ids = fields.One2many( + "automation.configuration.step", + inverse_name="configuration_id", + domain=[("parent_id", "=", False)], + ) + record_test_count = fields.Integer(compute="_compute_record_test_count") + record_count = fields.Integer(compute="_compute_record_count") + record_done_count = fields.Integer(compute="_compute_record_count") + record_run_count = fields.Integer(compute="_compute_record_count") + activity_mail_count = fields.Integer(compute="_compute_activity_count") + activity_action_count = fields.Integer(compute="_compute_activity_count") + click_count = fields.Integer(compute="_compute_click_count") + next_execution_date = fields.Datetime(compute="_compute_next_execution_date") + + @api.depends("filter_id.domain", "filter_id", "editable_domain") + def _compute_domain(self): + for record in self: + record.domain = ( + record.filter_id and record.filter_id.domain + ) or record.editable_domain + + @api.depends() + def _compute_click_count(self): + data = self.env["link.tracker.click"].read_group( + [("automation_configuration_id", "in", self.ids)], + [], + ["automation_configuration_id"], + lazy=False, + ) + mapped_data = {d["automation_configuration_id"][0]: d["__count"] for d in data} + for record in self: + record.click_count = mapped_data.get(record.id, 0) + + @api.depends() + def _compute_activity_count(self): + data = self.env["automation.record.step"].read_group( + [ + ("configuration_id", "in", self.ids), + ("state", "=", "done"), + ("is_test", "=", False), + ], + [], + ["configuration_id", "step_type"], + lazy=False, + ) + mapped_data = defaultdict(lambda: {}) + for d in data: + mapped_data[d["configuration_id"][0]][d["step_type"]] = d["__count"] + for record in self: + record.activity_mail_count = mapped_data[record.id].get("mail", 0) + record.activity_action_count = mapped_data[record.id].get("action", 0) + + @api.depends() + def _compute_record_count(self): + data = self.env["automation.record"].read_group( + [("configuration_id", "in", self.ids), ("is_test", "=", False)], + [], + ["configuration_id", "state"], + lazy=False, + ) + mapped_data = defaultdict(lambda: {}) + for d in data: + mapped_data[d["configuration_id"][0]][d["state"]] = d["__count"] + for record in self: + record.record_done_count = mapped_data[record.id].get("done", 0) + record.record_run_count = mapped_data[record.id].get("periodic", 0) + record.record_count = sum(mapped_data[record.id].values()) + + @api.depends() + def _compute_record_test_count(self): + data = self.env["automation.record"].read_group( + [("configuration_id", "in", self.ids), ("is_test", "=", True)], + [], + ["configuration_id"], + lazy=False, + ) + mapped_data = {d["configuration_id"][0]: d["__count"] for d in data} + for record in self: + record.record_test_count = mapped_data.get(record.id, 0) + + @api.depends("model_id") + def _compute_filter_domain(self): + for record in self: + record.filter_domain = ( + [] if not record.model_id else [("model_id", "=", record.model_id.id)] + ) + + @api.depends("state") + def _compute_next_execution_date(self): + for record in self: + if record.state == "periodic": + record.next_execution_date = self.env.ref( + "automation_oca.cron_configuration_run" + ).nextcall + else: + record.next_execution_date = False + + @api.onchange("filter_id") + def _onchange_filter(self): + self.model_id = self.filter_id.model_id + + @api.onchange("model_id") + def _onchange_model(self): + self.editable_domain = [] + self.filter_id = False + self.field_id = False + self.automation_step_ids = [(5, 0, 0)] + + def start_automation(self): + self.ensure_one() + if self.state != "draft": + raise ValidationError(_("State must be in draft in order to start")) + self.state = "periodic" if self.is_periodic else "ondemand" + + def done_automation(self): + self.ensure_one() + self.state = "done" + + def back_to_draft(self): + self.ensure_one() + self.state = "draft" + + def cron_automation(self): + for record in self.search([("state", "=", "periodic")]): + record.run_automation() + + def _get_automation_records_to_create(self): + """ + We will find all the records that fulfill the domain but don't have a record created. + Also, we need to check by autencity field if defined. + + In order to do this, we will add some extra joins on the query of the domain + """ + domain = safe_eval(self.domain) + Record = self.env[self.model_id.model] + if self.company_id and "company_id" in Record._fields: + # In case of company defined, we add only if the records have company field + domain += [("company_id", "=", self.company_id.id)] + query = Record._where_calc(domain) + alias = query.left_join( + query._tables[Record._table], + "id", + "automation_record", + "res_id", + "automation_record", + "{rhs}.model = %s AND {rhs}.configuration_id = %s AND " + "({rhs}.is_test IS NULL OR NOT {rhs}.is_test)", + (Record._name, self.id), + ) + query.add_where("{}.id is NULL".format(alias)) + if self.field_id: + # In case of unicity field defined, we need to add this + # left join to find already created records + linked_tab = query.left_join( + query._tables[Record._table], + self.field_id.name, + Record._table, + self.field_id.name, + "linked", + ) + alias2 = query.left_join( + linked_tab, + "id", + "automation_record", + "res_id", + "automation_record_linked", + "{rhs}.model = %s AND {rhs}.configuration_id = %s AND " + "({rhs}.is_test IS NULL OR NOT {rhs}.is_test)", + (Record._name, self.id), + ) + query.add_where("{}.id is NULL".format(alias2)) + from_clause, where_clause, params = query.get_sql() + # We also need to find with a group by in order to avoid duplication + # when we have both records created between two executions + # (first one has priority) + query_str = "SELECT {} FROM {} WHERE {}{}{}{} GROUP BY {}".format( + ", ".join([f'MIN("{next(iter(query._tables))}".id) as id']), + from_clause, + where_clause or "TRUE", + (" ORDER BY %s" % self.order) if query.order else "", + (" LIMIT %d" % self.limit) if query.limit else "", + (" OFFSET %d" % self.offset) if query.offset else "", + "%s.%s" % (query._tables[Record._table], self.field_id.name), + ) + else: + query_str, params = query.select() + self.env.cr.execute(query_str, params) + return Record.browse([r[0] for r in self.env.cr.fetchall()]) + + def run_automation(self): + self.ensure_one() + if self.state not in ["periodic", "ondemand"]: + return + records = self.env["automation.record"] + for record in self._get_automation_records_to_create(): + records |= self._create_record(record) + records.automation_step_ids._trigger_activities() + + def _create_record(self, record, **kwargs): + return self.env["automation.record"].create( + self._create_record_vals(record, **kwargs) + ) + + def _create_record_vals(self, record, **kwargs): + return { + **kwargs, + "res_id": record.id, + "model": record._name, + "configuration_id": self.id, + "automation_step_ids": [ + (0, 0, activity._create_record_activity_vals(record)) + for activity in self.automation_direct_step_ids + ], + } + + def _group_expand_states(self, states, domain, order): + """ + This is used to show all the states on the kanban view + """ + return [key for key, _val in self._fields["state"].selection] + + def save_filter(self): + self.ensure_one() + self.filter_id = self.env["automation.filter"].create( + { + "name": self.name, + "domain": self.editable_domain, + "model_id": self.model_id.id, + } + ) diff --git a/automation_oca/models/automation_configuration_step.py b/automation_oca/models/automation_configuration_step.py new file mode 100644 index 0000000..cdbfd0b --- /dev/null +++ b/automation_oca/models/automation_configuration_step.py @@ -0,0 +1,508 @@ +# Copyright 2024 Dixmit +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from collections import defaultdict + +import babel.dates +from dateutil.relativedelta import relativedelta + +from odoo import _, api, fields, models +from odoo.exceptions import ValidationError +from odoo.osv import expression +from odoo.tools import get_lang +from odoo.tools.safe_eval import safe_eval + + +class AutomationConfigurationStep(models.Model): + + _name = "automation.configuration.step" + _description = "Automation Steps" + _order = "trigger_interval_hours ASC" + + name = fields.Char(required=True) + configuration_id = fields.Many2one( + "automation.configuration", required=True, auto_join=True + ) + domain = fields.Char( + required=True, default="[]", help="Filter to apply specifically" + ) + applied_domain = fields.Char( + compute="_compute_applied_domain", + recursive=True, + ) + parent_id = fields.Many2one("automation.configuration.step", ondelete="cascade") + model_id = fields.Many2one(related="configuration_id.model_id") + model = fields.Char(related="model_id.model") + child_ids = fields.One2many( + "automation.configuration.step", inverse_name="parent_id" + ) + step_type = fields.Selection( + [("mail", "Mail"), ("action", "Server Action"), ("activity", "Activity")], + required=True, + default="mail", + ) + step_icon = fields.Char(compute="_compute_step_info") + step_name = fields.Char(compute="_compute_step_info") + trigger_interval_hours = fields.Integer( + compute="_compute_trigger_interval_hours", store=True + ) + trigger_interval = fields.Integer() + trigger_interval_type = fields.Selection( + [("hours", "Hours"), ("days", "Days")], required=True, default="hours" + ) + allow_expiry = fields.Boolean(compute="_compute_allow_expiry") + expiry = fields.Boolean(compute="_compute_expiry", store=True, readonly=False) + expiry_interval = fields.Integer() + expiry_interval_type = fields.Selection( + [("hours", "Hours"), ("days", "Days")], required=True, default="hours" + ) + trigger_type = fields.Selection( + selection="_trigger_type_selection", + required=True, + default="start", + ) + trigger_child_types = fields.Json(compute="_compute_trigger_child_types") + trigger_type_data = fields.Json(compute="_compute_trigger_type_data") + mail_author_id = fields.Many2one( + "res.partner", required=True, default=lambda r: r.env.user.id + ) + mail_template_id = fields.Many2one( + "mail.template", domain="[('model_id', '=', model_id)]" + ) + server_action_id = fields.Many2one( + "ir.actions.server", domain="[('model_id', '=', model_id)]" + ) + activity_type_id = fields.Many2one( + "mail.activity.type", + string="Activity", + domain="['|', ('res_model', '=', False), ('res_model', '=', model)]", + compute="_compute_activity_info", + readonly=False, + store=True, + ondelete="restrict", + ) + activity_summary = fields.Char( + "Summary", compute="_compute_activity_info", readonly=False, store=True + ) + activity_note = fields.Html( + "Note", compute="_compute_activity_info", readonly=False, store=True + ) + activity_date_deadline_range = fields.Integer( + string="Due Date In", + compute="_compute_activity_info", + readonly=False, + store=True, + ) + activity_date_deadline_range_type = fields.Selection( + [("days", "Days"), ("weeks", "Weeks"), ("months", "Months")], + string="Due type", + default="days", + compute="_compute_activity_info", + readonly=False, + store=True, + ) + activity_user_type = fields.Selection( + [("specific", "Specific User"), ("generic", "Generic User From Record")], + compute="_compute_activity_info", + readonly=False, + store=True, + help="""Use 'Specific User' to always assign the same user on the next activity. + Use 'Generic User From Record' to specify the field name of the user + to choose on the record.""", + ) + activity_user_id = fields.Many2one( + "res.users", + string="Responsible", + compute="_compute_activity_info", + readonly=False, + store=True, + ) + activity_user_field_id = fields.Many2one( + "ir.model.fields", + "User field name", + compute="_compute_activity_info", + readonly=False, + store=True, + ) + parent_position = fields.Integer( + compute="_compute_parent_position", recursive=True, store=True + ) + graph_data = fields.Json(compute="_compute_graph_data") + graph_done = fields.Integer(compute="_compute_total_graph_data") + graph_error = fields.Integer(compute="_compute_total_graph_data") + + @api.onchange("trigger_type") + def _onchange_trigger_type(self): + if self.trigger_type == "start": + # Theoretically, only start allows no parent, so we will keep it this way + self.parent_id = False + + ######################################## + # Graph computed fields ################ + ######################################## + + @api.depends() + def _compute_graph_data(self): + total = self.env["automation.record.step"].read_group( + [ + ("configuration_step_id", "in", self.ids), + ( + "processed_on", + ">=", + fields.Date.context_today(self) + relativedelta(days=-14), + ), + ("is_test", "=", False), + ], + ["configuration_step_id"], + ["configuration_step_id", "processed_on:day"], + lazy=False, + ) + done = self.env["automation.record.step"].read_group( + [ + ("configuration_step_id", "in", self.ids), + ( + "processed_on", + ">=", + fields.Date.context_today(self) + relativedelta(days=-14), + ), + ("state", "=", "done"), + ("is_test", "=", False), + ], + ["configuration_step_id"], + ["configuration_step_id", "processed_on:day"], + lazy=False, + ) + now = fields.Datetime.now() + date_map = { + babel.dates.format_datetime( + now + relativedelta(days=i - 14), + format="dd MMM yyy", + tzinfo=self._context.get("tz", None), + locale=get_lang(self.env).code, + ): 0 + for i in range(0, 15) + } + result = defaultdict( + lambda: {"done": date_map.copy(), "error": date_map.copy()} + ) + for line in total: + result[line["configuration_step_id"][0]]["error"][ + line["processed_on:day"] + ] += line["__count"] + for line in done: + result[line["configuration_step_id"][0]]["done"][ + line["processed_on:day"] + ] += line["__count"] + result[line["configuration_step_id"][0]]["error"][ + line["processed_on:day"] + ] -= line["__count"] + for record in self: + graph_info = dict(result[record.id]) + record.graph_data = { + "error": [ + {"x": key[:-5], "y": value, "name": key} + for (key, value) in graph_info["error"].items() + ], + "done": [ + {"x": key[:-5], "y": value, "name": key} + for (key, value) in graph_info["done"].items() + ], + } + + @api.depends() + def _compute_total_graph_data(self): + for record in self: + record.graph_done = self.env["automation.record.step"].search_count( + [ + ("configuration_step_id", "=", record.id), + ("state", "=", "done"), + ("is_test", "=", False), + ] + ) + record.graph_error = self.env["automation.record.step"].search_count( + [ + ("configuration_step_id", "=", record.id), + ("state", "in", ["expired", "rejected", "error", "cancel"]), + ("is_test", "=", False), + ] + ) + + @api.depends("step_type") + def _compute_activity_info(self): + for to_reset in self.filtered(lambda act: act.step_type != "activity"): + to_reset.activity_summary = False + to_reset.activity_note = False + to_reset.activity_date_deadline_range = False + to_reset.activity_date_deadline_range_type = False + to_reset.activity_user_type = False + to_reset.activity_user_id = False + to_reset.activity_user_field_id = False + for activity in self.filtered(lambda act: act.step_type == "activity"): + if not activity.activity_date_deadline_range_type: + activity.activity_date_deadline_range_type = "days" + if not activity.activity_user_id: + activity.activity_user_id = self.env.user.id + + @api.depends("trigger_interval", "trigger_interval_type") + def _compute_trigger_interval_hours(self): + for record in self: + record.trigger_interval_hours = record._get_trigger_interval_hours() + + def _get_trigger_interval_hours(self): + if self.trigger_interval_type == "days": + return self.trigger_interval * 24 + return self.trigger_interval + + @api.depends("parent_id", "parent_id.parent_position", "trigger_type") + def _compute_parent_position(self): + for record in self: + record.parent_position = ( + (record.parent_id.parent_position + 1) if record.parent_id else 0 + ) + + @api.depends( + "domain", "configuration_id.domain", "parent_id", "parent_id.applied_domain" + ) + def _compute_applied_domain(self): + for record in self: + record.applied_domain = expression.AND( + [ + safe_eval(record.domain), + safe_eval( + (record.parent_id and record.parent_id.applied_domain) + or record.configuration_id.domain + ), + ] + ) + + @api.model + def _trigger_type_selection(self): + return [ + (trigger_id, trigger.get("name", trigger_id)) + for trigger_id, trigger in self._trigger_types().items() + ] + + @api.model + def _trigger_types(self): + """ + This function will return a dictionary that map trigger_types to its configurations. + Each trigger_type can contain: + - name (Required field) + - step type: List of step types that succeed after this. + If it is false, it will work for all step types, + otherwise only for the ones on the list + - color: Color of the icon + - icon: Icon to show + - message_configuration: Message to show on the step configuration + - allow_expiry: True if it allows expiration of activity + - message: Message to show on the record if expected is not date defined + """ + return { + "start": { + "name": _("start of workflow"), + "step_type": [], + "message_configuration": False, + "message": False, + "allow_parent": True, + }, + "after_step": { + "name": _("execution of another step"), + "color": "text-success", + "icon": "fa fa-code-fork fa-rotate-180 fa-flip-vertical", + "message_configuration": False, + "message": False, + }, + "mail_open": { + "name": _("Mail opened"), + "allow_expiry": True, + "step_type": ["mail"], + "color": "text-success", + "icon": "fa fa-envelope-open-o", + "message_configuration": _("Opened after"), + "message": _("Not opened yet"), + }, + "mail_not_open": { + "name": _("Mail not opened"), + "step_type": ["mail"], + "color": "text-danger", + "icon": "fa fa-envelope-open-o", + "message_configuration": _("Not opened within"), + "message": False, + }, + "mail_reply": { + "name": _("Mail replied"), + "allow_expiry": True, + "step_type": ["mail"], + "color": "text-success", + "icon": "fa fa-reply", + "message_configuration": _("Replied after"), + "message": _("Not replied yet"), + }, + "mail_not_reply": { + "name": _("Mail not replied"), + "step_type": ["mail"], + "color": "text-danger", + "icon": "fa fa-reply", + "message_configuration": _("Not replied within"), + "message": False, + }, + "mail_click": { + "name": _("Mail clicked"), + "allow_expiry": True, + "step_type": ["mail"], + "color": "text-success", + "icon": "fa fa-hand-pointer-o", + "message_configuration": _("Clicked after"), + "message": _("Not clicked yet"), + }, + "mail_not_clicked": { + "name": _("Mail not clicked"), + "step_type": ["mail"], + "color": "text-danger", + "icon": "fa fa-hand-pointer-o", + "message_configuration": _("Not clicked within"), + "message": False, + }, + "mail_bounce": { + "name": _("Mail bounced"), + "allow_expiry": True, + "step_type": ["mail"], + "color": "text-danger", + "icon": "fa fa-exclamation-circle", + "message_configuration": _("Bounced after"), + "message": _("Not bounced yet"), + }, + "activity_done": { + "name": _("Activity has been finished"), + "step_type": ["activity"], + "color": "text-success", + "icon": "fa fa-clock-o", + "message_configuration": _("After finished"), + "message": _("Activity not done"), + }, + "activity_not_done": { + "name": _("Activity has not been finished"), + "allow_expiry": True, + "step_type": ["activity"], + "color": "text-danger", + "icon": "fa fa-clock-o", + "message_configuration": _("Not finished within"), + "message": False, + }, + } + + @api.model + def _step_icons(self): + """ + This function will return a dictionary that maps step types and icons + """ + return { + "mail": "fa fa-envelope", + "activity": "fa fa-clock-o", + "action": "fa fa-cogs", + } + + @api.depends("step_type") + def _compute_step_info(self): + step_icons = self._step_icons() + step_name_map = dict(self._fields["step_type"].selection) + for record in self: + record.step_icon = step_icons.get(record.step_type, "") + record.step_name = step_name_map.get(record.step_type, "") + + @api.depends("trigger_type") + def _compute_trigger_type_data(self): + trigger_types = self._trigger_types() + for record in self: + record.trigger_type_data = trigger_types[record.trigger_type] + + @api.depends("trigger_type") + def _compute_allow_expiry(self): + trigger_types = self._trigger_types() + for record in self: + record.allow_expiry = trigger_types[record.trigger_type].get( + "allow_expiry", False + ) + + @api.depends("trigger_type") + def _compute_expiry(self): + trigger_types = self._trigger_types() + for record in self: + record.expiry = ( + trigger_types[record.trigger_type].get("allow_expiry", False) + and record.expiry + ) + + @api.depends("step_type") + def _compute_trigger_child_types(self): + trigger_types = self._trigger_types() + for record in self: + trigger_child_types = {} + for trigger_type_id, trigger_type in trigger_types.items(): + if "step_type" not in trigger_type: + # All are allowed + trigger_child_types[trigger_type_id] = trigger_type + elif record.step_type in trigger_type["step_type"]: + trigger_child_types[trigger_type_id] = trigger_type + record.trigger_child_types = trigger_child_types + + def _check_configuration(self): + trigger_conf = self._trigger_types()[self.trigger_type] + if not self.parent_id and not trigger_conf.get("allow_parent"): + raise ValidationError( + _("%s configurations needs a parent") % trigger_conf["name"] + ) + if ( + self.parent_id + and "step_type" in trigger_conf + and self.parent_id.step_type not in trigger_conf["step_type"] + ): + step_types = dict(self._fields["step_type"].selection) + raise ValidationError( + _("To use a %(name)s trigger type we need a parent of type %(parents)s") + % { + "name": trigger_conf["name"], + "parents": ",".join( + [ + name + for step_type, name in step_types.items() + if step_type in trigger_conf["step_type"] + ] + ), + } + ) + + @api.constrains("parent_id", "parent_id.step_type", "trigger_type") + def _check_parent_configuration(self): + for record in self: + record._check_configuration() + + def _get_record_activity_scheduled_date(self): + if self.trigger_type in [ + "mail_open", + "mail_bounce", + "mail_click", + "mail_not_clicked", + "mail_reply", + "mail_not_reply", + "activity_done", + ]: + return False + return fields.Datetime.now() + relativedelta( + **{self.trigger_interval_type: self.trigger_interval} + ) + + def _get_expiry_date(self): + if not self.expiry: + return False + return fields.Datetime.now() + relativedelta( + **{self.expiry_interval_type: self.expiry_interval} + ) + + def _create_record_activity_vals(self, record, **kwargs): + return { + "configuration_step_id": self.id, + "expiry_date": self._get_expiry_date(), + "scheduled_date": self._get_record_activity_scheduled_date(), + **kwargs, + } diff --git a/automation_oca/models/automation_filter.py b/automation_oca/models/automation_filter.py new file mode 100644 index 0000000..3d681f1 --- /dev/null +++ b/automation_oca/models/automation_filter.py @@ -0,0 +1,24 @@ +# Copyright 2024 Dixmit +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import api, fields, models + + +class AutomationFilter(models.Model): + _name = "automation.filter" + _description = "Automation Filter" + + name = fields.Char(required=True) + model_id = fields.Many2one( + "ir.model", + domain=[("is_mail_thread", "=", True)], + required=True, + ondelete="cascade", + help="Model where the configuration is applied", + ) + model = fields.Char(related="model_id.model") + domain = fields.Char(required=True, default="[]", help="Filter to apply") + + @api.onchange("model_id") + def _onchange_model(self): + self.domain = [] diff --git a/automation_oca/models/automation_record.py b/automation_oca/models/automation_record.py new file mode 100644 index 0000000..6803d3f --- /dev/null +++ b/automation_oca/models/automation_record.py @@ -0,0 +1,191 @@ +# Copyright 2024 Dixmit +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import logging +from collections import defaultdict + +from odoo import api, fields, models + +_logger = logging.getLogger(__name__) + + +class AutomationRecord(models.Model): + + _name = "automation.record" + _description = "Automation Record" + + name = fields.Char(compute="_compute_name") + state = fields.Selection( + [("run", "Running"), ("done", "Done")], compute="_compute_state", store=True + ) + configuration_id = fields.Many2one( + "automation.configuration", required=True, readonly=True + ) + model = fields.Char(index=True, required=False, readonly=True) + resource_ref = fields.Reference( + selection="_selection_target_model", + compute="_compute_resource_ref", + readonly=True, + ) + res_id = fields.Many2oneReference( + string="Record", + index=True, + required=False, + readonly=True, + model_field="model", + copy=False, + ) + automation_step_ids = fields.One2many( + "automation.record.step", inverse_name="record_id", readonly=True + ) + is_test = fields.Boolean() + + @api.model + def _selection_target_model(self): + return [ + (model.model, model.name) + for model in self.env["ir.model"] + .sudo() + .search([("is_mail_thread", "=", True)]) + ] + + @api.depends("automation_step_ids.state") + def _compute_state(self): + for record in self: + record.state = ( + "run" + if record.automation_step_ids.filtered(lambda r: r.state == "scheduled") + else "done" + ) + + @api.depends("model", "res_id") + def _compute_resource_ref(self): + for record in self: + if record.model and record.model in self.env: + record.resource_ref = "%s,%s" % (record.model, record.res_id or 0) + else: + record.resource_ref = None + + @api.depends("res_id", "model") + def _compute_name(self): + for record in self: + record.name = self.env[record.model].browse(record.res_id).display_name + + @api.model + def _search( + self, + args, + offset=0, + limit=None, + order=None, + count=False, + access_rights_uid=None, + ): + ids = super()._search( + args, + offset=offset, + limit=limit, + order=order, + count=False, + access_rights_uid=access_rights_uid, + ) + if self.env.is_system(): + # restrictions do not apply to group "Settings" + return len(ids) if count else ids + + # TODO highlight orphaned records in UI: + # - self.model + self.res_id are set + # - self.record returns empty recordset + # Remark: self.record is @property, not field + + if not ids: + return 0 if count else [] + orig_ids = ids + ids = set(ids) + result = [] + model_data = defaultdict( + lambda: defaultdict(set) + ) # {res_model: {res_id: set(ids)}} + for sub_ids in self._cr.split_for_in_conditions(ids): + self._cr.execute( + """ + SELECT id, res_id, model + FROM "%s" + WHERE id = ANY (%%(ids)s)""" + % self._table, + dict(ids=list(sub_ids)), + ) + for eid, res_id, model in self._cr.fetchall(): + model_data[model][res_id].add(eid) + + for model, targets in model_data.items(): + if not self.env[model].check_access_rights("read", False): + continue + recs = self.env[model].browse(list(targets)) + missing = recs - recs.exists() + if missing: + for res_id in missing.ids: + _logger.warning( + "Deleted record %s,%s is referenced by automation.record %s", + model, + res_id, + list(targets[res_id]), + ) + recs = recs - missing + allowed = ( + self.env[model] + .with_context(active_test=False) + ._search([("id", "in", recs.ids)]) + ) + for target_id in allowed: + result += list(targets[target_id]) + if len(orig_ids) == limit and len(result) < len(orig_ids): + result.extend( + self._search( + args, + offset=offset + len(orig_ids), + limit=limit, + order=order, + count=count, + access_rights_uid=access_rights_uid, + )[: limit - len(result)] + ) + # Restore original ordering + result = [x for x in orig_ids if x in result] + return len(result) if count else list(result) + + def read(self, fields=None, load="_classic_read"): + """Override to explicitely call check_access_rule, that is not called + by the ORM. It instead directly fetches ir.rules and apply them.""" + self.check_access_rule("read") + return super().read(fields=fields, load=load) + + def check_access_rule(self, operation): + """In order to check if we can access a record, we are checking if we can access + the related document""" + super().check_access_rule(operation) + if self.env.is_superuser(): + return + default_checker = self.env["mail.thread"].get_automation_access + by_model_rec_ids = defaultdict(set) + by_model_checker = {} + for exc_rec in self.sudo(): + by_model_rec_ids[exc_rec.model].add(exc_rec.res_id) + if exc_rec.model not in by_model_checker: + by_model_checker[exc_rec.model] = getattr( + self.env[exc_rec.model], "get_automation_access", default_checker + ) + + for model, rec_ids in by_model_rec_ids.items(): + records = self.env[model].browse(rec_ids).with_user(self._uid) + checker = by_model_checker[model] + for record in records: + check_operation = checker( + [record.id], operation, model_name=record._name + ) + record.check_access_rights(check_operation) + record.check_access_rule(check_operation) + + def write(self, vals): + self.check_access_rule("write") + return super().write(vals) diff --git a/automation_oca/models/automation_record_step.py b/automation_oca/models/automation_record_step.py new file mode 100644 index 0000000..277a27b --- /dev/null +++ b/automation_oca/models/automation_record_step.py @@ -0,0 +1,397 @@ +# Copyright 2024 Dixmit +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +import threading +import traceback +from io import StringIO + +import werkzeug.urls +from dateutil.relativedelta import relativedelta + +from odoo import _, api, fields, models, tools +from odoo.tools.safe_eval import safe_eval + + +class AutomationRecordStep(models.Model): + _name = "automation.record.step" + _description = "Activities done on the record" + _order = "scheduled_date ASC" + + name = fields.Char(related="configuration_step_id.name") + record_id = fields.Many2one("automation.record", required=True, ondelete="cascade") + configuration_step_id = fields.Many2one( + "automation.configuration.step", required=True + ) + configuration_id = fields.Many2one( + related="configuration_step_id.configuration_id", + store=True, + ) + step_type = fields.Selection(related="configuration_step_id.step_type", store=True) + scheduled_date = fields.Datetime(readonly=True) + expiry_date = fields.Datetime(readonly=True) + processed_on = fields.Datetime(readonly=True) + parent_id = fields.Many2one("automation.record.step", readonly=True) + child_ids = fields.One2many("automation.record.step", inverse_name="parent_id") + trigger_type = fields.Selection(related="configuration_step_id.trigger_type") + trigger_type_data = fields.Json(compute="_compute_trigger_type_data") + step_icon = fields.Char(compute="_compute_step_info") + step_name = fields.Char(compute="_compute_step_info") + state = fields.Selection( + [ + ("scheduled", "Scheduled"), + ("done", "Done"), + ("expired", "Expired"), + ("rejected", "Rejected"), + ("error", "Error"), + ("cancel", "Cancelled"), + ], + default="scheduled", + readonly=True, + ) + error_trace = fields.Text(readonly=True) + parent_position = fields.Integer( + compute="_compute_parent_position", recursive=True, store=True + ) + + # Mailing fields + message_id = fields.Char(readonly=True) + mail_status = fields.Selection( + [ + ("sent", "Sent"), + ("open", "Opened"), + ("bounce", "Bounced"), + ("reply", "Replied"), + ], + readonly=True, + ) + mail_clicked_on = fields.Datetime(readonly=True) + mail_replied_on = fields.Datetime(readonly=True) + mail_opened_on = fields.Datetime(readonly=True) + activity_done_on = fields.Datetime(readonly=True) + is_test = fields.Boolean(related="record_id.is_test", store=True) + step_actions = fields.Json(compute="_compute_step_actions") + + @api.depends("trigger_type") + def _compute_trigger_type_data(self): + trigger_types = self.env["automation.configuration.step"]._trigger_types() + for record in self: + record.trigger_type_data = trigger_types[record.trigger_type] + + @api.depends("parent_id", "parent_id.parent_position") + def _compute_parent_position(self): + for record in self: + record.parent_position = ( + (record.parent_id.parent_position + 1) if record.parent_id else 0 + ) + + @api.depends("step_type") + def _compute_step_info(self): + step_icons = self.env["automation.configuration.step"]._step_icons() + step_name_map = dict( + self.env["automation.configuration.step"]._fields["step_type"].selection + ) + for record in self: + record.step_icon = step_icons.get(record.step_type, "") + record.step_name = step_name_map.get(record.step_type, "") + + def _check_to_execute(self): + if ( + self.configuration_step_id.trigger_type == "mail_not_open" + and self.parent_id.mail_status in ["open", "reply"] + ): + return False + if ( + self.configuration_step_id.trigger_type == "mail_not_reply" + and self.parent_id.mail_status == "reply" + ): + return False + if ( + self.configuration_step_id.trigger_type == "mail_not_clicked" + and self.parent_id.mail_clicked_on + ): + return False + if ( + self.configuration_step_id.trigger_type == "activity_not_done" + and self.parent_id.activity_done_on + ): + return False + return True + + def run(self, trigger_activity=True): + self.ensure_one() + if self.state != "scheduled": + return self.browse() + if ( + self.record_id.resource_ref is None + or not self.record_id.resource_ref.filtered_domain( + safe_eval(self.configuration_step_id.applied_domain) + ) + or not self._check_to_execute() + ): + self.write({"state": "rejected", "processed_on": fields.Datetime.now()}) + return self.browse() + try: + result = getattr(self, "_run_%s" % self.configuration_step_id.step_type)() + self.write({"state": "done", "processed_on": fields.Datetime.now()}) + if result: + childs = self._fill_childs() + if trigger_activity: + childs._trigger_activities() + return childs + except Exception: + buff = StringIO() + traceback.print_exc(file=buff) + traceback_txt = buff.getvalue() + self.write( + { + "state": "error", + "error_trace": traceback_txt, + "processed_on": fields.Datetime.now(), + } + ) + return self.browse() + + def _fill_childs(self, **kwargs): + return self.create( + [ + activity._create_record_activity_vals( + self.record_id.resource_ref, + parent_id=self.id, + record_id=self.record_id.id, + **kwargs + ) + for activity in self.configuration_step_id.child_ids + ] + ) + + def _run_activity(self): + record = self.env[self.record_id.model].browse(self.record_id.res_id) + + vals = { + "summary": self.configuration_step_id.activity_summary or "", + "note": self.configuration_step_id.activity_note or "", + "activity_type_id": self.configuration_step_id.activity_type_id.id, + "automation_record_step_id": self.id, + } + if self.configuration_step_id.activity_date_deadline_range > 0: + range_type = self.configuration_step_id.activity_date_deadline_range_type + vals["date_deadline"] = fields.Date.context_today(self) + relativedelta( + **{range_type: self.configuration_step_id.activity_date_deadline_range} + ) + user = False + if self.configuration_step_id.activity_user_type == "specific": + user = self.configuration_step_id.activity_user_id + elif self.configuration_step_id.activity_user_type == "generic": + user = record[self.configuration_step_id.activity_user_field_id.name] + if user: + vals["user_id"] = user.id + record.activity_schedule(**vals) + return True + + def _run_mail(self): + author_id = self.configuration_step_id.mail_author_id.id + composer_values = { + "author_id": author_id, + "record_name": False, + "model": self.record_id.model, + "composition_mode": "mass_mail", + "template_id": self.configuration_step_id.mail_template_id.id, + "automation_record_step_id": self.id, + } + res_ids = [self.record_id.res_id] + composer = ( + self.env["mail.compose.message"] + .with_context(active_ids=res_ids) + .create(composer_values) + ) + composer.write( + composer._onchange_template_id( + self.configuration_step_id.mail_template_id.id, + "mass_mail", + self.record_id.model, + self.record_id.res_id, + )["value"] + ) + # composer.body = + extra_context = self._run_mail_context() + composer = composer.with_context(active_ids=res_ids, **extra_context) + # auto-commit except in testing mode + auto_commit = not getattr(threading.current_thread(), "testing", False) + if not self.is_test: + # We just abort the sending, but we want to check how the generation works + composer._action_send_mail(auto_commit=auto_commit) + self.mail_status = "sent" + return True + + def _get_mail_tracking_token(self): + return tools.hmac(self.env(su=True), "automation_oca", self.id) + + def _get_mail_tracking_url(self): + return werkzeug.urls.url_join( + self.get_base_url(), + "automation_oca/track/%s/%s/blank.gif" + % (self.id, self._get_mail_tracking_token()), + ) + + def _run_mail_context(self): + return {} + + def _run_action(self): + self.configuration_step_id.server_action_id.with_context( + active_model=self.record_id.model, + active_ids=[self.record_id.res_id], + ).run() + return True + + def _cron_automation_steps(self): + childs = self.browse() + for activity in self.search( + [ + ("state", "=", "scheduled"), + ("scheduled_date", "<=", fields.Datetime.now()), + ] + ): + childs |= activity.run(trigger_activity=False) + childs._trigger_activities() + self.search( + [ + ("state", "=", "scheduled"), + ("expiry_date", "!=", False), + ("expiry_date", "<=", fields.Datetime.now()), + ] + )._expiry() + + def _trigger_activities(self): + # Creates a cron trigger. + # On glue modules we could use queue job for a more discrete example + # But cron trigger fulfills the job in some way + for date in set(self.mapped("scheduled_date")): + if date: + self.env["ir.cron.trigger"].create( + { + "call_at": date, + "cron_id": self.env.ref("automation_oca.cron_step_execute").id, + } + ) + + def _expiry(self): + self.write({"state": "expired", "processed_on": fields.Datetime.now()}) + + def cancel(self): + self.filtered(lambda r: r.state == "scheduled").write( + {"state": "cancel", "processed_on": fields.Datetime.now()} + ) + + def _activate(self): + todo = self.filtered(lambda r: not r.scheduled_date) + for record in todo: + config = record.configuration_step_id + record.scheduled_date = fields.Datetime.now() + relativedelta( + **{config.trigger_interval_type: config.trigger_interval} + ) + todo._trigger_activities() + + def _set_activity_done(self): + self.write({"activity_done_on": fields.Datetime.now()}) + self.child_ids.filtered( + lambda r: r.trigger_type == "activity_done" + and not r.scheduled_date + and r.state == "scheduled" + )._activate() + + def _set_mail_bounced(self): + self.write({"mail_status": "bounce"}) + self.child_ids.filtered( + lambda r: r.trigger_type == "mail_bounce" + and not r.scheduled_date + and r.state == "scheduled" + )._activate() + + def _set_mail_open(self): + self.filtered(lambda t: t.mail_status not in ["open", "reply"]).write( + {"mail_status": "open", "mail_opened_on": fields.Datetime.now()} + ) + self.child_ids.filtered( + lambda r: r.trigger_type + in ["mail_open", "mail_not_reply", "mail_not_clicked"] + and not r.scheduled_date + and r.state == "scheduled" + )._activate() + + def _set_mail_clicked(self): + self.filtered(lambda t: not t.mail_clicked_on).write( + {"mail_clicked_on": fields.Datetime.now()} + ) + self.child_ids.filtered( + lambda r: r.trigger_type == "mail_click" + and not r.scheduled_date + and r.state == "scheduled" + )._activate() + + def _set_mail_reply(self): + self.filtered(lambda t: t.mail_status != "reply").write( + {"mail_status": "reply", "mail_replied_on": fields.Datetime.now()} + ) + self.child_ids.filtered( + lambda r: r.trigger_type == "mail_reply" + and not r.scheduled_date + and r.state == "scheduled" + )._activate() + + @api.depends("state") + def _compute_step_actions(self): + for record in self: + record.step_actions = record._get_step_actions() + + def _get_step_actions(self): + """ + This should return a list of dictionaries that will have the following keys: + - icon: Icon to show (fontawesome icon like fa fa-clock-o) + - name: name of the action to show (translatable value) + - done: if the action succeeded (boolean) + - color: Color to show when done (text-success, text-danger...) + """ + if self.step_type == "activity": + return [ + { + "icon": "fa fa-clock-o", + "name": _("Activity Done"), + "done": bool(self.activity_done_on), + "color": "text-success", + } + ] + if self.step_type == "mail": + return [ + { + "icon": "fa fa-envelope", + "name": _("Sent"), + "done": bool(self.mail_status and self.mail_status != "bounced"), + "color": "text-success", + }, + { + "icon": "fa fa-envelope-open-o", + "name": _("Opened"), + "done": bool( + self.mail_status and self.mail_status in ["reply", "open"] + ), + "color": "text-success", + }, + { + "icon": "fa fa-hand-pointer-o", + "name": _("Clicked"), + "done": bool(self.mail_status and self.mail_clicked_on), + "color": "text-success", + }, + { + "icon": "fa fa-reply", + "name": _("Replied"), + "done": bool(self.mail_status and self.mail_status == "reply"), + "color": "text-success", + }, + { + "icon": "fa fa-exclamation-circle", + "name": _("Bounced"), + "done": bool(self.mail_status and self.mail_status == "bounce"), + "color": "text-danger", + }, + ] + return [] diff --git a/automation_oca/models/automation_tag.py b/automation_oca/models/automation_tag.py new file mode 100644 index 0000000..1c8c490 --- /dev/null +++ b/automation_oca/models/automation_tag.py @@ -0,0 +1,20 @@ +# Copyright 2024 Dixmit +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from random import randint + +from odoo import api, fields, models + + +class AutomationTag(models.Model): + + _name = "automation.tag" + _description = "Automation Tag" + + @api.model + def _get_default_color(self): + return randint(1, 11) + + name = fields.Char(required=True) + color = fields.Integer(default=lambda r: r._get_default_color()) + active = fields.Boolean(default=True) diff --git a/automation_oca/models/link_tracker.py b/automation_oca/models/link_tracker.py new file mode 100644 index 0000000..d2fb24a --- /dev/null +++ b/automation_oca/models/link_tracker.py @@ -0,0 +1,45 @@ +# Copyright 2024 Dixmit +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import api, fields, models + + +class LinkTrackerClick(models.Model): + _inherit = "link.tracker.click" + + automation_record_step_id = fields.Many2one("automation.record.step") + automation_configuration_step_id = fields.Many2one( + related="automation_record_step_id.configuration_step_id", store=True + ) + automation_configuration_id = fields.Many2one( + related="automation_record_step_id.configuration_id", store=True + ) + + @api.model + def add_click(self, code, automation_record_step_id=False, **route_values): + if automation_record_step_id: + tracker_code = self.env["link.tracker.code"].search([("code", "=", code)]) + if not tracker_code: + return None + ip = route_values.get("ip", False) + if self.search_count( + [ + ( + "automation_record_step_id", + "=", + automation_record_step_id, + ), + ("link_id", "=", tracker_code.link_id.id), + ("ip", "=", ip), + ] + ): + return None + route_values["link_id"] = tracker_code.link_id.id + click_values = self._prepare_click_values_from_route( + automation_record_step_id=automation_record_step_id, **route_values + ) + click = self.create(click_values) + click.automation_record_step_id._set_mail_open() + click.automation_record_step_id._set_mail_clicked() + return click + return super().add_click(code, **route_values) diff --git a/automation_oca/models/mail_activity.py b/automation_oca/models/mail_activity.py new file mode 100644 index 0000000..b9cdfd4 --- /dev/null +++ b/automation_oca/models/mail_activity.py @@ -0,0 +1,15 @@ +# Copyright 2024 Dixmit +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class MailActivity(models.Model): + _inherit = "mail.activity" + + automation_record_step_id = fields.Many2one("automation.record.step") + + def _action_done(self, *args, **kwargs): + if self.automation_record_step_id: + self.automation_record_step_id._set_activity_done() + return super()._action_done(*args, **kwargs) diff --git a/automation_oca/models/mail_mail.py b/automation_oca/models/mail_mail.py new file mode 100644 index 0000000..93e42c1 --- /dev/null +++ b/automation_oca/models/mail_mail.py @@ -0,0 +1,51 @@ +# Copyright 2024 Dixmit +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import re + +import markupsafe +import werkzeug.urls + +from odoo import api, fields, models, tools + + +class MailMail(models.Model): + + _inherit = "mail.mail" + + automation_record_step_id = fields.Many2one("automation.record.step") + + @api.model_create_multi + def create(self, values_list): + records = super().create(values_list) + for record in records.filtered("automation_record_step_id"): + record.automation_record_step_id.message_id = record.message_id + return records + + def _send_prepare_body(self): + body = super()._send_prepare_body() + if self.automation_record_step_id: + body = self.env["mail.render.mixin"]._shorten_links(body, {}, blacklist=[]) + token = self.automation_record_step_id._get_mail_tracking_token() + for match in set(re.findall(tools.URL_REGEX, body)): + href = match[0] + url = match[1] + + parsed = werkzeug.urls.url_parse(url, scheme="http") + + if parsed.scheme.startswith("http") and parsed.path.startswith("/r/"): + new_href = href.replace( + url, + "%s/au/%s/%s" + % (url, str(self.automation_record_step_id.id), token), + ) + body = body.replace( + markupsafe.Markup(href), markupsafe.Markup(new_href) + ) + body = tools.append_content_to_html( + body, + '' + % self.automation_record_step_id._get_mail_tracking_url(), + plaintext=False, + ) + return body diff --git a/automation_oca/models/mail_thread.py b/automation_oca/models/mail_thread.py new file mode 100644 index 0000000..9436578 --- /dev/null +++ b/automation_oca/models/mail_thread.py @@ -0,0 +1,70 @@ +# Copyright 2024 Dixmit +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import api, models, tools + + +class MailThread(models.AbstractModel): + + _inherit = "mail.thread" + + @api.model + def _routing_handle_bounce(self, email_message, message_dict): + """We want to mark the bounced email""" + result = super(MailThread, self)._routing_handle_bounce( + email_message, message_dict + ) + bounced_msg_id = message_dict.get("bounced_msg_id") + if bounced_msg_id: + self.env["automation.record.step"].search( + [("message_id", "in", bounced_msg_id)] + )._set_mail_bounced() + return result + + @api.model + def _message_route_process(self, message, message_dict, routes): + """Override to update the parent mailing traces. The parent is found + by using the References header of the incoming message and looking for + matching message_id in automation.record.step.""" + if routes: + thread_references = ( + message_dict["references"] or message_dict["in_reply_to"] + ) + msg_references = tools.mail_header_msgid_re.findall(thread_references) + if msg_references: + records = self.env["automation.record.step"].search( + [("message_id", "in", msg_references)] + ) + records._set_mail_open() + records._set_mail_reply() + return super(MailThread, self)._message_route_process( + message, message_dict, routes + ) + + @api.model + def get_automation_access(self, doc_ids, operation, model_name=False): + """Retrieve access policy. + + The behavior is similar to `mail.thread` and `mail.message` + and it relies on the access rules defines on the related record. + The behavior can be customized on the related model + by defining `_automation_record_access`. + + By default `write`, otherwise the custom permission is returned. + """ + DocModel = self.env[model_name] if model_name else self + create_allow = getattr(DocModel, "_automation_record_access", "write") + if operation in ["write", "unlink"]: + check_operation = "write" + elif operation == "create" and create_allow in [ + "create", + "read", + "write", + "unlink", + ]: + check_operation = create_allow + elif operation == "create": + check_operation = "write" + else: + check_operation = operation + return check_operation diff --git a/automation_oca/readme/CONTRIBUTORS.md b/automation_oca/readme/CONTRIBUTORS.md new file mode 100644 index 0000000..d1fd286 --- /dev/null +++ b/automation_oca/readme/CONTRIBUTORS.md @@ -0,0 +1 @@ +- Enric Tobella ([Dixmit](https://www.dixmit.com/)) diff --git a/automation_oca/readme/CREDITS.md b/automation_oca/readme/CREDITS.md new file mode 100644 index 0000000..9823448 --- /dev/null +++ b/automation_oca/readme/CREDITS.md @@ -0,0 +1,3 @@ +The development of this module has been financially supported by: + +- Associacion Española de Odoo ([AEODOO](https://www.aeodoo.org/)) diff --git a/automation_oca/readme/DESCRIPTION.md b/automation_oca/readme/DESCRIPTION.md new file mode 100644 index 0000000..0f0845e --- /dev/null +++ b/automation_oca/readme/DESCRIPTION.md @@ -0,0 +1,7 @@ +This module allows to automate several process according to some rules. + +This is useful for creating automated actions on your database like: + +- Send a welcome email to all new partners (or filtered according to some rules) +- Remember to online customers that they forgot their basket with some items +- Send documents to sign to all new employees diff --git a/automation_oca/readme/USAGE.md b/automation_oca/readme/USAGE.md new file mode 100644 index 0000000..43bdc61 --- /dev/null +++ b/automation_oca/readme/USAGE.md @@ -0,0 +1,49 @@ +Configure your processes +------------------------ + +1. Access the `Automation` menu. +2. Create a new Configuration. +3. Set the model and filters. +4. Create the different activities. +5. Press `Start`. Now, every 6 hours, a process will check if new records need to be created. +6. Inside the process, you can check all the created items. + +![Configuration Screenshot](./static/description/configuration.png) + +Configuration of activities +--------------------------- + +Activities can trigger one of the following options: + +- `Email`: Sends an email using a template. +- `Server Action`: Executes a server action. +- `Activity`: Creates an activity to the related record. + +All the activities need to specify the moment of execution. We will set the number of hours/days and a trigger type: + +- `Start of workflow`: It will be executed at the previously-configured time after we create the record. +- `Execution of another activity`: It will be executed at the previously-configured time after the previous activity is finished properly. +- `Mail opened`: It will be executed at the previously-configured time after the mail from the previous activity is opened. +- `Mail not opened`: It will be executed at the previously-configured time after the mail from the previous activity is sent if it is not opened before this time. +- `Mail replied`: It will be executed at the previously-configured time after the mail from the previous activity is replied. +- `Mail not replied`: It will be executed at the previously-configured time after the mail from the previous activity is opened if it has not been replied. +- `Mail clicked`: It will be executed at the previously-configured time after the links of the mail from the previous activity are clicked. +- `Mail not clicked`: It will be executed at the previously-configured time after the mail from the previous activity is opened and no links are clicked. +- `Mail bounced`: It will be executed at the previously-configured time after the mail from the previous activity is bounced back for any reason. +- `Activity done`: It will be executed at the previously-configured time after the activity from the previous action is done. +- `Activity not done`: It will be executed at the previously-configured time after the previous action is executed if the related activity is not done. + +Important to remember to define a proper template when sending the email. +It will the template without using a notification template. +Also, it is important to define correctly the text partner or email to field on the template + +Records creation +---------------- + +Records are created using a cron action. This action is executed every 6 hours by default. + +Activity execution +------------------ + +Activities are executed using a cron action. This action is executed every hour by default. +On the record view, you can execute manually an action. diff --git a/automation_oca/security/ir.model.access.csv b/automation_oca/security/ir.model.access.csv new file mode 100644 index 0000000..43c4efe --- /dev/null +++ b/automation_oca/security/ir.model.access.csv @@ -0,0 +1,14 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_automation_configuration,Access Automation Configuration,model_automation_configuration,group_automation_user,1,0,0,0 +manage_automation_configuration,Access Automation Configuration,model_automation_configuration,group_automation_manager,1,1,1,1 +access_automation_configuration_step,Access Automation Configuration Activity,model_automation_configuration_step,group_automation_user,1,0,0,0 +manage_automation_configuration_step,Access Automation Configuration Activity,model_automation_configuration_step,group_automation_manager,1,1,1,1 +access_automation_record,Access Automation Record,model_automation_record,group_automation_user,1,0,0,0 +manage_automation_record,Access Automation Record,model_automation_record,group_automation_manager,1,1,1,1 +access_automation_filter,Access Automation filter,model_automation_filter,group_automation_user,1,0,0,0 +manage_automation_filter,Access Automation filter,model_automation_filter,group_automation_manager,1,1,1,1 +access_automation_tag,Access Automation tag,model_automation_tag,group_automation_user,1,0,0,0 +manage_automation_tag,Access Automation tag,model_automation_tag,group_automation_manager,1,1,1,1 +access_automation_record_step,Access Automation Record Activity,model_automation_record_step,group_automation_user,1,0,0,0 +manage_automation_record_step,Access Automation Record Activity,model_automation_record_step,group_automation_manager,1,1,1,1 +manage_automation_configuration_test,Access Automation Configuration Test,model_automation_configuration_test,group_automation_manager,1,1,1,1 diff --git a/automation_oca/security/security.xml b/automation_oca/security/security.xml new file mode 100644 index 0000000..4966a77 --- /dev/null +++ b/automation_oca/security/security.xml @@ -0,0 +1,33 @@ + + + + User + + + + Manager + + + + + + + + + Automation Configuration Company rule + + + ['|', ('company_id', 'in', company_ids), ('company_id', '=', False)] + + + Automation Configuration Company Activity rule + + + ['|', ('configuration_id.company_id', 'in', company_ids), ('configuration_id.company_id', '=', False)] + + + diff --git a/automation_oca/static/description/configuration.png b/automation_oca/static/description/configuration.png new file mode 100644 index 0000000000000000000000000000000000000000..cda0f7f73d915ef0a19227f20af3c039a85169e5 GIT binary patch literal 55863 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­+r3B|su#B&}Orek0eZ?_6WV7ww!j0`c^`m@>`BP zAZo#K-`xv+b|XeIU@}D~Xhl3D_5cvqIs%^SKo)+GQ#gO}-#OAu>I+l3)U2$>`Zh8^ z^!zChA4-kh-C^%Id`Vl%`hvUv;}!Wkw-BMidTI3$&*Ts%b(P9Gq;CMc=Og$Bj``oI zMa-;L6QG4B6aM`+IQy|Ry9o}Isc@suDgC<&zxTb*tfz2R)H(tDv3`q-36q#)26Q6#+J6;6~YvzWO1A@xlx z^Ukk7?2_(lJVD|kYxNnpj*r9WS5W%U9mEdg9dPD;`0PZBEzXzAW{vlC%AppLo(+e0 z=Y(3h+@G!p%QQa)Se2(ib;k&pVu%_=PJ*x{4#qwf(-mp*1d7a!LK*I}Zp=NEQU{;T zQ&dd4pH_|MDZ!QGP?E&v%y2)~(Qo9lInNimQ^3%8)Zu=xXq*|_ zngQz3WAX{#bwb2*TQW8jm)+<)rq>cQLc-j&(RZ@I!)84XzM6_uC=rUXg5+{SbF?la zmp>VP&?=I6TwakK$PqFT8QXO~IA{aM3mr3w+Y$($w1Idztof`n)GyY%e}(HT1@{qk z#9yg=n1q;={VTWlwBC37zK1T($vly_yUgUND`DgKxAu>Lmk3vc-OzzXT0p*}f|C#O z`m?g{3W90x+|jK%+Zvrz^_VmT!VuW0tFm3Vy2MSpCPAzQJDNm?%T{)ueoY5GH!P1Z zN>x6WfmjS4XowEI@h~e8iBu)f=>@fW>Rq8d?viCYWy~(7uauS(eDPicSoZxnxqa)C zl}=M7hGbIgx3z9;Q?X1;ur#Q~O@whG(nrbdgAcGU=}LSbbR84v|(Tz zL&g&frqJ>4m8fu%8gEu+!DpHDCnu})2VXWm9C_0#8T>}?y})HBi?`T1LqFelM&44h zKQ!PsK1rZn*2w73SaldLCk{~X3xyLQBWhhsk?uDL_WHSmU34?w?`5ZW z+%1$-#8>QGhhQeE02Ye1R0fvPdQ>_dP%L6Ao7mQW`^s6!JoZ*pA55`HM&xWvVQ(ei zR&P!z_x}tY6C@QX=4>#73f2wHIRR8U6nQOn4PED@LxWB!ro)ZMRh{gLxT zlDJhmUcu|z`H&9#Rn|+kQy}v2{WgqvRFu(Vq1{Bg9FmiId49H?gyE2SjW+=H9-(AG zge_uFqs9`zpTx}thlqODYW?+*Cl27-x%H+j)1Fo(y?jDpCZmrK)E>BdYAd%KdHca_ zj9~UV$)W$E<>Rv2k>6?5Nxx6>d^TrN)rb^}EW|CD6d7H%q9Ks4p#bKPV$qSwqT7w8 z${1LpL<#xeHXspnvMa2JES<4AnpHelrsSer5hr%7JqC|c!`Ge+t?9gT+!f%Sprz{$ zjl(a#T?x~9w>>Vz7tALC=3B}6T&e|do>%?|>B$J~_~B_B?E$un;of|K^f1@?U?eC* z*EG?-^mE=(CTRrI0}l61x$&q~*n(1BK&wy%3HWirn2LjfOI6(A`XE9%d5YtML{6_w zQ~51-;e2BN@MB9@L=g(6|H$QTB|lF}7PkvUnFA!pT${cB95d`b$p(~?HJ4AYsxjAV zPgXm$WM-uz4arAvpvdo@Dm0{ZT4l`XyKWdKr;O}Y_i3y$!?>4GuMA{c&1gxSw*w82 zrMJ7Lgnf?sry+U!jCw#y`~mqUpfR%O+P4R;tAmgW#0QU~l$7O!jc;o(B-2=0cJVRyfak+Sk0=e|%X+jF!KRcF)_fmJ*k zu-wq*DA2#BK7vAwuh8$KBsBMH^q^2;?e6Urj))me+}g?IjHG7GL7lt)S+F)WQwLHb zK{PZf>G}>vnF2{1jV;~~%E%tI-`;6HT(ijQA>I5+L1RKFtNxId!7Z0aheA*q2i zXjtCHU|ad3B-3yk@gjw*U>~GWS&XQ8i7=G2I7yjv1L%XqmSME2>}qFu}iz zfmgT5vVTARWXSe*rlLWXnjFf`mhNpbL8FaxbS{j(St1LXB)p>t$JQy*8#HZ>xCw=q zNYslknkYoH@^7`pMvbYu&KQ44(8^a&LwIMMlsxcEx-oEZMR#TvawGc5Ps?_O5nN2% z$K2;XNa*&wb5(tVRjt3Mn$M^?vU5CMC_;&iP=?xaDEEVMoFXJ&q9jX%Ai|KYkVYuT za|n`xXEwT@YB;-@(qoLYQIkfk}eNg*VV};oG-qWYTQvUWZ=GmiD@*8T2qEmCQ=XX*>6EUoWzL+Fk@}CyTU&ww~ z(*=kh^8tBSqfk2vzi-n^wm70CUFKJN%Tb2Ns4F_`BYSlv=IFxInhM#_F$Bc!P`_xi z=#?lyqE=@nzM;22&@S_zBC*v(9&Y!clnj6=u=*=zr3j+RP_19LlYa@K&I5UBrQ`z;VDl% z7SKdFKG2yW1XV3VH1vUl8N>R^Ev!g{U;-`1jd@l#B<>dvP^96{GTt*7ZdTGa>ey9y zl6=~PN(4&-=N{)AmS_sIKX>mP3Iccc6M!F3Q@C;7a0VatO)(JdOzVEF3y{+xsdC_l zLrDSAa|tGOU({JM!G4@mmaukuSoUQ0v1Ks+Gi~c+F~*t6%aa@qxZ#0&yToDC~D^w z|L?!__QZTpkrpwaG*)eBY5Hl1VV(g6!#9~Tg7j7IjtF~b)57mGuNH(j#^f0_PnqZ3 zRJHjlssbnE&nHc%@QgmuZj2=;*VtI7x>h?*pEAKXh(>KX8qT<=v_Nlas!0^w=J91a z^LjelzNXN-CLu3Jd@FTDJf5(1UDIgS?IfGQ6Y?nj9E2T!g0z?6p*!-!?FRy`T~?v_ zWBa^F!DK?*(a(sY=UI+OqPed!2{K+zMjZ!D_sE78-SV!n&}w>VBfDLkM!a@@+L8{1u_K4G z%8KvM#C6UNpojRq;iJF-20>XP0_nNsMSS=eZRi?#PYg z?}8O7{RPzK!kJ<{DLc!!O`0bJksCmK&~$Y?#ED03xXDOc9BWGlhTrSx_!<_+uU ztbiyb7S?`(Jvbx|=RRy0W*M(&htm7MtLj#_CGoSt>vp9maU&ppKB3$xrOkB*EBn42 z@^hJ)PLRWrqz`%3n_gy^u@s0%c;@y-dw6Kjz1Y3;$cnr=LAwv})NZi*bCKtqN_;pAar=SYJ8mz-sS59DnP`nw3VVk*qmF5Ol3#Wa!qcnWUpUAhc>s$H zcK7d}{q<`|0-`S6um14AMUIhd$%SqRC+x0#LpaZU!RJ2n!M9D|_ns6x9#}nbOU9Gc z@gfjOKB}^*5CzH09v*hnlpKFPtkmws^rga8z&X&x%W^ zr2>fS_lQCMqKb$N+-KXvePXy?qa#BXP56`Z;bAEu@p(@HPvc$0hK}QkPsds>lH`#O z9BP7`$>3=(oB3zFs&ktrmuZ^vmM0Vha z4&1pSx_R%bt7GpF4)&P7iJY$Jp2*X~@xaVyJ4uV=cQqj{Kdeo(Q0S0N*tN?6m)){? zFUrXJ?G^CrEJR<8&Rv`l`rIh|vY0MI1>~2ng$;Qf5h_Jb1P*OpxOezuwB?wQ%^YYG z5vX_8G$}as)k=Q^#KL&Hj6~%~b+~>AoO=OU2z7*+l^9xJ$&$6%U967zl|V-wpZRD@ zoj1>vYA>Y%=_Ic@!(Z)S)>n^4!?AX1ubU&k#utYX(4%jC?056H_-_>Z#RpAs}(PD);CbBzxqH192>CsZA?0D`OEi%w3fvs>PAm{<>-w zUH>V&^el$=&l(x6StiMAW(r7V`ajgb0nEWl>h*!6x!t1d9D-!^}+f zRWVwrzPc4${4jZY%GXhxPwWT(S&<7(4EXliQR+Wy%>9$#+9zpoJI%M=m8YYphve8$ zStnRItlfzwCep=EjTH8dD);?9+)ttIUF#X`WnA(2eYk#ax})(H&C3^~Xy0ZpZYHDT znI~kPmq`ui%tc3Wx_f)++1n=DUgrY9C6{_guE$AGY_`Goay=D1`{8;0sY8hzn9Pyy z>jU?MPqW9MDy4;2i+{y&3kgUhEYPi99^SoxmuYZ(Uj&IpEi4L2q*lhRO0a@sZPlZHX(O8n5b(M= zi;76OyBK0dZFsxXU{}!En!1DgRnpmcx`^?N!zeeqM%_B_)5d62fyE;Cp6dwUX+vm9 z@$7(ObzX5U4w8NpWX-*83pnKMnR6lKwTZ1d7)kiR^MvsAzVrzGFs{;Di*|o5a_xSX zc=N=r?`%C*?_#i7MZM6u7yd`RO0&(<^Dcz9vT~jA`CN}a8Q55_XnQLrDn{kY#YkDq zh8KQRA$-Mqt;Z#T)vfF{JZu^}Ml(R9y9U)mxPOB$-;lcGkY9xn9_c@o;(4^Z;qjk7TSXE?%vZd z(XRMi5EBy01-?UWSI2L0J3u6$o+wtOywuc7~-{7D*7Vd4` zqCgBD%^rc{8w8O%okAh|lhA+H0${N~*e#7eF6BkuSC^Ow|B^6S-CBQjs{l`^3HzA% zK-3U`TVTX>2eQl0t&ME}dDy*F3u<^De&~epha8g9?A8WrN8pvh7td^>7FfUREA_oc z+N?>KBz|wt-1T@-Y+*DlHn>YwP3;XDCZ?$M(Od=v)?z#KJmvyd*0CO+;(VPIu3jnV z_en8bc6N@%ulT`w#h+z5GX4VHeSK4(SOZ*O4;R$5q`(i?QZ~Np2s}F#L$s*8Zbz%V zJ7^q;AE_jP@&T6^=8K=wyJPEh-CQIL=SoRBip#^4b5EYnS$LmR_@G{YK>n!Av37W( zo839@52NoV@P_ljkk@9P#G|F73(cj9xCI$xL1L+Frf5wirK%T)ntVA62c?g=d3ncr zvm87jIJkMg;*+J@4sPh^=sR3n;^JE70Y3a{Ev{8ob4-InT}SV^J7?Ah$`9&YPgmu{ zdgkmP=MO86^Wc1>iI()&RwhJFHF?~8u>ML7%2?;-Pz`tTrc39(BOhN(^(j-A^ z=@?m2Ye8L5$UT^;a9g|dyhi<$?|FToRPj|HbJd`sh+f`fT&pCzG!{6CNLP=0zQ4hJ z*JU*%*>bb1JJVWD4@u9O0U^?IIgDx4e)DwHwfI1^Ni-Iis`ON<=X82PGGjQFE`Wub zp!oHx@9&E=zK&d?Ye~tw2NB(o%Ir$>_pGcF(>X+P$sF%8otL;?MW@XRRc&TY`Df() zL%}ri+c(Kva;9!Gwh$u!3<=41FnkO7AJj)K{;0x28fE3=>JmrX>%+N%b&p-2V^;+j zXxOwLhCjmsqY74rY^F%Kn9{edZmyaS5guNgfG;ue6jJDXSq7hH42haN-Xdl6jy0=Q zKW*bNy!0Z=9;6^)$El)0G6?pzpa^;1-=k@I7bY%=uXy`>ErxdsP{|Sre3$CBI`_yY zaN7Fr`{%5K8`rm95^zWFu?7cSi5&R`f~fa8kRO?z8Wo(KIdFB+`1Q4PyMm~5%P*5Bh)raQtXVZo&@e?ZPE0&X z5dRKHFki0O&3w-_TjJI)_0p98Y8m}1rjIgx{?nQZ$EjCt^X;w6%UX79?&ji6ScI5& zPR|eOMu?x4o1=M;(I5aAI16zbU(Je9{&I7)pyDGCu$bWo(DT(A4f3qe00R?q^q%Dn5*Ea)<*Lxti)mErOuJwMQ3{lF2?C~}@|NnWeEK56 z4bG(+9fA(X_!z`~f1ck{r62yi1!DV2si=}6;wWV~nYhy8R%13sl%3tGeDLRA*LlMq zAbjG2P{0Ubk@NO>P1QNd*VuWIrw>9Gj?>A%mfWSQ3jfvA{y$Ud0;zEE#|jD4X(&3x zc!g`f_IBYQj=Bw5g63~YOYTp+|E-O^()h{Ecki50Fjh9CZ3k>c*9vtwD9R4ike(Vu zp87H&-`^$Xpik9YIXZv=zh!&8kb;d0`g~;Y%jL(#3=JoPXnCH%re>FE(h_RFOSZ)V zqBh*WfSG3>Hbd>_7G(%)vunP5#gi&asdDO)_#Ra)W%$TYZcDh~rp=)pj7^ zC4DqkT`~DJLR04D?(#*hl8kQdC61iEO`g&sK|EC4GBcy=p$RFQJs%UZ$+u7jh|do= zm+DwmVKl%cz(9wyG^rw9zWWkWW~pZh6fL3GM2!ui!*#%!czo+M&QKVN;DXQ{8iu)7 zPn@%K1o=mJJHSB8%Og6h0le;flCMTR`7ag|9nmjy9}v)2is9y5E!s z-ZW!qhfN4MrJyiLemJ(%rKYkofnCI_5Y_|*d>j3&hUI-m?fCxDafo}4^|H+TVVlF~ z{&aQYl02<%PKIRtDb1R`?0D&sHcXY zyN!B5EaUt>(lwVt0==yTPs$|Y(pCw(GKSNNdxDRMBs3PMNO#;GSD?eMYZW{l^>*-Q z30zOGRfq3e3tUPx+&$Tw4%c#9;_R?*U*O*6>MC|Fb|gTdX}3jYpX~R!>({-{r$DFC z9ZV+lR(lcRCa-$tMZEn%l;!eNmdR^ggh=di@EW@0)?`5F#5;leRs4m1QmU0ia(c9+ zu?P_T-dudtW%u&rNDPaBNpoK1^T16|E(Oe5SQ>h5v`_jYp0! z@*}5B2|vS|!9e+yF)KJ#rMlBEFYq}8@`ZzdAoy3Mh_VJCa-LndjuIR8G{JkBuS$La z1BmFQr8S>-lZL&w;SJvYJ|TO)*{1?7x{#AM-l8A`^cLm91Qg3zFMA3yvL~Y>f1w7* z4vAA*Gn5)?tW$-4%#WUghoI_x4E!`)q2Xrp^^xU7WPL=*X(O6srb*a$a^l}77>Ki{ z6nQpq%Eh}lU-}9rt1X~9Q|22P1Evr7IAJD}#hkfA0C8wPt=xZCkN1*w4(uCqyfv2; z_hj!BONsnM`L_;+AS<&*+1b(wLuOV9Els*3NQ^lMpem!vG-gvY->|4 zu0Tb5oV=SOlXn$k{dw%Wm z=Q2@M6IW?}=@yb)^4o_jhD^X?kr2Y>Ce^2wSwhy$?0BIV4BrITrm9&>g;OGZi&OvBhlPb+o_q1 z)K7H_TFHXNp0cHB7lcH-grwDlef`F{IiA+Jj!}dV-H3)rEUS!5|=@ z-N@bS&u8^A-2q}aO2P}zvtnuwvsplGSzSKV%4t$hC+vHzx(gTadcOVb*OC;MPGOPV zNV%H3^*Al0G;H3hxI7@=Ek z+5X3H`&`bt_OQ7GIYZ$tDCm5K?Q6a%@|7<1)PXubH4w0%L!u$o!CNuscW%4e<>XLB zjs1eO;G;VRG#NsG@*~^D53SZ3^ZnImixX9%u>pP(MT9ZJ9_%*X-?1}wx6}0GJ%SW(R z-=yK+9UkpA+D&Hn2S!sB!EkS%&jaRST(+;DKA}DcTv3qoNwC0V(`C$E2S3rxsH@)Z_Zt||A?c_$% zUr-f~N*YWFyP%zq9cz_h_wX{Kvss!E#NM$-++jABd%(UD#DivkRc`G50pGqv*O$f6 z^p90+FP*T^n+o*BHgKVRIX!_v46$vn(v?W&Gt{Bhg%o<1b}pv=!m2OtKwSm~LYuq3HOkg%*Y_y0G=6k4)pvNia3mKY zxUclV8$(A_p24X<{;uD3sj!!Z@)kkl+jpAfMmwp*&g{((*?Pg9CQBWjt@PfmV$pww zKJZu|wP{3c-$?9Y-t%Ene1Kc3$A^T!kSvpNCJDM>&jA^5YD&R86O9Q0olKJDyu<_- zhLFfl8~$r=zn;LrEqD0lPRk|Dr}P%q+^@3YU1R-v7O&~9T&EZaaS|z=aFr4l!UTT- z5>ERC(}r4~cCLoX+bIpg2p-%bTLXl5Mfjh$KG^Qt@BBcgF1sm&3J7}h_VJ_=ZT|5I z3Zbp>a)8={WH+;fib^#YAtinDCU{5r!zXjSKAQat+uZGrt`xI*WVTms!0ZtgdE5Ek z4A@cx4Su8~^x5`#n{fWS)J?NdA7#v!Ic_cPRrvWBhP|R!-(*^1V~Pv(C4b#ay6in{ zeKuwuyo}oH{K3eS^TO$~k0oNE*K&ioZ9I(vQ|<{A8;Y+!s%nRM+ve^)p{(8}5ai2g zg=#uU&6jdoD`-zAli4S1yPx=FQOzjv64HTj>GAU6Y}kmIgf8vPobE7iSwqu-KL`JH zo;AN0ILNhmTkU_^IQV$%M11u=>%4w4o)ux=DN$$jdD49VU7k9R;rGGuMVVxTxpV9y z5(!Mw5sz(A^PuY?R|P?(Edr44Yx>jt-UiPS*WGx3?ceWzuV?cc*w-!rQ?Q)+bFs_B zU=ih>{M2~&JVs=!D%X>Hy>;EWpHKSmcrjY3DqL?w+x0gkW|YNJJL<&)xDCGdN0E)V zD9a(rJC?Al8p{?@aesO8{0muEC#LzPfLmmOSKf{<{%>szXl!&zX=YlV4jnY!DwWE8 z+3^f|-bZR-G)kK#%DhdS^GK{Rjz~Tg`0)Cu(ffaF48%L3+133gKiaph3WNAnR0P7u zKnk&8;s2W&xl_yWoe?g8PSSrzj1e}p%l%seF^A3Jq96W=lAMzlajBlHr$t==ga5G~ za@wfD+kbahDA=qG_S{|%6?jZPTBvzhf9~V- zJAGVG#>iIF9;9Vb`}Ti0Qv0jgC@~m7EZ3i!@imupfkaLpNrtUL#H(u%PO?{ zOGBe3r`+mQW9B-AH$+V16+D>fm`rABt7$qJoKg(~Y2E%aFJ=+lBIS7rl*C&1}{*;_gq1fb$S0Vev;}$_M z&@W+jc7scuN%#s~0iguVUieHQc!b}Tc=^Sh=ze=9>un#Gvcay|(Ea8xB?I`8iw>W{ z(c-0j`FRD)(6YQ1Y$yA$|5EMz$n-Nb-#{57;dy9V_hp5HDKqiKL;quqP(_mnfxyWN zs;v!N9IT(w%}|(DYjvWH+MDEB>mIk$J&CCISr?bsPpr2U3}7ABrJtMGuAr?JZkGb1 znl``4d%x6P_L{Jw(r<~4pOl&IseIsE3l4uwRC+TB;dgvv+!mJgf1gb0w2uwge@WwD z`dKVkzkQi>6C816J{6bd_d?(Cc#IKWPM=dPLV)XXGC&xJxlnD*dKrr0u@{WT!N1C# zX$h$q6N-afN{Qjz_!@L0ONEnZ2wf2l=1~z`h3Y0gUHyyo9V9I0okB<@Sd*WDn%2mV zI1ZT*a>x?kqXZp+XklfIKKarlufyYY$kEf9O@14Hh6lhl(@uG$uG72833q>P+un$J zzS)|!<8j40zXy3kLgNTPo-eB4>j?faBk~0cnHh$Q(X5ksbtf#>Zt@n?44ljiWz0yJ z+IpM0R>6PS-(oFmrSQV%IhkAhQ>+G;-QtMLmN-Rj&OMVsb{iJ*798|6^Liih_onSb z6)*2UMjZ}_VH?z|QB6Kl`|LaT@}#Ig3z>HChf%+;zF#23ibMderL zJ)_sJ({w5q>O%0kG0ZuhO>sQQ=vwdYx|9ct*debitMkR|bhRtH^ztRDL;u6O4{yXi zuXnAiWt4N4p)ccy zq4U5|HU88u@-E0A5kzc{314W`iFD?zJEx0$>oX>Bir33@GUvUTF-CuW={(2clp`UQ z!QD=UWzS<&_lZ&_5<`mhItn?WincvP>>NSaa}Z8D!!}_wES4gviyRwNcGDk(n8&D9 zarOHo+2_<98L48Gzt$79bXYgEtiuiB5eJt7V+(!T80ngQ;shT%%SlyAn1=%y4^s72 zfCKsFkMdz6m-hULOH{41`cqa1GvhE=DtfQ^?6I+V=nK0Y&$zpg>@_CLnLhkse+HU zLmZ`m1H~wW3I|t^cN#-I5r7D@x8bV>EqMbrO963MOYV^O;wAwyeC}!p1c02mhgB%0 zinfdLU|kw9x5(0kgFDZn9a%qlZh6Re^)iBOS!}Vc;>!+E$N{eJH0|>JV%;3AQ$_iK zNm-o$of5cNTC1gI>(hgU%+X@w0@DvMx`(|>wXl;pwTVHE-c`h=8&g_QrO;Cxilhw= zSHse)u3gW&7siQi%r~la(vlFg%&EDfz* zm=>YrjRj;n-VO-9c?HR@7aq2jV5Q(7WH*SWC3lvxvPBs5@~Fnh5j3ykW!VeuHyMXO zZQowa;B(UuRhQk^V%zw_(8J}Zx%i~vyy-1geYe%7t;r#;(CXRtF17pq6|y^SahgoAkON?$V4J5p$eQ=!n#@O&TGm}y4d7I}UGa_I7*WQAvg$l;IO)6k7zA0Y77bW{P`_ zZB|Pd#ApRcB~6{8hiw~l>wv+PjVB+yRwxAc3mQ66@-0@zfU2Y#O*%VkBx!4!k)DHA zC$RMU%jh%owvu#44k7f1^XMqT$6>X@Q_qbU9UKOzhBFIU!OM0A_yv@mO73k|`U^^^!X#SVRBt zB=vDci;XWEt4v_<6Akiye8n|!{tz~9!RR_RZ|wq8@MkO>CaL?uPnuc%&q~xCgMqpF zI@J%~PsVppcMg&j$_|z%O36K7d6kJuM|1otTA2C38mWX3RQL;#D+&+onk14)X|*_Q z+2dLsU!GdnT-VsZ zs?5(fGHSQBM;!aJ`==D9_S&JREBeqznn2k-VO)nbezru(|8mBbE}f5x%;IdogVr27Df$YAOJh1~w9fvqr^@5S>jRKqGBO_2eW? z8w^6GV<=J7jFp2fh?x0q3q(0lT_i2>1Wf&Ah^3~n-HPxu^j5g8TCDTvab$e)ZD9+| zl@AOuKl8w~*r=Q;iT?!yfq0w_HkM~dpcj=$VPqxwvF^oc*%1|&mbJVp(kZhk)_m!p zZcu?BXB~l>&%Wb&K>Hz`=#k+w_mj&8bBXNqy6R$yoz>SJsFmA(w4O=eD?6=Gumr!= zW+{0HCz43CkDU+eHMidN&O5Q^@)jNt$vdgBymASIoYD#Tq;iPUy#9uQs$l*6SAjse zM+pDl-eByXgI#kUjH|Z(;hW(4MGJA}`}*#|3i<@%xr^2-F#cQ-=!m||n!D+4>AtO2 z#r5Gx6K44Ab!C%toPU8^Y{ns`!Lk7hN4+>y2c-!pt~?-3SB9A#+_-TK>>qZ@XGOan zV;O?-TezC9`@+1e^uu1t{@$tGjI(l~SbJgt;5?K&Mmo$n$5qf!u2rq|%5mM%F+O*3 zC{Qh0)oBtXCGR`fKgnumdiQrmI0Q6}4f}OXc9v%IGy?a3_~rH|f3uE)wJ(NqJoh(U z7WaQD-mb^E^nn}9y>X>Z zrGX4|lLrO1Gn*Nw@dDHm@-pGgs2TZur)gyqX_T}QGAC_c6-wusy+Vu+Ii+ie(o3|= z#3h$NQ*yWMdB~5B#>@_MWH+)A8DM{+q)CaSODe17%k76vxQv6*c3`UB{*6*io#ox7 z_U-VzcGgcR=*L_2-KlRWyKS(qFs*&kK3Tg5&|md14Y-~U2X(>lTpwf39o{rF(C%F5 zT=kb3VOKe%+a`m*aeabJN5^+QzcY8hbL-cmi_MrWV-mLlNL+S0s6N`{-?ac~x2t(l ztSxoMCpn%kiZe}YDsvVEHBse0`sL63PKC8Muwa85ej5iFWeB8gU;J9#%F})Hn}OBp zd*_d!NqH&WRvmohTYA$Yfak@pN6w;4+qRc+aB>IW!1q~`(kemSudbwcYr3K~t3TUG z0=bN@Gy9@a@Cl^g>0xFu)t2!J7nFO}cBJAb-CVK;tSO%6b#P?G)o9-%HtUH;o8zgb znB+&|D!rT&KZVnZ7O}t}4MLnrr_%B?Hq`?UVZBlx6~JgpzEweYKVFrT%!y_JqYkA@ zxuDr_QZk20b+c74``# zr^S^ysMjee=qMoO#+Fi{vYfPO+)_@iS+iSRowcHgW5@6oW+m zPG4Soh%gI{C<{I&)`_NJ9en?2;wD{hyJD=H-sgOnEr~tgTUhvfPMD2t08ABAo8r+n zG{mAU2>qC}!kTXeBvZ>xWhmHM&ZBfPJDj`>E*eYfat6aaQWmX;kd|Z=6y{1Q?pV3)ywi!IM{X8M2I$tOcQp!>U*$G|=mm|W8 zX=bTB%mm&oDyZGsg^$SKkVe*k`#aE!c@raQepa&6kfx8rE#iuxN3*&DLH-H4^7?bx zA_gnKZkbPdPW~W!4CJNi=Wf{1+ea`CoSPj;;!sfuO5bui(aXBOB;)A!6qecXp)^a_ zPj;@)g`P1&PZb;FvHn%l9p)4iFdPR#M4#uGel9a(09MB_5V9ht!dI4;D3I?N!yuy5HwCzCulQZXm98GeZ+_japghZzYB=&YfKDHKZx>Gh92n4Ej} zHgw7SZ3o_eCvex~&t}%tclT$rs7c4Ei3sinPTRU&5+t$1@#d8qcSo9DJ^23pC-YO zw<$nA{y2f?h<^wCZN=yo_ZRO_YwR%p{@?#^K>fD*pG1O4JJWAJRr9%y)d>~7SQ?85 zq!j_yjRe361Ea^9QCYw9;CB2lsk&J2!u08~NV2^_Dw&*Gl!DhKVOx8%B1!^DF(7q_O~=}4K*}CUtI6Rv2iO~`+S1L4 zQTHTao`BR+_ZSr+yHRTnhMxqU@j=eqT|Ngge>w6uE<6@RQ@!o%L!k0g|7xfm z2tfW$YiqGt%nFrgH{h5Y3!R3CV6<0)7!oyEGY_n*?iVrQ{~b7kR54n`;$^AmbBr7` zoaQ#ouV&;>706qj>q=@O!Q)S4*lN-S>o`gHe7%6|Wqzr3oFC~MgMWR2Gi-Gan$;ps z{8ZplIpu9@Hf5bFPrKCA*b8pSSL9wM`8=O`F+AI_cPOD&9E$y5)3(`N$q8H+jn@6? z1k~5j{h4d;g|p5cox!>|xw%rCoJAdZo>o7q)x9ByS=?BT%pzVx-a*O3EMui1>4-&Z z|-{< zZXaFs1FOL?IW|?x?DJ50-3}xs?=GC{u1aQM)jFSj_x|qynPP}EPVrvh#Qf>WEu6Pj zFLRCCL`=$ty4LIo|7eIO*ci6IY6hwT{ok(U|GEb3eKV9W@W$A5W?3OT?An0xb(a2^ zf8hT*EKqBlZ~xuUCR)Qaclvud{>OjRaQ~$s4`)Gj{I34>+Ht&^<-hcikYH(#k2|+ZL!JI(|#_Yiq`!8qpp2 z^&WHNC-8d}ZKBubK>5$XWBseKXgmb{glh8_mE+ZDG*Baj%UW3}X6-am&HJN69BZYv z*O9b5%K49WUMeWTDhQT3`7wjxx&Du^K~FWZ;m-1|uGxRO9ywrSgM%ybfh(y-*|IuR@WO{2 zCyX`OV0@7C!-~V>q2Gwe?Nfzj1^;8wp!Cp;pMG}!@F#_O`D048h|c+^&69V~*sqji zgpOg$mYc7%Z5T`|r}9g8EReyUJ+^iBQ_2zrVlAxFNj-QEJn%khRLqxFPhKR0#l-Ti zN6(Knbhe$FIjq0fMYL@(NKF=Rrzl8)`!I>ni=(6U8TB4=Ag~-J_W7RgqtekcYV;+- zkF?tu%0YtXSyxit+tzkOL^e*Bf!8S{!;@o;Q*mtf!`P)CO1cP-pAMJvM*k`SEY;L{ zNyIl_|5OwEp+l=@4dnSGX~c||+w=0=ymJTHpunaEc5LonpVXW?h7kxkAytvv*Noi!LIm$$Q+iA`A|tWtYytgBNU?g?wcH! z?b@Q``JscUgK@{$*jOoGXDJRc!7_l*h^%%$7%(_QI|>S`rdrTwQ+2AFyg6YElkb zX?+!1X;e}lzgnpUJ4sLB#=i#WJe8Kf3SD}c=#a;(Tco4MkG)0wnO z4T}^{^I>ciPODy308evCtxeNc-?Og*U>A|DsF9_@hTYX7S`{<%!3d}83+|hDD2^60 zdS>RGwYYWlH_gRPqY>1(?IrFpy6;>9XBZ0*lxHzUt`uhH( z9ec76HFNbKlVSDJPLRf0m}1B)MmKEv>q-WSR84Na(Euzj1$W$OKs*j$FCRa%W&J3)$(($4F@ zxNI@AS5jcETL3wt^GWa86@LIuKDLHMW~@v*XX{4^=hl=R=f&pC!S(aUm0DIEybQ&3l7s(+fd#a)Nt92VCPsb#!F^9{My!boh!pfJ1Q!4 z$ejVsrM^v|sZhuFO{)1qoNqIkNAF5gm+fI?X_*&U9G|2wqt>7#X^fx8aA?{~j($M1jt_l&_1*=Mhq@yut=z4ls*i3iQa*9g)H93myn&YDSc3uv-o zU;);wBD1WZ1FrYX2S;&J7q~<_jG^crOsbnSxTez!S#y`QER_jnFXR+N%^W&m*J;(|&jyu> z(cM2C2XAtCj2DKg_nl48J69ErJ=Nx^$mt!6-fq7N-TroV;AZ&o#?7RFR&Y>vpBJ{G~;M0TDRJ%N@B2`nu{84FZuBoU7nj` z<8e89RKjO0LVZGuUiUyV9bVxi8Zd4f;DQ4=j`ASM88)U^FC?sk=dAL&5IZ$aQ~{pu%u

^iVR8eSbd_&9Tlz6LwQ;YsC61t*^;Hp59BMTjaT-)YNd2hUcu z)1Os{BXCvH$_C)uy20#UvlS9s5oNftf!C89PwS7l5shd<8&ED6&dI!#ttZU7BrvahwX1h@&}fydgawM*X>Dj5p3-6}hY~X@qY_N``ZAD;#+?Se! zrz;fi*0S5s{K1Gjj8ylI;itB%Gb)KG&V~=$#41Su)f)2R8Gw@o(Z8< z`7<`4;G%^SjLWB1QC~HtDeIAARpn2=xj*bbWYSak;jHf1Be>dfs^Q>3rEIP!9iFn` znUU9tq{vR$XEh71i-j?Zas< zI6miLJ(}c*w=1VxD_i@x1i$XKK9HpWl9oJAS(Fr3QSaJR8kVQT9>M$ZDycCsbYp!4 zy;zo3CAB)=x5uY#eP1bfc1YTuk<-Cc zy)hLE)La?0WQeB2vWTi$xPpqWWzDRUoibx2wr--_`0F<(lks^x0;`J(9SbYe+-tBU zkyLVy!aoWl+u=dFLkDu30mB$QC(Sj3y+!OKwI&};Nk{rn{6eX>uw`uWK-;5zf-;9Yi zZw>ZcHP7mY3@Eor0(WGpr;87S7S2wE7ABTm3-RBs6E|^YrFjZ|TuS#7P4W)fcduJX zzS#Ki{UB{l-~OY`VAk{zjo>=w3n`La8F1ZL?R5=OCIF(~~J?;WxU$Za=sV{8R~>nEib{e&+4zQPpA5t1PP|6D*K7*O?ML|#|_RNLoTlTW=ldi z4Nh$rn64jfs6uBmTi)h*ZL1T1t}3BWckDne%;x3dz^YB68Lhw`)g5-9v9WM(Ffb|j zBuQb9pVU2P6jgwZ~|E!GXbB8jU;np_Qv~Em?gyE zkraxcQ9rKp?Is?M>pL;OM2bK+Az?H7gW~$pjdpL-Y&plK54Cr;t+q=O(6Lmq(C3t2 z%vN2z6Mt`dK)*dZShU;ZROd1BNUmHpkBbI#l24^9ABioUA-=Gyn9E$i&$nv7fjs67 zzWvR?$M)s=n9;ffGNLmcvy4qk;is6(ny}bO3l%Yg`!hS2U2ItrNz?WTqI{ibm?RwO ziHTZ{lDpeG3ZUcR$v8UBCed96Z`v55k;UzhufwJZZl_Ug4NfE9?0B%eUP=c}MoJ7| zQq=|^N0o6&VsSjF+8;a9I0zV1jzBkJaoIpJ z9-qoij^5KxO@uRJcZZx~(Y@EVirW-cibL=?^w{-X$e3tM#so8l7Sa(UTa;)0B0f&g8T@v0_r>O`5)R=H)E5m^ZE1eNgod9k4j;W$ zp^9zh2_CU8a48!QaGB7WXQ2KM6+ZL3z-C25FzLXeg{8#ai(@4Oq~M8(H(YzxyjtYrrk~6C$s1 zn1Y4~Eqe|Bj=!-nw_!QIaF*OWm@1J@Mu5E(*3oIUx3@prDreZXv&CVWl=HpP^5hfhmeYgg^9h@ z_+pc$+JVxuSwD-6nvz}iUh|~Lc%V@wTuF^bB29OR*X=w{^lXyvJ`Jo<*|HgG`CU;D zEfg=CK0@MWrlF@-acOXhXS%ZYJl!&}Z&LOOBso8Y&3;-9)C;lx>>#hrK2@pBl`E zg+!bIkym=Gn|mIu(?Ir)M9IWeEMg@jz)k=KEjk4kd}`^tmJI>|0%YP?SXk2c57o-$ zN0mMNY3CiZvtQo}Hkg4znex8ntRt~6opF+@)70Y&S8n2{=qPFyG9kd4>sLn+-L_I} z{seh>POy^dgk$QL<^Ecclea5N_Bt1aT_W!EQc#6j7SMn6} zc-g_)LXhRGd(&GY;Ag7&rTA)rN;hT%+AZu|9>?e80+SHaQ~eeW+yYLuH_D#`N@oh| z&=xf}W1j~KgL9q&g1!w)x1{8GHy>3WAE(50=*tUe;UFMP+*5MdU%oBUf<$e!Tov!v zuA@oc3xE!XDLN=S{|tr7o;%_lzJ@g#JdQ8nweUtZpD%&vu0^}oezUn=i2;S_qnU?n zjOOy@45fe@+zCD*D2IN5KLH;4<;pifa?pQaV02#p`%A_; ztk);yhwAZvRiW`;L;cBCVHdTbxnE3PqLW5k;r@?T{G@xnr5+oJ>SGvF)JI5%$dw#m z{N>X`P79^Cot$&!%nFq=&FVENo(b2Y7bo&Y;DN3{AGB|9447vaS&s@{wq$R^cKC7e z_U3+WVtHe{Cn#_KT{fa4IUpTDhEiN=Ro1v)-vp=(1YkVQLuLMUWP75( zQ(GX|_X*l{{TI&LJYsXRSiXr@SE|AWs$>7I1>hMUednF;uYtd@qY>naejXQ4)wMm! zRv<{8|AWlv=AGQ(nL9j;6~ZT|88=QcaH@w#8uu2=RCBttkZrYHo_H!?3~MyCc$U#a zE|DND_X}$~3O<|@ zKWP8}RKBTxn~;5$oWIF-n=yDk_jsWn3GHWzl9o>4z2&`J%HCB;qIFJ5A1r>y!()cT z$jW)T@XTMmPf=(BqsfZ;)z&Zq;OJ6nQv5s@8N{H`61UKL1BYK~col zDndQ$PU8B0PnMPbV8`Xsx@#3Pr{7q}<$Q9pT!@&}uAQ4Qf<1dq^^;*-XW`R1$klF_ zdV?^HxOU5xzxHf17Br5&)-;MOCvqz47aKv4>VJAm!yHj^;)wXu4j2≪G{5BJLl? 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

LHi|!&OakGZytxC?|=>$7)B-cIM ztef`qka?B)g9e^p!M312Oc&Xx&=TajBNTH)&w!hQ6l4}d4H^K0LvYu3!hJ*#eobRu zk7_#BIR05q(D%{(9{ptb5vDTsRs9!y*FkRGnB|(=t){f-=+=})0`L3|ou=ED+b20o zipk6+#zwxT&T~XbAH;HVHNb*xgD71{vX2`aJl6AxvO#IsPsh_}dAlJq_EODa}PPJv^X{PQFc7fBt{;C zWr`0fzZ<_h)VUUbB2RUDv1~Mh;(y9N1AJt3z`6nbr3U{bIZYcPS8P?kXTQnBZfGwC zDUbQr87HKQt=mh+_{E!T;}YTgYaTi=T_xo{)TJqX4`a@~(yIv*k#K9Gj}@RyWO1%> z+0SAP0w}Ps{Hy-U<0YrPA*sr&3@I}T=^Y+rkSHzfRvpHf&4ZE!sEH_>z_nF#bEdI@ zydPxU-ix=XL*BAJHQWgP>vv`u0{3U1-~MmZ?u|smp z{qHk}V;1EAnknzk&>=jg*$uwPLW;p0S&U&oE=t_GdqV6oJ@Q9(O8Ua-dB}(C(m_N#-DE}U-Y=ys zWD-Nb>L};h)rhIm`5p!y{Pp7yt1>i*G$3D$-)gPWrJPA@hR6LN#Kf&yV|at`oN@d zLDsoRCn)0#dkT>kqwjGxN-oSgTbvK|8|);HJFD-Qd!2U;ffyQXjHU@4;dDLaPVlQ9 z#5WULQ7Q<1Z!qs6n(@nM2 zD7(H?1Y6aFQ!=Px_3=g<4JIKyMN#B8Rr8&r;k{Q%4r)Sjy%a~NL{|N9p_4yfr=1MJ zV?rS+?xo8+HY5qZ3ZOm2;S2_P4iID7~t{ z=l~NKxbFJSXi)_MxJk5=Tpb7h&F6s1E?S4T- ztQ>c4cgqzafJ7cl_GQ=|Sy)T}LkL@r|BIur?Lr5Zkhc7c>KEnbC~t(-T0iF&DhR(X z!+EqtPIihPiLW6hMw)379(NHMJM&EZad~FY4lk$jsG80rvV!O*kjzpKt(Uv)zU3X-k>Z@k0YQrCY5>fcSwh9$0#yeK}E+sH` zT9Dx~AN4ZYR>dQrRWLiRBb+4dsIw){WojU;$~|lLsvXijT|#V`oB-OA2N3BJmQV4`<;4?`;S?wt~M!1{)M-mXg|b4!z2>N2+x{z>6U6my7k@*8aA zV7e;X;`L|0#?tKAMLy!I<{e==ZjF+E{`@)La4C8%#diOaCeeH{T{Ic34zI4ZzFuX< z6}_y_vNPOr&Mvq0WFw)LV`&0yEc~KJ`b2|hC+kFc-6(nd?=v6W z5hEmR5y^13F>V^pQ{jg1d{b-<^)_<0yjVS_o}{TsDu>uQIc>x`Fd=sKOB~qdm!3c7 z-{B@{bV%Y@P8I-aUb^;lsYYio#$dv5v0PBgUy~4YKxwqss?``U`<5?n#0=T-@i`G+dWZYF~N_sK#$y08J5aMa>#xgy)!fxL_9`Q#VjN`5I5<{ ztHg|V*|8VjzAokC@15mUL&R!wOSeBhaK3ELiqW9G3Tp}$(2lO!vh3Z?9y1oaGiZD~ zxEyuRCLDul@kv1WqdkoReqYohCX&V}RmF3aR@{{p5FR&6Zy&+MY;(zq3dF+@~FQ^exn6nkJfml(73~We0TJeV907!tJifu zcP>xDfVw}f3FpF2bV0_)KHKz7ZyY!Niq! z-1IhacCoL$KL^o$*M^`e zAA%XGPaXHV11)8}u0Kap|q+}{MLN_a7a9zL1&xnDnbOWQg)6l^~)OiWD7=X^x)|JUCj zORw*{N1`O_@Bg+@&T$P1hPG?_o8kN4elT<35bE}<7V4vd3E1J5S5{iTy?C9gT0-pV z>RPB#6=2X6va-D`(G>!A!!Ug=l3^v88XJqdF_aQoQL#6brQ725438;u98#>^5S^Jx zM(lN|w^RA$%cE#|?cOSjDO+df%@I+OEE%G?wn!{mmF3ONE+1I<04rv4azF71ijh)1 z!Mm#=@7aFMazn(hu(19(X8(9ri^ap*-y0j{mNQKH1_rRWY?io0TuzUce|^XBIG-^A zm*C#oF7tCb?`v5SL{Q37fYvx1Ho)-plA0YhWuU$P8Ohih&FVujfW2JtCz-Bupfs+C zD=mHd7>zin^}|bWN0wxa{n@rM%=N{A>{PLKU{MR`&uMqMwbpLUbgsI{(M>@CouTOz z9qcAv$$@iD$m9+8K1{(*iqoSD2%*+b4 z>cTHCU5-yq@W71s=4u$KgL}Kg^7D9SBKQQW>z1fSsLPYIFShio~p^)T4qdo|7^xhzgcy`|+`zh`L3adEp}hjI%9kPKqAg7=>F7bv0df%>* zj2D3S9_q2em|wk8V8Wv6wxVNWi#}++3F(QTRLl@T0ee{wqzeZE@sutewjB4;zT@Jm zFzi8Rvzq<2;Jl=5$<^H4+#k!h7QJ@0?2GniEC)?bPmhqxDaa2Q=XkrIME@9D-S|B!Ex=~+r5D`~&ZS6bb zf%p!vZItA$cV@bSG1!lJaveC5>pkQGxUt;#xx7D#;56#zqM^h_y^+-KI5>{Z=WHsh z=GgY@cZ$&s^12f_>{cIHfHOu$GsRup-JzF&Q)%d@skyEPb?EE$MbmkJ)(nk}7nKtI zQkrs)yYZ-M#UWtB{qI3RnZUItx1u0777WqAUczEyyT|hs8S#Um7S7OonXdK;H&S;n z(kU5?>guSAB#y}lDLFaW<9aK{<7`5eyi*~$i-LmUt1R!;7hs9{CsGs`Bwm-KS65d~ zHS#(fuV1}72ho~PKsfk#d26dDS3aYV{RuF~SFEg9fgNq&JcF(II%ax$dYDR^rQ{T^ z>%3oZ2;eA!#R0InGh@O)Z9@aI$EEFOUtid7p`j-G^L0Qmat%&atE;O|QBV+49d{;U z2aLh+%%)38HEXQNSy;y1NR4GpO{wJN<)0CA98P+)hs5Ax3@RI z5phXL+A@C9H*bQ#e&svDngDw^I5-|XeykBQQ)2^8Jt=z4$CpxB$pW@r@8G$ZZ{Hfr zQLnQnr>3R`0}T>+Jvcb{@nA6t+$|s|=(_ryvam`7m|0<8WTs+{jF5;(ctnI?mHC9v z=Y)H%{n~anaN5tRFfdqQYis(vygU}m=^u;+ogY~6T{q%Po}!{s(9#A51wDg-b_mqx z3UHJW_t~$GjxsNZ8?e1!Wd>amJfgsU4NXi=`WRY<(}j^!mql2?90dJQVShwLbpWgr zlaP4D!;=IS1uz@;<9c{{x}FTM+u2o(Df+^|;K_;$y;rg$1&{!&4YryC0~c4!!-HGb z8^Q|UYP?7@=EZA~NS6?6R+GtM?LL4R9B!vnz$Iy;nbXqJ!1-Ip^A1C>ayc?Y6w>kF z9sT@2iPVNnahCupfErxBBgn}eaTt`Tb@%nXW?(?`ZRH0Znb(z-?yvzoq|$r>$;QTJ z$3z#1k|Lt#noDhw!{P4M6$lg{nE^nE$uoc?z3Y83k`SK4Z;8N)$;in^GDOitf^gq| z{8;I@(0dI5gdv7ryKQVNqS|T>J1`4?N2T?`ti}njOp)ECmX=>?y{}EUxw+pt?cxGF z>ljo7rz-%=`#CWYb$jbEIyRQ4T%h7dlF-=5-5EmE2Luv>jFR#M$Vy$8q2-ndW^*WI)1Soa!|mmfR5Ev*Ui;UhL9PQeF6Ln14*+hZ zk~pz^TYVxTA_m6Tfru0o6kuGIKRuBs?9~ki;T|0e%gB=i56`S(9|Y2Op4Ti;Fawsh zp6tF~R(Dyh_6C4(LR&d~CyaJHNZZpZ5$!wG?8yr^zqD7%{Am}Jhr-@U$!KrF- z+{M*33^;|cF$Hk7H#&{6Kv{t9Xy`u^_Vf4G98v_HK`E1%3R;@Hy16*W0p21N&+;9( z9Y=2F{|0aa+?G;ZQUk%a*>px_W_*uCQ$faN2CCnFqvU zgVkT(rDEU0R?Jvlxa)**i~;7Pudfflt4qJE)bqv(BuHCZTL3wUIWS(lXay0j&=1!S z_&n6E@>=|-XhRe6Sj{oPe)qI}{Gvb;4Iw0DWsRzH*wn~bUtd?ByK&wg@405l0XhQ|Y}y#oMsQP(goM;K zFc3VJBfGY@C#|A_4TN14cR$r~7X=WoSgWorPcf%^aIkvNW}%)H=#IguU6lnJ#Dz1k3?4IO8Le|LNzcf@d;0+fAoB(wRVGW#p8T!D+G|~*n}An=cri+ADiIyE44hg3Pt!(=pkIu&~zoItS31Odihk%F2r6Ot~MJ(W}?5wiZ2tzGMu& z2Qc#A>+5CS_g-L8pCzw}o@D~N%|>q&Z6dE{JTN}ZO?JRue*ZR1I8k!31O&%m?9Tmg zxwTNUCIq~%h;D-fP!mcH_ZJ!@`{Mx2&}-HH1RV{O>b1Xf-X}cS9HOM6%Kb&z92Le4 z=rK6=;N)^GbdEz1friPX+HyufLj%90=^P)#7O*vN4)?|_Dk z{RSr0W~mvNMl@%mj_YqJy`s<^EF$IY>vxjC7!_S2yb5TJYca7%NQT<{khdlZaKJoi z9P5BnfJp#v-n-MqL`N486MH&WZDnwGdnJ{^+w?1&8q3uZ1mKx!E6p-5eTg+O5cci4 z6j2cDo(6AElr{Hi>g&G(=A3Q4P#=WH-07AU1$gMsPw>DZ9|M;0YJY#9U3sC3s$xbe zfz2wG$td8A6@Y=o*>*b48HfTPZYCgajgOBj6zDIBxwzC;F_)yvYQxARadrSY*4^Em z>Uy%#F>BG#&;S^m!3oPDh+>ljBOn7DTxiP!3st>Fn|}&pMN;{$o~4Z z@3(JH2a|au0Racr#`oB_)r}CzfcnjsI^Jk#bVta^$)Vhvt*WS-Y0v?4=J3Ab78e)4 z1?-8a_-wgwC%1h7w&6wl5th~NZ7b1cB( zsg!OR>nrR$ya3H}L_VfXU{ zN*dtzCRfMnpu$vMUOru_2M;m_qgk&4m7;ephcCfs_}|a%OzF4}xV=}Hmcd-!O!3YZ z3C5q?P6Zh+;9D)@*^k)K@$q|`J#VHfOhTnG{MxM8EhdA!AlD=L%CrE1-zE&O^+8D{ zoo4liGBuD^tQ@YiPv);2wA}M9E-v0e4!m3P0+#^qgg@-S(Zy_tZE`tEubR3?CE~Q* z=*N459VpEW)bj!4pnyn4#KeHUxd24N%gGrzwSPt7Z$(&66Z9{%@{?Qzn#qw#QfqW! zBqb&7NY)?$X%M&uh8PeSh`c$NRPjAaI1n2E#H@y<(aFXDbYvi*KHf;MVKNyc+~|*M z2g8HXP!MBF-7V{#_IhRcAVS8**;O{d0{&a}o zUa~95b116SmOCHlfW)>;?al5&!<4m2vYZ`<;`ENElM@?updU0Hq}Q&W_GI!b;84W3 zu{ls!doxh9A?2E%y9S5Az5onjbH*g4s>jUTn%rc!r@Q;Z(P~F#Ru*ijK^H2Z@qxkb z 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�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 L?&se%c>4bV8g6&X literal 0 HcmV?d00001 diff --git a/automation_oca/static/description/icon.svg b/automation_oca/static/description/icon.svg new file mode 100644 index 0000000..2a0191a --- /dev/null +++ b/automation_oca/static/description/icon.svg @@ -0,0 +1,99 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/automation_oca/static/description/index.html b/automation_oca/static/description/index.html new file mode 100644 index 0000000..4cfd73e --- /dev/null +++ b/automation_oca/static/description/index.html @@ -0,0 +1,516 @@ + + + + + +Automation Oca + + + + + + diff --git a/automation_oca/static/src/fields/automation_activity/automation_activity.esm.js b/automation_oca/static/src/fields/automation_activity/automation_activity.esm.js new file mode 100644 index 0000000..e740fa1 --- /dev/null +++ b/automation_oca/static/src/fields/automation_activity/automation_activity.esm.js @@ -0,0 +1,53 @@ +/** @odoo-module **/ +import {useOpenX2ManyRecord, useX2ManyCrud} from "@web/views/fields/relational_utils"; + +import {AutomationKanbanRenderer} from "../../views/automation_kanban/automation_kanban_renderer.esm"; +import {X2ManyField} from "@web/views/fields/x2many/x2many_field"; +import {registry} from "@web/core/registry"; + +const {useSubEnv} = owl; + +export class AutomationActivity extends X2ManyField { + setup() { + super.setup(); + useSubEnv({ + onAddActivity: this.onAdd.bind(this), + }); + const {saveRecord, updateRecord} = useX2ManyCrud( + () => this.list, + this.isMany2Many + ); + const openRecord = useOpenX2ManyRecord({ + resModel: this.list.resModel, + activeField: this.activeField, + activeActions: this.activeActions, + getList: () => this.list, + saveRecord: async (record) => { + await saveRecord(record); + await this.props.record.save(); + }, + updateRecord, + withParentId: this.activeField.widget !== "many2many", + }); + this._openRecord = (params) => { + const activeElement = document.activeElement; + openRecord({ + ...params, + onClose: async () => { + if (activeElement) { + activeElement.focus(); + } + await this.props.record.save(); + this.props.record.model.notify(); + }, + }); + }; + } +} + +AutomationActivity.components = { + ...AutomationActivity.components, + KanbanRenderer: AutomationKanbanRenderer, +}; + +registry.category("fields").add("automation_step", AutomationActivity); diff --git a/automation_oca/static/src/fields/automation_graph/automation_graph.esm.js b/automation_oca/static/src/fields/automation_graph/automation_graph.esm.js new file mode 100644 index 0000000..b22906b --- /dev/null +++ b/automation_oca/static/src/fields/automation_graph/automation_graph.esm.js @@ -0,0 +1,104 @@ +/** @odoo-module **/ +/* global Chart*/ + +import {loadJS} from "@web/core/assets"; +import {registry} from "@web/core/registry"; +import {standardFieldProps} from "@web/views/fields/standard_field_props"; + +const {Component, onWillStart, useEffect, useRef} = owl; + +export class AutomationGraph extends Component { + setup() { + this.chart = null; + this.canvasRef = useRef("canvas"); + onWillStart(() => loadJS("/web/static/lib/Chart/Chart.js")); + useEffect(() => { + this.renderChart(); + return () => { + if (this.chart) { + this.chart.destroy(); + } + }; + }); + } + _getChartConfig() { + return { + type: "line", + data: { + labels: this.props.value.done.map(function (pt) { + return pt.x; + }), + datasets: [ + { + backgroundColor: "#4CAF5080", + borderColor: "#4CAF50", + data: this.props.value.done, + fill: "start", + label: this.env._t("Done"), + borderWidth: 2, + }, + { + backgroundColor: "#F4433680", + borderColor: "#F44336", + data: this.props.value.error, + fill: "start", + label: this.env._t("Error"), + borderWidth: 2, + }, + ], + }, + options: { + legend: {display: false}, + + layout: { + padding: {left: 10, right: 10, top: 10, bottom: 10}, + }, + scales: { + yAxes: [ + { + type: "linear", + display: false, + ticks: { + beginAtZero: true, + }, + }, + ], + xAxes: [ + { + ticks: { + maxRotation: 0, + }, + }, + ], + }, + maintainAspectRatio: false, + elements: { + line: { + tension: 0.000001, + }, + }, + tooltips: { + intersect: false, + position: "nearest", + caretSize: 0, + borderWidth: 2, + }, + }, + }; + } + renderChart() { + if (this.chart) { + this.chart.destroy(); + } + var config = this._getChartConfig(); + this.chart = new Chart(this.canvasRef.el, config); + Chart.animationService.advance(); + } +} + +AutomationGraph.template = "automation_oca.AutomationGraph"; +AutomationGraph.props = { + ...standardFieldProps, +}; + +registry.category("fields").add("automation_graph", AutomationGraph); diff --git a/automation_oca/static/src/fields/automation_graph/automation_graph.xml b/automation_oca/static/src/fields/automation_graph/automation_graph.xml new file mode 100644 index 0000000..03212b5 --- /dev/null +++ b/automation_oca/static/src/fields/automation_graph/automation_graph.xml @@ -0,0 +1,10 @@ + + + + +
+ +
+
+ +
diff --git a/automation_oca/static/src/views/automation_kanban/automation_kanban.scss b/automation_oca/static/src/views/automation_kanban/automation_kanban.scss new file mode 100644 index 0000000..4eb3c99 --- /dev/null +++ b/automation_oca/static/src/views/automation_kanban/automation_kanban.scss @@ -0,0 +1,128 @@ +.o_automation_kanban { + .o_kanban_renderer.o_kanban_ungrouped .o_kanban_record { + flex: 0 0 100%; + width: unset; + margin: 0px; + > div { + border: none; + } + .o_automation_kanban_box { + display: flex; + .o_automation_kanban_card { + width: 600px; + max-width: 600px; + } + .o_automation_kanban_extra { + flex-direction: column; + width: 10rem; + } + .o_automation_kanban_position_line { + border-right: 3px dotted $gray-300; + top: -0.5rem; + bottom: 0.5rem; + width: 8.75rem; + position: absolute; + } + .o_automation_kanban_card_position { + position: absolute; + top: 1.5rem; + text-align: right; + width: 9.125rem; + } + .o_automation_kanban_time { + flex-direction: column; + .o_automation_kanban_time_info { + border: 1px solid $gray-300; + width: 7rem; + } + } + .o_automation_kanban_card { + border: 1px solid $gray-300; + flex-grow: 1; + flex-basis: 0; + flex-shrink: 0; + flex-direction: column; + .o_automation_kanban_header { + position: relative; + .o_automation_kanban_header_icon { + display: inline-block; + padding: 3px 7px; + margin: 5px; + width: 28px; + height: 28px; + color: white; + font-size: 14px; + border-radius: 3px; + } + .o_automation_kanban_header_title { + display: inline-block; + position: absolute; + top: auto; + left: auto; + bottom: auto; + right: auto; + } + .o_automation_kanban_header_actions { + position: absolute; + top: 0px; + left: auto; + bottom: auto; + right: 0px; + } + } + .o_automation_kanban_graph { + .o_automation_kpi_processed { + color: #4caf50; + } + .o_automation_kpi_error { + color: #f44336; + } + } + .o_automation_kanban_child_add { + .o_automation_kanban_child_add_title { + padding: 2px; + } + .o_automation_kanban_child_add_buttons { + display: none; + } + } + .o_automation_kanban_child_add:hover { + .o_automation_kanban_child_add_buttons { + display: flex; + .o_automation_kanban_child_add_button { + cursor: pointer; + flex-grow: 1; + flex-basis: 0; + flex-shrink: 0; + flex-direction: column; + border: 1px solid $gray-300; + } + } + } + .o_automation_kanban_states { + display: flex; + .o_automation_kanban_state { + padding: 0.5rem; + flex-grow: 1; + flex-basis: 0; + flex-shrink: 0; + flex-direction: column; + border-top: 1px solid $gray-300; + border-right: 1px solid $gray-300; + text-align: center; + } + + .o_automation_kanban_state:last-child { + border-right: none; + } + } + } + } + } +} +.o_field_automation_graph { + width: 100%; +} +.filter-left { + text-align: left; +} diff --git a/automation_oca/static/src/views/automation_kanban/automation_kanban_compiler.esm.js b/automation_oca/static/src/views/automation_kanban/automation_kanban_compiler.esm.js new file mode 100644 index 0000000..39a2e1d --- /dev/null +++ b/automation_oca/static/src/views/automation_kanban/automation_kanban_compiler.esm.js @@ -0,0 +1,22 @@ +/** @odoo-module */ + +import {KanbanCompiler} from "@web/views/kanban/kanban_compiler"; + +export class AutomationKanbanCompiler extends KanbanCompiler { + setup() { + super.setup(); + this.compilers.push({ + selector: ".o_automation_kanban_child_add_button[t-att-trigger-type]", + fn: this.compileHierarchyAddButton, + }); + } + compileHierarchyAddButton(el) { + el.setAttribute( + "t-on-click", + "() => this.addNewChild({trigger_type: " + + el.getAttribute("t-att-trigger-type") + + "})" + ); + return el; + } +} diff --git a/automation_oca/static/src/views/automation_kanban/automation_kanban_record.esm.js b/automation_oca/static/src/views/automation_kanban/automation_kanban_record.esm.js new file mode 100644 index 0000000..9ede878 --- /dev/null +++ b/automation_oca/static/src/views/automation_kanban/automation_kanban_record.esm.js @@ -0,0 +1,17 @@ +/** @odoo-module */ + +import {AutomationKanbanCompiler} from "./automation_kanban_compiler.esm"; +import {KanbanRecord} from "@web/views/kanban/kanban_record"; + +export class AutomationKanbanRecord extends KanbanRecord { + addNewChild(params) { + this.env.onAddActivity({ + context: { + default_parent_id: this.props.record.data.id, + default_trigger_type: params.trigger_type, + }, + }); + } +} + +AutomationKanbanRecord.Compiler = AutomationKanbanCompiler; diff --git a/automation_oca/static/src/views/automation_kanban/automation_kanban_renderer.esm.js b/automation_oca/static/src/views/automation_kanban/automation_kanban_renderer.esm.js new file mode 100644 index 0000000..eb43e14 --- /dev/null +++ b/automation_oca/static/src/views/automation_kanban/automation_kanban_renderer.esm.js @@ -0,0 +1,38 @@ +/** @odoo-module */ + +import {AutomationKanbanRecord} from "./automation_kanban_record.esm"; +import {KanbanRenderer} from "@web/views/kanban/kanban_renderer"; + +export class AutomationKanbanRenderer extends KanbanRenderer { + /* + Here we are going to reorder the items in the proper way and + we will show the items with the proper padding + */ + getGroupsOrRecords() { + return this._sortRecordsHierarchy(this.props.list.records, false).map( + (record) => ({ + record, + key: record.id, + }) + ); + } + _sortRecordsHierarchy(records, parent_id) { + return records.flatMap((record) => { + if (!record.data.id) { + return []; + } + if (record.data.parent_id && record.data.parent_id[0] !== parent_id) { + return []; + } + if (!record.data.parent_id && parent_id) { + return []; + } + return [record, ...this._sortRecordsHierarchy(records, record.data.id)]; + }); + } +} + +AutomationKanbanRenderer.components = { + ...AutomationKanbanRenderer.components, + KanbanRecord: AutomationKanbanRecord, +}; diff --git a/automation_oca/tests/__init__.py b/automation_oca/tests/__init__.py new file mode 100644 index 0000000..33ebf06 --- /dev/null +++ b/automation_oca/tests/__init__.py @@ -0,0 +1,5 @@ +from . import test_automation_action +from . import test_automation_activity +from . import test_automation_base +from . import test_automation_mail +from . import test_automation_security diff --git a/automation_oca/tests/common.py b/automation_oca/tests/common.py new file mode 100644 index 0000000..de909ea --- /dev/null +++ b/automation_oca/tests/common.py @@ -0,0 +1,93 @@ +# Copyright 2024 Dixmit +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo.tests.common import TransactionCase + + +class AutomationTestCase(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.env["automation.configuration"].search([]).toggle_active() + cls.action = cls.env["ir.actions.server"].create( + { + "name": "Demo action", + "state": "code", + "model_id": cls.env.ref("base.model_res_partner").id, + "code": "records.write({'comment': env.context.get('key_value')})", + } + ) + cls.activity_type = cls.env["mail.activity.type"].create({"name": "DEMO"}) + cls.error_action = cls.env["ir.actions.server"].create( + { + "name": "Demo action", + "state": "code", + "model_id": cls.env.ref("base.model_res_partner").id, + "code": "raise UserError('ERROR')", + } + ) + cls.template = cls.env["mail.template"].create( + { + "name": "My template", + "model_id": cls.env.ref("base.model_res_partner").id, + "subject": "Subject", + "partner_to": "{{ object.id }}", + "body_html": 'My template with link', + } + ) + cls.partner_01 = cls.env["res.partner"].create( + {"name": "Demo partner", "comment": "Demo", "email": "test@test.com"} + ) + cls.partner_02 = cls.env["res.partner"].create( + {"name": "Demo partner 2", "comment": "Demo", "email": "test@test.com"} + ) + cls.configuration = cls.env["automation.configuration"].create( + { + "name": "Test configuration", + "model_id": cls.env.ref("base.model_res_partner").id, + "is_periodic": True, + } + ) + + @classmethod + def create_server_action(cls, parent_id=False, **kwargs): + return cls.env["automation.configuration.step"].create( + { + "name": "Demo activity", + "parent_id": parent_id, + "configuration_id": cls.configuration.id, + "step_type": "action", + "server_action_id": cls.action.id, + "trigger_type": "after_step" if parent_id else "start", + **kwargs, + } + ) + + @classmethod + def create_activity_action(cls, parent_id=False, **kwargs): + return cls.env["automation.configuration.step"].create( + { + "name": "Demo activity", + "parent_id": parent_id, + "configuration_id": cls.configuration.id, + "step_type": "activity", + "activity_type_id": cls.activity_type.id, + "trigger_type": "after_step" if parent_id else "start", + **kwargs, + } + ) + + @classmethod + def create_mail_activity(cls, parent_id=False, trigger_type=False, **kwargs): + return cls.env["automation.configuration.step"].create( + { + "name": "Demo activity", + "parent_id": parent_id, + "configuration_id": cls.configuration.id, + "step_type": "mail", + "mail_template_id": cls.template.id, + "trigger_type": trigger_type + or ("after_step" if parent_id else "start"), + **kwargs, + } + ) diff --git a/automation_oca/tests/test_automation_action.py b/automation_oca/tests/test_automation_action.py new file mode 100644 index 0000000..477add5 --- /dev/null +++ b/automation_oca/tests/test_automation_action.py @@ -0,0 +1,205 @@ +# Copyright 2024 Dixmit +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from .common import AutomationTestCase + + +class TestAutomationAction(AutomationTestCase): + def test_activity_execution(self): + """ + We will check the execution of the tasks and that we cannot execute them again + """ + activity = self.create_server_action() + self.configuration.editable_domain = "[('id', '=', %s)]" % self.partner_01.id + self.configuration.start_automation() + self.env["automation.configuration"].cron_automation() + self.assertTrue(self.partner_01.comment) + self.assertTrue(self.partner_02.comment) + self.env["automation.record.step"]._cron_automation_steps() + self.assertFalse(self.partner_01.comment) + self.assertTrue(self.partner_02.comment) + record_activity = self.env["automation.record.step"].search( + [("configuration_step_id", "=", activity.id)] + ) + self.assertEqual(1, len(record_activity)) + self.assertEqual("done", record_activity.state) + self.partner_01.comment = "My comment" + # We check that the action is not executed again + record_activity.run() + self.assertFalse(record_activity.step_actions) + self.assertTrue(self.partner_01.comment) + + def test_child_execution_filters(self): + """ + We will create a task that executes two more tasks filtered with and extra task + The child tasks should only be created after the first one is finished. + Also, if one is aborted, the subsuquent tasks will not be created. + TASK 1 ---> TASK 1_1 (only for partner 1) --> TASK 1_1_1 + ---> TASK 1_2 (only for partner 2) --> TASK 1_2_1 + + In this case, the task 1_1_1 will only be generated for partner 1 and task 1_2_1 + for partner 2 + """ + self.configuration.editable_domain = "[('id', 'in', [%s, %s])]" % ( + self.partner_01.id, + self.partner_02.id, + ) + + activity_1 = self.create_server_action() + activity_1_1 = self.create_server_action( + parent_id=activity_1.id, domain="[('id', '=', %s)]" % self.partner_01.id + ) + activity_1_2 = self.create_server_action( + parent_id=activity_1.id, domain="[('id', '=', %s)]" % self.partner_02.id + ) + activity_1_1_1 = self.create_server_action(parent_id=activity_1_1.id) + activity_1_2_1 = self.create_server_action(parent_id=activity_1_2.id) + self.configuration.start_automation() + self.env["automation.configuration"].cron_automation() + self.assertEqual( + 0, + self.env["automation.record.step"].search_count( + [ + ( + "configuration_step_id", + "in", + ( + activity_1_1 + | activity_1_2 + | activity_1_1_1 + | activity_1_2_1 + ).ids, + ) + ] + ), + ) + self.assertTrue(self.partner_01.comment) + self.assertTrue(self.partner_02.comment) + self.env["automation.record.step"]._cron_automation_steps() + self.assertFalse(self.partner_01.comment) + self.assertFalse(self.partner_02.comment) + self.assertEqual( + 1, + self.env["automation.record.step"].search_count( + [ + ("configuration_step_id", "=", activity_1_1.id), + ("record_id.res_id", "=", self.partner_01.id), + ] + ), + ) + self.assertEqual( + 1, + self.env["automation.record.step"].search_count( + [ + ("configuration_step_id", "=", activity_1_2.id), + ("record_id.res_id", "=", self.partner_01.id), + ] + ), + ) + self.assertEqual( + 1, + self.env["automation.record.step"].search_count( + [ + ("configuration_step_id", "=", activity_1_1.id), + ("record_id.res_id", "=", self.partner_02.id), + ] + ), + ) + self.assertEqual( + 1, + self.env["automation.record.step"].search_count( + [ + ("configuration_step_id", "=", activity_1_2.id), + ("record_id.res_id", "=", self.partner_02.id), + ] + ), + ) + self.assertEqual( + 0, + self.env["automation.record.step"].search_count( + [ + ( + "configuration_step_id", + "in", + (activity_1_1_1 | activity_1_2_1).ids, + ) + ] + ), + ) + self.env["automation.record.step"]._cron_automation_steps() + self.assertEqual( + 1, + self.env["automation.record.step"].search_count( + [ + ("configuration_step_id", "=", activity_1_1.id), + ("record_id.res_id", "=", self.partner_01.id), + ("state", "=", "done"), + ] + ), + ) + self.assertEqual( + 1, + self.env["automation.record.step"].search_count( + [ + ("configuration_step_id", "=", activity_1_2.id), + ("record_id.res_id", "=", self.partner_01.id), + ("state", "=", "rejected"), + ] + ), + ) + self.assertEqual( + 1, + self.env["automation.record.step"].search_count( + [ + ("configuration_step_id", "=", activity_1_1.id), + ("record_id.res_id", "=", self.partner_02.id), + ("state", "=", "rejected"), + ] + ), + ) + self.assertEqual( + 1, + self.env["automation.record.step"].search_count( + [ + ("configuration_step_id", "=", activity_1_2.id), + ("record_id.res_id", "=", self.partner_02.id), + ("state", "=", "done"), + ] + ), + ) + self.assertEqual( + 1, + self.env["automation.record.step"].search_count( + [ + ("configuration_step_id", "=", activity_1_1_1.id), + ("record_id.res_id", "=", self.partner_01.id), + ] + ), + ) + self.assertEqual( + 0, + self.env["automation.record.step"].search_count( + [ + ("configuration_step_id", "=", activity_1_2_1.id), + ("record_id.res_id", "=", self.partner_01.id), + ] + ), + ) + self.assertEqual( + 0, + self.env["automation.record.step"].search_count( + [ + ("configuration_step_id", "=", activity_1_1_1.id), + ("record_id.res_id", "=", self.partner_02.id), + ] + ), + ) + self.assertEqual( + 1, + self.env["automation.record.step"].search_count( + [ + ("configuration_step_id", "=", activity_1_2_1.id), + ("record_id.res_id", "=", self.partner_02.id), + ] + ), + ) diff --git a/automation_oca/tests/test_automation_activity.py b/automation_oca/tests/test_automation_activity.py new file mode 100644 index 0000000..1163088 --- /dev/null +++ b/automation_oca/tests/test_automation_activity.py @@ -0,0 +1,144 @@ +# Copyright 2024 Dixmit +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo.tests import Form + +from .common import AutomationTestCase + + +class TestAutomationActivity(AutomationTestCase): + def test_activity_execution(self): + """ + We will check the execution of activity tasks (generation of an activity) + """ + activity = self.create_activity_action() + self.configuration.editable_domain = "[('id', '=', %s)]" % self.partner_01.id + self.configuration.start_automation() + self.env["automation.configuration"].cron_automation() + self.assertFalse(self.partner_01.activity_ids) + self.env["automation.record.step"]._cron_automation_steps() + self.assertTrue(self.partner_01.activity_ids) + record_activity = self.env["automation.record.step"].search( + [("configuration_step_id", "=", activity.id)] + ) + self.assertEqual( + record_activity, self.partner_01.activity_ids.automation_record_step_id + ) + self.assertFalse(record_activity.activity_done_on) + record_activity.invalidate_recordset() + self.assertFalse( + [ + step + for step in record_activity.step_actions + if step["done"] and step["icon"] == "fa fa-clock-o" + ] + ) + self.partner_01.activity_ids.action_feedback() + self.assertTrue(record_activity.activity_done_on) + record_activity.invalidate_recordset() + self.assertTrue( + [ + step + for step in record_activity.step_actions + if step["done"] and step["icon"] == "fa fa-clock-o" + ] + ) + + def test_activity_execution_child(self): + """ + We will check the execution of the hild task (activity_done) is only scheduled + after the activity is done + """ + activity = self.create_activity_action() + child_activity = self.create_server_action( + parent_id=activity.id, trigger_type="activity_done" + ) + self.configuration.editable_domain = "[('id', '=', %s)]" % self.partner_01.id + self.configuration.start_automation() + self.env["automation.configuration"].cron_automation() + self.env["automation.record.step"]._cron_automation_steps() + record_activity = self.env["automation.record.step"].search( + [("configuration_step_id", "=", activity.id)] + ) + record_child_activity = self.env["automation.record.step"].search( + [("configuration_step_id", "=", child_activity.id)] + ) + self.assertEqual( + record_activity, self.partner_01.activity_ids.automation_record_step_id + ) + self.assertFalse(record_activity.activity_done_on) + self.assertFalse(record_child_activity.scheduled_date) + self.partner_01.activity_ids.action_feedback() + self.assertTrue(record_activity.activity_done_on) + self.assertTrue(record_child_activity.scheduled_date) + + def test_activity_execution_not_done_child_done(self): + """ + We will check the execution of the tasks with activity_not_done is not executed + if it has been done + """ + activity = self.create_activity_action() + child_activity = self.create_server_action( + parent_id=activity.id, trigger_type="activity_not_done" + ) + self.configuration.editable_domain = "[('id', '=', %s)]" % self.partner_01.id + self.configuration.start_automation() + self.env["automation.configuration"].cron_automation() + self.env["automation.record.step"]._cron_automation_steps() + record_activity = self.env["automation.record.step"].search( + [("configuration_step_id", "=", activity.id)] + ) + record_child_activity = self.env["automation.record.step"].search( + [("configuration_step_id", "=", child_activity.id)] + ) + self.assertEqual( + record_activity, self.partner_01.activity_ids.automation_record_step_id + ) + self.assertFalse(record_activity.activity_done_on) + self.assertTrue(record_child_activity.scheduled_date) + self.partner_01.activity_ids.action_feedback() + self.assertTrue(record_activity.activity_done_on) + self.assertTrue(record_child_activity.scheduled_date) + self.assertEqual("scheduled", record_child_activity.state) + record_child_activity.run() + self.assertEqual("rejected", record_child_activity.state) + + def test_activity_execution_not_done_child_not_done(self): + """ + We will check the execution of the tasks with activity_not_done is executed + if it has been not done + """ + activity = self.create_activity_action() + child_activity = self.create_server_action( + parent_id=activity.id, trigger_type="activity_not_done" + ) + self.configuration.editable_domain = "[('id', '=', %s)]" % self.partner_01.id + self.configuration.start_automation() + self.env["automation.configuration"].cron_automation() + self.env["automation.record.step"]._cron_automation_steps() + record_activity = self.env["automation.record.step"].search( + [("configuration_step_id", "=", activity.id)] + ) + record_child_activity = self.env["automation.record.step"].search( + [("configuration_step_id", "=", child_activity.id)] + ) + self.assertEqual( + record_activity, self.partner_01.activity_ids.automation_record_step_id + ) + self.assertFalse(record_activity.activity_done_on) + self.assertTrue(record_child_activity.scheduled_date) + self.assertEqual("scheduled", record_child_activity.state) + record_child_activity.run() + self.assertEqual("done", record_child_activity.state) + + def test_compute_default_values(self): + activity = self.create_server_action() + self.assertFalse(activity.activity_user_id) + with Form(activity) as f: + f.step_type = "activity" + f.activity_type_id = self.activity_type + self.assertTrue(activity.activity_user_id) + with Form(activity) as f: + f.step_type = "action" + f.server_action_id = self.action + self.assertFalse(activity.activity_user_id) diff --git a/automation_oca/tests/test_automation_base.py b/automation_oca/tests/test_automation_base.py new file mode 100644 index 0000000..4888711 --- /dev/null +++ b/automation_oca/tests/test_automation_base.py @@ -0,0 +1,546 @@ +# Copyright 2024 Dixmit +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from datetime import datetime + +from freezegun import freeze_time + +from odoo.exceptions import ValidationError +from odoo.tests import Form +from odoo.tools.safe_eval import safe_eval + +from .common import AutomationTestCase + + +class TestAutomationBase(AutomationTestCase): + def test_no_cron_no_start(self): + """ + We want to check that the system only generates on periodical configurations + """ + self.env["automation.configuration"].cron_automation() + self.assertEqual( + 0, + self.env["automation.record"].search_count( + [("configuration_id", "=", self.configuration.id)] + ), + ) + self.configuration.run_automation() + self.assertEqual( + 0, + self.env["automation.record"].search_count( + [("configuration_id", "=", self.configuration.id)] + ), + ) + + def test_no_cron_on_demand(self): + """ + We want to check that the system does not generate using cron + on on demand configurations, but allows manuall execution + """ + self.configuration.is_periodic = False + self.configuration.start_automation() + self.env["automation.configuration"].cron_automation() + self.assertEqual( + 0, + self.env["automation.record"].search_count( + [("configuration_id", "=", self.configuration.id)] + ), + ) + self.configuration.run_automation() + self.assertNotEqual( + 0, + self.env["automation.record"].search_count( + [("configuration_id", "=", self.configuration.id)] + ), + ) + + def test_next_execution_date(self): + with freeze_time("2022-01-01"): + self.assertFalse(self.configuration.next_execution_date) + self.env.ref( + "automation_oca.cron_configuration_run" + ).nextcall = datetime.now() + self.configuration.start_automation() + self.assertEqual( + self.configuration.next_execution_date, datetime(2022, 1, 1, 0, 0, 0) + ) + + def test_cron_no_duplicates(self): + """ + We want to check that the records are generated only once, not twice + """ + self.create_server_action() + self.configuration.start_automation() + self.env["automation.configuration"].cron_automation() + record = self.env["automation.record"].search( + [ + ("configuration_id", "=", self.configuration.id), + ("res_id", "=", self.partner_01.id), + ] + ) + self.assertEqual( + 1, + self.env["automation.record"].search_count( + [ + ("configuration_id", "=", self.configuration.id), + ("res_id", "=", self.partner_01.id), + ] + ), + ) + self.assertEqual( + 1, + self.env["automation.record"].search_count( + [ + ("configuration_id", "=", self.configuration.id), + ("res_id", "=", self.partner_02.id), + ] + ), + ) + + self.env["automation.configuration"].cron_automation() + self.assertEqual( + 1, + self.env["automation.record"].search_count( + [ + ("configuration_id", "=", self.configuration.id), + ("res_id", "=", self.partner_01.id), + ] + ), + ) + self.assertEqual( + 1, + self.env["automation.record"].search_count( + [ + ("configuration_id", "=", self.configuration.id), + ("res_id", "=", self.partner_02.id), + ] + ), + ) + record = self.env["automation.record"].search( + [ + ("configuration_id", "=", self.configuration.id), + ("res_id", "=", self.partner_01.id), + ] + ) + self.assertEqual( + 1, + self.env["automation.record.step"].search_count( + [("record_id", "=", record.id)] + ), + ) + + def test_filter(self): + """ + We want to see that the records are only generated for + the records that fulfill the domain + """ + self.create_server_action() + self.configuration.editable_domain = "[('id', '=', %s)]" % self.partner_01.id + self.configuration.start_automation() + self.env["automation.configuration"].cron_automation() + self.assertEqual( + 1, + self.env["automation.record"].search_count( + [ + ("configuration_id", "=", self.configuration.id), + ("res_id", "=", self.partner_01.id), + ] + ), + ) + self.assertEqual( + 0, + self.env["automation.record"].search_count( + [ + ("configuration_id", "=", self.configuration.id), + ("res_id", "=", self.partner_02.id), + ] + ), + ) + + def test_exception(self): + """ + Check that the error is raised properly and stored the full error + """ + activity = self.create_server_action(server_action_id=self.error_action.id) + self.configuration.editable_domain = "[('id', '=', %s)]" % self.partner_01.id + self.configuration.start_automation() + self.env["automation.configuration"].cron_automation() + record = self.env["automation.record.step"].search( + [("configuration_step_id", "=", activity.id)] + ) + self.assertFalse(record.error_trace) + self.env["automation.record.step"]._cron_automation_steps() + self.assertEqual(record.state, "error") + self.assertTrue(record.error_trace) + + def test_record_resource_information(self): + """ + Check the record computed fields of record + """ + self.create_server_action(server_action_id=self.error_action.id) + self.configuration.editable_domain = "[('id', '=', %s)]" % self.partner_01.id + self.configuration.start_automation() + self.env["automation.configuration"].cron_automation() + record = self.env["automation.record"].search( + [("configuration_id", "=", self.configuration.id)] + ) + self.assertEqual(self.partner_01.display_name, record.display_name) + self.assertEqual(self.partner_01, record.resource_ref) + record.model = "unexistent.model" + self.assertFalse(record.resource_ref) + + def test_expiry(self): + """ + Testing that expired actions are not executed + """ + activity = self.create_server_action(expiry=True, trigger_interval=1) + self.configuration.editable_domain = "[('id', '=', %s)]" % self.partner_01.id + self.configuration.start_automation() + self.env["automation.configuration"].cron_automation() + record_activity = self.env["automation.record.step"].search( + [("configuration_step_id", "=", activity.id)] + ) + self.assertEqual("scheduled", record_activity.state) + self.env["automation.record.step"]._cron_automation_steps() + self.assertEqual("expired", record_activity.state) + + def test_cancel(self): + """ + Testing that cancelled actions are not executed + """ + activity = self.create_server_action() + self.configuration.editable_domain = "[('id', '=', %s)]" % self.partner_01.id + self.configuration.start_automation() + self.env["automation.configuration"].cron_automation() + record_activity = self.env["automation.record.step"].search( + [("configuration_step_id", "=", activity.id)] + ) + self.assertEqual("scheduled", record_activity.state) + record_activity.cancel() + self.assertEqual("cancel", record_activity.state) + self.env["automation.record.step"]._cron_automation_steps() + self.assertEqual("cancel", record_activity.state) + + def test_counter(self): + """ + Check the counter function + """ + self.create_server_action(server_action_id=self.error_action.id) + self.configuration.editable_domain = "[('id', '=', %s)]" % self.partner_01.id + self.configuration.start_automation() + self.assertEqual(0, self.configuration.record_count) + self.assertEqual(0, self.configuration.record_test_count) + self.env["automation.configuration"].cron_automation() + self.configuration.invalidate_recordset() + self.assertEqual(1, self.configuration.record_count) + self.assertEqual(0, self.configuration.record_test_count) + + def test_start_configuration_twice_exception(self): + """ + Check that we cannot start automation twice + """ + self.configuration.start_automation() + with self.assertRaises(ValidationError): + self.configuration.start_automation() + + def test_state_automation_management(self): + """ + Testing the change of state + Draft -> Run -> Stop -> Draft + """ + self.configuration.start_automation() + self.assertEqual(self.configuration.state, "periodic") + self.configuration.done_automation() + self.assertEqual(self.configuration.state, "done") + self.env["automation.configuration"].cron_automation() + self.assertFalse( + self.env["automation.record"].search( + [ + ("configuration_id", "=", self.configuration.id), + ] + ) + ) + self.configuration.back_to_draft() + self.assertEqual(self.configuration.state, "draft") + + def test_graph(self): + """ + Checking the graph results. + We will use 2 parent actions (1 will fail) and a child action of the one ok. + After 2 executions, we should have (1 OK, 0 Errors) for parent and child and + (0 OK, 1 Error) for the failing one. + """ + activity_01 = self.create_server_action() + activity_02 = self.create_server_action(server_action_id=self.error_action.id) + activity_03 = self.create_mail_activity() + child_activity = self.create_server_action(parent_id=activity_01.id) + self.configuration.editable_domain = "[('id', '=', %s)]" % self.partner_01.id + self.configuration.start_automation() + self.env["automation.configuration"].cron_automation() + self.assertEqual(0, self.configuration.activity_mail_count) + self.assertEqual(0, self.configuration.activity_action_count) + self.assertEqual(0, activity_01.graph_done) + self.assertEqual(0, activity_01.graph_error) + self.assertEqual(0, sum(d["y"] for d in activity_01.graph_data["done"])) + self.assertEqual(0, sum(d["y"] for d in activity_01.graph_data["error"])) + self.assertEqual(0, activity_02.graph_done) + self.assertEqual(0, activity_02.graph_error) + self.assertEqual(0, sum(d["y"] for d in activity_02.graph_data["done"])) + self.assertEqual(0, sum(d["y"] for d in activity_02.graph_data["error"])) + self.assertEqual(0, activity_03.graph_done) + self.assertEqual(0, activity_03.graph_error) + self.assertEqual(0, sum(d["y"] for d in activity_03.graph_data["done"])) + self.assertEqual(0, sum(d["y"] for d in activity_03.graph_data["error"])) + self.assertEqual(0, child_activity.graph_done) + self.assertEqual(0, child_activity.graph_error) + self.assertEqual(0, sum(d["y"] for d in child_activity.graph_data["done"])) + self.assertEqual(0, sum(d["y"] for d in child_activity.graph_data["error"])) + self.env["automation.record.step"]._cron_automation_steps() + self.configuration.invalidate_recordset() + self.assertEqual(1, self.configuration.activity_mail_count) + self.assertEqual(1, self.configuration.activity_action_count) + activity_01.invalidate_recordset() + self.assertEqual(1, activity_01.graph_done) + self.assertEqual(0, activity_01.graph_error) + self.assertEqual(1, sum(d["y"] for d in activity_01.graph_data["done"])) + self.assertEqual(0, sum(d["y"] for d in activity_01.graph_data["error"])) + activity_02.invalidate_recordset() + self.assertEqual(0, activity_02.graph_done) + self.assertEqual(1, activity_02.graph_error) + self.assertEqual(0, sum(d["y"] for d in activity_02.graph_data["done"])) + self.assertEqual(1, sum(d["y"] for d in activity_02.graph_data["error"])) + activity_03.invalidate_recordset() + self.assertEqual(1, activity_03.graph_done) + self.assertEqual(0, activity_03.graph_error) + self.assertEqual(1, sum(d["y"] for d in activity_03.graph_data["done"])) + self.assertEqual(0, sum(d["y"] for d in activity_03.graph_data["error"])) + child_activity.invalidate_recordset() + self.assertEqual(0, child_activity.graph_done) + self.assertEqual(0, child_activity.graph_error) + self.assertEqual(0, sum(d["y"] for d in child_activity.graph_data["done"])) + self.assertEqual(0, sum(d["y"] for d in child_activity.graph_data["error"])) + self.env["automation.record.step"]._cron_automation_steps() + self.configuration.invalidate_recordset() + self.assertEqual(1, self.configuration.activity_mail_count) + self.assertEqual(2, self.configuration.activity_action_count) + activity_01.invalidate_recordset() + self.assertEqual(1, activity_01.graph_done) + self.assertEqual(0, activity_01.graph_error) + self.assertEqual(1, sum(d["y"] for d in activity_01.graph_data["done"])) + self.assertEqual(0, sum(d["y"] for d in activity_01.graph_data["error"])) + activity_02.invalidate_recordset() + self.assertEqual(0, activity_02.graph_done) + self.assertEqual(1, activity_02.graph_error) + self.assertEqual(0, sum(d["y"] for d in activity_02.graph_data["done"])) + self.assertEqual(1, sum(d["y"] for d in activity_02.graph_data["error"])) + activity_03.invalidate_recordset() + self.assertEqual(1, activity_03.graph_done) + self.assertEqual(0, activity_03.graph_error) + self.assertEqual(1, sum(d["y"] for d in activity_03.graph_data["done"])) + self.assertEqual(0, sum(d["y"] for d in activity_03.graph_data["error"])) + child_activity.invalidate_recordset() + self.assertEqual(1, child_activity.graph_done) + self.assertEqual(0, child_activity.graph_error) + self.assertEqual(1, sum(d["y"] for d in child_activity.graph_data["done"])) + self.assertEqual(0, sum(d["y"] for d in child_activity.graph_data["error"])) + + def test_schedule_date_computation_hours(self): + with freeze_time("2022-01-01"): + activity = self.create_server_action(trigger_interval=1) + self.assertEqual(1, activity.trigger_interval_hours) + self.configuration.editable_domain = ( + "[('id', '=', %s)]" % self.partner_01.id + ) + self.configuration.start_automation() + self.env["automation.configuration"].cron_automation() + record_activity = self.env["automation.record.step"].search( + [("configuration_step_id", "=", activity.id)] + ) + self.assertEqual("scheduled", record_activity.state) + self.assertEqual( + record_activity.scheduled_date, datetime(2022, 1, 1, 1, 0, 0, 0) + ) + + def test_schedule_date_computation_days(self): + with freeze_time("2022-01-01"): + activity = self.create_server_action( + trigger_interval=1, trigger_interval_type="days" + ) + self.assertEqual(24, activity.trigger_interval_hours) + self.configuration.editable_domain = ( + "[('id', '=', %s)]" % self.partner_01.id + ) + self.configuration.start_automation() + self.env["automation.configuration"].cron_automation() + record_activity = self.env["automation.record.step"].search( + [("configuration_step_id", "=", activity.id)] + ) + self.assertEqual("scheduled", record_activity.state) + self.assertEqual( + record_activity.scheduled_date, datetime(2022, 1, 2, 0, 0, 0, 0) + ) + + def test_onchange_activity_trigger_type(self): + activity = self.create_mail_activity() + child_activity = self.create_mail_activity(parent_id=activity.id) + self.assertEqual(child_activity.trigger_type, "after_step") + self.assertTrue(child_activity.parent_id) + with Form(child_activity) as f: + f.trigger_type = "mail_bounce" + self.assertTrue(f.parent_id) + + def test_onchange_activity_trigger_type_start(self): + activity = self.create_server_action() + child_activity = self.create_server_action(parent_id=activity.id) + self.assertEqual(child_activity.trigger_type, "after_step") + self.assertTrue(child_activity.parent_id) + with Form(child_activity) as f: + f.trigger_type = "start" + self.assertFalse(f.parent_id) + + def test_field_not_field_unicity(self): + self.configuration.editable_domain = ( + "[('id', 'in', %s)]" % (self.partner_01 | self.partner_02).ids + ) + self.configuration.start_automation() + self.env["automation.configuration"].cron_automation() + self.assertEqual( + 2, + len( + self.env["automation.record"].search( + [("configuration_id", "=", self.configuration.id)] + ) + ), + ) + + def test_field_field_unicity(self): + self.configuration.editable_domain = ( + "[('id', 'in', %s)]" % (self.partner_01 | self.partner_02).ids + ) + self.configuration.field_id = self.env.ref("base.field_res_partner__email") + self.configuration.start_automation() + self.env["automation.configuration"].cron_automation() + self.assertEqual( + 1, + len( + self.env["automation.record"].search( + [("configuration_id", "=", self.configuration.id)] + ) + ), + ) + self.partner_01.email = "t" + self.partner_01.email + self.env["automation.configuration"].cron_automation() + self.assertEqual( + 2, + len( + self.env["automation.record"].search( + [("configuration_id", "=", self.configuration.id)] + ) + ), + ) + + def test_configuration_filter_domain(self): + domain = [("partner_id", "=", self.partner_01.id)] + self.assertFalse(self.configuration.filter_id) + self.configuration.editable_domain = domain + self.configuration.save_filter() + self.assertTrue(self.configuration.filter_id) + self.assertEqual(self.configuration.model_id, self.configuration.model_id) + domain = [("partner_id", "=", self.partner_02.id)] + self.configuration.invalidate_recordset() + self.assertNotEqual(domain, safe_eval(self.configuration.domain)) + self.configuration.filter_id.domain = domain + self.assertEqual(domain, safe_eval(self.configuration.domain)) + with Form(self.env["automation.configuration"]) as f: + self.assertFalse(f.filter_domain) + f.name = "My other configuration" + f.filter_id = self.configuration.filter_id + self.assertEqual(f.model_id, self.env.ref("base.model_res_partner")) + self.assertIn( + self.configuration.filter_id, + self.env["automation.filter"].search(f.filter_domain), + ) + f.model_id = self.env.ref("base.model_res_users") + self.assertFalse(f.filter_id) + + def test_filter_onchange(self): + with Form(self.env["automation.filter"]) as f: + f.name = "My other configuration" + f.model_id = self.env.ref("base.model_res_partner") + f.domain = [("id", "=", 1)] + f.model_id = self.env.ref("base.model_res_users") + self.assertFalse(safe_eval(f.domain)) + + def test_constrains_mail(self): + activity = self.create_server_action() + with self.assertRaises(ValidationError): + self.create_server_action(parent_id=activity.id, trigger_type="mail_bounce") + + def test_constrains_start_with_parent(self): + activity = self.create_server_action() + with self.assertRaises(ValidationError): + self.create_server_action(parent_id=activity.id, trigger_type="start") + + def test_constrains_no_start_without_parent(self): + with self.assertRaises(ValidationError): + self.create_server_action(parent_id=False, trigger_type="after_step") + + def test_is_test_behavior(self): + """ + We want to ensure that no mails are sent on tests + """ + self.create_server_action() + with Form( + self.env["automation.configuration.test"].with_context( + default_configuration_id=self.configuration.id, + defaul_model=self.configuration.model, + ) + ) as f: + self.assertTrue(f.resource_ref) + f.resource_ref = "%s,%s" % (self.partner_01._name, self.partner_01.id) + wizard = f.save() + wizard_action = wizard.test_record() + record = self.env[wizard_action["res_model"]].browse(wizard_action["res_id"]) + self.assertEqual(self.configuration, record.configuration_id) + self.assertEqual(1, self.configuration.record_test_count) + self.assertEqual(0, self.configuration.record_count) + + def test_check_icons(self): + action = self.create_server_action() + mail = self.create_mail_activity() + activity = self.create_activity_action() + self.assertEqual(action.step_icon, "fa fa-cogs") + self.assertEqual(mail.step_icon, "fa fa-envelope") + self.assertEqual(activity.step_icon, "fa fa-clock-o") + + def test_trigger_types(self): + action = self.create_server_action() + child = self.create_server_action(parent_id=action.id) + self.assertTrue(action.trigger_type_data["allow_parent"]) + self.assertFalse(child.trigger_type_data.get("allow_parent", False)) + + def test_trigger_childs(self): + action = self.create_server_action() + mail = self.create_mail_activity() + activity = self.create_activity_action() + self.assertEqual(1, len(action.trigger_child_types)) + self.assertEqual({"after_step"}, set(action.trigger_child_types.keys())) + self.assertEqual(8, len(mail.trigger_child_types)) + self.assertEqual( + { + "after_step", + "mail_open", + "mail_not_open", + "mail_reply", + "mail_not_reply", + "mail_click", + "mail_not_clicked", + "mail_bounce", + }, + set(mail.trigger_child_types.keys()), + ) + self.assertEqual(3, len(activity.trigger_child_types)) + self.assertEqual( + {"after_step", "activity_done", "activity_not_done"}, + set(activity.trigger_child_types.keys()), + ) diff --git a/automation_oca/tests/test_automation_mail.py b/automation_oca/tests/test_automation_mail.py new file mode 100644 index 0000000..560ccf6 --- /dev/null +++ b/automation_oca/tests/test_automation_mail.py @@ -0,0 +1,568 @@ +# Copyright 2024 Dixmit +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import tools +from odoo.tests.common import Form, HttpCase + +from odoo.addons.mail.tests.common import MockEmail + +from .common import AutomationTestCase + +MAIL_TEMPLATE = """Return-Path: +To: {to} +cc: {cc} +Received: by mail1.openerp.com (Postfix, from userid 10002) + id 5DF9ABFB2A; Fri, 10 Aug 2012 16:16:39 +0200 (CEST) +From: {email_from} +Subject: {subject} +MIME-Version: 1.0 +Content-Type: multipart/alternative; + boundary="----=_Part_4200734_24778174.1344608186754" +Date: Fri, 10 Aug 2012 14:16:26 +0000 +Message-ID: {msg_id} +{extra} +------=_Part_4200734_24778174.1344608186754 +Content-Type: text/plain; charset=utf-8 +Content-Transfer-Encoding: quoted-printable + +I would gladly answer to your mass mailing ! + +-- +Your Dear Customer +------=_Part_4200734_24778174.1344608186754 +Content-Type: text/html; charset=utf-8 +Content-Transfer-Encoding: quoted-printable + + + + =20 + + =20 + =20 + +

I would gladly answer to your mass mailing !

+ +

--
+ Your Dear Customer +

+ + +------=_Part_4200734_24778174.1344608186754-- +""" + + +class TestAutomationMail(AutomationTestCase, MockEmail, HttpCase): + def test_activity_execution(self): + """ + We will check the execution of the tasks and that we cannot execute them again + """ + activity = self.create_mail_activity() + self.configuration.editable_domain = "[('id', '=', %s)]" % self.partner_01.id + self.configuration.start_automation() + self.env["automation.configuration"].cron_automation() + messages_01 = self.partner_01.message_ids + with self.mock_mail_gateway(): + self.env["automation.record.step"]._cron_automation_steps() + self.assertSentEmail(self.env.user.partner_id, [self.partner_01]) + record_activity = self.env["automation.record.step"].search( + [("configuration_step_id", "=", activity.id)] + ) + self.assertEqual(1, len(record_activity)) + self.assertEqual("done", record_activity.state) + self.assertEqual("sent", record_activity.mail_status) + self.assertTrue(self.partner_01.message_ids - messages_01) + + def test_bounce(self): + """ + Now we will check the execution of scheduled activities""" + activity = self.create_mail_activity() + child_activity = self.create_mail_activity( + parent_id=activity.id, trigger_type="mail_bounce" + ) + self.configuration.editable_domain = "[('id', '=', %s)]" % self.partner_01.id + self.configuration.start_automation() + self.env["automation.configuration"].cron_automation() + with self.mock_mail_gateway(): + self.env["automation.record.step"]._cron_automation_steps() + self.assertSentEmail(self.env.user.partner_id, [self.partner_01]) + record_activity = self.env["automation.record.step"].search( + [("configuration_step_id", "=", activity.id)] + ) + record_child_activity = self.env["automation.record.step"].search( + [("configuration_step_id", "=", child_activity.id)] + ) + self.assertEqual("sent", record_activity.mail_status) + self.assertTrue(record_child_activity) + self.assertFalse(record_child_activity.scheduled_date) + parsed_bounce_values = { + "email_from": "some.email@external.example.com", + "to": "bounce@test.example.com", + "message_id": tools.generate_tracking_message_id("MailTest"), + "bounced_partner": self.env["res.partner"].sudo(), + "bounced_message": self.env["mail.message"].sudo(), + "bounced_email": "", + "bounced_msg_id": [record_activity.message_id], + } + record_activity.invalidate_recordset() + self.assertFalse( + [ + step + for step in record_activity.step_actions + if step["done"] and step["icon"] == "fa fa-exclamation-circle" + ] + ) + self.env["mail.thread"]._routing_handle_bounce(False, parsed_bounce_values) + self.assertEqual("bounce", record_activity.mail_status) + self.assertTrue(record_child_activity.scheduled_date) + record_activity.invalidate_recordset() + self.assertTrue( + [ + step + for step in record_activity.step_actions + if step["done"] and step["icon"] == "fa fa-exclamation-circle" + ] + ) + + def test_reply(self): + """ + Now we will check the execution of scheduled activities""" + activity = self.create_mail_activity() + child_activity = self.create_mail_activity( + parent_id=activity.id, trigger_type="mail_reply" + ) + self.configuration.editable_domain = "[('id', '=', %s)]" % self.partner_01.id + self.configuration.start_automation() + self.env["automation.configuration"].cron_automation() + with self.mock_mail_gateway(): + self.env["automation.record.step"]._cron_automation_steps() + self.assertSentEmail(self.env.user.partner_id, [self.partner_01]) + record_activity = self.env["automation.record.step"].search( + [("configuration_step_id", "=", activity.id)] + ) + record_child_activity = self.env["automation.record.step"].search( + [("configuration_step_id", "=", child_activity.id)] + ) + self.assertEqual("sent", record_activity.mail_status) + self.assertTrue(record_child_activity) + self.assertFalse(record_child_activity.scheduled_date) + record_activity.invalidate_recordset() + self.assertFalse( + [ + step + for step in record_activity.step_actions + if step["done"] and step["icon"] == "fa fa-reply" + ] + ) + self.gateway_mail_reply_wrecord( + MAIL_TEMPLATE, self.partner_01, use_in_reply_to=True + ) + self.assertEqual("reply", record_activity.mail_status) + self.assertTrue(record_child_activity.scheduled_date) + record_activity.invalidate_recordset() + self.assertTrue( + [ + step + for step in record_activity.step_actions + if step["done"] and step["icon"] == "fa fa-reply" + ] + ) + + def test_no_reply(self): + """ + Now we will check the not reply validation. To remember: + if it is not opened, the schedule date of the child task will be false + """ + activity = self.create_mail_activity() + child_activity = self.create_mail_activity( + parent_id=activity.id, trigger_type="mail_not_reply" + ) + self.configuration.editable_domain = "[('id', '=', %s)]" % self.partner_01.id + self.configuration.start_automation() + self.env["automation.configuration"].cron_automation() + with self.mock_mail_gateway(): + self.env["automation.record.step"]._cron_automation_steps() + self.assertSentEmail(self.env.user.partner_id, [self.partner_01]) + record_activity = self.env["automation.record.step"].search( + [("configuration_step_id", "=", activity.id)] + ) + record_child_activity = self.env["automation.record.step"].search( + [("configuration_step_id", "=", child_activity.id)] + ) + self.assertEqual("sent", record_activity.mail_status) + self.assertTrue(record_child_activity) + self.assertFalse(record_child_activity.scheduled_date) + self.url_open(record_activity._get_mail_tracking_url()) + self.assertEqual("open", record_activity.mail_status) + self.assertTrue(record_child_activity.scheduled_date) + self.gateway_mail_reply_wrecord( + MAIL_TEMPLATE, self.partner_01, use_in_reply_to=True + ) + self.assertEqual("reply", record_activity.mail_status) + self.env["automation.record.step"]._cron_automation_steps() + self.assertEqual("rejected", record_child_activity.state) + + def test_open(self): + """ + Now we will check the execution of scheduled activities""" + activity = self.create_mail_activity() + child_activity = self.create_mail_activity( + parent_id=activity.id, trigger_type="mail_open" + ) + self.configuration.editable_domain = "[('id', '=', %s)]" % self.partner_01.id + self.configuration.start_automation() + self.env["automation.configuration"].cron_automation() + with self.mock_mail_gateway(): + self.env["automation.record.step"]._cron_automation_steps() + self.assertSentEmail(self.env.user.partner_id, [self.partner_01]) + record_activity = self.env["automation.record.step"].search( + [("configuration_step_id", "=", activity.id)] + ) + record_child_activity = self.env["automation.record.step"].search( + [("configuration_step_id", "=", child_activity.id)] + ) + self.assertEqual("sent", record_activity.mail_status) + self.assertTrue(record_child_activity) + self.assertFalse(record_child_activity.scheduled_date) + record_activity.invalidate_recordset() + self.assertFalse( + [ + step + for step in record_activity.step_actions + if step["done"] and step["icon"] == "fa fa-envelope-open-o" + ] + ) + self.url_open(record_activity._get_mail_tracking_url()) + self.assertEqual("open", record_activity.mail_status) + self.assertTrue(record_child_activity.scheduled_date) + record_activity.invalidate_recordset() + self.assertTrue( + [ + step + for step in record_activity.step_actions + if step["done"] and step["icon"] == "fa fa-envelope-open-o" + ] + ) + + def test_open_wrong_code(self): + """ + We wan to ensure that the code is checked on the call + """ + activity = self.create_mail_activity() + child_activity = self.create_mail_activity( + parent_id=activity.id, trigger_type="mail_open" + ) + self.configuration.editable_domain = "[('id', '=', %s)]" % self.partner_01.id + self.configuration.start_automation() + self.env["automation.configuration"].cron_automation() + with self.mock_mail_gateway(): + self.env["automation.record.step"]._cron_automation_steps() + self.assertSentEmail(self.env.user.partner_id, [self.partner_01]) + record_activity = self.env["automation.record.step"].search( + [("configuration_step_id", "=", activity.id)] + ) + record_child_activity = self.env["automation.record.step"].search( + [("configuration_step_id", "=", child_activity.id)] + ) + self.assertEqual("sent", record_activity.mail_status) + self.assertTrue(record_child_activity) + self.assertFalse(record_child_activity.scheduled_date) + self.url_open( + "/automation_oca/track/%s/INVENTED_CODE/blank.gif" % record_activity.id + ) + self.assertEqual("sent", record_activity.mail_status) + self.assertFalse(record_child_activity.scheduled_date) + + def test_no_open(self): + """ + Now we will check the not open validation when it is not opened (should be executed) + """ + activity = self.create_mail_activity() + child_activity = self.create_mail_activity( + parent_id=activity.id, trigger_type="mail_not_open" + ) + self.configuration.editable_domain = "[('id', '=', %s)]" % self.partner_01.id + self.configuration.start_automation() + self.env["automation.configuration"].cron_automation() + with self.mock_mail_gateway(): + self.env["automation.record.step"]._cron_automation_steps() + self.assertSentEmail(self.env.user.partner_id, [self.partner_01]) + record_activity = self.env["automation.record.step"].search( + [("configuration_step_id", "=", activity.id)] + ) + record_child_activity = self.env["automation.record.step"].search( + [("configuration_step_id", "=", child_activity.id)] + ) + self.assertEqual("sent", record_activity.mail_status) + self.assertTrue(record_child_activity) + self.assertTrue(record_child_activity.scheduled_date) + self.env["automation.record.step"]._cron_automation_steps() + self.assertEqual("done", record_child_activity.state) + + def test_no_open_rejected(self): + """ + Now we will check the not open validation when it was already opened (rejection) + """ + activity = self.create_mail_activity() + child_activity = self.create_mail_activity( + parent_id=activity.id, trigger_type="mail_not_open" + ) + self.configuration.editable_domain = "[('id', '=', %s)]" % self.partner_01.id + self.configuration.start_automation() + self.env["automation.configuration"].cron_automation() + with self.mock_mail_gateway(): + self.env["automation.record.step"]._cron_automation_steps() + self.assertSentEmail(self.env.user.partner_id, [self.partner_01]) + record_activity = self.env["automation.record.step"].search( + [("configuration_step_id", "=", activity.id)] + ) + record_child_activity = self.env["automation.record.step"].search( + [("configuration_step_id", "=", child_activity.id)] + ) + self.assertEqual("sent", record_activity.mail_status) + self.assertTrue(record_child_activity) + self.assertTrue(record_child_activity.scheduled_date) + self.url_open(record_activity._get_mail_tracking_url()) + self.assertEqual("open", record_activity.mail_status) + self.env["automation.record.step"]._cron_automation_steps() + self.assertEqual("rejected", record_child_activity.state) + + def test_click(self): + """ + Now we will check the execution of scheduled activities that should happen + after a click + """ + activity = self.create_mail_activity() + child_activity = self.create_mail_activity( + parent_id=activity.id, trigger_type="mail_click" + ) + self.configuration.editable_domain = "[('id', '=', %s)]" % self.partner_01.id + self.env["link.tracker"].search( + [("url", "=", "https://www.twitter.com")] + ).unlink() + self.configuration.start_automation() + self.assertEqual(0, self.configuration.click_count) + self.env["automation.configuration"].cron_automation() + with self.mock_mail_gateway(): + self.env["automation.record.step"]._cron_automation_steps() + self.assertSentEmail(self.env.user.partner_id, [self.partner_01]) + record_activity = self.env["automation.record.step"].search( + [("configuration_step_id", "=", activity.id)] + ) + record_child_activity = self.env["automation.record.step"].search( + [("configuration_step_id", "=", child_activity.id)] + ) + self.assertEqual("sent", record_activity.mail_status) + self.configuration.invalidate_recordset() + self.assertEqual(0, self.configuration.click_count) + self.assertTrue(record_child_activity) + self.assertFalse(record_child_activity.scheduled_date) + self.url_open(record_activity._get_mail_tracking_url()) + self.assertEqual("open", record_activity.mail_status) + self.configuration.invalidate_recordset() + self.assertEqual(0, self.configuration.click_count) + self.assertFalse(record_child_activity.scheduled_date) + record_activity.invalidate_recordset() + self.assertFalse( + [ + step + for step in record_activity.step_actions + if step["done"] and step["icon"] == "fa fa-hand-pointer-o" + ] + ) + tracker = self.env["link.tracker"].search( + [("url", "=", "https://www.twitter.com")] + ) + self.assertTrue(tracker) + self.url_open( + "/r/%s/au/%s/%s" + % ( + tracker.code, + record_activity.id, + record_activity._get_mail_tracking_token(), + ) + ) + self.assertEqual("open", record_activity.mail_status) + self.assertEqual( + 1, + self.env["link.tracker.click"].search_count( + [ + ("automation_record_step_id", "=", record_activity.id), + ("link_id", "=", tracker.id), + ] + ), + ) + record_activity.invalidate_recordset() + self.assertTrue( + [ + step + for step in record_activity.step_actions + if step["done"] and step["icon"] == "fa fa-hand-pointer-o" + ] + ) + self.assertTrue(record_child_activity.scheduled_date) + self.configuration.invalidate_recordset() + self.assertEqual(1, self.configuration.click_count) + # Now we will check that a second click does not generate a second log + self.url_open( + "/r/%s/au/%s/%s" + % ( + tracker.code, + record_activity.id, + record_activity._get_mail_tracking_token(), + ) + ) + self.assertEqual( + 1, + self.env["link.tracker.click"].search_count( + [ + ("automation_record_step_id", "=", record_activity.id), + ("link_id", "=", tracker.id), + ] + ), + ) + self.configuration.invalidate_recordset() + self.assertEqual(1, self.configuration.click_count) + + def test_click_wrong_url(self): + """ + Now we will check that no log is processed when the clicked url is malformed. + That happens because we add a code information on the URL. + """ + activity = self.create_mail_activity() + child_activity = self.create_mail_activity( + parent_id=activity.id, trigger_type="mail_click" + ) + self.configuration.editable_domain = "[('id', '=', %s)]" % self.partner_01.id + self.configuration.start_automation() + self.env["automation.configuration"].cron_automation() + with self.mock_mail_gateway(): + self.env["automation.record.step"]._cron_automation_steps() + self.assertSentEmail(self.env.user.partner_id, [self.partner_01]) + record_activity = self.env["automation.record.step"].search( + [("configuration_step_id", "=", activity.id)] + ) + record_child_activity = self.env["automation.record.step"].search( + [("configuration_step_id", "=", child_activity.id)] + ) + self.assertEqual("sent", record_activity.mail_status) + self.assertTrue(record_child_activity) + self.assertFalse(record_child_activity.scheduled_date) + tracker = self.env["link.tracker"].search( + [("url", "=", "https://www.twitter.com")] + ) + self.assertTrue(tracker) + self.url_open( + "/r/%s/au/%s/1234" + % ( + tracker.code, + record_activity.id, + ) + ) + self.assertEqual("sent", record_activity.mail_status) + self.assertFalse(record_child_activity.scheduled_date) + # Now we check the case where the code is not found + tracker.unlink() + self.url_open( + "/r/%s/au/%s/%s" + % ( + tracker.code, + record_activity.id, + record_activity._get_mail_tracking_token(), + ) + ) + self.assertEqual("sent", record_activity.mail_status) + self.assertFalse(record_child_activity.scheduled_date) + + def test_no_click(self): + """ + Checking the not clicked validation when it is not clicked (should be executed) + """ + activity = self.create_mail_activity() + child_activity = self.create_mail_activity( + parent_id=activity.id, trigger_type="mail_not_clicked" + ) + self.configuration.editable_domain = "[('id', '=', %s)]" % self.partner_01.id + self.configuration.start_automation() + self.env["automation.configuration"].cron_automation() + with self.mock_mail_gateway(): + self.env["automation.record.step"]._cron_automation_steps() + self.assertSentEmail(self.env.user.partner_id, [self.partner_01]) + record_activity = self.env["automation.record.step"].search( + [("configuration_step_id", "=", activity.id)] + ) + record_child_activity = self.env["automation.record.step"].search( + [("configuration_step_id", "=", child_activity.id)] + ) + self.assertEqual("sent", record_activity.mail_status) + self.assertTrue(record_child_activity) + self.assertFalse(record_child_activity.scheduled_date) + self.url_open(record_activity._get_mail_tracking_url()) + self.assertEqual("open", record_activity.mail_status) + self.assertTrue(record_child_activity.scheduled_date) + self.env["automation.record.step"]._cron_automation_steps() + self.assertEqual("done", record_child_activity.state) + + def test_no_click_rejected(self): + """ + Checking the not clicked validation when it was already clicked + """ + activity = self.create_mail_activity() + child_activity = self.create_mail_activity( + parent_id=activity.id, trigger_type="mail_not_clicked" + ) + self.configuration.editable_domain = "[('id', '=', %s)]" % self.partner_01.id + self.configuration.start_automation() + self.env["automation.configuration"].cron_automation() + with self.mock_mail_gateway(): + self.env["automation.record.step"]._cron_automation_steps() + self.assertSentEmail(self.env.user.partner_id, [self.partner_01]) + record_activity = self.env["automation.record.step"].search( + [("configuration_step_id", "=", activity.id)] + ) + record_child_activity = self.env["automation.record.step"].search( + [("configuration_step_id", "=", child_activity.id)] + ) + self.assertEqual("sent", record_activity.mail_status) + self.assertTrue(record_child_activity) + self.assertFalse(record_child_activity.scheduled_date) + self.url_open(record_activity._get_mail_tracking_url()) + self.assertEqual("open", record_activity.mail_status) + self.assertTrue(record_child_activity.scheduled_date) + tracker = self.env["link.tracker"].search( + [("url", "=", "https://www.twitter.com")] + ) + self.url_open( + "/r/%s/au/%s/%s" + % ( + tracker.code, + record_activity.id, + record_activity._get_mail_tracking_token(), + ) + ) + self.env["automation.record.step"]._cron_automation_steps() + self.assertEqual("rejected", record_child_activity.state) + + def test_is_test_behavior(self): + """ + We want to ensure that no mails are sent on tests + """ + self.create_mail_activity() + self.configuration.editable_domain = "[('id', '=', %s)]" % self.partner_01.id + with Form( + self.env["automation.configuration.test"].with_context( + default_configuration_id=self.configuration.id, + defaul_model=self.configuration.model, + ) + ) as f: + self.assertTrue(f.resource_ref) + f.resource_ref = "%s,%s" % (self.partner_01._name, self.partner_01.id) + wizard = f.save() + wizard_action = wizard.test_record() + record = self.env[wizard_action["res_model"]].browse(wizard_action["res_id"]) + self.assertTrue(record) + self.assertEqual("scheduled", record.automation_step_ids.state) + self.assertFalse(record.automation_step_ids.mail_status) + with self.mock_mail_gateway(): + record.automation_step_ids.run() + self.assertNotSentEmail() + self.assertEqual("sent", record.automation_step_ids.mail_status) diff --git a/automation_oca/tests/test_automation_security.py b/automation_oca/tests/test_automation_security.py new file mode 100644 index 0000000..dbb605e --- /dev/null +++ b/automation_oca/tests/test_automation_security.py @@ -0,0 +1,105 @@ +# Copyright 2024 Dixmit +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo.tests.common import users + +from odoo.addons.mail.tests.common import mail_new_test_user + +from .common import AutomationTestCase + + +class TestAutomationSecurity(AutomationTestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + # Removing rules in order to check only what we expect + cls.env["ir.rule"].search( + [("model_id", "=", cls.env.ref("base.model_res_partner").id)] + ).toggle_active() + + cls.user_automation_01 = mail_new_test_user( + cls.env, + login="user_automation_01", + name="User automation 01", + email="user_automation_01@test.example.com", + company_id=cls.env.user.company_id.id, + notification_type="inbox", + groups="base.group_user,automation_oca.group_automation_user", + ) + cls.user_automation_02 = mail_new_test_user( + cls.env, + login="user_automation_02", + name="User automation 01", + email="user_automation_02@test.example.com", + company_id=cls.env.user.company_id.id, + notification_type="inbox", + groups="base.group_user,automation_oca.group_automation_user", + ) + cls.group_1 = cls.env["res.groups"].create( + { + "name": "G1", + "users": [(4, cls.user_automation_01.id)], + "rule_groups": [ + ( + 0, + 0, + { + "name": "Rule 01", + "model_id": cls.env.ref("base.model_res_partner").id, + "domain_force": "[('id', '!=', %s)]" % cls.partner_01.id, + }, + ) + ], + } + ) + cls.group_2 = cls.env["res.groups"].create( + { + "name": "G2", + "users": [(4, cls.user_automation_02.id)], + "rule_groups": [ + ( + 0, + 0, + { + "name": "Rule 01", + "model_id": cls.env.ref("base.model_res_partner").id, + "domain_force": "[('id', '!=', %s)]" % cls.partner_02.id, + }, + ) + ], + } + ) + cls.configuration.editable_domain = [ + ("id", "in", (cls.partner_01 | cls.partner_02).ids) + ] + cls.configuration.start_automation() + cls.env["automation.configuration"].cron_automation() + + @users("user_automation_01") + def test_security_01(self): + record = self.env["automation.record"].search( + [("configuration_id", "=", self.configuration.id)] + ) + self.assertEqual(1, len(record)) + self.assertEqual(self.partner_02, record.resource_ref) + + @users("user_automation_02") + def test_security_02(self): + record = self.env["automation.record"].search( + [("configuration_id", "=", self.configuration.id)] + ) + self.assertEqual(1, len(record)) + self.assertEqual(self.partner_01, record.resource_ref) + + @users("user_automation_01") + def test_security_deleted_record(self): + original_record = self.env["automation.record"].search( + [("configuration_id", "=", self.configuration.id)] + ) + self.partner_02.unlink() + record = self.env["automation.record"].search( + [("configuration_id", "=", self.configuration.id)] + ) + self.assertFalse(record) + self.assertTrue(original_record) + self.assertFalse(original_record.read()) diff --git a/automation_oca/views/automation_configuration.xml b/automation_oca/views/automation_configuration.xml new file mode 100644 index 0000000..44ec517 --- /dev/null +++ b/automation_oca/views/automation_configuration.xml @@ -0,0 +1,488 @@ + + + + + + automation.configuration + +

+
+
+ + + + +
+ + +
+ +
+

+
+ + + + + + + + + + + + + + + + + + + + + +
+
+
+
+
+
+ +
+
+ +
+ + + + + +
+
+
+
+ + +
+
+ +
+
+

+ +

+
Processed
+

+ +

+
Error
+
+
+ +
+ +
+ + +
+ +
+
+
+
+
+
+ + + + +
+ + +
+ + + + + + automation.configuration + + + + + + + + + + + + + + + + automation.configuration + + + + + +
+
+ + +
+
+
+
Records
+ +
+
+
Running
+ +
+
+
Done
+ +
+
+
+
+
+ +
+
+
+ +
+
+
+ +
+
+
+
+
+
+
+
+ + + + automation.configuration + + + + + + + + + + Automation Configuration + automation.configuration + kanban,tree,form + [] + {} + + + + Automation Configuration + + + + + + diff --git a/automation_oca/views/automation_configuration_step.xml b/automation_oca/views/automation_configuration_step.xml new file mode 100644 index 0000000..ec30383 --- /dev/null +++ b/automation_oca/views/automation_configuration_step.xml @@ -0,0 +1,186 @@ + + + + + + automation.configuration.step + +
+
+ +
+

+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
This is the final domain that will be applied to the records. + Consists in the join of the specific domain of the step and the domain of the records.
+ +
+ + + + + + +
+ +
+ + + + + + automation.configuration.step + + + + + + + + + automation.configuration.step + + + + + + + + + diff --git a/automation_oca/views/automation_filter.xml b/automation_oca/views/automation_filter.xml new file mode 100644 index 0000000..0cbe9a1 --- /dev/null +++ b/automation_oca/views/automation_filter.xml @@ -0,0 +1,65 @@ + + + + + + automation.filter + +
+
+ + + + + + + + + + + + + + automation.filter + + + + + + + + + + automation.filter + + + + + + + + + + Filters + automation.filter + tree,form + [] + {} + + + + Filters + + + + + + diff --git a/automation_oca/views/automation_record.xml b/automation_oca/views/automation_record.xml new file mode 100644 index 0000000..ef0a15c --- /dev/null +++ b/automation_oca/views/automation_record.xml @@ -0,0 +1,297 @@ + + + + + + automation.record + +
+
+ +
+ + + +
+

+
+ + + + + + + + + + + + + + + + + + + + + +
+
+
+
+
+
+ +
+ + + + Expired + + + Rejected + + + Error on + + + Cancelled + + + + + + +
+
+
+
+
+ +
+
+

+
+
+
+
+
+
+ +
+ +
+
+
+
+
+ + + + + + + + + + automation.record + + + + + + + + + + + automation.record + + + + + + + + + + + + + automation.record.graph + automation.record + + + + + + + + + automation.record.pivot + automation.record + + + + + + + + + + Automation Record + automation.record + graph,pivot,tree,form + [] + {} + + + + Records + automation.record + tree,form + [('configuration_id', '=', active_id), ('is_test', '=', False)] + {} + + + Test Records + automation.record + tree,form + [('configuration_id', '=', active_id), ('is_test', '=', True)] + {} + + + + Records + + + + + + diff --git a/automation_oca/views/automation_record_step.xml b/automation_oca/views/automation_record_step.xml new file mode 100644 index 0000000..1afb773 --- /dev/null +++ b/automation_oca/views/automation_record_step.xml @@ -0,0 +1,142 @@ + + + + + + automation.record.step + +
+
+ +
+ + + + + + + + + + + + + +
+
+
+ + + automation.record.step + + + + +
-
-

Configuration of activities

-

Activities can trigger one of the following options:

+
+

Configuration of steps

+

Steps can trigger one of the following options:

    -
  • Email: Sends an email using a template.
  • +
  • Mail: Sends an email using a template.
  • Server Action: Executes a server action.
  • Activity: Creates an activity to the related record.
-

All the activities need to specify the moment of execution. We will set -the number of hours/days and a trigger type:

+

All the steps need to specify the moment of execution. We will set the +number of hours/days and a trigger type:

  • Start of workflow: It will be executed at the previously-configured time after we create the record.
  • -
  • Execution of another activity: It will be executed at the -previously-configured time after the previous activity is finished +
  • Execution of another step: It will be executed at the +previously-configured time after the previous step is finished properly.
  • Mail opened: It will be executed at the previously-configured -time after the mail from the previous activity is opened.
  • +time after the mail from the previous step is opened.
  • Mail not opened: It will be executed at the previously-configured -time after the mail from the previous activity is sent if it is not +time after the mail from the previous step is sent if it is not opened before this time.
  • Mail replied: It will be executed at the previously-configured -time after the mail from the previous activity is replied.
  • +time after the mail from the previous step is replied.
  • Mail not replied: It will be executed at the -previously-configured time after the mail from the previous activity -is opened if it has not been replied.
  • +previously-configured time after the mail from the previous step is +opened if it has not been replied.
  • Mail clicked: It will be executed at the previously-configured -time after the links of the mail from the previous activity are -clicked.
  • +time after the links of the mail from the previous step are clicked.
  • Mail not clicked: It will be executed at the -previously-configured time after the mail from the previous activity -is opened and no links are clicked.
  • +previously-configured time after the mail from the previous step is +opened and no links are clicked.
  • Mail bounced: It will be executed at the previously-configured -time after the mail from the previous activity is bounced back for -any reason.
  • -
  • Activity done: It will be executed at the previously-configured -time after the activity from the previous action is done.
  • -
  • Activity not done: It will be executed at the +time after the mail from the previous step is bounced back for any +reason.
  • +
  • Activity has been finished: It will be executed at the +previously-configured time after the activity from the previous +action is done.
  • +
  • Activity has not been finished: It will be executed at the previously-configured time after the previous action is executed if the related activity is not done.
@@ -464,11 +477,10 @@

Records creation

Records are created using a cron action. This action is executed every 6 hours by default.

-
-

Activity execution

-

Activities are executed using a cron action. This action is executed -every hour by default. On the record view, you can execute manually an -action.

+
+

Step execution

+

Steps are executed using a cron action. This action is executed every +hour by default. On the record view, you can execute manually an action.

+

Automation Oca

+ + +

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

+

This module allows to automate several process according to some rules.

+

This is useful for creating automated actions on your database like:

+
    +
  • Send a welcome email to all new partners (or filtered according to +some rules)
  • +
  • Remember to online customers that they forgot their basket with some +items
  • +
  • Send documents to sign to all new employees
  • +
+

Table of contents

+ +
+

Usage

+
+

Configure your processes

+
    +
  1. Access the Automation menu.
  2. +
  3. Create a new Configuration.
  4. +
  5. Set the model and filters.
  6. +
  7. Create the different activities.
  8. +
  9. Press Start. Now, every 6 hours, a process will check if new +records need to be created.
  10. +
  11. Inside the process, you can check all the created items.
  12. +
+

Configuration Screenshot

+
+
+

Configuration of activities

+

Activities can trigger one of the following options:

+
    +
  • Email: Sends an email using a template.
  • +
  • Server Action: Executes a server action.
  • +
  • Activity: Creates an activity to the related record.
  • +
+

All the activities need to specify the moment of execution. We will set +the number of hours/days and a trigger type:

+
    +
  • Start of workflow: It will be executed at the +previously-configured time after we create the record.
  • +
  • Execution of another activity: It will be executed at the +previously-configured time after the previous activity is finished +properly.
  • +
  • Mail opened: It will be executed at the previously-configured +time after the mail from the previous activity is opened.
  • +
  • Mail not opened: It will be executed at the previously-configured +time after the mail from the previous activity is sent if it is not +opened before this time.
  • +
  • Mail replied: It will be executed at the previously-configured +time after the mail from the previous activity is replied.
  • +
  • Mail not replied: It will be executed at the +previously-configured time after the mail from the previous activity +is opened if it has not been replied.
  • +
  • Mail clicked: It will be executed at the previously-configured +time after the links of the mail from the previous activity are +clicked.
  • +
  • Mail not clicked: It will be executed at the +previously-configured time after the mail from the previous activity +is opened and no links are clicked.
  • +
  • Mail bounced: It will be executed at the previously-configured +time after the mail from the previous activity is bounced back for +any reason.
  • +
  • Activity done: It will be executed at the previously-configured +time after the activity from the previous action is done.
  • +
  • Activity not done: It will be executed at the +previously-configured time after the previous action is executed if +the related activity is not done.
  • +
+

Important to remember to define a proper template when sending the +email. It will the template without using a notification template. Also, +it is important to define correctly the text partner or email to field +on the template

+
+
+

Records creation

+

Records are created using a cron action. This action is executed every 6 +hours by default.

+
+
+

Activity execution

+

Activities are executed using a cron action. This action is executed +every hour by default. On the record view, you can execute manually an +action.

+
+
+
+

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

+
    +
  • Dixmit
  • +
+
+
+

Contributors

+ +
+
+

Other credits

+

The development of this module has been financially supported by:

+
    +
  • Associacion Española de Odoo (AEODOO)
  • +
+
+
+

Maintainers

+

This module is maintained by the OCA.

+Odoo Community Association +

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

+

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

+

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

+
+
+