Przeglądaj źródła

Add email dashboard

This allows admins to:

* Preview emails in the dashboard
* Send preview emails
* Save emails

Fixes #359
master
David Winterbottom 13 lat temu
rodzic
commit
ba59c01467
45 zmienionych plików z 3003 dodań i 9701 usunięć
  1. 1
    0
      .vagrant
  2. 1
    0
      oscar/__init__.py
  3. 23
    11
      oscar/apps/customer/abstract_models.py
  4. 18
    33
      oscar/apps/customer/forms.py
  5. 218
    0
      oscar/apps/customer/migrations/0004_auto__chg_field_communicationeventtype_email_subject_template.py
  6. 3
    11
      oscar/apps/customer/views.py
  7. 3
    0
      oscar/apps/dashboard/app.py
  8. 0
    0
      oscar/apps/dashboard/communications/__init__.py
  9. 31
    0
      oscar/apps/dashboard/communications/app.py
  10. 86
    0
      oscar/apps/dashboard/communications/forms.py
  11. 0
    0
      oscar/apps/dashboard/communications/models.py
  12. 89
    0
      oscar/apps/dashboard/communications/views.py
  13. 10
    0
      oscar/forms/widgets.py
  14. 577
    2264
      oscar/static/oscar/css/dashboard.css
  15. 1354
    7346
      oscar/static/oscar/css/styles.css
  16. 1
    1
      oscar/static/oscar/js/oscar/dashboard.js
  17. 5
    0
      oscar/static/oscar/less/dashboard.less
  18. 1
    1
      oscar/templates/oscar/customer/emails/commtype_order_placed_subject.txt
  19. 7
    8
      oscar/templates/oscar/customer/emails/commtype_password_reset_body.html
  20. 3
    6
      oscar/templates/oscar/customer/emails/commtype_password_reset_body.txt
  21. 1
    1
      oscar/templates/oscar/customer/emails/commtype_registration_body.html
  22. 96
    0
      oscar/templates/oscar/dashboard/comms/detail.html
  23. 49
    0
      oscar/templates/oscar/dashboard/comms/list.html
  24. 1
    0
      oscar/templates/oscar/dashboard/pages/index.html
  25. 4
    1
      oscar/templates/oscar/registration/password_reset_done.html
  26. 4
    18
      oscar/templates/oscar/registration/password_reset_form.html
  27. 32
    0
      sites/_fixtures/comms.json
  28. 23
    0
      sites/puppet/manifests/site.pp
  29. 8
    0
      sites/puppet/modules/memcached/Modulefile
  30. 9
    0
      sites/puppet/modules/memcached/README-DEVELOPER
  31. 29
    0
      sites/puppet/modules/memcached/README.md
  32. 17
    0
      sites/puppet/modules/memcached/Rakefile
  33. 33
    0
      sites/puppet/modules/memcached/manifests/init.pp
  34. 21
    0
      sites/puppet/modules/memcached/manifests/params.pp
  35. 31
    0
      sites/puppet/modules/memcached/metadata.json
  36. 101
    0
      sites/puppet/modules/memcached/spec/classes/memcached_spec.rb
  37. 0
    0
      sites/puppet/modules/memcached/spec/fixtures/manifests/site.pp
  38. 6
    0
      sites/puppet/modules/memcached/spec/spec.opts
  39. 13
    0
      sites/puppet/modules/memcached/spec/spec_helper.rb
  40. 46
    0
      sites/puppet/modules/memcached/templates/memcached.conf.erb
  41. 5
    0
      sites/puppet/modules/memcached/templates/memcached_sysconfig.erb
  42. 1
    0
      sites/puppet/modules/memcached/tests/init.pp
  43. 1
    0
      sites/puppet/modules/python
  44. 1
    0
      sites/puppet/modules/userconfig
  45. 40
    0
      tests/functional/dashboard/communication_tests.py

+ 1
- 0
.vagrant Wyświetl plik

@@ -0,0 +1 @@
1
+{"active":{"default":"21412894-03d1-4be4-bf6d-d4eca2a5662f"}}

+ 1
- 0
oscar/__init__.py Wyświetl plik

