Initial commit: OCA Technical packages (595 packages)

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

View file

@ -0,0 +1,10 @@
from . import automation_configuration
from . import automation_configuration_step
from . import automation_record
from . import automation_record_step
from . import mail_mail
from . import mail_thread
from . import link_tracker
from . import automation_filter
from . import automation_tag
from . import mail_activity

View file

@ -0,0 +1,328 @@
# Copyright 2024 Dixmit
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
from collections import defaultdict
from odoo import _, api, fields, models
from odoo.exceptions import ValidationError
from odoo.tools.safe_eval import (
datetime as safe_datetime,
dateutil as safe_dateutil,
safe_eval,
time as safe_time,
)
class AutomationConfiguration(models.Model):
_name = "automation.configuration"
_description = "Automation Configuration"
_inherit = ["mail.thread"]
name = fields.Char(required=True)
active = fields.Boolean(default=True)
tag_ids = fields.Many2many("automation.tag")
company_id = fields.Many2one("res.company")
domain = fields.Char(
required=True,
default="[]",
compute="_compute_domain",
help="""
Filter to apply
Following special variable can be used in filter :
* datetime
* dateutil
* time
* user
* ref """,
)
editable_domain = fields.Char(
required=True,
default="[]",
help="""Filter to apply
Following special variable can be used in filter :
* datetime
* dateutil
* time
* user
* ref """,
)
model_id = fields.Many2one(
"ir.model",
domain=[("is_mail_thread", "=", True)],
required=True,
ondelete="cascade",
help="Model where the configuration is applied",
)
filter_id = fields.Many2one("automation.filter")
filter_domain = fields.Binary(compute="_compute_filter_domain")
model = fields.Char(related="model_id.model")
field_id = fields.Many2one(
"ir.model.fields",
domain="[('model_id', '=', model_id), "
"('ttype', 'in', ['char', 'selection', 'integer', 'text', 'many2one'])]",
help="Used to avoid duplicates",
)
is_periodic = fields.Boolean(
help="Mark it if you want to make the execution periodic"
)
# The idea of flow of states will be:
# draft -> run -> done -> draft (for periodic execution)
# -> on demand -> done -> draft (for on demand execution)
state = fields.Selection(
[
("draft", "Draft"),
("periodic", "Periodic"),
("ondemand", "On demand"),
("done", "Done"),
],
default="draft",
required=True,
group_expand="_group_expand_states",
)
automation_step_ids = fields.One2many(
"automation.configuration.step", inverse_name="configuration_id"
)
automation_direct_step_ids = fields.One2many(
"automation.configuration.step",
inverse_name="configuration_id",
domain=[("parent_id", "=", False)],
)
record_test_count = fields.Integer(compute="_compute_record_test_count")
record_count = fields.Integer(compute="_compute_record_count")
record_done_count = fields.Integer(compute="_compute_record_count")
record_run_count = fields.Integer(compute="_compute_record_count")
activity_mail_count = fields.Integer(compute="_compute_activity_count")
activity_action_count = fields.Integer(compute="_compute_activity_count")
click_count = fields.Integer(compute="_compute_click_count")
next_execution_date = fields.Datetime(compute="_compute_next_execution_date")
@api.depends("filter_id.domain", "filter_id", "editable_domain")
def _compute_domain(self):
for record in self:
record.domain = (
record.filter_id and record.filter_id.domain
) or record.editable_domain
@api.depends()
def _compute_click_count(self):
data = self.env["link.tracker.click"].read_group(
[("automation_configuration_id", "in", self.ids)],
[],
["automation_configuration_id"],
lazy=False,
)
mapped_data = {d["automation_configuration_id"][0]: d["__count"] for d in data}
for record in self:
record.click_count = mapped_data.get(record.id, 0)
@api.depends()
def _compute_activity_count(self):
data = self.env["automation.record.step"].read_group(
[
("configuration_id", "in", self.ids),
("state", "=", "done"),
("is_test", "=", False),
],
[],
["configuration_id", "step_type"],
lazy=False,
)
mapped_data = defaultdict(lambda: {})
for d in data:
mapped_data[d["configuration_id"][0]][d["step_type"]] = d["__count"]
for record in self:
record.activity_mail_count = mapped_data[record.id].get("mail", 0)
record.activity_action_count = mapped_data[record.id].get("action", 0)
@api.depends()
def _compute_record_count(self):
data = self.env["automation.record"].read_group(
[("configuration_id", "in", self.ids), ("is_test", "=", False)],
[],
["configuration_id", "state"],
lazy=False,
)
mapped_data = defaultdict(lambda: {})
for d in data:
mapped_data[d["configuration_id"][0]][d["state"]] = d["__count"]
for record in self:
record.record_done_count = mapped_data[record.id].get("done", 0)
record.record_run_count = mapped_data[record.id].get("periodic", 0)
record.record_count = sum(mapped_data[record.id].values())
@api.depends()
def _compute_record_test_count(self):
data = self.env["automation.record"].read_group(
[("configuration_id", "in", self.ids), ("is_test", "=", True)],
[],
["configuration_id"],
lazy=False,
)
mapped_data = {d["configuration_id"][0]: d["__count"] for d in data}
for record in self:
record.record_test_count = mapped_data.get(record.id, 0)
@api.depends("model_id")
def _compute_filter_domain(self):
for record in self:
record.filter_domain = (
[] if not record.model_id else [("model_id", "=", record.model_id.id)]
)
@api.depends("state")
def _compute_next_execution_date(self):
for record in self:
if record.state == "periodic":
record.next_execution_date = self.env.ref(
"automation_oca.cron_configuration_run"
).nextcall
else:
record.next_execution_date = False
@api.onchange("filter_id")
def _onchange_filter(self):
self.model_id = self.filter_id.model_id
@api.onchange("model_id")
def _onchange_model(self):
self.editable_domain = []
self.filter_id = False
self.field_id = False
self.automation_step_ids = [(5, 0, 0)]
def start_automation(self):
self.ensure_one()
if self.state != "draft":
raise ValidationError(_("State must be in draft in order to start"))
self.state = "periodic" if self.is_periodic else "ondemand"
def done_automation(self):
self.ensure_one()
self.state = "done"
def back_to_draft(self):
self.ensure_one()
self.state = "draft"
def cron_automation(self):
for record in self.search([("state", "=", "periodic")]):
record.run_automation()
def _get_eval_context(self):
"""Prepare the context used when evaluating python code
:returns: dict -- evaluation context given to safe_eval
"""
return {
"ref": self.env.ref,
"user": self.env.user,
"time": safe_time,
"datetime": safe_datetime,
"dateutil": safe_dateutil,
}
def _get_automation_records_to_create(self):
"""
We will find all the records that fulfill the domain but don't have a record created.
Also, we need to check by autencity field if defined.
In order to do this, we will add some extra joins on the query of the domain
"""
eval_context = self._get_eval_context()
domain = safe_eval(self.domain, eval_context)
Record = self.env[self.model_id.model]
if self.company_id and "company_id" in Record._fields:
# In case of company defined, we add only if the records have company field
domain += [("company_id", "=", self.company_id.id)]
query = Record._where_calc(domain)
alias = query.left_join(
query._tables[Record._table],
"id",
"automation_record",
"res_id",
"automation_record",
"{rhs}.model = %s AND {rhs}.configuration_id = %s AND "
"({rhs}.is_test IS NULL OR NOT {rhs}.is_test)",
(Record._name, self.id),
)
query.add_where("{}.id is NULL".format(alias))
if self.field_id:
# In case of unicity field defined, we need to add this
# left join to find already created records
linked_tab = query.left_join(
query._tables[Record._table],
self.field_id.name,
Record._table,
self.field_id.name,
"linked",
)
alias2 = query.left_join(
linked_tab,
"id",
"automation_record",
"res_id",
"automation_record_linked",
"{rhs}.model = %s AND {rhs}.configuration_id = %s AND "
"({rhs}.is_test IS NULL OR NOT {rhs}.is_test)",
(Record._name, self.id),
)
query.add_where("{}.id is NULL".format(alias2))
from_clause, where_clause, params = query.get_sql()
# We also need to find with a group by in order to avoid duplication
# when we have both records created between two executions
# (first one has priority)
query_str = "SELECT {} FROM {} WHERE {}{}{}{} GROUP BY {}".format(
", ".join([f'MIN("{next(iter(query._tables))}".id) as id']),
from_clause,
where_clause or "TRUE",
(" ORDER BY %s" % self.order) if query.order else "",
(" LIMIT %d" % self.limit) if query.limit else "",
(" OFFSET %d" % self.offset) if query.offset else "",
"%s.%s" % (query._tables[Record._table], self.field_id.name),
)
else:
query_str, params = query.select()
self.env.cr.execute(query_str, params)
return Record.browse([r[0] for r in self.env.cr.fetchall()])
def run_automation(self):
self.ensure_one()
if self.state not in ["periodic", "ondemand"]:
return
records = self.env["automation.record"]
for record in self._get_automation_records_to_create():
records |= self._create_record(record)
records.automation_step_ids._trigger_activities()
def _create_record(self, record, **kwargs):
return self.env["automation.record"].create(
self._create_record_vals(record, **kwargs)
)
def _create_record_vals(self, record, **kwargs):
return {
**kwargs,
"res_id": record.id,
"model": record._name,
"configuration_id": self.id,
"automation_step_ids": [
(0, 0, activity._create_record_activity_vals(record))
for activity in self.automation_direct_step_ids
],
}
def _group_expand_states(self, states, domain, order):
"""
This is used to show all the states on the kanban view
"""
return [key for key, _val in self._fields["state"].selection]
def save_filter(self):
self.ensure_one()
self.filter_id = self.env["automation.filter"].create(
{
"name": self.name,
"domain": self.editable_domain,
"model_id": self.model_id.id,
}
)

