Allow party to be unsubscribed from scenario

issue11426
review403391045
This commit is contained in:
Cédric Krier
2022-06-07 18:03:53 +02:00
parent 23865fc8f4
commit 412d9b3dba
19 changed files with 264 additions and 50 deletions
+2
View File
@@ -1,3 +1,5 @@
* Allow party to be unsubscribed from scenario
Version 6.4.0 - 2022-05-02
* Bug fixes (see mercurial logs for details)
* Add support for Python 3.10
+2 -4
View File
@@ -15,12 +15,10 @@ def register():
marketing_automation.Activity,
marketing_automation.Record,
marketing_automation.RecordActivity,
party.Party,
party.PartyUnsubscribedScenario,
web.ShortenedURL,
module='marketing_automation', type_='model')
Pool.register(
party.Party,
module='marketing_automation', type_='model',
depends=['party'])
Pool.register(
sale.Sale,
module='marketing_automation', type_='model',
+44 -39
View File
@@ -4,6 +4,7 @@ import datetime
import logging
import time
import uuid
from collections import defaultdict
from email.header import Header
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
@@ -72,6 +73,9 @@ class Scenario(Workflow, ModelSQL, ModelView):
fields.Integer("Records"), 'get_record_count')
record_count_blocked = fields.Function(
fields.Integer("Records Blocked"), 'get_record_count')
unsubscribable = fields.Boolean(
"Unsubscribable",
help="If checked parties are also unsubscribed from the scenario.")
state = fields.Selection([
('draft', "Draft"),
('running', "Running"),
@@ -108,6 +112,10 @@ class Scenario(Workflow, ModelSQL, ModelView):
def default_domain(cls):
return '[]'
@classmethod
def default_unsubscribable(cls):
return False
@classmethod
def get_models(cls):
pool = Pool()
@@ -212,6 +220,11 @@ class Scenario(Workflow, ModelSQL, ModelView):
record = Record.__table__()
cursor = Transaction().connection.cursor()
domain = PYSONDecoder({}).decode(scenario.domain)
domain = [
domain,
('marketing_party.marketing_scenario_unsubscribed',
'not where', [('id', '=', scenario.id)]),
]
try:
query = Model.search(domain, query=True, order=[])
except Exception:
@@ -506,28 +519,12 @@ class Activity(ModelSQL, ModelView):
for child in self.children])
def _email_recipient(self, record):
pool = Pool()
try:
Party = pool.get('party.party')
except KeyError:
Party = None
try:
Sale = pool.get('sale.sale')
except KeyError:
Sale = None
def get_party_email(party):
contact = party.contact_mechanism_get('email')
if contact and contact.email:
return _formataddr(
contact.name or party.rec_name,
contact.email)
return None
if Party and isinstance(record, Party):
return get_party_email(record)
elif Sale and isinstance(record, Sale):
return get_party_email(record.party)
party = record.marketing_party
contact = party.contact_mechanism_get('email')
if contact and contact.email:
return _formataddr(
contact.name or party.rec_name,
contact.email)
def execute_send_email(
self, record_activity, smtpd_datamanager=None, **kwargs):
@@ -638,7 +635,11 @@ class Record(ModelSQL, ModelView):
required=True, ondelete='CASCADE')
record = fields.Reference(
"Record", selection='get_models', required=True)
blocked = fields.Boolean("Blocked")
blocked = fields.Boolean(
"Blocked",
states={
'readonly': ~Eval('blocked', False),
})
uuid = fields.Char("UUID", readonly=True)
@classmethod
@@ -652,6 +653,11 @@ class Record(ModelSQL, ModelView):
('uuid_unique', Unique(t, t.uuid),
'marketing_automation.msg_record_uuid_unique'),
]
cls._buttons.update({
'block': {
'invisible': Eval('blocked', False),
},
})
@classmethod
def default_uuid(cls):
@@ -683,27 +689,26 @@ class Record(ModelSQL, ModelView):
@property
def language(self):
pool = Pool()
try:
Party = pool.get('party.party')
except KeyError:
Party = None
try:
Sale = pool.get('sale.sale')
except KeyError:
Sale = None
if Party and isinstance(self.record, Party):
if self.record.lang:
return self.record.lang.code
elif Sale and isinstance(self.record, Sale):
if self.record.party.lang:
return self.record.party.lang.code
lang = self.record.marketing_party.lang
if lang:
return lang.code
@dualmethod
@ModelView.button
def block(cls, records):
pool = Pool()
Party = pool.get('party.party')
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)
Party.write(*sum((
([p], {'marketing_scenario_unsubscribed': [('add', s)]})
for p, s in parties.items()), ()))
def get_rec_name(self, name):
if self.record:
return self.record.rec_name
+6
View File
@@ -205,6 +205,12 @@ this repository contains the full copyright notices and license terms. -->
<field name="perm_delete" eval="True"/>
</record>
<record model="ir.model.button" id="record_block_button">
<field name="name">block</field>
<field name="string">Block</field>
<field name="model" search="[('model', '=', 'marketing.automation.record')]"/>
</record>
<record model="ir.ui.view" id="record_activity_view_list">
<field name="model">marketing.automation.record.activity</field>
<field name="type">tree</field>
+13
View File
@@ -1,6 +1,19 @@
# This file is part of Tryton. The COPYRIGHT file at the top level of
# this repository contains the full copyright notices and license terms.
from trytond.model import fields
class MarketingAutomationMixin:
__slots__ = ()
marketing_party = fields.Function(
fields.Many2One('party.party', "Marketing Party"),
'get_marketing_party', searcher='search_marketing_party')
def get_marketing_party(self, name):
raise NotImplementedError
@classmethod
def search_marketing_party(cls, name, clause):
raise NotImplementedError
+38
View File
@@ -1,6 +1,7 @@
# This file is part of Tryton. The COPYRIGHT file at the top level of
# this repository contains the full copyright notices and license terms.
from trytond.model import ModelSQL, fields
from trytond.pool import PoolMeta
from .mixin import MarketingAutomationMixin
@@ -8,3 +9,40 @@ from .mixin import MarketingAutomationMixin
class Party(MarketingAutomationMixin, metaclass=PoolMeta):
__name__ = 'party.party'
marketing_scenario_unsubscribed = fields.Many2Many(
'party.party-unsubscribed-marketing.automation.scenario',
'party', 'scenario', "Marketing Automation Scenario Unsubscribed")
def get_marketing_party(self, name):
return self.id
@classmethod
def search_marketing_party(cls, name, clause):
operand, operator, value = clause[:3]
nested = operand.lstrip(name)
if not nested:
if operator.endswith('where'):
query = cls.search(value, order=[], query=True)
if operator.startswith('not'):
return [('id', 'not in', query)]
else:
return [('id', 'in', query)]
elif isinstance(value, str):
nested = 'rec_name'
else:
nested = 'id'
else:
nested = nested.lstrip('.')
return [(nested,) + tuple(clause[1:])]
class PartyUnsubscribedScenario(ModelSQL):
"Party Unsubscribed Scenario"
__name__ = 'party.party-unsubscribed-marketing.automation.scenario'
party = fields.Many2One(
'party.party', "Party", required=True, select=True, ondelete='CASCADE')
scenario = fields.Many2One(
'marketing.automation.scenario', "Scenario",
required=True, ondelete='CASCADE')
+12
View File
@@ -0,0 +1,12 @@
<?xml version="1.0"?>
<!-- This file is part of Tryton. The COPYRIGHT file at the top level of
this repository contains the full copyright notices and license terms. -->
<tryton>
<data>
<record model="ir.ui.view" id="party_view_form">
<field name="model">party.party</field>
<field name="inherit" ref="party.party_view_form"/>
<field name="name">party_form</field>
</record>
</data>
</tryton>
+8
View File
@@ -8,3 +8,11 @@ from .mixin import MarketingAutomationMixin
class Sale(MarketingAutomationMixin, metaclass=PoolMeta):
__name__ = 'sale.sale'
def get_marketing_party(self, name):
return self.party.id
@classmethod
def search_marketing_party(cls, name, clause):
nested = clause[0].lstrip(name)
return [('party' + nested,) + tuple(clause[1:])]
+3 -2
View File
@@ -64,8 +64,9 @@ for dep in info.get('depends', []):
requires.append(get_require_version('trytond_%s' % dep))
requires.append(get_require_version('trytond'))
tests_require = [get_require_version('proteus'),
get_require_version('trytond_party')]
tests_require = [
get_require_version('proteus'),
get_require_version('trytond_sale')]
dependency_links = []
if minor_version % 2:
dependency_links.append(
+1 -1
View File
@@ -4,7 +4,7 @@
<title>Hello!</title>
</head>
<body>
<p>Hello, ${record.name}!</p>
<p>Hello, ${record.rec_name}!</p>
<p>
Here is a
<a href="http://example.com/action">
+3 -1
View File
@@ -17,10 +17,12 @@ Patch sendmail_transactional::
>>> from trytond.modules.marketing_automation import marketing_automation
>>> smtp_calls = patch.object(
... marketing_automation, 'sendmail_transactional').start()
>>> manager = patch.object(
... marketing_automation, 'SMTPDataManager').start()
Activate modules::
>>> config = activate_modules(['marketing_automation', 'party'])
>>> config = activate_modules('marketing_automation')
Create a party::
@@ -0,0 +1,110 @@
Marketing Automation Unsubscribable Scenario
============================================
Imports::
>>> import datetime
>>> import re
>>> from proteus import Model, Wizard
>>> from proteus.config import get_config
>>> from trytond.modules.company.tests.tools import create_company
>>> from trytond.pyson import Eval, PYSONEncoder
>>> from trytond.tests.tools import activate_modules
>>> from trytond.tools import file_open
Patch sendmail_transactional::
>>> from unittest.mock import patch
>>> from trytond.modules.marketing_automation import marketing_automation
>>> smtp_calls = patch.object(
... marketing_automation, 'sendmail_transactional').start()
>>> manager = patch.object(
... marketing_automation, 'SMTPDataManager').start()
Activate modules::
>>> config = activate_modules(['marketing_automation', 'sale'])
>>> Activity = Model.get('marketing.automation.activity')
>>> Cron = Model.get('ir.cron')
>>> Party = Model.get('party.party')
>>> Record = Model.get('marketing.automation.record')
>>> Sale = Model.get('sale.sale')
>>> Scenario = Model.get('marketing.automation.scenario')
>>> cron_trigger, = Cron.find([
... ('method', '=', 'marketing.automation.scenario|trigger'),
... ])
>>> cron_process, = Cron.find([
... ('method', '=', 'marketing.automation.record.activity|process'),
... ])
Create a party::
>>> party = Party()
>>> party.name = "Michael Scott"
>>> contact = party.contact_mechanisms.new()
>>> contact.type = 'email'
>>> contact.value = 'michael@example.com'
>>> party.save()
Create company::
>>> _ = create_company()
Create a sale::
>>> sale = Sale(party=party)
>>> sale.save()
Create the running scenario::
>>> scenario = Scenario()
>>> scenario.name = "Sale Scenario"
>>> scenario.model = 'sale.sale'
>>> scenario.domain = '[["state", "=", "draft"]]'
>>> scenario.unsubscribable = True
>>> scenario.save()
>>> activity = scenario.activities.new()
>>> activity.name = "First E-Mail"
>>> activity.action = 'send_email'
>>> activity.email_title = "Pending Sale"
>>> activity.condition = PYSONEncoder().encode(
... Eval('self', {}).get('active'))
>>> with file_open('marketing_automation/tests/email.html', mode='r') as fp:
... activity.email_template = fp.read()
>>> scenario.click('run')
Trigger scenario::
>>> cron_trigger.click('run_once')
>>> cron_process.click('run_once')
>>> record, = Record.find([])
>>> record.record == sale
True
Block and unsubscribe::
>>> record.click('block')
>>> bool(record.blocked)
True
>>> party.reload()
>>> party.marketing_scenario_unsubscribed == [scenario]
True
Create a new sale::
>>> sale = Sale(party=party)
>>> sale.save()
Trigger scenario::
>>> cron_trigger.click('run_once')
>>> cron_process.click('run_once')
>>> Record.find([('blocked', '=', False)])
[]
+1
View File
@@ -7,6 +7,7 @@ from trytond.tests.test_tryton import ModuleTestCase
class MarketingAutomationTestCase(ModuleTestCase):
'Test Marketing Automation module'
module = 'marketing_automation'
extras = ['sale']
del ModuleTestCase
+3 -2
View File
@@ -2,12 +2,13 @@
version=6.5.0
depends:
ir
res
marketing
party
res
web_shortener
extras_depend:
sale
party
xml:
marketing_automation.xml
party.xml
message.xml
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0"?>
<!-- This file is part of Tryton. The COPYRIGHT file at the top level of
this repository contains the full copyright notices and license terms. -->
<data>
<xpath expr="/form/notebook" position="inside">
<page string="Marketing Automation" id="marketing_automation">
<field name="marketing_scenario_unsubscribed" widget="multiselection"/>
</page>
</xpath>
</data>
+2
View File
@@ -6,6 +6,8 @@ this repository contains the full copyright notices and license terms. -->
<field name="record"/>
<label name="scenario"/>
<field name="scenario"/>
<label name="blocked"/>
<field name="blocked"/>
<button name="block" colspan="2"/>
</form>
+1
View File
@@ -5,4 +5,5 @@ this repository contains the full copyright notices and license terms. -->
<field name="scenario" expand="1"/>
<field name="record" expand="1"/>
<field name="blocked"/>
<button name="block" tree_invisible="1"/>
</tree>
+4 -1
View File
@@ -7,8 +7,11 @@ this repository contains the full copyright notices and license terms. -->
<label name="model"/>
<field name="model"/>
<label name="unsubscribable"/>
<field name="unsubscribable"/>
<label name="domain"/>
<field name="domain" widget="pyson"/>
<field name="domain" widget="pyson" colspan="3"/>
<field name="activities" colspan="4" view_ids="marketing_automation.activity_view_list"/>
+1
View File
@@ -4,6 +4,7 @@ this repository contains the full copyright notices and license terms. -->
<tree keyword_open="1">
<field name="name" expand="2"/>
<field name="model" expand="1"/>
<field name="unsubscribable" optional="0"/>
<field name="record_count"/>
<field name="record_count_blocked"/>
<field name="state"/>