Initial commit: OCA Technical packages (595 packages)

This commit is contained in:
Ernad Husremovic 2025-08-29 15:43:03 +02:00
commit 2cc02aac6e
24950 changed files with 2318079 additions and 0 deletions

View file

@ -0,0 +1,45 @@
# Automation Oca
Odoo addon: automation_oca
## Installation
```bash
pip install odoo-bringout-oca-automation-automation_oca
```
## Dependencies
This addon depends on:
- mail
- link_tracker
## Manifest Information
- **Name**: Automation Oca
- **Version**: 16.0.1.5.5
- **Category**: Automation
- **License**: AGPL-3
- **Installable**: False
## Source
Based on [OCA/automation](https://github.com/OCA/automation) branch 16.0, addon `automation_oca`.
## License
This package maintains the original AGPL-3 license from the upstream Odoo project.
## Documentation
- Overview: doc/OVERVIEW.md
- Architecture: doc/ARCHITECTURE.md
- Models: doc/MODELS.md
- Controllers: doc/CONTROLLERS.md
- Wizards: doc/WIZARDS.md
- Install: doc/INSTALL.md
- Usage: doc/USAGE.md
- Configuration: doc/CONFIGURATION.md
- Dependencies: doc/DEPENDENCIES.md
- Troubleshooting: doc/TROUBLESHOOTING.md
- FAQ: doc/FAQ.md

View file

@ -0,0 +1,188 @@
.. image:: https://odoo-community.org/readme-banner-image
:target: https://odoo-community.org/get-involved?utm_source=readme
:alt: Odoo Community Association
==============
Automation Oca
==============
..
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! This file is generated by oca-gen-addon-readme !!
!! changes will be overwritten. !!
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! source digest: sha256:bf7f94a060a16c6d36248bb970d9cbc5925480da1127a49bfb77208b1a1821e5
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png
:target: https://odoo-community.org/page/development-status
:alt: Beta
.. |badge2| image:: https://img.shields.io/badge/license-AGPL--3-blue.png
: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 Automation Configuration.
3. Set the model and domains.
4. Go to Configuration -> Filters to create filters as a preconfigured
domains. Filters can be established in the proper field in the
Automation Configuration record.
5. Create the different steps by clicking the "ADD" button inside the
automation configuration form.
6. Create child steps by clicking the "Add child activity" at the
bottom of a created step.
7.
8. Select the kind of configuration you create. You can choose between:
- *Periodic configurations*: every 6 hours, a process will check if
new records need to be created.
- *On demand configurations*: user need to execute manually the job.
9. Press ``Start``.
10. Inside the process, you can check all the created items.
|Configuration Screenshot|
Configuration of steps
----------------------
Steps can trigger one of the following options:
- ``Mail``: Sends an email using a template.
- ``Server Action``: Executes a server action.
- ``Activity``: Creates an activity to the related record.
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 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 step is opened.
- ``Mail not opened``: It will be executed at the previously-configured
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 step is replied.
- ``Mail not replied``: It will be executed at the 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 step are clicked.
- ``Mail not clicked``: It will be executed at the 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 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.
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.
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.
There is a way to enforce step execution when finalize the previous one.
If we set a negative value on the period, the execution will be
immediate without a cron.
.. |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 <https://github.com/OCA/automation/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 <https://github.com/OCA/automation/issues/new?body=module:%20automation_oca%0Aversion:%2016.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**>`_.
Do not contact contributors directly about support or help with technical issues.
Credits
=======
Authors
-------
* Dixmit
Contributors
------------
- Enric Tobella (`Dixmit <https://www.dixmit.com/>`__)
Other credits
-------------
The development of this module has been financially supported by:
- Associacion Española de Odoo (`AEODOO <https://www.aeodoo.org/>`__)
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 <https://github.com/OCA/automation/tree/16.0/automation_oca>`_ project on GitHub.
You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

View file

@ -0,0 +1,3 @@
from . import controllers
from . import models
from . import wizards

View file

@ -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.5.5",
"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",
],
}

View file

@ -0,0 +1 @@
from . import main

View file

@ -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/<int:record_id>/<string:token>/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/<string:code>/au/<int:record_id>/<string:token>", 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)

View file

@ -0,0 +1,28 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo noupdate="1">
<record forcecreate="True" id="cron_step_execute" model="ir.cron">
<field name="name">Automation: Execute scheduled activities</field>
<field name="model_id" ref="model_automation_record_step" />
<field name="state">code</field>
<field name="code">model._cron_automation_steps()</field>
<field name="user_id" ref="base.user_root" />
<field name="interval_number">1</field>
<field name="interval_type">hours</field>
<field name="numbercall">-1</field>
<field name="active" eval="True" />
<field name="doall" eval="False" />
</record>
<record forcecreate="True" id="cron_configuration_run" model="ir.cron">
<field name="name">Automation: Create records</field>
<field name="model_id" ref="model_automation_configuration" />
<field name="state">code</field>
<field name="code">model.cron_automation()</field>
<field name="user_id" ref="base.user_root" />
<field name="interval_number">6</field>
<field name="interval_type">hours</field>
<field name="numbercall">-1</field>
<field name="active" eval="True" />
<field name="doall" eval="False" />
</record>
</odoo>

View file

@ -0,0 +1,64 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<record id="demo_tag_process" model="automation.tag">
<field name="name">Automatic Process</field>
</record>
<record id="demo_tag_demo" model="automation.tag">
<field name="name">Demo</field>
</record>
<record id="demo_bounce_action" model="ir.actions.server">
<field name="name">Blacklist Partner</field>
<field name="type">ir.actions.server</field>
<field name="state">code</field>
<field name="model_id" ref="base.model_res_partner" />
<field name="code">
for record in records.filtered(lambda r: not r.is_blacklisted):
env["mail.blacklist"].create({"email": record.email})
</field>
</record>
<record id="demo_welcome_template" model="mail.template">
<field name="name">Welcome</field>
<field name="model_id" ref="base.model_res_partner" />
<field name="partner_to">{{object.id}}</field>
<field name="subject">Welcome! Thanks for being part of our database</field>
<field name="body_html" type="html">
<p>Welcome!</p>
<p>Thanks <t t-out="object.name" /> for becoming a contact.</p>
<p>Kind regards,</p>
</field>
</record>
<record id="demo_configuration_welcome" model="automation.configuration">
<field name="name">Welcome email</field>
<field name="model_id" ref="base.model_res_partner" />
<field
name="tag_ids"
eval="[(4, ref('demo_tag_process')), (4, ref('demo_tag_demo'))]"
/>
<field name="field_id" ref="base.field_res_partner__email" />
<field
name="editable_domain"
>[ ("email", "!=", False), ("create_date", "&lt;=", datetime.datetime.now())]</field>
</record>
<record id="demo_configuration_welcome_send" model="automation.configuration.step">
<field name="name">Send email</field>
<field name="configuration_id" ref="demo_configuration_welcome" />
<field name="step_type">mail</field>
<field name="trigger_interval">2</field>
<field name="mail_template_id" ref="demo_welcome_template" />
</record>
<record
id="demo_configuration_welcome_bounced"
model="automation.configuration.step"
>
<field name="name">Blacklist bounced</field>
<field name="configuration_id" ref="demo_configuration_welcome" />
<field name="step_type">action</field>
<field name="trigger_type">mail_bounce</field>
<field name="expiry" eval="True" />
<field name="expiry_interval">24</field>
<field name="parent_id" ref="demo_configuration_welcome_send" />
<field name="trigger_interval">1</field>
<field name="server_action_id" ref="demo_bounce_action" />
</record>
</odoo>

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -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

View file