View file

@ -0,0 +1,566 @@
# Copyright 2024 Dixmit
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
import json
from collections import defaultdict
import babel.dates
from dateutil.relativedelta import relativedelta
from odoo import _, api, fields, models
from odoo.exceptions import ValidationError
from odoo.osv import expression
from odoo.tools import get_lang
from odoo.tools.safe_eval import safe_eval
class AutomationConfigurationStep(models.Model):
_name = "automation.configuration.step"
_description = "Automation Steps"
_order = "trigger_interval_hours ASC"
name = fields.Char(required=True)
configuration_id = fields.Many2one(
"automation.configuration", required=True, auto_join=True
)
domain = fields.Char(
required=True, default="[]", help="Filter to apply specifically"
)
apply_parent_domain = fields.Boolean(default=True)
applied_domain = fields.Char(
compute="_compute_applied_domain",
recursive=True,
)
parent_id = fields.Many2one("automation.configuration.step", ondelete="cascade")
model_id = fields.Many2one(related="configuration_id.model_id")
model = fields.Char(related="model_id.model")
child_ids = fields.One2many(
"automation.configuration.step", inverse_name="parent_id"
)
step_type = fields.Selection(
[("mail", "Mail"), ("action", "Server Action"), ("activity", "Activity")],
required=True,
default="mail",
)
step_icon = fields.Char(compute="_compute_step_info")
step_name = fields.Char(compute="_compute_step_info")
trigger_date_kind = fields.Selection(
[
("offset", "Offset"),
("date", "Force on Record Date"),
],
required=True,
default="offset",
)
trigger_interval_hours = fields.Integer(
compute="_compute_trigger_interval_hours", store=True
)
trigger_interval = fields.Integer(
help="""Set a negative time trigger if you want the step to be executed
immediately, in parallel with the previous step, without waiting for it to
finish."""
)
trigger_interval_type = fields.Selection(
[("hours", "Hour(s)"), ("days", "Day(s)")], required=True, default="hours"
)
trigger_date_field_id = fields.Many2one(
"ir.model.fields",
domain="[('model_id', '=', model_id), ('ttype', 'in', ['date', 'datetime'])]",
)
trigger_date_field = fields.Char(related="trigger_date_field_id.field_description")
allow_expiry = fields.Boolean(compute="_compute_allow_expiry")
expiry = fields.Boolean(compute="_compute_expiry", store=True, readonly=False)
expiry_interval = fields.Integer()
expiry_interval_type = fields.Selection(
[("hours", "Hour(s)"), ("days", "Day(s)")], required=True, default="hours"
)
trigger_type = fields.Selection(
selection="_trigger_type_selection",
required=True,
default="start",
)
trigger_child_types = fields.Json(compute="_compute_trigger_child_types")
trigger_type_data = fields.Json(compute="_compute_trigger_type_data")
mail_author_id = fields.Many2one(
"res.partner", required=True, default=lambda r: r.env.user.id
)
mail_template_id = fields.Many2one(
"mail.template", domain="[('model_id', '=', model_id)]"
)
server_action_id = fields.Many2one(
"ir.actions.server", domain="[('model_id', '=', model_id)]"
)
server_context = fields.Text(default="{}")
activity_type_id = fields.Many2one(
"mail.activity.type",
string="Activity",
domain="['|', ('res_model', '=', False), ('res_model', '=', model)]",
compute="_compute_activity_info",
readonly=False,
store=True,
ondelete="restrict",
)
activity_summary = fields.Char(
"Summary", compute="_compute_activity_info", readonly=False, store=True
)
activity_note = fields.Html(
"Note", compute="_compute_activity_info", readonly=False, store=True
)
activity_date_deadline_range = fields.Integer(
string="Due Date In",
compute="_compute_activity_info",
readonly=False,
store=True,
)
activity_date_deadline_range_type = fields.Selection(
[("days", "Day(s)"), ("weeks", "Week(s)"), ("months", "Month(s)")],
string="Due type",
default="days",
compute="_compute_activity_info",
readonly=False,
store=True,
)
activity_user_type = fields.Selection(
[("specific", "Specific User"), ("generic", "Generic User From Record")],
compute="_compute_activity_info",
readonly=False,
store=True,
help="""Use 'Specific User' to always assign the same user on the next activity.
Use 'Generic User From Record' to specify the field name of the user
to choose on the record.""",
)
activity_user_id = fields.Many2one(
"res.users",
string="Responsible",
compute="_compute_activity_info",
readonly=False,
store=True,
)
activity_user_field_id = fields.Many2one(
"ir.model.fields",
"User field name",
compute="_compute_activity_info",
readonly=False,
store=True,
)
parent_position = fields.Integer(
compute="_compute_parent_position", recursive=True, store=True
)
graph_data = fields.Json(compute="_compute_graph_data")
graph_done = fields.Integer(compute="_compute_total_graph_data")
graph_error = fields.Integer(compute="_compute_total_graph_data")
@api.constrains("server_context")
def _check_server_context(self):
for record in self:
if record.server_context:
try:
json.loads(record.server_context)
except Exception as e:
raise ValidationError(_("Server Context is not wellformed")) from e
@api.onchange("trigger_type")
def _onchange_trigger_type(self):
if self.trigger_type == "start":
# Theoretically, only start allows no parent, so we will keep it this way
self.parent_id = False
########################################
# Graph computed fields ################
########################################
@api.depends()
def _compute_graph_data(self):
total = self.env["automation.record.step"].read_group(
[
("configuration_step_id", "in", self.ids),
(
"processed_on",
">=",
fields.Date.context_today(self) + relativedelta(days=-14),
),
("is_test", "=", False),
],
["configuration_step_id"],
["configuration_step_id", "processed_on:day"],
lazy=False,
)
done = self.env["automation.record.step"].read_group(
[
("configuration_step_id", "in", self.ids),
(
"processed_on",
">=",
fields.Date.context_today(self) + relativedelta(days=-14),
),
("state", "=", "done"),
("is_test", "=", False),
],
["configuration_step_id"],
["configuration_step_id", "processed_on:day"],
lazy=False,
)
now = fields.Datetime.now()
date_map = {
babel.dates.format_datetime(
now + relativedelta(days=i - 14),
format="dd MMM yyy",
tzinfo=self._context.get("tz", None),
locale=get_lang(self.env).code,
): 0
for i in range(0, 15)
}
result = defaultdict(
lambda: {"done": date_map.copy(), "error": date_map.copy()}
)
for line in total:
result[line["configuration_step_id"][0]]["error"][
line["processed_on:day"]
] += line["__count"]
for line in done:
result[line["configuration_step_id"][0]]["done"][
line["processed_on:day"]
] += line["__count"]
result[line["configuration_step_id"][0]]["error"][
line["processed_on:day"]
] -= line["__count"]
for record in self:
graph_info = dict(result[record.id])
record.graph_data = {
"error": [
{"x": key[:-5], "y": value, "name": key}
for (key, value) in graph_info["error"].items()
],
"done": [
{"x": key[:-5], "y": value, "name": key}
for (key, value) in graph_info["done"].items()
],
}
@api.depends()
def _compute_total_graph_data(self):
for record in self:
record.graph_done = self.env["automation.record.step"].search_count(
[
("configuration_step_id", "=", record.id),
("state", "=", "done"),
("is_test", "=", False),
]
)
record.graph_error = self.env["automation.record.step"].search_count(
[
("configuration_step_id", "=", record.id),
("state", "in", ["expired", "rejected", "error", "cancel"]),
("is_test", "=", False),
]
)
@api.depends("step_type")
def _compute_activity_info(self):
for to_reset in self.filtered(lambda act: act.step_type != "activity"):
to_reset.activity_summary = False
to_reset.activity_note = False
to_reset.activity_date_deadline_range = False
to_reset.activity_date_deadline_range_type = False
to_reset.activity_user_type = False
to_reset.activity_user_id = False
to_reset.activity_user_field_id = False
for activity in self.filtered(lambda act: act.step_type == "activity"):
if not activity.activity_date_deadline_range_type:
activity.activity_date_deadline_range_type = "days"
if not activity.activity_user_id:
activity.activity_user_id = self.env.user.id
@api.depends("trigger_interval", "trigger_interval_type")
def _compute_trigger_interval_hours(self):
for record in self:
record.trigger_interval_hours = record._get_trigger_interval_hours()
def _get_trigger_interval_hours(self):
if self.trigger_interval_type == "days":
return self.trigger_interval * 24
return self.trigger_interval
@api.depends("parent_id", "parent_id.parent_position", "trigger_type")
def _compute_parent_position(self):
for record in self:
record.parent_position = (
(record.parent_id.parent_position + 1) if record.parent_id else 0
)
@api.depends(
"domain",
"configuration_id.domain",
"parent_id",
"parent_id.applied_domain",
"apply_parent_domain",
)
def _compute_applied_domain(self):
for record in self:
eval_context = record.configuration_id._get_eval_context()
if record.apply_parent_domain:
record.applied_domain = expression.AND(
[
safe_eval(record.domain, eval_context),
safe_eval(
(record.parent_id and record.parent_id.applied_domain)
or record.configuration_id.domain,
eval_context,
),
]
)
else:
record.applied_domain = safe_eval(record.domain, eval_context)
@api.model
def _trigger_type_selection(self):
return [
(trigger_id, trigger.get("name", trigger_id))
for trigger_id, trigger in self._trigger_types().items()
]
@api.model
def _trigger_types(self):
"""
This function will return a dictionary that map trigger_types to its configurations.
Each trigger_type can contain:
- name (Required field)
- step type: List of step types that succeed after this.
If it is false, it will work for all step types,
otherwise only for the ones on the list
- color: Color of the icon
- icon: Icon to show
- message_configuration: Message to show on the step configuration
- allow_expiry: True if it allows expiration of activity
- message: Message to show on the record if expected is not date defined
"""
return {
"start": {
"name": _("start of workflow"),
"step_type": [],
"message_configuration": False,
"message": False,
"allow_parent": True,
},
"after_step": {
"name": _("execution of another step"),
"color": "text-success",
"icon": "fa fa-code-fork fa-rotate-180 fa-flip-vertical",
"message_configuration": False,
"message": False,
},
"mail_open": {
"name": _("Mail opened"),
"allow_expiry": True,
"step_type": ["mail"],
"color": "text-success",
"icon": "fa fa-envelope-open-o",
"message_configuration": _("Opened after"),
"message": _("Not opened yet"),
},
"mail_not_open": {
"name": _("Mail not opened"),
"step_type": ["mail"],
"color": "text-danger",
"icon": "fa fa-envelope-open-o",
"message_configuration": _("Not opened within"),
"message": False,
},
"mail_reply": {
"name": _("Mail replied"),
"allow_expiry": True,
"step_type": ["mail"],
"color": "text-success",
"icon": "fa fa-reply",
"message_configuration": _("Replied after"),
"message": _("Not replied yet"),
},
"mail_not_reply": {
"name": _("Mail not replied"),
"step_type": ["mail"],
"color": "text-danger",
"icon": "fa fa-reply",
"message_configuration": _("Not replied within"),
"message": False,
},
"mail_click": {
"name": _("Mail clicked"),
"allow_expiry": True,
"step_type": ["mail"],
"color": "text-success",
"icon": "fa fa-hand-pointer-o",
"message_configuration": _("Clicked after"),
"message": _("Not clicked yet"),
},
"mail_not_clicked": {
"name": _("Mail not clicked"),
"step_type": ["mail"],
"color": "text-danger",
"icon": "fa fa-hand-pointer-o",
"message_configuration": _("Not clicked within"),
"message": False,
},
"mail_bounce": {
"name": _("Mail bounced"),
"allow_expiry": True,
"step_type": ["mail"],
"color": "text-danger",
"icon": "fa fa-exclamation-circle",
"message_configuration": _("Bounced after"),
"message": _("Not bounced yet"),
},
"activity_done": {
"name": _("Activity has been finished"),
"step_type": ["activity"],
"color": "text-success",
"icon": "fa fa-clock-o",
"message_configuration": _("After finished"),
"message": _("Activity not done"),
},
"activity_cancel": {
"name": _("Activity has been cancelled"),
"step_type": ["activity"],
"color": "text-warning",
"icon": "fa fa-ban",
"message_configuration": _("After finished"),
"message": _("Activity not cancelled"),
},
"activity_not_done": {
"name": _("Activity has not been finished"),
"allow_expiry": True,
"step_type": ["activity"],
"color": "text-danger",
"icon": "fa fa-clock-o",
"message_configuration": _("Not finished within"),
"message": False,
},
}
@api.model
def _step_icons(self):
"""
This function will return a dictionary that maps step types and icons
"""
return {
"mail": "fa fa-envelope",
"activity": "fa fa-clock-o",
"action": "fa fa-cogs",
}
@api.depends("step_type")
def _compute_step_info(self):
step_icons = self._step_icons()
step_name_map = dict(self._fields["step_type"].selection)
for record in self:
record.step_icon = step_icons.get(record.step_type, "")
record.step_name = step_name_map.get(record.step_type, "")
@api.depends("trigger_type")
def _compute_trigger_type_data(self):
trigger_types = self._trigger_types()
for record in self:
record.trigger_type_data = trigger_types[record.trigger_type]
@api.depends("trigger_type")
def _compute_allow_expiry(self):
trigger_types = self._trigger_types()
for record in self:
record.allow_expiry = trigger_types[record.trigger_type].get(
"allow_expiry", False
)
@api.depends("trigger_type")
def _compute_expiry(self):
trigger_types = self._trigger_types()
for record in self:
record.expiry = (
trigger_types[record.trigger_type].get("allow_expiry", False)
and record.expiry
)
@api.depends("step_type")
def _compute_trigger_child_types(self):
trigger_types = self._trigger_types()
for record in self:
trigger_child_types = {}
for trigger_type_id, trigger_type in trigger_types.items():
if "step_type" not in trigger_type:
# All are allowed
trigger_child_types[trigger_type_id] = trigger_type
elif record.step_type in trigger_type["step_type"]:
trigger_child_types[trigger_type_id] = trigger_type
record.trigger_child_types = trigger_child_types
def _check_configuration(self):
trigger_conf = self._trigger_types()[self.trigger_type]
if not self.parent_id and not trigger_conf.get("allow_parent"):
raise ValidationError(
_("%s configurations needs a parent") % trigger_conf["name"]
)
if (
self.parent_id
and "step_type" in trigger_conf
and self.parent_id.step_type not in trigger_conf["step_type"]
):
step_types = dict(self._fields["step_type"].selection)
raise ValidationError(
_("To use a %(name)s trigger type we need a parent of type %(parents)s")
% {
"name": trigger_conf["name"],
"parents": ",".join(
[
name
for step_type, name in step_types.items()
if step_type in trigger_conf["step_type"]
]
),
}
)
@api.constrains("parent_id", "parent_id.step_type", "trigger_type")
def _check_parent_configuration(self):
for record in self:
record._check_configuration()
def _get_record_activity_scheduled_date(self, record, force=False):
if not force and self.trigger_type in [
"mail_open",
"mail_bounce",
"mail_click",
"mail_not_clicked",
"mail_reply",
"mail_not_reply",
"activity_done",
"activity_cancel",
]:
return False
if (
self.trigger_date_kind == "date"
and self.trigger_date_field_id
and record[self.trigger_date_field_id.name]
):
date = fields.Datetime.to_datetime(record[self.trigger_date_field_id.name])
else:
date = fields.Datetime.now()
return date + relativedelta(
**{self.trigger_interval_type: self.trigger_interval}
)
def _get_expiry_date(self):
if not self.expiry:
return False
return fields.Datetime.now() + relativedelta(
**{self.expiry_interval_type: self.expiry_interval}
)
def _create_record_activity_vals(self, record, **kwargs):
scheduled_date = self._get_record_activity_scheduled_date(record)
do_not_wait = scheduled_date and scheduled_date < fields.Datetime.now()
return {
"configuration_step_id": self.id,
"do_not_wait": do_not_wait,
"expiry_date": self._get_expiry_date(),
"scheduled_date": scheduled_date,
**kwargs,
}

