Browse Source

Merge pull request #2315 from django-oscar/refactor-alerts-dispatcher

Refactor product alerts to use Communication Events
master
Alexander Gaevsky 8 years ago
parent
commit
c5c54900f8

+ 2
- 0
docs/source/ref/apps/customer.rst View File

@@ -39,3 +39,5 @@ as affected stock records are updated. Alternatively, the management command
39 39
 The context for the alert email body contains a ``hurry`` variable that is set
40 40
 to ``True`` if the number of active alerts for a product is greater than the
41 41
 quantity of the product available in stock.
42
+
43
+Alerts are sent using the Communication Event framework.

+ 12
- 6
docs/source/releases/v1.5.rst View File

@@ -53,7 +53,7 @@ These methods/modules have been removed:
53 53
 
54 54
 Minor changes
55 55
 ~~~~~~~~~~~~~
56
- - Add billing address to user's address book during checkout (:issue:`1532`).
56
+ - Added billing address to user's address book during checkout (:issue:`1532`).
57 57
    Number of usages for billing and shipping addresses tracked separately:
58 58
    billing address in ``UserAddress.num_orders_as_billing_address`` field and
59 59
    shipping address in ``UserAddress.num_order_as_shipping_address``
@@ -63,10 +63,10 @@ Minor changes
63 63
    set if the number of alerts for a product exceeds the quantity in stock,
64 64
    rather than the other way around.
65 65
 
66
- - Update search to use the haystack CharField instead of the EdgeNgramField
66
+ - Updated search to use the haystack CharField instead of the EdgeNgramField
67 67
    to fix irrelevant results (:issue:`2128`)
68 68
 
69
- - Update ``customer.utils.Dispatcher`` to improve the ability to customise
69
+ - Updated ``customer.utils.Dispatcher`` to improve the ability to customise
70 70
    the implementation (:issue:`2303`)
71 71
 
72 72
  - Fixed logic for determining "hurry mode" on stock alerts. Hurry mode is now
@@ -80,6 +80,12 @@ Minor changes
80 80
    database with new columns and copies into it original data -
81 81
    https://docs.djangoproject.com/en/1.11/topics/migrations/#sqlite
82 82
 
83
+ - Allowed the communication event dispatcher to accept an optional
84
+   ``mail_connection`` to use when sending email messages.
85
+
86
+ - Changed product alerts to be sent using the Communication Event framework.
87
+   The old email templates will continue to be supported until Oscar 2.0.
88
+
83 89
 .. _incompatible_in_1.5:
84 90
 
85 91
 Backwards incompatible changes in Oscar 1.5
@@ -122,11 +128,11 @@ Backwards incompatible changes in Oscar 1.5
122 128
    ``OSCAR_GOOGLE_ANALYTICS_ID`` and ``OSCAR_USE_LESS`` respectively in order
123 129
    to keep all Oscar settings under common namespace.
124 130
 
125
- - Remove ``display_version`` and ``version`` variables from templates and
131
+ - Removed ``display_version`` and ``version`` variables from templates and
126 132
    template context.
127 133
 
128
-- Offer ``Applicator`` is now loaded from the ``offer.applicator`` module, instead of ``offer.utils``.
129
-  Old path is deprecated and won't be supported in the next Oscar versions.
134
+ - Offer ``Applicator`` is now loaded from the ``offer.applicator`` module, instead of ``offer.utils``.
135
+   Old path is deprecated and won't be supported in the next Oscar versions.
130 136
 
131 137
 
132 138
 Dependency changes

+ 97
- 33
src/oscar/apps/customer/alerts/utils.py View File

@@ -1,14 +1,18 @@
1 1
 import logging
2
+import warnings
2 3
 
3 4
 from django.conf import settings
4 5
 from django.contrib.sites.models import Site
5 6
 from django.core import mail
6 7
 from django.db.models import Max
7
-from django.template import loader
8
+from django.template import loader, TemplateDoesNotExist
8 9
 
9 10
 from oscar.apps.customer.notifications import services
11
+from oscar.apps.customer.utils import Dispatcher
10 12
 from oscar.core.loading import get_class, get_model
13
+from oscar.utils.deprecation import RemovedInOscar20Warning
11 14
 
15
+CommunicationEventType = get_model('customer', 'CommunicationEventType')
12 16
 ProductAlert = get_model('customer', 'ProductAlert')
13 17
 Product = get_model('catalogue', 'Product')
14 18
 Selector = get_class('partner.strategy', 'Selector')
@@ -36,16 +40,38 @@ def send_alert_confirmation(alert):
36 40
         'alert': alert,
37 41
         'site': Site.objects.get_current(),
38 42
     }