@ -0,0 +1,328 @@
# 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 (
datetime as safe_datetime,
dateutil as safe_dateutil,
safe_eval,
time as safe_time,
)
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="[]",
compute="_compute_domain",
help="""
Filter to apply
Following special variable can be used in filter :
* datetime
* dateutil
* time
* user
* ref """,
)
editable_domain = fields.Char(
required=True,
default="[]",
help="""Filter to apply
Following special variable can be used in filter :
* datetime
* dateutil
* time
* user
* ref """,
)
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_eval_context(self):
"""Prepare the context used when evaluating python code
:returns: dict -- evaluation context given to safe_eval
"""
return {
"ref": self.env.ref,
"user": self.env.user,
"time": safe_time,
"datetime": safe_datetime,
"dateutil": safe_dateutil,
}
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
"""
eval_context = self._get_eval_context()
domain = safe_eval(self.domain, eval_context)
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,
}
)

View file

@ -0,0 +1,566 @@
# Copyright 2024 Dixmit
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
import json
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"
)
apply_parent_domain = fields.Boolean(default=True)
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_date_kind = fields.Selection(
[
("offset", "Offset"),
("date", "Force on Record Date"),
],
required=True,
default="offset",
)
trigger_interval_hours = fields.Integer(
compute="_compute_trigger_interval_hours", store=True
)
trigger_interval = fields.Integer(
help="""Set a negative time trigger if you want the step to be executed
immediately, in parallel with the previous step, without waiting for it to
finish."""
)
trigger_interval_type = fields.Selection(
[("hours", "Hour(s)"), ("days", "Day(s)")], required=True, default="hours"
)
trigger_date_field_id = fields.Many2one(
"ir.model.fields",
domain="[('model_id', '=', model_id), ('ttype', 'in', ['date', 'datetime'])]",
)
trigger_date_field = fields.Char(related="trigger_date_field_id.field_description")
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", "Hour(s)"), ("days", "Day(s)")], 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)]"
)
server_context = fields.Text(default="{}")
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", "Day(s)"), ("weeks", "Week(s)"), ("months", "Month(s)")],
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.constrains("server_context")
def _check_server_context(self):
for record in self:
if record.server_context:
try:
json.loads(record.server_context)
except Exception as e:
raise ValidationError(_("Server Context is not wellformed")) from e
@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",
"apply_parent_domain",
)
def _compute_applied_domain(self):
for record in self:
eval_context = record.configuration_id._get_eval_context()
if record.apply_parent_domain:
record.applied_domain = expression.AND(
[
safe_eval(record.domain, eval_context),
safe_eval(
(record.parent_id and record.parent_id.applied_domain)
or record.configuration_id.domain,
eval_context,
),
]
)
else:
record.applied_domain = safe_eval(record.domain, eval_context)
@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_cancel": {
"name": _("Activity has been cancelled"),
"step_type": ["activity"],
"color": "text-warning",
"icon": "fa fa-ban",
"message_configuration": _("After finished"),
"message": _("Activity not cancelled"),
},
"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, record, force=False):
if not force and self.trigger_type in [
"mail_open",
"mail_bounce",
"mail_click",
"mail_not_clicked",
"mail_reply",
"mail_not_reply",
"activity_done",
"activity_cancel",
]:
return False
if (
self.trigger_date_kind == "date"
and self.trigger_date_field_id
and record[self.trigger_date_field_id.name]
):
date = fields.Datetime.to_datetime(record[self.trigger_date_field_id.name])
else:
date = fields.Datetime.now()
return date + 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):
scheduled_date = self._get_record_activity_scheduled_date(record)
do_not_wait = scheduled_date and scheduled_date < fields.Datetime.now()
return {
"configuration_step_id": self.id,
"do_not_wait": do_not_wait,
"expiry_date": self._get_expiry_date(),
"scheduled_date": scheduled_date,
**kwargs,
}

View file

@ -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 = []

View file

@ -0,0 +1,201 @@
# 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()
is_orphan_record = fields.Boolean(
default=False,
help="Indicates if this record is a placeholder for a missing resource.",
readonly=True,
)
@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:
if not record.is_orphan_record:
record.name = self.env[record.model].browse(record.res_id).display_name
else:
record.name = _("Orphan Record")
@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 not ids:
return 0 if count else []
orig_ids = ids
ids = set(ids)
result = []
model_data = defaultdict(lambda: defaultdict(set))
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
res_ids = targets.keys()
recs = self.env[model].browse(res_ids)
missing = recs - recs.exists()
if len(missing) > 0:
for res_id in targets.keys():
if res_id and res_id not in missing.ids:
continue
automation_record = self.env["automation.record"].browse(
list(targets[res_id])
)
if not automation_record.is_orphan_record:
_logger.info(
"Deleted record %s,%s is referenced by automation.record",
model,
res_id,
)
# sudo to avoid access rights check on the record
automation_record.sudo().write(
{
"is_orphan_record": True,
"res_id": False,
}
)
result += list(targets[res_id])
allowed = (
self.env[model]
.with_context(active_test=False)
._search([("id", "in", recs.ids)])
)
for target_id in allowed:
result += list(targets.get(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)

View file

@ -0,0 +1,459 @@
# Copyright 2024 Dixmit
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
import json
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(compute="_compute_step_data", store=True)
record_id = fields.Many2one("automation.record", required=True, ondelete="cascade")
configuration_step_id = fields.Many2one("automation.configuration.step")
configuration_id = fields.Many2one(
"automation.configuration",
compute="_compute_step_data",
store=True,
)
step_type = fields.Selection(
selection=lambda r: r.env["automation.configuration.step"]
._fields["step_type"]
.selection,
compute="_compute_step_data",
store=True,
)
scheduled_date = fields.Datetime(readonly=True)
do_not_wait = fields.Boolean()
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(
selection=lambda r: r.env[
"automation.configuration.step"
]._trigger_type_selection(),
compute="_compute_step_data",
store=True,
)
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)
activity_cancel_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("configuration_step_id")
def _compute_step_data(self):
for record in self.filtered(lambda r: r.configuration_step_id):
record.name = record.configuration_step_id.name
record.configuration_id = record.configuration_step_id.configuration_id
record.step_type = record.configuration_step_id.step_type
record.trigger_type = record.configuration_step_id.trigger_type
@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.configuration_step_id
or not self.record_id.resource_ref.filtered_domain(
safe_eval(
self.configuration_step_id.applied_domain,
self.configuration_step_id.configuration_id._get_eval_context(),
)
)
or not self._check_to_execute()
):
self._reject()
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 _reject(self):
self.write({"state": "rejected", "processed_on": fields.Datetime.now()})
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):
context = {}
if self.configuration_step_id.server_context:
context.update(json.loads(self.configuration_step_id.server_context))
self.configuration_step_id.server_action_id.with_context(
**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 activity in self.filtered(lambda r: r.do_not_wait):
activity.run()
for date in set(
self.filtered(lambda r: not r.do_not_wait).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)
current_date = fields.Datetime.now()
for record in todo:
config = record.configuration_step_id
scheduled_date = config._get_record_activity_scheduled_date(
record.record_id.resource_ref, force=True
)
record.write(
{
"scheduled_date": scheduled_date,
"do_not_wait": scheduled_date < current_date,
}
)
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()
self.child_ids.filtered(
lambda r: r.trigger_type == "activity_cancel"
and not r.scheduled_date
and r.state == "scheduled"
)._reject()
def _set_activity_cancel(self):
self.write({"activity_cancel_on": fields.Datetime.now()})
self.child_ids.filtered(
lambda r: r.trigger_type == "activity_cancel"
and not r.scheduled_date
and r.state == "scheduled"
)._activate()
self.child_ids.filtered(
lambda r: r.trigger_type == "activity_done"
and not r.scheduled_date
and r.state == "scheduled"
)._reject()
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 []

View file

@ -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)

View file

@ -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)

View file

@ -0,0 +1,27 @@
# 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.sudo()._set_activity_done()
return super(
MailActivity,
self.with_context(
automation_done=True,
),
)._action_done(*args, **kwargs)
def unlink(self):
if self.automation_record_step_id and not self.env.context.get(
"automation_done"
):
self.automation_record_step_id.sudo()._set_activity_cancel()
return super(MailActivity, self).unlink()

View file

@ -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,
'<img src="%s"/>'
% self.automation_record_step_id._get_mail_tracking_url(),
plaintext=False,
)
return body

View file

@ -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

View file

@ -0,0 +1 @@
- Enric Tobella ([Dixmit](https://www.dixmit.com/))

View file

@ -0,0 +1,3 @@
The development of this module has been financially supported by:
- Associacion Española de Odoo ([AEODOO](https://www.aeodoo.org/))

View file

@ -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

View file

@ -0,0 +1,59 @@
Configure your processes
------------------------
1. Access the `Automation` menu.
2. Create a new Automation Configuration.
3. Set the model and domains.
4. Go to Configuration -> Filters to create filters as a preconfigured domains.
Filters can be established in the proper field in the Automation Configuration record.
5. Create the different steps by clicking the "ADD" button inside the automation configuration form.
6. Create child steps by clicking the "Add child activity" at the bottom of a created step.
7.
8. Select the kind of configuration you create. You can choose between:
* *Periodic configurations*: every 6 hours, a process will check if new records need to be created.
* *On demand configurations*: user need to execute manually the job.
9. Press `Start`.
10. Inside the process, you can check all the created items.
![Configuration Screenshot](./static/description/configuration.png)
Configuration of steps
---------------------------
Steps can trigger one of the following options:
- `Mail`: Sends an email using a template.
- `Server Action`: Executes a server action.
- `Activity`: Creates an activity to the related record.
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 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 step is opened.
- `Mail not opened`: It will be executed at the previously-configured 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 step is replied.
- `Mail not replied`: It will be executed at the 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 step are clicked.
- `Mail not clicked`: It will be executed at the 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 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.
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.
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.
There is a way to enforce step execution when finalize the previous one.
If we set a negative value on the period, the execution will be immediate without a cron.

View file

@ -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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_automation_configuration Access Automation Configuration model_automation_configuration group_automation_user 1 0 0 0
3 manage_automation_configuration Access Automation Configuration model_automation_configuration group_automation_manager 1 1 1 1
4 access_automation_configuration_step Access Automation Configuration Activity model_automation_configuration_step group_automation_user 1 0 0 0
5 manage_automation_configuration_step Access Automation Configuration Activity model_automation_configuration_step group_automation_manager 1 1 1 1
6 access_automation_record Access Automation Record model_automation_record group_automation_user 1 0 0 0
7 manage_automation_record Access Automation Record model_automation_record group_automation_manager 1 1 1 1
8 access_automation_filter Access Automation filter model_automation_filter group_automation_user 1 0 0 0
9 manage_automation_filter Access Automation filter model_automation_filter group_automation_manager 1 1 1 1
10 access_automation_tag Access Automation tag model_automation_tag group_automation_user 1 0 0 0
11 manage_automation_tag Access Automation tag model_automation_tag group_automation_manager 1 1 1 1
12 access_automation_record_step Access Automation Record Activity model_automation_record_step group_automation_user 1 0 0 0
13 manage_automation_record_step Access Automation Record Activity model_automation_record_step group_automation_manager 1 1 1 1
14 manage_automation_configuration_test Access Automation Configuration Test model_automation_configuration_test group_automation_manager 1 1 1 1

View file

@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<record id="group_automation_user" model="res.groups">
<field name="name">User</field>
<field name="category_id" ref="base.module_category_automation" />
</record>
<record id="group_automation_manager" model="res.groups">
<field name="name">Manager</field>
<field name="category_id" ref="base.module_category_automation" />
<field name="implied_ids" eval="[(4, ref('group_automation_user'))]" />
</record>
<data noupdate="1">
<record id="base.user_admin" model="res.users">
<field name="groups_id" eval="[(4, ref('group_automation_manager'))]" />
</record>
<record id="automation_configuration_rule" model="ir.rule">
<field name="name">Automation Configuration Company rule</field>
<field name="model_id" ref="model_automation_configuration" />
<field name="global" eval="True" />
<field
name="domain_force"
>['|', ('company_id', 'in', company_ids), ('company_id', '=', False)]</field>
</record>
<record id="automation_configuration_step_rule" model="ir.rule">
<field name="name">Automation Configuration Company Activity rule</field>
<field name="model_id" ref="model_automation_configuration_step" />
<field name="global" eval="True" />
<field
name="domain_force"
>['|', ('configuration_id.company_id', 'in', company_ids), ('configuration_id.company_id', '=', False)]</field>
</record>
</data>
</odoo>

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

View file

@ -0,0 +1,99 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="300mm"
height="300mm"
viewBox="0 0 300 300"
version="1.1"
id="svg5"
sodipodi:docname="icon.svg"
inkscape:version="1.1.2 (0a00cf5339, 2022-02-04)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview7"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:document-units="mm"
showgrid="false"
inkscape:object-paths="true"
inkscape:snap-intersection-paths="false"
inkscape:object-nodes="false"
inkscape:snap-nodes="false"
inkscape:snap-grids="true"
inkscape:zoom="0.25300781"
inkscape:cx="156.12166"
inkscape:cy="654.13"
inkscape:window-width="1858"
inkscape:window-height="1016"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="g8486"
showguides="false"
inkscape:snap-bbox="false" />
<defs
id="defs2" />
<g
inkscape:label="Capa 1"
inkscape:groupmode="layer"
id="layer1">
<g
id="g8486">
<path
id="rect853"
style="fill:#f2f2f2;stroke-width:1.04233"
d="M 37.794922 0 C 9.65678 0.0059939099 0.01989664 9.7609371 0 37.794922 L 0 566.92969 L 0 1096.0645 C 0.0060472441 1124.2026 9.7609187 1133.8395 37.794922 1133.8594 L 566.92969 1133.8594 L 1096.0645 1133.8594 C 1124.2026 1133.8518 1133.8395 1124.0985 1133.8594 1096.0645 L 1133.8594 566.92969 L 1133.8594 37.794922 C 1133.8518 9.6567549 1124.0985 0.019880315 1096.0645 0 L 566.92969 0 L 37.794922 0 z "
transform="scale(0.26458333)" />
<g
id="g33171"
transform="translate(-200.1634,457.94961)">
<g
id="g30156"
transform="translate(306.99551,-203.13726)">
<path
id="rect17527"
style="opacity:1;fill:#8a83d5;fill-opacity:1;stroke:none;stroke-width:0.254742"
d="m -64.332108,-194.81209 v 25.00002 H 85.668037 c 16.229943,0.0454 25.051923,7.8517 25.000023,25.00003 -0.0454,16.22994 -7.85169,25.05192 -25.000023,25.00002 H -14.33206 c -35.314439,0.0273 -49.947178,18.50891 -50.000048,50.00005 0.0273,35.31444 18.508909,49.947179 50.000048,50.000048 H 135.66808 V -2.3122169 L 175.66771,-32.311934 135.66808,-62.31217 v 17.500224 H -14.33206 c -16.229941,-0.0454 -25.051924,-7.851685 -25.000024,-25.000024 0.0454,-16.22993 7.851695,-25.051935 25.000024,-25.000026 H 85.668037 c 35.314443,-0.02735 49.947183,-18.508904 50.000043,-50.000044 -0.0273,-35.31444 -18.5089,-49.94718 -50.000043,-50.00005 z" />
<circle
style="opacity:1;fill:#d4d337;fill-opacity:1;stroke:none;stroke-width:0.270481"
id="path21903"
cx="5.667892"
cy="-32.312092"
r="25" />
<circle
style="fill:#009b80;fill-opacity:1;stroke:none;stroke-width:0.270481"
id="path21903-4"
cx="60.667892"
cy="-107.31209"
r="25" />
<circle
style="fill:#009b80;fill-opacity:1;stroke:none;stroke-width:0.270481"
id="path21903-7"
cx="-64.332108"
cy="-182.31209"
r="25" />
</g>
<path
id="rect17527-4"
style="fill:#6a8366;fill-opacity:1;stroke:none;stroke-width:0.254742"
d="m 346.01267,-322.94926 a 25,25 0 0 0 -3.34915,12.50001 25,25 0 0 0 3.34915,12.50001 h 43.30123 a 25,25 0 0 0 3.34967,-12.50001 25,25 0 0 0 -3.35174,-12.50001 z" />
<path
id="rect17527-6"
style="fill:#8a836d;fill-opacity:1;stroke:none;stroke-width:0.254742"
d="m 291.03411,-247.98278 a 25,25 0 0 0 -3.37085,12.5336 25,25 0 0 0 3.3352,12.47263 c 0.55396,0.0103 1.10272,0.0264 1.66501,0.0274 h 41.65069 a 25,25 0 0 0 3.34915,-12.50002 25,25 0 0 0 -3.35122,-12.50001 h -41.64862 c -0.55236,-0.002 -1.09421,-0.014 -1.62936,-0.0336 z" />
<path
id="rect17527-1"
style="fill:#6a8366;fill-opacity:1;stroke:none;stroke-width:0.254742"
d="m 221.01255,-397.94933 a 25,25 0 0 0 -3.34915,12.50001 25,25 0 0 0 3.34915,12.50001 h 43.30123 a 25,25 0 0 0 3.34967,-12.50001 25,25 0 0 0 -3.35122,-12.50001 z" />
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.5 KiB

View file

@ -0,0 +1,539 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<meta name="generator" content="Docutils: https://docutils.sourceforge.io/" />
<title>README.rst</title>
<style type="text/css">
/*
:Author: David Goodger (goodger@python.org)
:Id: $Id: html4css1.css 9511 2024-01-13 09:50:07Z milde $
:Copyright: This stylesheet has been placed in the public domain.
Default cascading style sheet for the HTML output of Docutils.
Despite the name, some widely supported CSS2 features are used.
See https://docutils.sourceforge.io/docs/howto/html-stylesheets.html for how to
customize this style sheet.
*/
/* used to remove borders from tables and images */
.borderless, table.borderless td, table.borderless th {
border: 0 }
table.borderless td, table.borderless th {
/* Override padding for "table.docutils td" with "! important".
The right padding separates the table cells. */
padding: 0 0.5em 0 0 ! important }
.first {
/* Override more specific margin styles with "! important". */
margin-top: 0 ! important }
.last, .with-subtitle {
margin-bottom: 0 ! important }
.hidden {
display: none }
.subscript {
vertical-align: sub;
font-size: smaller }
.superscript {
vertical-align: super;
font-size: smaller }
a.toc-backref {
text-decoration: none ;
color: black }
blockquote.epigraph {
margin: 2em 5em ; }
dl.docutils dd {
margin-bottom: 0.5em }
object[type="image/svg+xml"], object[type="application/x-shockwave-flash"] {
overflow: hidden;
}
/* Uncomment (and remove this text!) to get bold-faced definition list terms
dl.docutils dt {
font-weight: bold }
*/
div.abstract {
margin: 2em 5em }
div.abstract p.topic-title {
font-weight: bold ;
text-align: center }
div.admonition, div.attention, div.caution, div.danger, div.error,
div.hint, div.important, div.note, div.tip, div.warning {
margin: 2em ;
border: medium outset ;
padding: 1em }
div.admonition p.admonition-title, div.hint p.admonition-title,
div.important p.admonition-title, div.note p.admonition-title,
div.tip p.admonition-title {
font-weight: bold ;
font-family: sans-serif }
div.attention p.admonition-title, div.caution p.admonition-title,
div.danger p.admonition-title, div.error p.admonition-title,
div.warning p.admonition-title, .code .error {
color: red ;
font-weight: bold ;
font-family: sans-serif }
/* Uncomment (and remove this text!) to get reduced vertical space in
compound paragraphs.
div.compound .compound-first, div.compound .compound-middle {
margin-bottom: 0.5em }
div.compound .compound-last, div.compound .compound-middle {
margin-top: 0.5em }
*/
div.dedication {
margin: 2em 5em ;
text-align: center ;
font-style: italic }
div.dedication p.topic-title {
font-weight: bold ;
font-style: normal }
div.figure {
margin-left: 2em ;
margin-right: 2em }
div.footer, div.header {
clear: both;
font-size: smaller }
div.line-block {
display: block ;
margin-top: 1em ;
margin-bottom: 1em }
div.line-block div.line-block {
margin-top: 0 ;
margin-bottom: 0 ;
margin-left: 1.5em }
div.sidebar {
margin: 0 0 0.5em 1em ;
border: medium outset ;
padding: 1em ;
background-color: #ffffee ;
width: 40% ;
float: right ;
clear: right }
div.sidebar p.rubric {
font-family: sans-serif ;
font-size: medium }
div.system-messages {
margin: 5em }
div.system-messages h1 {
color: red }
div.system-message {
border: medium outset ;
padding: 1em }
div.system-message p.system-message-title {
color: red ;
font-weight: bold }
div.topic {
margin: 2em }
h1.section-subtitle, h2.section-subtitle, h3.section-subtitle,
h4.section-subtitle, h5.section-subtitle, h6.section-subtitle {
margin-top: 0.4em }
h1.title {
text-align: center }
h2.subtitle {
text-align: center }
hr.docutils {
width: 75% }
img.align-left, .figure.align-left, object.align-left, table.align-left {
clear: left ;
float: left ;
margin-right: 1em }
img.align-right, .figure.align-right, object.align-right, table.align-right {
clear: right ;
float: right ;
margin-left: 1em }
img.align-center, .figure.align-center, object.align-center {
display: block;
margin-left: auto;
margin-right: auto;
}
table.align-center {
margin-left: auto;
margin-right: auto;
}
.align-left {
text-align: left }
.align-center {
clear: both ;
text-align: center }
.align-right {
text-align: right }
/* reset inner alignment in figures */
div.align-right {
text-align: inherit }
/* div.align-center * { */
/* text-align: left } */
.align-top {
vertical-align: top }
.align-middle {
vertical-align: middle }
.align-bottom {
vertical-align: bottom }
ol.simple, ul.simple {
margin-bottom: 1em }
ol.arabic {
list-style: decimal }
ol.loweralpha {
list-style: lower-alpha }
ol.upperalpha {
list-style: upper-alpha }
ol.lowerroman {
list-style: lower-roman }
ol.upperroman {
list-style: upper-roman }
p.attribution {
text-align: right ;
margin-left: 50% }
p.caption {
font-style: italic }
p.credits {
font-style: italic ;
font-size: smaller }
p.label {
white-space: nowrap }
p.rubric {
font-weight: bold ;
font-size: larger ;
color: maroon ;
text-align: center }
p.sidebar-title {
font-family: sans-serif ;
font-weight: bold ;
font-size: larger }
p.sidebar-subtitle {
font-family: sans-serif ;
font-weight: bold }
p.topic-title {
font-weight: bold }
pre.address {
margin-bottom: 0 ;
margin-top: 0 ;
font: inherit }
pre.literal-block, pre.doctest-block, pre.math, pre.code {
margin-left: 2em ;
margin-right: 2em }
pre.code .ln { color: gray; } /* line numbers */
pre.code, code { background-color: #eeeeee }
pre.code .comment, code .comment { color: #5C6576 }
pre.code .keyword, code .keyword { color: #3B0D06; font-weight: bold }
pre.code .literal.string, code .literal.string { color: #0C5404 }
pre.code .name.builtin, code .name.builtin { color: #352B84 }
pre.code .deleted, code .deleted { background-color: #DEB0A1}
pre.code .inserted, code .inserted { background-color: #A3D289}
span.classifier {
font-family: sans-serif ;
font-style: oblique }
span.classifier-delimiter {
font-family: sans-serif ;
font-weight: bold }
span.interpreted {
font-family: sans-serif }
span.option {
white-space: nowrap }
span.pre {
white-space: pre }
span.problematic, pre.problematic {
color: red }
span.section-subtitle {
/* font-size relative to parent (h1..h6 element) */
font-size: 80% }
table.citation {
border-left: solid 1px gray;
margin-left: 1px }
table.docinfo {
margin: 2em 4em }
table.docutils {
margin-top: 0.5em ;
margin-bottom: 0.5em }
table.footnote {
border-left: solid 1px black;
margin-left: 1px }
table.docutils td, table.docutils th,
table.docinfo td, table.docinfo th {
padding-left: 0.5em ;
padding-right: 0.5em ;
vertical-align: top }
table.docutils th.field-name, table.docinfo th.docinfo-name {
font-weight: bold ;
text-align: left ;
white-space: nowrap ;
padding-left: 0 }
/* "booktabs" style (no vertical lines) */
table.docutils.booktabs {
border: 0px;
border-top: 2px solid;
border-bottom: 2px solid;
border-collapse: collapse;
}
table.docutils.booktabs * {
border: 0px;
}
table.docutils.booktabs th {
border-bottom: thin solid;
text-align: left;
}
h1 tt.docutils, h2 tt.docutils, h3 tt.docutils,
h4 tt.docutils, h5 tt.docutils, h6 tt.docutils {
font-size: 100% }
ul.auto-toc {
list-style-type: none }
</style>
</head>
<body>
<div class="document">
<a class="reference external image-reference" href="https://odoo-community.org/get-involved?utm_source=readme">
<img alt="Odoo Community Association" src="https://odoo-community.org/readme-banner-image" />
</a>
<div class="section" id="automation-oca">
<h1>Automation Oca</h1>
<!-- !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! This file is generated by oca-gen-addon-readme !!
!! changes will be overwritten. !!
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! source digest: sha256:bf7f94a060a16c6d36248bb970d9cbc5925480da1127a49bfb77208b1a1821e5
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -->
<p><a class="reference external image-reference" href="https://odoo-community.org/page/development-status"><img alt="Beta" src="https://img.shields.io/badge/maturity-Beta-yellow.png" /></a> <a class="reference external image-reference" href="http://www.gnu.org/licenses/agpl-3.0-standalone.html"><img alt="License: AGPL-3" src="https://img.shields.io/badge/license-AGPL--3-blue.png" /></a> <a class="reference external image-reference" href="https://github.com/OCA/automation/tree/16.0/automation_oca"><img alt="OCA/automation" src="https://img.shields.io/badge/github-OCA%2Fautomation-lightgray.png?logo=github" /></a> <a class="reference external image-reference" href="https://translation.odoo-community.org/projects/automation-16-0/automation-16-0-automation_oca"><img alt="Translate me on Weblate" src="https://img.shields.io/badge/weblate-Translate%20me-F47D42.png" /></a> <a class="reference external image-reference" href="https://runboat.odoo-community.org/builds?repo=OCA/automation&amp;target_branch=16.0"><img alt="Try me on Runboat" src="https://img.shields.io/badge/runboat-Try%20me-875A7B.png" /></a></p>
<p>This module allows to automate several process according to some rules.</p>
<p>This is useful for creating automated actions on your database like:</p>
<ul class="simple">
<li>Send a welcome email to all new partners (or filtered according to
some rules)</li>
<li>Remember to online customers that they forgot their basket with some
items</li>
<li>Send documents to sign to all new employees</li>
</ul>
<p><strong>Table of contents</strong></p>
<div class="contents local topic" id="contents">
<ul class="simple">
<li><a class="reference internal" href="#usage" id="toc-entry-1">Usage</a><ul>
<li><a class="reference internal" href="#configure-your-processes" id="toc-entry-2">Configure your processes</a></li>
<li><a class="reference internal" href="#configuration-of-steps" id="toc-entry-3">Configuration of steps</a></li>
<li><a class="reference internal" href="#records-creation" id="toc-entry-4">Records creation</a></li>
<li><a class="reference internal" href="#step-execution" id="toc-entry-5">Step execution</a></li>
</ul>
</li>
<li><a class="reference internal" href="#bug-tracker" id="toc-entry-6">Bug Tracker</a></li>
<li><a class="reference internal" href="#credits" id="toc-entry-7">Credits</a><ul>
<li><a class="reference internal" href="#authors" id="toc-entry-8">Authors</a></li>
<li><a class="reference internal" href="#contributors" id="toc-entry-9">Contributors</a></li>
<li><a class="reference internal" href="#other-credits" id="toc-entry-10">Other credits</a></li>
<li><a class="reference internal" href="#maintainers" id="toc-entry-11">Maintainers</a></li>
</ul>
</li>
</ul>
</div>
<div class="section" id="usage">
<h2><a class="toc-backref" href="#toc-entry-1">Usage</a></h2>
<div class="section" id="configure-your-processes">
<h3><a class="toc-backref" href="#toc-entry-2">Configure your processes</a></h3>
<ol class="arabic simple">
<li>Access the <tt class="docutils literal">Automation</tt> menu.</li>
<li>Create a new Automation Configuration.</li>
<li>Set the model and domains.</li>
<li>Go to Configuration -&gt; Filters to create filters as a preconfigured
domains. Filters can be established in the proper field in the
Automation Configuration record.</li>
<li>Create the different steps by clicking the “ADD” button inside the
automation configuration form.</li>
<li>Create child steps by clicking the “Add child activity” at the
bottom of a created step.</li>
<li></li>
<li>Select the kind of configuration you create. You can choose between:<ul>
<li><em>Periodic configurations</em>: every 6 hours, a process will check if
new records need to be created.</li>
<li><em>On demand configurations</em>: user need to execute manually the job.</li>
</ul>
</li>
<li>Press <tt class="docutils literal">Start</tt>.</li>
<li>Inside the process, you can check all the created items.</li>
</ol>
<p><img alt="Configuration Screenshot" src="https://raw.githubusercontent.com/OCA/automation/16.0/automation_oca/static/description/configuration.png" /></p>
</div>
<div class="section" id="configuration-of-steps">
<h3><a class="toc-backref" href="#toc-entry-3">Configuration of steps</a></h3>
<p>Steps can trigger one of the following options:</p>
<ul class="simple">
<li><tt class="docutils literal">Mail</tt>: Sends an email using a template.</li>
<li><tt class="docutils literal">Server Action</tt>: Executes a server action.</li>
<li><tt class="docutils literal">Activity</tt>: Creates an activity to the related record.</li>
</ul>
<p>All the steps need to specify the moment of execution. We will set the
number of hours/days and a trigger type:</p>
<ul class="simple">
<li><tt class="docutils literal">Start of workflow</tt>: It will be executed at the
previously-configured time after we create the record.</li>
<li><tt class="docutils literal">Execution of another step</tt>: It will be executed at the
previously-configured time after the previous step is finished
properly.</li>
<li><tt class="docutils literal">Mail opened</tt>: It will be executed at the previously-configured time
after the mail from the previous step is opened.</li>
<li><tt class="docutils literal">Mail not opened</tt>: It will be executed at the previously-configured
time after the mail from the previous step is sent if it is not opened
before this time.</li>
<li><tt class="docutils literal">Mail replied</tt>: It will be executed at the previously-configured
time after the mail from the previous step is replied.</li>
<li><tt class="docutils literal">Mail not replied</tt>: It will be executed at the previously-configured
time after the mail from the previous step is opened if it has not
been replied.</li>
<li><tt class="docutils literal">Mail clicked</tt>: It will be executed at the previously-configured
time after the links of the mail from the previous step are clicked.</li>
<li><tt class="docutils literal">Mail not clicked</tt>: It will be executed at the previously-configured
time after the mail from the previous step is opened and no links are
clicked.</li>
<li><tt class="docutils literal">Mail bounced</tt>: It will be executed at the previously-configured
time after the mail from the previous step is bounced back for any
reason.</li>
<li><tt class="docutils literal">Activity has been finished</tt>: It will be executed at the
previously-configured time after the activity from the previous action
is done.</li>
<li><tt class="docutils literal">Activity has not been finished</tt>: It will be executed at the
previously-configured time after the previous action is executed if
the related activity is not done.</li>
</ul>
<p>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</p>
</div>
<div class="section" id="records-creation">
<h3><a class="toc-backref" href="#toc-entry-4">Records creation</a></h3>
<p>Records are created using a cron action. This action is executed every 6
hours by default.</p>
</div>
<div class="section" id="step-execution">
<h3><a class="toc-backref" href="#toc-entry-5">Step execution</a></h3>
<p>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.</p>
<p>There is a way to enforce step execution when finalize the previous one.
If we set a negative value on the period, the execution will be
immediate without a cron.</p>
</div>
</div>
<div class="section" id="bug-tracker">
<h2><a class="toc-backref" href="#toc-entry-6">Bug Tracker</a></h2>
<p>Bugs are tracked on <a class="reference external" href="https://github.com/OCA/automation/issues">GitHub Issues</a>.
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
<a class="reference external" href="https://github.com/OCA/automation/issues/new?body=module:%20automation_oca%0Aversion:%2016.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**">feedback</a>.</p>
<p>Do not contact contributors directly about support or help with technical issues.</p>
</div>
<div class="section" id="credits">
<h2><a class="toc-backref" href="#toc-entry-7">Credits</a></h2>
<div class="section" id="authors">
<h3><a class="toc-backref" href="#toc-entry-8">Authors</a></h3>
<ul class="simple">
<li>Dixmit</li>
</ul>
</div>
<div class="section" id="contributors">
<h3><a class="toc-backref" href="#toc-entry-9">Contributors</a></h3>
<ul class="simple">
<li>Enric Tobella (<a class="reference external" href="https://www.dixmit.com/">Dixmit</a>)</li>
</ul>
</div>
<div class="section" id="other-credits">
<h3><a class="toc-backref" href="#toc-entry-10">Other credits</a></h3>
<p>The development of this module has been financially supported by:</p>
<ul class="simple">
<li>Associacion Española de Odoo (<a class="reference external" href="https://www.aeodoo.org/">AEODOO</a>)</li>
</ul>
</div>
<div class="section" id="maintainers">
<h3><a class="toc-backref" href="#toc-entry-11">Maintainers</a></h3>
<p>This module is maintained by the OCA.</p>
<a class="reference external image-reference" href="https://odoo-community.org">
<img alt="Odoo Community Association" src="https://odoo-community.org/logo.png" />
</a>
<p>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.</p>
<p>This module is part of the <a class="reference external" href="https://github.com/OCA/automation/tree/16.0/automation_oca">OCA/automation</a> project on GitHub.</p>
<p>You are welcome to contribute. To learn how please visit <a class="reference external" href="https://odoo-community.org/page/Contribute">https://odoo-community.org/page/Contribute</a>.</p>
</div>
</div>
</div>
</div>
</body>
</html>

View file

@ -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);

View file

@ -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);

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates id="template" xml:space="preserve">
<t t-name="automation_oca.AutomationGraph" owl="1">
<div class="o_automation_graph w-100" t-att-class="props.className">
<canvas t-ref="canvas" />
</div>
</t>
</templates>

View file

@ -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;
}

View file

@ -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;
}
}

View file

@ -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;

View file

@ -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,
};

View file

@ -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

View file

@ -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 <a href="https://www.twitter.com" /> 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,
}
)

View file

@ -0,0 +1,235 @@
# 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_immediate_execution(self):
"""
We will check the execution of the tasks and that we cannot execute them again
"""
activity = self.create_server_action(trigger_interval=-1)
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.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)
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),
]
),
)
def test_context(self):
"""
We will check that the context is modified and passed on server actions
"""
self.create_server_action(server_context='{"key_value": "My Value"}')
self.partner_01.comment = False
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.comment)
self.env["automation.record.step"]._cron_automation_steps()
self.assertIn("My Value", self.partner_01.comment)

View file

@ -0,0 +1,334 @@
# Copyright 2024 Dixmit
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
from odoo.tests import Form, new_test_user
from .common import AutomationTestCase
class TestAutomationActivity(AutomationTestCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.user = new_test_user(
cls.env,
login="test_user_automation",
groups="base.group_user,base.group_partner_manager",
)
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_permission(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.with_user(self.user.id).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 child 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)
self.assertFalse(record_child_activity.processed_on)
def test_activity_execution_child_immediate(self):
"""
We will check the execution of the child 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", trigger_interval=-1
)
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)
self.assertTrue(record_child_activity.processed_on)
def test_activity_execution_on_cancel(self):
"""
We will check the execution of the child 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.unlink()
self.assertFalse(record_activity.activity_done_on)
self.assertFalse(record_child_activity.scheduled_date)
self.assertEqual(record_child_activity.state, "rejected")
def test_activity_execution_on_cancel_permission(self):
"""
We will check the execution of the child 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.with_user(self.user.id).unlink()
self.assertFalse(record_activity.activity_done_on)
self.assertFalse(record_child_activity.scheduled_date)
self.assertEqual(record_child_activity.state, "rejected")
def test_activity_execution_cancel_child(self):
"""
We will check the execution of the child task (activity_cancel) is only scheduled
after the activity is cancel
"""
activity = self.create_activity_action()
child_activity = self.create_server_action(
parent_id=activity.id, trigger_type="activity_cancel"
)
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_cancel_on)
self.assertFalse(record_child_activity.scheduled_date)
self.partner_01.activity_ids.unlink()
self.assertTrue(record_activity.activity_cancel_on)
self.assertTrue(record_child_activity.scheduled_date)
def test_activity_execution_cancel_child_on_done(self):
"""
We will check the execution of the child task (activity_cancel) is not scheduled
after the activity is done
"""
activity = self.create_activity_action()
child_activity = self.create_server_action(
parent_id=activity.id, trigger_type="activity_cancel"
)
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_cancel_on)
self.assertFalse(record_child_activity.scheduled_date)
self.partner_01.activity_ids.action_feedback()
self.assertFalse(record_activity.activity_cancel_on)
self.assertFalse(record_child_activity.scheduled_date)
self.assertEqual(record_child_activity.state, "rejected")
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)

