Преглед на файлове

Merge branch 'master' into features/category-management

Conflicts:
	requirements.txt
master
David Winterbottom преди 13 години
родител
ревизия
ed19b59721
променени са 100 файла, в които са добавени 1152 реда и са изтрити 657 реда
  1. 1
    1
      .travis.yml
  2. 9
    6
      README.rst
  3. 5
    4
      create_migration.py
  4. 7
    5
      docs/source/conf.py
  5. 1
    1
      docs/source/getting_started.rst
  6. 17
    1
      docs/source/index.rst
  7. 40
    0
      docs/source/recipes/how_to_configure_stock_messaging.rst
  8. 31
    4
      docs/source/recipes/how_to_disable_an_app.rst
  9. 99
    3
      docs/source/reference/signals.rst
  10. 4
    2
      oscar/__init__.py
  11. 2
    2
      oscar/app.py
  12. 21
    5
      oscar/apps/basket/abstract_models.py
  13. 5
    4
      oscar/apps/basket/middleware.py
  14. 1
    1
      oscar/apps/basket/signals.py
  15. 7
    1
      oscar/apps/basket/views.py
  16. 17
    8
      oscar/apps/catalogue/abstract_models.py
  17. 161
    0
      oscar/apps/catalogue/migrations/0004_auto__chg_field_productattributevalue_value_boolean.py
  18. 2
    0
      oscar/apps/catalogue/signals.py
  19. 1
    1
      oscar/apps/catalogue/views.py
  20. 3
    2
      oscar/apps/checkout/forms.py
  21. 1
    2
      oscar/apps/checkout/signals.py
  22. 83
    52
      oscar/apps/checkout/utils.py
  23. 117
    106
      oscar/apps/checkout/views.py
  24. 2
    3
      oscar/apps/customer/utils.py
  25. 58
    56
      oscar/apps/dashboard/catalogue/forms.py
  26. 2
    2
      oscar/apps/dashboard/catalogue/views.py
  27. 9
    4
      oscar/apps/partner/abstract_models.py
  28. 0
    4
      oscar/apps/partner/tests/__init__.py
  29. 7
    0
      oscar/apps/partner/wrappers.py
  30. 0
    2
      oscar/apps/payment/tests/__init__.py
  31. 1
    0
      oscar/apps/promotions/context_processors.py
  32. 8
    2
      oscar/apps/promotions/models.py
  33. 0
    61
      oscar/apps/promotions/tests.py
  34. 0
    42
      oscar/apps/search/tests.py
  35. 4
    4
      oscar/apps/shipping/repository.py
  36. 5
    1
      oscar/apps/voucher/abstract_models.py
  37. 7
    7
      oscar/core/loading.py
  38. 4
    2
      oscar/defaults.py
  39. 4
    4
      oscar/static/oscar/js/oscar/checkout.js
  40. 0
    1
      oscar/static/oscar/js/oscar/ui.js
  41. 2
    2
      oscar/templates/basket/basket.html
  42. 1
    1
      oscar/templates/checkout/preview.html
  43. 5
    1
      oscar/templates/customer/anon-order.html
  44. 10
    5
      oscar/templates/dashboard/catalogue/product_update.html
  45. 12
    8
      oscar/templates/dashboard/orders/order_detail.html
  46. 10
    2
      oscar/templates/dashboard/reviews/review_list.html
  47. 1
    1
      oscar/templates/dashboard/vouchers/voucher_delete.html
  48. 5
    1
      oscar/test/__init__.py
  49. 17
    56
      oscar/test/helpers.py
  50. 0
    38
      oscar/tests.py
  51. 1
    1
      pre-commit.sh
  52. 4
    1
      requirements.txt
  53. 7
    5
      runtests.py
  54. 9
    8
      tests/config.py
  55. 0
    0
      tests/functional/__init__.py
  56. 23
    26
      tests/functional/basket_tests.py
  57. 21
    0
      tests/functional/catalogue_tests.py
  58. 75
    12
      tests/functional/checkout_tests.py
  59. 2
    19
      tests/functional/customer_tests.py
  60. 0
    0
      tests/functional/dashboard/__init__.py
  61. 0
    0
      tests/functional/dashboard/catalogue_tests.py
  62. 0
    0
      tests/functional/dashboard/offer_tests.py
  63. 0
    0
      tests/functional/dashboard/order_tests.py
  64. 0
    0
      tests/functional/dashboard/page_tests.py
  65. 0
    0
      tests/functional/dashboard/promotion_tests.py
  66. 0
    0
      tests/functional/dashboard/range_tests.py
  67. 0
    0
      tests/functional/dashboard/reports_tests.py
  68. 0
    0
      tests/functional/dashboard/review_tests.py
  69. 0
    0
      tests/functional/dashboard/user_tests.py
  70. 0
    10
      tests/functional/dashboard_tests.py
  71. 0
    0
      tests/site/__init__.py
  72. 0
    0
      tests/site/apps/__init__.py
  73. 0
    0
      tests/site/apps/shipping/__init__.py
  74. 0
    0
      tests/site/apps/shipping/methods.py
  75. 0
    0
      tests/site/apps/shipping/models.py
  76. 0
    0
      tests/site/shipping/__init__.py
  77. 0
    0
      tests/site/shipping/methods.py
  78. 0
    0
      tests/site/shipping/models.py
  79. 0
    0
      tests/site/templates/base.html
  80. 0
    0
      tests/site/templates/layout.html
  81. 0
    0
      tests/site/urls.py
  82. 0
    0
      tests/unit/__init__.py
  83. 4
    6
      tests/unit/address_tests.py
  84. 54
    0
      tests/unit/basket_tests.py
  85. 27
    28
      tests/unit/catalogue_tests.py
  86. 43
    0
      tests/unit/checkout_tests.py
  87. 50
    18
      tests/unit/core_tests.py
  88. 19
    0
      tests/unit/customer_tests.py
  89. 0
    0
      tests/unit/logging_tests.py
  90. 0
    0
      tests/unit/offer_tests.py
  91. 1
    2
      tests/unit/order_tests.py
  92. 0
    0
      tests/unit/partner/__init__.py
  93. 0
    0
      tests/unit/partner/fixtures/books-small-semicolon.csv
  94. 0
    0
      tests/unit/partner/fixtures/books-small.csv
  95. 0
    0
      tests/unit/partner/import_tests.py
  96. 3
    0
      tests/unit/partner/model_tests.py
  97. 0
    0
      tests/unit/partner/stock_tests.py
  98. 0
    0
      tests/unit/partner/wrapper_tests.py
  99. 0
    0
      tests/unit/payment_tests.py
  100. 0
    0
      tests/unit/promotion_tests.py

+ 1
- 1
.travis.yml Целия файл

@@ -6,4 +6,4 @@ install:
6 6
 script:
7 7
   - pip install -r requirements.txt
8 8
   - ./setup.py develop
9
-  - ./run_tests.py
9
+  - ./runtests.py

+ 9
- 6
README.rst Целия файл

@@ -10,12 +10,13 @@ core functionality can be customised to suit the needs of your project.  This
10 10
 allows it to handle a wide range of e-commerce requirements, from large-scale B2C
11 11
 sites to complex B2B sites rich in domain-specific business logic.
12 12
 
13
-This README is just a stub - see the following links for more details
14
-information:
13
+More information:
15 14
 
16 15
 * `Official homepage`_ 
17
-* `Demo site`_ (experimental) 
18
-* `Documentation`_ on `readthedocs.org`_
16
+* `Sandbox site`_ (an hourly build of the unstable master branch - it's
17
+  experimental but feel free to explore and get a feel for the base Oscar
18
+  install) 
19
+* `Documentation`_ on the excellent `readthedocs.org`_
19 20
 * `Google Group`_ - the mailing list is django-oscar@googlegroups.com
20 21
 * `Continuous integration homepage`_ on `travis-ci.org`_
21 22
 * `Twitter account for news and updates`_
@@ -24,7 +25,7 @@ information:
24 25
 .. image:: https://secure.travis-ci.org/tangentlabs/django-oscar.png
25 26
 
26 27
 .. _`Official homepage`: http://oscarcommerce.com
27
-.. _`Demo site`: http://sandbox.oscar.tangentlabs.co.uk
28
+.. _`Sandbox site`: http://sandbox.oscar.tangentlabs.co.uk
28 29
 .. _`Documentation`: http://django-oscar.readthedocs.org/en/latest/
29 30
 .. _`readthedocs.org`: http://readthedocs.org
30 31
 .. _`Continuous integration homepage`: http://travis-ci.org/#!/tangentlabs/django-oscar 
@@ -34,7 +35,8 @@ information:
34 35
 .. _`Google Group`: https://groups.google.com/forum/?fromgroups#!forum/django-oscar
35 36
 
36 37
 Oscar was written by `David Winterbottom`_ (`@codeinthehole`_) and is developed
37
-and maintained by `Tangent Labs`_, a London-based digital agency.
38
+and maintained by `Tangent Labs`_, a London-based digital agency, with help from
39
+`Mirumee`.
38 40
 
39 41
 Oscar is released under the permissive `New BSD license`_.
40 42
 
@@ -42,6 +44,7 @@ Oscar is released under the permissive `New BSD license`_.
42 44
 .. _`@codeinthehole`: https://twitter.com/codeinthehole
43 45
 .. _`Tangent Labs`: http://www.tangentlabs.co.uk
44 46
 .. _`New BSD license`: https://github.com/tangentlabs/django-oscar/blob/master/LICENSE
47
+.. _`Mirumee`: http://mirumee.com/
45 48
 
46 49
 Case studies
47 50
 ------------

+ 5
- 4
create_migration.py Целия файл

@@ -1,9 +1,11 @@
1 1
 #!/usr/bin/env python
2
-import sys
3
-import os
2
+"""
3
+Convenience script to create migrations
4
+"""
4 5
 from optparse import OptionParser
5 6
 
6
-import tests.config
7
+from tests.config import configure
8
+configure()
7 9
 
8 10
 
9 11
 def create_migration(app_label, **kwargs):
@@ -20,4 +22,3 @@ if __name__ == '__main__':
20 22
                       action='store_true', default=False)
21 23
     (options, args) = parser.parse_args()
22 24
     create_migration(args[0], initial=options.initial, auto=options.auto)
23
-

+ 7
- 5
docs/source/conf.py Целия файл

@@ -11,14 +11,16 @@
11 11
 # All configuration values have a default; values that are commented out
12 12
 # serve to show the default.
13 13
 
14
-import sys, os
15
-oscar_folder = os.path.realpath(os.path.join(os.path.dirname(__file__), '../..'))
16
-sys.path.append(oscar_folder)
17
-
18 14
 # If extensions (or modules to document with autodoc) are in another directory,
19 15
 # add these directories to sys.path here. If the directory is relative to the
20 16
 # documentation root, use os.path.abspath to make it absolute, like shown here.
21 17
 #sys.path.insert(0, os.path.abspath('.'))
18
+import sys, os
19
+oscar_folder = os.path.realpath(os.path.join(os.path.dirname(__file__), '../..'))
20
+sys.path.append(oscar_folder)
21
+
22
+# Get django settings
23
+os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'sandbox.settings')
22 24
 
23 25
 # -- General configuration -----------------------------------------------------
24 26
 
@@ -43,7 +45,7 @@ master_doc = 'index'
43 45
 
44 46
 # General information about the project.
45 47
 project = u'django-oscar'
46
-copyright = u'2011, David Winterbottom'
48
+copyright = u'Tangent Labs'
47 49
 
48 50
 # The version info for the project you're documenting, acts as replacement for
49 51
 # |version| and |release|, also used in various other places throughout the

+ 1
- 1
docs/source/getting_started.rst Целия файл

@@ -159,7 +159,7 @@ Install dependencies::
159 159
 
160 160
 Create database::
161 161
 
162
-    python manage.py syncdb -noinput
162
+    python manage.py syncdb --noinput
163 163
     python manage.py migrate
164 164
 
165 165
 And that should be it.

+ 17
- 1
docs/source/index.rst Целия файл

@@ -39,9 +39,25 @@ Features:
39 39
 
40 40
 * Extension libraries available for PayPal, GoCardless, DataCash and more
41 41
 
42
+Oscar is a good choice if your domain has non-trivial business logic.  Oscar's
43
+flexibility means it's straightforward to implement business rules that would be
44
+difficult to apply in other frameworks.  
45
+
46
+Example requirements that Oscar applications already handle:
47
+
48
+* Paying for an order with multiple payment sources (eg using a bankcard,
49
+  voucher, giftcard and business account).
50
+
51
+* Complex access control rules governing who can view and order what.
52
+
53
+* Supporting a hierarchy of customers, sales reps and sales directors - each
54
+  being able to "masquerade" as their subordinate users.
55
+
56
+* Multi-lingual products and categories
57
+
42 58
 Oscar is developed by `Tangent Labs`_, a London-based digital agency.  It is
43 59
 used in production in several applications to sell everything from beer mats to
44
-ipads.  The `source is on Github`_. 
60
+ipads.  The `source is on Github`_ - contributions welcome.
45 61
 
46 62
 .. _`Tangent Labs`: http://www.tangentlabs.co.uk
47 63
 .. _`source is on Github`: https://github.com/tangentlabs/django-oscar

+ 40
- 0
docs/source/recipes/how_to_configure_stock_messaging.rst Целия файл

@@ -1,3 +1,43 @@
1 1
 ================================
2 2
 How to configure stock messaging
3 3
 ================================
4
+
5
+Stock messaging is controlled on a per-partner basis.  A product's stockrecord
6
+has the following methods for messaging:
7
+
8
+.. autoclass:: oscar.apps.partner.abstract_models.AbstractStockRecord
9
+    :members: availability, availability_code
10
+
11
+Both these methods delegate to a "partner wrapper" instance.  These are defined
12
+in the ``OSCAR_PARTNER_WRAPPERS`` setting which is a dict mapping from partner
13
+name to a class path, for instance::
14
+
15
+    # settings.py
16
+    OSCAR_PARTNER_WRAPPERS = {
17
+        'Partner A': 'myproject.wrappers.PartnerAWrapper',
18
+    }
19
+
20
+The default wrapper is :class:`oscar.apps.partner.wrappers.DefaultWrapper`,
21
+which provides methods of the same name.
22
+
23
+.. autoclass:: oscar.apps.partner.wrappers.DefaultWrapper
24
+    :members: availability, availability_code
25
+
26
+Custom wrappers should subclass this class and override the appropriate methods.
27
+Here's an example wrapper that provides custom availability messaging::
28
+
29
+    # myproject/wrappers.py
30
+    from oscar.apps.partner import wrappers
31
+
32
+
33
+    class PartnerAWrapper(wrappers.DefaultWrapper):
34
+        
35
+        def availability(self, stockrecord):
36
+            if stockrecord.net_stock_level > 0:
37
+                return "Available to buy now!"
38
+            return "Sorry, not available"
39
+
40
+        def availability_code(self, stockrecord):
41
+            if stockrecord.net_stock_level > 0:
42
+                return "icon_tick"
43
+            return "icon_cross"

+ 31
- 4
docs/source/recipes/how_to_disable_an_app.rst Целия файл

@@ -1,6 +1,33 @@
1
-=====================
2
-How to disable an app
3
-=====================
1
+============================
2
+How to disable an app's URLs
3
+============================
4 4
 
5
-...
5
+Suppose you don't want to use Oscar's dashboard but use your own.  The way to do
6
+this is to modify the URLs config to exclude the URLs from the app in question.
6 7
 
8
+You need to use your own root 'application' instance which gives you control
9
+over the URLs structure.  So your root ``urls.py`` should have::
10
+
11
+    # urls.py
12
+    from myproject.app import application
13
+
14
+    urlpatterns = patterns('',
15
+        ...
16
+        (r'', include(application.urls)),
17
+    )
18
+
19
+where ``application`` is a subclass of ``oscar.app.Shop`` which overrides the 
20
+link to the dashboard app::
21
+
22
+    # myproject/app.py
23
+    from oscar.app import Shop
24
+    from oscar.core.application import Application
25
+
26
+    class MyShop(Shop):
27
+
28
+        # Override the core dashboard_app instance to use a blank application 
29
+        # instance.  This means no dashboard URLs are included.
30
+        dashboard_app = Application()
31
+
32
+The only remaining task is to ensure your templates don't reference any
33
+dashboard URLs. 

+ 99
- 3
docs/source/reference/signals.rst Целия файл

@@ -2,16 +2,112 @@
2 2
 Signals
3 3
 =======
4 4
 
5
-Oscar defined a number of custom signals that provide useful hook-points for
5
+Oscar implements a number of custom signals that provide useful hook-points for
6 6
 adding functionality.
7 7
 
8
+product_viewed
9
+--------------
10
+
11
+.. data:: oscar.apps.catalogue.signals.product_viewed
12
+    :class:
13
+
14
+Raised when a product detail page is viewed.  
15
+
16
+Arguments sent with this signal:
17
+
18
+``product``
19
+    The product being viewed
20
+
21
+``user``
22
+    The user in question
23
+
24
+``request``
25
+    The request instance
26
+
27
+``response``
28
+    The response instance
29
+
30
+product_search
31
+--------------
32
+
33
+.. data:: oscar.apps.catalogue.signals.product_search
34
+    :class:
35
+
36
+Raised when a search is performed.
37
+
38
+Arguments sent with this signal:
39
+
40
+``query``
41
+    The search term
42
+
43
+``user``
44
+    The user in question
45
+
46
+basket_addition
47
+---------------
48
+
49
+.. data:: oscar.apps.basket.signals.basket_addition
50
+    :class:
51
+
52
+Raised when a product is added to a basket
53
+
54
+Arguments sent with this signal:
55
+
56
+``product``
57
+    The product being added
58
+
59
+``user``
60
+    The user in question
61
+
62
+voucher_addition
63
+----------------
64
+
65
+.. data:: oscar.apps.basket.signals.voucher_addition
66
+    :class:
67
+
68
+Raised when a valid voucher is added to a basket
69
+
70
+Arguments sent with this signal:
71
+
72
+``basket``
73
+    The basket in question
74
+
75
+``voucher``
76
+    The voucher in question
77
+
78
+pre_payment
79
+-----------
80
+
81
+.. data:: oscar.apps.checkout.signals.pre_payment
82
+    :class:
83
+
84
+Raised immediately before attempting to take payment in the checkout.
85
+
86
+Arguments sent with this signal:
87
+
88
+``view``
89
+    The view class instance
90
+
91
+post_payment
92
+------------
93
+
94
+.. data:: oscar.apps.checkout.signals.post_payment
95
+    :class:
96
+
97
+Raised immediately after payment has been taken.
98
+
99
+Arguments sent with this signal:
100
+
101
+``view``
102
+    The view class instance
103
+
8 104
 order_placed
9 105
 ------------
10 106
 
11
-.. data:: oscar.apps.order.order_placed
107
+.. data:: oscar.apps.order.signals.order_placed
12 108
     :class:
13 109
 
14
-Raised by the :class:`oscar.apps.order.OrderCreator` class when creating an order.
110
+Raised by the :class:`oscar.apps.order.utils.OrderCreator` class when creating an order.
15 111
 
16 112
 Arguments sent with this signal:
17 113
 

+ 4
- 2
oscar/__init__.py Целия файл

@@ -4,7 +4,7 @@ import os
4 4
 # a full release
5 5
 
6 6
 VERSION = (0, 3, 0, 'alpha', 0)
7
-
7
+    
8 8
 def get_short_version():
9 9
     return '%s.%s' % (VERSION[0], VERSION[1])
10 10
 
@@ -66,12 +66,14 @@ def get_core_apps(overrides=None):
66 66
     """
67 67
     Return a list of oscar's apps amended with any passed overrides
68 68
     """
69
-    if overrides is None:
69
+    if not overrides:
70 70
         return OSCAR_CORE_APPS
71 71
     def get_app_label(app_label, overrides):
72 72
         pattern = app_label.replace('oscar.apps.', '')
