mirror of
https://github.com/bringout/oca-technical.git
synced 2026-04-18 04:11:59 +02:00
Initial commit: OCA Technical packages (595 packages)
This commit is contained in:
commit
2cc02aac6e
24950 changed files with 2318079 additions and 0 deletions
45
odoo-bringout-oca-automation-automation_oca/README.md
Normal file
45
odoo-bringout-oca-automation-automation_oca/README.md
Normal 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
|
||||
|
|
@ -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.
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
from . import controllers
|
||||
from . import models
|
||||
from . import wizards
|
||||
|
|
@ -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",
|
||||
],
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
from . import main
|
||||
|
|
@ -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)
|
||||
|
|
@ -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>
|
||||
|
|
@ -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", "<=", 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
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
|
|
@ -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,
|
||||
}
|
||||
)
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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 = []
|
||||
|
|
@ -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)
|
||||
|
|
@ -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 []
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1 @@
|
|||
- Enric Tobella ([Dixmit](https://www.dixmit.com/))
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
The development of this module has been financially supported by:
|
||||
|
||||
- Associacion Española de Odoo ([AEODOO](https://www.aeodoo.org/))
|
||||
|
|
@ -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
|
||||
|
|
@ -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 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.
|
||||
|
|
@ -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
|
||||
|
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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&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 -> 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>
|
||||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
|
|
@ -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>
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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
|
||||
|
|
@ -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,
|
||||
}
|
||||
)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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())
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
from . import mail_compose_message
|
||||
from . import automation_configuration_test
|
||||
|
|
@ -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()
|
||||
|
|
@ -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>
|
||||
|
|
@ -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
|
||||
|
|
@ -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.
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
# Configuration
|
||||
|
||||
Refer to Odoo settings for automation_oca. Configure related models, access rights, and options as needed.
|
||||
|
|
@ -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.
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
# Dependencies
|
||||
|
||||
This addon depends on:
|
||||
|
||||
- [mail](../../odoo-bringout-oca-ocb-mail)
|
||||
- [link_tracker](../../odoo-bringout-oca-ocb-link_tracker)
|
||||
4
odoo-bringout-oca-automation-automation_oca/doc/FAQ.md
Normal file
4
odoo-bringout-oca-automation-automation_oca/doc/FAQ.md
Normal 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.
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
# Install
|
||||
|
||||
```bash
|
||||
pip install odoo-bringout-oca-automation-automation_oca"
|
||||
# or
|
||||
uv pip install odoo-bringout-oca-automation-automation_oca"
|
||||
```
|
||||
21
odoo-bringout-oca-automation-automation_oca/doc/MODELS.md
Normal file
21
odoo-bringout-oca-automation-automation_oca/doc/MODELS.md
Normal 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.
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
# Reports
|
||||
|
||||
This module does not define custom reports.
|
||||
42
odoo-bringout-oca-automation-automation_oca/doc/SECURITY.md
Normal file
42
odoo-bringout-oca-automation-automation_oca/doc/SECURITY.md
Normal 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
|
||||
|
|
@ -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.
|
||||
7
odoo-bringout-oca-automation-automation_oca/doc/USAGE.md
Normal file
7
odoo-bringout-oca-automation-automation_oca/doc/USAGE.md
Normal 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
|
||||
```
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
# Wizards
|
||||
|
||||
This module does not include UI wizards.
|
||||
44
odoo-bringout-oca-automation-automation_oca/pyproject.toml
Normal file
44
odoo-bringout-oca-automation-automation_oca/pyproject.toml
Normal 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",
|
||||
]
|
||||
Loading…
Add table
Add a link
Reference in a new issue