View file

@ -0,0 +1,681 @@
# 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_force(self):
partner_01 = self.env["res.partner"].create(
{
"name": "Demo partner",
"comment": "Demo",
"email": "test@test.com",
"date": "2025-01-01",
}
)
with freeze_time("2024-01-01 00:00:00"):
activity = self.create_server_action(
trigger_date_kind="date",
trigger_date_field_id=self.env["ir.model.fields"]
.search(
[
("name", "=", "date"),
("model", "=", "res.partner"),
]
)
.id,
trigger_interval=1,
trigger_interval_type="days",
)
self.configuration.editable_domain = "[('id', '=', %s)]" % 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(2025, 1, 2))
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_constrains_wrong_context(self):
with self.assertRaises(ValidationError):
self.create_server_action(server_context="{not a json}")
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(4, len(activity.trigger_child_types))
self.assertEqual(
{"after_step", "activity_done", "activity_not_done", "activity_cancel"},
set(activity.trigger_child_types.keys()),
)
def test_search(self):
configuration_2 = self.env["automation.configuration"].create(
{
"name": "Test configuration",
"model_id": self.env.ref("base.model_res_partner").id,
"is_periodic": True,
}
)
self.create_server_action()
self.create_server_action(configuration_id=configuration_2.id)
self.configuration.editable_domain = "[('id', '=', %s)]" % self.partner_01.id
self.configuration.start_automation()
configuration_2.editable_domain = "[('id', '=', %s)]" % self.partner_01.id
configuration_2.start_automation()
self.env["automation.configuration"].cron_automation()
record_activity = self.env["automation.record"].search(
[("model", "=", self.partner_01._name), ("res_id", "=", self.partner_01.id)]
)
self.assertEqual(2, len(record_activity))
def test_generation_orphan_record(self):
self.configuration.editable_domain = (
"['|', ('id', '=', %s), ('id', '=', %s)]"
% (self.partner_01.id, self.partner_02.id)
)
self.configuration.start_automation()
self.env["automation.configuration"].cron_automation()
self.partner_01.unlink()
records = self.env["automation.record"].search(
[("configuration_id", "=", self.configuration.id), ("is_test", "=", False)]
)
self.configuration._compute_record_count()
self.assertEqual(len(records), 2, "Seems like no orphan record was created")
orphan_record_found = any(record.name == "Orphan Record" for record in records)
self.assertTrue(
orphan_record_found, "No record named 'Orphan Record' was found"
)
def test_delete_step_executed(self):
"""
Testing that deleting a step will keep the results of the executed related steps
"""
activity = self.create_server_action()
child_activity = self.create_server_action(parent_id=activity.id)
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.assertFalse(
self.env["automation.record.step"].search(
[("configuration_step_id", "=", child_activity.id)]
)
)
self.env["automation.record.step"]._cron_automation_steps()
self.assertEqual("done", record_activity.state)
record_child_activity = self.env["automation.record.step"].search(
[("configuration_step_id", "=", child_activity.id)]
)
self.assertEqual("scheduled", record_child_activity.state)
self.env["automation.record.step"]._cron_automation_steps()
self.assertEqual("done", record_child_activity.state)
child_activity.unlink()
child_activity.flush_recordset()
self.assertEqual("action", record_child_activity.step_type)
def test_delete_step_to_execute(self):
"""
Testing that deleting a step will make pending actions related
to be rejected
"""
activity = self.create_server_action()
child_activity = self.create_server_action(parent_id=activity.id)
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.assertFalse(
self.env["automation.record.step"].search(
[("configuration_step_id", "=", child_activity.id)]
)
)
self.env["automation.record.step"]._cron_automation_steps()
self.assertEqual("done", record_activity.state)
record_child_activity = self.env["automation.record.step"].search(
[("configuration_step_id", "=", child_activity.id)]
)
self.assertEqual("scheduled", record_child_activity.state)
child_activity.unlink()
child_activity.flush_recordset()
self.assertEqual("action", record_child_activity.step_type)
self.env["automation.record.step"]._cron_automation_steps()
self.assertEqual("rejected", record_child_activity.state)