View file

@ -0,0 +1,24 @@
# Copyright 2024 Dixmit
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
from odoo import api, fields, models
class AutomationFilter(models.Model):
_name = "automation.filter"
_description = "Automation Filter"
name = fields.Char(required=True)
model_id = fields.Many2one(
"ir.model",
domain=[("is_mail_thread", "=", True)],
required=True,
ondelete="cascade",
help="Model where the configuration is applied",
)
model = fields.Char(related="model_id.model")
domain = fields.Char(required=True, default="[]", help="Filter to apply")
@api.onchange("model_id")
def _onchange_model(self):
self.domain = []

View file

@ -0,0 +1,201 @@
# Copyright 2024 Dixmit
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
import logging
from collections import defaultdict
from odoo import _, api, fields, models
_logger = logging.getLogger(__name__)
class AutomationRecord(models.Model):
_name = "automation.record"
_description = "Automation Record"
name = fields.Char(compute="_compute_name")
state = fields.Selection(
[("run", "Running"), ("done", "Done")], compute="_compute_state", store=True
)
configuration_id = fields.Many2one(
"automation.configuration", required=True, readonly=True
)
model = fields.Char(index=True, required=False, readonly=True)
resource_ref = fields.Reference(
selection="_selection_target_model",
compute="_compute_resource_ref",
readonly=True,
)
res_id = fields.Many2oneReference(
string="Record",
index=True,
required=False,
readonly=True,
model_field="model",
copy=False,
)
automation_step_ids = fields.One2many(
"automation.record.step", inverse_name="record_id", readonly=True
)
is_test = fields.Boolean()
is_orphan_record = fields.Boolean(
default=False,
help="Indicates if this record is a placeholder for a missing resource.",
readonly=True,
)
@api.model
def _selection_target_model(self):
return [
(model.model, model.name)
for model in self.env["ir.model"]
.sudo()
.search([("is_mail_thread", "=", True)])
]
@api.depends("automation_step_ids.state")
def _compute_state(self):
for record in self:
record.state = (
"run"
if record.automation_step_ids.filtered(lambda r: r.state == "scheduled")
else "done"
)
@api.depends("model", "res_id")
def _compute_resource_ref(self):
for record in self:
if record.model and record.model in self.env:
record.resource_ref = "%s,%s" % (record.model, record.res_id or 0)
else:
record.resource_ref = None
@api.depends("res_id", "model")
def _compute_name(self):
for record in self:
if not record.is_orphan_record:
record.name = self.env[record.model].browse(record.res_id).display_name
else:
record.name = _("Orphan Record")
@api.model
def _search(
self,
args,
offset=0,
limit=None,
order=None,
count=False,
access_rights_uid=None,
):
ids = super()._search(
args,
offset=offset,
limit=limit,
order=order,
count=False,
access_rights_uid=access_rights_uid,
)
if not ids:
return 0 if count else []
orig_ids = ids
ids = set(ids)
result = []
model_data = defaultdict(lambda: defaultdict(set))
for sub_ids in self._cr.split_for_in_conditions(ids):
self._cr.execute(
"""
SELECT id, res_id, model
FROM "%s"
WHERE id = ANY (%%(ids)s)"""
% self._table,
dict(ids=list(sub_ids)),
)
for eid, res_id, model in self._cr.fetchall():
model_data[model][res_id].add(eid)
for model, targets in model_data.items():
if not self.env[model].check_access_rights("read", False):
continue
res_ids = targets.keys()
recs = self.env[model].browse(res_ids)
missing = recs - recs.exists()
if len(missing) > 0:
for res_id in targets.keys():
if res_id and res_id not in missing.ids:
continue
automation_record = self.env["automation.record"].browse(
list(targets[res_id])
)
if not automation_record.is_orphan_record:
_logger.info(
"Deleted record %s,%s is referenced by automation.record",
model,
res_id,
)
# sudo to avoid access rights check on the record
automation_record.sudo().write(
{
"is_orphan_record": True,
"res_id": False,
}
)
result += list(targets[res_id])
allowed = (
self.env[model]
.with_context(active_test=False)
._search([("id", "in", recs.ids)])
)
for target_id in allowed:
result += list(targets.get(target_id, {}))
if len(orig_ids) == limit and len(result) < len(orig_ids):
result.extend(
self._search(
args,
offset=offset + len(orig_ids),
limit=limit,
order=order,
count=count,
access_rights_uid=access_rights_uid,
)[: limit - len(result)]
)
# Restore original ordering
result = [x for x in orig_ids if x in result]
return len(result) if count else list(result)
def read(self, fields=None, load="_classic_read"):
"""Override to explicitely call check_access_rule, that is not called
by the ORM. It instead directly fetches ir.rules and apply them."""
self.check_access_rule("read")
return super().read(fields=fields, load=load)
def check_access_rule(self, operation):
"""In order to check if we can access a record, we are checking if we can access
the related document"""
super().check_access_rule(operation)
if self.env.is_superuser():
return
default_checker = self.env["mail.thread"].get_automation_access
by_model_rec_ids = defaultdict(set)
by_model_checker = {}
for exc_rec in self.sudo():
by_model_rec_ids[exc_rec.model].add(exc_rec.res_id)
if exc_rec.model not in by_model_checker:
by_model_checker[exc_rec.model] = getattr(
self.env[exc_rec.model], "get_automation_access", default_checker
)
for model, rec_ids in by_model_rec_ids.items():
records = self.env[model].browse(rec_ids).with_user(self._uid)
checker = by_model_checker[model]
for record in records:
check_operation = checker(
[record.id], operation, model_name=record._name
)
record.check_access_rights(check_operation)
record.check_access_rule(check_operation)
def write(self, vals):
self.check_access_rule("write")
return super().write(vals)

