Files

1055 lines
36 KiB
Python
Raw Permalink Normal View History

2019-03-30 17:36:08 +01:00
# This file is part of Tryton. The COPYRIGHT file at the top level of
# this repository contains the full copyright notices and license terms.
import datetime
2020-01-20 22:27:31 +01:00
import logging
2019-03-30 17:36:08 +01:00
import time
import uuid
from collections import defaultdict
2024-01-25 16:04:05 +01:00
from email.message import EmailMessage
from functools import partial
2019-03-30 17:36:08 +01:00
from urllib.parse import (
2021-12-11 17:58:42 +01:00
parse_qsl, quote, urlencode, urljoin, urlsplit, urlunsplit)
2019-03-30 17:36:08 +01:00
from dateutil.relativedelta import relativedelta
2021-12-11 17:58:42 +01:00
from genshi.core import END, START, Attrs, QName
from genshi.template import MarkupTemplate
from genshi.template import TemplateError as GenshiTemplateError
from genshi.template import TextTemplate
2019-03-30 17:36:08 +01:00
from sql.aggregate import Count
2025-10-07 08:19:57 +02:00
import trytond.config as config
2019-03-30 17:36:08 +01:00
from trytond.i18n import gettext
2021-12-11 17:58:42 +01:00
from trytond.model import (
EvalEnvironment, Index, ModelSQL, ModelView, Unique, Workflow, dualmethod,
fields)
2019-03-30 17:36:08 +01:00
from trytond.pool import Pool
2022-11-24 19:16:51 +01:00
from trytond.pyson import Eval, If, PYSONDecoder, TimeDelta
2025-12-11 15:20:11 +01:00
from trytond.report import Report, html_to_text, mjml_to_html
2024-02-21 21:38:53 +01:00
from trytond.sendmail import SMTPDataManager, send_message_transactional
2026-03-22 21:05:26 +01:00
from trytond.tools import pairwise_longest
2024-09-29 11:46:53 +02:00
from trytond.tools.chart import sparkline
2024-02-21 21:43:50 +01:00
from trytond.tools.email_ import format_address, has_rcpt, set_from_header
2019-03-30 17:36:08 +01:00
from trytond.transaction import Transaction
2023-11-12 23:44:43 +01:00
from trytond.url import http_base
2019-03-30 17:36:08 +01:00
from trytond.wsgi import Base64Converter
try:
from trytond.model import ModelAccessProxy
except ImportError:
ModelAccessProxy = None
2021-12-11 17:58:42 +01:00
from .exceptions import ConditionError, DomainError, TemplateError
2019-03-30 17:36:08 +01:00
from .mixin import MarketingAutomationMixin
2020-01-20 22:27:31 +01:00
logger = logging.getLogger(__name__)
2019-03-30 17:36:08 +01:00
def trend_mixin(model_name, field_name):
class TrendMixin:
__slots__ = ()
def get_trend(self, name):
name = name[:-len('_trend')]
2024-09-29 11:46:53 +02:00
return sparkline([
getattr(ts, name, 0) or 0 for ts in self.trends])
@property
def trends(self):
pool = Pool()
Model = pool.get(model_name)
Date = pool.get('ir.date')
delta = self._trend_period_delta()
date = Date.today()
date -= 10 * delta
records = Model.search([
('date', '>=', date),
(field_name, '=', self.id),
],
order=[('date', 'ASC')])
for record, next_record in pairwise_longest(records):
yield record
if delta and next_record:
date = record.date + delta
while date < next_record.date:
yield None
date += delta
@classmethod
def _trend_period_delta(cls):
context = Transaction().context
return {
'year': relativedelta(years=1),
'month': relativedelta(months=1),
'day': relativedelta(days=1),
}.get(context.get('period', 'month'))
return TrendMixin
class Scenario(
trend_mixin('marketing.automation.reporting.scenario', 'scenario'),
Workflow, ModelSQL, ModelView):
2019-03-30 17:36:08 +01:00
__name__ = 'marketing.automation.scenario'
name = fields.Char("Name", translate=True)
2019-03-30 17:36:08 +01:00
model = fields.Selection('get_models', "Model", required=True)
domain = fields.Char(
"Domain", required=True,
help="A PYSON domain used to filter records valid for this scenario.")
activities = fields.One2Many(
'marketing.automation.activity', 'parent', "Activities")
record_count = fields.Function(
fields.Integer("Records"), 'get_record_count')
record_count_blocked = fields.Function(
fields.Integer("Records Blocked"), 'get_record_count')
block_rate = fields.Function(
fields.Float("Block Rate"),
'get_rate')
block_rate_trend = fields.Function(
fields.Char("Block Rate Trend"),
'get_trend')
unsubscribable = fields.Boolean(
"Unsubscribable",
help="If checked parties are also unsubscribed from the scenario.")
2019-03-30 17:36:08 +01:00
state = fields.Selection([
('draft', "Draft"),
('running', "Running"),
('stopped', "Stopped"),
2022-02-15 00:08:47 +01:00
], "State", required=True, readonly=True, sort=False)
2019-03-30 17:36:08 +01:00
@classmethod
def __setup__(cls):
super().__setup__()
t = cls.__table__()
cls._sql_indexes.add(
Index(
2024-07-27 18:36:14 +02:00
t,
(t.state, Index.Equality(cardinality='low')),
where=t.state.in_(['draft', 'running'])))
2019-03-30 17:36:08 +01:00
cls._transitions |= set((
('draft', 'running'),
('running', 'stopped'),
('stopped', 'draft'),
))
cls._buttons.update(
draft={
'invisible': Eval('state') != 'stopped',
'depends': ['state'],
},
run={
'invisible': Eval('state') != 'draft',
'depends': ['state'],
},
stop={
'invisible': Eval('state') != 'running',
},
)
@classmethod
def default_state(cls):
return 'draft'
@classmethod
def default_domain(cls):
return '[]'
@classmethod
def default_unsubscribable(cls):
return False
2019-03-30 17:36:08 +01:00
@classmethod
def get_models(cls):
pool = Pool()
Model = pool.get('ir.model')
2020-12-27 00:19:30 +01:00
get_name = Model.get_name
models = (name for name, klass in pool.iterobject()
if issubclass(klass, MarketingAutomationMixin))
return [(m, get_name(m)) for m in models]
2019-03-30 17:36:08 +01:00
@classmethod
def get_record_count(cls, scenarios, names):
pool = Pool()
Record = pool.get('marketing.automation.record')
record = Record.__table__()
cursor = Transaction().connection.cursor()
drafts = []
others = []
for scenario in scenarios:
if scenario.state == 'draft':
drafts.append(scenario)
else:
others.append(scenario)
count = {name: defaultdict(int) for name in names}
2026-03-22 21:05:26 +01:00
cursor.execute(*record.select(
record.scenario,
Count(),
Count(filter_=record.blocked),
where=fields.SQL_OPERATORS['in'](
record.scenario, map(int, others)),
group_by=record.scenario))
for id_, all_, blocked in cursor:
if 'record_count' in count:
count['record_count'][id_] = all_
if 'record_count_blocked' in count:
count['record_count_blocked'][id_] = blocked
2019-03-30 17:36:08 +01:00
for scenario in drafts:
Model = pool.get(scenario.model)
domain = PYSONDecoder({}).decode(scenario.domain)
2020-01-20 22:27:31 +01:00
try:
count['record_count'][scenario.id] = Model.search(
domain, count=True)
except Exception:
pass
2019-03-30 17:36:08 +01:00
return count
@classmethod
def get_rate(cls, scenarios, names):
rates = {name: defaultdict(float) for name in names}
for scenario in scenarios:
if 'block_rate' in names and scenario.record_count:
rates['block_rate'][scenario.id] = round(
scenario.record_count_blocked / scenario.record_count, 2)
return rates
2019-03-30 17:36:08 +01:00
@classmethod
2022-04-11 23:24:21 +02:00
def validate_fields(cls, scenarios, field_names):
super().validate_fields(scenarios, field_names)
2019-03-30 17:36:08 +01:00
cls.check_domain(scenarios)
@classmethod
2022-04-11 23:24:21 +02:00
def check_domain(cls, scenarios, field_names=None):
2020-01-20 22:27:31 +01:00
pool = Pool()
2022-04-11 23:24:21 +02:00
if field_names and not (field_names & {'model', 'domain'}):
return
2019-03-30 17:36:08 +01:00
for scenario in scenarios:
2020-01-20 22:27:31 +01:00
Model = pool.get(scenario.model)
2019-03-30 17:36:08 +01:00
try:
value = PYSONDecoder({}).decode(scenario.domain)
fields.domain_validate(value)
2020-01-20 22:27:31 +01:00
Model.search(value, limit=0)
2019-03-30 17:36:08 +01:00
except Exception as exception:
raise DomainError(
gettext('marketing_automation.msg_scenario_invalid_domain',
scenario=scenario.rec_name,
exception=exception)) from exception
@classmethod
@ModelView.button
@Workflow.transition('draft')
def draft(cls, scenarios):
pass
@classmethod
@ModelView.button
@Workflow.transition('running')
def run(cls, scenarios):
pass
@classmethod
@ModelView.button
@Workflow.transition('stopped')
def stop(cls, scenarios):
pass
@classmethod
def trigger(cls, scenarios=None):
pool = Pool()
Record = pool.get('marketing.automation.record')
RecordActivity = pool.get('marketing.automation.record.activity')
if scenarios is None:
scenarios = cls.search([
('state', '=', 'running'),
])
for scenario in scenarios:
Model = pool.get(scenario.model)
record = Record.__table__()
cursor = Transaction().connection.cursor()
domain = PYSONDecoder({}).decode(scenario.domain)
domain = [
domain,
('marketing_party.marketing_scenario_unsubscribed',
'not where', [('id', '=', scenario.id)]),
]
2020-01-20 22:27:31 +01:00
try:
query = Model.search(domain, query=True, order=[])
except Exception:
logger.error(
"Error when triggering scenario %d", scenario.id,
exc_info=True)
continue
2019-03-30 17:36:08 +01:00
cursor.execute(*(
2020-01-20 22:27:31 +01:00
query - record.select(
Record.record.sql_id(record.record, Model),
2019-03-30 17:36:08 +01:00
where=record.scenario == scenario.id)))
records = []
for id_, in cursor:
2019-03-30 17:36:08 +01:00
records.append(
Record(scenario=scenario, record=Model(id_)))
if not records:
continue
Record.save(records)
record_activities = []
for record in records:
for activity in scenario.activities:
if (activity.condition
2020-09-16 23:21:37 +02:00
and not record.eval(activity.condition)):
2019-03-30 17:36:08 +01:00
continue
record_activities.append(
RecordActivity.get(record, activity))
RecordActivity.save(record_activities)
class Activity(
trend_mixin('marketing.automation.reporting.activity', 'activity'),
ModelSQL, ModelView):
2019-03-30 17:36:08 +01:00
__name__ = 'marketing.automation.activity'
name = fields.Char("Name", translate=True, required=True)
2019-03-30 17:36:08 +01:00
parent = fields.Reference(
"Parent", [
('marketing.automation.scenario', "Scenario"),
('marketing.automation.activity', "Activity"),
],
required=True)
children = fields.One2Many(
'marketing.automation.activity', 'parent', "Children")
parent_action = fields.Function(
fields.Selection('get_parent_actions', "Parent Action"),
'on_change_with_parent_action')
event = fields.Selection([
(None, ""),
2024-08-22 09:52:06 +02:00
('email_opened', "Email Opened"),
('email_clicked', "Email Clicked"),
2019-03-30 17:36:08 +01:00
], "Event") # domain set by _parent_action_events
negative = fields.Boolean("Negative",
states={
'invisible': ~Eval('event'),
},
help="Check to execute the activity "
"if the event has not happened by the end of the delay.")
on = fields.Function(fields.Selection([
(None, ""),
2024-08-22 09:52:06 +02:00
('email_opened', "Email Opened"),
('email_opened_not', "Email Not Opened"),
('email_clicked', "Email Clicked"),
('email_clicked_not', "Email Not Clicked"),
2019-03-30 17:36:08 +01:00
], "On"), # domain set by _parent_action_events
'get_on', setter='set_on')
condition = fields.Char("Condition",
help="The PYSON statement that the record must match "
"in order to execute the activity.\n"
'The record is represented by "self".')
2019-03-30 17:36:08 +01:00
delay = fields.TimeDelta(
"Delay",
2022-11-24 23:04:58 +01:00
domain=['OR',
2022-11-24 19:16:51 +01:00
('delay', '=', None),
('delay', '>=', TimeDelta()),
],
2019-03-30 17:36:08 +01:00
states={
'required': Eval('negative', False),
},
help="After how much time the action should be executed.")
action = fields.Selection([
(None, ''),
2024-08-22 09:52:06 +02:00
('send_email', "Send email"),
2019-03-30 17:36:08 +01:00
], "Action")
2024-08-22 09:52:06 +02:00
# Send email
email_from = fields.Char("From", translate=True,
2019-03-30 17:36:08 +01:00
states={
'invisible': Eval('action') != 'send_email',
},
help="Leave empty to use the value defined in the configuration file.")
email_title = fields.Char(
2024-08-22 09:52:06 +02:00
"Email Title",
2019-03-30 17:36:08 +01:00
translate=True,
states={
'invisible': Eval('action') != 'send_email',
'required': Eval('action') == 'send_email',
},
2020-07-04 20:07:31 +02:00
help="The subject of the email.\n"
"The Genshi syntax can be used "
"with 'record' in the evaluation context.")
2019-03-30 17:36:08 +01:00
email_template = fields.Text(
2024-08-22 09:52:06 +02:00
"Email Template",
2019-03-30 17:36:08 +01:00
translate=True,
states={
'invisible': Eval('action') != 'send_email',
'required': Eval('action') == 'send_email',
},
2024-08-22 09:52:06 +02:00
help="The HTML content of the email.\n"
2019-03-30 17:36:08 +01:00
"The Genshi syntax can be used "
"with 'record' in the evaluation context.")
record_count = fields.Function(
fields.Integer("Records"), 'get_record_count')
email_opened = fields.Function(
fields.Integer(
2024-08-22 09:52:06 +02:00
"Emails Opened",
2019-03-30 17:36:08 +01:00
states={
'invisible': Eval('action') != 'send_email',
}), 'get_record_count')
2019-03-30 17:36:08 +01:00
email_clicked = fields.Function(
fields.Integer(
2024-08-22 09:52:06 +02:00
"Emails Clicked",
2019-03-30 17:36:08 +01:00
states={
'invisible': Eval('action') != 'send_email',
}),
'get_record_count')
2019-03-30 17:36:08 +01:00
email_open_rate = fields.Function(
fields.Float(
2024-08-22 09:52:06 +02:00
"Email Open Rate",
states={
'invisible': Eval('action') != 'send_email',
}),
'get_rate')
email_open_rate_trend = fields.Function(
fields.Char(
2024-08-22 09:52:06 +02:00
"Email Open Rate Trend",
states={
'invisible': Eval('action') != 'send_email',
}),
'get_trend')
email_click_rate = fields.Function(
fields.Float(
2024-08-22 09:52:06 +02:00
"Email Click Rate",
states={
'invisible': Eval('action') != 'send_email',
}),
'get_rate')
email_click_rate_trend = fields.Function(
fields.Char(
2024-08-22 09:52:06 +02:00
"Email Click Rate Trend",
states={
'invisible': Eval('action') != 'send_email',
}),
'get_trend')
email_click_through_rate = fields.Function(
fields.Float(
2024-08-22 09:52:06 +02:00
"Email Click-Through Rate",
states={
'invisible': Eval('action') != 'send_email',
}),
'get_rate')
email_click_through_rate_trend = fields.Function(
fields.Char(
2024-08-22 09:52:06 +02:00
"Email Click-Through Rate Trend",
states={
'invisible': Eval('action') != 'send_email',
}),
'get_trend')
2019-03-30 17:36:08 +01:00
@classmethod
def __setup__(cls):
super().__setup__()
for name in ['event', 'on']:
field = getattr(cls, name)
domain = [(name, '=', None)]
for parent_action, events in cls._parent_action_events().items():
if name == 'on':
events += [e + '_not' for e in events]
domain = If(Eval('parent_action') == parent_action,
[(name, 'in', events + [None])],
domain)
field.domain = [domain]
field.depends.add('parent_action')
2019-03-30 17:36:08 +01:00
@classmethod
def view_attributes(cls):
2021-04-09 10:52:03 +02:00
return super().view_attributes() + [
2019-03-30 17:36:08 +01:00
('//group[@id="email"]', 'states', {
'invisible': Eval('action') != 'send_email',
}),
]
@classmethod
def get_parent_actions(cls):
return cls.fields_get(['action'])['action']['selection']
@fields.depends('parent')
def on_change_with_parent_action(self, name=None):
if isinstance(self.parent, self.__class__):
return self.parent.action
return None
@classmethod
def _parent_action_events(cls):
"Return dictionary to pair parent action and valid events"
return {
'send_email': ['email_opened', 'email_clicked'],
}
def get_on(self, name):
value = self.event
if self.negative and value:
value += '_not'
return value
@fields.depends('on', 'event', 'negative')
def on_change_on(self):
if not self.on:
self.negative = False
self.event = None
else:
self.negative = self.on.endswith('_not')
self.event = self.on[:-len('_not')] if self.negative else self.on
@classmethod
def set_on(cls, activities, name, value):
if not value:
negative = False
event = None
else:
negative = value.endswith('_not')
event = value[:-len('_not')] if negative else value
cls.write(activities, {
'event': event,
'negative': negative,
})
@classmethod
def get_record_count(cls, activities, names):
pool = Pool()
RecordActivity = pool.get('marketing.automation.record.activity')
record_activity = RecordActivity.__table__()
cursor = Transaction().connection.cursor()
count = {name: defaultdict(int) for name in names}
2026-03-22 21:05:26 +01:00
cursor.execute(*record_activity.select(
record_activity.activity,
Count(filter_=record_activity.state == 'done'),
Count(filter_=record_activity.email_opened),
Count(filter_=record_activity.email_clicked),
where=fields.SQL_OPERATORS['in'](
record_activity.activity, map(int, activities)),
group_by=record_activity.activity))
for id_, all_, email_opened, email_clicked in cursor:
if 'record_count' in count:
count['record_count'][id_] = all_
if 'email_opened' in count:
count['email_opened'][id_] = email_opened
if 'email_clicked' in count:
count['email_clicked'][id_] = email_clicked
2019-03-30 17:36:08 +01:00
return count
@classmethod
def get_rate(cls, activities, names):
rates = {name: defaultdict(float) for name in names}
for activity in activities:
if 'email_open_rate' in names and activity.record_count:
rates['email_open_rate'][activity.id] = round(
activity.email_opened / activity.record_count, 2)
if 'email_click_rate' in names and activity.record_count:
rates['email_click_rate'][activity.id] = round(
activity.email_clicked / activity.record_count, 2)
if 'email_click_through_rate' in names and activity.email_opened:
rates['email_click_through_rate'][activity.id] = round(
activity.email_clicked / activity.email_opened, 2)
return rates
2019-03-30 17:36:08 +01:00
@classmethod
2022-04-11 23:24:21 +02:00
def validate_fields(cls, activities, fields_names):
super().validate_fields(activities, fields_names)
2019-03-30 17:36:08 +01:00
for activity in activities:
2022-04-11 23:24:21 +02:00
activity.check_condition(fields_names)
activity.check_email_title(fields_names)
activity.check_email_template(fields_names)
2019-03-30 17:36:08 +01:00
2022-04-11 23:24:21 +02:00
def check_condition(self, fields_names=None):
if fields_names and 'condition' not in fields_names:
return
2019-03-30 17:36:08 +01:00
if not self.condition:
return
try:
PYSONDecoder(noeval=True).decode(self.condition)
except Exception as exception:
raise ConditionError(
gettext('marketing_automation.msg_activity_invalid_condition',
condition=self.condition,
activity=self.rec_name,
exception=exception)) from exception
2022-04-11 23:24:21 +02:00
def check_email_template(self, fields_names=None):
if fields_names and 'email_template' not in fields_names:
return
2019-03-30 17:36:08 +01:00
if not self.email_template:
return
try:
MarkupTemplate(self.email_template)
except GenshiTemplateError as exception:
2019-03-30 17:36:08 +01:00
raise TemplateError(
gettext('marketing_automation'
'.msg_activity_invalid_email_template',
activity=self.rec_name,
exception=exception)) from exception
2022-04-11 23:24:21 +02:00
def check_email_title(self, fields_names=None):
if fields_names and 'email_title' not in fields_names:
return
2020-07-04 20:07:31 +02:00
if not self.email_title:
return
try:
TextTemplate(self.email_title)
except GenshiTemplateError as exception:
2020-07-04 20:07:31 +02:00
raise TemplateError(
gettext('marketing_automation'
'.msg_activity_invalid_email_title',
activity=self.rec_name,
exception=exception)) from exception
def execute(self, record_activity, **kwargs):
2019-03-30 17:36:08 +01:00
pool = Pool()
RecordActivity = pool.get('marketing.automation.record.activity')
record = record_activity.record
2019-03-30 17:36:08 +01:00
2020-11-16 21:22:13 +01:00
# As it is a reference, the record may have been deleted
if not record.record:
2020-11-16 21:22:13 +01:00
return
2019-03-30 17:36:08 +01:00
# XXX: use domain
if self.condition and not record.eval(self.condition):
return
if self.action:
getattr(self, 'execute_' + self.action)(record_activity, **kwargs)
2019-03-30 17:36:08 +01:00
RecordActivity.save([
RecordActivity.get(record, child)
for child in self.children])
def _email_recipient(self, record):
party = record.marketing_party
contact = party.contact_mechanism_get('email')
if contact and contact.email:
2024-02-21 21:43:50 +01:00
return format_address(
contact.email, contact.name or party.rec_name)
2019-03-30 17:36:08 +01:00
def execute_send_email(
self, record_activity, smtpd_datamanager=None, **kwargs):
pool = Pool()
WebShortener = pool.get('web.shortened_url')
2023-02-09 18:50:31 +01:00
Email = pool.get('ir.email')
2019-03-30 17:36:08 +01:00
record = record_activity.record
2025-10-07 08:19:57 +02:00
url_base = config.get(
2023-11-12 23:44:43 +01:00
'marketing', 'automation_base', default=http_base())
2025-10-07 08:19:57 +02:00
url_open = urljoin(url_base, '/m/empty.gif')
2019-03-30 17:36:08 +01:00
with Transaction().set_context(language=record.language):
record = record.__class__(record.id)
translated = self.__class__(self.id)
to = self._email_recipient(record.record)
def unsubscribe(redirect):
parts = urlsplit(urljoin(
2025-10-07 08:19:57 +02:00
url_base, quote('/m/%(database)s/unsubscribe' % {
2019-03-31 00:01:09 +01:00
'database': Base64Converter(None).to_url(
2021-02-14 15:54:28 +01:00
Transaction().database.name),
2019-03-30 17:36:08 +01:00
})))
query = parse_qsl(parts.query)
query.append(('r', record.uuid))
if redirect:
query.append(('next', redirect))
parts = list(parts)
parts[3] = urlencode(query)
return urlunsplit(parts)
def short(url, event):
url = WebShortener(
record=record_activity,
method='marketing.automation.record.activity|%s' % event,
redirect_url=url)
url.save()
return url.shortened_url
def convert_href(stream):
for kind, data, pos in stream:
if kind is START:
tag, attrs = data
if tag == 'a' and attrs.get('href'):
2019-03-30 17:36:08 +01:00
href = attrs.get('href')
attrs -= 'href'
if href.startswith('unsubscribe'):
href = unsubscribe(href[len('unsubscribe|'):])
else:
href = short(href, 'on_email_clicked')
attrs |= [(QName('href'), href)]
data = tag, attrs
elif kind is END and data == 'body':
yield START, (QName('img'), Attrs([
(QName('src'), short(
2025-10-07 08:19:57 +02:00
url_open, 'on_email_opened')),
2019-03-30 17:36:08 +01:00
(QName('height'), '1'),
(QName('width'), '1'),
])), pos
yield END, QName('img'), pos
yield kind, data, pos
context = self.email_context(record)
context['short'] = partial(short, event='on_email_clicked')
try:
title = (TextTemplate(translated.email_title)
.generate(**context)
.render())
except GenshiTemplateError as exception:
raise TemplateError(
gettext('marketing_automation'
'.msg_activity_invalid_email_title',
activity=self.rec_name,
exception=exception)) from exception
try:
template = MarkupTemplate(translated.email_template)
content = (template
.generate(**context)
.filter(convert_href)
.render())
except GenshiTemplateError as exception:
raise TemplateError(gettext('marketing_automation'
'.msg_activity_invalid_email_template',
activity=self.rec_name,
exception=exception)) from exception
2019-03-30 17:36:08 +01:00
2025-12-11 15:20:11 +01:00
if content.startswith('<mjml'):
content = mjml_to_html(content)
2024-02-21 21:43:50 +01:00
msg = self._email(translated.email_from, to, title, content)
2019-03-30 17:36:08 +01:00
2024-02-21 21:43:50 +01:00
if has_rcpt(msg):
2024-02-21 21:38:53 +01:00
send_message_transactional(msg, datamanager=smtpd_datamanager)
2024-02-21 21:43:50 +01:00
email = Email.from_message(
msg, resource=record.record,
2023-02-09 18:50:31 +01:00
marketing_automation_activity=self,
2024-02-21 21:43:50 +01:00
marketing_automation_record=record)
2023-02-09 18:50:31 +01:00
email.save()
def email_context(self, record):
if ModelAccessProxy:
record = ModelAccessProxy(
record.record, record.record.marketing_access_context)
else:
record = record.record
return {
'record': record,
'format_date': Report.format_date,
'format_datetime': Report.format_datetime,
'format_timedelta': Report.format_timedelta,
'format_currency': Report.format_currency,
'format_number': Report.format_number,
'datetime': datetime,
}
2024-02-21 21:43:50 +01:00
def _email(self, sender, to, subject, content):
msg = EmailMessage()
from_ = (config.get('marketing', 'email_from')
or config.get('email', 'from'))
set_from_header(msg, from_, sender or from_)
msg['To'] = to
msg['Subject'] = subject
2025-12-11 17:57:01 +01:00
if content_text := html_to_text(content):
2024-02-21 21:43:50 +01:00
msg.add_alternative(content_text, subtype='plain')
if msg.is_multipart():
msg.add_alternative(content, subtype='html')
else:
msg.set_content(content, subtype='html')
return msg
2019-03-30 17:36:08 +01:00
class Record(ModelSQL, ModelView):
__name__ = 'marketing.automation.record'
scenario = fields.Many2One(
'marketing.automation.scenario', "Scenario",
required=True, ondelete='CASCADE')
record = fields.Reference(
"Record", selection='get_models', required=True)
blocked = fields.Boolean(
"Blocked",
states={
'readonly': ~Eval('blocked', False),
})
uuid = fields.Char("UUID", readonly=True, strip=False)
2019-03-30 17:36:08 +01:00
@classmethod
def __setup__(cls):
super().__setup__()
t = cls.__table__()
cls._sql_constraints = [
('scenario_record_unique', Unique(t, t.scenario, t.record),
'marketing_automation.msg_record_scenario_unique'),
('uuid_unique', Unique(t, t.uuid),
'marketing_automation.msg_record_uuid_unique'),
]
cls._buttons.update({
'block': {
'invisible': Eval('blocked', False),
},
})
2019-03-30 17:36:08 +01:00
@classmethod
def default_uuid(cls):
return uuid.uuid4().hex
@classmethod
def default_blocked(cls):
return False
@fields.depends('scenario')
def get_models(self):
pool = Pool()
Model = pool.get('ir.model')
Scenario = pool.get('marketing.automation.scenario')
2019-03-30 17:36:08 +01:00
if not self.scenario:
return Scenario.get_models()
2019-03-30 17:36:08 +01:00
2020-12-27 00:19:30 +01:00
model = self.scenario.model
return [(model, Model.get_name(model))]
2019-03-30 17:36:08 +01:00
def eval(self, expression):
env = {}
env['current_date'] = datetime.datetime.today()
env['time'] = time
env['context'] = Transaction().context
env['self'] = EvalEnvironment(self.record, self.record.__class__)
return PYSONDecoder(env).decode(expression)
@property
def language(self):
if self.record:
lang = self.record.marketing_party.lang
if lang:
return lang.code
2019-03-30 17:36:08 +01:00
@dualmethod
@ModelView.button
2019-03-30 17:36:08 +01:00
def block(cls, records):
pool = Pool()
Party = pool.get('party.party')
2019-03-30 17:36:08 +01:00
cls.write(records, {'blocked': True})
parties = defaultdict(set)
for record in records:
if record.scenario.unsubscribable:
parties[record.record.marketing_party].add(record.scenario.id)
if parties:
Party.write(*sum((
([p], {'marketing_scenario_unsubscribed': [
('add', s)]})
for p, s in parties.items()), ()))
2019-03-30 17:36:08 +01:00
def get_rec_name(self, name):
2020-11-16 21:22:13 +01:00
if self.record:
return self.record.rec_name
else:
return '(%s)' % self.id
2019-03-30 17:36:08 +01:00
@classmethod
def preprocess_values(cls, mode, values):
values = super().preprocess_values(mode, values)
if mode == 'create':
2019-03-30 17:36:08 +01:00
# Ensure to get a different uuid for each record
# default methods are called only once
values.setdefault('uuid', cls.default_uuid())
return values
2019-03-30 17:36:08 +01:00
class RecordActivity(Workflow, ModelSQL, ModelView):
__name__ = 'marketing.automation.record.activity'
record = fields.Many2One(
'marketing.automation.record', "Record",
required=True, ondelete='CASCADE')
activity = fields.Many2One(
'marketing.automation.activity', "Activity",
required=True, ondelete='CASCADE')
activity_action = fields.Function(
fields.Selection('get_activity_actions', "Activity Action"),
'on_change_with_activity_action')
at = fields.DateTime(
"At",
states={
'readonly': Eval('state') != 'waiting',
'required': Eval('state') == 'done',
})
2019-03-30 17:36:08 +01:00
email_opened = fields.Boolean(
2024-08-22 09:52:06 +02:00
"Email Opened",
2019-03-30 17:36:08 +01:00
states={
'invisible': Eval('activity_action') != 'send_email',
})
2019-03-30 17:36:08 +01:00
email_clicked = fields.Boolean(
2024-08-22 09:52:06 +02:00
"Email Clicked",
2019-03-30 17:36:08 +01:00
states={
'invisible': Eval('activity_action') != 'send_email',
})
2019-03-30 17:36:08 +01:00
state = fields.Selection([
('waiting', "Waiting"),
('done', "Done"),
('cancelled', "Cancelled"),
2022-02-15 00:08:47 +01:00
], "State", required=True, readonly=True, sort=False)
2019-03-30 17:36:08 +01:00
@classmethod
def __setup__(cls):
super().__setup__()
t = cls.__table__()
cls._sql_constraints = [
('activity_record_unique', Unique(t, t.activity, t.record),
'marketing_automation.msg_activity_record_unique'),
]
cls._sql_indexes.add(
Index(
t,
2024-07-27 18:36:14 +02:00
(t.state, Index.Equality(cardinality='low')),
where=t.state.in_(['waiting'])))
2019-03-30 17:36:08 +01:00
cls._transitions |= set((
('waiting', 'done'),
('waiting', 'cancelled'),
))
cls._buttons.update(
on_email_opened={
'invisible': ((Eval('state') != 'done')
2019-03-30 17:36:08 +01:00
| (Eval('activity_action') != 'send_email')
| Eval('email_opened', False)),
'depends': ['state', 'activity_action', 'email_opened'],
},
on_email_clicked={
'invisible': ((Eval('state') != 'done')
2019-03-30 17:36:08 +01:00
| (Eval('activity_action') != 'send_email')
| Eval('email_clicked', False)),
'depends': ['state', 'activity_action', 'email_clicked'],
},
)
@classmethod
def default_email_opened(cls):
return False
@classmethod
def default_email_clicked(cls):
return False
@classmethod
def default_state(cls):
return 'waiting'
@classmethod
def get_activity_actions(cls):
pool = Pool()
Activity = pool.get('marketing.automation.activity')
return Activity.fields_get(['action'])['action']['selection']
@fields.depends('activity')
def on_change_with_activity_action(self, name=None):
if self.activity:
return self.activity.action
@classmethod
def get(cls, record, activity):
record_activity = cls(activity=activity, record=record)
if activity.negative or not activity.event:
record_activity.set_delay()
return record_activity
def set_delay(self):
now = datetime.datetime.now()
self.at = now
if self.activity.delay is not None:
self.at += self.activity.delay
@classmethod
def process(cls):
transaction = Transaction()
context = transaction.context
2019-03-30 17:36:08 +01:00
now = datetime.datetime.now()
activities = cls.search([
('state', '=', 'waiting'),
('at', '<=', now),
('record.blocked', '!=', True),
])
with transaction.set_context(
queue_batch=context.get('queue_batch', True)):
cls.__queue__.do(activities)
2019-03-30 17:36:08 +01:00
@classmethod
@ModelView.button
def on_email_opened(cls, record_activities):
for record_activity in record_activities:
record_activity._on_event('email_opened')
cls.save(record_activities)
@classmethod
@ModelView.button
def on_email_clicked(cls, record_activities):
for record_activity in record_activities:
record_activity._on_event('email_clicked')
cls.save(record_activities)
def _on_event(self, event):
cls = self.__class__
record_activities = cls.search([
('record', '=', self.record),
2019-03-30 17:36:08 +01:00
('activity', 'in', [
c.id for c in self.activity.children
if c.event == event and not c.negative]),
('state', '=', 'waiting'),
])
cls._cancel_opposite(record_activities)
for record_activity in record_activities:
record_activity.set_delay()
cls.save(record_activities)
setattr(self, event, True)
@classmethod
def _cancel_opposite(cls, record_activities):
to_cancel = set()
for record_activity in record_activities:
records = cls.search([
('record', '=', record_activity.record),
('state', '=', 'waiting'),
('activity.parent',
'=', str(record_activity.activity.parent)),
('activity.event', '=', record_activity.activity.event),
('activity.negative',
'=', not record_activity.activity.negative),
])
to_cancel.update(records)
cls.cancel(to_cancel)
@classmethod
@Workflow.transition('done')
def do(cls, record_activities, **kwargs):
cls._cancel_opposite(record_activities)
now = datetime.datetime.now()
2020-10-24 13:13:11 +02:00
smtpd_datamanager = Transaction().join(SMTPDataManager())
2019-03-30 17:36:08 +01:00
for record_activity in record_activities:
record_activity.activity.execute(
2020-10-24 13:13:11 +02:00
record_activity, smtpd_datamanager=smtpd_datamanager, **kwargs)
2019-03-30 17:36:08 +01:00
record_activity.at = now
record_activity.state = 'done'
cls.save(record_activities)
@classmethod
@Workflow.transition('cancelled')
def cancel(cls, record_activities):
now = datetime.datetime.now()
cls.write(record_activities, {
'at': now,
'state': 'cancelled',
})
class Unsubscribe(Report):
__name__ = 'marketing.automation.unsubscribe'