View file

@ -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: <whatever-2a840@postmaster.twitter.com>
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
<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd">
<html>
<head>=20
<meta http-equiv=3D"Content-Type" content=3D"text/html; charset=3Dutf-8" />
</head>=20
<body style=3D"margin: 0; padding: 0; background: #ffffff;-webkit-text-size-adjust: 100%;">=20
<p>I would gladly answer to your mass mailing !</p>
<p>--<br/>
Your Dear Customer
<p>
</body>
</html>
------=_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)

View file

@ -0,0 +1,106 @@
# 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.assertTrue(record)
self.assertTrue(record.is_orphan_record)
self.assertTrue(original_record)
self.assertTrue(original_record.read())

View file

@ -0,0 +1,509 @@
<?xml version="1.0" encoding="utf-8" ?>
<!-- Copyright 2024 Dixmit
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). -->
<odoo>
<record model="ir.ui.view" id="automation_configuration_form_view">
<field name="model">automation.configuration</field>
<field name="arch" type="xml">
<form>
<header>
<button
class="btn btn-primary"
type="object"
name="start_automation"
string="Start"
attrs="{'invisible': [('state', '!=', 'draft')]}"
/>
<button
class="btn btn-primary"
type="object"
name="run_automation"
attrs="{'invisible': [('state', '!=', 'ondemand')]}"
string="Generate new records"
/>
<button
type="object"
name="done_automation"
class="btn-secondary"
string="Mark as done"
attrs="{'invisible': [('state', 'not in', ['periodic','ondemand'])]}"
/>
<button
type="object"
name="back_to_draft"
class="btn-secondary"
string="Back to draft"
attrs="{'invisible': [('state', '!=', 'done')]}"
/>
<button
type="action"
name="%(automation_configuration_test_act_window)s"
string="Test"
class="btn-warning"
groups="automation_oca.group_automation_manager"
/>
<button
type="object"
name="run_automation"
class="btn-info"
string="Enforce generation"
attrs="{'invisible': [('state', '!=', 'periodic')]}"
/>
<field name="state" widget="statusbar" />
</header>
<div
class="alert alert-info"
role="alert"
attrs="{'invisible': [('state', '!=', 'draft')]}"
>
<div
class="px-4"
attrs="{'invisible': [('is_periodic', '=', False)]}"
>
On <b
>periodical configurations</b>, the records are created automatically by a cron job.
It is great for tasks that don't need user validation.
</div>
<div
class="px-4"
attrs="{'invisible': [('is_periodic', '=', True)]}"
>
On <b
>on demand configurations</b>, the records are created only by pressing the run button.
It is useful if you want to control the execution and created records manually.
</div>
</div>
<div
class="alert alert-info"
role="alert"
attrs="{'invisible': [('state', '!=', 'ondemand')]}"
>
<div class="px-4">
This is configuration only generates new records on demand. Press the button in order to execute them manually.
</div>
</div>
<div
class="alert alert-info"
role="alert"
attrs="{'invisible': [('state', '!=', 'periodic')]}"
>
<div class="px-4">
This a periodical configuration. New records are generated automatically.
Next execution is programed for <field
name="next_execution_date"
/>
</div>
</div>
<sheet>
<div name="button_box">
<button
name="%(automation_record_configuration_act_window)s"
type="action"
class="oe_stat_button"
icon="fa-envelope"
attrs="{'invisible': [('record_count', '=', 0)]}"
>
<field
name="record_count"
widget="statinfo"
string="Records"
/>
</button>
<button
name="%(automation_record_configuration_test_act_window)s"
type="action"
class="oe_stat_button"
icon="fa-flask"
attrs="{'invisible': [('record_test_count', '=', 0)]}"
>
<field
name="record_test_count"
widget="statinfo"
string="Tests"
/>
</button>
</div>
<widget
name="web_ribbon"
title="Archived"
bg_color="text-bg-danger"
attrs="{'invisible': [('active', '=', True)]}"
/>
<div class="oe_title">
<h1><field
name="name"
placeholder="e.g. Remember unpaid invoices"
/></h1>
</div>
<group>
<field name="active" invisible="1" />
<field
name="is_periodic"
attrs="{'readonly': [('state', '!=', 'draft')]}"
widget="boolean_toggle"
/>
<field
name="tag_ids"
widget="many2many_tags"
options="{'color_field': 'color'}"
/>
<field
name="model_id"
options="{'no_create_edit': True, 'no_open': True}"
/>
<field name="model" invisible="1" />
<field
name="field_id"
string="Unicity based on"
options="{'no_create_edit': True, 'no_open': True}"
/>
<label for="filter_id" string="Filter" />
<div class="container ps-0">
<div class="row">
<div class="col-5">
<field name="filter_id" domain="filter_domain" />
</div>
<button
name="save_filter"
type="object"
string="Save filter"
icon="fa-save"
class="text-primary filter-left col-7"
attrs="{'invisible': [('filter_id', '!=', False)]}"
/>
</div>
</div>
<field name="filter_domain" invisible="1" />
<field
name="domain"
string="Domain"
widget="domain"
attrs="{'invisible': [('filter_id', '=', False)]}"
options="{'foldable': True, 'model': 'model'}"
/>
<field
name="editable_domain"
string="Domain"
widget="domain"
attrs="{'invisible': [('filter_id', '!=', False)]}"
options="{'foldable': True, 'model': 'model'}"
/>
<field name="company_id" groups="base.group_multi_company" />
</group>
<field
name="automation_step_ids"
widget="automation_step"
mode="kanban"
class="o_automation_kanban"
>
<kanban limit="1000">
<field name="id" />
<field name="name" />
<field name="step_type" />
<field name="parent_id" />
<field name="trigger_type" />
<field name="parent_position" />
<field name="trigger_child_types" />
<field name="trigger_type_data" />
<field name="trigger_date_kind" />
<field name="step_icon" />
<field name="step_name" />
<templates>
<div
t-name="kanban-box"
class="o_automation_kanban_box p-2"
>
<div
class="o_automation_kanban_extra"
t-foreach="[...Array(record.parent_position.raw_value).keys()]"
t-as="extra"
t-key="extra"
>
<div
class="o_automation_kanban_position_line"
/>
</div>
<div class="me-5 o_automation_kanban_time">
<div
class="o_automation_kanban_position_line"
/>
<i
class="o_automation_kanban_card_position fa fa-circle text-info text-right"
title="Run on"
/>
<div
class="p-2 o_automation_kanban_time_info text-center"
>
<div
t-att-class="record.trigger_type_data.raw_value.color"
t-if="record.trigger_type_data.raw_value.message_configuration"
>
<i
t-att-class="record.trigger_type_data.raw_value.icon"
/> <t
t-esc="record.trigger_type_data.raw_value.message_configuration"
/>
</div>
<div
t-if="record.trigger_date_kind.raw_value == 'date'"
>
<i
class="fa fa-calendar pe-1"
role="img"
aria-label="At"
title="At"
/>
<field name="trigger_date_field" />
</div>
<strong>
<i
class="fa fa-clock-o pe-1"
role="img"
aria-label="Select time"
title="Select time"
/>
<t
t-if="record.trigger_interval.raw_value >= 0"
>
<span class="pe-1"><field
name="trigger_interval"
/></span>
<field
name="trigger_interval_type"
/>
</t>
<t t-else="">
<span class="pe-1">Immediatly</span>
</t>
</strong>
</div>
</div>
<div class="o_automation_kanban_card">
<div class="o_automation_kanban_header">
<button
class="o_automation_kanban_header_icon btn btn-primary"
type="edit"
>
<span
t-att-class="record.step_icon.raw_value"
t-att-title="record.step_name.raw_value"
role="img"
t-att-aria-label="record.step_name.raw_value"
/>
</button>
<div
class="o_automation_kanban_header_title"
>
<h3 class="mb0 mt0"><a
type="edit"
class="oe_kanban_action oe_kanban_action_a"
t-att-title="record.name.raw_value"
><field name="name" /></a></h3>
<div t-esc="record.step_type.value" />
</div>
<div
class="o_automation_kanban_header_actions"
>
<div
t-if="!read_only_mode"
class="float-start mt8 mr4"
>
<a
type="edit"
role="button"
class="btn btn-primary btn-sm"
href="#"
>Edit</a>
<a
type="delete"
role="button"
class="btn btn-link btn-sm"
href="#"
>Delete</a>
</div>
</div>
</div>
<div class="o_automation_kanban_graph row">
<div class="col-8">
<field
name="graph_data"
widget="automation_graph"
/>
</div>
<div class="col-4 text-center pt-2">
<h2 class="o_automation_kpi_processed">
<field name="graph_done" />
</h2>
<div
class="o_automation_kpi_processed pb-3"
> Processed</div>
<h2 class="o_automation_kpi_error">
<field name="graph_error" />
</h2>
<div
class="o_automation_kpi_error"
> Error</div>
</div>
</div>
<div
t-if="!read_only_mode"
class="o_automation_kanban_child_add text-center"
>
<div
class="o_automation_kanban_child_add_title card-footer"
>
<i
class="fa fa-plus-circle"
/> Add child activity
</div>
<div
class="o_automation_kanban_child_add_buttons"
>
<t
t-foreach="record.trigger_child_types.raw_value"
t-as="trigger_type_id"
t-key="trigger_type_id"
>
<t
t-set="trigger_type"
t-value="record.trigger_child_types.raw_value[trigger_type_id]"
/>
<div
t-att-trigger-type="trigger_type_id"
class="o_automation_kanban_child_add_button"
>
<i
t-attf-class="{{trigger_type.icon}} {{trigger_type.color}}"
t-att-title="trigger_type.name"
role="img"
t-att-aria-label="trigger_type.name"
/>
</div>
</t>
</div>
</div>
</div>
</div>
</templates>
</kanban>
</field>
</sheet>
<div class="oe_chatter">
<field name="message_follower_ids" />
<field name="message_ids" />
</div>
</form>
</field>
</record>
<record model="ir.ui.view" id="automation_configuration_search_view">
<field name="model">automation.configuration</field>
<field name="arch" type="xml">
<search>
<field name="name" />
<field name="tag_ids" />
<separator />
<filter name="draft" string="Draft" domain="[('state','=', 'draft')]" />
<filter
name="run"
string="Run"
domain="[('state','in', ['periodic', 'ondemand'])]"
/>
<filter name="done" string="Done" domain="[('state','=', 'done')]" />
<separator />
<filter
name="archived"
string="Archived"
domain="[('active','=', False)]"
/>
</search>
</field>
</record>
<record model="ir.ui.view" id="automation_configuration_kanban_view">
<field name="model">automation.configuration</field>
<field name="arch" type="xml">
<kanban default_group_by="state" quick_create="false">
<field name="state" readonly="1" />
<templates>
<t t-name="kanban-box">
<div class="oe_kanban_card oe_kanban_global_click">
<div class="row">
<field name="name" class="o_text_overflow" />
<field
name="tag_ids"
widget="many2many_tags"
options="{'color_field': 'color'}"
/>
</div>
<div class="row mt8">
<div class="col-4 text-center text-black-50">
<div>Records</div>
<field name="record_count" class="h1" />
</div>
<div class="col-4 text-center text-primary">
<div>Running</div>
<field name="record_run_count" class="h1" />
</div>
<div class="col-4 text-center text-black-50">
<div>Done</div>
<field name="record_done_count" class="h1" />
</div>
</div>
<div class="row mt8">
<div class="col-4 text-center text-black-50">
<div><i
class="fa fa-envelope"
title="Mails"
/></div>
<field name="activity_mail_count" class="h2" />
</div>
<div class="col-4 text-center text-black-50">
<div><i class="fa fa-cogs" title="Actions" /></div>
<field name="activity_action_count" class="h2" />
</div>
<div class="col-4 text-center text-black-50">
<div><i
class="fa fa-hand-pointer-o"
title="Clicks"
/></div>
<field name="click_count" class="h2" />
</div>
</div>
</div>
</t>
</templates>
</kanban>
</field>
</record>
<record model="ir.ui.view" id="automation_configuration_tree_view">
<field name="model">automation.configuration</field>
<field name="arch" type="xml">
<tree>
<!-- TODO -->
<field name="name" />
</tree>
</field>
</record>
<record model="ir.actions.act_window" id="automation_configuration_act_window">
<field name="name">Automation Configuration</field> <!-- TODO -->
<field name="res_model">automation.configuration</field>
<field name="view_mode">kanban,tree,form</field>
<field name="domain">[]</field>
<field name="context">{}</field>
</record>
<record model="ir.ui.menu" id="automation_configuration_menu">
<field name="name">Automation Configuration</field>
<field name="parent_id" ref="automation_root_menu" />
<field name="action" ref="automation_configuration_act_window" />
<field name="sequence" eval="10" />
</record>
</odoo>