View file

@ -0,0 +1,459 @@
# Copyright 2024 Dixmit
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
import json
import threading
import traceback
from io import StringIO
import werkzeug.urls
from dateutil.relativedelta import relativedelta
from odoo import _, api, fields, models, tools
from odoo.tools.safe_eval import safe_eval
class AutomationRecordStep(models.Model):
_name = "automation.record.step"
_description = "Activities done on the record"
_order = "scheduled_date ASC"
name = fields.Char(compute="_compute_step_data", store=True)
record_id = fields.Many2one("automation.record", required=True, ondelete="cascade")
configuration_step_id = fields.Many2one("automation.configuration.step")
configuration_id = fields.Many2one(
"automation.configuration",
compute="_compute_step_data",
store=True,
)
step_type = fields.Selection(
selection=lambda r: r.env["automation.configuration.step"]
._fields["step_type"]
.selection,
compute="_compute_step_data",
store=True,
)
scheduled_date = fields.Datetime(readonly=True)
do_not_wait = fields.Boolean()
expiry_date = fields.Datetime(readonly=True)
processed_on = fields.Datetime(readonly=True)
parent_id = fields.Many2one("automation.record.step", readonly=True)
child_ids = fields.One2many("automation.record.step", inverse_name="parent_id")
trigger_type = fields.Selection(
selection=lambda r: r.env[
"automation.configuration.step"
]._trigger_type_selection(),
compute="_compute_step_data",
store=True,
)
trigger_type_data = fields.Json(compute="_compute_trigger_type_data")
step_icon = fields.Char(compute="_compute_step_info")
step_name = fields.Char(compute="_compute_step_info")
state = fields.Selection(
[
("scheduled", "Scheduled"),
("done", "Done"),
("expired", "Expired"),
("rejected", "Rejected"),
("error", "Error"),
("cancel", "Cancelled"),
],
default="scheduled",
readonly=True,
)
error_trace = fields.Text(readonly=True)
parent_position = fields.Integer(
compute="_compute_parent_position", recursive=True, store=True
)
# Mailing fields
message_id = fields.Char(readonly=True)
mail_status = fields.Selection(
[
("sent", "Sent"),
("open", "Opened"),
("bounce", "Bounced"),
("reply", "Replied"),
],
readonly=True,
)
mail_clicked_on = fields.Datetime(readonly=True)
mail_replied_on = fields.Datetime(readonly=True)
mail_opened_on = fields.Datetime(readonly=True)
activity_done_on = fields.Datetime(readonly=True)
activity_cancel_on = fields.Datetime(readonly=True)
is_test = fields.Boolean(related="record_id.is_test", store=True)
step_actions = fields.Json(compute="_compute_step_actions")
@api.depends("configuration_step_id")
def _compute_step_data(self):
for record in self.filtered(lambda r: r.configuration_step_id):
record.name = record.configuration_step_id.name
record.configuration_id = record.configuration_step_id.configuration_id
record.step_type = record.configuration_step_id.step_type
record.trigger_type = record.configuration_step_id.trigger_type
@api.depends("trigger_type")
def _compute_trigger_type_data(self):
trigger_types = self.env["automation.configuration.step"]._trigger_types()
for record in self:
record.trigger_type_data = trigger_types[record.trigger_type]
@api.depends("parent_id", "parent_id.parent_position")
def _compute_parent_position(self):
for record in self:
record.parent_position = (
(record.parent_id.parent_position + 1) if record.parent_id else 0
)
@api.depends("step_type")
def _compute_step_info(self):
step_icons = self.env["automation.configuration.step"]._step_icons()
step_name_map = dict(
self.env["automation.configuration.step"]._fields["step_type"].selection
)
for record in self:
record.step_icon = step_icons.get(record.step_type, "")
record.step_name = step_name_map.get(record.step_type, "")
def _check_to_execute(self):
if (
self.configuration_step_id.trigger_type == "mail_not_open"
and self.parent_id.mail_status in ["open", "reply"]
):
return False
if (
self.configuration_step_id.trigger_type == "mail_not_reply"
and self.parent_id.mail_status == "reply"
):
return False
if (
self.configuration_step_id.trigger_type == "mail_not_clicked"
and self.parent_id.mail_clicked_on
):
return False
if (
self.configuration_step_id.trigger_type == "activity_not_done"
and self.parent_id.activity_done_on
):
return False
return True
def run(self, trigger_activity=True):
self.ensure_one()
if self.state != "scheduled":
return self.browse()
if (
self.record_id.resource_ref is None
or not self.configuration_step_id
or not self.record_id.resource_ref.filtered_domain(
safe_eval(
self.configuration_step_id.applied_domain,
self.configuration_step_id.configuration_id._get_eval_context(),
)
)
or not self._check_to_execute()
):
self._reject()
return self.browse()
try:
result = getattr(self, "_run_%s" % self.configuration_step_id.step_type)()
self.write({"state": "done", "processed_on": fields.Datetime.now()})
if result:
childs = self._fill_childs()
if trigger_activity:
childs._trigger_activities()
return childs
except Exception:
buff = StringIO()
traceback.print_exc(file=buff)
traceback_txt = buff.getvalue()
self.write(
{
"state": "error",
"error_trace": traceback_txt,
"processed_on": fields.Datetime.now(),
}
)
return self.browse()
def _reject(self):
self.write({"state": "rejected", "processed_on": fields.Datetime.now()})
def _fill_childs(self, **kwargs):
return self.create(
[
activity._create_record_activity_vals(
self.record_id.resource_ref,
parent_id=self.id,
record_id=self.record_id.id,
**kwargs,
)
for activity in self.configuration_step_id.child_ids
]
)
def _run_activity(self):
record = self.env[self.record_id.model].browse(self.record_id.res_id)
vals = {
"summary": self.configuration_step_id.activity_summary or "",
"note": self.configuration_step_id.activity_note or "",
"activity_type_id": self.configuration_step_id.activity_type_id.id,
"automation_record_step_id": self.id,
}
if self.configuration_step_id.activity_date_deadline_range > 0:
range_type = self.configuration_step_id.activity_date_deadline_range_type
vals["date_deadline"] = fields.Date.context_today(self) + relativedelta(
**{range_type: self.configuration_step_id.activity_date_deadline_range}
)
user = False
if self.configuration_step_id.activity_user_type == "specific":
user = self.configuration_step_id.activity_user_id
elif self.configuration_step_id.activity_user_type == "generic":
user = record[self.configuration_step_id.activity_user_field_id.name]
if user:
vals["user_id"] = user.id
record.activity_schedule(**vals)
return True
def _run_mail(self):
author_id = self.configuration_step_id.mail_author_id.id
composer_values = {
"author_id": author_id,
"record_name": False,
"model": self.record_id.model,
"composition_mode": "mass_mail",
"template_id": self.configuration_step_id.mail_template_id.id,
"automation_record_step_id": self.id,
}
res_ids = [self.record_id.res_id]
composer = (
self.env["mail.compose.message"]
.with_context(active_ids=res_ids)
.create(composer_values)
)
composer.write(
composer._onchange_template_id(
self.configuration_step_id.mail_template_id.id,
"mass_mail",
self.record_id.model,
self.record_id.res_id,
)["value"]
)
# composer.body =
extra_context = self._run_mail_context()
composer = composer.with_context(active_ids=res_ids, **extra_context)
# auto-commit except in testing mode
auto_commit = not getattr(threading.current_thread(), "testing", False)
if not self.is_test:
# We just abort the sending, but we want to check how the generation works
composer._action_send_mail(auto_commit=auto_commit)
self.mail_status = "sent"
return True
def _get_mail_tracking_token(self):
return tools.hmac(self.env(su=True), "automation_oca", self.id)
def _get_mail_tracking_url(self):
return werkzeug.urls.url_join(
self.get_base_url(),
"automation_oca/track/%s/%s/blank.gif"
% (self.id, self._get_mail_tracking_token()),
)
def _run_mail_context(self):
return {}
def _run_action(self):
context = {}
if self.configuration_step_id.server_context:
context.update(json.loads(self.configuration_step_id.server_context))
self.configuration_step_id.server_action_id.with_context(
**context,
active_model=self.record_id.model,
active_ids=[self.record_id.res_id],
).run()
return True
def _cron_automation_steps(self):
childs = self.browse()
for activity in self.search(
[
("state", "=", "scheduled"),
("scheduled_date", "<=", fields.Datetime.now()),
]
):
childs |= activity.run(trigger_activity=False)
childs._trigger_activities()
self.search(
[
("state", "=", "scheduled"),
("expiry_date", "!=", False),
("expiry_date", "<=", fields.Datetime.now()),
]
)._expiry()
def _trigger_activities(self):
# Creates a cron trigger.
# On glue modules we could use queue job for a more discrete example
# But cron trigger fulfills the job in some way
for activity in self.filtered(lambda r: r.do_not_wait):
activity.run()
for date in set(
self.filtered(lambda r: not r.do_not_wait).mapped("scheduled_date")
):
if date:
self.env["ir.cron.trigger"].create(
{
"call_at": date,
"cron_id": self.env.ref("automation_oca.cron_step_execute").id,
}
)
def _expiry(self):
self.write({"state": "expired", "processed_on": fields.Datetime.now()})
def cancel(self):
self.filtered(lambda r: r.state == "scheduled").write(
{"state": "cancel", "processed_on": fields.Datetime.now()}
)
def _activate(self):
todo = self.filtered(lambda r: not r.scheduled_date)
current_date = fields.Datetime.now()
for record in todo:
config = record.configuration_step_id
scheduled_date = config._get_record_activity_scheduled_date(
record.record_id.resource_ref, force=True
)
record.write(
{
"scheduled_date": scheduled_date,
"do_not_wait": scheduled_date < current_date,
}
)
todo._trigger_activities()
def _set_activity_done(self):
self.write({"activity_done_on": fields.Datetime.now()})
self.child_ids.filtered(
lambda r: r.trigger_type == "activity_done"
and not r.scheduled_date
and r.state == "scheduled"
)._activate()
self.child_ids.filtered(
lambda r: r.trigger_type == "activity_cancel"
and not r.scheduled_date
and r.state == "scheduled"
)._reject()
def _set_activity_cancel(self):
self.write({"activity_cancel_on": fields.Datetime.now()})
self.child_ids.filtered(
lambda r: r.trigger_type == "activity_cancel"
and not r.scheduled_date
and r.state == "scheduled"
)._activate()
self.child_ids.filtered(
lambda r: r.trigger_type == "activity_done"
and not r.scheduled_date
and r.state == "scheduled"
)._reject()
def _set_mail_bounced(self):
self.write({"mail_status": "bounce"})
self.child_ids.filtered(
lambda r: r.trigger_type == "mail_bounce"
and not r.scheduled_date
and r.state == "scheduled"
)._activate()
def _set_mail_open(self):
self.filtered(lambda t: t.mail_status not in ["open", "reply"]).write(
{"mail_status": "open", "mail_opened_on": fields.Datetime.now()}
)
self.child_ids.filtered(
lambda r: r.trigger_type
in ["mail_open", "mail_not_reply", "mail_not_clicked"]
and not r.scheduled_date
and r.state == "scheduled"
)._activate()
def _set_mail_clicked(self):
self.filtered(lambda t: not t.mail_clicked_on).write(
{"mail_clicked_on": fields.Datetime.now()}
)
self.child_ids.filtered(
lambda r: r.trigger_type == "mail_click"
and not r.scheduled_date
and r.state == "scheduled"
)._activate()
def _set_mail_reply(self):
self.filtered(lambda t: t.mail_status != "reply").write(
{"mail_status": "reply", "mail_replied_on": fields.Datetime.now()}
)
self.child_ids.filtered(
lambda r: r.trigger_type == "mail_reply"
and not r.scheduled_date
and r.state == "scheduled"
)._activate()
@api.depends("state")
def _compute_step_actions(self):
for record in self:
record.step_actions = record._get_step_actions()
def _get_step_actions(self):
"""
This should return a list of dictionaries that will have the following keys:
- icon: Icon to show (fontawesome icon like fa fa-clock-o)
- name: name of the action to show (translatable value)
- done: if the action succeeded (boolean)
- color: Color to show when done (text-success, text-danger...)
"""
if self.step_type == "activity":
return [
{
"icon": "fa fa-clock-o",
"name": _("Activity Done"),
"done": bool(self.activity_done_on),
"color": "text-success",
}
]
if self.step_type == "mail":
return [
{
"icon": "fa fa-envelope",
"name": _("Sent"),
"done": bool(self.mail_status and self.mail_status != "bounced"),
"color": "text-success",
},
{
"icon": "fa fa-envelope-open-o",
"name": _("Opened"),
"done": bool(
self.mail_status and self.mail_status in ["reply", "open"]
),
"color": "text-success",
},
{
"icon": "fa fa-hand-pointer-o",
"name": _("Clicked"),
"done": bool(self.mail_status and self.mail_clicked_on),
"color": "text-success",
},
{
"icon": "fa fa-reply",
"name": _("Replied"),
"done": bool(self.mail_status and self.mail_status == "reply"),
"color": "text-success",
},
{
"icon": "fa fa-exclamation-circle",
"name": _("Bounced"),
"done": bool(self.mail_status and self.mail_status == "bounce"),
"color": "text-danger",
},
]
return []