73 73
         for override in overrides:
74 74
             if override.endswith(pattern):
75
+                if 'dashboard' in override and 'dashboard' not in pattern:
76
+                    continue
75 77
                 return override
76 78
         return app_label
77 79
 

+ 2
- 2
oscar/app.py Целия файл

@@ -46,5 +46,5 @@ class Shop(Application):
46 46
         )
47 47
         return urlpatterns
48 48
     
49
-
50
-shop = Shop()
49
+# 'shop' kept for legacy projects - 'application' is a better name
50
+shop = application = Shop()

+ 21
- 5
oscar/apps/basket/abstract_models.py Целия файл

@@ -129,7 +129,7 @@ class AbstractBasket(models.Model):
129 129
         self.discounts = []
130 130
         self._lines = None
131 131
 
132
-    def merge_line(self, line):
132
+    def merge_line(self, line, add_quantities=True):
133 133
         """
134 134
         For transferring a line from another basket to this one.
135 135
 
@@ -142,17 +142,23 @@ class AbstractBasket(models.Model):
142 142
             line.basket = self
143 143
             line.save()
144 144
         else:
145
-            # Line already exists - bump its quantity and delete the old
146
-            existing_line.quantity += line.quantity
145
+            # Line already exists - assume the max quantity is correct and delete the old
146
+            if add_quantities:
147
+                existing_line.quantity += line.quantity
148
+            else:
149
+                existing_line.quantity = max(existing_line.quantity, line.quantity)
147 150
             existing_line.save()
148 151
             line.delete()
149 152
 
150
-    def merge(self, basket):
153
+    def merge(self, basket, add_quantities=True):
151 154
         """
152 155
         Merges another basket with this one.
156
+
157
+        :basket: The basket to merge into this one
158
+        :add_quantities: Whether to add line quantities when they are merged.
153 159
         """
154 160
         for line_to_merge in basket.all_lines():
155
-            self.merge_line(line_to_merge)
161
+            self.merge_line(line_to_merge, add_quantities)
156 162
         basket.status = MERGED
157 163
         basket.date_merged = datetime.datetime.now()
158 164
         basket.save()
@@ -183,6 +189,16 @@ class AbstractBasket(models.Model):
183 189
         for line in self.all_lines():
184 190
             line.set_as_tax_exempt()
185 191
 
192
+    def is_shipping_required(self):
193
+        """
194
+        Test whether the basket contains physical products that require
195
+        shipping.
196
+        """
197
+        for line in self.all_lines():
198
+            if line.product.is_shipping_required:
199
+                return True
200
+        return False
201
+
186 202
     # =======
187 203
     # Helpers
188 204
     # =======

+ 5
- 4
oscar/apps/basket/middleware.py Целия файл

@@ -55,7 +55,7 @@ class BasketMiddleware(object):
55 55
         
56 56
         This is its own method to allow it to be overridden
57 57
         """
58
-        master.merge(slave)    
58
+        master.merge(slave, add_quantities=False)
59 59
         
60 60
     def process_response(self, request, response):
61 61
         # Delete any surplus cookies
@@ -76,9 +76,10 @@ class BasketMiddleware(object):
76 76
         return response
77 77
     
78 78
     def process_template_response(self, request, response):
79
-        if response.context_data is None:
80
-            response.context_data = {}
81
-        response.context_data['basket'] = request.basket
79
+        if hasattr(response, 'context_data'):
80
+            if response.context_data is None:
81
+                response.context_data = {}
82
+            response.context_data['basket'] = request.basket
82 83
         return response
83 84
     
84 85
     def get_cookie_basket(self, cookie_key, request, manager):

+ 1
- 1
oscar/apps/basket/signals.py Целия файл

@@ -1,4 +1,4 @@
1 1
 import django.dispatch
2 2
 
3 3
 basket_addition = django.dispatch.Signal(providing_args=["product", "user"])
4
-basket_voucher = django.dispatch.Signal(providing_args=["basket", "voucher"])
4
+voucher_addition = django.dispatch.Signal(providing_args=["basket", "voucher"])

+ 7
- 1
oscar/apps/basket/views.py Целия файл

@@ -7,7 +7,7 @@ from django.utils.translation import ugettext_lazy as _
7 7
 from django.core.exceptions import ObjectDoesNotExist
8 8
 
9 9
 from extra_views import ModelFormSetView
10
-from oscar.apps.basket.signals import basket_addition
10
+from oscar.apps.basket.signals import basket_addition, voucher_addition
11 11
 from oscar.core.loading import get_class, get_classes
12 12
 Applicator = get_class('offer.utils', 'Applicator')
13 13
 BasketLineForm, AddToBasketForm, BasketVoucherForm, \
@@ -169,6 +169,7 @@ class BasketAddView(FormView):
169 169
 class VoucherAddView(FormView):
170 170
     form_class = BasketVoucherForm
171 171
     voucher_model = get_model('voucher', 'voucher')
172
+    add_signal = voucher_addition
172 173
 
173 174
     def get(self, request, *args, **kwargs):
174 175
         return HttpResponseRedirect(reverse('basket:summary'))
@@ -185,6 +186,11 @@ class VoucherAddView(FormView):
185 186
 
186 187
         self.request.basket.vouchers.add(voucher)
187 188
 
189
+        # Raise signal
190
+        self.add_signal.send(sender=self, 
191
+                             basket=self.request.basket, 
192
+                             voucher=voucher)
193
+
188 194
         # Recalculate discounts to see if the voucher gives any
189 195
         discounts_before = self.request.basket.get_discounts()
190 196
         self.request.basket.remove_discounts()

+ 17
- 8
oscar/apps/catalogue/abstract_models.py Целия файл

@@ -18,12 +18,19 @@ BrowsableProductManager = get_class('catalogue.managers', 'BrowsableProductManag
18 18
 class AbstractProductClass(models.Model):
19 19
     """
20 20
     Defines the options and attributes for a group of products, e.g. Books, DVDs and Toys.
21
+
21 22
     Not necessarily equivalent to top-level categories but usually will be.
22 23
     """
23 24
     name = models.CharField(_('name'), max_length=128)
24 25
     slug = models.SlugField(max_length=128, unique=True)
26
+
27
+    # Some product type don't require shipping (eg digital products) - we use
28
+    # this field to take some shortcuts in the checkout.
29
+    requires_shipping = models.BooleanField(_("Requires shipping?"), default=True)
25 30
     
26
-    # These are the options (set by the user when they add to basket) for this item class
31
+    # These are the options (set by the user when they add to basket) for this
32
+    # item class.  For instance, a product class of "SMS message" would always
33
+    # require a message to be specified before it could be bought.
27 34
     options = models.ManyToManyField('catalogue.Option', blank=True)
28 35
 
29 36
     class Meta:
@@ -33,8 +40,8 @@ class AbstractProductClass(models.Model):
33 40
 
34 41
     def save(self, *args, **kwargs):
35 42
         if not self.slug:
36
-            self.slug= slugify(self.name)
37
-        super(AbstractProductClass, self).save(*args, **kwargs)
43
+            self.slug = slugify(self.name)
44
+        return super(AbstractProductClass, self).save(*args, **kwargs)
38 45
 
39 46
     def __unicode__(self):
40 47
         return self.name
@@ -42,7 +49,9 @@ class AbstractProductClass(models.Model):
42 49
 
43 50
 class AbstractCategory(MP_Node):
44 51
     """
45
-    Category hierarchy, top-level nodes represent departments. Uses django-treebeard.
52
+    A product category.
53
+    
54
+    Uses django-treebeard.
46 55
     """
47 56
     name = models.CharField(max_length=255, db_index=True)
48 57
     description = models.TextField(blank=True, null=True)
@@ -235,7 +244,7 @@ class AbstractProduct(models.Model):
235 244
         help_text="""Choose what type of product this is""")
236 245
     attributes = models.ManyToManyField('catalogue.ProductAttribute', through='ProductAttributeValue',
237 246
         help_text="""A product attribute is something that this product MUST have, such as a size, as specified by its class""")
238
-    product_options = models.ManyToManyField('catalogue.Option', blank=True, 
247
+    product_options = models.ManyToManyField('catalogue.Option', blank=True,
239 248
         help_text="""Options are values that can be associated with a item when it is added to 
240 249
                      a customer's basket.  This could be something like a personalised message to be
241 250
                      printed on a T-shirt.<br/>""")
@@ -347,9 +356,9 @@ class AbstractProduct(models.Model):
347 356
         return None
348 357
 
349 358
     def primary_image(self):
350
-        images = self.images.all().order_by('display_order')
359
+        images = self.images.all()
351 360
         if images.count():
352
-            return images[0]
361
+            return images.order_by('display_order')[0]
353 362
         return {
354 363
             'original': MissingProductImage(),
355 364
             'caption': '',
@@ -606,7 +615,7 @@ class AbstractProductAttributeValue(models.Model):
606 615
     product = models.ForeignKey('catalogue.Product', related_name='attribute_values')
607 616
     value_text = models.CharField(max_length=255, blank=True, null=True)
608 617
     value_integer = models.IntegerField(blank=True, null=True)
609
-    value_boolean = models.BooleanField(blank=True)
618
+    value_boolean = models.NullBooleanField(blank=True)
610 619
     value_float = models.FloatField(blank=True, null=True)
611 620
     value_richtext = models.TextField(blank=True, null=True)
612 621
     value_date = models.DateField(blank=True, null=True)

+ 161
- 0
oscar/apps/catalogue/migrations/0004_auto__chg_field_productattributevalue_value_boolean.py Целия файл

@@ -0,0 +1,161 @@
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 'ProductAttributeValue.value_boolean'
12
+        db.alter_column('catalogue_productattributevalue', 'value_boolean', self.gf('django.db.models.fields.NullBooleanField')(null=True))
13
+
14
+
15
+    def backwards(self, orm):
16
+        
17
+        # Changing field 'ProductAttributeValue.value_boolean'
18
+        db.alter_column('catalogue_productattributevalue', 'value_boolean', self.gf('django.db.models.fields.BooleanField')())
19
+
20
+
21
+    models = {
22
+        'catalogue.attributeentity': {
23
+            'Meta': {'object_name': 'AttributeEntity'},
24
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
25
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
26
+            'slug': ('django.db.models.fields.SlugField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}),
27
+            'type': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'entities'", 'to': "orm['catalogue.AttributeEntityType']"})
28
+        },
29
+        'catalogue.attributeentitytype': {
30
+            'Meta': {'object_name': 'AttributeEntityType'},
31
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
32
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
33
+            'slug': ('django.db.models.fields.SlugField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'})
34
+        },
35
+        'catalogue.attributeoption': {
36
+            'Meta': {'object_name': 'AttributeOption'},
37
+            'group': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'options'", 'to': "orm['catalogue.AttributeOptionGroup']"}),
38
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
39
+            'option': ('django.db.models.fields.CharField', [], {'max_length': '255'})
40
+        },
41
+        'catalogue.attributeoptiongroup': {
42
+            'Meta': {'object_name': 'AttributeOptionGroup'},
43
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
44
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '128'})
45
+        },
46
+        'catalogue.category': {
47
+            'Meta': {'ordering': "['full_name']", 'object_name': 'Category'},
48
+            'depth': ('django.db.models.fields.PositiveIntegerField', [], {}),
49
+            'description': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
50
+            'full_name': ('django.db.models.fields.CharField', [], {'max_length': '1024', 'db_index': 'True'}),
51
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
52
+            'image': ('django.db.models.fields.files.ImageField', [], {'max_length': '100', 'null': 'True', 'blank': 'True'}),
53
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
54
+            'numchild': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
55
+            'path': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '255'}),
56
+            'slug': ('django.db.models.fields.SlugField', [], {'max_length': '1024', 'db_index': 'True'})
57
+        },
58
+        'catalogue.contributor': {
59
+            'Meta': {'object_name': 'Contributor'},
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', [], {'max_length': '255', 'db_index': 'True'})
63
+        },
64
+        'catalogue.contributorrole': {
65
+            'Meta': {'object_name': 'ContributorRole'},
66
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
67
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}),
68
+            'name_plural': ('django.db.models.fields.CharField', [], {'max_length': '50'}),
69
+            'slug': ('django.db.models.fields.SlugField', [], {'max_length': '50', 'db_index': 'True'})
70
+        },
71
+        'catalogue.option': {
72
+            'Meta': {'object_name': 'Option'},
73
+            'code': ('django.db.models.fields.SlugField', [], {'max_length': '128', 'db_index': 'True'}),
74
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
75
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
76
+            'type': ('django.db.models.fields.CharField', [], {'default': "'Required'", 'max_length': '128'})
77
+        },
78
+        'catalogue.product': {
79
+            'Meta': {'ordering': "['-date_created']", 'object_name': 'Product'},
80
+            'attributes': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['catalogue.ProductAttribute']", 'through': "orm['catalogue.ProductAttributeValue']", 'symmetrical': 'False'}),
81
+            'categories': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['catalogue.Category']", 'through': "orm['catalogue.ProductCategory']", 'symmetrical': 'False'}),
82
+            'date_created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}),
83
+            'date_updated': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}),
84
+            'description': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
85
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
86
+            'parent': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'variants'", 'null': 'True', 'to': "orm['catalogue.Product']"}),
87
+            'product_class': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['catalogue.ProductClass']", 'null': 'True'}),
88
+            'product_options': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['catalogue.Option']", 'symmetrical': 'False', 'blank': 'True'}),
89
+            'recommended_products': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['catalogue.Product']", 'symmetrical': 'False', 'through': "orm['catalogue.ProductRecommendation']", 'blank': 'True'}),
90
+            'related_products': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "'relations'", 'blank': 'True', 'to': "orm['catalogue.Product']"}),
91
+            'score': ('django.db.models.fields.FloatField', [], {'default': '0.0', 'db_index': 'True'}),
92
+            'slug': ('django.db.models.fields.SlugField', [], {'max_length': '255', 'db_index': 'True'}),
93
+            'status': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '128', 'null': 'True', 'blank': 'True'}),
94
+            'title': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
95
+            'upc': ('django.db.models.fields.CharField', [], {'max_length': '64', 'unique': 'True', 'null': 'True', 'blank': 'True'})
96
+        },
97
+        'catalogue.productattribute': {
98
+            'Meta': {'ordering': "['code']", 'object_name': 'ProductAttribute'},
99
+            'code': ('django.db.models.fields.SlugField', [], {'max_length': '128', 'db_index': 'True'}),
100
+            'entity_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['catalogue.AttributeEntityType']", 'null': 'True', 'blank': 'True'}),
101
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
102
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
103
+            'option_group': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['catalogue.AttributeOptionGroup']", 'null': 'True', 'blank': 'True'}),
104
+            'product_class': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'attributes'", 'null': 'True', 'to': "orm['catalogue.ProductClass']"}),
105
+            'required': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
106
+            'type': ('django.db.models.fields.CharField', [], {'default': "'text'", 'max_length': '20'})
107
+        },
108
+        'catalogue.productattributevalue': {
109
+            'Meta': {'object_name': 'ProductAttributeValue'},
110
+            'attribute': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['catalogue.ProductAttribute']"}),
111
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
112
+            'product': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'attribute_values'", 'to': "orm['catalogue.Product']"}),
113
+            'value_boolean': ('django.db.models.fields.NullBooleanField', [], {'null': 'True', 'blank': 'True'}),
114
+            'value_date': ('django.db.models.fields.DateField', [], {'null': 'True', 'blank': 'True'}),
115
+            'value_entity': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['catalogue.AttributeEntity']", 'null': 'True', 'blank': 'True'}),
116
+            'value_float': ('django.db.models.fields.FloatField', [], {'null': 'True', 'blank': 'True'}),
117
+            'value_integer': ('django.db.models.fields.IntegerField', [], {'null': 'True', 'blank': 'True'}),
118
+            'value_option': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['catalogue.AttributeOption']", 'null': 'True', 'blank': 'True'}),
119
+            'value_richtext': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
120
+            'value_text': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'})
121
+        },
122
+        'catalogue.productcategory': {
123
+            'Meta': {'ordering': "['-is_canonical']", 'object_name': 'ProductCategory'},
124
+            'category': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['catalogue.Category']"}),
125
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
126
+            'is_canonical': ('django.db.models.fields.BooleanField', [], {'default': 'False', 'db_index': 'True'}),
127
+            'product': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['catalogue.Product']"})
128
+        },
129
+        'catalogue.productclass': {
130
+            'Meta': {'ordering': "['name']", 'object_name': 'ProductClass'},
131
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
132
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
133
+            'options': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['catalogue.Option']", 'symmetrical': 'False', 'blank': 'True'}),
134
+            'slug': ('django.db.models.fields.SlugField', [], {'unique': 'True', 'max_length': '128', 'db_index': 'True'})
135
+        },
136
+        'catalogue.productcontributor': {
137
+            'Meta': {'object_name': 'ProductContributor'},
138
+            'contributor': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['catalogue.Contributor']"}),
139
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
140
+            'product': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['catalogue.Product']"}),
141
+            'role': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['catalogue.ContributorRole']", 'null': 'True', 'blank': 'True'})
142
+        },
143
+        'catalogue.productimage': {
144
+            'Meta': {'ordering': "['display_order']", 'unique_together': "(('product', 'display_order'),)", 'object_name': 'ProductImage'},
145
+            'caption': ('django.db.models.fields.CharField', [], {'max_length': '200', 'null': 'True', 'blank': 'True'}),
146
+            'date_created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}),
147
+            'display_order': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
148
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
149
+            'original': ('django.db.models.fields.files.ImageField', [], {'max_length': '100'}),
150
+            'product': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'images'", 'to': "orm['catalogue.Product']"})
151
+        },
152
+        'catalogue.productrecommendation': {
153
+            'Meta': {'object_name': 'ProductRecommendation'},
154
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
155
+            'primary': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'primary_recommendations'", 'to': "orm['catalogue.Product']"}),
156
+            'ranking': ('django.db.models.fields.PositiveSmallIntegerField', [], {'default': '0'}),
157
+            'recommendation': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['catalogue.Product']"})
158
+        }
159
+    }
160
+
161
+    complete_apps = ['catalogue']

+ 2
- 0
oscar/apps/catalogue/signals.py Целия файл

@@ -1,4 +1,6 @@
1 1
 import django.dispatch
2 2
 
3 3
 product_viewed = django.dispatch.Signal(providing_args=["product", "user", "request", "response"])
4
+
5
+# This needs to be moved into the search app when it is refactored
4 6
 product_search = django.dispatch.Signal(providing_args=["query", '"user'])

+ 1
- 1
oscar/apps/catalogue/views.py Целия файл

@@ -39,7 +39,7 @@ class ProductDetailView(DetailView):
39 39
 
40 40
         # Send signal to record the view of this product
41 41
         self.view_signal.send(sender=self, product=product, user=request.user, request=request, response=response)
42
-        return response;
42
+        return response
43 43
 
44 44
     def get_template_names(self):
45 45
         """

+ 3
- 2
oscar/apps/checkout/forms.py Целия файл

@@ -9,8 +9,9 @@ class ShippingAddressForm(forms.ModelForm):
9 9
         super(ShippingAddressForm,self ).__init__(*args, **kwargs)
10 10
         self.set_country_queryset() 
11 11
         
12
-    def set_country_queryset(self):    
13
-        self.fields['country'].queryset = get_model('address', 'country')._default_manager.filter(is_shipping_country=True)
12
+    def set_country_queryset(self):
13
+        self.fields['country'].queryset = get_model('address', 'country')._default_manager.filter(
14
+            is_shipping_country=True)
14 15
     
15 16
     class Meta:
16 17
         model = get_model('order', 'shippingaddress')

+ 1
- 2
oscar/apps/checkout/signals.py Целия файл

@@ -1,5 +1,4 @@
1 1
 from django.dispatch import Signal
2 2
 
3 3
 pre_payment = Signal(providing_args=["view"])
4
-post_payment = Signal(providing_args=["view"])
5
-order_placed = Signal(providing_args=["order"])
4
+post_payment = Signal(providing_args=["view"])

+ 83
- 52
oscar/apps/checkout/utils.py Целия файл

@@ -7,16 +7,16 @@ class CheckoutSessionData(object):
7 7
     Class responsible for marshalling all the checkout session data
8 8
     """