39
-    subject_tpl = loader.get_template('customer/alerts/emails/'
40
-                                      'confirmation_subject.txt')
41
-    body_tpl = loader.get_template('customer/alerts/emails/'
42
-                                   'confirmation_body.txt')
43
-    mail.send_mail(
44
-        subject_tpl.render(ctx).strip(),
45
-        body_tpl.render(ctx),
46
-        settings.OSCAR_FROM_EMAIL,
47
-        [alert.email],
48
-    )
43
+
44
+    # For backwards compability, we check if the old (non-communication-event)
45
+    # templates exist, and use them if they do.
46
+    # This will be removed in Oscar 2.0
47
+    try:
48
+        subject_tpl = loader.get_template('customer/alerts/emails/'
49
+                                          'confirmation_subject.txt')
50
+        body_tpl = loader.get_template('customer/alerts/emails/'
51
+                                       'confirmation_body.txt')
52
+        warnings.warn(
53
+            "Product alert notifications now use the CommunicationEvent. "
54
+            "Move '{}' to '{}', and '{}' to '{}'".format(
55
+                'customer/alerts/emails/confirmation_subject.txt',
56
+                'customer/emails/commtype_product_alert_confirmation_subject.txt',
57
+                'customer/alerts/emails/confirmation_body.txt',
58
+                'customer/emails/commtype_product_alert_confirmation_body.txt',
59
+            ),
60
+            category=RemovedInOscar20Warning, stacklevel=2
61
+        )
62
+
63
+        messages = {
64
+            'subject': subject_tpl.render(ctx).strip(),
65
+            'body': body_tpl.render(ctx),
66
+            'html': '',
67
+            'sms': '',
68
+        }
69
+    except TemplateDoesNotExist:
70
+        code = 'PRODUCT_ALERT_CONFIRMATION'
71
+        messages = CommunicationEventType.objects.get_and_render(code, ctx)
72
+
73
+    if messages and messages['body']:
74
+        Dispatcher().dispatch_direct_messages(alert.email, messages)
49 75
 
50 76
 
51 77
 def send_product_alerts(product):
@@ -75,14 +101,37 @@ def send_product_alerts(product):
75 101
     # hurry_mode is false if num_in_stock is None
76 102
     hurry_mode = num_in_stock is not None and alerts.count() > num_in_stock
77 103
 
78
-    # Load templates
79
-    message_tpl = loader.get_template('customer/alerts/message.html')
80
-    email_subject_tpl = loader.get_template('customer/alerts/emails/'
81
-                                            'alert_subject.txt')
82
-    email_body_tpl = loader.get_template('customer/alerts/emails/'
83
-                                         'alert_body.txt')
104
+    # For backwards compability, we check if the old (non-communication-event)
105
+    # templates exist, and use them if they do.
106
+    # This will be removed in Oscar 2.0
107
+    try:
108
+        email_subject_tpl = loader.get_template('customer/alerts/emails/'
109
+                                                'alert_subject.txt')
110
+        email_body_tpl = loader.get_template('customer/alerts/emails/'
111
+                                             'alert_body.txt')
112
+
113
+        use_deprecated_templates = True
114
+        warnings.warn(
115
+            "Product alert notifications now use the CommunicationEvent. "
116
+            "Move '{}' to '{}', and '{}' to '{}'".format(
117
+                'customer/alerts/emails/alert_subject.txt',
118
+                'customer/emails/commtype_product_alert_subject.txt',
119
+                'customer/alerts/emails/alert_body.txt',
120
+                'customer/emails/commtype_product_alert_body.txt',
121
+            ),
122
+            category=RemovedInOscar20Warning, stacklevel=2
123
+        )
124
+
125
+    except TemplateDoesNotExist:
126
+        code = 'PRODUCT_ALERT'
127
+        try:
128
+            event_type = CommunicationEventType.objects.get(code=code)
129
+        except CommunicationEventType.DoesNotExist:
130
+            event_type = CommunicationEventType.objects.model(code=code)
131
+        use_deprecated_templates = False
84 132
 
85
-    emails = []
133
+    messages_to_send = []
134
+    user_messages_to_send = []
86 135
     num_notifications = 0
87 136
     selector = Selector()
88 137
     for alert in alerts:
@@ -100,26 +149,41 @@ def send_product_alerts(product):
100 149
         if alert.user:
101 150
             # Send a site notification
102 151
             num_notifications += 1
152
+            message_tpl = loader.get_template('customer/alerts/message.html')
103 153
             services.notify_user(alert.user, message_tpl.render(ctx))
104 154
 