View file

@ -0,0 +1,20 @@
# Copyright 2024 Dixmit
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
from random import randint
from odoo import api, fields, models
class AutomationTag(models.Model):
_name = "automation.tag"
_description = "Automation Tag"
@api.model
def _get_default_color(self):
return randint(1, 11)
name = fields.Char(required=True)
color = fields.Integer(default=lambda r: r._get_default_color())
active = fields.Boolean(default=True)

View file

@ -0,0 +1,45 @@
# Copyright 2024 Dixmit
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
from odoo import api, fields, models
class LinkTrackerClick(models.Model):
_inherit = "link.tracker.click"
automation_record_step_id = fields.Many2one("automation.record.step")
automation_configuration_step_id = fields.Many2one(
related="automation_record_step_id.configuration_step_id", store=True
)
automation_configuration_id = fields.Many2one(
related="automation_record_step_id.configuration_id", store=True
)
@api.model
def add_click(self, code, automation_record_step_id=False, **route_values):
if automation_record_step_id:
tracker_code = self.env["link.tracker.code"].search([("code", "=", code)])
if not tracker_code:
return None
ip = route_values.get("ip", False)
if self.search_count(
[
(
"automation_record_step_id",
"=",
automation_record_step_id,
),
("link_id", "=", tracker_code.link_id.id),
("ip", "=", ip),
]
):
return None
route_values["link_id"] = tracker_code.link_id.id
click_values = self._prepare_click_values_from_route(
automation_record_step_id=automation_record_step_id, **route_values
)
click = self.create(click_values)
click.automation_record_step_id._set_mail_open()
click.automation_record_step_id._set_mail_clicked()
return click
return super().add_click(code, **route_values)