View file

@ -0,0 +1,228 @@
<?xml version="1.0" encoding="utf-8" ?>
<!-- Copyright 2024 Dixmit
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). -->
<odoo>
<record model="ir.ui.view" id="automation_configuration_step_form_view">
<field name="model">automation.configuration.step</field>
<field name="arch" type="xml">
<form>
<header />
<sheet>
<div class="oe_title">
<h1><field
name="name"
placeholder="e.g. Remember unpaid invoices"
/></h1>
</div>
<div
class="alert alert-warning"
role="alert"
attrs="{'invisible': [('trigger_interval', '>=', 0)]}"
>
In this case, the task will be executed automatically when we create it in the same process.
</div>
<group>
<group>
<field name="step_type" />
<field name="configuration_id" invisible="1" />
<field name="model_id" invisible="1" />
<field name="model" invisible="1" />
</group>
<group>
<field name="trigger_date_kind" />
<label for="trigger_interval" string="Trigger" />
<div class="container ps-0">
<div class="row">
<div class="col-2"><field
name="trigger_interval"
nolabel="1"
/></div>
<div class="col-10"><field
name="trigger_interval_type"
nolabel="1"
/></div>
</div>
<div
class="row"
attrs="{'invisible': [('trigger_date_kind', '!=', 'date')]}"
>
<span class="col-2">after</span>
<div class="col-10"><field
name="trigger_date_field_id"
nolabel="1"
attrs="{'required': [('trigger_date_kind', '=', 'date')]}"
/></div>
</div>
<div class="row">
<span
class="col-2"
attrs="{'invisible': [('trigger_date_kind', '!=', 'offset')]}"
>after</span>
<span
class="col-2"
attrs="{'invisible': [('trigger_date_kind', '=', 'offset')]}"
>when</span>
<div class="col-10"><field
name="trigger_type"
nolabel="1"
/></div>
</div>
<div
class="row"
attrs="{'invisible': [('trigger_type', '=', 'start')]}"
>
<span class="col-2">of</span>
<div class="col-10">
<field
name="parent_id"
domain="[('configuration_id', '=', configuration_id)]"
/></div>
</div>
</div>
<field name="allow_expiry" invisible="1" />
<div
class="container ps-0 alert alert-warning"
colspan="2"
attrs="{'invisible': [('trigger_date_kind', '!=', 'date')]}"
>
The scheduled date will be the date of the record at the moment we generate the new record.
Later changes of the field will not affect the scheduled date.
</div>
<field
name="expiry"
attrs="{'invisible': [('allow_expiry', '=', False)]}"
/>
<label
for="expiry_interval"
string="Trigger"
attrs="{'invisible': [('expiry', '=', False)]}"
/>
<div
class="container ps-0"
attrs="{'invisible': [('expiry', '=', False)]}"
>
<div class="row">
<div class="col-2"><field
name="expiry_interval"
attrs="{'required': [('expiry', '=', True)]}"
nolabel="1"
/></div>
<div class="col-10"><field
name="expiry_interval_type"
attrs="{'required': [('expiry', '=', True)]}"
nolabel="1"
/></div>
</div>
</div>
</group>
<group attrs="{'invisible':[('step_type', '!=', 'action')]}">
<field
name="server_action_id"
context="{'default_model_id': model_id}"
attrs="{'required': [('step_type', '=', 'action')]}"
/>
</group>
<group attrs="{'invisible':[('step_type', '!=', 'mail')]}">
<field
name="mail_template_id"
attrs="{'required': [('step_type', '=', 'mail')]}"
/>
<field name="mail_author_id" />
</group>
<group attrs="{'invisible':[('step_type', '!=', 'activity')]}">
<field
name="activity_type_id"
attrs="{'required': [('step_type', '=', 'activity')]}"
/>
<label
for="activity_date_deadline_range"
string="Deadline"
/>
<div class="container ps-0">
<div class="row">
<div class="col-2"><field
name="activity_date_deadline_range"
nolabel="1"
/></div>
<div class="col-10"><field
name="activity_date_deadline_range_type"
nolabel="1"
/></div>
</div>
</div>
<field name="activity_user_type" />
<field
name="activity_user_id"
attrs="{'invisible': [('activity_user_type', '!=', 'specific')], 'required': [('activity_user_type', '=', 'specific')]}"
/>
<field
name="activity_user_field_id"
domain="[('model_id', '=', model_id)]"
attrs="{'invisible': [('activity_user_type', '!=', 'generic')], 'required': [('activity_user_type', '=', 'generic')]}"
/>
</group>
</group>
<notebook>
<page string="Specific Domain" name="specific_domain">
<group>
<field name="apply_parent_domain" />
</group>
<field
name="domain"
widget="domain"
options="{'foldable': True, 'model': 'model'}"
/>
</page>
<page string="Final Domain" name="final_domain">
<div
>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.</div>
<field
name="applied_domain"
widget="domain"
options="{'foldable': True, 'model': 'model'}"
/>
</page>
<page
string="Activity"
name="activity"
attrs="{'invisible':[('step_type', '!=', 'activity')]}"
>
<group>
<field name="activity_summary" />
<field name="activity_note" />
</group>
</page>
<page name="action" string="Server Action">
<group>
<field name="server_context" />
</group>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
<record model="ir.ui.view" id="automation_configuration_step_search_view">
<field name="model">automation.configuration.step</field>
<field name="arch" type="xml">
<search>
<!-- TODO -->
</search>
</field>
</record>
<record model="ir.ui.view" id="automation_configuration_step_tree_view">
<field name="model">automation.configuration.step</field>
<field name="arch" type="xml">
<tree>
<!-- TODO -->
<field name="name" />
</tree>
</field>
</record>
</odoo>