9 9
     SESSION_KEY = 'checkout_data'
10
-    
10
+
11 11
     def __init__(self, request):
12 12
         self.request = request
13 13
         if self.SESSION_KEY not in self.request.session:
14 14
             self.request.session[self.SESSION_KEY] = {}
15
-    
15
+
16 16
     def _check_namespace(self, namespace):
17 17
         if namespace not in self.request.session[self.SESSION_KEY]:
18 18
             self.request.session[self.SESSION_KEY][namespace] = {}
19
-          
19
+
20 20
     def _get(self, namespace, key, default=None):
21 21
         """
22 22
         Return session value or None
@@ -25,7 +25,7 @@ class CheckoutSessionData(object):
25 25
         if key in self.request.session[self.SESSION_KEY][namespace]:
26 26
             return self.request.session[self.SESSION_KEY][namespace][key]
27 27
         return default
28
-            
28
+
29 29
     def _set(self, namespace, key, value):
30 30
         """
31 31
         Set session value
@@ -33,7 +33,7 @@ class CheckoutSessionData(object):
33 33
         self._check_namespace(namespace)
34 34
         self.request.session[self.SESSION_KEY][namespace][key] = value
35 35
         self.request.session.modified = True
36
-        
36
+
37 37
     def _unset(self, namespace, key):
38 38
         """
39 39
         Unset session value
@@ -42,7 +42,11 @@ class CheckoutSessionData(object):
42 42
         if key in self.request.session[self.SESSION_KEY][namespace]:
43 43
             del self.request.session[self.SESSION_KEY][namespace][key]
44 44
             self.request.session.modified = True
45
-            
45
+
46
+    def _flush_namespace(self, namespace):
47
+        self.request.session[self.SESSION_KEY][namespace] = {}
48
+        self.request.session.modified = True
49
+
46 50
     def flush(self):
47 51
         """
48 52
         Delete session key
@@ -56,73 +60,73 @@ class CheckoutSessionData(object):
56 60
 
57 61
     def get_guest_email(self):
58 62
         return self._get('guest', 'email')
59
-        
60
-    # Shipping addresses    
63
+
64
+    # Shipping address
65
+    # ================
66
+    # Options:
67
+    # 1. No shipping required (eg digital products)
68
+    # 2. Ship to new address (entered in a form)
69
+    # 3. Ship to an addressbook address (address chosen from list)
61 70
 
62 71
     def reset_shipping_data(self):
63
-        self._unset('shipping', 'not_required')
64
-        self._unset('shipping', 'new_address_fields')
65
-        self._unset('shipping', 'user_address_id')
72
+        self._flush_namespace('shipping')
66 73
 
67
-    def no_shipping_required(self):
68
-        """
69
-        Record fact that basket doesn't require a shipping address or method
70
-        """
71
-        self.reset_shipping_data()
72
-        self._set('shipping', 'is_required', False)
73
-        
74 74
     def ship_to_user_address(self, address):
75 75
         """
76 76
         Set existing shipping address id to session and unset address fields from session
77 77
         """
78 78
         self.reset_shipping_data()
79 79
         self._set('shipping', 'user_address_id', address.id)
80
-        
80
+
81 81
     def ship_to_new_address(self, address_fields):
82 82
         """
83 83
         Set new shipping address details to session and unset shipping address id
84 84
         """
85 85
         self._unset('shipping', 'new_address_fields')
86 86
         self._set('shipping', 'new_address_fields', address_fields)
87
-        
87
+
88 88
     def new_shipping_address_fields(self):
89 89
         """
90 90
         Get shipping address fields from session
91 91
         """
92 92
         return self._get('shipping', 'new_address_fields')
93
-        
94
-    def user_address_id(self):
93
+
94
+    def shipping_user_address_id(self):
95 95
         """
96 96
         Get user address id from session
97 97
         """
98 98
         return self._get('shipping', 'user_address_id')
99
-
100
-    def is_shipping_required(self):
101
-        return self._get('shipping', 'is_required', True)
99
+    user_address_id = shipping_user_address_id
102 100
 
103 101
     def is_shipping_address_set(self):
102
+        """
103
+        Test whether a shipping address has been stored in the session.
104
+
105
+        This can be from a new address or re-using an existing address.
106
+        """
104 107
         new_fields = self.new_shipping_address_fields()
105 108
         has_new_address = new_fields is not None
106 109
         has_old_address = self.user_address_id() > 0
107 110
         return has_new_address or has_old_address
108
-    
109
-    # Shipping methods
110
-    
111
+
112
+    # Shipping method
113
+    # ===============
114
+
111 115
     def use_free_shipping(self):
112 116
         """
113 117
         Set "free shipping" code to session
114 118
         """
115 119
         self._set('shipping', 'method_code', '__free__')
116
-    
120
+
117 121
     def use_shipping_method(self, code):
118 122
         """
119 123
         Set shipping method code to session
120 124
         """
121 125
         self._set('shipping', 'method_code', code)
122
-        
126
+
123 127
     def shipping_method(self):
124 128
         """
125
-        Returns the shipping method model based on the 
129
+        Returns the shipping method model based on the
126 130
         data stored in the session.
127 131
         """
128 132
         code = self._get('shipping', 'method_code')
@@ -132,50 +136,77 @@ class CheckoutSessionData(object):
132 136
 
133 137
     def is_shipping_method_set(self):
134 138
         return bool(self._get('shipping', 'method_code'))
135
-    
139
+
136 140
     # Billing address fields
137
-    
141
+    # ======================
142
+    #
143
+    # There are 3 common options:
144
+    # 1. Billing address is entered manually through a form
145
+    # 2. Billing address is selected from address book
146
+    # 3. Billing address is the same as the shipping address
147
+
138 148
     def bill_to_new_address(self, address_fields):
139 149
         """
140 150
         Store address fields for a billing address.
141 151
         """
152
+        self._flush_namespace('billing')
142 153
         self._set('billing', 'new_address_fields', address_fields)
143
-    
144
-    def new_billing_address_fields(self):
154
+
155
+    def bill_to_user_address(self, address):
145 156
         """
146
-        Return fields for a billing address
157
+        Set an address from a user's address book as the billing address
158
+
159
+        :address: The address object
147 160
         """
148
-        return self._get('billing', 'new_address_fields')
149
-    
150
-    def billing_address_same_as_shipping(self):
161
+        self._flush_namespace('billing')
162
+        self._set('billing', 'user_address_id', address.id)
163
+
164
+    def bill_to_shipping_address(self):
151 165
         """
152 166
         Record fact that the billing address is to be the same as
153 167
         the shipping address.
154 168
         """
155
-        self._set('payment', 'billing_address_same_as_shipping', True)
156
-        
169
+        self._flush_namespace('billing')
170
+        self._set('billing', 'billing_address_same_as_shipping', True)
171
+
172
+    # Legacy method name
173
+    billing_address_same_as_shipping = bill_to_shipping_address
174
+
157 175
     def is_billing_address_same_as_shipping(self):
158
-        return self._get('payment', 'billing_address_same_as_shipping', False)
159
-    
176
+        return self._get('billing', 'billing_address_same_as_shipping', False)
177
+
178
+    def billing_user_address_id(self):
179
+        """
180
+        Return the ID of the user address being used for billing
181
+        """
182
+        return self._get('billing', 'user_address_id')
183
+
184
+    def new_billing_address_fields(self):
185
+        """
186
+        Return fields for a billing address
187
+        """
188
+        return self._get('billing', 'new_address_fields')
189
+
160 190
     # Payment methods
161
-    
191
+    # ===============
192
+
162 193
     def pay_by(self, method):
163 194
         self._set('payment', 'method', method)
164
-        
195
+
165 196
     def payment_method(self):
166 197
         return self._get('payment', 'method')
167
-    
198
+
168 199
     # Submission methods
169
-    
200
+
170 201
     def set_order_number(self, order_number):
171 202
         self._set('submission', 'order_number', order_number)
172
-        
203
+
173 204
     def get_order_number(self):
174
-        return self._get('submission', 'order_number')    
175
-    
205
+        return self._get('submission', 'order_number')
206
+
176 207
     def set_submitted_basket(self, basket):
177 208
         self._set('submission', 'basket_id', basket.id)
178
-        
209
+
179 210
     def get_submitted_basket_id(self):
180 211
         return self._get('submission', 'basket_id')
181
-        
212
+

+ 117
- 106
oscar/apps/checkout/views.py Целия файл

@@ -43,7 +43,7 @@ logger = logging.getLogger('oscar.checkout')
43 43
 class CheckoutSessionMixin(object):
44 44
     """
45 45
     Mixin to provide common functionality shared between checkout views.
46
-    """   
46
+    """
47 47
 
48 48
     def dispatch(self, request, *args, **kwargs):
49 49
         self.checkout_session = CheckoutSessionData(request)
@@ -52,9 +52,9 @@ class CheckoutSessionMixin(object):
52 52
     def get_shipping_address(self):
53 53
         """
54 54
         Return the current shipping address for this checkout session.
55
-        
55
+
56 56
         This could either be a ShippingAddress model which has been
57
-        pre-populated (not saved), or a UserAddress model which will 
57
+        pre-populated (not saved), or a UserAddress model which will
58 58
         need converting into a ShippingAddress model at submission
59 59
         """
60 60
         addr_data = self.checkout_session.new_shipping_address_fields()
@@ -70,7 +70,7 @@ class CheckoutSessionMixin(object):
70 70
                 # session data that refers to addresses that no longer exist
71 71
                 pass
72 72
         return None
73
-        
73
+
74 74
     def get_shipping_method(self, basket=None):
75 75
         method = self.checkout_session.shipping_method()
76 76
         if method:
@@ -81,7 +81,7 @@ class CheckoutSessionMixin(object):
81 81
             # We default to using free shipping
82 82
             method = Free()
83 83
         return method
84
-    
84
+
85 85
     def get_order_totals(self, basket=None, shipping_method=None, **kwargs):
86 86
         """
87 87
         Returns the total for the order with and without tax (as a tuple)
@@ -94,22 +94,22 @@ class CheckoutSessionMixin(object):
94 94
         total_incl_tax = calc.order_total_incl_tax(basket, shipping_method, **kwargs)
95 95
         total_excl_tax = calc.order_total_excl_tax(basket, shipping_method, **kwargs)
96 96
         return total_incl_tax, total_excl_tax
97
-    
97
+
98 98
     def get_context_data(self, **kwargs):
99 99
         """
100 100
         Assign common template variables to the context.
101 101
         """
102 102
         ctx = super(CheckoutSessionMixin, self).get_context_data(**kwargs)
103 103
         ctx['shipping_address'] = self.get_shipping_address()
104
-        
104
+
105 105
         method = self.get_shipping_method()
106 106
         if method:
107 107
             ctx['shipping_method'] = method
108 108
             ctx['shipping_total_excl_tax'] = method.basket_charge_excl_tax()
109 109
             ctx['shipping_total_incl_tax'] = method.basket_charge_incl_tax()
110
-            
110
+
111 111
         ctx['order_total_incl_tax'], ctx['order_total_excl_tax'] = self.get_order_totals()
112
-        
112
+
113 113
         return ctx
114 114
 
115 115
 
@@ -162,20 +162,25 @@ class IndexView(CheckoutSessionMixin, FormView):
162 162
 class ShippingAddressView(CheckoutSessionMixin, FormView):
163 163
     """
164 164
     Determine the shipping address for the order.
165
-    
165
+
166 166
     The default behaviour is to display a list of addresses from the users's
167 167
     address book, from which the user can choose one to be their shipping address.
168 168
     They can add/edit/delete these USER addresses.  This address will be
169 169
     automatically converted into a SHIPPING address when the user checks out.
170
-    
170
+
171 171
     Alternatively, the user can enter a SHIPPING address directly which will be
172 172
     saved in the session and saved as a model when the order is sucessfully submitted.
173 173
     """
174
-    
174
+
175 175
     template_name = 'checkout/shipping_address.html'
176 176
     form_class = ShippingAddressForm
177
-    
177
+
178 178
     def get(self, request, *args, **kwargs):
179
+        # Check that the user's basket is not empty
180
+        if request.basket.is_empty:
181
+            messages.error(request, _("You need to add some items to your basket to checkout"))
182
+            return HttpResponseRedirect(reverse('basket:summary'))
183
+
179 184
         # Check that guests have entered an email address
180 185
         if not request.user.is_authenticated() and not self.checkout_session.get_guest_email():
181 186
             messages.error(request, _("Please either sign in or enter your email address"))
@@ -183,35 +188,25 @@ class ShippingAddressView(CheckoutSessionMixin, FormView):
183 188
 
184 189
         # Check to see that a shipping address is actually required.  It may not be if
185 190
         # the basket is purely downloads
186
-        if not self.does_basket_require_shipping(request.basket):
191
+        if not request.basket.is_shipping_required():
187 192
             messages.info(request, _("Your basket does not require a shipping address to be submitted"))
188
-            self.checkout_session.no_shipping_required()
189 193
             return HttpResponseRedirect(self.get_success_url())
190 194
 
191 195
         return super(ShippingAddressView, self).get(request, *args, **kwargs)
192 196
 
193
-    def does_basket_require_shipping(self, basket):
194
-        """
195
-        Test whether the contents of the basket require shipping
196
-        """
197
-        for line in basket.all_lines():
198
-            if line.product.is_shipping_required:
199
-                return True
200
-        return False
201
-
202 197
     def get_initial(self):
203 198
         return self.checkout_session.new_shipping_address_fields()
204
-    
199
+
205 200
     def get_context_data(self, **kwargs):
206 201
         kwargs = super(ShippingAddressView, self).get_context_data(**kwargs)
207 202
         if self.request.user.is_authenticated():
208 203
             # Look up address book data
209 204
             kwargs['addresses'] = self.get_available_addresses()
210 205
         return kwargs
211
-    
206
+
212 207
     def get_available_addresses(self):
213 208
         return UserAddress._default_manager.filter(user=self.request.user).order_by('-is_default_for_shipping')
214
-    
209
+
215 210
     def post(self, request, *args, **kwargs):
216 211
         # Check if a shipping address was selected directly (eg no form was filled in)
217 212
         if self.request.user.is_authenticated and 'address_id' in self.request.POST:
@@ -228,15 +223,15 @@ class ShippingAddressView(CheckoutSessionMixin, FormView):
228 223
                 return HttpResponseBadRequest()
229 224
         else:
230 225
             return super(ShippingAddressView, self).post(request, *args, **kwargs)
231
-    
226
+
232 227
     def form_valid(self, form):
233 228
         # Store the address details in the session and redirect to next step
234 229
         self.checkout_session.ship_to_new_address(form.clean())
235 230
         return super(ShippingAddressView, self).form_valid(form)
236
-    
231
+
237 232
     def get_success_url(self):
238 233
         return reverse('checkout:shipping-method')
239
-    
234
+
240 235
 
241 236
 class UserAddressCreateView(CheckoutSessionMixin, CreateView):
242 237
     """
@@ -252,7 +247,7 @@ class UserAddressCreateView(CheckoutSessionMixin, CreateView):
252 247
         kwargs = super(UserAddressCreateView, self).get_context_data(**kwargs)
253 248
         kwargs['form_url'] = reverse('checkout:user-address-create')
254 249
         return kwargs
255
-    
250
+
256 251
     def form_valid(self, form):
257 252
         self.object = form.save(commit=False)
258 253
         self.object.user = self.request.user
@@ -263,15 +258,15 @@ class UserAddressCreateView(CheckoutSessionMixin, CreateView):
263 258
         messages.info(self.request, _("Address saved"))
264 259
         # We redirect back to the shipping address page
265 260
         return HttpResponseRedirect(reverse('checkout:shipping-address'))
266
-    
267
-    
261
+
262
+
268 263
 class UserAddressUpdateView(CheckoutSessionMixin, UpdateView):
269 264
     """
270 265
     Update a user address
271 266
     """
272 267
     template_name = 'checkout/user_address_form.html'
273 268
     form_class = UserAddressForm
274
-    
269
+
275 270
     def get_queryset(self):
276 271
         return UserAddress._default_manager.filter(user=self.request.user)
277 272
 
@@ -283,8 +278,8 @@ class UserAddressUpdateView(CheckoutSessionMixin, UpdateView):
283 278
     def get_success_url(self):
284 279
         messages.info(self.request, _("Address saved"))
285 280
         return reverse('checkout:shipping-address')
286
-    
287
-    
281
+
282
+
288 283
 class UserAddressDeleteView(CheckoutSessionMixin, DeleteView):
289 284
     """
290 285
     Delete an address from a user's addressbook.
@@ -293,34 +288,39 @@ class UserAddressDeleteView(CheckoutSessionMixin, DeleteView):
293 288
 
294 289
     def get_queryset(self):
295 290
         return UserAddress._default_manager.filter(user=self.request.user)
296
-    
291
+
297 292
     def get_success_url(self):
298 293
         messages.info(self.request, _("Address deleted"))
299 294
         return reverse('checkout:shipping-address')
300
-    
301 295
 
302
-# ===============    
296
+
297
+# ===============
303 298
 # Shipping method
304
-# ===============    
305
-    
299
+# ===============
300
+
306 301
 
307 302
 class ShippingMethodView(CheckoutSessionMixin, TemplateView):
308 303
     """
309 304
     View for allowing a user to choose a shipping method.
310
-    
305
+
311 306
     Shipping methods are largely domain-specific and so this view
312 307
     will commonly need to be subclassed and customised.
313
-    
308
+
314 309
     The default behaviour is to load all the available shipping methods
315
-    using the shipping Repository.  If there is only 1, then it is 
310
+    using the shipping Repository.  If there is only 1, then it is
316 311
     automatically selected.  Otherwise, a page is rendered where
317 312
     the user can choose the appropriate one.
318 313
     """
319 314
     template_name = 'checkout/shipping_methods.html';
320
-    
315
+
321 316
     def get(self, request, *args, **kwargs):
317
+        # Check that the user's basket is not empty
318
+        if request.basket.is_empty:
319
+            messages.error(request, _("You need to add some items to your basket to checkout"))
320
+            return HttpResponseRedirect(reverse('basket:summary'))
321
+
322 322
         # Check that shipping is required at all
323
-        if not self.checkout_session.is_shipping_required():
323
+        if not request.basket.is_shipping_required():
324 324
             self.checkout_session.use_shipping_method(NoShippingRequired().code)
325 325
             return self.get_success_response()
326 326
 
@@ -354,13 +354,13 @@ class ShippingMethodView(CheckoutSessionMixin, TemplateView):
354 354
         """
355 355
         Returns all applicable shipping method objects
356 356
         for a given basket.
357
-        """ 
357
+        """
358 358
         # Shipping methods can depend on the user, the contents of the basket
359 359
         # and the shipping address.  I haven't come across a scenario that doesn't
360 360
         # fit this system.
361
-        return Repository().get_shipping_methods(self.request.user, self.request.basket, 
361
+        return Repository().get_shipping_methods(self.request.user, self.request.basket,
362 362
                                                  self.get_shipping_address())
363
-    
363
+
364 364
     def post(self, request, *args, **kwargs):
365 365
         # Need to check that this code is valid for this user
366 366
         method_code = request.POST.get('method_code', None)
@@ -376,7 +376,7 @@ class ShippingMethodView(CheckoutSessionMixin, TemplateView):
376 376
         # and continue to the next step.
377 377
         self.checkout_session.use_shipping_method(method_code)
378 378
         return self.get_success_response()
379
-        
379
+
380 380
     def get_success_response(self):
381 381
         return HttpResponseRedirect(reverse('checkout:payment-method'))
382 382
 
@@ -389,23 +389,29 @@ class ShippingMethodView(CheckoutSessionMixin, TemplateView):
389 389
 class PaymentMethodView(CheckoutSessionMixin, TemplateView):
390 390
     """