@@ -55,6 +55,7 @@ OSCAR_CORE_APPS = [
55 55
     'oscar.apps.dashboard.offers',
56 56
     'oscar.apps.dashboard.ranges',
57 57
     'oscar.apps.dashboard.vouchers',
58
+    'oscar.apps.dashboard.communications',
58 59
     # 3rd-party apps that oscar depends on
59 60
     'haystack',
60 61
     'treebeard',

+ 23
- 11
oscar/apps/customer/abstract_models.py Wyświetl plik

@@ -34,27 +34,37 @@ class AbstractEmail(models.Model):
34 34
 
35 35
 
36 36
 class AbstractCommunicationEventType(models.Model):
37
+    """
38
+    A 'type' of communication.  Like a order confirmation email.
39
+    """
37 40
 
38 41
     # Code used for looking up this event programmatically.
39 42
     # eg. PASSWORD_RESET
40 43
     code = models.SlugField(_('Code'), max_length=128)
41 44
 
42 45
     # Name is the friendly description of an event for use in the admin
43
-    name = models.CharField(_('Name'), max_length=255)
46
+    name = models.CharField(
47
+        _('Name'), max_length=255,
48
+        help_text=_("This is just used for organisational purposes"))
44 49
 
45 50
     # We allow communication types to be categorised
46 51
     ORDER_RELATED = _('Order related')
47 52
     USER_RELATED = _('User related')
48
-    category = models.CharField(_('Category'), max_length=255, default=ORDER_RELATED)
53
+    category = models.CharField(_('Category'), max_length=255,
54
+                                default=ORDER_RELATED)
49 55
 
50 56
     # Template content for emails
51
-    email_subject_template = models.CharField(_('Email Subject Template'), max_length=255, blank=True)
52
-    email_body_template = models.TextField(_('Email Body Template'), blank=True, null=True)
53
-    email_body_html_template = models.TextField(_('Email Body HTML Temlate'), blank=True, null=True,
57
+    email_subject_template = models.CharField(
58
+        _('Email Subject Template'), max_length=255, blank=True, null=True)
59
+    email_body_template = models.TextField(
60
+        _('Email Body Template'), blank=True, null=True)
61
+    email_body_html_template = models.TextField(
62
+        _('Email Body HTML Template'), blank=True, null=True,
54 63
         help_text=_("HTML template"))
55 64
 
56 65
     # Template content for SMS messages
57
-    sms_template = models.CharField(_('SMS Template'), max_length=170, blank=True, help_text=_("SMS template"))
66
+    sms_template = models.CharField(_('SMS Template'), max_length=170,
67
+                                    blank=True, help_text=_("SMS template"))
58 68
 
59 69
     date_created = models.DateTimeField(_("Date Created"), auto_now_add=True)
60 70
     date_updated = models.DateTimeField(_("Date Updated"), auto_now=True)
@@ -69,8 +79,8 @@ class AbstractCommunicationEventType(models.Model):
69 79
 
70 80
     class Meta:
71 81
         abstract = True
72
-        verbose_name = _("Communication Event Type")
73
-        verbose_name_plural = _("Communication Event Types")
82
+        verbose_name = _("Communication event type")
83
+        verbose_name_plural = _("Communication event types")
74 84
 
75 85
     def get_messages(self, ctx=None):
76 86
         """
@@ -81,14 +91,14 @@ class AbstractCommunicationEventType(models.Model):
81 91
         """
82 92
         code = self.code.lower()
83 93
 
84
-        # Build a dict of message name to Template instance
94
+        # Build a dict of message name to Template instances
85 95
         templates = {'subject': 'email_subject_template',
86 96
                      'body': 'email_body_template',
87 97
                      'html': 'email_body_html_template',
88 98
                      'sms': 'sms_template'}
89 99
         for name, attr_name in templates.items():
90 100
             field = getattr(self, attr_name, None)
91
-            if field:
101
+            if field is not None:
92 102
                 # Template content is in a model field
93 103
                 templates[name] = Template(field)
94 104
             else:
@@ -99,10 +109,12 @@ class AbstractCommunicationEventType(models.Model):
99 109
                 except TemplateDoesNotExist:
100 110
                     templates[name] = None
101 111
 
112
+
102 113
         # Pass base URL for serving images within HTML emails
103 114
         if ctx is None:
104 115
             ctx = {}
105
-        ctx['static_base_url'] = getattr(settings, 'OSCAR_STATIC_BASE_URL', None)
116
+        ctx['static_base_url'] = getattr(settings,
117
+                                         'OSCAR_STATIC_BASE_URL', None)
106 118
 
107 119
         messages = {}
108 120
         for name, template in templates.items():

+ 18
- 33
oscar/apps/customer/forms.py Wyświetl plik

@@ -2,6 +2,7 @@ import string
2 2
 import random
3 3
 
4 4
 from django.contrib.auth.forms import AuthenticationForm
5
+from django.core.urlresolvers import reverse
5 6
 from django.utils.translation import ugettext_lazy as _
6 7
 from django.core.exceptions import ObjectDoesNotExist
7 8
 from django import forms
@@ -34,7 +35,8 @@ def generate_username():
34 35
 class PasswordResetForm(auth_forms.PasswordResetForm):
35 36
     communication_type_code = "PASSWORD_RESET"
36 37
 
37
-    def save(self, subject_template_name='registration/password_reset_subject.txt',
38
+    def save(self, domain_override=None,
39
+             subject_template_name='registration/password_reset_subject.txt',
38 40
              email_template_name='registration/password_reset_email.html',
39 41
              use_https=False, token_generator=default_token_generator,
40 42
              from_email=None, request=None, **kwargs):
@@ -42,40 +44,23 @@ class PasswordResetForm(auth_forms.PasswordResetForm):
42 44
         Generates a one-use only link for resetting password and sends to the
43 45
         user.
44 46
         """
47
+        site = get_current_site(request)
48
+        if domain_override is not None:
49
+            site.domain = site.name = domain_override
45 50
         for user in self.users_cache:
46
-            current_site = get_current_site(request)
51
+            # Build reset url
52
+            reset_url = "%s://%s%s" % (
53
+                'https' if use_https else 'http',
54
+                site.domain,
55
+                reverse('password-reset-confirm', kwargs={
56
+                    'uidb36': int_to_base36(user.id),
57
+                    'token': token_generator.make_token(user)}))
47 58
             ctx = {
48
-                'email': user.email,
49
-                'domain': current_site.domain,
50
-                'site_name': current_site.name,
51
-                'uid': int_to_base36(user.id),
52
-                'token': token_generator.make_token(user),
53
-                'protocol': use_https and 'https' or 'http',
54
-                'site': current_site,
55
-            }
56
-            self.send_reset_email(user, ctx)
57
-
58
-    def send_reset_email(self, user, extra_context=None):
59
-        code = self.communication_type_code
60
-        ctx = {
61
-            'user': user,
62
-        }
63
-
64
-        if extra_context:
65
-            ctx.update(extra_context)
66
-
67
-        try:
68
-            event_type = CommunicationEventType.objects.get(code=code)
69
-        except CommunicationEventType.DoesNotExist:
70
-            # No event in database, attempt to find templates for this type
71
-            messages = CommunicationEventType.objects.get_and_render(code, ctx)
72
-        else:
73
-            # Create order event
74
-            messages = event_type.get_messages(ctx)
75
-
76
-        if messages and messages['body']:
77
-            dispatcher = Dispatcher()
78
-            dispatcher.dispatch_user_messages(user, messages)
59
+                'site': site,
60
+                'reset_url': reset_url}
61
+            messages = CommunicationEventType.objects.get_and_render(
62
+                code=self.communication_type_code, context=ctx)
63
+            Dispatcher().dispatch_user_messages(user, messages)
79 64
 
80 65
 
81 66
 class EmailAuthenticationForm(AuthenticationForm):

+ 218
- 0
oscar/apps/customer/migrations/0004_auto__chg_field_communicationeventtype_email_subject_template.py Wyświetl plik

@@ -0,0 +1,218 @@
1
+# encoding: utf-8
2
+import datetime
3
+from south.db import db
4
+from south.v2 import SchemaMigration
5
+from django.db import models
6
+
7
+class Migration(SchemaMigration):
8
+
9
+    def forwards(self, orm):
10
+
11
+        # Changing field 'CommunicationEventType.email_subject_template'
12
+        db.alter_column('customer_communicationeventtype', 'email_subject_template', self.gf('django.db.models.fields.CharField')(max_length=255, null=True))
13
+
14
+
15
+    def backwards(self, orm):
16
+
17
+        # Changing field 'CommunicationEventType.email_subject_template'
18
+        db.alter_column('customer_communicationeventtype', 'email_subject_template', self.gf('django.db.models.fields.CharField')(default='', max_length=255))
19
+
20
+
21
+    models = {
22
+        'auth.group': {
23
+            'Meta': {'object_name': 'Group'},
24
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
25
+            'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}),
26
+            'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'})
27
+        },
28
+        'auth.permission': {
29
+            'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'},
30
+            'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
31
+            'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}),
32
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
33
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '50'})
34
+        },
35
+        'auth.user': {
36
+            'Meta': {'object_name': 'User'},
37
+            'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime(2012, 10, 15, 17, 40, 13, 532212)'}),
38
+            'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}),
39
+            'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
40
+            'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}),
41
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
42
+            'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
43
+            'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
44
+            'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
45
+            'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime(2012, 10, 15, 17, 40, 13, 532113)'}),
46
+            'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
47
+            'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
48
+            'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}),
49
+            'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'})
50
+        },
51
+        'catalogue.attributeentity': {
52
+            'Meta': {'object_name': 'AttributeEntity'},
53
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
54
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
55
+            'slug': ('django.db.models.fields.SlugField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}),
56
+            'type': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'entities'", 'to': "orm['catalogue.AttributeEntityType']"})
57
+        },
58
+        'catalogue.attributeentitytype': {
59
+            'Meta': {'object_name': 'AttributeEntityType'},
60
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
61
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
62
+            'slug': ('django.db.models.fields.SlugField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'})
63
+        },
64
+        'catalogue.attributeoption': {
65
+            'Meta': {'object_name': 'AttributeOption'},
66
+            'group': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'options'", 'to': "orm['catalogue.AttributeOptionGroup']"}),
67
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
68
+            'option': ('django.db.models.fields.CharField', [], {'max_length': '255'})
69
+        },
70
+        'catalogue.attributeoptiongroup': {
71
+            'Meta': {'object_name': 'AttributeOptionGroup'},
72
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
73
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '128'})
74
+        },
75
+        'catalogue.category': {
76
+            'Meta': {'ordering': "['full_name']", 'object_name': 'Category'},
77
+            'depth': ('django.db.models.fields.PositiveIntegerField', [], {}),
78
+            'description': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
79
+            'full_name': ('django.db.models.fields.CharField', [], {'max_length': '1024', 'db_index': 'True'}),
80
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
81
+            'image': ('django.db.models.fields.files.ImageField', [], {'max_length': '100', 'null': 'True', 'blank': 'True'}),
82
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
83
+            'numchild': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
84
+            'path': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '255'}),
85
+            'slug': ('django.db.models.fields.SlugField', [], {'max_length': '1024', 'db_index': 'True'})
86
+        },
87
+        'catalogue.option': {
88
+            'Meta': {'object_name': 'Option'},
89
+            'code': ('django.db.models.fields.SlugField', [], {'unique': 'True', 'max_length': '128', 'db_index': 'True'}),
90
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
91
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
92
+            'type': ('django.db.models.fields.CharField', [], {'default': "'Required'", 'max_length': '128'})
93
+        },
94
+        'catalogue.product': {
95
+            'Meta': {'ordering': "['-date_created']", 'object_name': 'Product'},
96
+            'attributes': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['catalogue.ProductAttribute']", 'through': "orm['catalogue.ProductAttributeValue']", 'symmetrical': 'False'}),
97
+            'categories': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['catalogue.Category']", 'through': "orm['catalogue.ProductCategory']", 'symmetrical': 'False'}),
98
+            'date_created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}),
99
+            'date_updated': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}),
100
+            'description': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
101
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
102
+            'is_discountable': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
103
+            'parent': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'variants'", 'null': 'True', 'to': "orm['catalogue.Product']"}),
104
+            'product_class': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['catalogue.ProductClass']", 'null': 'True'}),
105
+            'product_options': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['catalogue.Option']", 'symmetrical': 'False', 'blank': 'True'}),
106
+            'recommended_products': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['catalogue.Product']", 'symmetrical': 'False', 'through': "orm['catalogue.ProductRecommendation']", 'blank': 'True'}),
107
+            'related_products': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "'relations'", 'blank': 'True', 'to': "orm['catalogue.Product']"}),
108
+            'score': ('django.db.models.fields.FloatField', [], {'default': '0.0', 'db_index': 'True'}),
109
+            'slug': ('django.db.models.fields.SlugField', [], {'max_length': '255', 'db_index': 'True'}),
110
+            'status': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '128', 'null': 'True', 'blank': 'True'}),
111
+            'title': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
112
+            'upc': ('django.db.models.fields.CharField', [], {'max_length': '64', 'unique': 'True', 'null': 'True', 'blank': 'True'})
113
+        },
114
+        'catalogue.productattribute': {
115
+            'Meta': {'ordering': "['code']", 'object_name': 'ProductAttribute'},
116
+            'code': ('django.db.models.fields.SlugField', [], {'max_length': '128', 'db_index': 'True'}),
117
+            'entity_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['catalogue.AttributeEntityType']", 'null': 'True', 'blank': 'True'}),
118
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
119
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
120
+            'option_group': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['catalogue.AttributeOptionGroup']", 'null': 'True', 'blank': 'True'}),
121
+            'product_class': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'attributes'", 'null': 'True', 'to': "orm['catalogue.ProductClass']"}),
122
+            'required': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
123
+            'type': ('django.db.models.fields.CharField', [], {'default': "'text'", 'max_length': '20'})
124
+        },
125
+        'catalogue.productattributevalue': {
126
+            'Meta': {'object_name': 'ProductAttributeValue'},
127
+            'attribute': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['catalogue.ProductAttribute']"}),
128
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
129
+            'product': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'attribute_values'", 'to': "orm['catalogue.Product']"}),
130
+            'value_boolean': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
131
+            'value_date': ('django.db.models.fields.DateField', [], {'null': 'True', 'blank': 'True'}),
132
+            'value_entity': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['catalogue.AttributeEntity']", 'null': 'True', 'blank': 'True'}),
133
+            'value_float': ('django.db.models.fields.FloatField', [], {'null': 'True', 'blank': 'True'}),
134
+            'value_integer': ('django.db.models.fields.IntegerField', [], {'null': 'True', 'blank': 'True'}),
135
+            'value_option': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['catalogue.AttributeOption']", 'null': 'True', 'blank': 'True'}),
136
+            'value_richtext': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
137
+            'value_text': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'})
138
+        },
139
+        'catalogue.productcategory': {
140
+            'Meta': {'ordering': "['-is_canonical']", 'object_name': 'ProductCategory'},
141
+            'category': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['catalogue.Category']"}),
142
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
143
+            'is_canonical': ('django.db.models.fields.BooleanField', [], {'default': 'False', 'db_index': 'True'}),
144
+            'product': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['catalogue.Product']"})
145
+        },
146
+        'catalogue.productclass': {
147
+            'Meta': {'ordering': "['name']", 'object_name': 'ProductClass'},
148
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
149
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
150
+            'options': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['catalogue.Option']", 'symmetrical': 'False', 'blank': 'True'}),
151
+            'requires_shipping': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
152
+            'slug': ('django.db.models.fields.SlugField', [], {'unique': 'True', 'max_length': '128', 'db_index': 'True'}),
153
+            'track_stock': ('django.db.models.fields.BooleanField', [], {'default': 'True'})
154
+        },
155
+        'catalogue.productrecommendation': {
156
+            'Meta': {'object_name': 'ProductRecommendation'},
157
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
158
+            'primary': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'primary_recommendations'", 'to': "orm['catalogue.Product']"}),
159
+            'ranking': ('django.db.models.fields.PositiveSmallIntegerField', [], {'default': '0'}),
160
+            'recommendation': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['catalogue.Product']"})
161
+        },
162
+        'contenttypes.contenttype': {
163
+            'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"},
164
+            'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
165
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
166
+            'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
167
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '100'})
168
+        },
169
+        'customer.communicationeventtype': {
170
+            'Meta': {'object_name': 'CommunicationEventType'},
171
+            'category': ('django.db.models.fields.CharField', [], {'default': "u'Order related'", 'max_length': '255'}),
172
+            'code': ('django.db.models.fields.SlugField', [], {'max_length': '128', 'db_index': 'True'}),
173
+            'date_created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}),
174
+            'date_updated': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'blank': 'True'}),
175
+            'email_body_html_template': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
176
+            'email_body_template': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
177
+            'email_subject_template': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
178
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
179
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
180
+            'sms_template': ('django.db.models.fields.CharField', [], {'max_length': '170', 'blank': 'True'})
181
+        },
182
+        'customer.email': {
183
+            'Meta': {'object_name': 'Email'},
184
+            'body_html': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
185
+            'body_text': ('django.db.models.fields.TextField', [], {}),
186
+            'date_sent': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}),
187
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
188
+            'subject': ('django.db.models.fields.TextField', [], {'max_length': '255'}),
189
+            'user': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'emails'", 'to': "orm['auth.User']"})
190
+        },
191
+        'customer.notification': {
192
+            'Meta': {'ordering': "('-date_sent',)", 'object_name': 'Notification'},
193
+            'body': ('django.db.models.fields.TextField', [], {}),
194
+            'category': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True'}),
195
+            'date_read': ('django.db.models.fields.DateTimeField', [], {'null': 'True'}),
196
+            'date_sent': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}),
197
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
198
+            'location': ('django.db.models.fields.CharField', [], {'default': "'Inbox'", 'max_length': '32'}),
199
+            'recipient': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'notifications'", 'to': "orm['auth.User']"}),
200
+            'sender': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'null': 'True'}),
201
+            'subject': ('django.db.models.fields.CharField', [], {'max_length': '255'})
202
+        },
203
+        'customer.productalert': {
204
+            'Meta': {'object_name': 'ProductAlert'},
205
+            'date_cancelled': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}),
206
+            'date_closed': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}),
207
+            'date_confirmed': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}),
208
+            'date_created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}),
209
+            'email': ('django.db.models.fields.EmailField', [], {'db_index': 'True', 'max_length': '75', 'null': 'True', 'blank': 'True'}),
210
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
211
+            'key': ('django.db.models.fields.CharField', [], {'max_length': '128', 'null': 'True', 'db_index': 'True'}),
212
+            'product': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['catalogue.Product']"}),
213
+            'status': ('django.db.models.fields.CharField', [], {'default': "'Active'", 'max_length': '20'}),
214
+            'user': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'alerts'", 'null': 'True', 'to': "orm['auth.User']"})
215
+        }
216
+    }
217
+
218
+    complete_apps = ['customer']

+ 3
- 11
oscar/apps/customer/views.py Wyświetl plik

@@ -185,18 +185,10 @@ class AccountRegistrationView(TemplateView):
185 185
         code = self.communication_type_code
186 186
         ctx = {'user': user,
187 187
                'site': get_current_site(self.request)}
188
-        try:
189
-            event_type = CommunicationEventType.objects.get(code=code)
190
-        except CommunicationEventType.DoesNotExist:
191
-            # No event in database, attempt to find templates for this type
192
-            messages = CommunicationEventType.objects.get_and_render(code, ctx)
193
-        else:
194
-            # Create order event
195
-            messages = event_type.get_messages(ctx)
196
-
188
+        messages = CommunicationEventType.objects.get_and_render(
189
+            code, ctx)
197 190
         if messages and messages['body']:
198
-            dispatcher = Dispatcher()
199
-            dispatcher.dispatch_user_messages(user, messages)
191
+            Dispatcher().dispatch_user_messages(user, messages)
200 192
 
201 193
     def get(self, request, *args, **kwargs):
202 194
         context = self.get_context_data(*args, **kwargs)

+ 3
- 0
oscar/apps/dashboard/app.py Wyświetl plik

@@ -12,6 +12,7 @@ from oscar.apps.dashboard.offers.app import application as offers_app
12 12
 from oscar.apps.dashboard.ranges.app import application as ranges_app
13 13
 from oscar.apps.dashboard.reviews.app import application as reviews_app
14 14
 from oscar.apps.dashboard.vouchers.app import application as vouchers_app
15
+from oscar.apps.dashboard.communications.app import application as comms_app
15 16
 from oscar.apps.dashboard import views
16 17
 
17 18
 
@@ -29,6 +30,7 @@ class DashboardApplication(Application):
29 30
     ranges_app = ranges_app
30 31
     reviews_app = reviews_app
31 32
     vouchers_app = vouchers_app
33
+    comms_app = comms_app
32 34
 
33 35
     def get_urls(self):
34 36
         urlpatterns = patterns('',
@@ -43,6 +45,7 @@ class DashboardApplication(Application):
43 45
             url(r'^ranges/', include(self.ranges_app.urls)),
44 46
             url(r'^reviews/', include(self.reviews_app.urls)),
45 47
             url(r'^vouchers/', include(self.vouchers_app.urls)),
48
+            url(r'^comms/', include(self.comms_app.urls)),
46 49
         )
47 50
         return self.post_process_urls(urlpatterns)
48 51
 

+ 0
- 0
oscar/apps/dashboard/communications/__init__.py Wyświetl plik


+ 31
- 0
oscar/apps/dashboard/communications/app.py Wyświetl plik

@@ -0,0 +1,31 @@
1
+from django.conf.urls import patterns, url
2
+from django.utils.translation import ugettext_lazy as _
3
+from django.contrib.admin.views.decorators import staff_member_required
4
+
5
+from oscar.core.application import Application
6
+from oscar.apps.dashboard.communications import views
7
+from oscar.apps.dashboard.nav import register, Node
8
+
9
+node = Node(_('Communications'))
10
+node.add_child(Node(_('Emails'), 'dashboard:comms-list'))
11
+register(node, 35)
12
+
13
+
14
+class CommsDashboardApplication(Application):
15
+    name = None
16
+    list_view = views.ListView
17
+    update_view = views.UpdateView
18
+
19
+    def get_urls(self):
20
+        urlpatterns = patterns('',
21
+            url(r'^$', self.list_view.as_view(), name='comms-list'),
22
+            url(r'^(?P<code>\w+)/$', self.update_view.as_view(),
23
+                name='comms-update'),
24
+        )
25
+        return self.post_process_urls(urlpatterns)
26
+
27
+    def get_url_decorator(self, url_name):
28
+        return staff_member_required
29
+
30
+
31
+application = CommsDashboardApplication()

+ 86
- 0
oscar/apps/dashboard/communications/forms.py Wyświetl plik

@@ -0,0 +1,86 @@
1
+from django import forms
2
+from django.db.models import get_model
3
+from django.template import Template, TemplateSyntaxError
4
+from django.utils.translation import ugettext_lazy as _
5
+
6
+from oscar.forms import widgets
7
+
8
+CommunicationEventType = get_model('customer', 'CommunicationEventType')
9
+Order = get_model('order', 'Order')
10
+
11
+
12
+class CommunicationEventTypeForm(forms.ModelForm):
13
+    email_subject_template = forms.CharField(
14
+        label=_("Email subject template"))
15
+    email_body_template = forms.CharField(
16
+        label=_("Email body text template"), required=True,
17
+        widget=forms.widgets.Textarea(attrs={'class': 'plain'}))
18
+    email_body_html_template = forms.CharField(
19
+        label=_("Email body HTML template"), required=True,
20
+        widget=widgets.WYSIWYGTextArea)
21
+
22
+    preview_order_number = forms.CharField(
23
+        label=_("Order number"), required=False)
24
+    preview_email = forms.EmailField(label=_("Preview email"),
25
+                                     required=False)
26
+
27
+    def __init__(self, data=None, *args, **kwargs):
28
+        self.show_preview = False
29
+        self.send_preview = False
30
+        if data:
31
+            self.show_preview = 'show_preview' in data
32
+            self.send_preview = 'send_preview' in data
33
+        super(CommunicationEventTypeForm, self).__init__(data, *args, **kwargs)
34
+
35
+    def validate_template(self, value):
36
+        try:
37
+            Template(value)
38
+        except TemplateSyntaxError, e:
39
+            raise forms.ValidationError(e.message)
40
+
41
+    def clean_email_subject_template(self):
42
+        subject = self.cleaned_data['email_subject_template']
43
+        self.validate_template(subject)
44
+        return subject
45
+
46
+    def clean_email_body_template(self):
47
+        body = self.cleaned_data['email_body_template']
48
+        self.validate_template(body)
49
+        return body
50
+
51
+    def clean_email_body_html_template(self):
52
+        body = self.cleaned_data['email_body_html_template']
53
+        self.validate_template(body)
54
+        return body
55
+
56
+    def clean_preview_order_number(self):
57
+        number = self.cleaned_data['preview_order_number'].strip()
58
+        if not self.instance.is_order_related():
59
+            return number
60
+        if not self.show_preview and not self.send_preview:
61
+            return number
62
+        try:
63
+            self.preview_order = Order.objects.get(number=number)
64
+        except Order.DoesNotExist:
65
+            raise forms.ValidationError(_(
66
+                "No order found with this number"))
67
+        return number
68
+
69
+    def clean_preview_email(self):
70
+        email = self.cleaned_data['preview_email'].strip()
71
+        if not self.send_preview:
72
+            return email
73
+        if not email:
74
+            raise forms.ValidationError(_(
75
+                "Please enter an email address"))
76
+        return email
77
+
78
+    def get_preview_context(self):
79
+        ctx = {}
80
+        if hasattr(self, 'preview_order'):
81
+            ctx['order'] = self.preview_order
82
+        return ctx
83
+
84
+    class Meta:
85
+        model = CommunicationEventType
86
+        exclude = ('code', 'category', 'sms_template')

+ 0
- 0
oscar/apps/dashboard/communications/models.py Wyświetl plik


+ 89
- 0
oscar/apps/dashboard/communications/views.py Wyświetl plik

@@ -0,0 +1,89 @@
1
+from django.conf import settings
2
+from django.contrib import messages
3
+from django.contrib.sites.models import get_current_site
4
+from django.db.models import get_model
5
+from django.shortcuts import get_object_or_404
6
+from django.template import TemplateSyntaxError
7
+from django.utils.translation import ugettext_lazy as _
8
+from django.views import generic
9
+
10
+from oscar.core.loading import get_class
11
+
12
+CommunicationEventType = get_model('customer', 'CommunicationEventType')
13
+CommunicationEventTypeForm = get_class('dashboard.communications.forms',
14
+                                       'CommunicationEventTypeForm')
15
+Dispatcher = get_class('customer.utils', 'Dispatcher')
16
+
17
+
18
+class ListView(generic.ListView):
19
+    model = CommunicationEventType
20
+    template_name = 'dashboard/comms/list.html'
21
+    context_object_name = 'commtypes'
22
+
23
+
24
+class UpdateView(generic.UpdateView):
25
+    model = CommunicationEventType
26
+    form_class = CommunicationEventTypeForm
27
+    template_name = 'dashboard/comms/detail.html'
28
+    context_object_name = 'commtype'
29
+    success_url = '.'
30
+
31
+    def get_object(self, **kwargs):
32
+        return get_object_or_404(self.model,
33
+                                 code=self.kwargs['code'].upper())
34
+
35
+    def form_invalid(self, form):
36
+        messages.error(self.request,
37
+            _("The submitted form was not valid, please correct "
38
+              "the errors and resubmit"))
39
+        return super(UpdateView, self).form_invalid(form)
40
+
41
+    def form_valid(self, form):
42
+        if 'send_preview' in self.request.POST:
43
+            return self.send_preview(form)
44
+        if 'show_preview' in self.request.POST:
45
+            return self.show_preview(form)
46
+        messages.success(self.request, _("Email saved"))
47
+        return super(UpdateView, self).form_valid(form)
48
+
49
+    def get_messages_context(self, form):
50
+        ctx = {'user': self.request.user,
51
+               'site': get_current_site(self.request)}
52
+        ctx.update(form.get_preview_context())
53
+        return ctx
54
+
55
+    def show_preview(self, form):
56
+        ctx = super(UpdateView, self).get_context_data()
57
+        ctx['form'] = form
58
+
59
+        commtype = form.save(commit=False)
60
+        commtype_ctx = self.get_messages_context(form)
61
+        try:
62
+            msgs = commtype.get_messages(commtype_ctx)
63
+        except TemplateSyntaxError, e:
64
+            form.errors['__all__'] = form.error_class([e.message])
65
+            return self.render_to_response(ctx)
66
+
67
+        ctx['show_preview'] = True
68
+        ctx['preview'] = msgs
69
+        return self.render_to_response(ctx)
70
+
71
+    def send_preview(self, form):
72
+        ctx = super(UpdateView, self).get_context_data()
73
+        ctx['form'] = form
74
+
75
+        commtype = form.save(commit=False)
76
+        commtype_ctx = self.get_messages_context(form)
77
+        try:
78
+            msgs = commtype.get_messages(commtype_ctx)
79
+        except TemplateSyntaxError, e:
80
+            form.errors['__all__'] = form.error_class([e.message])
81
+            return self.render_to_response(ctx)
82
+
83
+        email = form.cleaned_data['preview_email']
84
+        dispatch = Dispatcher()
85
+        dispatch.send_email_messages(email, msgs)
86
+        messages.success(self.request,
87
+                         _("A preview email has been sent to %s") % email)
88
+
89
+        return self.render_to_response(ctx)

+ 10
- 0
oscar/forms/widgets.py Wyświetl plik

@@ -1,4 +1,5 @@
1 1
 from django.conf import settings
2
+from django import forms
2 3
 from django.template import Context
3 4
 from django.forms.widgets import FileInput
4 5
 from django.utils.encoding import force_unicode
@@ -43,3 +44,12 @@ class ImageInput(FileInput):
43 44
             'image_url': image_url,
44 45
             'image_id': "%s-image" % final_attrs['id'],
45 46
         }))
47
+
48
+
49
+class WYSIWYGTextArea(forms.Textarea):
50
+
51
+    def __init__(self, *args, **kwargs):
52
+        kwargs.setdefault('attrs', {})
53
+        kwargs['attrs'].setdefault('class', '')
54
+        kwargs['attrs']['class'] += ' wysiwyg'
55
+        super(WYSIWYGTextArea, self).__init__(*args, **kwargs)

+ 577
- 2264
oscar/static/oscar/css/dashboard.css
Plik diff jest za duży
Wyświetl plik


+ 1354
- 7346
oscar/static/oscar/css/styles.css
Plik diff jest za duży
Wyświetl plik


+ 1
- 1
oscar/static/oscar/js/oscar/dashboard.js Wyświetl plik

@@ -21,7 +21,7 @@ oscar.dashboard = {
21 21
         var options = {
22 22
             "html": true
23 23
         };
24
-        $('form.wysiwyg textarea').wysihtml5(options);
24
+        $('form.wysiwyg textarea, textarea.wysiwyg').wysihtml5(options);
25 25
 
26 26
         $('.scroll-pane').jScrollPane();
27 27
 

+ 5
- 0
oscar/static/oscar/less/dashboard.less Wyświetl plik

@@ -635,6 +635,7 @@ caption {
635 635
  .jspVerticalBar .jspArrow:focus{outline:0}
636 636
  .jspCorner{background:#eeeef4;float:left;height:100%}
637 637
  * html .jspCorner{margin:0 -3px 0 0}
638
+
638 639
 /* Changes for wysihtml5 */
639 640
 textarea {
640 641
 	width: 500px;
@@ -653,3 +654,7 @@ textarea {
653 654
 .wysihtml5-toolbar {
654 655
   .clearfix();
655 656
 }
657
+
658
+textarea.plain {
659
+	font-family: monospace;
660
+}

+ 1
- 1
oscar/templates/oscar/customer/emails/commtype_order_placed_subject.txt Wyświetl plik

@@ -1 +1 @@
1
-{% load i18n %}{% blocktrans %}Confirmation of order {{ order.number }}{% endblocktrans %}
1
+{% load i18n %}{% blocktrans with number=order.number %}Confirmation of order {{ number }}{% endblocktrans %}

+ 7
- 8
oscar/templates/oscar/customer/emails/commtype_password_reset_body.html Wyświetl plik

@@ -1,13 +1,12 @@
1 1
 {% load i18n %}{% load url from future %}{% autoescape off %}
2
-{% blocktrans %}You're receiving this e-mail because you requested a password reset for your user account at {{ site_name }}.{% endblocktrans %}
2
+<p>
3
+	{% blocktrans with name=site.name %}You're receiving this e-mail because you requested a password reset for your user account at {{ name }}.{% endblocktrans %}
4
+</p>
3 5
 
4
-{% trans "Please go to the following page and choose a new password:" %}
5
-{% block reset_link %}
6
-{{ protocol }}://{{ domain }}{% url 'django.contrib.auth.views.password_reset_confirm' uidb36=uid token=token %}
7
-{% endblock %}
6
+<p>{% trans "Please go to the following page and choose a new password:" %}</p>
7
+{{ reset_url }}
8 8
 
9
-{% trans "Thanks for using our site!" %}
10
-
11
-{% blocktrans %}The {{ site_name }} team{% endblocktrans %}
9
+<p>{% trans "Thanks for using our site!" %}</p>
10
+<p>{% blocktrans with name=site.name %}The {{ name }} team{% endblocktrans %}</p>
12 11
 
13 12
 {% endautoescape %}

+ 3
- 6
oscar/templates/oscar/customer/emails/commtype_password_reset_body.txt Wyświetl plik

@@ -1,13 +1,10 @@
1 1
 {% load i18n %}{% load url from future %}{% autoescape off %}
2
-{% blocktrans %}You're receiving this e-mail because you requested a password reset for your user account at {{ site_name }}.{% endblocktrans %}
2
+{% blocktrans with name=site.name %}You're receiving this e-mail because you requested a password reset for your user account at {{ name }}.{% endblocktrans %}
3 3
 
4 4
 {% trans "Please go to the following page and choose a new password:" %}
5
-{% block reset_link %}
6
-{{ protocol }}://{{ domain }}{% url 'django.contrib.auth.views.password_reset_confirm' uidb36=uid token=token %}
7
-{% endblock %}
5
+{{ reset_url }}
8 6
 
9 7
 {% trans "Thanks for using our site!" %}
10
-
11
-{% blocktrans %}The {{ site_name }} team{% endblocktrans %}
8
+{% blocktrans with name=site.name %}The {{ name }} team{% endblocktrans %}
12 9
 
13 10
 {% endautoescape %}

+ 1
- 1
oscar/templates/oscar/customer/emails/commtype_registration_body.html Wyświetl plik

@@ -1 +1 @@
1
-{% load i18n %}{% trans 'Thank you for registering.' %}
1
+{% load i18n %}<p>{% trans 'Thank you for registering.' %}</p>

+ 96
- 0
oscar/templates/oscar/dashboard/comms/detail.html Wyświetl plik

@@ -0,0 +1,96 @@
1
+{% extends 'dashboard/layout.html' %}
2
+{% load i18n %}
3
+
4
+{% block title %}
5
+{{ commtype.name }} | {{ block.super }}
6
+{% endblock %}
7
+
8
+{% block breadcrumbs %}
9
+<ul class="breadcrumb">
10
+    <li>
11
+        <a href="{% url dashboard:index %}">{% trans "Dashboard" %}</a>
12
+        <span class="divider">/</span>
13
+    </li>
14
+    <li>
15
+        <a href="{% url dashboard:comms-list %}">{% trans "Emails" %}</a>
16
+        <span class="divider">/</span>
17
+    </li>
18
+    <li class="active"><a href=".">{{ commtype.name }}</a></li>
19
+</ul>
20
+{% endblock %}
21
+
22
+{% block dashboard_content %}
23
+
24
+<div class="sub-header">
25
+    <h2>{{ commtype.name }}</h2>
26
+</div>
27
+
28
+<form method="post" action="." class="form-horizontal">
29
+<div class="tabbable">
30
+     {% if preview %}
31
+     <ul class="nav nav-tabs">
32
+         <li><a href="#email_form" data-toggle="tab">{% trans "Edit" %}</a></li>
33
+         <li class="active"><a href="#email_preview" data-toggle="tab">{% trans "Preview" %}</a></li>
34
+     </ul>
35
+     {% endif %}
36
+     <div class="tab-content">
37
+         <div class="tab-pane {% if not preview %}active{% endif %}" id="email_form">
38
+             {% csrf_token %}
39
+             {% include 'partials/form_field.html' with field=form.name %}
40
+             <div class="well">
41
+				 <h4>{% trans "Email content" %}</h4>
42
+				 <p>{% trans "These fields are rendered using Django's template system." %}</p>
43
+				 <p>{% trans "You can use the following variables:" %}</p>
44
+                 <dl>
45
+                     <dt><code>{% templatetag openvariable %} user.get_full_name {% templatetag closevariable %}</code></dt>
46
+					 <dd>{% trans "The full name of the user (if they have one)" %}</dd>
47
+                     <dt><code>{% templatetag openvariable %} user.email {% templatetag closevariable %}</code></dt>
48
+					 <dd>{% trans "The user's email address" %}</dd>
49
+                     <dt><code>{% templatetag openvariable %} site.name {% templatetag closevariable %}</code></dt>
50
+					 <dd>{% trans "The name of the site (eg example.com)" %}</dd>
51
+                     {% if commtype.is_order_related %}
52
+                        <dt><code>{% templatetag openvariable %} order.number {% templatetag closevariable %}</code></dt>
53
+						<dd>{% trans "Order number" %}</dd>
54
+                     {% endif %}
55
+                 </dl>
56
+             </div>
57
+             {% include 'partials/form_field.html' with field=form.email_subject_template %}
58
+             {% include 'partials/form_field.html' with field=form.email_body_template %}
59
+             {% include 'partials/form_field.html' with field=form.email_body_html_template %}
60
+             <div class="well">
61
+                 <h3>Preview</h3>
62
+                 {% if commtype.is_order_related %}
63
+				 <p>{% trans "View a preview of this email using order:" %}</p>
64
+                 {% include 'partials/form_field.html' with field=form.preview_order_number %}
65
+                 {% endif %}
66
+                 <button type="submit" class="btn btn-primary btn-large" name="show_preview">{% trans "View preview" %}</button>
67
+				 <p>{% trans "or send a preview to:" %}</p>
68
+                 {% include 'partials/form_field.html' with field=form.preview_email %}
69
+                 <button type="submit" class="btn btn-primary btn-large" name="send_preview">{% trans "Send preview email" %}</button>
70
+             </div>
71
+         </div>
72
+         <div class="tab-pane {% if preview %}active{% endif %}" id="email_preview">
73
+             <table>
74
+                 <tr>
75
+                     <th>{% trans "Subject" %}</th>
76
+                     <td>{{ preview.subject }}</td>
77
+                 </tr>
78
+                 <tr>
79
+                     <th>{% trans "Body text" %}</th>
80
+                     <td><pre>{{ preview.body }}</pre></td>
81
+                 </tr>
82
+                 <tr>
83
+                     <th>{% trans "Body HTML" %}</th>
84
+                     <td>{{ preview.html }}</td>
85
+                 </tr>
86
+             </table>
87
+         </div>
88
+     </div>
89
+</div>
90
+<div class="well">
91
+    <button type="submit" class="btn btn-primary btn-large">{% trans "Save" %}</button>
92
+    {% trans "or" %} <a href="{% url dashboard:comms-list %}">{% trans "cancel" %}</a>.
93
+</div>
94
+</form>
95
+
96
+{% endblock dashboard_content %}

+ 49
- 0
oscar/templates/oscar/dashboard/comms/list.html Wyświetl plik

@@ -0,0 +1,49 @@
1
+{% extends 'dashboard/layout.html' %}
2
+{% load i18n %}
3
+
4
+{% block title %}
5
+	{% trans "Emails" %} | {{ block.super }}
6
+{% endblock %}
7
+
8
+{% block breadcrumbs %}
9
+<ul class="breadcrumb">
10
+    <li>
11
+	<a href="{% url dashboard:index %}">{% trans "Dashboard" %}</a>
12
+        <span class="divider">/</span>
13
+    </li>
14
+	<li class="active"><a href=".">{% trans "Emails" %}</a></li>
15
+</ul>
16
+{% endblock %}
17
+
18
+{% block dashboard_content %}
19
+<div class="sub-header">
20
+	<h2>{% trans "Emails" %}</h2>
21
+</div>
22
+{% if commtypes %}
23
+<table class="table table-striped table-bordered">
24
+	<thead>
25
+		<tr>
26
+			<th>{% trans "Code" %}</th>
27
+			<th>{% trans "Name" %}</th>
28
+			<th>{% trans "Category" %}</th>
29
+			<th></th>
30
+		</tr>
31
+	</thead>
32
+	<tbody>
33
+		{% for commtype in commtypes %}
34
+		<tr>
35
+			<td>{{ commtype.code }}</td>
36
+			<td>{{ commtype.name }}</td>
37
+			<td>{{ commtype.category }}</td>
38
+			<td>
39
+				<a class="btn btn-success" href="{% url dashboard:comms-update commtype.code|lower %}">{% trans "Edit" %}</a>
40
+			</td>
41
+		</tr>
42
+		{% endfor %}
43
+	</tbody>
44
+</table>
45
+{% include "partials/pagination.html" %}
46
+{% else %}
47
+<p>{% trans "There are no defined emails to edit." %}</p>
48
+{% endif %}
49
+{% endblock dashboard_content %}

+ 1
- 0
oscar/templates/oscar/dashboard/pages/index.html Wyświetl plik

@@ -3,6 +3,7 @@
3 3
 {% load i18n %}
4 4
 
5 5
 {% block body_class %}pages{% endblock %}
6
+
6 7
 {% block title %}
7 8
 {% trans "Pages" %} | {{ block.super }}
8 9
 {% endblock %}

+ 4
- 1
oscar/templates/oscar/registration/password_reset_done.html Wyświetl plik

@@ -15,7 +15,10 @@
15 15
 
16 16
 {% block content %}
17 17
 
18
-    <h1>{% trans 'Password reset successful' %}</h1>
18
+    <div class="sub-header">
19
+        <h2>{% trans "Password reset successful" %}</h2>
20
+    </div>
21
+
19 22
     <p>{% trans "We've e-mailed you instructions for setting your password to the e-mail address you submitted. You should be receiving it shortly." %}</p>
20 23
 
21 24
 {% endblock %}

+ 4
- 18
oscar/templates/oscar/registration/password_reset_form.html Wyświetl plik

@@ -19,27 +19,13 @@
19 19
         <h2>{% trans "Password reset" %}</h2>
20 20
     </div>
21 21
 
22
-    <form id="password_reset_form" action="" method="post" class="form-stacked well">
23
-    {% csrf_token %}
24
-
22
+    <form id="password_reset_form" action="." method="post" class="form-horizontal">
23
+		{% csrf_token %}
25 24
         <p>{% trans "Forgotten your password? Enter your e-mail address below, and we'll e-mail instructions for setting a new one." %}</p>
26
-
27
-        <div class="control-group {% for error in form.email.errors %}error{% endfor %}">
28
-            <label for="id_email" class="control-label {% if form.email.field.required %}required{% endif %}">{% trans 'E-mail address:' %}{% if form.email.field.required %} <span>*</span>{% endif %}</label>
29
-            <div class="controls">
30
-                {{ form.email }}
31
-                {% for error in form.email.errors %}
32
-                    <span class="help-block">
33
-                        {{ error|escape }}
34
-                    </span>
35
-                {% endfor %}
36
-            </div>
37
-        </div>
38
-
25
+		{% include 'partials/form_fields.html' %}
39 26
         <div class="form-actions">
40
-             <input type="submit" class="btn btn-primary" value="{% trans 'Reset my password' %}" />
27
+			<button type="submit" class="btn btn-primary btn-large">{% trans 'Send reset email' %}</button>
41 28
         </div>
42
-
43 29
     </form>
44 30
 
45 31
 {% endblock %}

+ 32
- 0
sites/_fixtures/comms.json Wyświetl plik

@@ -0,0 +1,32 @@
1
+[
2
+    {
3
+        "pk": 1,
4
+        "model": "customer.communicationeventtype",
5
+        "fields": {
6
+            "category": "User related",
7
+            "email_body_html_template": "<p>Thank you for registering</p>",
8
+            "code": "REGISTRATION",
9
+            "sms_template": "",
10
+            "name": "Newly registered user",
11
+            "email_subject_template": "Welcome to {{ site.name }}",
12
+            "date_updated": "2012-10-16T10:23:23.059Z",
13
+            "email_body_template": "Thank you for registering",
14
+            "date_created": "2012-10-11T15:09:56.317Z"
15
+        }
16
+    },
17
+    {
18
+        "pk": 2,
19
+        "model": "customer.communicationeventtype",
20
+        "fields": {
21
+            "category": "User related",
22
+            "email_body_html_template": "<p>You're receiving this e-mail because you requested a password reset for your user account at {{ site.name }}.</p>\r\n\r\n<p>Please go to the following page and choose a new password:</p>\r\n{{ reset_url }} \r\n\r\n<p>Thanks for using our site!</p>\r\n<p>The {{ site.name }} team</p>",
23
+            "code": "PASSWORD_RESET",
24
+            "sms_template": "",
25
+            "name": "Forgotten password",
26
+            "email_subject_template": "Resetting your password at {{ site.name }}",
27
+            "date_updated": "2012-10-16T10:21:32.909Z",
28
+            "email_body_template": "You're receiving this e-mail because you requested a password reset for your user account at {{ site.name }}.\r\n\r\nPlease go to the following page and choose a new password:\r\n{{ reset_url }} \r\n\r\nThanks for using our site!\r\nThe {{ site.name }} team",
29
+            "date_created": "2012-10-16T09:47:43.471Z"
30
+        }
31
+    }
32
+]

+ 23
- 0
sites/puppet/manifests/site.pp Wyświetl plik

@@ -0,0 +1,23 @@
1
+# Set default path for all Exec tasks
2
+Exec {
3
+	path => "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
4
+}
5
+
6
+node precise64 {
7
+	include userconfig
8
+
9
+	# Memcached
10
+	class {"memcached": max_memory => 64 }
11
+
12
+	# Python
13
+	$virtualenv = "/var/www/virtualenv"
14
+	include python::dev
15
+	include python::venv
16
+	python::venv::isolate { $virtualenv:
17
+	    requirements => "/vagrant/requirements.txt"
18
+	}
19
+    exec {"install-oscar":
20
+	    command => "$virtualenv/bin/python /vagrant/setup.py develop",
21
+		require => Python::Venv::Isolate[$virtualenv]
22
+	}
23
+}

+ 8
- 0
sites/puppet/modules/memcached/Modulefile Wyświetl plik

@@ -0,0 +1,8 @@
1
+name    'saz-memcached'
2
+version '2.0.2'
3
+source 'git://github.com/saz/puppet-memcached.git'
4
+author 'saz'
5
+license 'Apache License, Version 2.0'
6
+summary 'UNKNOWN'
7
+description 'Manage memcached via Puppet'
8
+project_page 'https://github.com/saz/puppet-memcached'

+ 9
- 0
sites/puppet/modules/memcached/README-DEVELOPER Wyświetl plik

@@ -0,0 +1,9 @@
1
+In order to run tests:
2
+  - puppet and facter must be installed and available in Ruby's LOADPATH
3
+  - the latest revision of rspec-puppet must be installed
4
+  - rake, and rspec2 must be install
5
+
6
+  - the name of the module directory needs to be memcached
7
+
8
+to run all tests:
9
+  rake spec

+ 29
- 0
sites/puppet/modules/memcached/README.md Wyświetl plik

@@ -0,0 +1,29 @@
1
+
2
+# puppet-memcached
3
+
4
+Manage memcached via Puppet
5
+
6
+## How to use
7
+
8
+### Use roughly 90% of memory
9
+
10
+```
11
+    class { 'memcached': }
12
+```
13
+
14
+### Set a fixed memory limit in MB
15
+
16
+```
17
+    class { 'memcached':
18
+      max_memory => 2048
19
+    }
20
+```
21
+
22
+### Other class parameters
23
+
24
+* $logfile = '/var/log/memcached.log'
25
+* $listen_ip = '0.0.0.0'
26
+* $tcp_port = 11211
27
+* $udp_port = 11211
28
+* $user = '' (OS specific setting, see params.pp)
29
+* $max_connections = 8192

+ 17
- 0
sites/puppet/modules/memcached/Rakefile Wyświetl plik

@@ -0,0 +1,17 @@
1
+require 'rubygems'
2
+require 'rake'
3
+require 'rspec/core/rake_task'
4
+
5
+task :default => [:spec]
6
+
7
+desc "Run all module spec tests (Requires rspec-puppet gem)"
8
+RSpec::Core::RakeTask.new(:spec) do |t|
9
+  t.rspec_opts = ['--color']
10
+  # ignores fixtures directory.
11
+  t.pattern = 'spec/{classes,defines,unit}/**/*_spec.rb'
12
+end
13
+
14
+desc "Build package"
15
+task :build do
16
+  system("puppet-module build")
17
+end

+ 33
- 0
sites/puppet/modules/memcached/manifests/init.pp Wyświetl plik

@@ -0,0 +1,33 @@
1
+class memcached(
2
+  $package_ensure  = 'present',
3
+  $logfile         = '/var/log/memcached.log',
4
+  $max_memory      = false,
5
+  $listen_ip       = '0.0.0.0',
6
+  $tcp_port        = 11211,
7
+  $udp_port        = 11211,
8
+  $user            = $::memcached::params::user,
9
+  $max_connections = '8192',
10
+  $verbosity       = undef,
11
+  $unix_socket     = undef
12
+) inherits memcached::params {
13
+
14
+  package { $memcached::params::package_name:
15
+    ensure => $package_ensure,
16
+  }
17
+
18
+  file { $memcached::params::config_file:
19
+    owner   => 'root',
20
+    group   => 'root',
21
+    mode    => '0644',
22
+    content => template($memcached::params::config_tmpl),
23
+    require => Package[$memcached::params::package_name],
24
+  }
25
+
26
+  service { $memcached::params::service_name:
27
+    ensure     => running,
28
+    enable     => true,
29
+    hasrestart => true,
30
+    hasstatus  => false,
31
+    subscribe  => File[$memcached::params::config_file],
32
+  }
33
+}

+ 21
- 0
sites/puppet/modules/memcached/manifests/params.pp Wyświetl plik

@@ -0,0 +1,21 @@
1
+class memcached::params {
2
+  case $::osfamily {
3
+    'Debian': {
4
+      $package_name = 'memcached'
5
+      $service_name = 'memcached'
6
+      $config_file  = '/etc/memcached.conf'
7
+      $config_tmpl  = "${module_name}/memcached.conf.erb"
8
+      $user = 'nobody'
9
+    }
10
+    'RedHat': {
11
+      $package_name = 'memcached'
12
+      $service_name = 'memcached'
13
+      $config_file  = '/etc/sysconfig/memcached'
14
+      $config_tmpl  = "${module_name}/memcached_sysconfig.erb"
15
+      $user = 'memcached'
16
+    }
17
+    default: {
18
+      fail("Unsupported platform: ${::osfamily}")
19
+    }
20
+  }
21
+}

+ 31
- 0
sites/puppet/modules/memcached/metadata.json Wyświetl plik

@@ -0,0 +1,31 @@
1
+{
2
+  "license": "Apache License, Version 2.0",
3
+  "source": "git://github.com/saz/puppet-memcached.git",
4
+  "description": "Manage memcached via Puppet",
5
+  "dependencies": [
6
+
7
+  ],
8
+  "summary": "UNKNOWN",
9
+  "types": [
10
+
11
+  ],
12
+  "author": "saz",
13
+  "version": "2.0.2",
14
+  "name": "saz-memcached",
15
+  "checksums": {
16
+    "templates/memcached_sysconfig.erb": "fdcbb4381b08683291fe7da542a6435f",
17
+    "templates/memcached.conf.erb": "c86e1c762e655461c0651e16e094b52e",
18
+    "README-DEVELOPER": "d45048731ddb158a56a1b26293fb5dbf",
19
+    "spec/fixtures/manifests/site.pp": "d41d8cd98f00b204e9800998ecf8427e",
20
+    "spec/classes/memcached_spec.rb": "abaa4afe238f7cf599bea859a8d37bfa",
21
+    "tests/init.pp": "e798f4999ba392f3c0fce0d5290c263f",
22
+    "Rakefile": "5e1b5fb743446de634dbba3093a255cf",
23
+    "README.md": "02b0390847cb84c98c2c53c042f97b62",
24
+    "spec/spec.opts": "a600ded995d948e393fbe2320ba8e51c",
25
+    "manifests/params.pp": "6eafeac3502dec0a85b13aa9b7b2bac2",
26
+    "Modulefile": "41b0bfec37d8a4d807352b7d94b2e76e",
27
+    "manifests/init.pp": "0030a2858c6866425183adbd651d17e7",
28
+    "spec/spec_helper.rb": "983ae671e9a03e34b55b16e0476bf1c8"
29
+  },
30
+  "project_page": "https://github.com/saz/puppet-memcached"
31
+}

+ 101
- 0
sites/puppet/modules/memcached/spec/classes/memcached_spec.rb Wyświetl plik

@@ -0,0 +1,101 @@
1
+require 'spec_helper'
2
+describe 'memcached' do
3
+
4
+  let :default_params do
5
+    {
6
+      :package_ensure  => 'present',
7
+      :logfile         => '/var/log/memcached.log',
8
+      :max_memory      => false,
9
+      :listen_ip       => '0.0.0.0',
10
+      :tcp_port        => '11211',
11
+      :udp_port        => '11211',
12
+      :user            => 'nobody',
13
+      :max_connections => '8192'
14
+    }
15
+  end
16
+
17
+  [ {},
18
+    {
19
+      :package_ensure  => 'latest',
20
+      :logfile         => '/var/log/memcached.log',
21
+      :max_memory      => '2',
22
+      :listen_ip       => '127.0.0.1',
23
+      :tcp_port        => '11212',
24
+      :udp_port        => '11213',
25
+      :user            => 'somebdy',
26
+      :max_connections => '8193'
27
+    }
28
+  ].each do |param_set|
29
+    describe "when #{param_set == {} ? "using default" : "specifying"} class parameters" do
30
+
31
+      let :param_hash do
32
+        default_params.merge(param_set)
33
+      end
34
+
35
+      let :params do
36
+        param_set
37
+      end
38
+
39
+      ['Debian'].each do |osfamily|
40
+
41
+        let :facts do
42
+          {
43
+            :osfamily => osfamily,
44
+            :memorysize => '1',
45
+            :processorcount => '1',
46
+          }
47
+        end
48
+
49
+        describe "on supported osfamily: #{osfamily}" do
50
+
51
+          it { should contain_class('memcached::params') }
52
+
53
+          it { should contain_package('memcached').with_ensure(param_hash[:package_ensure]) }
54
+
55
+          it { should contain_file('/etc/memcached.conf').with(
56
+            'owner'   => 'root',
57
+            'group'   => 'root'
58
+          )}
59
+
60
+          it { should contain_service('memcached').with(
61
+            'ensure'     => 'running',
62
+            'enable'     => true,
63
+            'hasrestart' => true,
64
+            'hasstatus'  => false
65
+          )}
66
+
67
+          it 'should compile the template based on the class parameters' do
68
+            content = param_value(
69
+              subject,
70
+              'file',
71
+              '/etc/memcached.conf',
72
+              'content'
73
+            )
74
+            expected_lines = [
75
+              "logfile #{param_hash[:logfile]}",
76
+              "-l #{param_hash[:listen_ip]}",
77
+              "-p #{param_hash[:tcp_port]}",
78
+              "-U #{param_hash[:udp_port]}",
79
+              "-u #{param_hash[:user]}",
80
+              "-c #{param_hash[:max_connections]}",
81
+              "-t #{facts[:processorcount]}"
82
+            ]
83
+            if(param_hash[:max_memory])
84
+              expected_lines.push("-m #{param_hash[:max_memory]}")
85
+            else
86
+              expected_lines.push("-m #{((facts[:memorysize].to_f*1024)*0.95).floor}")
87
+            end
88
+            (content.split("\n") & expected_lines).should =~ expected_lines
89
+          end
90
+        end
91
+      end
92
+      ['Redhat'].each do |osfamily|
93
+        describe 'on supported platform' do
94
+          it 'should fail' do
95
+
96
+          end
97
+        end
98
+      end
99
+    end
100
+  end
101
+end

+ 0
- 0
sites/puppet/modules/memcached/spec/fixtures/manifests/site.pp Wyświetl plik


+ 6
- 0
sites/puppet/modules/memcached/spec/spec.opts Wyświetl plik

@@ -0,0 +1,6 @@
1
+--format
2
+s
3
+--colour
4
+--loadby
5
+mtime
6
+--backtrace

+ 13
- 0
sites/puppet/modules/memcached/spec/spec_helper.rb Wyświetl plik

@@ -0,0 +1,13 @@
1
+require 'puppet'
2
+require 'rubygems'
3
+require 'rspec-puppet'
4
+
5
+# get the value of a certain parameter
6
+def param_value(subject, type, title, param)
7
+  subject.resource(type, title).send(:parameters)[param.to_sym]
8
+end
9
+
10
+RSpec.configure do |c|
11
+  c.module_path = File.join(File.dirname(__FILE__), '../../')
12
+  c.manifest_dir = File.expand_path(File.join(File.dirname(__FILE__), 'fixtures/manifests'))
13
+end

+ 46
- 0
sites/puppet/modules/memcached/templates/memcached.conf.erb Wyświetl plik

@@ -0,0 +1,46 @@
1
+# File managed by puppet
2
+
3
+# Run memcached as a daemon.
4
+-d
5
+
6
+# pidfile
7
+-P /var/run/memcached.pid
8
+
9
+# Log memcached's output
10
+logfile <%= logfile %>
11
+
12
+<% if @verbosity -%>
13
+# Verbosity
14
+-<%= verbosity %>
15
+<% end -%>
16
+
17
+# Use <num> MB memory max to use for object storage.
18
+<% if max_memory -%>
19
+-m <%= max_memory %>
20
+<% else -%>
21
+-m <%= ((memorysize.to_f*1024)*0.95).floor %>
22
+<% end -%>
23
+
24
+<% if @unix_socket -%>
25
+# UNIX socket path to listen on
26
+-s <%= unix_socket %>
27
+<% else -%>
28
+
29
+# IP to listen on
30
+-l <%= listen_ip %>
31
+
32
+# TCP port to listen on
33
+-p <%= tcp_port %>
34
+
35
+# UDP port to listen on
36
+-U <%= udp_port %>
37
+<% end -%>
38
+
39
+# Run daemon as user
40
+-u <%= user %>
41
+
42
+# Limit the number of simultaneous incoming connections.
43
+-c <%= max_connections %>
44
+
45
+# Number of threads to use to process incoming requests.
46
+-t <%= processorcount %>

+ 5
- 0
sites/puppet/modules/memcached/templates/memcached_sysconfig.erb Wyświetl plik

@@ -0,0 +1,5 @@
1
+PORT="<%= udp_port %>"
2
+USER="<%= user %>"
3
+MAXCONN="<%= max_connections %>"
4
+CACHESIZE="<%= max_memory %>"
5
+OPTIONS=""

+ 1
- 0
sites/puppet/modules/memcached/tests/init.pp Wyświetl plik

@@ -0,0 +1 @@
1
+include memcached

+ 1
- 0
sites/puppet/modules/python

@@ -0,0 +1 @@
1
+Subproject commit d12cc81c170e5dbc78973f30a35e1b36da667a89

+ 1
- 0
sites/puppet/modules/userconfig

@@ -0,0 +1 @@
1
+Subproject commit a6faba58ebfeef2ea84b6614853da24b383e1409

+ 40
- 0
tests/functional/dashboard/communication_tests.py Wyświetl plik

@@ -0,0 +1,40 @@
1
+from django_dynamic_fixture import G
2
+from django.contrib.auth.models import User
3
+from django.core.urlresolvers import reverse
4
+from django.core import mail
5
+
6
+from oscar.test import WebTestCase
7
+from oscar.apps.customer.models import CommunicationEventType
8
+
9
+
10
+class TestAnAdmin(WebTestCase):
11
+
12
+    def setUp(self):
13
+        self.staff = G(User, is_staff=True, username='1234')
14
+        self.commtype = CommunicationEventType.objects.create(
15
+            code='PASSWORD_RESET', name="Password reset",
16
+            category=CommunicationEventType.USER_RELATED)
17
+
18
+    def test_can_preview_an_email(self):
19
+        list_page = self.app.get(reverse('dashboard:comms-list'),
20
+                                 user=self.staff)
21
+        update_page = list_page.click('Edit')
22
+        form = update_page.form
23
+        form['email_subject_template'] = 'Hello {{ user.username }}'
24
+        form['email_body_template'] = 'Hello {{ user.username }}'
25
+        form['email_body_html_template'] = 'Hello {{ user.username }}'
26
+        preview = form.submit('show_preview')
27
+        self.assertTrue('Hello 1234' in preview.content)
28
+
29
+    def test_can_send_a_preview_email(self):
30
+        list_page = self.app.get(reverse('dashboard:comms-list'),
31
+                                 user=self.staff)
32
+        update_page = list_page.click('Edit')
33
+        form = update_page.form
34
+        form['email_subject_template'] = 'Hello {{ user.username }}'
35
+        form['email_body_template'] = 'Hello {{ user.username }}'
36
+        form['email_body_html_template'] = 'Hello {{ user.username }}'
37
+        form['preview_email'] = 'testing@example.com'
38
+        form.submit('send_preview')
39
+
40
+        self.assertEqual(len(mail.outbox), 1)

Ładowanie…
Anuluj
Zapisz