Browse Source

Merge branch 'master' of https://github.com/tangentlabs/django-oscar

Conflicts:
	oscar/product/signals.py
	oscar/templates/product/item.html
	oscar/tests.py
master
Geekfish 14 years ago
parent
commit
e45dbf20f2
65 changed files with 1024 additions and 275 deletions
  1. 1
    0
      .gitignore
  2. 0
    66
      FEATURES.rst
  3. 4
    3
      README.rst
  4. 1
    0
      docs/source/components.rst
  5. 15
    0
      docs/source/components/promotions.rst
  6. 3
    9
      docs/source/contributing/installation.rst
  7. 2
    2
      docs/source/getting_started/installation.rst
  8. 1
    1
      docs/source/index.rst
  9. BIN
      examples/defaultshop/assets/images/promotions/banners/pod.jpg
  10. BIN
      examples/defaultshop/assets/images/promotions/banners/pod_1.jpg
  11. 6
    4
      examples/defaultshop/settings.py
  12. 1
    1
      examples/recreate_project_tables.sh
  13. 1
    1
      examples/run_tests.sh
  14. 5
    1
      oscar/address/abstract_models.py
  15. 37
    0
      oscar/analytics/abstract_models.py
  16. 19
    0
      oscar/analytics/admin.py
  17. 21
    2
      oscar/analytics/models.py
  18. 66
    0
      oscar/analytics/reports.py
  19. 11
    0
      oscar/basket/abstract_models.py
  20. 2
    1
      oscar/basket/models.py
  21. 58
    0
      oscar/basket/reports.py
  22. 0
    0
      oscar/discount/__init__.py
  23. 82
    0
      oscar/discount/abstract_models.py
  24. 10
    0
      oscar/discount/admin.py
  25. 6
    0
      oscar/discount/models.py
  26. 53
    0
      oscar/discount/tests.py
  27. 0
    80
      oscar/marketing/abstract_models.py
  28. 0
    13
      oscar/marketing/admin.py
  29. 0
    34
      oscar/marketing/context_processors.py
  30. 0
    10
      oscar/marketing/models.py
  31. 10
    1
      oscar/order/abstract_models.py
  32. 30
    0
      oscar/order/reports.py
  33. 1
    0
      oscar/order/utils.py
  34. 38
    8
      oscar/product/abstract_models.py
  35. 10
    3
      oscar/product/admin.py
  36. 2
    1
      oscar/product/signals.py
  37. 4
    1
      oscar/product/views.py
  38. 0
    0
      oscar/promotions/__init__.py
  39. 129
    0
      oscar/promotions/abstract_models.py
  40. 19
    0
      oscar/promotions/admin.py
  41. 46
    0
      oscar/promotions/context_processors.py
  42. 16
    0
      oscar/promotions/models.py
  43. 22
    0
      oscar/promotions/tests.py
  44. 6
    0
      oscar/promotions/urls.py
  45. 24
    0
      oscar/promotions/views.py
  46. 0
    0
      oscar/reports/__init__.py
  47. 23
    0
      oscar/reports/forms.py
  48. 30
    0
      oscar/reports/reports.py
  49. 5
    0
      oscar/reports/urls.py
  50. 24
    0
      oscar/reports/utils.py
  51. 33
    0
      oscar/reports/views.py
  52. 6
    0
      oscar/shipping/abstract_models.py
  53. 37
    0
      oscar/shipping/tests.py
  54. 28
    5
      oscar/stock/abstract_models.py
  55. 2
    1
      oscar/stock/admin.py
  56. 1
    1
      oscar/templates/basket/summary.html
  57. 4
    4
      oscar/templates/checkout/thank_you.html
  58. 5
    18
      oscar/templates/layout.html
  59. 14
    0
      oscar/templates/layout_admin.html
  60. 22
    1
      oscar/templates/product/item.html
  61. 19
    0
      oscar/templates/reports/dashboard.html
  62. 4
    1
      oscar/templatetags/currency_filters.py
  63. 1
    0
      oscar/tests.py
  64. 2
    0
      oscar/urls.py
  65. 2
    2
      setup.py

+ 1
- 0
.gitignore View File