391 391
     View for a user to choose which payment method(s) they want to use.
392
-    
392
+
393 393
     This would include setting allocations if payment is to be split
394 394
     between multiple sources.
395 395
     """
396
-    
396
+
397 397
     def get(self, request, *args, **kwargs):
398
+        # Check that the user's basket is not empty
399
+        if request.basket.is_empty:
400
+            messages.error(request, _("You need to add some items to your basket to checkout"))
401
+            return HttpResponseRedirect(reverse('basket:summary'))
402
+
398 403
         # Check that shipping address has been completed
399
-        if self.checkout_session.is_shipping_required() and not self.checkout_session.is_shipping_address_set():
404
+        if request.basket.is_shipping_required() and not self.checkout_session.is_shipping_address_set():
400 405
             messages.error(request, _("Please choose a shipping address"))
401 406
             return HttpResponseRedirect(reverse('checkout:shipping-address'))
407
+
402 408
         # Check that shipping method has been set
403 409
         if not self.checkout_session.is_shipping_method_set():
404 410
             messages.error(request, _("Please choose a shipping method"))
405 411
             return HttpResponseRedirect(reverse('checkout:shipping-method'))
406 412
 
407 413
         return self.get_success_response()
408
-    
414
+
409 415
     def get_success_response(self):
410 416
         return HttpResponseRedirect(reverse('checkout:payment-details'))
411 417
 
@@ -427,24 +433,24 @@ class OrderPlacementMixin(CheckoutSessionMixin):
427 433
     _payment_events = None
428 434
 
429 435
     # Default code for the email to send after successful checkout
430
-    communication_type_code = 'ORDER_PLACED' 
431
-    
432
-    def handle_order_placement(self, order_number, basket, total_incl_tax, total_excl_tax, **kwargs): 
436
+    communication_type_code = 'ORDER_PLACED'
437
+
438
+    def handle_order_placement(self, order_number, basket, total_incl_tax, total_excl_tax, **kwargs):
433 439
         """
434 440
         Write out the order models and return the appropriate HTTP response
435
-        
441
+
436 442
         We deliberately pass the basket in here as the one tied to the request
437 443
         isn't necessarily the correct one to use in placing the order.  This can
438 444
         happen when a basket gets frozen.
439
-        """   
445
+        """
440 446
         order = self.place_order(order_number, basket, total_incl_tax, total_excl_tax, **kwargs)
441 447
         basket.set_as_submitted()
442 448
         return self.handle_successful_order(order)
443
-        
449
+
444 450
     def add_payment_source(self, source):
445 451
         if self._payment_sources is None:
446 452
             self._payment_sources = []
447
-        self._payment_sources.append(source)  
453
+        self._payment_sources.append(source)
448 454
 
449 455
     def add_payment_event(self, event_type_name, amount):
450 456
         event_type, n = PaymentEventType.objects.get_or_create(name=event_type_name)
@@ -452,25 +458,25 @@ class OrderPlacementMixin(CheckoutSessionMixin):
452 458
             self._payment_events = []
453 459
         event = PaymentEvent(event_type=event_type, amount=amount)
454 460
         self._payment_events.append(event)
455
-        
456
-    def handle_successful_order(self, order):  
461
+
462
+    def handle_successful_order(self, order):
457 463
         """
458 464
         Handle the various steps required after an order has been successfully placed.
459 465
 
460 466
         Override this view if you want to perform custom actions when an
461 467
         order is submitted.
462
-        """  
468
+        """
463 469
         # Send confirmation message (normally an email)
464 470
         self.send_confirmation_message(order)
465
-        
471
+
466 472
         # Flush all session data
467 473
         self.checkout_session.flush()
468
-        
474
+
469 475
         # Save order id in session so thank-you page can load it
470 476
         self.request.session['checkout_order_id'] = order.id
471 477
 
472 478
         return HttpResponseRedirect(reverse('checkout:thank-you'))
473
-    
479
+
474 480
     def place_order(self, order_number, basket, total_incl_tax, total_excl_tax, **kwargs):
475 481
         """
476 482
         Writes the order out to the DB including the payment models
@@ -501,20 +507,20 @@ class OrderPlacementMixin(CheckoutSessionMixin):
501 507
                                            **kwargs)
502 508
         self.save_payment_details(order)
503 509
         return order
504
-    
510
+
505 511
     def create_shipping_address(self):
506 512
         """
507 513
         Create and returns the shipping address for the current order.
508
-        
514
+
509 515
         If the shipping address was entered manually, then we simply
510 516
         write out a ShippingAddress model with the appropriate form data.  If
511 517
         the user is authenticated, then we create a UserAddress from this data
512
-        too so it can be re-used in the future. 
513
-        
518
+        too so it can be re-used in the future.
519
+
514 520
         If the shipping address was selected from the user's address book,
515 521
         then we convert the UserAddress to a ShippingAddress.
516 522
         """
517
-        if not self.checkout_session.is_shipping_required():
523
+        if not self.request.basket.is_shipping_required():
518 524
             return None
519 525
 
520 526
         addr_data = self.checkout_session.new_shipping_address_fields()
@@ -527,16 +533,16 @@ class OrderPlacementMixin(CheckoutSessionMixin):
527 533
         else:
528 534
             raise AttributeError("No shipping address data found")
529 535
         return addr
530
-    
536
+
531 537
     def create_shipping_address_from_form_fields(self, addr_data):
532 538
         """Creates a shipping address model from the saved form fields"""
533 539
         shipping_addr = ShippingAddress(**addr_data)
534
-        shipping_addr.save() 
540
+        shipping_addr.save()
535 541
         return shipping_addr
536
-    
542
+
537 543
     def create_user_address(self, addr_data):
538 544
         """
539
-        For signed-in users, we create a user address model which will go 
545
+        For signed-in users, we create a user address model which will go
540 546
         into their address book.
541 547
         """
542 548
         if self.request.user.is_authenticated():
@@ -548,19 +554,19 @@ class OrderPlacementMixin(CheckoutSessionMixin):
548 554
                 UserAddress._default_manager.get(hash=user_addr.generate_hash())
549 555
             except ObjectDoesNotExist:
550 556
                 user_addr.save()
551
-    
557
+
552 558
     def create_shipping_address_from_user_address(self, addr_id):
553 559
         """Creates a shipping address from a user address"""
554 560
         address = UserAddress._default_manager.get(pk=addr_id)
555
-        # Increment the number of orders to help determine popularity of orders 
561
+        # Increment the number of orders to help determine popularity of orders
556 562
         address.num_orders += 1
557 563
         address.save()
558
-        
564
+
559 565
         shipping_addr = ShippingAddress()
560 566
         address.populate_alternative_model(shipping_addr)
561 567
         shipping_addr.save()
562 568
         return shipping_addr
563
-    
569
+
564 570
     def create_billing_address(self, shipping_address=None):
565 571
         """
566 572
         Saves any relevant billing data (eg a billing address).
@@ -569,12 +575,12 @@ class OrderPlacementMixin(CheckoutSessionMixin):
569 575
 
570 576
     def save_payment_details(self, order):
571 577
         """
572
-        Saves all payment-related details. This could include a billing 
578
+        Saves all payment-related details. This could include a billing
573 579
         address, payment sources and any order payment events.
574 580
         """
575 581
         self.save_payment_events(order)
576 582
         self.save_payment_sources(order)
577
-    
583
+
578 584
     def save_payment_events(self, order):
579 585
         """
580 586
         Saves any relevant payment events for this order
@@ -588,8 +594,8 @@ class OrderPlacementMixin(CheckoutSessionMixin):
588 594
     def save_payment_sources(self, order):
589 595
         """
590 596
         Saves any payment sources used in this order.
591
-        
592
-        When the payment sources are created, the order model does not exist and 
597
+
598
+        When the payment sources are created, the order model does not exist and
593 599
         so they need to have it set before saving.
594 600
         """
595 601
         if not self._payment_sources:
@@ -597,14 +603,14 @@ class OrderPlacementMixin(CheckoutSessionMixin):
597 603
         for source in self._payment_sources:
598 604
             source.order = order
599 605
             source.save()
600
-    
606
+
601 607
     def get_initial_order_status(self, basket):
602 608
         return None
603
-        
609
+
604 610
     def get_submitted_basket(self):
605 611
         basket_id = self.checkout_session.get_submitted_basket_id()
606 612
         return Basket._default_manager.get(pk=basket_id)
607
-    
613
+
608 614
     def restore_frozen_basket(self):
609 615
         """
610 616
         Restores a frozen basket as the sole OPEN basket.  Note that this also merges
@@ -645,8 +651,8 @@ class OrderPlacementMixin(CheckoutSessionMixin):
645 651
             CommunicationEvent._default_manager.create(order=order, event_type=event_type)
646 652
             messages = event_type.get_messages(ctx)
647 653
 
648
-        if messages and messages['body']:      
649
-            logger.info("Order #%s - sending %s messages", order.number, code)  
654
+        if messages and messages['body']:
655
+            logger.info("Order #%s - sending %s messages", order.number, code)
650 656
             dispatcher = Dispatcher(logger)
651 657
             dispatcher.dispatch_order_messages(order, messages, event_type, **kwargs)
652 658
         else:
@@ -656,11 +662,11 @@ class OrderPlacementMixin(CheckoutSessionMixin):
656 662
 class PaymentDetailsView(OrderPlacementMixin, TemplateView):
657 663
     """
658 664
     For taking the details of payment and creating the order
659
-    
665
+
660 666
     The class is deliberately split into fine-grained methods, responsible for only one
661 667
     thing.  This is to make it easier to subclass and override just one component of
662 668
     functionality.
663
-    
669
+
664 670
     Almost all projects will need to subclass and customise this class.
665 671
     """
666 672
     template_name = 'checkout/payment_details.html'
@@ -671,8 +677,12 @@ class PaymentDetailsView(OrderPlacementMixin, TemplateView):
671 677
         return [self.template_name_preview] if self.preview else [self.template_name]
672 678
 
673 679
     def get_error_response(self):
680
+        # Check that the user's basket is not empty
681
+        if self.request.basket.is_empty:
682
+            messages.error(self.request, _("You need to add some items to your basket to checkout"))
683
+            return HttpResponseRedirect(reverse('basket:summary'))
674 684
         # Check that shipping address has been completed
675
-        if self.checkout_session.is_shipping_required() and not self.checkout_session.is_shipping_address_set():
685
+        if self.request.basket.is_shipping_required() and not self.checkout_session.is_shipping_address_set():
676 686
             messages.error(self.request, _("Please choose a shipping address"))
677 687
             return HttpResponseRedirect(reverse('checkout:shipping-address'))
678 688
         # Check that shipping method has been set
@@ -685,7 +695,7 @@ class PaymentDetailsView(OrderPlacementMixin, TemplateView):
685 695
         if error_response:
686 696
             return error_response
687 697
         return super(PaymentDetailsView, self).get(request, *args, **kwargs)
688
-    
698
+
689 699
     def post(self, request, *args, **kwargs):
690 700
         """
691 701
         This method is designed to be overridden by subclasses which will
@@ -693,6 +703,7 @@ class PaymentDetailsView(OrderPlacementMixin, TemplateView):
693 703
         then the method can call submit()
694 704
         """
695 705
         error_response = self.get_error_response()
706
+        
696 707
         if error_response:
697 708
             return error_response
698 709
         if self.preview:
@@ -745,7 +756,7 @@ class PaymentDetailsView(OrderPlacementMixin, TemplateView):
745 756
     def submit(self, basket, payment_kwargs=None, order_kwargs=None):
746 757
         """
747 758
         Submit a basket for order placement.
748
-        
759
+
749 760
         The process runs as follows:
750 761
          * Generate an order number
751 762
          * Freeze the basket so it cannot be modified any more.
@@ -762,7 +773,7 @@ class PaymentDetailsView(OrderPlacementMixin, TemplateView):
762 773
         # Next, check that basket isn't empty
763 774
         if basket.is_empty:
764 775
             messages.error(self.request, _("This order cannot be submitted as the basket is empty"))
765
-            url = self.request.META.get('HTTP_REFERER', reverse('checkout:shipping-address'))
776
+            url = self.request.META.get('HTTP_REFERER', reverse('basket:summary'))
766 777
             return HttpResponseRedirect(url)
767 778
 
768 779
         # Domain-specific checks on the basket
@@ -772,17 +783,17 @@ class PaymentDetailsView(OrderPlacementMixin, TemplateView):
772 783
             return HttpResponseRedirect(url)
773 784
 
774 785
         # We generate the order number first as this will be used
775
-        # in payment requests (ie before the order model has been 
786
+        # in payment requests (ie before the order model has been
776 787
         # created).  We also save it in the session for multi-stage
777 788
         # checkouts (eg where we redirect to a 3rd party site and place
778 789
         # the order on a different request).
779 790
         order_number = self.generate_order_number(basket)
780 791
         logger.info("Order #%s: beginning submission process for basket %d", order_number, basket.id)
781
-        
792
+
782 793
         self.freeze_basket(basket)
783 794
         self.checkout_session.set_submitted_basket(basket)
784
-        
785
-        # Handle payment.  Any payment problems should be handled by the 
795
+
796
+        # Handle payment.  Any payment problems should be handled by the
786 797
         # handle_payment method raise an exception, which should be caught
787 798
         # within handle_POST and the appropriate forms redisplayed.
788 799
         try:
@@ -821,7 +832,7 @@ class PaymentDetailsView(OrderPlacementMixin, TemplateView):
821 832
             msg = unicode(e)
822 833
             self.restore_frozen_basket()
823 834
             return self.render_to_response(self.get_context_data(error=msg))
824
-    
835
+
825 836
     def generate_order_number(self, basket):
826 837
         generator = OrderNumberGenerator()
827 838
         order_number = generator.order_number(basket)
@@ -834,11 +845,11 @@ class PaymentDetailsView(OrderPlacementMixin, TemplateView):
834 845
         # need to be "unfrozen".  We also store the basket ID in the session
835 846
         # so the it can be retrieved by multistage checkout processes.
836 847
         basket.freeze()
837
-    
848
+
838 849
     def handle_payment(self, order_number, total, **kwargs):
839 850
         """
840
-        Handle any payment processing.  
841
-        
851
+        Handle any payment processing.
852
+
842 853
         This method is designed to be overridden within your project.  The
843 854
         default is to do nothing.
844 855
         """
@@ -862,7 +873,7 @@ class ThankYouView(DetailView):
862 873
     """
863 874
     template_name = 'checkout/thank_you.html'
864 875
     context_object_name = 'order'
865
-    
876
+
866 877
     def get_object(self):
867 878
         # We allow superusers to force an order thankyou page for testing
868 879
         order = None
@@ -871,11 +882,11 @@ class ThankYouView(DetailView):
871 882
                 order = Order._default_manager.get(number=self.request.GET['order_number'])
872 883
             elif 'order_id' in self.request.GET:
873 884
                 order = Order._default_manager.get(id=self.request.GET['orderid'])
874
-        
885
+
875 886
         if not order:
876 887
             if 'checkout_order_id' in self.request.session:
877 888
                 order = Order._default_manager.get(pk=self.request.session['checkout_order_id'])
878 889
             else:
879 890
                 raise Http404(_("No order found"))
880
-        
891
+
881 892
         return order

+ 2
- 3
oscar/apps/customer/utils.py Целия файл

@@ -8,8 +8,7 @@ CommunicationEvent = get_model('order', 'CommunicationEvent')
8 8
 Email = get_model('customer', 'Email')
9 9
 
10 10
 
11
-class Dispatcher(object):
12
-
11
+class Dispatcher(object): 
13 12
     def __init__(self, logger=None):
14 13
         if not logger:
15 14
             logger = logging.getLogger(__name__)
@@ -24,7 +23,7 @@ class Dispatcher(object):
24 23
         if messages['subject'] and messages['body']:
25 24
             self.send_email_messages(recipient, messages)
26 25
     
27
-    def dispatch_order_messages(self, order, messages, event_type, **kwargs):
26
+    def dispatch_order_messages(self, order, messages, event_type=None, **kwargs):
28 27
         """
29 28
         Dispatch order-related messages to the customer
30 29
         """

+ 58
- 56
oscar/apps/dashboard/catalogue/forms.py Целия файл

@@ -70,8 +70,65 @@ class StockRecordForm(forms.ModelForm):
70 70
         exclude = ('product', 'num_allocated', 'price_currency')
71 71
 
72 72
 
73
+def _attr_text_field(attribute):
74
+    return forms.CharField(label=attribute.name,
75
+                           required=attribute.required)
76
+
77
+def _attr_integer_field(attribute):
78
+    return forms.IntegerField(label=attribute.name,
79
+                              required=attribute.required)
80
+
81
+def _attr_boolean_field(attribute):
82
+    return forms.BooleanField(label=attribute.name,
83
+                              required=attribute.required)
84
+
85
+def _attr_float_field(attribute):
86
+    return forms.FloatField(label=attribute.name,
87
+                            required=attribute.required)
88
+
89
+def _attr_date_field(attribute):
90
+    return forms.DateField(label=attribute.name,
91
+                           required=attribute.required,
92
+                           widget=forms.widgets.DateInput)
93
+
94
+def _attr_option_field(attribute):
95
+    return forms.ModelChoiceField(
96
+        label=attribute.name,
97
+        required=attribute.required,
98
+        queryset=attribute.option_group.options.all())
99
+
100
+def _attr_multi_option_field(attribute):
101
+    return forms.ModelMultipleChoiceField(
102
+        label=attribute.name,
103
+        required=attribute.required,
104
+        queryset=attribute.option_group.options.all())
105
+
106
+def _attr_entity_field(attribute):
107
+    return forms.ModelChoiceField(
108
+        label=attribute.name,
109
+        required=attribute.required,
110
+        queryset=attribute.entity_type.entities.all())
111
+
112
+def _attr_numeric_field(attribute):
113
+    return forms.FloatField(label=attribute.name,
114
+                            required=attribute.required)
115
+
116
+
73 117
 class ProductForm(forms.ModelForm):
74 118
 
119
+    FIELD_FACTORIES = {
120
+        "text": _attr_text_field,
121
+        "richtext": _attr_text_field,
122
+        "integer": _attr_integer_field,
123
+        "boolean": _attr_boolean_field,
124
+        "float": _attr_float_field,
125
+        "date": _attr_date_field,
126
+        "option": _attr_option_field,
127
+        "multi_option" : _attr_multi_option_field,
128
+        "entity": _attr_entity_field,
129
+        "numeric" : _attr_numeric_field,
130
+    }
131
+
75 132
     def __init__(self, product_class, *args, **kwargs):
76 133
         self.product_class = product_class
77 134
         self.set_initial_attribute_values(kwargs)
@@ -91,68 +148,13 @@ class ProductForm(forms.ModelForm):
91 148
             else:
92 149
                 kwargs['initial']['attr_%s' % attribute.code] = value
93 150
 
94
-    def _attr_text_field(self, attribute):
95
-        return forms.CharField(label=attribute.name,
96
-                               required=attribute.required)
97
-
98
-    def _attr_integer_field(self, attribute):
99
-        return forms.IntegerField(label=attribute.name,
100
-                                  required=attribute.required)
101
-
102
-    def _attr_boolean_field(self, attribute):
103
-        return forms.BooleanField(label=attribute.name,
104
-                                  required=attribute.required)
105
-
106
-    def _attr_float_field(self, attribute):
107
-        return forms.FloatField(label=attribute.name,
108
-                                required=attribute.required)
109
-
110
-    def _attr_date_field(self, attribute):
111
-        return forms.DateField(label=attribute.name,
112
-                               required=attribute.required,
113
-                               widget=forms.widgets.SelectDateWidget)
114
-
115
-    def _attr_option_field(self, attribute):
116
-        return forms.ModelChoiceField(
117
-            label=attribute.name,
118
-            required=attribute.required,
119
-            queryset=attribute.option_group.options.all())
120
-
121
-    def _attr_multi_option_field(self, attribute):
122
-        return forms.ModelMultipleChoiceField(
123
-            label=attribute.name,
124
-            required=attribute.required,
125
-            queryset=attribute.option_group.options.all())
126
-
127
-    def _attr_entity_field(self, attribute):
128
-        return forms.ModelChoiceField(
129
-            label=attribute.name,
130
-            required=attribute.required,
131
-            queryset=attribute.entity_type.entities.all())
132
-
133
-    def _attr_numeric_field(self, attribute):
134
-        return forms.FloatField(label=attribute.name,
135
-                                required=attribute.required)
136
-
137
-    FIELD_FACTORIES = {
138
-        "text": _attr_text_field,
139
-        "integer": _attr_integer_field,
140
-        "boolean": _attr_boolean_field,
141
-        "float": _attr_float_field,
142
-        "date": _attr_date_field,
143
-        "option": _attr_option_field,
144
-        "multi_option" : _attr_multi_option_field,
145
-        "entity": _attr_entity_field,
146
-        "numeric" : _attr_numeric_field,
147
-    }
148
-
149 151
     def add_attribute_fields(self):
150 152
         for attribute in self.product_class.attributes.all():
151 153
             self.fields['attr_%s' % attribute.code] = \
152 154
                     self.get_attribute_field(attribute)
153 155
 
154 156
     def get_attribute_field(self, attribute):
155
-        return self.FIELD_FACTORIES[attribute.type](self, attribute)
157
+        return self.FIELD_FACTORIES[attribute.type](attribute)
156 158
 
157 159
     class Meta:
158 160
         model = Product

+ 2
- 2
oscar/apps/dashboard/catalogue/views.py Целия файл

@@ -120,7 +120,7 @@ class ProductCreateView(generic.CreateView):
120 120
         image_formset = ProductImageFormSet(self.request.POST,
121 121
                                             self.request.FILES,
122 122
                                             instance=product)
123
-        if stockrecord_form.is_valid() and category_formset.is_valid() and image_formset.is_valid():
123
+        if all([stockrecord_form.is_valid(), category_formset.is_valid(), image_formset.is_valid()]):
124 124
             # Save product
125 125
             product.save()
126 126
             # Save stock record
@@ -194,7 +194,7 @@ class ProductUpdateView(generic.UpdateView):
194 194
         image_formset = ProductImageFormSet(self.request.POST,
195 195
                                             self.request.FILES,
196 196
                                             instance=self.object)
197
-        if stockrecord_form.is_valid() and category_formset.is_valid() and image_formset.is_valid():
197
+        if all([stockrecord_form.is_valid(), category_formset.is_valid(), image_formset.is_valid()]):
198 198
             form.save()
199 199
             stockrecord_form.save()
200 200
             category_formset.save()

+ 9
- 4
oscar/apps/partner/abstract_models.py Целия файл

@@ -191,22 +191,27 @@ class AbstractStockRecord(models.Model):
191 191
     @property
192 192
     def availability_code(self):
193 193
         """