View file

@ -0,0 +1,27 @@
# Copyright 2024 Dixmit
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
from odoo import fields, models
class MailActivity(models.Model):
_inherit = "mail.activity"
automation_record_step_id = fields.Many2one("automation.record.step")
def _action_done(self, *args, **kwargs):
if self.automation_record_step_id:
self.automation_record_step_id.sudo()._set_activity_done()
return super(
MailActivity,
self.with_context(
automation_done=True,
),
)._action_done(*args, **kwargs)
def unlink(self):
if self.automation_record_step_id and not self.env.context.get(
"automation_done"
):
self.automation_record_step_id.sudo()._set_activity_cancel()
return super(MailActivity, self).unlink()

View file

@ -0,0 +1,51 @@
# Copyright 2024 Dixmit
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
import re
import markupsafe
import werkzeug.urls
from odoo import api, fields, models, tools
class MailMail(models.Model):
_inherit = "mail.mail"
automation_record_step_id = fields.Many2one("automation.record.step")
@api.model_create_multi
def create(self, values_list):
records = super().create(values_list)
for record in records.filtered("automation_record_step_id"):
record.automation_record_step_id.message_id = record.message_id
return records
def _send_prepare_body(self):
body = super()._send_prepare_body()
if self.automation_record_step_id:
body = self.env["mail.render.mixin"]._shorten_links(body, {}, blacklist=[])
token = self.automation_record_step_id._get_mail_tracking_token()
for match in set(re.findall(tools.URL_REGEX, body)):
href = match[0]
url = match[1]
parsed = werkzeug.urls.url_parse(url, scheme="http")
if parsed.scheme.startswith("http") and parsed.path.startswith("/r/"):
new_href = href.replace(
url,
"%s/au/%s/%s"
% (url, str(self.automation_record_step_id.id), token),
)
body = body.replace(
markupsafe.Markup(href), markupsafe.Markup(new_href)
)
body = tools.append_content_to_html(
body,
'<img src="%s"/>'
% self.automation_record_step_id._get_mail_tracking_url(),
plaintext=False,
)
return body