@@ -12,3 +12,4 @@ docs/build/*
12 12
 *.*~
13 13
 examples/.coverage
14 14
 assets/*
15
+examples/defaultshop/assets/*

+ 0
- 66
FEATURES.rst View File

@@ -1,33 +1,3 @@
1
-========
2
-Features
3
-========
4
-
5
-Below is a list of required features for oscar, together with a brief spec for
6
-what they should implement.  If you're looking for something to do, please have a
7
-go at one of the below.
8
-
9
-
10
-Reviews and voting
11
-------------------
12
-
13
-Create a new ``oscar.reviews`` app which provides reviews and voting functionality. 
14
-
15
-* Only signed in users can review
16
-* Each product can have reviews attached to it.  Each review has a title, a body and a score from 1-5.
17
-* Reviews must be approved before they are live
18
-* The product page should have a review form on it, any errors in the submission will be shown on the same product page
19
-* The product page will show the most recent 5 reviews with a link to browse all reviews for that product.
20
-* The URL for browsing a products offers should be the normal product URL with /reviews appended at the end
21
-* The product page should show the average score based on the reviews 
22
-* The review browsing page should allow reviews to be sorted by score, or recency.
23
-* Each review should have a permalink, ie it has its own page.
24
-* Each reviews can be voted up or down by other users
25
-* Only signed in users can vote
26
-* A user can only vote once on each product once
27
-
28
-It might be possible to use the Django comments framework for this.
29
-
30
-
31 1
 Countdowns
32 2
 ----------
33 3
 
@@ -53,43 +23,7 @@ and control the order of them.
53 23
 * The order of the products within the block should be controllable
54 24
 
55 25
 
56
-Wishlists
57
----------
58
-
59
-Create a new ``oscar.wishlist`` app similar to the basket app. 
60
-
61
-* A wishlist is simply a list of products
62
-* Wishlist products do not have quantities
63
-* Only signed in users can have wishlists
64
-* A user can only have one wishlist (for now)
65
-* A wishlist can either be public or private
66
-* A user can add a product to their wishlist from a product page - after adding they are redirected
67
-  to their wishlist management page but with a link back to the page they just came from.
68
-* The wishlist management should have URL /wishlist
69
-* On the wishlist management page:
70
-  - you can add the item to your normal basket or remove it from your wishlist 
71
-  - you can make your list public or private
72
-  - you can empty your whole wishlist
73
-  
74
-The view functions for this app should be v similar to the basket ones.  Also have a look at Amazons for
75
-guidance.
76
-
77
-
78
-CurrencyField
79
--------------
80
-
81
-Lots of models have a currency field but the field attributes are duplicated each time.  This could be
82
-simplified if we had a currency field class.  
83
-
84
-* This should set the correct decimal settings and support a default value
85
-
86
-
87
-Extended URL field
88
-------------------
89 26
 
90
-Pods, banners and merchandising blocks need to link to either an external URL or an internal
91
-one (eg /fiction-books/).  The current URLField only supports external ones.  Write a new type 
92
-of field class that allows internal URLs too. 
93 27
 
94 28
 
95 29
   

README.md → README.rst View File

@@ -1,4 +1,5 @@
1
-# Django-Oscar - Flexible e-commerce on Django
1
+Django-Oscar - Flexible e-commerce on Django
2
+============================================
2 3
 
3 4
 django-oscar is a flexible ecommerce platform, designed to build domain-driven
4 5
 ecommerce sites to be constructed.  It is not supposed to be a framework that can
@@ -10,5 +11,5 @@ However, a small amount of work up front in determine the right models for your
10 11
 shop can really pay off in terms of building a high-quality application that
11 12
 is a pleasure to work with and maintain.
12 13
 
13
-See the *.rst files within `docs/source` for documentation or visit
14
-[http://readthedocs.org/docs/django-oscar/en/](http://readthedocs.org/docs/django-oscar/en/)
14
+See the ``*.rst`` files within ``docs/source`` for documentation or visit
15
+http://django-oscar.readthedocs.org/en/latest/

+ 1
- 0
docs/source/components.rst View File

@@ -12,3 +12,4 @@ Contents:
12 12
    components/checkout
13 13
    components/shipping
14 14
    components/analytics
15
+   components/promotions

+ 15
- 0
docs/source/components/promotions.rst View File

@@ -0,0 +1,15 @@
1
+==========
2
+Promotions
3
+==========
4
+
5
+Promotions are small blocks of content that can link through to other parts of this site.  
6
+Examples include:
7
+
8
+* A banner image shown on at the top of the homepage that links through to a new offer page
9
+* A "pod" image shown in the right-hand sidebar of a page, linking through to newly merchandised
10
+  page.
11
+* A biography of an author (featuring an image and a block of HTML) shown at the top of the search
12
+  results page when the search query includes the author's surname.
13
+
14
+These are modelled using a base "promotion" model, which contains the image fieids and the link
15
+destination, and two "linking" models which link promotions to either a page URL, or a particular keyword.

+ 3
- 9
docs/source/contributing/installation.rst View File

@@ -16,7 +16,8 @@ Reload bash with the following command::
16 16
 
17 17
     ~/.bashrc
18 18
 
19
-Do the following from your workspace folder:
19
+Do the following from your workspace folder::
20
+
20 21
     mkdir oscar
21 22
     cd oscar
22 23
     mkvirtualenv --no-site-packages oscar
@@ -24,10 +25,7 @@ Do the following from your workspace folder:
24 25
     
25 26
 After checking out your fork, install the latest version of Django into your virtualenv (currenty a beta of 1.3)::
26 27
 
27
-    wget http://www.djangoproject.com/download/1.3-beta-1/tarball/
28
-    pip install Django-1.3-beta-1.tar.gz
29
-
30
-Clone this repository to get the latest version of Oscar
28
+    pip install django
31 29
 
32 30
 Install all packages from the requirements file (optional)::
33 31
 
@@ -40,10 +38,6 @@ Install oscar in development mode within your virtual env::
40 38
 
41 39
     python setup.py develop
42 40
 
43
-Optionally, install all packages from the requirements file::
44
-
45
-    pip install -r requirements.txt
46
-
47 41
 Note: In case of gcc crashing and complaining in-between installation process,
48 42
 make sure you have appropriate -devel packages installed (ie. mysql-devel) in
49 43
 your system.

+ 2
- 2
docs/source/getting_started/installation.rst View File

@@ -19,7 +19,7 @@ Install oscar and its dependencies::
19 19
     
20 20
     pip install -e git+git://github.com/codeinthehole/django-oscar.git#egg=django-oscar
21 21
     
22
-This will install Django and a few other packages.  Now create the project    
22
+This will install Django and a few other packages.  Now create the project::
23 23
     
24 24
     cd /path/to/my/workspace
25 25
     django-admin.py startproject $PROJECTNAME
@@ -69,7 +69,7 @@ Configure urls
69 69
 
70 70
 Oscar comes with a number of urls and views out of the box.  These are
71 71
 recommendations rather than a requirement but you easily use them in your
72
-e-commerce site by adding the oscar urls to your projects local ``urls.py``
72
+e-commerce site by adding the oscar urls to your projects local ``urls.py``::
73 73
 
74 74
     (r'^', include('oscar.urls')),
75 75
 

+ 1
- 1
docs/source/index.rst View File

@@ -16,7 +16,7 @@ a much better representation of the domain at hand.
16 16
 It is developed by Tangent Labs, a London-based digital agency, and is based on
17 17
 their existing Taoshop PHP platform which currently powers several large-scale ecommerce sites.  
18 18
 
19
-It is still in early development, but a stable release is planned for April 2011.
19
+It is still in early development, but a stable release is planned for early summer 2011.
20 20
 
21 21
 The source is on Github: https://github.com/codeinthehole/django-oscar
22 22
 

BIN
examples/defaultshop/assets/images/promotions/banners/pod.jpg View File


BIN
examples/defaultshop/assets/images/promotions/banners/pod_1.jpg View File


+ 6
- 4
examples/defaultshop/settings.py View File

@@ -6,8 +6,8 @@ PROJECT_DIR = os.path.dirname(__file__)
6 6
 location = lambda x: os.path.join(os.path.dirname(os.path.realpath(__file__)), x)
7 7
 
8 8
 DEBUG = True
9
-TEMPLATE_DEBUG = DEBUG
10
-SQL_DEBUG = DEBUG
9
+TEMPLATE_DEBUG = True
10
+SQL_DEBUG = True
11 11
 
12 12
 ADMINS = (
13 13
     # ('Your Name', 'your_email@domain.com'),
@@ -82,7 +82,7 @@ TEMPLATE_CONTEXT_PROCESSORS = (
82 82
     "django.core.context_processors.static",
83 83
     "django.contrib.messages.context_processors.messages",
84 84
     # Oscar specific
85
-    'oscar.marketing.context_processors.marketing',
85
+    'oscar.promotions.context_processors.promotions',
86 86
 ) 
87 87
 
88 88
 MIDDLEWARE_CLASSES = (
@@ -144,6 +144,7 @@ INSTALLED_APPS = (
144 144
     # Apps from oscar
145 145
     'oscar',
146 146
     'oscar.analytics',
147
+    'oscar.discount',
147 148
     'oscar.order',
148 149
     'oscar.checkout',
149 150
     'oscar.shipping',
@@ -156,7 +157,8 @@ INSTALLED_APPS = (
156 157
     'oscar.stock',
157 158
     'oscar.image',
158 159
     'oscar.customer',
159
-    'oscar.marketing',
160
+    'oscar.promotions',
161
+    'oscar.reports',
160 162
 )
161 163
 
162 164
 LOGIN_REDIRECT_URL = '/shop/accounts/profile/'

+ 1
- 1
examples/recreate_project_tables.sh View File

@@ -14,7 +14,7 @@ then
14 14
 fi
15 15
 echo "Recreating all tables in $PROJECT_FOLDER"
16 16
 echo "Dropping tables"
17
-$MANAGE_COMMAND sqlclear marketing analytics payment offer shipping order basket stock image address product | \
17
+$MANAGE_COMMAND sqlclear promotions analytics payment offer shipping order basket stock image address product | \
18 18
 	awk 'BEGIN {print "set foreign_key_checks=0;"} {print $0}' | \
19 19
     $MANAGE_COMMAND dbshell && \
20 20
     $MANAGE_COMMAND syncdb

+ 1
- 1
examples/run_tests.sh View File

@@ -13,6 +13,6 @@ then
13 13
 	printf "$MANAGE_COMMAND cannot be found\n"
14 14
 fi
15 15
 echo "Running all tests in $PROJECT_FOLDER"
16
-time coverage run $MANAGE_COMMAND test oscar --settings=test_settings -v 1 --failfast | \
16
+time $MANAGE_COMMAND test oscar --settings=test_settings -v 1 --failfast | \
17 17
 	grep -v "^\(Installing\|Creating\)"
18 18
 

+ 5
- 1
oscar/address/abstract_models.py View File

@@ -121,7 +121,7 @@ class AbstractShippingAddress(AbstractAddress):
121 121
     it should be read-only after that. 
122 122
     """
123 123
     phone_number = models.CharField(max_length=32, blank=True, null=True)
124
-    notes = models.TextField(blank=True, null=True) 
124
+    notes = models.TextField(blank=True, null=True, help_text="""Shipping notes""")
125 125
     
126 126
     class Meta:
127 127
         abstract = True
@@ -140,10 +140,14 @@ class AbstractUserAddress(AbstractShippingAddress):
140 140
     """
141 141
     user = models.ForeignKey('auth.User', related_name='addresses')
142 142
     
143
+    # Customers can set which is their default billing address
144
+    default_for_billing = models.BooleanField(default=False)
145
+    
143 146
     # We keep track of the number of times an address has been used
144 147
     # as a shipping address so we can show the most popular ones 
145 148
     # first at the checkout.
146 149
     num_orders = models.PositiveIntegerField(default=0)
150
+    
147 151
     # A hash is kept to try and avoid duplicate addresses being added
148 152
     # to the address book.
149 153
     hash = models.CharField(max_length=255, db_index=True)

+ 37
- 0
oscar/analytics/abstract_models.py View File

@@ -14,14 +14,22 @@ class AbstractProductRecord(models.Model):
14 14
     """
15 15
     
16 16
     product = models.OneToOneField('product.Item')
17
+    
18
+    # Data used for generating a score
17 19
     num_views = models.PositiveIntegerField(default=0)
18 20
     num_basket_additions = models.PositiveIntegerField(default=0)
19 21
     num_purchases = models.PositiveIntegerField(default=0, db_index=True)
20 22
     
23
+    # Product score - used within search
24
+    score = models.DecimalField(decimal_places=2, max_digits=12, default=Decimal('0.00'))
25
+    
21 26
     class Meta:
22 27
         abstract = True
23 28
         ordering = ['-num_purchases']
24 29
         
30
+    def __unicode__(self):
31
+        return u"Record for '%s'" % self.product
32
+        
25 33
 
26 34
 class AbstractUserRecord(models.Model):
27 35
     u"""
@@ -44,4 +52,33 @@ class AbstractUserRecord(models.Model):
44 52
     class Meta:
45 53
         abstract = True
46 54
         
55
+        
56
+class AbstractUserProductView(models.Model):
57
+    
58
+     user = models.ForeignKey('auth.User')
59
+     product = models.ForeignKey('product.Item')
60
+     date_created = models.DateTimeField(auto_now_add=True)
61
+     
62
+     class Meta:
63
+         abstract = True
64
+         
65
+     def __unicode__(self):
66
+         return u"%s viewed '%s'" % (self.user, self.product)
67
+             
68
+
69
+class AbstractUserSearch(models.Model):
70
+    
71
+     user = models.ForeignKey('auth.User')
72
+     query = models.CharField(_("Search term"), max_length=255, db_index=True)
73
+     date_created = models.DateTimeField(auto_now_add=True)
74
+     
75
+     class Meta:
76
+         abstract = True
77
+         verbose_name_plural = "User search queries"
78
+         
79
+     def __unicode__(self):
80
+         return u"%s searched for '%s'" % (self.user, self.query)
81
+     
82
+         
83
+
47 84
     

+ 19
- 0
oscar/analytics/admin.py View File

@@ -0,0 +1,19 @@
1
+from django.contrib import admin
2
+
3
+from oscar.services import import_module
4
+models = import_module('analytics.models', ['ProductRecord', 'UserRecord', 'UserSearch', 
5
+                                            'UserProductView'])
6
+
7
+class ProductRecordAdmin(admin.ModelAdmin):
8
+    list_display = ('product', 'num_views', 'num_basket_additions', 'num_purchases')
9
+    
10
+class UserProductViewAdmin(admin.ModelAdmin):
11
+    list_display = ('user', 'product', 'date_created')
12
+
13
+class UserRecordAdmin(admin.ModelAdmin):
14
+    list_display = ('user', 'num_product_views', 'num_basket_additions', 'num_orders', 'total_spent', 'date_last_order')
15
+
16
+admin.site.register(models.ProductRecord, ProductRecordAdmin)
17
+admin.site.register(models.UserRecord, UserRecordAdmin)
18
+admin.site.register(models.UserSearch)
19
+admin.site.register(models.UserProductView, UserProductViewAdmin)

+ 21
- 2
oscar/analytics/models.py View File

@@ -1,9 +1,10 @@
1 1
 from django.dispatch import receiver
2 2
 
3
-from oscar.analytics.abstract_models import AbstractProductRecord, AbstractUserRecord
3
+from oscar.analytics.abstract_models import (AbstractProductRecord, AbstractUserRecord,
4
+                                             AbstractUserProductView, AbstractUserSearch)
4 5
 from oscar.services import import_module
5 6
 
6
-product_signals = import_module('product.signals', ['product_viewed'])
7
+product_signals = import_module('product.signals', ['product_viewed', 'product_search'])
7 8
 basket_signals = import_module('basket.signals', ['basket_addition'])
8 9
 order_signals = import_module('order.signals', ['order_placed'])
9 10
 
@@ -13,6 +14,12 @@ class ProductRecord(AbstractProductRecord):
13 14
 class UserRecord(AbstractUserRecord):
14 15
     pass
15 16
 
17
+class UserProductView(AbstractUserProductView):
18
+    pass
19
+
20
+class UserSearch(AbstractUserSearch):
21
+    pass
22
+
16 23
 # Helpers
17 24
 
18 25
 def _record_product_view(product):
@@ -22,10 +29,14 @@ def _record_product_view(product):
22 29
     
23 30
 def _record_user_product_view(user, product):
24 31
     if user.is_authenticated():
32
+        # Update user record
25 33
         record,_ = UserRecord.objects.get_or_create(user=user)
26 34
         record.num_product_views += 1
27 35
         record.save()
28 36
         
37
+        # Add user product view record
38
+        UserProductView.objects.create(product=product, user=user)
39
+        
29 40
 def _record_basket_addition(product):
30 41
     record,_ = ProductRecord.objects.get_or_create(product=product)
31 42
     record.num_basket_additions += 1
@@ -53,6 +64,10 @@ def _record_user_order(user, order):
53 64
         record.date_last_order = order.date_placed
54 65
         record.save()
55 66
 
67
+def _record_user_product_search(user, query):
68
+    if user.is_authenticated():
69
+        UserSearch._default_manager.create(user=user, query=query)
70
+
56 71
 # Receivers
57 72
 
58 73
 @receiver(product_signals.product_viewed)
@@ -60,6 +75,10 @@ def receive_product_view(sender, product, user, **kwargs):
60 75
     _record_product_view(product)
61 76
     _record_user_product_view(user, product)
62 77
     
78
+@receiver(product_signals.product_search)
79
+def receive_product_search(sender, query, user, **kwargs):
80
+    _record_user_product_search(user, query)
81
+    
63 82
 @receiver(basket_signals.basket_addition)
64 83
 def receive_basket_addition(sender, product, user, **kwargs):
65 84
     _record_basket_addition(product)

+ 66
- 0
oscar/analytics/reports.py View File

@@ -0,0 +1,66 @@
1
+import csv
2
+
3
+from oscar.services import import_module
4
+report_classes = import_module('reports.reports', ['ReportGenerator'])
5
+analytics_models = import_module('analytics.models', ['ProductRecord', 'UserRecord'])
6
+
7
+
8
+class ProductReportGenerator(report_classes.ReportGenerator):
9
+    
10
+    filename_template = 'product-analytics.csv'
11
+    code = 'product_analytics'
12
+    description = 'Product analytics'
13
+    
14
+    def generate(self, response):
15
+        writer = csv.writer(response)
16
+        header_row = ['Product',
17
+                      'Views',
18
+                      'Basket additions',
19
+                      'Purchases',]
20
+        writer.writerow(header_row)
21
+        
22
+        records = analytics_models.ProductRecord._default_manager.all()
23
+        for record in records:
24
+            row = [record.product, record.num_views, record.num_basket_additions, record.num_purchases]
25
+            writer.writerow(row)
26
+            
27
+    def is_available_to(self, user):
28
+        return user.is_staff
29
+    
30
+    def filename(self):
31
+        return self.filename_template
32
+    
33
+
34
+class UserReportGenerator(report_classes.ReportGenerator):
35
+    
36
+    filename_template = 'user-analytics.csv'
37
+    code = 'user_analytics'
38
+    description = 'User analytics'
39
+    
40
+    def generate(self, response):
41
+        writer = csv.writer(response)
42
+        header_row = ['Username',
43
+                      'Name',
44
+                      'Date registered',
45
+                      'Product views',
46
+                      'Basket additions',
47
+                      'Orders',
48
+                      'Order lines',
49
+                      'Order items',
50
+                      'Total spent',
51
+                      'Date of last order',
52
+                      ]
53
+        writer.writerow(header_row)
54
+        
55
+        records = analytics_models.UserRecord._default_manager.select_related().all()
56
+        for record in records:
57
+            row = [record.user.username, record.user.get_full_name(), record.user.date_joined, 
58
+                   record.num_product_views, record.num_basket_additions, record.num_orders,
59
+                   record.num_order_lines, record.num_order_items, record.total_spent, record.date_last_order]
60
+            writer.writerow(row)
61
+            
62
+    def is_available_to(self, user):
63
+        return user.is_staff
64
+    
65
+    def filename(self):
66
+        return self.filename_template

+ 11
- 0
oscar/basket/abstract_models.py View File

@@ -170,6 +170,17 @@ class AbstractBasket(models.Model):
170 170
         u"""Return number of items"""
171 171
         return reduce(lambda num,line: num+line.quantity, self.all_lines(), 0)
172 172
     
173
+    @property
174
+    def time_before_submit(self):
175
+        if not self.date_submitted:
176
+            return None
177
+        return self.date_submitted - self.date_created
178
+    
179
+    @property
180
+    def time_since_creation(self, test_datetime=None):
181
+        if not test_datetime:
182
+            test_datetime = datetime.datetime.now()
183
+        return test_datetime - self.date_created
173 184
     
174 185
 class AbstractLine(models.Model):
175 186
     u"""A line of a basket (product and a quantity)"""

+ 2
- 1
oscar/basket/models.py View File

@@ -3,7 +3,8 @@ from exceptions import Exception
3 3
 from django.core.signals import request_finished
4 4
 from django.dispatch import receiver
5 5
 
6
-from oscar.basket.abstract_models import AbstractBasket, AbstractLine, AbstractLineAttribute
6
+from oscar.basket.abstract_models import (AbstractBasket, AbstractLine, AbstractLineAttribute,
7
+                                          OPEN, MERGED, SAVED, SUBMITTED)
7 8
 
8 9
 
9 10
 class InvalidBasketLineError(Exception):

+ 58
- 0
oscar/basket/reports.py View File

@@ -0,0 +1,58 @@
1
+import csv
2
+
3
+from oscar.services import import_module
4
+report_classes = import_module('reports.reports', ['ReportGenerator'])
5
+basket_models = import_module('basket.models', ['Basket', 'OPEN', 'SUBMITTED'])
6
+
7
+
8
+class OpenBasketReportGenerator(report_classes.ReportGenerator):
9
+    
10
+    filename_template = 'open-baskets-%s-%s.csv'
11
+    code = 'open_baskets'
12
+    description = 'Open baskets'
13
+    
14
+    def generate(self, response):
15
+        writer = csv.writer(response)
16
+        header_row = ['User ID',
17
+                      'User',
18
+                      'Basket status',
19
+                      'Num lines',
20
+                      'Num items',
21
+                      'Value',
22
+                      'Date of creation',
23
+                      'Time since creation',
24
+                     ]
25
+        writer.writerow(header_row)
26
+        
27
+        baskets = basket_models.Basket._default_manager.filter(status=basket_models.OPEN)
28
+        for basket in baskets:
29
+            row = [basket.owner_id, basket.owner, basket.status, basket.num_lines,
30
+                   basket.num_items, basket.total_incl_tax, 
31
+                   basket.date_created, basket.time_since_creation]
32
+            writer.writerow(row)
33
+
34
+
35
+class SubmittedBasketReportGenerator(report_classes.ReportGenerator):
36
+    
37
+    filename_template = 'submitted_baskets-%s-%s.csv'
38
+    code = 'submitted_baskets'
39
+    description = 'Submitted baskets'
40
+    
41
+    def generate(self, response):
42
+        writer = csv.writer(response)
43
+        header_row = ['User ID',
44
+                      'User',
45
+                      'Basket status',
46
+                      'Num lines',
47
+                      'Num items',
48
+                      'Value',
49
+                      'Time between creation and submission',
50
+                     ]
51
+        writer.writerow(header_row)
52
+        
53
+        baskets = basket_models.Basket._default_manager.filter(status=basket_models.SUBMITTED)
54
+        for basket in baskets:
55
+            row = [basket.owner_id, basket.owner, basket.status, basket.num_lines,
56
+                   basket.num_items, basket.total_incl_tax, 
57
+                   basket.date_created, basket.time_before_submit]
58
+            writer.writerow(row)

oscar/marketing/__init__.py → oscar/discount/__init__.py View File


+ 82
- 0
oscar/discount/abstract_models.py View File

@@ -0,0 +1,82 @@
1
+from decimal import Decimal, ROUND_UP
2
+
3
+from django.db import models
4
+from django.utils.translation import ugettext as _
5
+
6
+ABSOLUTE_DISCOUNT, PERCENTAGE_DISCOUNT, FINAL_PRICE = "Absolute", "Percentage", "Final price"
7
+
8
+class AbstractDiscountOffer(models.Model):
9
+    u"""
10
+    A fixed-discount offer (eg get 10% off all fiction books)
11
+    """
12
+    name = models.CharField(max_length=128)
13
+    description = models.TextField(blank=True, null=True)
14
+    
15
+    start_date = models.DateField()
16
+    end_date = models.DateField()
17
+    
18
+    products = models.ManyToManyField('product.Item')
19
+    TYPE_CHOICES = (
20
+        (ABSOLUTE_DISCOUNT, _("An absolute amount of discount off the site price")),
21
+        (PERCENTAGE_DISCOUNT, _("A percentage discount off the site price")),
22
+        (FINAL_PRICE, _("Sets the site price")),
23
+    )
24
+    discount_type = models.CharField(_("Discount type"), max_length=128, choices=TYPE_CHOICES, default=PERCENTAGE_DISCOUNT)
25
+    discount_value = models.DecimalField(decimal_places=2, max_digits=12, 
26
+        help_text="""For percentage values, enter integers less than 100 (eg '25' for a 25% discount)""")
27
+    
28
+    date_created = models.DateTimeField(auto_now_add=True)
29
+
30
+    class Meta:
31
+        ordering = ['-date_created']
32
+        abstract = True
33
+        
34
+    def __unicode__(self):
35
+        return self.name    
36
+        
37
+    def is_active(self, test_date=None):
38
+        u"""
39
+        Tests whether this offer is currently active or not.
40
+        """
41
+        if not test_date:
42
+            test_date = datetime.date.today()
43
+        return self.start_date <= test_date and test_date < self.end_date
44
+
45
+    def num_products(self):
46
+        return self.products.count()
47
+
48
+    def apply(self):
49
+        u"""
50
+        Applies this offer to its linked products.
51
+        
52
+        The default behaviour of this method is to always
53
+        apply this offer, even if it makes the product more expensive.
54
+        This behaviour can of course customised by subclassing.
55
+        """
56
+        for product in self.products.all():
57
+            self._apply_discount_to_product(product)
58
+            
59
+    def _apply_discount_to_product(self, product):
60
+        u"""
61
+        Applies this offer to an individual product
62
+        """
63
+        if product.has_stockrecord:
64
+            discount_price = self._get_discount_price(product)
65
+            product.stockrecord.set_discount_price(discount_price)
66
+    
67
+    def _get_discount_price(self, product):
68
+        u"""
69
+        Returns the discounted price
70
+        """
71
+        current_price = product.stockrecord.price_excl_tax
72
+        if self.discount_type == ABSOLUTE_DISCOUNT:
73
+            price = max(Decimal('0.00'), current_price - self.discount_value)
74
+        elif self.discount_type == PERCENTAGE_DISCOUNT:
75
+            price = current_price * (1 - self.discount_value/100)
76
+        else:
77
+            price = self.discount_value
78
+        return price.quantize(Decimal('.01'))
79
+                
80
+
81
+        
82
+        

+ 10
- 0
oscar/discount/admin.py View File

@@ -0,0 +1,10 @@
1
+from django.contrib import admin
2
+
3
+from oscar.services import import_module
4
+models = import_module('discount.models', ['DiscountOffer'])
5
+
6
+class DiscountOfferAdmin(admin.ModelAdmin):
7
+    list_display = ('name', 'discount_type', 'discount_value', 'num_products')
8
+    list_filter = ('discount_type',)
9
+
10
+admin.site.register(models.DiscountOffer, DiscountOfferAdmin)

+ 6
- 0
oscar/discount/models.py View File

@@ -0,0 +1,6 @@
1
+from oscar.discount.abstract_models import (AbstractDiscountOffer, PERCENTAGE_DISCOUNT, 
2
+                                            ABSOLUTE_DISCOUNT, FINAL_PRICE)
3
+
4
+class DiscountOffer(AbstractDiscountOffer):
5
+    pass
6
+

+ 53
- 0
oscar/discount/tests.py View File

@@ -0,0 +1,53 @@
1
+from decimal import Decimal as D
2
+import datetime
3
+
4
+from django.utils import unittest
5
+
6
+from oscar.utils import create_product
7
+from oscar.discount.models import DiscountOffer, PERCENTAGE_DISCOUNT, ABSOLUTE_DISCOUNT, FINAL_PRICE 
8
+
9
+
10
+class PercentageDiscountOfferTest(unittest.TestCase):
11
+    
12
+    def setUp(self):
13
+        self.offer = DiscountOffer(discount_type=PERCENTAGE_DISCOUNT,
14
+                                   discount_value=D('25.00'))
15
+    
16
+    def test_simple_discounted_price(self):
17
+        product = create_product(D('100.00'))
18
+        self.assertEquals(D('75.00'), self.offer._get_discount_price(product))
19
+        
20
+    def test_rounded_discounted_price(self):
21
+        product = create_product(D('99.99'))
22
+        self.assertEquals(D('74.99'), self.offer._get_discount_price(product))
23
+
24
+        
25
+class AbsoluteDiscountOfferTest(unittest.TestCase):
26
+    
27
+    def setUp(self):
28
+        self.offer = DiscountOffer(discount_type=ABSOLUTE_DISCOUNT,
29
+                                   discount_value=D('25.00'))
30
+    
31
+    def test_simple_discounted_price(self):
32
+        product = create_product(D('100.00'))
33
+        self.assertEquals(D('75.00'), self.offer._get_discount_price(product))
34
+        
35
+    def test_discount_larger_than_price_sets_price_to_zero(self):
36
+        product = create_product(D('20.00'))
37
+        self.assertEquals(D('0.00'), self.offer._get_discount_price(product))
38
+        
39
+    
40
+class FixedPriceDiscountOfferTest(unittest.TestCase):
41
+    
42
+    def setUp(self):
43
+        self.offer = DiscountOffer(discount_type=FINAL_PRICE,
44
+                                   discount_value=D('25.00'))
45
+    
46
+    def test_simple_discounted_price(self):
47
+        product = create_product(D('100.00'))
48
+        self.assertEquals(D('25.00'), self.offer._get_discount_price(product))
49
+        
50
+    def test_discounted_price_when_original_is_cheaper(self):
51
+        product = create_product(D('20.00'))
52
+        self.assertEquals(D('25.00'), self.offer._get_discount_price(product))    
53
+   

+ 0
- 80
oscar/marketing/abstract_models.py View File

@@ -1,80 +0,0 @@
1
-from django.db import models
2
-from django.conf import settings
3
-from django.utils.translation import ugettext as _
4
-
5
-try:
6
-    BANNER_FOLDER = settings.OSCAR_BANNER_FOLDER
7
-except AttributeError: 
8
-    BANNER_FOLDER = 'images/banners/'
9
-    
10
-try:
11
-    POD_FOLDER = settings.OSCAR_POD_FOLDER
12
-except AttributeError: 
13
-    POD_FOLDER = 'images/pods/'
14
-
15
-LEFT, RIGHT = ('Left', 'Right')
16
-
17
-
18
-class AbstractBanner(models.Model):
19
-    u"""
20
-    A banner image linked to a particular page.
21
-    """
22
-    name = models.CharField(_("Name"), max_length=128)
23
-    page_url = models.CharField(_('URL'), max_length=128, db_index=True)
24
-    display_order = models.PositiveIntegerField(default=0)
25
-    link_url = models.URLField(blank=True, null=True, help_text="""This is 
26
-        where the banner links to""")
27
-    image = models.ImageField(upload_to=BANNER_FOLDER)
28
-    date_created = models.DateTimeField(auto_now_add=True)
29
-    
30
-    class Meta:
31
-        abstract = True
32
-        ordering = ['page_url', 'display_order']
33
-        get_latest_by = "date_created"
34
-        
35
-    def __unicode__(self):
36
-        return "%s (%s)" % (self.name, self.page_url)   
37
-        
38
-    def save(self, *args, **kwargs):
39
-        super(AbstractBanner, self).save(*args, **kwargs)
40
-        
41
-    @property    
42
-    def has_link(self):
43
-        return self.link_url != None    
44
-    
45
-    
46
-class AbstractPod(models.Model):
47
-    u"""
48
-    A pod image linked to a particular page.
49
-    """
50
-    POSITION_CHOICES = (
51
-        (LEFT, _("Left of page")),
52
-        (RIGHT, _("Right of page")),
53
-    )
54
-    
55
-    name = models.CharField(_("Name"), max_length=128)
56
-    page_url = models.CharField(_('URL'), max_length=128, db_index=True)
57
-    page_position = models.CharField(_("Position"), max_length=128, choices=POSITION_CHOICES)
58
-    display_order = models.PositiveIntegerField(default=0)
59
-    link_url = models.URLField(blank=True, null=True, help_text="""This is 
60
-        where the pod links to""")
61
-    image = models.ImageField(upload_to=POD_FOLDER)
62
-    date_created = models.DateTimeField(auto_now_add=True)
63
-    
64
-    class Meta:
65
-        abstract = True
66
-        ordering = ["page_url", "page_position", 'display_order']
67
-        get_latest_by = "date_created"
68
-        
69
-    def __unicode__(self):
70
-        return "%s (%s)" % (self.name, self.page_url)   
71
-        
72
-    def save(self, *args, **kwargs):
73
-        super(AbstractPod, self).save(*args, **kwargs)
74
-        
75
-    @property    
76
-    def has_link(self):
77
-        return self.link_url != None 
78
-
79
-        
80
-    

+ 0
- 13
oscar/marketing/admin.py View File

@@ -1,13 +0,0 @@
1
-from django.contrib import admin
2
-
3
-from oscar.services import import_module
4
-models = import_module('marketing.models', ['Banner', 'Pod'])
5
-
6
-class BannerAdmin(admin.ModelAdmin):
7
-    list_display = ['page_url', 'name']
8
-
9
-class PodAdmin(admin.ModelAdmin):
10
-    list_display = ['page_url', 'page_position', 'name']
11
-
12
-admin.site.register(models.Banner, BannerAdmin)
13
-admin.site.register(models.Pod, PodAdmin)

+ 0
- 34
oscar/marketing/context_processors.py View File

@@ -1,34 +0,0 @@
1
-from django.core.exceptions import ObjectDoesNotExist
2
-
3
-from oscar.services import import_module
4
-
5
-marketing_models = import_module('marketing.models', ['Banner', 'Pod'])
6
-
7
-
8
-def marketing(request):
9
-    u"""
10
-    For adding bindings for banners and pods to the template
11
-    context.
12
-    """
13
-    bindings = {
14
-        'url_path': request.path
15
-    }
16
-    
17
-    # Look for a banner
18
-    try:
19
-        banner = marketing_models.Banner._default_manager.get(page_url=request.path)
20
-        bindings['banner'] = banner
21
-    except ObjectDoesNotExist:
22
-        pass
23
-    
24
-    # Looks for pods
25
-    try:
26
-        pods = marketing_models.Pod._default_manager.filter(page_url=request.path)
27
-        bindings['pods'] = pods
28
-    except ObjectDoesNotExist:
29
-        pass
30
-
31
-    return bindings
32
-
33
-        
34
-    

+ 0
- 10
oscar/marketing/models.py View File

@@ -1,10 +0,0 @@
1
-"""
2
-Vanilla marketing models
3
-"""
4
-from oscar.marketing.abstract_models import AbstractBanner, AbstractPod
5
-
6
-class Banner(AbstractBanner):
7
-    pass
8
-
9
-class Pod(AbstractPod):
10
-    pass

+ 10
- 1
oscar/order/abstract_models.py View File

@@ -31,7 +31,9 @@ class AbstractOrder(models.Model):
31 31
     # is not mandatory.
32 32
     shipping_address = models.ForeignKey('order.ShippingAddress', null=True, blank=True)
33 33
     shipping_method = models.CharField(_("Shipping method"), max_length=128, null=True, blank=True)
34
-    date_placed = models.DateTimeField(auto_now_add=True)
34
+    
35
+    # Index added to this field for reporting
36
+    date_placed = models.DateTimeField(auto_now_add=True, db_index=True)
35 37
     
36 38
     @property
37 39
     def basket_total_incl_tax(self):
@@ -94,6 +96,9 @@ class AbstractOrder(models.Model):
94 96
     class Meta:
95 97
         abstract = True
96 98
         ordering = ['-date_placed',]
99
+        permissions = (
100
+            ("can_view", "Can view orders (eg for reporting)"),
101
+        )
97 102
     
98 103
     def __unicode__(self):
99 104
         return u"#%s" % (self.number,)
@@ -178,6 +183,10 @@ class AbstractLine(models.Model):
178 183
         help_text=_("This is the item number that the partner uses within their system"))
179 184
     partner_line_notes = models.TextField(blank=True, null=True)
180 185
     
186
+    # Estimated dispatch date - should be set at order time
187
+    est_dispatch_date = models.DateField(blank=True, null=True)
188
+    
189
+    
181 190
     @property
182 191
     def description(self):
183 192
         u"""

+ 30
- 0
oscar/order/reports.py View File

@@ -0,0 +1,30 @@
1
+import csv
2
+
3
+from oscar.services import import_module
4
+report_classes = import_module('reports.reports', ['ReportGenerator'])
5
+order_models = import_module('order.models', ['Order'])
6
+
7
+
8
+class OrderReportGenerator(report_classes.ReportGenerator):
9
+    
10
+    filename_template = 'orders-%s-to-%s.csv'
11
+    code = 'order_report'
12
+    description = "Orders placed"
13
+    
14
+    def generate(self, response):
15
+        orders = order_models.Order._default_manager.filter(
16
+            date_placed__gte=self.start_date
17
+        ).filter(date_placed__lt=self.end_date)
18
+        
19
+        writer = csv.writer(response)
20
+        header_row = ['Order number',
21
+                      'User',
22
+                      'Total incl. tax',
23
+                      'Date placed',]
24
+        writer.writerow(header_row)
25
+        for order in orders:
26
+            row = [order.number, order.user, order.total_incl_tax, order.date_placed]
27
+            writer.writerow(row)
28
+            
29
+    def is_available_to(self, user):
30
+        return user.is_staff and user.has_perm('order.can_view')

+ 1
- 0
oscar/order/utils.py View File

@@ -82,6 +82,7 @@ class OrderCreator(object):
82 82
                                       line_price_incl_tax=basket_line.line_price_incl_tax_and_discounts)
83 83
         if basket_line.product.has_stockrecord:
84 84
             order_line.partner_reference = basket_line.product.stockrecord.partner_reference
85
+            order_line.dispatch_date = basket_line.product.stockrecord.dispatch_date
85 86
         order_line.save()
86 87
         self._create_line_price_models(order, order_line, basket_line)
87 88
         self._create_line_attributes(order, order_line, basket_line)

+ 38
- 8
oscar/product/abstract_models.py View File

@@ -58,19 +58,33 @@ class AbstractItem(models.Model):
58 58
     upc = models.CharField(_("UPC"), max_length=64, blank=True, null=True,
59 59
         help_text="""Universal Product Code (UPC) is an identifier for a product which is 
60 60
                      not specific to a particular supplier.  Eg an ISBN for a book.""")
61
+    
61 62
     # No canonical product should have a stock record as they cannot be bought.
62 63
     parent = models.ForeignKey('self', null=True, blank=True, related_name='variants',
63 64
         help_text="""Only choose a parent product if this is a 'variant' of a canonical product.  For example 
64 65
                      if this is a size 4 of a particular t-shirt.  Leave blank if this is a CANONICAL PRODUCT (ie 
65 66
                      there is only one version of this product).""")
67
+    
66 68
     # Title is mandatory for canonical products but optional for child products
67 69
     title = models.CharField(_('Title'), max_length=255, blank=True, null=True)
68 70
     slug = models.SlugField(max_length=255, unique=False)
69 71
     description = models.TextField(_('Description'), blank=True, null=True)
70 72
     item_class = models.ForeignKey('product.ItemClass', verbose_name=_('item class'), null=True,
71 73
         help_text="""Choose what type of product this is""")
72
-    attribute_types = models.ManyToManyField('product.AttributeType', through='ItemAttributeValue')
73
-    item_options = models.ManyToManyField('product.Option', blank=True)
74
+    attribute_types = models.ManyToManyField('product.AttributeType', through='ItemAttributeValue',
75
+        help_text="""An attribute type is something that this product MUST have, such as a size""")
76
+    item_options = models.ManyToManyField('product.Option', blank=True, 
77
+        help_text="""Options are values that can be associated with a item when it is added to 
78
+                     a customer's basket.  This could be something like a personalised message to be
79
+                     printed on a T-shirt.<br/>""")
80
+    
81
+    related_items = models.ManyToManyField('product.Item', related_name='relations', blank=True, help_text="""Related 
82
+        items are things like different formats of the same book.  Grouping them together allows
83
+        better linking betwen products on the site.<br/>""")
84
+    
85
+    # Recommended products
86
+    recommended_items = models.ManyToManyField('product.Item', through='ProductRecommendation', blank=True)
87
+    
74 88
     date_created = models.DateTimeField(auto_now_add=True)
75 89
     date_updated = models.DateTimeField(auto_now=True, null=True, default=None)
76 90
 
@@ -158,12 +172,13 @@ class AbstractItem(models.Model):
158 172
             return "%s (%s)" % (self.get_title(), self.attribute_summary())
159 173
         return self.get_title()
160 174
     
175
+    @models.permalink
161 176
     def get_absolute_url(self):
162 177
         u"""Return a product's absolute url"""
163
-        args = {'item_class_slug': self.get_item_class().slug, 
164
-                'item_slug': self.slug,
165
-                'item_id': self.id}
166
-        return reverse('oscar-product-item', kwargs=args)
178
+        return ('oscar-product-item', (), {
179
+            'item_class_slug': self.get_item_class().slug, 
180
+            'item_slug': self.slug,
181
+            'item_id': self.id})
167 182
     
168 183
     def save(self, *args, **kwargs):
169 184
         if self.is_top_level and not self.title:
@@ -174,6 +189,15 @@ class AbstractItem(models.Model):
174 189
         super(AbstractItem, self).save(*args, **kwargs)
175 190
 
176 191
 
192
+class ProductRecommendation(models.Model):
193
+    u"""
194
+    'Through' model for product recommendations
195
+    """
196
+    primary = models.ForeignKey('product.Item', related_name='primary_recommendations')
197
+    recommendation = models.ForeignKey('product.Item')
198
+    ranking = models.PositiveSmallIntegerField(default=0)
199
+
200
+
177 201
 class AbstractAttributeType(models.Model):
178 202
     u"""Defines an attribute. (Eg. size)"""
179 203
     name = models.CharField(_('name'), max_length=128)
@@ -227,8 +251,12 @@ class AbstractItemAttributeValue(models.Model):
227 251
 class AbstractOption(models.Model):
228 252
     u"""
229 253
     An option that can be selected for a particular item when the product
230
-    is added to the basket.  Eg a message for a SMS message.  This is not
231
-    the same as an attribute as options do not have a fixed value for 
254
+    is added to the basket.  
255
+    
256
+    Eg a list ID for an SMS message send, or a personalised message to 
257
+    print on a T-shirt.  
258
+    
259
+    This is not the same as an attribute as options do not have a fixed value for 
232 260
     a particular item - options, they need to be specified by the customer.
233 261
     """
234 262
     name = models.CharField(_('name'), max_length=128)
@@ -251,4 +279,6 @@ class AbstractOption(models.Model):
251 279
         if not self.code:
252 280
             self.code = _convert_to_underscores(self.name)
253 281
         super(AbstractOption, self).save(*args, **kwargs)
282
+    
283
+
254 284
     

+ 10
- 3
oscar/product/admin.py View File

@@ -2,24 +2,31 @@ from django.contrib import admin
2 2
 
3 3
 from oscar.services import import_module
4 4
 product_models = import_module('product.models', ['Item', 'ItemClass', 'AttributeType', 
5
-                                                  'ItemAttributeValue', 'Option'])
5
+                                                  'ItemAttributeValue', 'Option', 'ProductRecommendation'])
6 6
 
7 7
 class AttributeInline(admin.TabularInline):
8 8
     model = product_models.ItemAttributeValue
9 9
 
10
+class ProductRecommendationInline(admin.TabularInline):
11
+    model = product_models.ProductRecommendation
12
+    fk_name = 'primary'
13
+
10 14
 class ItemClassAdmin(admin.ModelAdmin):
11 15
     prepopulated_fields = {"slug": ("name",)}
12 16
     
13 17
 class ItemAdmin(admin.ModelAdmin):
14 18
     list_display = ('get_title', 'upc', 'get_item_class', 'is_top_level', 'is_group', 'is_variant', 'attribute_summary', 'date_created')
15 19
     prepopulated_fields = {"slug": ("title",)}
16
-    inlines = [AttributeInline]
20
+    inlines = [AttributeInline, ProductRecommendationInline]
17 21
     
18 22
 class AttributeTypeAdmin(admin.ModelAdmin):
19 23
     prepopulated_fields = {"code": ("name",)}
24
+    
25
+class OptionAdmin(admin.ModelAdmin):
26
+    prepopulated_fields = {"code": ("name",)}
20 27
 
21 28
 admin.site.register(product_models.ItemClass, ItemClassAdmin)
22 29
 admin.site.register(product_models.Item, ItemAdmin)
23 30
 admin.site.register(product_models.AttributeType, AttributeTypeAdmin)
24 31
 admin.site.register(product_models.ItemAttributeValue)
25
-admin.site.register(product_models.Option)
32
+admin.site.register(product_models.Option, OptionAdmin)

+ 2
- 1
oscar/product/signals.py View File

@@ -1,3 +1,4 @@
1 1
 import django.dispatch
2 2
 
3
-product_viewed = django.dispatch.Signal(providing_args=["product", "user", "request", "response"])
3
+product_viewed = django.dispatch.Signal(providing_args=["product", "user", "request", "response"])
4
+product_search = django.dispatch.Signal(providing_args=["query", '"user'])

+ 4
- 1
oscar/product/views.py View File

@@ -9,7 +9,7 @@ from django.views.generic import ListView, DetailView
9 9
 from oscar.services import import_module
10 10
 
11 11
 product_models = import_module('product.models', ['Item', 'ItemClass'])
12
-product_signals = import_module('product.signals', ['product_viewed'])
12
+product_signals = import_module('product.signals', ['product_viewed', 'product_search'])
13 13
 basket_forms = import_module('basket.forms', ['FormFactory'])
14 14
 history_helpers = import_module('customer.history_helpers', ['receive_product_view'])
15 15
 
@@ -80,6 +80,9 @@ class ProductListView(ListView):
80 80
         u"""Return a set of prodcuts"""
81 81
         q = self.get_search_query()
82 82
         if q:
83
+            # Send signal to record the view of this product
84
+            product_signals.product_search.send(sender=self, query=q, user=self.request.user)
85
+            
83 86
             return product_models.Item.browsable.filter(title__icontains=q)
84 87
         else:
85 88
             return product_models.Item.browsable.all()

+ 0
- 0
oscar/promotions/__init__.py View File


+ 129
- 0
oscar/promotions/abstract_models.py View File

@@ -0,0 +1,129 @@
1
+from django.db import models
2
+from django.conf import settings
3
+from django.utils.translation import ugettext as _
4
+from django.core.urlresolvers import reverse
5
+
6
+BANNER_FOLDER = getattr(settings, 'OSCAR_BANNER_FOLDER', 'images/promotions/banners')
7
+POD_FOLDER = getattr(settings, 'OSCAR_POD_FOLDER', 'images/promotions/pods')
8
+
9
+BANNER, LEFT_POD, RIGHT_POD = ('Banner', 'Left pod', 'Right pod')
10
+POSITION_CHOICES = (
11
+    (BANNER, _("Banner")),
12
+    (LEFT_POD, _("Pod on left-hand side of page")),
13
+    (RIGHT_POD, _("Pod on right-hand side of page")),
14
+)
15
+
16
+class AbstractPromotion(models.Model):
17
+    u"""
18
+    A promotion model.
19
+
20
+    This is effectively a link for directing users to different parts of the site.
21
+    It can have two images associated with it.
22
+
23
+    """
24
+    name = models.CharField(_("Name"), max_length=128)
25
+    link_url = models.URLField(blank=True, null=True, help_text="""This is 
26
+        where this promotion links to""")
27
+
28
+    # Three ways of supplying the content
29
+    banner_image = models.ImageField(upload_to=BANNER_FOLDER, blank=True, null=True)
30
+    pod_image = models.ImageField(upload_to=BANNER_FOLDER, blank=True, null=True)
31
+    raw_html = models.TextField(blank=True, null=True)
32
+
33
+    date_created = models.DateTimeField(auto_now_add=True)
34
+
35
+    _proxy_link_url = None
36
+
37
+    class Meta:
38
+        abstract = True
39
+        ordering = ['date_created']
40
+        get_latest_by = "date_created"
41
+        
42
+    def __unicode__(self):
43
+        if not self.link_url:
44
+            return self.name
45
+        return "%s (%s)" % (self.name, self.link_url)   
46
+        
47
+    def save(self, *args, **kwargs):
48
+        # @todo check that at least one of the three content fields is set
49
+        super(AbstractPromotion, self).save(*args, **kwargs)
50
+    
51
+    def set_proxy_link(self, url):
52
+        self._proxy_link_url = url    
53
+        
54
+    @property    
55
+    def has_link(self):
56
+        return self.link_url != None    
57
+
58
+    def get_banner_html(self):
59
+        return self._get_html('banner_image')
60
+
61
+    def get_pod_html(self):
62
+        return self._get_html('pod_image')
63
+
64
+    def get_raw_html(self):
65
+        return self.raw_html
66
+
67
+    def _get_html(self, image_field):
68
+        if self.raw_html:
69
+            return self.raw_html
70
+        try:
71
+            image = getattr(self, image_field)
72
+            if self.link_url and self._proxy_link_url:
73
+                return '<a href="%s"><img src="%s" alt="%s" /></a>' % (self._proxy_link_url, image.url, self.name)
74
+            return '<img src="%s" alt="%s" />' % (image.url, self.name)
75
+        except AttributeError:
76
+            return ''
77
+
78
+
79
+class LinkedPromotion(models.Model):
80
+
81
+    promotion = models.ForeignKey('promotions.Promotion')
82
+    position = models.CharField(_("Position"), max_length=100, help_text="Position on page", choices=POSITION_CHOICES)
83
+    display_order = models.PositiveIntegerField(default=0)
84
+    clicks = models.PositiveIntegerField(default=0)
85
+    date_created = models.DateTimeField(auto_now_add=True)
86
+
87
+    class Meta:
88
+        abstract = True
89
+        ordering = ['-clicks']
90
+
91
+    def record_click(self):
92
+        self.clicks += 1
93
+        self.save()
94
+
95
+
96
+class AbstractPagePromotion(LinkedPromotion):
97
+    u"""
98
+    A promotion embedded on a particular page.
99
+    """
100
+    page_url = models.CharField(_('URL'), max_length=128, db_index=True)
101
+
102
+    class Meta:
103
+        abstract = True
104
+
105
+    def __unicode__(self):
106
+        return u"%s on %s" % (self.promotion, self.page_url)
107
+    
108
+    def get_link(self):
109
+        return reverse('oscar-page-promotion-click', kwargs={'page_promotion_id': self.id})
110
+        
111
+    
112
+class AbstractKeywordPromotion(LinkedPromotion):
113
+    u"""
114
+    A promotion linked to a specific keyword.
115
+
116
+    This can be used on a search results page to show promotions
117
+    linked to a particular keyword.
118
+    """
119
+
120
+    keyword = models.CharField(_("Keyword"), max_length=200)
121
+
122
+    class Meta:
123
+        abstract = True
124
+
125
+    def get_link(self):
126
+        return reverse('oscar-keyword-promotion-click', kwargs={'keyword_promotion_id': self.id})
127
+
128
+    
129
+

+ 19
- 0
oscar/promotions/admin.py View File

@@ -0,0 +1,19 @@
1
+from django.contrib import admin
2
+
3
+from oscar.services import import_module
4
+models = import_module('promotions.models', ['Promotion', 'PagePromotion', 'KeywordPromotion'])
5
+
6
+class PromotionAdmin(admin.ModelAdmin):
7
+    pass
8
+
9
+class PagePromotionAdmin(admin.ModelAdmin):
10
+    list_display = ['page_url', 'position', 'clicks']
11
+    readonly_fields = ['clicks']
12
+
13
+class KeywordPromotionAdmin(admin.ModelAdmin):
14
+    list_display = ['keyword', 'position', 'clicks']
15
+    readonly_fields = ['clicks']
16
+
17
+admin.site.register(models.Promotion, PromotionAdmin)
18
+admin.site.register(models.PagePromotion, PagePromotionAdmin)
19
+admin.site.register(models.KeywordPromotion, KeywordPromotionAdmin)

+ 46
- 0
oscar/promotions/context_processors.py View File

@@ -0,0 +1,46 @@
1
+from itertools import chain
2
+
3
+from django.core.exceptions import ObjectDoesNotExist
4
+
5
+from oscar.promotions.abstract_models import BANNER, LEFT_POD, RIGHT_POD
6
+from oscar.services import import_module
7
+
8
+promotion_models = import_module('promotions.models', ['PagePromotion', 'KeywordPromotion'])
9
+
10
+
11
+def promotions(request):
12
+    u"""
13
+    For adding bindings for banners and pods to the template
14
+    context.
15
+    """
16
+    bindings = {
17
+        'url_path': request.path
18
+    }
19
+    promotions = promotion_models.PagePromotion._default_manager.select_related().filter(page_url=request.path)
20
+
21
+    if 'q' in request.GET:
22
+        keyword_promotions = promotion_models.KeywordPromotion._default_manager.select_related().filter(keyword=request.GET['q'])
23
+        if keyword_promotions.count() > 0:
24
+            promotions = list(chain(promotions, keyword_promotions))
25
+
26
+    bindings['banners'], bindings['left_pods'], bindings['right_pods'] = _split_by_position(promotions)
27
+
28
+    return bindings
29
+
30
+def _split_by_position(linked_promotions):
31
+    # We split the queries into 3 sets based on the position field
32
+    banners, left_pods, right_pods = [], [], []
33
+    for linked_promotion in linked_promotions:
34
+        promotion = linked_promotion.promotion
35
+        if linked_promotion.position == BANNER:
36
+            banners.append(promotion)
37
+        elif linked_promotion.position == LEFT_POD:
38
+            left_pods.append(promotion)
39
+        elif linked_promotion.position == RIGHT_POD:
40
+            right_pods.append(promotion)
41
+        promotion.set_proxy_link(linked_promotion.get_link())    
42
+    return banners, left_pods, right_pods        
43
+
44
+
45
+        
46
+    

+ 16
- 0
oscar/promotions/models.py View File

@@ -0,0 +1,16 @@
1
+"""
2
+Vanilla promotion models
3
+"""
4
+from oscar.promotions.abstract_models import AbstractPromotion, AbstractPagePromotion, AbstractKeywordPromotion
5
+
6
+
7
+class Promotion(AbstractPromotion):
8
+    pass
9
+
10
+
11
+class PagePromotion(AbstractPagePromotion):
12
+    pass
13
+
14
+
15
+class KeywordPromotion(AbstractKeywordPromotion):
16
+    pass

+ 22
- 0
oscar/promotions/tests.py View File

@@ -0,0 +1,22 @@
1
+from django.utils import unittest
2
+
3
+from oscar.promotions.models import * 
4
+
5
+
6
+class PromotionTest(unittest.TestCase):
7
+
8
+    def test_promotion_cannot_be_saved_without_content(self):
9
+        pass
10
+
11
+
12
+class PagePromotionTest(unittest.TestCase):
13
+    
14
+    def setUp(self):
15
+        pass
16
+    
17
+    def test_click_is_recorded(self):
18
+        promotion = Promotion.objects.create(name="Dummy promotion")
19
+        promotion.record_click()
20
+        self.assertTrue(1, promotion.clicks)
21
+
22
+

+ 6
- 0
oscar/promotions/urls.py View File

@@ -0,0 +1,6 @@
1
+from django.conf.urls.defaults import *
2
+
3
+urlpatterns = patterns('oscar.promotions.views',
4
+    url(r'page-redirect/(?P<page_promotion_id>\d+)/$', 'page_promotion_click', name='oscar-page-promotion-click'),
5
+    url(r'keyword-redirect/(?P<keyword_promotion_id>\d+)/$', 'keyword_promotion_click', name='oscar-keyword-promotion-click'),
6
+)

+ 24
- 0
oscar/promotions/views.py View File

@@ -0,0 +1,24 @@
1
+from django.http import HttpResponseRedirect, Http404
2
+from django.shortcuts import get_object_or_404
3
+
4
+from oscar.services import import_module
5
+
6
+promotions_models = import_module('promotions.models', ['PagePromotion', 'KeywordPromotion'])
7
+
8
+
9
+def page_promotion_click(request, page_promotion_id):
10
+    u"""Records a click-through on a promotion"""
11
+    page_prom = get_object_or_404(promotions_models.PagePromotion, id=page_promotion_id)
12
+    if page_prom.promotion.has_link:
13
+        page_prom.record_click()
14
+        return HttpResponseRedirect(page_prom.promotion.link_url)
15
+    return Http404()
16
+    
17
+def keyword_promotion_click(request, keyword_promotion_id):
18
+    u"""Records a click-through on a promotion"""
19
+    keyword_prom = get_object_or_404(promotions_models.KeywordPromotion, id=keyword_promotion_id)
20
+    if keyword_prom.promotion.has_link:
21
+        keyword_prom.record_click()
22
+        return HttpResponseRedirect(keyword_prom.promotion.link_url)
23
+    return Http404()
24
+

+ 0
- 0
oscar/reports/__init__.py View File


+ 23
- 0
oscar/reports/forms.py View File

@@ -0,0 +1,23 @@
1
+from datetime import date, datetime
2
+
3
+from django import forms
4
+
5
+from oscar.services import import_module
6
+report_utils = import_module('reports.utils', ['GeneratorRepository'])
7
+
8
+class ReportForm(forms.Form):
9
+    
10
+    generators = report_utils.GeneratorRepository().get_report_generators()
11
+    
12
+    type_choices = []
13
+    for generator in generators:
14
+        type_choices.append((generator.code, generator.description))
15
+    report_type = forms.ChoiceField(widget=forms.Select(), choices=type_choices)
16
+    start_date = forms.DateField(widget=forms.widgets.DateInput(format="%d/%m/%Y"))
17
+    end_date = forms.DateField(widget=forms.widgets.DateInput(format="%d/%m/%Y"))
18
+    
19
+    def clean(self):
20
+        if 'start_date' in self.cleaned_data and 'end_date' in self.cleaned_data and self.cleaned_data['start_date'] > self.cleaned_data['end_date']:
21
+            raise forms.ValidationError("Your start date must be before your end date")
22
+        return self.cleaned_data
23
+   

+ 30
- 0
oscar/reports/reports.py View File

@@ -0,0 +1,30 @@
1
+class ReportGenerator(object):
2
+    u"""
3
+    Top-level class that needs to be subclassed to provide a 
4
+    report generator.
5
+    """
6
+    
7
+    filename_template = 'report-%s-to-%s.csv'
8
+    mimetype = 'text/csv'
9
+    code = ''
10
+    description = '<insert report description>'
11
+    
12
+    def __init__(self, **kwargs):
13
+        if 'start_date' in kwargs and 'end_date' in kwargs:
14
+            self.start_date = kwargs['start_date']
15
+            self.end_date = kwargs['end_date']
16
+    
17
+    def generate(self, response):
18
+        pass
19
+ 
20
+    def filename(self):
21
+        u"""
22
+        Returns the filename for this report
23
+        """
24
+        return self.filename_template % (self.start_date, self.end_date)
25
+    
26
+    def is_available_to(self, user):
27
+        u"""
28
+        Checks whether this report is available to this user
29
+        """
30
+        return user.is_staff

+ 5
- 0
oscar/reports/urls.py View File

@@ -0,0 +1,5 @@
1
+from django.conf.urls.defaults import *
2
+
3
+urlpatterns = patterns('oscar.reports.views',
4
+    url(r'^$', 'dashboard', name='oscar-report-dashboard'),
5
+)

+ 24
- 0
oscar/reports/utils.py View File

@@ -0,0 +1,24 @@
1
+from oscar.services import import_module
2
+order_reports = import_module('order.reports', ['OrderReportGenerator'])
3
+analytics_reports = import_module('analytics.reports', ['ProductReportGenerator', 'UserReportGenerator'])
4
+basket_reports = import_module('basket.reports', ['OpenBasketReportGenerator', 'SubmittedBasketReportGenerator'])     
5
+
6
+
7
+class GeneratorRepository(object):
8
+    
9
+    generators = [order_reports.OrderReportGenerator,
10
+                  analytics_reports.ProductReportGenerator,
11
+                  analytics_reports.UserReportGenerator,
12
+                  basket_reports.OpenBasketReportGenerator,
13
+                  basket_reports.SubmittedBasketReportGenerator,]
14
+
15
+    def get_report_generators(self):
16
+        return self.generators
17
+    
18
+    def get_generator(self, code):
19
+        for generator in self.generators:
20
+            if generator.code == code:
21
+                return generator
22
+        return None
23
+    
24
+    

+ 33
- 0
oscar/reports/views.py View File

@@ -0,0 +1,33 @@
1
+from django.http import HttpResponse, HttpResponseForbidden, Http404
2
+from django.shortcuts import render
3
+
4
+from oscar.services import import_module
5
+report_forms = import_module('reports.forms', ['ReportForm'])
6
+report_utils = import_module('reports.utils', ['GeneratorRepository'])
7
+
8
+def dashboard(request):
9
+    if 'report_type' in request.GET:
10
+        form = report_forms.ReportForm(request.GET)
11
+        if form.is_valid():
12
+            generator = _get_generator(form)
13
+            if not generator.is_available_to(request.user):
14
+                return HttpResponseForbidden("You do not have access to this report")
15
+            
16
+            response = HttpResponse(mimetype=generator.mimetype)
17
+            response['Content-Disposition'] = 'attachment; filename=%s' % generator.filename()
18
+            generator.generate(response)
19
+            return response
20
+    else:
21
+        form = report_forms.ReportForm()
22
+    return render(request, 'reports/dashboard.html', locals())
23
+
24
+
25
+def _get_generator(form):
26
+    code = form.cleaned_data['report_type']
27
+
28
+    repo = report_utils.GeneratorRepository()
29
+    generator_cls = repo.get_generator(code)
30
+    if not generator_cls:
31
+        raise Http404
32
+    return generator_cls(start_date=form.cleaned_data['start_date'], 
33
+                         end_date=form.cleaned_data['end_date'])

+ 6
- 0
oscar/shipping/abstract_models.py View File

@@ -27,6 +27,9 @@ class AbstractOrderAndItemLevelChargeMethod(models.Model, ShippingMethod):
27 27
     price_per_order = models.DecimalField(decimal_places=2, max_digits=12, default=Decimal('0.00'))
28 28
     price_per_item = models.DecimalField(decimal_places=2, max_digits=12, default=Decimal('0.00'))
29 29
     
30
+    # If basket value is above this threshold, then shipping is free
31
+    free_shipping_threshold = models.DecimalField(decimal_places=2, max_digits=12, null=True)
32
+    
30 33
     _basket = None
31 34
     
32 35
     def save(self, *args, **kwargs):
@@ -47,6 +50,9 @@ class AbstractOrderAndItemLevelChargeMethod(models.Model, ShippingMethod):
47 50
         u"""
48 51
         Return basket total including tax
49 52
         """
53
+        if self.free_shipping_threshold != None and self._basket.total_incl_tax >= self.free_shipping_threshold:
54
+            return Decimal('0.00')
55
+        
50 56
         charge = self.price_per_order
51 57
         for line in self._basket.lines.all():
52 58
             charge += line.quantity * self.price_per_item

+ 37
- 0
oscar/shipping/tests.py View File

@@ -51,3 +51,40 @@ class OrderAndItemLevelChargeMethodTest(unittest.TestCase):
51 51
         p = create_product()
52 52
         self.basket.add_product(p, 7)
53 53
         self.assertEquals(D('5.00') + 7*D('1.00'), self.method.basket_charge_incl_tax())
54
+
55
+class ZeroFreeShippingThresholdTest(unittest.TestCase):
56
+    
57
+    def setUp(self):
58
+        self.method = OrderAndItemLevelChargeMethod(price_per_order=D('10.00'), free_shipping_threshold=D('0.00'))
59
+        self.basket = Basket.objects.create()
60
+        self.method.set_basket(self.basket)
61
+    
62
+    def test_free_shipping_with_empty_basket(self):
63
+        self.assertEquals(D('0.00'), self.method.basket_charge_incl_tax())
64
+        
65
+    def test_free_shipping_with_nonempty_basket(self):
66
+        p = create_product(D('5.00'))
67
+        self.basket.add_product(p)
68
+        self.assertEquals(D('0.00'), self.method.basket_charge_incl_tax())
69
+
70
+class NonZeroFreeShippingThresholdTest(unittest.TestCase):
71
+    
72
+    def setUp(self):
73
+        self.method = OrderAndItemLevelChargeMethod(price_per_order=D('10.00'), free_shipping_threshold=D('20.00'))
74
+        self.basket = Basket.objects.create()
75
+        self.method.set_basket(self.basket)
76
+        
77
+    def test_basket_below_threshold(self):
78
+        p = create_product(D('5.00'))
79
+        self.basket.add_product(p)
80
+        self.assertEquals(D('10.00'), self.method.basket_charge_incl_tax())
81
+        
82
+    def test_basket_on_threshold(self):
83
+        p = create_product(D('5.00'))
84
+        self.basket.add_product(p, 4)
85
+        self.assertEquals(D('0.00'), self.method.basket_charge_incl_tax())
86
+        
87
+    def test_basket_above_threshold(self):
88
+        p = create_product(D('5.00'))
89
+        self.basket.add_product(p, 8)
90
+        self.assertEquals(D('0.00'), self.method.basket_charge_incl_tax())

+ 28
- 5
oscar/stock/abstract_models.py View File

@@ -1,6 +1,5 @@
1
-"""
2
-Models for the stock and fulfillment components of an project
3
-"""
1
+import datetime
2
+
4 3
 from django.conf import settings
5 4
 from django.db import models
6 5
 from django.utils.translation import ugettext_lazy as _
@@ -34,7 +33,10 @@ class AbstractStockRecord(models.Model):
34 33
     # We deliberately don't store tax information to allow each project
35 34
     # to subclass this model and put its own fields for convey tax.
36 35
     price_currency = models.CharField(max_length=12, default=settings.OSCAR_DEFAULT_CURRENCY)
37
-    # This is the base price for calculations
36
+    
37
+    # This is the base price for calculations - tax should be applied 
38
+    # by the appropriate method.  We don't store it here as its calculation is 
39
+    # highly domain-specific.
38 40
     price_excl_tax = models.DecimalField(decimal_places=2, max_digits=12)
39 41
     
40 42
     # Cost price is optional as not all partner supply it
@@ -54,6 +56,17 @@ class AbstractStockRecord(models.Model):
54 56
         self.num_allocated += delta
55 57
         self.save()
56 58
         
59
+    def set_discount_price(self, price):
60
+        u"""
61
+        A setter method for setting a new price.  
62
+        
63
+        This is called from within the "discount" app, which is responsible
64
+        for applying fixed-discount offers to products.  We use a setter method
65
+        so that this behaviour can be customised in projects.
66
+        """
67
+        self.price_excl_tax = price
68
+        self.save()
69
+        
57 70
     # Price retrieval methods - these default to no tax being applicable
58 71
     # These are intended to be overridden.   
59 72
     
@@ -64,6 +77,17 @@ class AbstractStockRecord(models.Model):
64 77
             return _("In stock (%d available)" % self.num_in_stock)
65 78
         return _("Out of stock")
66 79
     
80
+    @property
81
+    def dispatch_date(self):
82
+        u"""
83
+        Returns the estimated dispatch date for a line
84
+        """
85
+        if self.num_in_stock:
86
+            # Assume next day for in-stock items
87
+            return datetime.date.today() + datetime.timedelta(days=1)
88
+        # Assume one week for out-of-stock items
89
+        return datetime.date.today() + datetime.timedelta(days=7)
90
+    
67 91
     @property 
68 92
     def price_incl_tax(self):
69 93
         u"""Return a product's price including tax"""
@@ -72,7 +96,6 @@ class AbstractStockRecord(models.Model):
72 96
     @property 
73 97
     def price_tax(self):
74 98
         u"""Return a product's tax value"""
75
-        #TODO?
76 99
         return 0
77 100
         
78 101
     def __unicode__(self):

+ 2
- 1
oscar/stock/admin.py View File

@@ -4,7 +4,8 @@ from oscar.services import import_module
4 4
 models = import_module('stock.models', ['Partner', 'StockRecord'])
5 5
 
6 6
 class StockRecordAdmin(admin.ModelAdmin):
7
-    pass
7
+    list_display = ('product', 'partner', 'partner_reference', 'price_excl_tax', 'cost_price', 'num_in_stock')
8
+    list_filter = ('partner',)
8 9
     
9 10
 admin.site.register(models.Partner)
10 11
 admin.site.register(models.StockRecord, StockRecordAdmin)

+ 1
- 1
oscar/templates/basket/summary.html View File

@@ -10,7 +10,7 @@
10 10
 {% block content %}
11 11
 
12 12
 {% if basket.is_empty %}
13
-Your basket is currently empty - go and <a href="/shop/product/">buy something</a>!
13
+Your basket is currently empty - go and <a href="{% url oscar-products %}">buy something</a>!
14 14
 {% else %}
15 15
 
16 16
 <h3>To buy now</h3>

+ 4
- 4
oscar/templates/checkout/thank_you.html View File

@@ -15,15 +15,15 @@
15 15
 <table>
16 16
     <tr>
17 17
         <th>Product</th>
18
-        <th>Availability</th>
18
+        <th>Estimated dispatch date</th>
19 19
         <th>Quantity</th>
20 20
         <th>Line price excl tax</th>
21 21
         <th>Line price incl tax</th>
22 22
     </tr>
23
-    {% for batch in order.lines.all %}
23
+    {% for line in order.lines.all %}
24 24
     <tr>
25 25
         <td><a href="{{ line.product.get_absolute_url }}">{{ line.description }}</a></td>
26
-        <td>{{ line.product.stockrecord.availability }}</td>
26
+        <td>{{ line.est_dispatch_date }}</td>
27 27
         <td>{{ line.quantity }}</td>
28 28
         <td>{{ line.line_price_excl_tax|currency }}</td>
29 29
         <td>{{ line.line_price_incl_tax|currency }}</td>
@@ -44,6 +44,6 @@
44 44
         <td>{{ order.total_incl_tax|currency }}</td>
45 45
     </tr>
46 46
 </table>
47
-
47
+ 
48 48
 {% endblock content %}
49 49
 

+ 5
- 18
oscar/templates/layout.html View File

@@ -17,24 +17,11 @@
17 17
             <a href="">Login</a>
18 18
             {% endif %}
19 19
             
20
-            {% if banner %}
21
-                {% if banner.has_link %}
22
-                    <a href="{{ banner.link_url }}" title="{{ banner.name }}"><img src="{{ banner.image.url }}" alt="{{ banner.name }}" /></a>
23
-                {% else %}
24
-                    <img src="{{ banner.image.url }}" alt="{{ banner.name }}" />
25
-                {% endif %}
26
-            {% endif %}
27
-            
28
-            {% if pods %}
29
-                {% for pod in pods %}
30
-                    {% if pod.has_link %}
31
-                        <a href="{{ pod.link_url }}" title="{{ pod.name }}"><img src="{{ pod.image.url }}" alt="{{ pod.name }}" /></a>
32
-                    {% else %}
33
-                        <img src="{{ pod.image.url }}" alt="{{ pod.name }}" />
34
-                    {% endif %}
35
-                {% endfor %}
36
-            {% endif %}
37
-            <a href="/admin/marketing/pod/add/?page_url={{ url_path }}" >Add a pod to this page</a>
20
+            {% for banner in banners %}
21
+                {{ banner.get_banner_html|safe }}
22
+            {% endfor %}
23
+
24
+            <a href="/admin/promotions/pagepromotion/add/?page_url={{ url_path }}" >Add a promotion to this page</a>
38 25
             
39 26
             {% block header %}
40 27
             {% endblock %}

+ 14
- 0
oscar/templates/layout_admin.html View File

@@ -0,0 +1,14 @@
1
+{% extends "base.html" %}
2
+
3
+{% block layout %}
4
+    <div id="container">
5
+        <div id="header">
6
+            <h1>Oscar // Flexible e-commerce for Django</h1>
7
+            {% block header %}{% endblock header %}
8
+        </div>
9
+        <div id="content">
10
+            {% block content %}{% endblock %}
11
+        </div>
12
+        <div id="footer">{% block footer %}{% endblock %}</div>
13
+    </div>
14
+{% endblock %}

+ 22
- 1
oscar/templates/product/item.html View File

@@ -16,7 +16,6 @@
16 16
     {% endfor %}
17 17
 </div>
18 18
 
19
-
20 19
 <table>
21 20
     <caption>Product details</caption>
22 21
     <tr>
@@ -61,5 +60,27 @@
61 60
 
62 61
 {% recently_viewed_products %}
63 62
 
63
+{% if item.related_items.count %}
64
+<div class="products">
65
+    <h4>Related items</h4>
66
+    <ul>
67
+    {% for product in item.related_items.all %}
68
+        <li><a href="{{ product.get_absolute_url }}">{{ product.get_title }}</a>
69
+    {% endfor %}
70
+    </ul>
71
+</div>
72
+{% endif %}
73
+
74
+{% if item.recommended_items.count %}
75
+<div class="products">
76
+    <h4>Recommended items</h4>
77
+    <ul>
78
+    {% for product in item.recommended_items.all %}
79
+        <li><a href="{{ product.get_absolute_url }}">{{ product.get_title }}</a>
80
+    {% endfor %}
81
+    </ul>
82
+</div>
83
+{% endif %}
84
+
64 85
 {% endblock content %}
65 86
 

+ 19
- 0
oscar/templates/reports/dashboard.html View File

@@ -0,0 +1,19 @@
1
+{% extends "layout_admin.html" %}
2
+
3
+{% load currency_filters %}
4
+
5
+{% block header %}
6
+<h2>Reporting dashboard</h2>
7
+{% endblock header %}
8
+
9
+
10
+{% block content %}
11
+
12
+<form method="GET" action="{% url oscar-report-dashboard %}">
13
+
14
+    {{ form.as_p }} 
15
+    <input type="submit" value="Generate report" />
16
+</form>
17
+
18
+{% endblock content %}
19
+

+ 4
- 1
oscar/templatetags/currency_filters.py View File

@@ -14,4 +14,7 @@ def currency(value):
14 14
     except AttributeError:
15 15
         locale.setlocale(locale.LC_ALL, '')
16 16
     loc = locale.localeconv()
17
-    return locale.currency(value, loc['currency_symbol'], grouping=True)
17
+    try:
18
+        return locale.currency(value, loc['currency_symbol'], grouping=True)
19
+    except TypeError:
20
+        return ''

+ 1
- 0
oscar/tests.py View File

@@ -12,6 +12,7 @@ from oscar.payment.tests import *
12 12
 from oscar.offer.tests import *
13 13
 from oscar.shipping.tests import *
14 14
 from oscar.customer.tests import *
15
+from oscar.discount.tests import *
15 16
 
16 17
 from oscar.services import import_module, AppNotFoundError
17 18
 

+ 2
- 0
oscar/urls.py View File

@@ -9,6 +9,8 @@ urlpatterns = patterns('',
9 9
     (r'checkout/', include('oscar.checkout.urls')),
10 10
     (r'order-management/', include('oscar.order_management.urls')),
11 11
     (r'accounts/', include('oscar.customer.urls')),
12
+    (r'promotions/', include('oscar.promotions.urls')),
13
+    (r'reports/', include('oscar.reports.urls')),
12 14
     (r'^$', home),     
13 15
 )
14 16
 

+ 2
- 2
setup.py View File

@@ -2,8 +2,8 @@ from setuptools import setup
2 2
 
3 3
 setup(name='django-oscar',
4 4
       version='0.1.0',
5
-      url='https://github.com/codeinthehole/django-oscar',
6
-      description="A flexible ecommerce application for Django",
5
+      url='https://github.com/tangentlabs/django-oscar',
6
+      description="Domain-driven ecommerce for Django",
7 7
       author="David Winterbottom",
8 8
       author_email="david.winterbottom@tangentlabs.co.uk",
9 9
       package_dir={'': '.'},

Loading…
Cancel
Save