194
-        Return an item's availability as a code for use in CSS
194
+        Return an product's availability as a code for use in CSS to add icons
195
+        to the overall availability mark-up.  For example, "instock",
196
+        "unavailable".
195 197
         """
196 198
         return get_partner_wrapper(self.partner.name).availability_code(self)
197 199
     
198 200
     @property
199 201
     def availability(self):
200 202
         """
201
-        Return an item's availability as a string
203
+        Return a product's availability as a string that can be displayed to the
204
+        user.  For example, "In stock", "Unavailabl".
202 205
         """
203 206
         return get_partner_wrapper(self.partner.name).availability(self)
204 207
 
205
-    def max_purchase_quantity(self, user):
208
+    def max_purchase_quantity(self, user=None):
206 209
         """
207 210
         Return an item's availability as a string
211
+
212
+        :param user: (optional) The user who wants to purchase
208 213
         """
209
-        return get_partner_wrapper(self.partner.name).availability(self)
214
+        return get_partner_wrapper(self.partner.name).max_purchase_quantity(self, user)
210 215
     
211 216
     @property
212 217
     def dispatch_date(self):

+ 0
- 4
oscar/apps/partner/tests/__init__.py Целия файл

@@ -1,4 +0,0 @@
1
-from oscar.apps.partner.tests.models import *
2
-from oscar.apps.partner.tests.imports import *
3
-from oscar.apps.partner.tests.checkout import *
4
-from oscar.apps.partner.tests.wrappers import *

+ 7
- 0
oscar/apps/partner/wrappers.py Целия файл

@@ -47,6 +47,8 @@ class DefaultWrapper(object):
47 47
         Return a code for the availability of this product.
48 48
 
49 49
         This is normally used within CSS to add icons to stock messages
50
+
51
+        :param oscar.apps.partner.models.StockRecord stockrecord: stockrecord instance
50 52
         """
51 53
         if stockrecord.net_stock_level > 0:
52 54
             return 'instock'
@@ -55,6 +57,11 @@ class DefaultWrapper(object):
55 57
         return 'outofstock'
56 58
     
57 59
     def availability(self, stockrecord):
60
+        """
61
+        Return an availability message for the passed stockrecord.
62
+
63
+        :param oscar.apps.partner.models.StockRecord stockrecord: stockrecord instance
64
+        """
58 65
         if stockrecord.net_stock_level > 0:
59 66
             return _("In stock (%d available)" % stockrecord.net_stock_level)
60 67
         if self.is_available_to_buy(stockrecord):

+ 0
- 2
oscar/apps/payment/tests/__init__.py Целия файл

@@ -1,2 +0,0 @@
1
-from oscar.apps.payment.tests.main import *
2
-

+ 1
- 0
oscar/apps/promotions/context_processors.py Целия файл

@@ -25,6 +25,7 @@ def get_request_promotions(request):
25 25
     Return promotions relevant to this request
26 26
     """
27 27
     promotions = PagePromotion._default_manager.select_related() \
28
+                                               .prefetch_related('content_object') \
28 29
                                                .filter(page_url=request.path) \
29 30
                                                .order_by('display_order')
30 31
 

+ 8
- 2
oscar/apps/promotions/models.py Целия файл

@@ -247,8 +247,14 @@ class AutomaticProductList(AbstractProductList):
247 247
 
248 248
     def get_queryset(self):
249 249
         if self.method == self.BESTSELLING:
250
-            return Product.browsable.all().order_by('-score')
251
-        return Product.browsable.all().order_by('-date_created')
250
+            return (Product.browsable.all()
251
+                    .select_related('stockrecord__partner')
252
+                    .prefetch_related('variants', 'images')
253
+                    .order_by('-score'))
254
+        return (Product.browsable.all()
255
+                .select_related('stockrecord__partner')
256
+                .prefetch_related('variants', 'images')
257
+                .order_by('-date_created'))
252 258
 
253 259
     def get_products(self):
254 260
         return self.get_queryset()[:self.num_products]

+ 0
- 61
oscar/apps/promotions/tests.py Целия файл

@@ -1,61 +0,0 @@
1
-import httplib
2
-
3
-from django.test import TestCase
4
-from django.core.exceptions import ValidationError
5
-from django.core.urlresolvers import resolve
6
-from django.test import Client
7
-
8
-from oscar.apps.promotions.models import * 
9
-
10
-
11
-class PromotionTest(TestCase):
12
-
13
-    def test_default_template_name(self):
14
-        promotion = Image.objects.create(name="dummy banner")
15
-        self.assertEqual('promotions/image.html', promotion.template_name())
16
-
17
-
18
-
19
-
20
-
21
-#class PagePromotionTest(unittest.TestCase):
22
-#    
23
-#    def setUp(self):
24
-#        self.promotion = Promotion.objects.create(name='Dummy', link_url='http://www.example.com')
25
-#        self.page_prom = PagePromotion.objects.create(promotion=self.promotion,
26
-#                                                      position=RAW_HTML,
27
-#                                                      page_url='/')
28
-#    
29
-#    def test_clicks_start_at_zero(self):
30
-#        self.assertEquals(0, self.page_prom.clicks)
31
-#    
32
-#    def test_click_is_recorded(self):
33
-#        self.page_prom.record_click()
34
-#        self.assertEquals(1, self.page_prom.clicks)
35
-#
36
-#    def test_get_link(self):
37
-#        link = self.page_prom.get_link()
38
-#        match = resolve(link)
39
-#        self.assertEquals('page-click', match.url_name)
40
-#
41
-#
42
-#class KeywordPromotionTest(unittest.TestCase):
43
-#    
44
-#    def setUp(self):
45
-#        self.promotion = Promotion.objects.create(name='Dummy', link_url='http://www.example.com')
46
-#        self.kw_prom = KeywordPromotion.objects.create(promotion=self.promotion,
47
-#                                                       position=RAW_HTML,
48
-#                                                       keyword='cheese')
49
-#    
50
-#    def test_clicks_start_at_zero(self):
51
-#        self.assertEquals(0, self.kw_prom.clicks)
52
-#    
53
-#    def test_click_is_recorded(self):
54
-#        self.kw_prom.record_click()
55
-#        self.assertEquals(1, self.kw_prom.clicks)
56
-#        
57
-#    def test_get_link(self):
58
-#        link = self.kw_prom.get_link()
59
-#        match = resolve(link)
60
-#        self.assertEquals('keyword-click', match.url_name)
61
-        

+ 0
- 42
oscar/apps/search/tests.py Целия файл

@@ -1,42 +0,0 @@
1
-from django.test import TestCase, Client
2
-from django.core.urlresolvers import reverse
3
-
4
-from django.core import management
5
-from haystack import backend
6
-
7
-
8
-class SuggestViewTest(TestCase):
9
-    fixtures = ['sample-products']
10
-
11
-    def setUp(self):
12
-        #clear out existing index without prompting user and ensure that
13
-        #fixtures are indexed
14
-        self.client = Client()
15
-        sb = backend.SearchBackend()
16
-        sb.clear()
17
-        management.call_command('update_index', verbosity=0) #silenced
18
-
19
-    def test_term_in_fixtures_found(self):
20
-        url = reverse('oscar-search-suggest')
21
-        response = self.client.get(url, {'query_term': 'Pint'})
22
-        self.assertEquals(200, response.status_code)
23
-        self.assertTrue('Pint' in response.content) #ensuring we actually find a result in the response
24
-
25
-class MultiFacetedSearchViewTest(TestCase):
26
-    fixtures = ['sample-products']
27
-
28
-    def setUp(self):
29
-        #clear out existing index without prompting user and ensure that
30
-        #fixtures are indexed
31
-        self.client = Client()
32
-        sb = backend.SearchBackend()
33
-        sb.clear()
34
-        management.call_command('update_index', verbosity=0) #silenced
35
-
36
-    def test_with_query(self):
37
-        url = reverse('oscar-search')
38
-        response = self.client.get(url, {'q': 'Pint'})
39
-        self.assertEquals(200, response.status_code)
40
-        self.assertTrue('value="Pint"' in response.content) #ensuring query field is set
41
-    
42
-        

+ 4
- 4
oscar/apps/shipping/repository.py Целия файл

@@ -7,16 +7,16 @@ class Repository(object):
7 7
     Repository class responsible for returning ShippingMethod
8 8
     objects for a given user, basket etc
9 9
     """
10
-    
10
+
11 11
     def get_shipping_methods(self, user, basket, shipping_addr=None, **kwargs):
12 12
         """
13 13
         Return a list of all applicable shipping method objects
14 14
         for a given basket.
15
-        
15
+
16 16
         We default to returning the Method models that have been defined but
17 17
         this behaviour can easily be overridden by subclassing this class
18 18
         and overriding this method.
19
-        """ 
19
+        """
20 20
         methods = [Free()]
21 21
         return self.add_basket_to_methods(basket, methods)
22 22
 
@@ -24,7 +24,7 @@ class Repository(object):
24 24
         methods = self.get_shipping_methods(user, basket, shipping_addr, **kwargs)
25 25
         if len(methods) == 0:
26 26
             raise ImproperlyConfigured("You need to define some shipping methods")
27
-        return methods[0]
27
+        return min(methods, key=lambda method: method.basket_charge_incl_tax())
28 28
 
29 29
     def add_basket_to_methods(self, basket, methods):
30 30
         for method in methods:

+ 5
- 1
oscar/apps/voucher/abstract_models.py Целия файл

@@ -90,7 +90,10 @@ class AbstractVoucher(models.Model):
90 90
         """
91 91
         Records a usage of this voucher in an order.
92 92
         """
93
-        self.applications.create(voucher=self, order=order, user=user)
93
+        if user.is_authenticated():
94
+            self.applications.create(voucher=self, order=order, user=user)
95
+        else:
96
+            self.applications.create(voucher=self, order=order)
94 97
 
95 98
     @property
96 99
     def benefit(self):
@@ -102,6 +105,7 @@ class AbstractVoucherApplication(models.Model):
102 105
     For tracking how often a voucher has been used
103 106
     """
104 107
     voucher = models.ForeignKey('voucher.Voucher', related_name="applications")
108
+
105 109
     # It is possible for an anonymous user to apply a voucher so we need to allow
106 110
     # the user to be nullable
107 111
     user = models.ForeignKey('auth.User', blank=True, null=True)

+ 7
- 7
oscar/core/loading.py Целия файл

@@ -20,14 +20,14 @@ def get_class(module_label, classname):
20 20
 def get_classes(module_label, classnames):
21 21
     """
22 22
     For dynamically importing classes from a module.
23
-    
23
+
24 24
     Eg. calling get_classes('catalogue.models', ['Product']) will search
25 25
     INSTALLED_APPS for the relevant product app (default is
26 26
     'oscar.apps.catalogue') and then import the classes from there.  If the
27 27
     class can't be found in the overriding module, then we attempt to import it
28
-    from within oscar.  
29
-    
30
-    This is very similar to django.db.models.get_model although that is only 
28
+    from within oscar.
29
+
30
+    This is very similar to django.db.models.get_model although that is only
31 31
     for loading models while this method will load any class.
32 32
     """
33 33
     app_module_path = _get_app_module_path(module_label)
@@ -44,7 +44,7 @@ def get_classes(module_label, classnames):
44 44
     # App must be local - check if module is in local app (it could be in
45 45
     # oscar's)
46 46
     app_label = module_label.split('.')[0]
47
-    base_package = app_module_path.replace('.'+app_label, '')
47
+    base_package = app_module_path.rsplit('.'+app_label, 1)[0]
48 48
     local_app = "%s.%s" % (base_package, module_label)
49 49
     try:
50 50
         imported_local_module = __import__(local_app, fromlist=classnames)
@@ -74,7 +74,7 @@ def _pluck_classes(modules, classnames):
74 74
 
75 75
 
76 76
 def _get_app_module_path(module_label):
77
-    app_name = module_label.rsplit(".", 1)[0] 
77
+    app_name = module_label.rsplit(".", 1)[0]
78 78
     for installed_app in settings.INSTALLED_APPS:
79 79
         if installed_app.endswith(app_name):
80 80
             return installed_app
@@ -90,7 +90,7 @@ def import_module(module_label, classes, namespace=None):
90 90
         for classname, klass in zip(classes, klasses):
91 91
             namespace[classname] = klass
92 92
     else:
93
-        module = new_module("oscar.apps.%s" % module_label)   
93
+        module = new_module("oscar.apps.%s" % module_label)
94 94
         for classname, klass in zip(classes, klasses):
95 95
             setattr(module, classname, klass)
96 96
         return module

+ 4
- 2
oscar/defaults.py Целия файл

@@ -53,5 +53,7 @@ OSCAR_OFFER_BLACKLIST_PRODUCT = None
53 53
 # Max total number of items in basket
54 54
 OSCAR_MAX_BASKET_QUANTITY_THRESHOLD = None
55 55
 
56
-#Cookies
57
-OSCAR_COOKIES_DELETE_ON_LOGOUT = ['oscar_recently_viewed_products', ]
56
+# Cookies
57
+OSCAR_COOKIES_DELETE_ON_LOGOUT = ['oscar_recently_viewed_products', ]
58
+
59
+OSCAR_SETTINGS = dict([(k, v) for k, v in locals().items() if k.startswith('OSCAR_')])

+ 4
- 4
oscar/static/oscar/js/oscar/checkout.js Целия файл

@@ -27,11 +27,11 @@ oscar.basket = {
27 27
         }
28 28
     },
29 29
     showVoucherForm: function() {
30
-        $('#voucher_form_container').show(); 
30
+        $('#voucher_form_container').show();
31 31
         $('#voucher_form_link').hide();
32 32
     },
33 33
     hideVoucherForm: function() {
34
-        $('#voucher_form_container').hide(); 
34
+        $('#voucher_form_container').hide();
35 35
         $('#voucher_form_link').show();
36 36
     },