View file

@ -0,0 +1,65 @@
<?xml version="1.0" encoding="utf-8" ?>
<!-- Copyright 2024 Dixmit
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). -->
<odoo>
<record model="ir.ui.view" id="automation_filter_form_view">
<field name="model">automation.filter</field>
<field name="arch" type="xml">
<form>
<header />
<sheet>
<group>
<field name="name" />
<field
name="model_id"
options="{'no_create_edit': True, 'no_open': True}"
/>
<field name="model" invisible="1" />
<field
name="domain"
widget="domain"
options="{'foldable': True, 'model': 'model'}"
/>
</group>
</sheet>
</form>
</field>
</record>
<record model="ir.ui.view" id="automation_filter_search_view">
<field name="model">automation.filter</field>
<field name="arch" type="xml">
<search>
<field name="name" />
<field name="model_id" />
</search>
</field>
</record>
<record model="ir.ui.view" id="automation_filter_tree_view">
<field name="model">automation.filter</field>
<field name="arch" type="xml">
<tree>
<field name="name" />
<field name="model_id" />
</tree>
</field>
</record>
<record model="ir.actions.act_window" id="automation_filter_act_window">
<field name="name">Filters</field>
<field name="res_model">automation.filter</field>
<field name="view_mode">tree,form</field>
<field name="domain">[]</field>
<field name="context">{}</field>
</record>
<record model="ir.ui.menu" id="automation_filter_menu">
<field name="name">Filters</field>
<field name="parent_id" ref="automation_config_root_menu" />
<field name="action" ref="automation_filter_act_window" />
<field name="sequence" eval="20" />
</record>
</odoo>

