From 412d9b3dba4f9e8fb9fccc1718610d11fff3dd77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Krier?= Date: Tue, 7 Jun 2022 18:03:53 +0200 Subject: [PATCH] Allow party to be unsubscribed from scenario issue11426 review403391045 --- CHANGELOG | 2 + __init__.py | 6 +- marketing_automation.py | 83 ++++++------- marketing_automation.xml | 6 + mixin.py | 13 +++ party.py | 38 ++++++ party.xml | 12 ++ sale.py | 8 ++ setup.py | 5 +- tests/email.html | 2 +- tests/scenario_marketing_automation.rst | 4 +- ...io_marketing_automation_unsubscribable.rst | 110 ++++++++++++++++++ tests/test_module.py | 1 + tryton.cfg | 5 +- view/party_form.xml | 10 ++ view/record_form.xml | 2 + view/record_list.xml | 1 + view/scenario_form.xml | 5 +- view/scenario_list.xml | 1 + 19 files changed, 264 insertions(+), 50 deletions(-) create mode 100644 party.xml create mode 100644 tests/scenario_marketing_automation_unsubscribable.rst create mode 100644 view/party_form.xml diff --git a/CHANGELOG b/CHANGELOG index a0ac7a833f..0fb71525d8 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -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 diff --git a/__init__.py b/__init__.py index 2dc7405f77..31d230e6cf 100644 --- a/__init__.py +++ b/__init__.py @@ -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', diff --git a/marketing_automation.py b/marketing_automation.py index 881547dadc..3c3b2e62c1 100644 --- a/marketing_automation.py +++ b/marketing_automation.py @@ -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 diff --git a/marketing_automation.xml b/marketing_automation.xml index 837d011901..0139ecbfff 100644 --- a/marketing_automation.xml +++ b/marketing_automation.xml @@ -205,6 +205,12 @@ this repository contains the full copyright notices and license terms. --> + + block + Block + + + marketing.automation.record.activity tree diff --git a/mixin.py b/mixin.py index a2c4807f91..9223db9731 100644 --- a/mixin.py +++ b/mixin.py @@ -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 diff --git a/party.py b/party.py index bf16fc5b20..06f4f01101 100644 --- a/party.py +++ b/party.py @@ -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') diff --git a/party.xml b/party.xml new file mode 100644 index 0000000000..e5c6732c93 --- /dev/null +++ b/party.xml @@ -0,0 +1,12 @@ + + + + + + party.party + + party_form + + + diff --git a/sale.py b/sale.py index b18ccdd751..0417943ecf 100644 --- a/sale.py +++ b/sale.py @@ -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:])] diff --git a/setup.py b/setup.py index 3c6415ef33..fdf8103260 100755 --- a/setup.py +++ b/setup.py @@ -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( diff --git a/tests/email.html b/tests/email.html index dbb04f540a..5a85030b39 100644 --- a/tests/email.html +++ b/tests/email.html @@ -4,7 +4,7 @@ Hello! -

Hello, ${record.name}!

+

Hello, ${record.rec_name}!

Here is a diff --git a/tests/scenario_marketing_automation.rst b/tests/scenario_marketing_automation.rst index 1f005b3848..442de099a5 100644 --- a/tests/scenario_marketing_automation.rst +++ b/tests/scenario_marketing_automation.rst @@ -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:: diff --git a/tests/scenario_marketing_automation_unsubscribable.rst b/tests/scenario_marketing_automation_unsubscribable.rst new file mode 100644 index 0000000000..94c66d1346 --- /dev/null +++ b/tests/scenario_marketing_automation_unsubscribable.rst @@ -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)]) + [] diff --git a/tests/test_module.py b/tests/test_module.py index 9a156e3c1f..b0a4905e3f 100644 --- a/tests/test_module.py +++ b/tests/test_module.py @@ -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 diff --git a/tryton.cfg b/tryton.cfg index 6102eaee8a..6b6b36b9fb 100644 --- a/tryton.cfg +++ b/tryton.cfg @@ -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 diff --git a/view/party_form.xml b/view/party_form.xml new file mode 100644 index 0000000000..b6f5291469 --- /dev/null +++ b/view/party_form.xml @@ -0,0 +1,10 @@ + + + + + + + + + diff --git a/view/record_form.xml b/view/record_form.xml index 47c68f04e4..8160508372 100644 --- a/view/record_form.xml +++ b/view/record_form.xml @@ -6,6 +6,8 @@ this repository contains the full copyright notices and license terms. -->