37 37
     checkAndSubmit: function($ele, formPrefix, idSuffix) {
@@ -48,8 +48,8 @@ oscar.basket = {
48 48
 oscar.checkout = {
49 49
     init: function() {
50 50
         // Disable 'place order' button when it is clicked.
51
-        $('#place-order').click(function(e) {
52
-            var $btn = $(this);
51
+        $('#place-order-form').submit(function() {
52
+            var $btn = $('button#place-order', this);
53 53
             $btn.attr('disabled', 'disabled')
54 54
                 .html('Submitting...')
55 55
                 .removeClass('btn-primary')

+ 0
- 1
oscar/static/oscar/js/oscar/ui.js Целия файл

@@ -193,4 +193,3 @@ $(document).ready(function()
193 193
     }
194 194
  
195 195
 });
196
-    

+ 2
- 2
oscar/templates/basket/basket.html Целия файл

@@ -64,7 +64,7 @@ Basket | {{ block.super }}
64 64
     	    <div class="span5">
65 65
     		    <div class="checkout-quantity">
66 66
     		        {{ form.quantity }} 
67
-    		        <button class="btn">Update</button>
67
+    		        <button class="btn" type="submit">Update</button>
68 68
     			    <a href="#" data-id="{{ forloop.counter0 }}" data-behaviours="remove" class="inline">Remove</a>
69 69
 					{% if request.user.is_authenticated %}
70 70
 					| <a href="#" data-id="{{ forloop.counter0 }}" data-behaviours="save" class="inline">Save for later</a>
@@ -97,7 +97,7 @@ Basket | {{ block.super }}
97 97
 	        		{% csrf_token %}
98 98
 	        		{% include "partials/form_fields.html" with form=voucher_form %}
99 99
 	        		<div class="form-actions">
100
-	        			<button class="btn btn-info">Add voucher</button>
100
+	        			<button type="submit" class="btn btn-info">Add voucher</button>
101 101
 	        			or <a href="#" id="voucher_form_cancel">cancel</a>
102 102
 	        		</div>
103 103
 	        	</form>

+ 1
- 1
oscar/templates/checkout/preview.html Целия файл

@@ -10,7 +10,7 @@ Order preview | {{ block.super }}
10 10
 
11 11
 {% block place_order %}
12 12
 <h3>Please review the information above, then click "Place Order"</h3>
13
-<form method="post" action="{% url checkout:preview %}">
13
+<form method="post" action="{% url checkout:preview %}" id="place-order-form">
14 14
 	<input type="hidden" name="action" value="place_order" />
15 15
 	{% csrf_token %}
16 16
     <div class="form-actions">

+ 5
- 1
oscar/templates/customer/anon-order.html Целия файл

@@ -8,8 +8,12 @@
8 8
 </div>
9 9
 {% endblock header %}
10 10
 
11
-
12 11
 {% block content %}
12
+<div class="sub-header">
13
+    <h3>Status</h3>
14
+</div>
15
+<p>{{ order.status }}</p>
16
+
13 17
 <div class="sub-header">
14 18
     <h3>Shipping address</h3>
15 19
 </div>

+ 10
- 5
oscar/templates/dashboard/catalogue/product_update.html Целия файл

@@ -50,6 +50,11 @@ Update product | {{ block.super }}
50 50
                 					<li>{{ error|escape }}</li>
51 51
                 				</ul>
52 52
                 			{% endfor %}
53
+                        {% if field.help_text %}
54
+                            <span class='help-block'>
55
+                                {{ field.help_text|safe }}
56
+                            </span>
57
+                        {% endif %}
53 58
                 	{% endif %}
54 59
             	</div>
55 60
             {% else %}
@@ -76,8 +81,8 @@ Update product | {{ block.super }}
76 81
             {% endif %}
77 82
         {% endfor %}
78 83
     </div>
79
-		
80
-	<div class="well form-inline">	
84
+
85
+	<div class="well form-inline">
81 86
 		<div class="sub-header">
82 87
 			<h3 class="app-ico ico_home icon">Category information</h3>
83 88
 		</div>
@@ -87,7 +92,7 @@ Update product | {{ block.super }}
87 92
     		<hr/>
88 93
     	{% endfor %}
89 94
 	</div>
90
-	
95
+
91 96
 	<div class="well form-inline">
92 97
     	<div class="sub-header">
93 98
     	   <h3 class="app-ico ico_favourite icon">Images</h3>
@@ -98,7 +103,7 @@ Update product | {{ block.super }}
98 103
     		<hr/>
99 104
     	{% endfor %}
100 105
 	</div>
101
-	
106
+
102 107
 	<div class="well fields-full">
103 108
 		<div class="sub-header">
104 109
 		    <h3 class="app-ico ico_shop_bag icon">Stock and price information</h3>
@@ -132,7 +137,7 @@ Update product | {{ block.super }}
132 137
 		{% endblock %}
133 138
 	</div>
134 139
 	<div class="form-actions">
135
-        <button class="btn btn-primary btn-large" type="submit">Save Product</button> or 
140
+        <button class="btn btn-primary btn-large" type="submit">Save Product</button> or
136 141
             <a href="{% url dashboard:catalogue-product-list %}">cancel</a>
137 142
             {% if product %}
138 143
                 <a class="btn btn-success btn-large pull-right" href="{{ product.get_absolute_url }}">View on site</a>

+ 12
- 8
oscar/templates/dashboard/orders/order_detail.html Целия файл

@@ -31,7 +31,7 @@ Order {{ order.number }} | {{ block.super }}
31 31
 
32 32
     <h3>Customer Information</h3>
33 33
 	{% if order.user %}
34
-	
34
+
35 35
     <div class="row-fluid">
36 36
         <div class="span4">
37 37
             <div class="well well-info">
@@ -85,12 +85,14 @@ Order {{ order.number }} | {{ block.super }}
85 85
 
86 86
 <div class="tabbable dashboard">
87 87
 	<ul class="nav nav-tabs">
88
+	{% block nav_tabs %}
88 89
 		<li class="active"><a href="#lines" data-toggle="tab">Order contents</a></li>
89 90
 		<li><a href="#shipping" data-toggle="tab">Shipping</a></li>
90 91
 		<li><a href="#payment" data-toggle="tab">Payment</a></li>
91 92
 		<li><a href="#discounts" data-toggle="tab">Discounts</a></li>
92 93
 		<li><a href="#emails" data-toggle="tab">Emails</a></li>
93 94
 		<li><a href="#notes" data-toggle="tab">Notes</a></li>
95
+	{% endblock nav_tabs %}
94 96
 	</ul>
95 97
 
96 98
 	<div class="tab-content">
@@ -207,7 +209,7 @@ Order {{ order.number }} | {{ block.super }}
207 209
 							</select>
208 210
 						</label>
209 211
 						<label class="radio inline">
210
-							with amount <input type="text" name="amount" value="" /> 
212
+							with amount <input type="text" name="amount" value="" />
211 213
 						</label>
212 214
 					</div>
213 215
 				</div>
@@ -338,16 +340,16 @@ Order {{ order.number }} | {{ block.super }}
338 340
 				<h3 class="app-ico ico_mapmarker icon">Payment sources</h3>
339 341
 			</div>
340 342
 			{% if sources %}
341
-				<table class="table table-striped table-bordered">    
342
-					<thead>  
343
+				<table class="table table-striped table-bordered">
344
+					<thead>
343 345
 						<tr>
344 346
 							<th>Source</th>
345 347
 							<th>Allocation</th>
346 348
 							<th>Amount debited</th>
347 349
 							<th>Amount refunded</th>
348
-						</tr>  
349
-					</thead>	
350
-					<tbody> 
350
+						</tr>
351
+					</thead>
352
+					<tbody>
351 353
 						{% for source in sources %}
352 354
 						<tr>
353 355
 							<td>{{ source.source_type }}</td>
@@ -356,7 +358,7 @@ Order {{ order.number }} | {{ block.super }}
356 358
 							<td>{{ source.amount_refunded|currency }}</td>
357 359
 						</tr>
358 360
 						{% endfor %}
359
-					</tbody>	
361
+					</tbody>
360 362
 				</table>
361 363
 			{% else %}
362 364
 				<p>No payment sources</p>
@@ -463,6 +465,8 @@ Order {{ order.number }} | {{ block.super }}
463 465
 			</form>
464 466
 			{% endblock %}
465 467
 		</div>
468
+
469
+		{% block extra_tabs %}{% endblock %}
466 470
 	</div>
467 471
 </div>
468 472
 

+ 10
- 2
oscar/templates/dashboard/reviews/review_list.html Целия файл

@@ -50,10 +50,18 @@ Reviews | {{ block.super }}
50 50
         <tr>
51 51
             <td><input type="checkbox" name="selected_review" class="selected_review" value="{{ review.id }}"/>
52 52
                 <td>
53
-                    <a href="{% url catalogue:reviews-detail review.product.slug review.product.id review.id %}">{{ review.title }}</a>
53
+					{% if review.product %}
54
+						<a href="{% url catalogue:reviews-detail review.product.slug review.product.id review.id %}">{{ review.title }}</a>
55
+					{% else %}
56
+						{{ review.title }}
57
+					{% endif %}
54 58
                 </td>
55 59
             <td>
56
-                <a href='{% url catalogue:detail review.product.slug review.product.id %}'>{{ review.product.title }}</a> </td>
60
+				{% if review.product %}
61
+					<a href='{% url catalogue:detail review.product.slug review.product.id %}'>{{ review.product.title }}</a> </td>
62
+				{% else %}
63
+					[Product deleted]
64
+				{% endif %}
57 65
             <td>
58 66
 				{% if review.user %}
59 67
                 <a href="{% url dashboard:user-detail review.user.id %}">{{ review.get_reviewer_name }}</a>

+ 1
- 1
oscar/templates/dashboard/vouchers/voucher_delete.html Целия файл

@@ -43,7 +43,7 @@ Delete voucher '{{ voucher.name }}'? | Vouchers | {{ block.super }}
43 43
 		{% csrf_token %}
44 44
 		<div class="form-actions">
45 45
 			<button class="btn btn-danger btn-large" type="submit">Delete</button> or
46
-			<a href="{% url dashboard:range-list %}">cancel</a>
46
+			<a href="{% url dashboard:voucher-list %}">cancel</a>
47 47
 		</div>
48 48
 	</form>
49 49
 

+ 5
- 1
oscar/test/__init__.py Целия файл

@@ -4,6 +4,7 @@ from contextlib import contextmanager
4 4
 from django.test import TestCase
5 5
 from django.test.client import Client
6 6
 from django.contrib.auth.models import User
7
+from django.core.urlresolvers import reverse
7 8
 from purl import URL
8 9
 
9 10
 
@@ -51,7 +52,10 @@ class ClientTestCase(TestCase):
51 52
             location = URL.from_string(response['Location'])
52 53
             self.assertEqual(expected_url, location.path())
53 54
 
54
-
55
+    def assertRedirectUrlName(self, response, name):
56
+        self.assertIsRedirect(response)
57
+        location = response['Location'].replace('http://testserver', '')
58
+        self.assertEqual(location, reverse(name))
55 59
 
56 60
     def assertIsOk(self, response):
57 61
         self.assertEqual(httplib.OK, response.status_code)

+ 17
- 56
oscar/test/helpers.py Целия файл

@@ -1,11 +1,6 @@
1
-from StringIO import StringIO
2 1
 from decimal import Decimal as D
3 2
 import random
4
-
5
-from django.test import TestCase
6
-from django.core.servers.basehttp import AdminMediaHandler
7
-from django.core.handlers.wsgi import WSGIHandler
8
-from django.core.urlresolvers import reverse
3
+import datetime
9 4
 
10 5
 from oscar.apps.basket.models import Basket
11 6
 from oscar.apps.catalogue.models import ProductClass, Product, ProductAttribute, ProductAttributeValue
@@ -14,9 +9,10 @@ from oscar.apps.order.utils import OrderCreator
14 9
 from oscar.apps.partner.models import Partner, StockRecord
15 10
 from oscar.apps.shipping.methods import Free
16 11
 from oscar.apps.offer.models import Range, ConditionalOffer, Condition, Benefit
12
+from oscar.apps.voucher.models import Voucher
17 13
 
18 14
 
19
-def create_product(price=None, title="Dummy title", product_class="Dummy item class", 
15
+def create_product(price=None, title="Dummy title", product_class="Dummy item class",
20 16
         partner="Dummy partner", partner_sku=None, upc=None, num_in_stock=10, attributes=None):
21 17
     """
22 18
     Helper method for creating products that are used in tests.
@@ -72,6 +68,9 @@ def create_order(number=None, basket=None, user=None, shipping_address=None, shi
72 68
 
73 69
 
74 70
 def create_offer():
71
+    """
72
+    Helper method for creating an offer
73
+    """
75 74
     range = Range.objects.create(name="All products range", includes_all_products=True)
76 75
     condition = Condition.objects.create(range=range,
77 76
                                          type=Condition.COUNT,
@@ -79,7 +78,7 @@ def create_offer():
79 78
     benefit = Benefit.objects.create(range=range,
80 79
                                      type=Benefit.PERCENTAGE,
81 80
                                      value=20)
82
-    offer= ConditionalOffer.objects.create(
81
+    offer = ConditionalOffer.objects.create(
83 82
         name='Dummy offer',
84 83
         offer_type='Site',
85 84
         condition=condition,
@@ -88,53 +87,15 @@ def create_offer():
88 87
     return offer
89 88
 
90 89
 
91
-
92
-
93
-class TwillTestCase(TestCase):
90
+def create_voucher():
94 91
     """
95
-    Simple wrapper around Twill to make writing TestCases easier.
96
-
97
-    Commands availabel through self.command are:
98
-    - go        -> visit a URL
99
-    - back      -> back to previous URL
100
-    - reload    -> reload URL
101
-    - follow    -> follow a given link
102
-    - code      -> assert the HTTP response code
103
-    - find      -> assert page contains some string
104
-    - notfind   -> assert page does not contain
105
-    - title     -> assert page title
92
+    Helper method for creating a voucher
106 93
     """
107
-
108
-    HOST = '127.0.0.1'
109
-    PORT = 8080
110
-
111
-    def setUp(self):
112
-        app = AdminMediaHandler(WSGIHandler())
113
-        twill.add_wsgi_intercept(self.HOST, self.PORT, lambda: app)
114
-        twill.set_output(StringIO())
115
-        self.command = twill.commands
116
-
117
-    def tearDown(self):
118
-        twill.remove_wsgi_intercept(self.HOST, self.PORT)
119
-
120
-    def reverse(self, url_name, *args, **kwargs):
121
-        """
122
-        Custom 'reverse' function that includes the protocol and host
123
-        """
124
-        return 'http://%s:%d%s' % (self.HOST, self.PORT, reverse(url_name, *args, **kwargs))
125
-
126
-    def visit(self, url_name, *args,**kwargs):
127
-        self.command.go(self.reverse(url_name, *args, **kwargs))
128
-
129
-    def assertResponseCodeIs(self, code):
130
-        self.command.code(code)
131
-
132
-    def assertPageContains(self, regexp):
133
-        self.command.find(regexp)
134
-
135
-    def assertPageDoesNotContain(self, regexp):
136
-        self.command.notfind(regexp)
137
-
138
-    def assertPageTitleMatches(self, regexp):
139
-        self.command.title(regexp)
140
-
94
+    voucher = Voucher.objects.create(
95
+        name="Test voucher",
96
+        code="test",
97
+        start_date=datetime.date.today(),
98
+        end_date=datetime.date.today() + datetime.timedelta(days=12)
99
+    )
100
+    voucher.offers.add(create_offer())
101
+    return voucher

+ 0
- 38
oscar/tests.py Целия файл

@@ -1,38 +0,0 @@
1
-from django.test import TestCase
2
-
3
-from oscar.apps.address.tests import *
4
-from oscar.apps.basket.tests import *
5
-from oscar.apps.order.tests import *
6
-from oscar.apps.catalogue.tests import *
7
-from oscar.apps.partner.tests import *
8
-from oscar.apps.checkout.tests import *
9
-from oscar.apps.payment.tests import *
10
-from oscar.apps.offer.tests import *
11
-from oscar.apps.shipping.tests import *
12
-from oscar.apps.customer.tests import *
13
-from oscar.apps.promotions.tests import *
14
-from oscar.apps.catalogue.reviews.tests import *
15
-from oscar.apps.voucher.tests import *
16
-from oscar.apps.partner.tests import *
17
-from oscar.apps.dashboard.tests import *
18
-
19
-from oscar.core.tests import *
20
-from oscar.core.logging.tests import *
21
-
22
-import oscar
23
-
24
-
25
-class OscarTests(TestCase):
26
-
27
-    def test_app_list_exists(self):
28
-        core_apps = oscar.OSCAR_CORE_APPS
29
-        self.assertTrue('oscar' in core_apps)
30
-
31
-    def test_app_list_can_be_accessed_through_fn(self):
32
-        core_apps = oscar.get_core_apps()
33
-        self.assertTrue('oscar' in core_apps)
34
-
35
-    def test_app_list_can_be_accessed_with_overrides(self):
36
-        apps = oscar.get_core_apps(overrides=['apps.shipping'])
37
-        self.assertTrue('apps.shipping' in apps)
38
-        self.assertTrue('oscar.apps.shipping' not in apps)

+ 1
- 1
pre-commit.sh Целия файл

@@ -4,7 +4,7 @@ git stash --keep-index -q
4 4
 
5 5
 source ~/.virtualenvs/oscar/bin/activate
6 6
 
7
-./run_tests.py
7
+./runtests.py
8 8
 TEST_RESULT=$?
9 9
 
10 10
 jshint oscar/static/oscar/js/oscar

+ 4
- 1
requirements.txt Целия файл

@@ -3,7 +3,7 @@ Werkzeug==0.6.2
3 3
 coverage==3.5.1
4 4
 django-debug-toolbar==0.9.4
5 5
 django-dynamic-fixture==1.4.2
6
-django-extensions==0.8
6
+django-extensions==0.9
7 7
 docutils==0.8.1
8 8
 flake8==0.8
9 9
 ipdb==0.2
@@ -14,3 +14,6 @@ PyHamcrest==1.6
14 14
 South==0.7.3
15 15
 purl==0.3.2
16 16
 django-sorting==0.1
17
+pinocchio==0.3.1
18
+nose-progressive==1.3
19
+django-nose==1.1

run_tests.py → runtests.py Целия файл

@@ -4,21 +4,24 @@ import logging
4 4
 from optparse import OptionParser
5 5
 from coverage import coverage
6 6
 
7
-import tests.config
7
+# This configures the settings
8
+from tests.config import configure
9
+configure()
8 10
 
9
-from django.test.simple import DjangoTestSuiteRunner
11
+from django_nose import NoseTestSuiteRunner
10 12
 
11 13
 logging.disable(logging.CRITICAL)
12 14
 
13 15
 
14 16
 def run_tests(verbosity, *test_args):
15
-    test_runner = DjangoTestSuiteRunner(verbosity=verbosity)
17
+    test_runner = NoseTestSuiteRunner(verbosity=verbosity)
16 18
     if not test_args:
17
-        test_args = ['oscar']
19
+        test_args = ['tests']
18 20
     num_failures = test_runner.run_tests(test_args)
19 21
     if num_failures:
20 22
         sys.exit(num_failures)
21 23
 
24
+
22 25
 if __name__ == '__main__':
23 26
     parser = OptionParser()
24 27
     parser.add_option('-c', '--coverage', dest='use_coverage', default=False,
@@ -36,5 +39,4 @@ if __name__ == '__main__':
36 39
         print 'Generate HTML reports'
37 40
         c.html_report()
38 41
     else:
39
-        print 'Running tests'
40 42
         run_tests(options.verbosity, *args)

+ 9
- 8
tests/config.py Целия файл

@@ -4,14 +4,14 @@ from django.conf import settings, global_settings
4 4
 from oscar import OSCAR_CORE_APPS
5 5
 
6 6
 
7
-if not settings.configured:
8
-    from oscar.defaults import *
9
-    oscar_settings = dict([(k, v) for k, v in locals().items() if k.startswith('OSCAR_')])
7
+def configure():
8
+    if not settings.configured:
9
+        from oscar.defaults import OSCAR_SETTINGS
10 10
 
11
-    # Helper function to extract absolute path
12
-    location = lambda x: os.path.join(os.path.dirname(os.path.realpath(__file__)), x)
11
+        # Helper function to extract absolute path
12
+        location = lambda x: os.path.join(os.path.dirname(os.path.realpath(__file__)), x)
13 13
 
14
-    settings.configure(
14
+        settings.configure(
15 15
             DATABASES={
16 16
                 'default': {
17 17
                     'ENGINE': 'django.db.backends.sqlite3',
@@ -49,12 +49,13 @@ if not settings.configured:
49 49
                 'oscar.apps.customer.auth_backends.Emailbackend',
50 50
                 'django.contrib.auth.backends.ModelBackend',
51 51
                 ),
52
-            ROOT_URLCONF='tests.urls',
52
+            ROOT_URLCONF='tests.site.urls',
53 53
             LOGIN_REDIRECT_URL='/accounts/',
54 54
             DEBUG=False,
55 55
             SITE_ID=1,
56 56
             HAYSTACK_SEARCH_ENGINE='dummy',
57 57
             HAYSTACK_SITECONF = 'oscar.search_sites',
58 58
             APPEND_SLASH=True,
59
-            **oscar_settings
59
+            NOSE_ARGS=['-s', '-x', '--with-spec'],
60
+            **OSCAR_SETTINGS
60 61
         )

tests/apps/__init__.py → tests/functional/__init__.py Целия файл


oscar/apps/basket/tests.py → tests/functional/basket_tests.py Целия файл

@@ -2,41 +2,35 @@ from decimal import Decimal as D
2 2
 import httplib
3 3
 import datetime
4 4
 
5
-from django.conf import settings
6 5
 from django.contrib.auth.models import User
7
-from django.core.urlresolvers import reverse
6
+from django.conf import settings
8 7
 from django.test import TestCase, Client
9
-from django.http import HttpResponse
8
+from django.core.urlresolvers import reverse
10 9
 
11
-from oscar.apps.basket.models import Basket, Line
12 10
 from oscar.test.helpers import create_product
13
-from oscar.apps.basket.reports import (
14
-    OpenBasketReportGenerator, SubmittedBasketReportGenerator)
11
+from oscar.apps.basket.models import Basket
12
+from oscar.apps.basket import reports
15 13
 
16 14
 
17
-class BasketModelTest(TestCase):
15
+class BasketMergingTests(TestCase):
18 16
 
19 17
     def setUp(self):
20
-        self.basket = Basket.objects.create()
21
-        self.dummy_product = create_product()
18
+        self.product = create_product()
19
+        self.user_basket = Basket()
20
+        self.user_basket.add_product(self.product)
21
+        self.cookie_basket = Basket()
22
+        self.cookie_basket.add_product(self.product, 2)
23
+        self.user_basket.merge(self.cookie_basket, add_quantities=False)
22 24
 
23
-    def test_empty_baskets_have_zero_lines(self):
24
-        self.assertTrue(Basket().num_lines == 0)
25
+    def test_cookie_basket_has_status_set(self):
26
+        self.assertEqual('Merged', self.cookie_basket.status)
25 27
 
26
-    def test_new_baskets_are_empty(self):
27
-        self.assertTrue(Basket().is_empty)
28
+    def test_lines_are_moved_across(self):
29
+        self.assertEqual(1, self.user_basket.lines.all().count())
28 30
 
29
-    def test_basket_have_with_one_line(self):
30
-        Line.objects.create(basket=self.basket, product=self.dummy_product)
31
-        self.assertTrue(self.basket.num_lines == 1)
32
-
33
-    def test_add_product_creates_line(self):
34
-        self.basket.add_product(self.dummy_product)
35
-        self.assertTrue(self.basket.num_lines == 1)
36
-
37
-    def test_adding_multiproduct_line_returns_correct_number_of_items(self):
38
-        self.basket.add_product(self.dummy_product, 10)
39
-        self.assertEqual(self.basket.num_items, 10)
31
+    def test_merge_line_takes_max_quantity(self):
32
+        line = self.user_basket.lines.get(product=self.product)
33
+        self.assertEqual(2, line.quantity)
40 34
 
41 35
 
42 36
 class AnonAddToBasketViewTests(TestCase):
@@ -47,6 +41,7 @@ class AnonAddToBasketViewTests(TestCase):
47 41
         post_params = {'product_id': self.product.id,
48 42
                        'action': 'add',
49 43
                        'quantity': 1}
44
+        self.client = Client()
50 45
         self.response = self.client.post(url, post_params)
51 46
 
52 47
     def test_cookie_is_created(self):
@@ -109,6 +104,7 @@ class BasketThresholdTest(TestCase):
109 104
         self.assertTrue('Your basket currently has 2 items.' in
110 105
                         response.cookies['messages'].value)
111 106
 
107
+
112 108
 class BasketReportTests(TestCase):
113 109
 
114 110
     def test_open_report_doesnt_error(self):
@@ -117,7 +113,7 @@ class BasketReportTests(TestCase):
117 113
             'end_date': datetime.date(2012, 5, 17),
118 114
             'formatter': 'CSV'
119 115
         }
120
-        generator = OpenBasketReportGenerator(**data)
116
+        generator = reports.OpenBasketReportGenerator(**data)
121 117
         generator.generate()
122 118
 
123 119
     def test_submitted_report_doesnt_error(self):
@@ -126,11 +122,12 @@ class BasketReportTests(TestCase):
126 122
             'end_date': datetime.date(2012, 5, 17),
127 123
             'formatter': 'CSV'
128 124
         }
129
-        generator = SubmittedBasketReportGenerator(**data)
125
+        generator = reports.SubmittedBasketReportGenerator(**data)
130 126
         generator.generate()
131 127
 
132 128
 
133 129
 class SavedBasketTests(TestCase):
130
+
134 131
     def test_moving_from_saved_basket(self):
135 132
         user = User.objects.create_user(username='test', password='pass',
136 133
                                         email='test@example.com')

+ 21
- 0
tests/functional/catalogue_tests.py Целия файл

@@ -0,0 +1,21 @@
1
+import httplib
2
+
3
+from django.test import TestCase
4
+from django.test.client import Client
5
+from django.core.urlresolvers import reverse
6
+
7
+from oscar.test.helpers import create_product
8
+
9
+
10
+class SingleProductViewTest(TestCase):
11
+    
12
+    def setUp(self):
13
+        self.client = Client()
14
+        
15
+    def test_canonical_urls_are_enforced(self):
16
+        p = create_product()
17
+        args = {'product_slug': 'wrong-slug',
18
+                'pk': p.id}
19
+        wrong_url = reverse('catalogue:detail', kwargs=args)
20
+        response = self.client.get(wrong_url)
21
+        self.assertEquals(httplib.MOVED_PERMANENTLY, response.status_code)

oscar/apps/checkout/tests.py → tests/functional/checkout_tests.py Целия файл

@@ -5,14 +5,24 @@ from django.core.urlresolvers import reverse
5 5
 from django.conf import settings
6 6
 from django.utils.importlib import import_module
7 7
 
8
-from oscar.test.helpers import create_product
8
+from oscar.test.helpers import create_product, create_voucher
9 9
 from oscar.test import ClientTestCase, patch_settings
10 10
 from oscar.apps.basket.models import Basket
11 11
 from oscar.apps.order.models import Order
12
+from oscar.apps.address.models import Country
12 13
 
13 14
 
14 15
 class CheckoutMixin(object):
15
-    fixtures = ['countries.json']
16
+
17
+    def add_product_to_basket(self):
18
+        product = create_product(price=D('12.00'))
19
+        self.client.post(reverse('basket:add'), {'product_id': product.id,
20
+                                                 'quantity': 1})
21
+
22
+    def add_voucher_to_basket(self):
23
+        voucher = create_voucher()
24
+        self.client.post(reverse('basket:vouchers-add'),
25
+                         {'code': voucher.code})
16 26
 
17 27
     def complete_guest_email_form(self, email='test@example.com'):
18 28
         response = self.client.post(reverse('checkout:index'),
@@ -21,6 +31,10 @@ class CheckoutMixin(object):
21 31
         self.assertIsRedirect(response)
22 32
 
23 33
     def complete_shipping_address(self):
34
+        Country.objects.get_or_create(
35
+            iso_3166_1_a2='GB',
36
+            is_shipping_country=True
37
+        )
24 38
         response = self.client.post(reverse('checkout:shipping-address'),
25 39
                                      {'last_name': 'Doe',
26 40
                                       'line1': '1 Egg Street',
@@ -32,9 +46,8 @@ class CheckoutMixin(object):
32 46
     def complete_shipping_method(self):
33 47
         self.client.get(reverse('checkout:shipping-method'))
34 48
 
35
-    def assertRedirectUrlName(self, response, name):
36
-        location = response['Location'].replace('http://testserver', '')
37
-        self.assertEqual(location, reverse(name))
49
+    def submit(self):
50
+        return self.client.post(reverse('checkout:preview'), {'action': 'place_order'})
38 51
 
39 52
 
40 53
 class DisabledAnonymousCheckoutViewsTests(ClientTestCase):
@@ -96,8 +109,13 @@ class EnabledAnonymousCheckoutViewsTests(ClientTestCase, CheckoutMixin):
96 109
             self.assertEqual('barry@example.com', order.guest_email)
97 110
 
98 111
 
99
-class ShippingAddressViewTests(ClientTestCase):
112
+class TestShippingAddressView(ClientTestCase, CheckoutMixin):
100 113
     fixtures = ['countries.json']
114
+
115
+    def test_pages_returns_200(self):
116
+        self.add_product_to_basket()
117
+        response = self.client.get(reverse('checkout:shipping-address'))
118
+        self.assertIsOk(response)
101 119
     
102 120
     def test_anon_checkout_disabled_by_default(self):
103 121
         self.assertFalse(settings.OSCAR_ALLOW_ANON_CHECKOUT)
@@ -115,49 +133,72 @@ class ShippingAddressViewTests(ClientTestCase):
115 133
         self.assertEqual('1 Egg Street', session_address['line1'])
116 134
         self.assertEqual('N1 9RT', session_address['postcode'])
117 135
 
136
+    def test_user_must_have_a_nonempty_basket(self):
137
+        response = self.client.get(reverse('checkout:shipping-address'))
138
+        self.assertRedirectUrlName(response, 'basket:summary')
118 139
 
119
-class ShippingMethodViewTests(ClientTestCase, CheckoutMixin):
140
+
141
+class TestShippingMethodView(ClientTestCase, CheckoutMixin):
120 142
     fixtures = ['countries.json']
121 143
 
122 144
     def test_shipping_method_view_redirects_if_no_shipping_address(self):
145
+        self.add_product_to_basket()
123 146
         response = self.client.get(reverse('checkout:shipping-method'))
124 147
         self.assertIsRedirect(response)
125 148
         self.assertRedirectUrlName(response, 'checkout:shipping-address')
126 149
 
127 150
     def test_redirects_by_default(self):
151
+        self.add_product_to_basket()
128 152
         self.complete_shipping_address()
129 153
         response = self.client.get(reverse('checkout:shipping-method'))
130 154
         self.assertRedirectUrlName(response, 'checkout:payment-method')
131 155
 
156
+    def test_user_must_have_a_nonempty_basket(self):
157
+        response = self.client.get(reverse('checkout:shipping-method'))
158
+        self.assertRedirectUrlName(response, 'basket:summary')
132 159
 
133
-class PaymentMethodViewTests(ClientTestCase, CheckoutMixin):
160
+
161
+class TestPaymentMethodView(ClientTestCase, CheckoutMixin):
134 162
 
135 163
     def test_view_redirects_if_no_shipping_address(self):
164
+        self.add_product_to_basket() 
136 165
         response = self.client.get(reverse('checkout:payment-method'))
137 166
         self.assertIsRedirect(response)
138 167
         self.assertRedirectUrlName(response, 'checkout:shipping-address')
139 168
 
140 169
     def test_view_redirects_if_no_shipping_method(self):
170
+        self.add_product_to_basket() 
141 171
         self.complete_shipping_address()
142 172
         response = self.client.get(reverse('checkout:payment-method'))
143 173
         self.assertIsRedirect(response)
144 174
         self.assertRedirectUrlName(response, 'checkout:shipping-method')
145 175
 
176
+    def test_user_must_have_a_nonempty_basket(self):
177
+        response = self.client.get(reverse('checkout:payment-method'))
178
+        self.assertRedirectUrlName(response, 'basket:summary')
179
+
146 180
 
147
-class PreviewViewTests(ClientTestCase, CheckoutMixin):
181
+class TestPreviewView(ClientTestCase, CheckoutMixin):
182
+
183
+    def test_user_must_have_a_nonempty_basket(self):
184
+        response = self.client.get(reverse('checkout:preview'))
185
+        self.assertRedirectUrlName(response, 'basket:summary')
148 186
 
149 187
     def test_view_redirects_if_no_shipping_address(self):
188
+        self.add_product_to_basket()
150 189
         response = self.client.get(reverse('checkout:preview'))
151 190
         self.assertIsRedirect(response)
152 191
         self.assertRedirectUrlName(response, 'checkout:shipping-address')
153 192
 
154 193
     def test_view_redirects_if_no_shipping_method(self):
194
+        self.add_product_to_basket()
155 195
         self.complete_shipping_address()
156 196
         response = self.client.get(reverse('checkout:preview'))
157 197
         self.assertIsRedirect(response)
158 198
         self.assertRedirectUrlName(response, 'checkout:shipping-method')
159 199
 
160 200
     def test_ok_response_if_previous_steps_complete(self):
201
+        self.add_product_to_basket()
161 202
         self.complete_shipping_address()
162 203
         self.complete_shipping_method()
163 204
         response = self.client.get(reverse('checkout:preview'))
@@ -166,23 +207,27 @@ class PreviewViewTests(ClientTestCase, CheckoutMixin):
166 207
 
167 208
 class PaymentDetailsViewTests(ClientTestCase, CheckoutMixin):
168 209
 
210
+    def test_user_must_have_a_nonempty_basket(self):
211
+        response = self.client.get(reverse('checkout:payment-details'))
212
+        self.assertRedirectUrlName(response, 'basket:summary')
213
+
169 214
     def test_view_redirects_if_no_shipping_address(self):
215
+        self.add_product_to_basket()
170 216
         response = self.client.post(reverse('checkout:payment-details'))
171 217
         self.assertIsRedirect(response)
172 218
         self.assertRedirectUrlName(response, 'checkout:shipping-address')
173 219
 
174 220
     def test_view_redirects_if_no_shipping_method(self):
221
+        self.add_product_to_basket()
175 222
         self.complete_shipping_address()
176 223
         response = self.client.post(reverse('checkout:payment-details'))
177 224
         self.assertIsRedirect(response)
178 225
         self.assertRedirectUrlName(response, 'checkout:shipping-method')
179 226
 
180 227
     def test_placing_order_with_empty_basket_redirects(self):
181
-        self.complete_shipping_address()
182
-        self.complete_shipping_method()
183 228
         response = self.client.post(reverse('checkout:preview'), {'action': 'place_order'})
184 229
         self.assertIsRedirect(response)
185
-        self.assertRedirectUrlName(response, 'checkout:shipping-address')
230
+        self.assertRedirectUrlName(response, 'basket:summary')
186 231
 
187 232
 
188 233
 class OrderPlacementTests(ClientTestCase, CheckoutMixin):
@@ -206,3 +251,21 @@ class OrderPlacementTests(ClientTestCase, CheckoutMixin):
206 251
         self.assertIsRedirect(self.response)
207 252
         orders = Order.objects.all()
208 253
         self.assertEqual(1, len(orders))
254
+        
255
+
256
+class TestAnonUserOrderPlacementScenarios(ClientTestCase, CheckoutMixin):
257
+
258
+    def test_basic_submission_gets_redirect_to_thankyou(self):
259
+        self.add_product_to_basket()
260
+        self.complete_shipping_address()
261
+        self.complete_shipping_method()
262
+        response = self.submit()
263
+        self.assertRedirectUrlName(response, 'checkout:thank-you')
264
+
265
+    def test_submission_using_voucher(self):
266
+        self.add_product_to_basket()
267
+        self.add_voucher_to_basket()
268
+        self.complete_shipping_address()
269
+        self.complete_shipping_method()
270
+        response = self.submit()
271
+        self.assertRedirectUrlName(response, 'checkout:thank-you')

oscar/apps/customer/tests.py → tests/functional/customer_tests.py Целия файл

@@ -1,12 +1,11 @@
1 1
 import httplib
2 2
 
3 3
 from django.test.client import Client
4
+from django.contrib.auth.models import User
5
+from django.core.urlresolvers import reverse
4 6
 from django.test import TestCase
5 7
 from django.http import HttpRequest
6
-from django.core.urlresolvers import reverse
7
-from django.contrib.auth.models import User
8 8
 
9
-from oscar.apps.customer.models import CommunicationEventType
10 9
 from oscar.apps.customer.history_helpers import get_recently_viewed_product_ids
11 10
 from oscar.test.helpers import create_product, create_order
12 11
 
@@ -28,22 +27,6 @@ class HistoryHelpersTest(TestCase):
28 27
         self.assertTrue(self.product.id in get_recently_viewed_product_ids(request))
29 28
 
30 29
 
31
-class CommunicationTypeTest(TestCase):
32
-    keys = ('body', 'html', 'sms', 'subject')
33
-
34
-    def test_no_templates_returns_empty_string(self):
35
-        et = CommunicationEventType()
36
-        messages = et.get_messages()
37
-        for key in self.keys:
38
-            self.assertEqual('', messages[key])
39
-
40
-    def test_field_template_render(self):
41
-        et = CommunicationEventType(email_subject_template='Hello {{ name }}')
42
-        ctx = {'name': 'world'}
43
-        messages = et.get_messages(ctx)
44
-        self.assertEqual('Hello world', messages['subject'])
45
-
46
-
47 30
 class AnonOrderDetail(TestCase):
48 31
 
49 32
     def setUp(self):

tests/apps/shipping/__init__.py → tests/functional/dashboard/__init__.py Целия файл


oscar/apps/dashboard/catalogue/tests.py → tests/functional/dashboard/catalogue_tests.py Целия файл


oscar/apps/dashboard/offers/tests.py → tests/functional/dashboard/offer_tests.py Целия файл


oscar/apps/dashboard/orders/tests.py → tests/functional/dashboard/order_tests.py Целия файл


oscar/apps/dashboard/pages/tests.py → tests/functional/dashboard/page_tests.py Целия файл


oscar/apps/dashboard/promotions/tests.py → tests/functional/dashboard/promotion_tests.py Целия файл


oscar/apps/dashboard/ranges/tests.py → tests/functional/dashboard/range_tests.py Целия файл


oscar/apps/dashboard/reports/tests.py → tests/functional/dashboard/reports_tests.py Целия файл


oscar/apps/dashboard/reviews/tests.py → tests/functional/dashboard/review_tests.py Целия файл


oscar/apps/dashboard/users/tests.py → tests/functional/dashboard/user_tests.py Целия файл


oscar/apps/dashboard/tests.py → tests/functional/dashboard_tests.py Целия файл

@@ -2,16 +2,6 @@ from decimal import Decimal as D
2 2
 
3 3
 from django.core.urlresolvers import reverse
4 4
 
5
-from oscar.apps.dashboard.orders.tests import *
6
-from oscar.apps.dashboard.reports.tests import *
7
-from oscar.apps.dashboard.users.tests import *
8
-from oscar.apps.dashboard.promotions.tests import *
9
-from oscar.apps.dashboard.catalogue.tests import *
10
-from oscar.apps.dashboard.pages.tests import *
11
-from oscar.apps.dashboard.offers.tests import *
12
-from oscar.apps.dashboard.ranges.tests import *
13
-from oscar.apps.dashboard.reviews.tests import *
14
-
15 5
 from oscar.apps.dashboard.views import IndexView
16 6
 from oscar.test import ClientTestCase
17 7
 from oscar.test.helpers import create_order

tests/shipping/__init__.py → tests/site/__init__.py Целия файл


tests/apps/shipping/models.py → tests/site/apps/__init__.py Целия файл


tests/shipping/models.py → tests/site/apps/shipping/__init__.py Целия файл


tests/apps/shipping/methods.py → tests/site/apps/shipping/methods.py Целия файл


+ 0
- 0
tests/site/apps/shipping/models.py Целия файл


+ 0
- 0
tests/site/shipping/__init__.py Целия файл


tests/shipping/methods.py → tests/site/shipping/methods.py Целия файл


+ 0
- 0
tests/site/shipping/models.py Целия файл


tests/templates/base.html → tests/site/templates/base.html Целия файл


tests/templates/layout.html → tests/site/templates/layout.html Целия файл


tests/urls.py → tests/site/urls.py Целия файл


+ 0
- 0
tests/unit/__init__.py Целия файл


oscar/apps/address/tests.py → tests/unit/address_tests.py Целия файл

@@ -8,17 +8,16 @@ from oscar.apps.order.models import ShippingAddress
8 8
 
9 9
 
10 10
 class UserAddressTest(TestCase):
11
-    fixtures = ['countries.json']
12 11
     
13 12
     def setUp(self):
14 13
         self.user = User.objects.create(username='dummy_user')
15
-        self.country = Country.objects.get(iso_3166_1_a2='GB')
14
+        self.country = Country(iso_3166_1_a2='GB', name="UNITED KINGDOM")
16 15
     
17 16
     def tearDown(self):
18 17
         self.user.delete()
19 18
     
20 19
     def test_titleless_salutation_is_stripped(self):
21
-        a = UserAddress.objects.create(last_name='Barrington', line1="75 Smith Road", postcode="N4 8TY", 
20
+        a = UserAddress(last_name='Barrington', line1="75 Smith Road", postcode="N4 8TY", 
22 21
                                        country=self.country, user=self.user)
23 22
         self.assertEquals("Barrington", a.salutation())
24 23
     
@@ -67,10 +66,9 @@ class UserAddressTest(TestCase):
67 66
     def test_hashing_with_utf8(self):
68 67
         a = UserAddress(first_name=u"\u0141ukasz Smith", last_name=u'Smith', line1=u"75 Smith Road", postcode=u"n4 8ty", 
69 68
                         country=self.country, user=self.user)
70
-        hash = a.active_address_fields()
69
+        a.active_address_fields()
71 70
         
72 71
     def test_city_is_alias_of_line4(self):
73
-        a = UserAddress.objects.create(last_name='Barrington', line1="75 Smith Road", line4="London", postcode="n4 8ty", 
72
+        a = UserAddress(last_name='Barrington', line1="75 Smith Road", line4="London", postcode="n4 8ty", 
74 73
                                        country=self.country, user=self.user)
75 74
         self.assertEqual('London', a.city)
76
-           

+ 54
- 0
tests/unit/basket_tests.py Целия файл

@@ -0,0 +1,54 @@
1
+import datetime
2
+
3
+from django.test import TestCase
4
+
5
+from oscar.apps.basket.models import Basket, Line
6
+from oscar.test.helpers import create_product
7
+from oscar.apps.basket.reports import (
8
+    OpenBasketReportGenerator, SubmittedBasketReportGenerator)
9
+
10
+
11
+class BasketModelTest(TestCase):
12
+
13
+    def setUp(self):
14
+        self.basket = Basket.objects.create()
15
+        self.dummy_product = create_product()
16
+
17
+    def test_empty_baskets_have_zero_lines(self):
18
+        self.assertTrue(Basket().num_lines == 0)
19
+
20
+    def test_new_baskets_are_empty(self):
21
+        self.assertTrue(Basket().is_empty)
22
+
23
+    def test_basket_have_with_one_line(self):
24
+        Line.objects.create(basket=self.basket, product=self.dummy_product)
25
+        self.assertTrue(self.basket.num_lines == 1)
26
+
27
+    def test_add_product_creates_line(self):
28
+        self.basket.add_product(self.dummy_product)
29
+        self.assertTrue(self.basket.num_lines == 1)
30
+
31
+    def test_adding_multiproduct_line_returns_correct_number_of_items(self):
32
+        self.basket.add_product(self.dummy_product, 10)
33
+        self.assertEqual(self.basket.num_items, 10)
34
+
35
+
36
+class BasketReportTests(TestCase):
37
+
38
+    def test_open_report_doesnt_error(self):
39
+        data = {
40
+            'start_date': datetime.date(2012, 5, 1),
41
+            'end_date': datetime.date(2012, 5, 17),
42
+            'formatter': 'CSV'
43
+        }
44
+        generator = OpenBasketReportGenerator(**data)
45
+        generator.generate()
46
+
47
+    def test_submitted_report_doesnt_error(self):
48
+        data = {
49
+            'start_date': datetime.date(2012, 5, 1),
50
+            'end_date': datetime.date(2012, 5, 17),
51
+            'formatter': 'CSV'
52
+        }
53
+        generator = SubmittedBasketReportGenerator(**data)
54
+        generator.generate()

oscar/apps/catalogue/tests.py → tests/unit/catalogue_tests.py Целия файл

@@ -1,26 +1,40 @@
1
-from django.test import TestCase, Client
1
+from django.test import TestCase
2 2
 from django.core.exceptions import ValidationError
3
-from django.core.urlresolvers import reverse
4
-from django.conf import settings
5 3
 
6 4
 from oscar.apps.catalogue.models import Product, ProductClass, Category, \
7 5
         ProductAttribute
8 6
 from oscar.apps.catalogue.categories import create_from_breadcrumbs
9 7
 
10 8
 
11
-class CategoryTests(TestCase):
9
+class TestProductClassModel(TestCase):
10
+
11
+    def test_slug_is_auto_created(self):
12
+        books = ProductClass.objects.create(
13
+            name="Book",
14
+        )
15
+        self.assertEqual('book', books.slug)
16
+
17
+    def test_has_attribute_for_whether_shipping_is_required(self):
18
+        ""
19
+        ProductClass.objects.create(
20
+            name="Download",
21
+            requires_shipping=False
22
+        )
23
+
24
+
25
+class TestCategoryFactory(TestCase):
12 26
 
13 27
     def setUp(self):
14 28
         Category.objects.all().delete()
15 29
     
16
-    def test_creating_category_root(self):
30
+    def test_can_create_single_level_category(self):
17 31
         trail = 'Books'
18 32
         category = create_from_breadcrumbs(trail)
19 33
         self.assertIsNotNone(category)
20 34
         self.assertEquals(category.name, 'Books')
21 35
         self.assertEquals(category.slug, 'books')      
22 36
     
23
-    def test_creating_parent_and_child_categories(self):
37
+    def test_can_create_parent_and_child_categories(self):
24 38
         trail = 'Books > Science-Fiction'
25 39
         category = create_from_breadcrumbs(trail)
26 40
         
@@ -31,20 +45,20 @@ class CategoryTests(TestCase):
31 45
         self.assertEquals(2, Category.objects.count())
32 46
         self.assertEquals(category.slug, 'books/science-fiction')
33 47
         
34
-    def test_creating_multiple_categories(self):
48
+    def test_can_create_multiple_categories(self):
35 49
         trail = 'Books > Science-Fiction > Star Trek'
36 50
         create_from_breadcrumbs(trail)
37 51
         trail = 'Books > Factual > Popular Science'
38
-        category = create_from_breadcrumbs(trail)        
52
+        category = create_from_breadcrumbs(trail)
39 53
         
40 54
         self.assertIsNotNone(category)
41 55
         self.assertEquals(category.name, 'Popular Science')
42 56
         self.assertEquals(category.get_depth(), 3)
43
-        self.assertEquals(category.get_parent().name, 'Factual')        
57
+        self.assertEquals(category.get_parent().name, 'Factual')
44 58
         self.assertEquals(5, Category.objects.count())
45 59
         self.assertEquals(category.slug, 'books/factual/popular-science', )        
46 60
 
47
-    def test_alternative_separator_can_be_used(self):
61
+    def test_can_use_alternative_separator(self):
48 62
         trail = 'Food|Cheese|Blue'
49 63
         create_from_breadcrumbs(trail, separator='|')
50 64
         self.assertEquals(3, len(Category.objects.all()))
@@ -141,11 +155,11 @@ class VariantProductTests(ProductTests):
141 155
         self.parent = Product.objects.create(title="Parent product", product_class=self.product_class)
142 156
     
143 157
     def test_variant_products_dont_need_titles(self):
144
-        p = Product.objects.create(parent=self.parent, product_class=self.product_class)
158
+        Product.objects.create(parent=self.parent, product_class=self.product_class)
145 159
         
146 160
     def test_variant_products_dont_need_a_product_class(self):
147
-        p = Product.objects.create(parent=self.parent)
148
-        
161
+        Product.objects.create(parent=self.parent)
162
+       
149 163
     def test_variant_products_inherit_parent_titles(self):
150 164
         p = Product.objects.create(parent=self.parent, product_class=self.product_class)
151 165
         self.assertEquals("Parent product", p.get_title())
@@ -153,18 +167,3 @@ class VariantProductTests(ProductTests):
153 167
     def test_variant_products_inherit_product_class(self):
154 168
         p = Product.objects.create(parent=self.parent)
155 169
         self.assertEquals("Clothing", p.get_product_class().name)
156
-
157
-
158
-class SingleProductViewTest(TestCase):
159
-    fixtures = ['sample-products']
160
-    
161
-    def setUp(self):
162
-        self.client = Client()
163
-        
164
-    def test_canonical_urls_are_enforced(self):
165
-        p = Product.objects.get(id=1)
166
-        args = {'product_slug': 'wrong-slug',
167
-                'pk': p.id}
168
-        wrong_url = reverse('catalogue:detail', kwargs=args)
169
-        response = self.client.get(wrong_url)
170
-        self.assertEquals(301, response.status_code)

+ 43
- 0
tests/unit/checkout_tests.py Целия файл

@@ -0,0 +1,43 @@
1
+from django.test import TestCase
2
+from django.test.client import RequestFactory
3
+from django.contrib.sessions.middleware import SessionMiddleware
4
+import mock
5
+
6
+from oscar.apps.checkout.utils import CheckoutSessionData
7
+
8
+
9
+class TestCheckoutSession(TestCase):
10
+    """
11
+    oscar.apps.checkout.utils.CheckoutSessionData
12
+    """
13
+
14
+    def setUp(self):
15
+        request = RequestFactory().get('/')
16
+        SessionMiddleware().process_request(request)
17
+        self.session_data = CheckoutSessionData(request)
18
+
19
+    def test_allows_data_to_be_written_and_read_out(self):
20
+        self.session_data._set('namespace', 'key', 'value')
21
+        self.assertEqual('value', self.session_data._get('namespace', 'key'))
22
+
23
+    def test_allows_set_data_can_be_unset(self):
24
+        self.session_data._set('namespace', 'key', 'value')
25
+        self.session_data._unset('namespace', 'key')
26
+        self.assertIsNone(self.session_data._get('namespace', 'key'))
27
+
28
+    def test_stores_guest_email(self):
29
+        self.session_data.set_guest_email('a@a.com')
30
+        self.assertEquals('a@a.com', self.session_data.get_guest_email())
31
+
32
+    def test_allows_a_namespace_to_be_flushed(self):
33
+        self.session_data._set('ns', 'a', 1)
34
+        self.session_data._set('ns', 'b', 2)
35
+        self.session_data._flush_namespace('ns')
36
+        self.assertIsNone(self.session_data._get('ns', 'a'))
37
+        self.assertIsNone(self.session_data._get('ns', 'b'))
38
+
39
+    def test_allows_bill_to_user_address(self):
40
+        address = mock.Mock()
41
+        address.id = 1
42
+        self.session_data.bill_to_user_address(address)
43
+        self.assertEqual(1, self.session_data.billing_user_address_id())

oscar/core/tests.py → tests/unit/core_tests.py Целия файл

@@ -4,6 +4,7 @@ from django.conf import settings
4 4
 
5 5
 from django.contrib.flatpages.models import FlatPage
6 6
 
7
+import oscar
7 8
 from oscar.core.loading import import_module, AppNotFoundError, \
8 9
         get_classes, get_class, ClassNotFoundError
9 10
 from oscar.core.validators import ExtendedURLValidator
@@ -11,36 +12,42 @@ from oscar.core.validators import URLDoesNotExistValidator
11 12
 from oscar.test import patch_settings
12 13
 
13 14
 
14
-class ImportAppTests(TestCase):
15
+class TestImportModule(TestCase):
16
+    """
17
+    oscar.core.loading.import_module
18
+    """
15 19
 
16
-    def test_a_specified_class_is_imported_correctly(self):
20
+    def test_imports_a_class_correctly(self):
17 21
         module = import_module('analytics.models', ['ProductRecord'])
18 22
         self.assertEqual('oscar.apps.analytics.models', module.__name__)
19 23
 
20
-    def test_unknown_apps_raise_exception(self):
24
+    def test_raises_exception_for_unknown_app(self):
21 25
         self.assertRaises(AppNotFoundError, import_module, 'banana', ['skin'])
22 26
 
23 27
 
24
-class ClassLoadingTests(TestCase):
28
+class TestClassLoading(TestCase):
29
+    """
30
+    Oscar's class loading utilities
31
+    """
25 32
 
26
-    def test_loading_oscar_classes(self):
33
+    def test_load_oscar_classes_correctly(self):
27 34
         Product, Category = get_classes('catalogue.models', ('Product', 'Category'))
28 35
         self.assertEqual('oscar.apps.catalogue.models', Product.__module__)
29 36
         self.assertEqual('oscar.apps.catalogue.models', Category.__module__)
30 37
 
31
-    def test_loading_oscar_class(self):
38
+    def test_load_oscar_class_correctly(self):
32 39
         Product = get_class('catalogue.models', 'Product')
33 40
         self.assertEqual('oscar.apps.catalogue.models', Product.__module__)
34 41
 
35
-    def test_loading_oscar_class_from_dashboard_subapp(self):
42
+    def test_load_oscar_class_from_dashboard_subapp(self):
36 43
         ReportForm = get_class('dashboard.reports.forms', 'ReportForm')
37 44
         self.assertEqual('oscar.apps.dashboard.reports.forms', ReportForm.__module__)
38 45
 
39
-    def test_bad_appname_raises_exception(self):
46
+    def test_raise_exception_when_bad_appname_used(self):
40 47
         with self.assertRaises(AppNotFoundError):
41 48
             get_classes('fridge.models', ('Product', 'Category'))
42 49
 
43
-    def test_bad_classname_raises_exception(self):
50
+    def test_raise_exception_when_bad_classname_used(self):
44 51
         with self.assertRaises(ClassNotFoundError):
45 52
             get_class('catalogue.models', 'Monkey')
46 53
 
@@ -49,12 +56,12 @@ class ClassLoadingWithLocalOverrideTests(TestCase):
49 56
 
50 57
     def setUp(self):
51 58
         self.installed_apps = list(settings.INSTALLED_APPS)
52
-        self.installed_apps[self.installed_apps.index('oscar.apps.shipping')] = 'tests.shipping'
59
+        self.installed_apps[self.installed_apps.index('oscar.apps.shipping')] = 'tests.site.shipping'
53 60
 
54 61
     def test_loading_class_defined_in_local_module(self):
55 62
         with patch_settings(INSTALLED_APPS=self.installed_apps):
56 63
             (Free,) = get_classes('shipping.methods', ('Free',))
57
-            self.assertEqual('tests.shipping.methods', Free.__module__)
64
+            self.assertEqual('tests.site.shipping.methods', Free.__module__)
58 65
 
59 66
     def test_loading_class_which_is_not_defined_in_local_module(self):
60 67
         with patch_settings(INSTALLED_APPS=self.installed_apps):
@@ -69,13 +76,16 @@ class ClassLoadingWithLocalOverrideTests(TestCase):
69 76
     def test_loading_classes_defined_in_both_local_and_oscar_modules(self):
70 77
         with patch_settings(INSTALLED_APPS=self.installed_apps):
71 78
             (Free, FixedPrice) = get_classes('shipping.methods', ('Free', 'FixedPrice'))
72
-            self.assertEqual('tests.shipping.methods', Free.__module__)
79
+            self.assertEqual('tests.site.shipping.methods', Free.__module__)
73 80
             self.assertEqual('oscar.apps.shipping.methods', FixedPrice.__module__)
74 81
 
75 82
 
76
-class ValidatorTests(TestCase):
83
+class TestExtendedURLValidator(TestCase):
84
+    """
85
+    ExtendedURLValidator
86
+    """
77 87
 
78
-    def test_validate_local_url(self):
88
+    def test_validates_local_url(self):
79 89
         v = ExtendedURLValidator(verify_exists=True)
80 90
 
81 91
         try:
@@ -112,7 +122,7 @@ class ValidatorTests(TestCase):
112 122
             self.fail('ExtendedURLValidator raises ValidationError'
113 123
                       'unexpectedly!')
114 124
 
115
-    def test_validate_url_does_not_exist(self):
125
+    def test_raises_exception_for_missing_url(self):
116 126
         validator = URLDoesNotExistValidator()
117 127
         self.assertRaises(ValidationError, validator, '/')
118 128
         try:
@@ -125,13 +135,35 @@ class ValidatorTests(TestCase):
125 135
         self.assertRaises(ValidationError, validator, '/test/page/')
126 136
 
127 137
 
128
-class ClassLoadingWithLocalOverrideWith3SegmentsTests(TestCase):
138
+class ClassLoadingWithLocalOverrideWithMultipleSegmentsTests(TestCase):
129 139
 
130 140
     def setUp(self):
131 141
         self.installed_apps = list(settings.INSTALLED_APPS)
132
-        self.installed_apps[self.installed_apps.index('oscar.apps.shipping')] = 'tests.apps.shipping'
142
+        self.installed_apps[self.installed_apps.index('oscar.apps.shipping')] = 'tests.site.apps.shipping'
133 143
 
134 144
     def test_loading_class_defined_in_local_module(self):
135 145
         with patch_settings(INSTALLED_APPS=self.installed_apps):
136 146
             (Free,) = get_classes('shipping.methods', ('Free',))
137
-            self.assertEqual('tests.apps.shipping.methods', Free.__module__)
147
+            self.assertEqual('tests.site.apps.shipping.methods', Free.__module__)
148
+
149
+
150
+class TestGetCoreAppsFunction(TestCase):
151
+    """
152
+    oscar.get_core_apps function
153
+    """
154
+
155
+    def test_returns_core_apps_when_no_overrides_specified(self):
156
+        apps = oscar.get_core_apps()
157
+        self.assertEqual(oscar.OSCAR_CORE_APPS, apps)
158
+
159
+    def test_uses_non_dashboard_override_when_specified(self):
160
+        apps = oscar.get_core_apps(overrides=['apps.shipping'])
161
+        self.assertTrue('apps.shipping' in apps)
162
+        self.assertTrue('oscar.apps.shipping' not in apps)
163
+
164
+    def test_uses_dashboard_override_when_specified(self):
165
+        apps = oscar.get_core_apps(overrides=['apps.dashboard.catalogue'])
166
+        self.assertTrue('apps.dashboard.catalogue' in apps)
167
+        self.assertTrue('oscar.apps.dashboard.catalogue' not in apps)
168
+        self.assertTrue('oscar.apps.catalogue' in apps)
169
+

+ 19
- 0
tests/unit/customer_tests.py Целия файл

@@ -0,0 +1,19 @@
1
+from django.test import TestCase
2
+
3
+from oscar.apps.customer.models import CommunicationEventType
4
+
5
+
6
+class CommunicationTypeTest(TestCase):
7
+    keys = ('body', 'html', 'sms', 'subject')
8
+
9
+    def test_no_templates_returns_empty_string(self):
10
+        et = CommunicationEventType()
11
+        messages = et.get_messages()
12
+        for key in self.keys:
13
+            self.assertEqual('', messages[key])
14
+
15
+    def test_field_template_render(self):
16
+        et = CommunicationEventType(email_subject_template='Hello {{ name }}')
17
+        ctx = {'name': 'world'}
18
+        messages = et.get_messages(ctx)
19
+        self.assertEqual('Hello world', messages['subject'])

oscar/core/logging/tests.py → tests/unit/logging_tests.py Целия файл


oscar/apps/offer/tests.py → tests/unit/offer_tests.py Целия файл


oscar/apps/order/tests.py → tests/unit/order_tests.py Целия файл

@@ -10,12 +10,11 @@ from oscar.apps.address.models import Country
10 10
 from oscar.apps.basket.models import Basket
11 11
 from oscar.apps.order.models import ShippingAddress, Order, Line, \
12 12
         ShippingEvent, ShippingEventType, ShippingEventQuantity, OrderNote, \
13
-        ShippingEventType, OrderDiscount
13
+        OrderDiscount
14 14
 from oscar.apps.order.exceptions import (InvalidOrderStatus, InvalidLineStatus,
15 15
                                          InvalidShippingEvent)
16 16
 from oscar.test.helpers import create_order, create_product, create_offer
17 17
 from oscar.apps.order.utils import OrderCreator
18
-from oscar.apps.shipping.methods import Free
19 18
 from oscar.apps.order.processing import EventHandler
20 19
 from oscar.test import patch_settings
21 20
 

+ 0
- 0
tests/unit/partner/__init__.py Целия файл


oscar/apps/partner/tests/fixtures/books-small-semicolon.csv → tests/unit/partner/fixtures/books-small-semicolon.csv Целия файл


oscar/apps/partner/tests/fixtures/books-small.csv → tests/unit/partner/fixtures/books-small.csv Целия файл


oscar/apps/partner/tests/imports.py → tests/unit/partner/import_tests.py Целия файл


oscar/apps/partner/tests/models.py → tests/unit/partner/model_tests.py Целия файл

@@ -58,6 +58,9 @@ class StockRecordTests(TestCase):
58 58
         self.assertEqual(0, self.stockrecord.num_allocated)
59 59
         self.assertEqual(10, self.stockrecord.num_in_stock)
60 60
 
61
+    def test_max_purchase_quantity(self):
62
+        self.assertEqual(10, self.stockrecord.max_purchase_quantity())
63
+
61 64
 
62 65
 class DefaultWrapperTests(TestCase):
63 66
 

oscar/apps/partner/tests/checkout.py → tests/unit/partner/stock_tests.py Целия файл


oscar/apps/partner/tests/wrappers.py → tests/unit/partner/wrapper_tests.py Целия файл


oscar/apps/payment/tests/main.py → tests/unit/payment_tests.py Целия файл


+ 0
- 0
tests/unit/promotion_tests.py Целия файл


Някои файлове не бяха показани, защото твърде много файлове са промени

Loading…
Отказ
Запис