mirror of
https://github.com/bringout/oca-technical.git
synced 2026-04-23 20:12:05 +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
|
|
@ -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
|
||||
Loading…
Add table
Add a link
Reference in a new issue