View file

@ -0,0 +1,307 @@
<?xml version="1.0" encoding="utf-8" ?>
<!-- Copyright 2024 Dixmit
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). -->
<odoo>
<record model="ir.ui.view" id="automation_record_form_view">
<field name="model">automation.record</field>
<field name="arch" type="xml">
<form create="0">
<header>
<field name="state" widget="statusbar" />
</header>
<sheet>
<widget
name="web_ribbon"
title="Test"
bg_color="text-bg-danger"
attrs="{'invisible': [('is_test', '=', False)]}"
/>
<div class="oe_title">
<div attrs="{'invisible': [('is_orphan_record', '=', False)]}">
<field name="is_orphan_record" invisible="1" />
<h1><span>This record is an orphan record</span></h1>
<group>
<field name="model" />
</group>
</div>
<h1
attrs="{'invisible': [('is_orphan_record', '=', True)]}"
><field
name="resource_ref"
required="1"
options="{'hide_model': True}"
/></h1>
</div>
<group>
<field name="configuration_id" />
<field name="is_test" invisible="1" />
</group>
<field
name="automation_step_ids"
widget="automation_step"
mode="kanban"
class="o_automation_kanban"
>
<kanban limit="1000">
<field name="id" />
<field name="name" />
<field name="step_type" />
<field name="parent_id" />
<field name="trigger_type" />
<field name="parent_position" />
<field name="mail_status" />
<field name="mail_clicked_on" />
<field name="activity_done_on" />
<field name="state" />
<field name="trigger_type_data" />
<field name="step_icon" />
<field name="step_name" />
<field name="step_actions" />
<templates>
<div
t-name="kanban-box"
class="o_automation_kanban_box p-2"
>
<div
class="o_automation_kanban_extra"
t-foreach="[...Array(record.parent_position.raw_value).keys()]"
t-as="extra"
t-key="extra"
>
<div
class="o_automation_kanban_position_line"
/>
</div>
<div class="me-5 o_automation_kanban_time">
<div
class="o_automation_kanban_position_line"
/>
<i
class="o_automation_kanban_card_position fa fa-circle text-info text-right"
title="Run on"
/>
<div
class="p-2 o_automation_kanban_time_info text-center"
>
<strong>
<field
class="text-success"
name="processed_on"
t-if="record.state.raw_value === 'done'"
/>
<span
class="text-warning"
t-elif="record.state.raw_value == 'expired'"
>
Expired <field
name="processed_on"
/>
</span>
<span
class="text-info"
t-elif="record.state.raw_value == 'rejected'"
>
Rejected <field
name="processed_on"
/>
</span>
<span
class="text-danger"
t-elif="record.state.raw_value == 'error'"
>
Error on <field
name="processed_on"
/>
</span>
<span
class="text-warning"
t-elif="record.state.raw_value == 'cancel'"
>
Cancelled <field
name="processed_on"
/>
</span>
<field
name="scheduled_date"
t-elif="record.scheduled_date.value and record.state.raw_value === 'scheduled'"
/>
<span t-else="" class="text-primary">
<i
t-att-class="record.trigger_type_data.raw_value.icon"
/> <t
t-esc="record.trigger_type_data.raw_value.message"
/>
</span>
</strong>
</div>
</div>
<div class="o_automation_kanban_card">
<div class="o_automation_kanban_header">
<div
t-attf-class="o_automation_kanban_header_icon {{record.state.raw_value === 'done' ? 'btn-success': (record.state.raw_value === 'scheduled' ? 'btn-primary': (record.state.raw_value === 'error' ? 'btn-danger': (record.state.raw_value === 'rejected' ? 'btn-info': 'btn-warning')))}}"
>
<span
t-att-class="record.step_icon.raw_value"
t-att-title="record.step_name.raw_value"
role="img"
t-att-aria-label="record.step_name.raw_value"
/>
</div>
<div
class="o_automation_kanban_header_title"
>
<h3 class="mb0 mt0"><a
type="edit"
class="oe_kanban_action oe_kanban_action_a"
t-att-title="record.name.raw_value"
><field name="name" /></a></h3>
<div t-esc="record.step_type.value" />
</div>
<div
class="o_automation_kanban_header_actions"
>
<button
type="object"
name="run"
class="fa fa-play btn-primary o_automation_kanban_header_icon"
title="Run"
t-if="record.state.raw_value == 'scheduled'"
/>
<button
type="object"
name="cancel"
class="fa fa-times btn-warning o_automation_kanban_header_icon"
title="Cancel"
t-if="record.state.raw_value == 'scheduled'"
/>
</div>
</div>
<div
class="o_automation_kanban_states"
t-if="record.step_actions.raw_value.length > 0"
>
<t
t-foreach="record.step_actions.raw_value"
t-as="state"
t-key="state_index"
>
<div
t-attf-class="o_automation_kanban_state {{ state.done ? state.color: 'text-muted' }}"
>
<i
t-att-class="state.icon"
t-att-title="state.name"
role="img"
t-att-aria-label="state.name"
/> <t t-esc="state.name" />
</div>
</t>
</div>
</div>
</div>
</templates>
</kanban>
</field>
</sheet>
</form>
</field>
</record>
<record model="ir.ui.view" id="automation_record_search_view">
<field name="model">automation.record</field>
<field name="arch" type="xml">
<search>
<field name="configuration_id" />
<separator />
<filter
name="filter_create_date"
string="Created on"
date="create_date"
/>
</search>
</field>
</record>
<record model="ir.ui.view" id="automation_record_tree_view">
<field name="model">automation.record</field>
<field name="arch" type="xml">
<tree create="0">
<field name="name" />
<field name="configuration_id" />
<field name="create_date" />
<field
name="state"
widget="badge"
decoration-info="state == 'periodic'"
decoration-success="state == 'done'"
/>
</tree>
</field>
</record>
<record id="automation_record_graph_view" model="ir.ui.view">
<field name="name">automation.record.graph</field>
<field name="model">automation.record</field>
<field name="arch" type="xml">
<graph string="Records">
<field name="create_date" interval="day" />
</graph>
</field>
</record>
<record id="automation_record_pivot_view" model="ir.ui.view">
<field name="name">automation.record.pivot</field>
<field name="model">automation.record</field>
<field name="arch" type="xml">
<pivot string="Records">
<field name="configuration_id" type="row" />
<field name="state" type="col" />
</pivot>
</field>
</record>
<record model="ir.actions.act_window" id="automation_record_act_window">
<field name="name">Automation Record</field> <!-- TODO -->
<field name="res_model">automation.record</field>
<field name="view_mode">graph,pivot,tree,form</field>
<field name="domain">[]</field>
<field name="context">{}</field>
</record>
<record
model="ir.actions.act_window"
id="automation_record_configuration_act_window"
>
<field name="name">Records</field>
<field name="res_model">automation.record</field>
<field name="view_mode">tree,form</field>
<field
name="domain"
>[('configuration_id', '=', active_id), ('is_test', '=', False)]</field>
<field name="context">{}</field>
</record>
<record
model="ir.actions.act_window"
id="automation_record_configuration_test_act_window"
>
<field name="name">Test Records</field>
<field name="res_model">automation.record</field>
<field name="view_mode">tree,form</field>
<field
name="domain"
>[('configuration_id', '=', active_id), ('is_test', '=', True)]</field>
<field name="context">{}</field>
</record>
<record model="ir.ui.menu" id="automation_record_menu">
<field name="name">Records</field>
<field name="parent_id" ref="automation_reporting_root_menu" />
<field name="action" ref="automation_record_act_window" />
<field name="sequence" eval="16" />
</record>
</odoo>

View file

@ -0,0 +1,142 @@
<?xml version="1.0" encoding="utf-8" ?>
<!-- Copyright 2024 Dixmit
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). -->
<odoo>
<record model="ir.ui.view" id="automation_record_step_form_view">
<field name="model">automation.record.step</field>
<field name="arch" type="xml">
<form create="0">
<header>
<field name="state" widget="statusbar" />
</header>
<sheet>
<group>
<field name="id" />
<field name="name" />
<field name="scheduled_date" />
<field name="processed_on" />
<field name="step_type" />
<field
name="message_id"
attrs="{'invisible': [('message_id', '=', False)]}"
/>
</group>
<group
name="error"
string="Error"
attrs="{'invisible': [('state', '!=', 'error')]}"
>
<field name="error_trace" nolabel="1" />
</group>
</sheet>
</form>
</field>
</record>
<record model="ir.ui.view" id="automation_record_step_tree_view">
<field name="model">automation.record.step</field>
<field name="arch" type="xml">
<tree create="0">
<field name="name" />
<field name="state" />
<button
name="run"
type="object"
string="Run"
attrs="{'invisible': [('state', '!=', 'scheduled')]}"
/>
</tree>
</field>
</record>
<record id="automation_record_step_search_view" model="ir.ui.view">
<field name="name">automation.record.step.search</field>
<field name="model">automation.record.step</field>
<field name="arch" type="xml">
<search>
<field name="configuration_step_id" />
<field name="configuration_id" />
<field name="record_id" />
<separator />
<filter
string="Scheduled"
name="scheduled"
domain="[('state', '=', 'scheduled')]"
/>
<filter string="Done" name="done" domain="[('state', '=', 'done')]" />
<filter
string="Expired"
name="expired"
domain="[('state', '=', 'expired')]"
/>
<filter
string="Rejected"
name="rejected"
domain="[('state', '=', 'rejected')]"
/>
<filter
string="Error"
name="error"
domain="[('state', '=', 'error')]"
/>
<filter
string="Canceled"
name="cancel"
domain="[('state', '=', 'cancel')]"
/>
<separator />
<filter
name="filter_schedule_date"
string="Scheduled date"
date="scheduled_date"
/>
<separator />
<filter
name="filter_processed_on"
string="Processed on"
date="processed_on"
/>
</search>
</field>
</record>
<record id="automation_record_step_graph_view" model="ir.ui.view">
<field name="name">automation.record.step.graph</field>
<field name="model">automation.record.step</field>
<field name="arch" type="xml">
<graph string="Records">
<field name="scheduled_date" interval="day" />
<field name="state" />
</graph>
</field>
</record>
<record id="automation_record_step_pivot_view" model="ir.ui.view">
<field name="name">automation.record.step.pivot</field>
<field name="model">automation.record.step</field>
<field name="arch" type="xml">
<pivot string="Records">
<field name="configuration_id" type="row" />
<field name="state" type="col" />
</pivot>
</field>
</record>
<record model="ir.actions.act_window" id="automation_record_step_act_window">
<field name="name">Activities</field>
<field name="res_model">automation.record.step</field>
<field name="view_mode">graph,pivot,tree,form</field>
<field name="domain">[]</field>
<field name="context">{}</field>
</record>
<record model="ir.ui.menu" id="automation_record_step_menu">
<field name="name">Activities</field>
<field name="parent_id" ref="automation_reporting_root_menu" />
<field name="action" ref="automation_record_step_act_window" />
<field name="sequence" eval="20" />
</record>
</odoo>