105
-        # Build email and add to list
106
-        emails.append(
107
-            mail.EmailMessage(
108
-                email_subject_tpl.render(ctx).strip(),
109
-                email_body_tpl.render(ctx),
110
-                settings.OSCAR_FROM_EMAIL,
111
-                [alert.get_email_address()],
112
-            )
113
-        )
155
+        # Build message and add to list
156
+        if use_deprecated_templates:
157
+            messages = {
158
+                'subject': email_subject_tpl.render(ctx).strip(),
159
+                'body': email_body_tpl.render(ctx),
160
+                'html': '',
161
+                'sms': '',
162
+            }
163
+        else:
164
+            messages = event_type.get_messages(ctx)
165
+
166
+        if messages and messages['body']:
167
+            if alert.user:
168
+                user_messages_to_send.append(
169
+                    (alert.user, messages)
170
+                )
171
+            else:
172
+                messages_to_send.append(
173
+                    (alert.get_email_address(), messages)
174
+                )
114 175
         alert.close()
115 176
 
116
-    # Send all emails in one go to prevent multiple SMTP
117
-    # connections to be opened
118
-    if emails:
177
+    # Send all messages using one SMTP connection to avoid opening lots of them
178
+    if messages_to_send or user_messages_to_send:
119 179
         connection = mail.get_connection()
120 180
         connection.open()
121
-        connection.send_messages(emails)
181
+        disp = Dispatcher(mail_connection=connection)
182
+        for message in messages_to_send:
183
+            disp.dispatch_direct_messages(*message)
184
+        for message in user_messages_to_send:
185
+            disp.dispatch_user_messages(*message)
122 186
         connection.close()
123 187
 
124
-    logger.info("Sent %d notifications and %d emails", num_notifications,
125
-                len(emails))
188
+    logger.info("Sent %d notifications and %d messages", num_notifications,
189
+                len(messages_to_send) + len(user_messages_to_send))

+ 9
- 2
src/oscar/apps/customer/utils.py View File

@@ -15,10 +15,13 @@ Email = get_model('customer', 'Email')
15 15
 
16 16
 
17 17
 class Dispatcher(object):
18
-    def __init__(self, logger=None):
18
+    def __init__(self, logger=None, mail_connection=None):
19 19
         if not logger:
20 20
             logger = logging.getLogger(__name__)
21 21
         self.logger = logger
22
+        # Supply a mail_connection if you want the dispatcher to use that
23
+        # instead of opening a new one.
24
+        self.mail_connection = mail_connection
22 25
 
23 26
     # Public API methods
24 27
 
@@ -113,7 +116,11 @@ class Dispatcher(object):
113 116
                                  from_email=from_email,
114 117
                                  to=[recipient])
115 118
         self.logger.info("Sending email to %s" % recipient)
116
-        email.send()
119
+
120
+        if self.mail_connection:
121
+            self.mail_connection.send_messages([email])
122
+        else:
123
+            email.send()
117 124
 
118 125
         return email
119 126
 

src/oscar/templates/oscar/customer/alerts/emails/alert_body.txt → src/oscar/templates/oscar/customer/emails/commtype_product_alert_body.txt View File


src/oscar/templates/oscar/customer/alerts/emails/confirmation_body.txt → src/oscar/templates/oscar/customer/emails/commtype_product_alert_confirmation_body.txt View File


src/oscar/templates/oscar/customer/alerts/emails/confirmation_subject.txt → src/oscar/templates/oscar/customer/emails/commtype_product_alert_confirmation_subject.txt View File


src/oscar/templates/oscar/customer/alerts/emails/alert_subject.txt → src/oscar/templates/oscar/customer/emails/commtype_product_alert_subject.txt View File


+ 100
- 1
tests/functional/customer/test_alert.py View File

@@ -1,9 +1,15 @@
1
+import mock
2
+import os
3
+import warnings
4
+
1 5
 from django_webtest import WebTest
2 6
 from django.core.urlresolvers import reverse
3 7
 from django.core import mail
4 8
 from django.test import TestCase
9
+from oscar.utils.deprecation import RemovedInOscar20Warning
5 10
 
6
-from oscar.apps.customer.alerts.utils import send_product_alerts
11
+from oscar.apps.customer.alerts.utils import (send_alert_confirmation,
12
+    send_product_alerts)
7 13
 from oscar.apps.customer.models import ProductAlert
8 14
 from oscar.test.factories import create_product, create_stockrecord
9 15
 from oscar.test.factories import UserFactory
@@ -133,3 +139,96 @@ class TestHurryMode(TestCase):
133 139
 
134 140
         self.assertIn('Beware that the amount of items in stock is limited',
135 141
             mail.outbox[0].body)