View file

@ -0,0 +1,70 @@
# Copyright 2024 Dixmit
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
from odoo import api, models, tools
class MailThread(models.AbstractModel):
_inherit = "mail.thread"
@api.model
def _routing_handle_bounce(self, email_message, message_dict):
"""We want to mark the bounced email"""
result = super(MailThread, self)._routing_handle_bounce(
email_message, message_dict
)
bounced_msg_id = message_dict.get("bounced_msg_id")
if bounced_msg_id:
self.env["automation.record.step"].search(
[("message_id", "in", bounced_msg_id)]
)._set_mail_bounced()
return result
@api.model
def _message_route_process(self, message, message_dict, routes):
"""Override to update the parent mailing traces. The parent is found
by using the References header of the incoming message and looking for
matching message_id in automation.record.step."""
if routes:
thread_references = (
message_dict["references"] or message_dict["in_reply_to"]
)
msg_references = tools.mail_header_msgid_re.findall(thread_references)
if msg_references:
records = self.env["automation.record.step"].search(
[("message_id", "in", msg_references)]
)
records._set_mail_open()
records._set_mail_reply()
return super(MailThread, self)._message_route_process(
message, message_dict, routes
)
@api.model
def get_automation_access(self, doc_ids, operation, model_name=False):
"""Retrieve access policy.
The behavior is similar to `mail.thread` and `mail.message`
and it relies on the access rules defines on the related record.
The behavior can be customized on the related model
by defining `_automation_record_access`.
By default `write`, otherwise the custom permission is returned.
"""
DocModel = self.env[model_name] if model_name else self
create_allow = getattr(DocModel, "_automation_record_access", "write")
if operation in ["write", "unlink"]:
check_operation = "write"
elif operation == "create" and create_allow in [
"create",
"read",
"write",
"unlink",
]:
check_operation = create_allow
elif operation == "create":
check_operation = "write"
else:
check_operation = operation
return check_operation