View file

@ -0,0 +1,40 @@
<?xml version="1.0" encoding="utf-8" ?>
<!-- Copyright 2024 Dixmit
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). -->
<odoo>
<record model="ir.ui.view" id="automation_tag_search_view">
<field name="model">automation.tag</field>
<field name="arch" type="xml">
<search>
<field name="name" />
</search>
</field>
</record>
<record model="ir.ui.view" id="automation_tag_tree_view">
<field name="model">automation.tag</field>
<field name="arch" type="xml">
<tree editable="bottom">
<field name="name" />
<field name="color" />
</tree>
</field>
</record>
<record model="ir.actions.act_window" id="automation_tag_act_window">
<field name="name">Tags</field> <!-- TODO -->
<field name="res_model">automation.tag</field>
<field name="view_mode">tree,form</field>
<field name="domain">[]</field>
<field name="context">{}</field>
</record>
<record model="ir.ui.menu" id="automation_tag_menu">
<field name="name">Tags</field>
<field name="parent_id" ref="automation_config_root_menu" />
<field name="action" ref="automation_tag_act_window" />
<field name="sequence" eval="40" />
</record>
</odoo>

View file

@ -0,0 +1,38 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<record id="link_tracker_click_search_view" model="ir.ui.view">
<field name="name">link.tracker.click.view.search.inherit.mass_mailing</field>
<field name="model">link.tracker.click</field>
<field name="inherit_id" ref="link_tracker.link_tracker_click_view_search" />
<field name="arch" type="xml">
<xpath expr="//field[@name='country_id']" position="after">
<field name="automation_configuration_id" />
</xpath>
<xpath expr="//filter[@name='groupby_country_id']" position="after">
<filter
string="Automation configuration"
name="groupby_automation_configuration_id"
context="{'group_by': 'automation_configuration_id'}"
/>
</xpath>
</field>
</record>
<record model="ir.actions.act_window" id="link_tracker_click_act_window">
<field name="name">Clicks</field>
<field name="res_model">link.tracker.click</field>
<field name="view_mode">graph,pivot,tree</field>
<field name="domain">[('automation_record_step_id', '!=', False)]</field>
<field
name="context"
>{'search_default_groupby_automation_configuration_id': 1}</field>
</record>
<record model="ir.ui.menu" id="link_tracker_click_menu">
<field name="name">Clicks</field>
<field name="parent_id" ref="automation_reporting_root_menu" />
<field name="action" ref="link_tracker_click_act_window" />
<field name="sequence" eval="30" />
</record>
</odoo>

View file

@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<record model="ir.ui.menu" id="automation_root_menu">
<field name="name">Automation</field>
<field name="web_icon">automation_oca,static/description/icon.png</field>
<field name="groups_id" eval="[(4, ref('group_automation_user'))]" />
<field name="sequence" eval="55" />
</record>
<record model="ir.ui.menu" id="automation_reporting_root_menu">
<field name="name">Reporting</field>
<field name="parent_id" ref="automation_root_menu" />
<field name="groups_id" eval="[(4, ref('group_automation_manager'))]" />
<field name="sequence" eval="40" />
</record>
<record model="ir.ui.menu" id="automation_config_root_menu">
<field name="name">Configuration</field>
<field name="parent_id" ref="automation_root_menu" />
<field name="groups_id" eval="[(4, ref('group_automation_manager'))]" />
<field name="sequence" eval="90" />
</record>
</odoo>

View file

@ -0,0 +1,2 @@
from . import mail_compose_message
from . import automation_configuration_test

View file

@ -0,0 +1,43 @@
# Copyright 2024 Dixmit
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
from odoo import api, fields, models
class AutomationConfigurationTest(models.TransientModel):
_name = "automation.configuration.test"
_description = "Test automation configuration"
configuration_id = fields.Many2one("automation.configuration", required=True)
model = fields.Char(related="configuration_id.model")
resource_ref = fields.Reference(
selection="_selection_target_model",
readonly=False,
required=True,
store=True,
compute="_compute_resource_ref",
)
@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("model")
def _compute_resource_ref(self):
for record in self:
if record.model and record.model in self.env:
res = self.env[record.model].search([], limit=1)
record.resource_ref = "%s,%s" % (record.model, res.id)
else:
record.resource_ref = None
def test_record(self):
return self.configuration_id._create_record(
self.resource_ref, is_test=True
).get_formview_action()

View file

@ -0,0 +1,49 @@
<?xml version="1.0" encoding="utf-8" ?>
<!-- Copyright 2024 Dixmit
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). -->
<odoo>
<record model="ir.ui.view" id="automation_configuration_test_form_view">
<field name="model">automation.configuration.test</field>
<field name="arch" type="xml">
<form string="Test configuration">
<sheet>
<div class="oe_title">
<h2><field
name="resource_ref"
required="1"
options="{'hide_model': True}"
/></h2>
</div>
<div class="row text-center">
On tests, mails will not be sent, but templates will be generated and actions will be executed.
</div>
<group>
<field name="configuration_id" invisible="1" />
<field name="model" invisible="1" />
</group>
</sheet>
<footer>
<button
name="test_record"
string="Test"
class="btn-primary"
type="object"
/>
<button string="Cancel" class="btn-default" special="cancel" />
</footer>
</form>
</field>
</record>
<record model="ir.actions.act_window" id="automation_configuration_test_act_window">
<field name="name">Test Configuration</field>
<field name="res_model">automation.configuration.test</field>
<field name="view_mode">form</field>
<field name="context">{'default_configuration_id': active_id}</field>
<field name="target">new</field>
</record>
</odoo>

View file

@ -0,0 +1,20 @@
# Copyright 2024 Dixmit
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
from odoo import fields, models
class MailComposeMessage(models.TransientModel):
_inherit = "mail.compose.message"
automation_record_step_id = fields.Many2one("automation.record.step")
def get_mail_values(self, res_ids):
result = super().get_mail_values(res_ids)
if self.automation_record_step_id:
for res_id in res_ids:
result[res_id][
"automation_record_step_id"
] = self.automation_record_step_id.id
return result

View file

@ -0,0 +1,32 @@
# Architecture
```mermaid
flowchart TD
U[Users] -->|HTTP| V[Views and QWeb Templates]
V --> C[Controllers]
V --> W[Wizards Transient Models]
C --> M[Models and ORM]
W --> M
M --> R[Reports]
DX[Data XML] --> M
S[Security ACLs and Groups] -. enforces .-> M
subgraph Automation_oca Module - automation_oca
direction LR
M:::layer
W:::layer
C:::layer
V:::layer
R:::layer
S:::layer
DX:::layer
end
classDef layer fill:#eef8ff,stroke:#6ea8fe,stroke-width:1px
```
Notes
- Views include tree/form/kanban templates and report templates.
- Controllers provide website/portal routes when present.
- Wizards are UI flows implemented with `models.TransientModel`.
- Data XML loads data/demo records; Security defines groups and access.

View file

@ -0,0 +1,3 @@
# Configuration
Refer to Odoo settings for automation_oca. Configure related models, access rights, and options as needed.

View file

@ -0,0 +1,17 @@
# Controllers
HTTP routes provided by this module.
```mermaid
sequenceDiagram
participant U as User/Client
participant C as Module Controllers
participant O as ORM/Views
U->>C: HTTP GET/POST (routes)
C->>O: ORM operations, render templates
O-->>U: HTML/JSON/PDF
```
Notes
- See files in controllers/ for route definitions.

View file

@ -0,0 +1,6 @@
# Dependencies
This addon depends on:
- [mail](../../odoo-bringout-oca-ocb-mail)
- [link_tracker](../../odoo-bringout-oca-ocb-link_tracker)

View file

@ -0,0 +1,4 @@
# FAQ
- Q: Which Odoo version? A: 16.0 (OCA/OCB packaged).
- Q: How to enable? A: Start server with --addon automation_oca or install in UI.

View file

@ -0,0 +1,7 @@
# Install
```bash
pip install odoo-bringout-oca-automation-automation_oca"
# or
uv pip install odoo-bringout-oca-automation-automation_oca"
```

View file

@ -0,0 +1,21 @@
# Models
Detected core models and extensions in automation_oca.
```mermaid
classDiagram
class automation_configuration
class automation_configuration_step
class automation_filter
class automation_record
class automation_record_step
class automation_tag
class link_tracker_click
class mail_activity
class mail_mail
class mail_thread
```
Notes
- Classes show model technical names; fields omitted for brevity.
- Items listed under _inherit are extensions of existing models.

View file

@ -0,0 +1,6 @@
# Overview
Packaged Odoo addon: automation_oca. Provides features documented in upstream Odoo 16 under this addon.
- Source: OCA/OCB 16.0, addon automation_oca
- License: LGPL-3

View file

@ -0,0 +1,3 @@
# Reports
This module does not define custom reports.

View file

@ -0,0 +1,42 @@
# Security
Access control and security definitions in automation_oca.
## Access Control Lists (ACLs)
Model access permissions defined in:
- **[ir.model.access.csv](../automation_oca/security/ir.model.access.csv)**
- 13 model access rules
## Record Rules
Row-level security rules defined in:
## Security Groups & Configuration
Security groups and permissions defined in:
- **[security.xml](../automation_oca/security/security.xml)**
- 2 security groups defined
```mermaid
graph TB
subgraph "Security Layers"
A[Users] --> B[Groups]
B --> C[Access Control Lists]
C --> D[Models]
B --> E[Record Rules]
E --> F[Individual Records]
end
```
Security files overview:
- **[ir.model.access.csv](../automation_oca/security/ir.model.access.csv)**
- Model access permissions (CRUD rights)
- **[security.xml](../automation_oca/security/security.xml)**
- Security groups, categories, and XML-based rules
Notes
- Access Control Lists define which groups can access which models
- Record Rules provide row-level security (filter records by user/group)
- Security groups organize users and define permission sets
- All security is enforced at the ORM level by Odoo

View file

@ -0,0 +1,5 @@
# Troubleshooting
- Ensure Python and Odoo environment matches repo guidance.
- Check database connectivity and logs if startup fails.
- Validate that dependent addons listed in DEPENDENCIES.md are installed.

View file

@ -0,0 +1,7 @@
# Usage
Start Odoo including this addon (from repo root):
```bash
python3 scripts/nix_odoo_web_server.py --db-name mydb --addon automation_oca
```

View file

@ -0,0 +1,3 @@
# Wizards
This module does not include UI wizards.

View file

@ -0,0 +1,44 @@
[project]
name = "odoo-bringout-oca-automation-automation_oca"
version = "16.0.0"
description = "Automation Oca -
Automate actions in threaded models"
authors = [
{ name = "Ernad Husremovic", email = "hernad@bring.out.ba" }
]
dependencies = [
"odoo-bringout-oca-ocb-mail>=16.0.0",
"odoo-bringout-oca-ocb-link_tracker>=16.0.0",
"requests>=2.25.1"
]
readme = "README.md"
requires-python = ">= 3.11"
classifiers = [
"Development Status :: 5 - Production/Stable",
"Intended Audience :: Developers",
"License :: OSI Approved :: GNU Lesser General Public License v3 (LGPLv3)",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Topic :: Office/Business",
]
[project.urls]
homepage = "https://github.com/bringout/0"
repository = "https://github.com/bringout/0"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.metadata]
allow-direct-references = true
[tool.hatch.build.targets.wheel]
packages = ["automation_oca"]
[tool.rye]
managed = true
dev-dependencies = [
"pytest>=8.4.1",
]