142
+
143
+
144
+class TestAlertMessageSending(TestCase):
145
+
146
+    def setUp(self):
147
+        self.user = UserFactory()
148
+        self.product = create_product()
149
+        create_stockrecord(self.product, num_in_stock=1)
150
+
151
+    @mock.patch('oscar.apps.customer.utils.Dispatcher.dispatch_direct_messages')
152
+    def test_alert_confirmation_uses_dispatcher(self, mock_dispatch):
153
+        alert = ProductAlert.objects.create(
154
+            email='test@example.com',
155
+            key='dummykey',
156
+            status=ProductAlert.UNCONFIRMED,
157
+            product=self.product
158
+        )
159
+        send_alert_confirmation(alert)
160
+        self.assertEqual(mock_dispatch.call_count, 1)
161
+        self.assertEqual(mock_dispatch.call_args[0][0], 'test@example.com')
162
+
163
+    @mock.patch('oscar.apps.customer.utils.Dispatcher.dispatch_user_messages')
164
+    def test_alert_uses_dispatcher(self, mock_dispatch):
165
+        ProductAlert.objects.create(user=self.user, product=self.product)
166
+        send_product_alerts(self.product)
167
+        self.assertEqual(mock_dispatch.call_count, 1)
168
+        self.assertEqual(mock_dispatch.call_args[0][0], self.user)
169
+
170
+    def test_alert_creates_email_obj(self):
171
+        ProductAlert.objects.create(user=self.user, product=self.product)
172
+        send_product_alerts(self.product)
173
+        self.assertEqual(self.user.emails.count(), 1)
174
+
175
+    @staticmethod
176
+    def _load_deprecated_template(path):
177
+        from django.template.loader import select_template
178
+        # Replace the old template paths with new ones, thereby mocking
179
+        # the presence of the deprecated templates.
180
+        if path.startswith('customer/alerts/emails/'):
181
+            old_path = path.replace('customer/alerts/emails/', '')
182
+            path_map = {
183
+                'confirmation_body.txt': 'confirmation_body.txt',
184
+                'confirmation_subject.txt': 'confirmation_subject.txt',
185
+                'alert_body.txt': 'body.txt',
186
+                'alert_subject.txt': 'subject.txt',
187
+            }
188
+            new_path = 'customer/emails/commtype_product_alert_{}'.format(
189
+                path_map[old_path]
190
+            )
191
+            # We use 'select_template' because 'load_template' is mocked...
192
+            return select_template([new_path])
193
+        return select_template([path])
194
+
195
+    @mock.patch('oscar.apps.customer.alerts.utils.loader.get_template')
196
+    def test_deprecated_notification_templates_work(self, mock_loader):
197
+        """
198
+        This test can be removed when support for the deprecated templates is
199
+        dropped.
200
+        """
201
+        mock_loader.side_effect = self._load_deprecated_template
202
+        with warnings.catch_warnings(record=True) as warning_list:
203
+            warnings.simplefilter("always")
204
+
205
+            alert = ProductAlert.objects.create(
206
+                email='test@example.com',
207
+                key='dummykey',
208
+                status=ProductAlert.UNCONFIRMED,
209
+                product=self.product
210
+            )
211
+            send_alert_confirmation(alert)
212
+            # Check that warnings were raised
213
+            self.assertTrue(any(item.category == RemovedInOscar20Warning
214
+                                                    for item in warning_list))
215
+            # Check that alerts were still sent
216
+            self.assertEqual(len(mail.outbox), 1)
217
+
218
+    @mock.patch('oscar.apps.customer.alerts.utils.loader.get_template')
219
+    def test_deprecated_alert_templates_work(self, mock_loader):
220
+        """
221
+        This test can be removed when support for the deprecated templates is
222
+        dropped.
223
+        """
224
+        mock_loader.side_effect = self._load_deprecated_template
225
+        with warnings.catch_warnings(record=True) as warning_list:
226
+            warnings.simplefilter("always")
227
+
228
+            ProductAlert.objects.create(user=self.user, product=self.product)
229
+            send_product_alerts(self.product)
230
+            # Check that warnings were raised
231
+            self.assertTrue(any(item.category == RemovedInOscar20Warning
232
+                                                    for item in warning_list))
233
+            # Check that alerts were still sent
234
+            self.assertEqual(len(mail.outbox), 1)

+ 10
- 0
tests/integration/customer/test_dispatcher.py View File

@@ -88,3 +88,13 @@ class TestDispatcher(TestCase):
88 88
         ctx = {'user': user,
89 89
                'site': SiteFactory()}
90 90
         self._dispatch_user_messages(user, 'REGISTRATION', ctx, 'Thank you for registering.')
91
+
92
+    def test_dispatcher_uses_email_connection(self):
93
+        connection = mail.get_connection()
94
+        disp = Dispatcher(mail_connection=connection)
95
+        disp.dispatch_direct_messages('test@example.com', {
96
+            'subject': 'Test',
97
+            'body': 'This is a test.',
98
+            'html': '',
99
+        })
100
+        self.assertEqual(len(mail.outbox), 1)

Loading…
Cancel
Save